Compare commits
253 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 | |||
| 2f0f76e365 | |||
| 4f92e5056c | |||
| 6da9972ef4 | |||
| c284cefada | |||
| 53f6f30cf0 | |||
| a6618af5ed | |||
| 2b4ff9f422 | |||
| 84b21cad08 | |||
| 95baf60da3 | |||
| 9fe7759973 | |||
| f737bfc4db | |||
| 7ab1476d8f | |||
| 225456ec14 | |||
| c719b1ca5f | |||
| 9df2a001bb | |||
| c47450fe58 | |||
| bb1f5d6c94 | |||
| 0837680e03 | |||
| f74b786c6f | |||
| 7ebd25c59c | |||
| e0f59cdf82 | |||
| d3c8811fdb | |||
| c89a68e59e | |||
| eb4ea8bc42 | |||
| 060f351da7 | |||
| c55d0ab12a | |||
| 02468c94c0 | |||
| 630fffc0cc | |||
| 965af3a34c | |||
| c3fcfe88ee | |||
| 36d9f929c6 | |||
| 4c92b17617 | |||
| 9b4be663f7 | |||
| ce52dd153e | |||
| 3aff80fb0c | |||
| ca6da1acea | |||
| 40e2c76ab3 | |||
| c5678c7101 | |||
| 9423b1d1b9 | |||
| 252d4f25c8 | |||
| 7d24ba0b40 | |||
| 65e856f37a | |||
| 8f4a23a32d | |||
| e853a47879 | |||
| e077bde074 | |||
| f340d33eba | |||
| a56ea2c843 | |||
| 64700b355e | |||
| 4b9cf34243 | |||
| 5298467275 | |||
| 91b4034fee | |||
| 1b37b2aeea | |||
| 4a688098e8 | |||
| a2492f0b7e | |||
| fe6764df9a | |||
| db697924ed | |||
| f9a1fe21dc | |||
| 17c67b4f25 | |||
| cb2d503e84 | |||
| dccd9d09e5 | |||
| ca21feedc8 | |||
| 0a6ec9235e | |||
| c5b22e0c99 | |||
| 0f3ec9061e | |||
| e318215cc5 | |||
| 6864849115 | |||
| f6536e8d08 | |||
| e3f26d7572 | |||
| a3619c10d7 | |||
| d880c9d098 | |||
| d3b43250b8 | |||
| d1fb19810b | |||
| 062d607da9 | |||
| ef8eead513 | |||
| e58c96eb70 | |||
| 03c17987a1 | |||
| 9f4c4abb84 | |||
| d942b21354 | |||
| 4ff6050f43 | |||
| 42e02fe72d | |||
| 3984f39329 | |||
| 4417938558 | |||
| 90c7f02b40 | |||
| f591871277 | |||
| bae59e2ce0 | |||
| 58957a4aaa | |||
| cedc5de15d | |||
| 5eeef3a9c3 | |||
| 891fc5bea0 | |||
| fff47cc52e | |||
| 0f3ba9c207 | |||
| b53b36fdc5 | |||
| 2c9cea74e3 | |||
| 85c4cbbf37 | |||
| 4bf92f42b8 | |||
| 8336c01c5c | |||
| e35db90232 | |||
| 53774886e7 | |||
| 5c5054f740 | |||
| 642382cbe8 | |||
| f219b9c244 | |||
| 16c40ddae4 | |||
| b7f9099ad9 | |||
| f3c0481631 | |||
| d105842bf2 | |||
| 15d1e118ed | |||
| 0ba76d041a | |||
| 4298ae17ab | |||
| 0266dfd011 | |||
| 6a77cf6a89 | |||
| 10e4e8472b | |||
| 2134383b5a | |||
| ac8eb1bf99 | |||
| 3c9ac03ccc | |||
| b39c1d5dce |
@@ -114,3 +114,39 @@ docs-src/control_generator_routes.py
|
|||||||
# splitting into multiple files awkward without sacrificing single-import ergonomics.
|
# splitting into multiple files awkward without sacrificing single-import ergonomics.
|
||||||
consent-sdk/src/mobile/flutter/consent_sdk.dart
|
consent-sdk/src/mobile/flutter/consent_sdk.dart
|
||||||
consent-sdk/src/mobile/ios/ConsentManager.swift
|
consent-sdk/src/mobile/ios/ConsentManager.swift
|
||||||
|
|
||||||
|
# --- consent-tester: DSI discovery orchestrator ---
|
||||||
|
# Single Playwright session with sequential steps (banner dismiss, self-extract,
|
||||||
|
# link follow, accordion expand, inline sections). Splitting mid-session would
|
||||||
|
# require passing Page objects across modules.
|
||||||
|
consent-tester/services/dsi_discovery.py
|
||||||
|
|
||||||
|
# --- backend-compliance: unified compliance check orchestrator ---
|
||||||
|
# Sequential 7-step pipeline (text resolve, profile detect, check documents,
|
||||||
|
# banner scan, cross-check, profile extract, report). Phase 5 split target.
|
||||||
|
backend-compliance/compliance/api/agent_compliance_check_routes.py
|
||||||
|
|
||||||
|
# --- docs-src: binary office files (not source code) ---
|
||||||
|
# (Also excluded by extension in scripts/check-loc.sh — kept here for legibility.)
|
||||||
|
docs-src/Breakpilot ComplAI Finanzplan.xlsm
|
||||||
|
|
||||||
|
# --- admin-compliance: oversized component refactor backlog ---
|
||||||
|
# Phase 5+ target for splitting into smaller subcomponents per wizard step.
|
||||||
|
admin-compliance/components/sdk/ai-act/DecisionTreeWizard.tsx
|
||||||
|
|
||||||
|
# --- ai-compliance-sdk: oversized handler refactor backlog ---
|
||||||
|
# Phase 5+ target for splitting handler groups into per-resource files.
|
||||||
|
ai-compliance-sdk/internal/api/handlers/tender_handlers.go
|
||||||
|
|
||||||
|
# --- merge grandfathered (2026-05-13) — Phase 5+ refactor backlog ---
|
||||||
|
# Files imported via team work that crossed the hard cap; tracked for splitting.
|
||||||
|
consent-tester/checks/banner_checks.py
|
||||||
|
consent-tester/services/banner_detector.py
|
||||||
|
backend-compliance/compliance/api/agent_doc_check_routes.py
|
||||||
|
backend-compliance/compliance/services/service_registry.py
|
||||||
|
backend-compliance/compliance/services/dsr_workflow_service.py
|
||||||
|
ai-compliance-sdk/internal/iace/hazard_patterns_forestry_conveyor.go
|
||||||
|
admin-compliance/app/sdk/compliance-scope/page.tsx
|
||||||
|
|
||||||
|
# --- zeroclaw: ground-truth corpus (test fixture data, not source) ---
|
||||||
|
zeroclaw/docs/ground-truth/06-spiegel-dsi-fulltext.txt
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Build + push compliance service images to registry.meghsakha.com
|
# Build + push compliance service images to registry.meghsakha.com
|
||||||
# and trigger orca redeploy on every push to main that touches a service.
|
# and trigger orca redeploy after CI passes on main.
|
||||||
|
#
|
||||||
|
# This workflow is gated on the CI workflow completing successfully.
|
||||||
|
# It does not run independently — if CI fails, builds + deploy are skipped.
|
||||||
|
# Per-service builds are gated on detect-changes so only services with
|
||||||
|
# modified files are rebuilt; trigger-orca runs only if at least one build
|
||||||
|
# succeeded and none failed.
|
||||||
#
|
#
|
||||||
# Requires Gitea Actions secrets:
|
# Requires Gitea Actions secrets:
|
||||||
# REGISTRY_USERNAME / REGISTRY_PASSWORD — registry.meghsakha.com credentials
|
# REGISTRY_USERNAME / REGISTRY_PASSWORD — registry.meghsakha.com credentials
|
||||||
@@ -8,24 +14,68 @@
|
|||||||
name: Build + Deploy
|
name: Build + Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_run:
|
||||||
|
workflows: ["CI"]
|
||||||
|
types: [completed]
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
|
||||||
- 'admin-compliance/**'
|
|
||||||
- 'backend-compliance/**'
|
|
||||||
- 'ai-compliance-sdk/**'
|
|
||||||
- 'developer-portal/**'
|
|
||||||
- 'compliance-tts-service/**'
|
|
||||||
- 'document-crawler/**'
|
|
||||||
- 'dsms-gateway/**'
|
|
||||||
- 'dsms-node/**'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── per-service builds run in parallel ────────────────────────────────────
|
# ── gate: only proceed if CI succeeded ────────────────────────────────────
|
||||||
|
ci-passed:
|
||||||
|
runs-on: docker
|
||||||
|
container: alpine:3.20
|
||||||
|
if: github.event.workflow_run.conclusion == 'success'
|
||||||
|
steps:
|
||||||
|
- name: CI passed, proceeding with build + deploy
|
||||||
|
run: echo "CI run ${{ github.event.workflow_run.id }} succeeded on ${{ github.event.workflow_run.head_branch }} @ ${{ github.event.workflow_run.head_sha }}"
|
||||||
|
|
||||||
|
# ── detect which services changed since the last successful build ────────
|
||||||
|
# Diff base = the last-build/main git tag, set by mark-last-build at the
|
||||||
|
# end of every successful run. Works across squash merges, multi-commit
|
||||||
|
# raw pushes, and force pushes (force pushes leave a stale tag → diff
|
||||||
|
# shows symmetric differences → safe over-rebuild). If the tag doesn't
|
||||||
|
# exist yet, scripts/detect-changes.sh falls back to rebuilding all.
|
||||||
|
detect-changes:
|
||||||
|
runs-on: docker
|
||||||
|
container: alpine:3.20
|
||||||
|
needs: ci-passed
|
||||||
|
outputs:
|
||||||
|
admin: ${{ steps.diff.outputs.admin }}
|
||||||
|
backend: ${{ steps.diff.outputs.backend }}
|
||||||
|
sdk: ${{ steps.diff.outputs.sdk }}
|
||||||
|
portal: ${{ steps.diff.outputs.portal }}
|
||||||
|
tts: ${{ steps.diff.outputs.tts }}
|
||||||
|
crawler: ${{ steps.diff.outputs.crawler }}
|
||||||
|
dsms_gateway: ${{ steps.diff.outputs.dsms_gateway }}
|
||||||
|
dsms_node: ${{ steps.diff.outputs.dsms_node }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git bash
|
||||||
|
git clone --depth 200 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
git fetch --tags origin || true
|
||||||
|
- name: Resolve base SHA from last-build/main tag
|
||||||
|
run: |
|
||||||
|
BASE=$(git rev-parse --verify refs/tags/last-build/main 2>/dev/null || true)
|
||||||
|
echo "Base SHA: ${BASE:-<none, will rebuild all>}"
|
||||||
|
# Deepen if base isn't yet in the shallow clone.
|
||||||
|
if [ -n "$BASE" ] && ! git rev-parse --verify "${BASE}^{commit}" >/dev/null 2>&1; then
|
||||||
|
git fetch --unshallow origin 2>/dev/null \
|
||||||
|
|| git fetch --depth=10000 origin 2>/dev/null \
|
||||||
|
|| true
|
||||||
|
fi
|
||||||
|
echo "BASE_SHA=${BASE}" >> "$GITHUB_ENV"
|
||||||
|
- name: Detect changes
|
||||||
|
id: diff
|
||||||
|
run: bash scripts/detect-changes.sh
|
||||||
|
|
||||||
|
# ── per-service builds run in parallel (only changed services) ────────────
|
||||||
|
|
||||||
build-admin-compliance:
|
build-admin-compliance:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.admin == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -49,6 +99,8 @@ jobs:
|
|||||||
build-backend-compliance:
|
build-backend-compliance:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.backend == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -72,6 +124,8 @@ jobs:
|
|||||||
build-ai-sdk:
|
build-ai-sdk:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.sdk == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -95,6 +149,8 @@ jobs:
|
|||||||
build-developer-portal:
|
build-developer-portal:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.portal == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -118,6 +174,8 @@ jobs:
|
|||||||
build-tts:
|
build-tts:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.tts == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -141,6 +199,8 @@ jobs:
|
|||||||
build-document-crawler:
|
build-document-crawler:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.crawler == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -164,6 +224,8 @@ jobs:
|
|||||||
build-dsms-gateway:
|
build-dsms-gateway:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.dsms_gateway == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -187,6 +249,8 @@ jobs:
|
|||||||
build-dsms-node:
|
build-dsms-node:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.dsms_node == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -207,7 +271,52 @@ jobs:
|
|||||||
docker push registry.meghsakha.com/breakpilot/compliance-dsms-node:latest
|
docker push registry.meghsakha.com/breakpilot/compliance-dsms-node:latest
|
||||||
docker push registry.meghsakha.com/breakpilot/compliance-dsms-node:${SHORT_SHA}
|
docker push registry.meghsakha.com/breakpilot/compliance-dsms-node:${SHORT_SHA}
|
||||||
|
|
||||||
# ── orca redeploy (only after all builds succeed) ─────────────────────────
|
# ── advance the last-build/main tag — the diff base for future runs ──────
|
||||||
|
# Runs when no build failed. Covers two cases:
|
||||||
|
# - at least one service was rebuilt → mark this SHA as the new baseline
|
||||||
|
# - all services were skipped (nothing changed) → still advance the tag
|
||||||
|
# so we don't keep re-evaluating the same skipped commits forever
|
||||||
|
# Skips if any build failed → tag stays put → next push retries those
|
||||||
|
# services from the previous known-good base.
|
||||||
|
mark-last-build:
|
||||||
|
runs-on: docker
|
||||||
|
container: alpine:3.20
|
||||||
|
needs:
|
||||||
|
- build-admin-compliance
|
||||||
|
- build-backend-compliance
|
||||||
|
- build-ai-sdk
|
||||||
|
- build-developer-portal
|
||||||
|
- build-tts
|
||||||
|
- build-document-crawler
|
||||||
|
- build-dsms-gateway
|
||||||
|
- build-dsms-node
|
||||||
|
if: |
|
||||||
|
always() &&
|
||||||
|
!contains(needs.*.result, 'failure') &&
|
||||||
|
!contains(needs.*.result, 'cancelled')
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git
|
||||||
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
- name: Force-push last-build/main tag
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
SHA="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
||||||
|
echo "Advancing last-build/main → ${SHA}"
|
||||||
|
git tag -f last-build/main "$SHA"
|
||||||
|
# Encode token into the push URL (no on-disk credential persistence).
|
||||||
|
PUSH_URL="${GITHUB_SERVER_URL/https:\/\//https:\/\/x-access-token:${GITEA_TOKEN}@}/${GITHUB_REPOSITORY}.git"
|
||||||
|
git push --force "$PUSH_URL" "refs/tags/last-build/main"
|
||||||
|
echo "Tag last-build/main now at ${SHA}"
|
||||||
|
|
||||||
|
# ── orca redeploy — runs only if at least one build succeeded ─────────────
|
||||||
|
# `always()` lets this run when some builds are skipped (unchanged services).
|
||||||
|
# The contains() checks ensure we only redeploy when something actually built
|
||||||
|
# and no build failed.
|
||||||
|
|
||||||
trigger-orca:
|
trigger-orca:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
@@ -221,6 +330,11 @@ jobs:
|
|||||||
- build-document-crawler
|
- build-document-crawler
|
||||||
- build-dsms-gateway
|
- build-dsms-gateway
|
||||||
- build-dsms-node
|
- build-dsms-node
|
||||||
|
if: |
|
||||||
|
always() &&
|
||||||
|
contains(needs.*.result, 'success') &&
|
||||||
|
!contains(needs.*.result, 'failure') &&
|
||||||
|
!contains(needs.*.result, 'cancelled')
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout (for SHA)
|
- name: Checkout (for SHA)
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -19,6 +19,49 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
|
# ── Change detection (always runs first) ─────────────────────────────────
|
||||||
|
# Diff base:
|
||||||
|
# PR → merge-base with the PR base branch
|
||||||
|
# push → last-build/main tag (set by build-push-deploy after a green build)
|
||||||
|
# Falls back to "rebuild all" when the base is missing or unreachable.
|
||||||
|
detect-changes:
|
||||||
|
runs-on: docker
|
||||||
|
container: alpine:3.20
|
||||||
|
outputs:
|
||||||
|
admin: ${{ steps.diff.outputs.admin }}
|
||||||
|
backend: ${{ steps.diff.outputs.backend }}
|
||||||
|
sdk: ${{ steps.diff.outputs.sdk }}
|
||||||
|
portal: ${{ steps.diff.outputs.portal }}
|
||||||
|
tts: ${{ steps.diff.outputs.tts }}
|
||||||
|
crawler: ${{ steps.diff.outputs.crawler }}
|
||||||
|
dsms_gateway: ${{ steps.diff.outputs.dsms_gateway }}
|
||||||
|
dsms_node: ${{ steps.diff.outputs.dsms_node }}
|
||||||
|
any_python: ${{ steps.diff.outputs.any_python }}
|
||||||
|
any_node: ${{ steps.diff.outputs.any_node }}
|
||||||
|
any: ${{ steps.diff.outputs.any }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git bash
|
||||||
|
git clone --depth 200 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
|
||||||
|
git fetch --depth 200 origin "${GITHUB_BASE_REF}" || true
|
||||||
|
else
|
||||||
|
git fetch --tags origin || true
|
||||||
|
fi
|
||||||
|
- name: Resolve base SHA
|
||||||
|
run: |
|
||||||
|
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
|
||||||
|
BASE=$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD 2>/dev/null || true)
|
||||||
|
else
|
||||||
|
BASE=$(git rev-parse --verify refs/tags/last-build/main 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
echo "Base SHA: ${BASE:-<none>}"
|
||||||
|
echo "BASE_SHA=${BASE}" >> "$GITHUB_ENV"
|
||||||
|
- name: Detect changes
|
||||||
|
id: diff
|
||||||
|
run: bash scripts/detect-changes.sh
|
||||||
|
|
||||||
# ── Branch naming convention (PR only) ──────────────────────────────────
|
# ── Branch naming convention (PR only) ──────────────────────────────────
|
||||||
branch-name:
|
branch-name:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
@@ -55,10 +98,12 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── LOC budget (always) ──────────────────────────────────────────────────
|
# ── LOC budget (only if files changed) ───────────────────────────────────
|
||||||
loc-budget:
|
loc-budget:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: alpine:3.20
|
container: alpine:3.20
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.any == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -86,10 +131,11 @@ jobs:
|
|||||||
--redact \
|
--redact \
|
||||||
|| { echo "::error::Secrets detected — remove them before merging."; exit 1; }
|
|| { echo "::error::Secrets detected — remove them before merging."; exit 1; }
|
||||||
|
|
||||||
# ── Go lint + build (PR only) ────────────────────────────────────────────
|
# ── Go lint + build (PR only, gated on ai-compliance-sdk changes) ────────
|
||||||
go-lint:
|
go-lint:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
if: github.event_name == 'pull_request'
|
needs: detect-changes
|
||||||
|
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.sdk == 'true'
|
||||||
container: golangci/golangci-lint:v1.62-alpine
|
container: golangci/golangci-lint:v1.62-alpine
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -107,10 +153,11 @@ jobs:
|
|||||||
cd ai-compliance-sdk
|
cd ai-compliance-sdk
|
||||||
go build ./...
|
go build ./...
|
||||||
|
|
||||||
# ── Python lint + import check (PR only) ────────────────────────────────
|
# ── Python lint + import check (PR only, gated on python service changes) ─
|
||||||
python-lint:
|
python-lint:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
if: github.event_name == 'pull_request'
|
needs: detect-changes
|
||||||
|
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any_python == 'true'
|
||||||
container: python:3.12-slim
|
container: python:3.12-slim
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -137,10 +184,11 @@ jobs:
|
|||||||
python -c "import compliance; print('Import OK')" \
|
python -c "import compliance; print('Import OK')" \
|
||||||
|| { echo "::error::compliance package fails to import — missing import or syntax error."; exit 1; }
|
|| { echo "::error::compliance package fails to import — missing import or syntax error."; exit 1; }
|
||||||
|
|
||||||
# ── Node.js lint + type-check (PR only) ─────────────────────────────────
|
# ── Node.js lint + type-check (PR only, gated on Next.js service changes) ─
|
||||||
nodejs-lint:
|
nodejs-lint:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
if: github.event_name == 'pull_request'
|
needs: detect-changes
|
||||||
|
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any_node == 'true'
|
||||||
container: node:20-alpine
|
container: node:20-alpine
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -158,10 +206,12 @@ jobs:
|
|||||||
done
|
done
|
||||||
exit $fail
|
exit $fail
|
||||||
|
|
||||||
# ── Node.js build — next build (PR + push to main) ───────────────────────
|
# ── Node.js build — next build (gated on Next.js service changes) ───────
|
||||||
nodejs-build:
|
nodejs-build:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: node:20-alpine
|
container: node:20-alpine
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.any_node == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -244,10 +294,12 @@ jobs:
|
|||||||
- name: Vulnerability scan (fail on high+)
|
- name: Vulnerability scan (fail on high+)
|
||||||
run: grype sbom:sbom-out/sbom.cdx.json --fail-on high -q
|
run: grype sbom:sbom-out/sbom.cdx.json --fail-on high -q
|
||||||
|
|
||||||
# ── Tests (PR + push to main) ─────────────────────────────────────────────
|
# ── Tests (gated per service) ────────────────────────────────────────────
|
||||||
test-go:
|
test-go:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: golang:1.24-alpine
|
container: golang:1.24-alpine
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.sdk == 'true'
|
||||||
env:
|
env:
|
||||||
CGO_ENABLED: "0"
|
CGO_ENABLED: "0"
|
||||||
steps:
|
steps:
|
||||||
@@ -265,6 +317,8 @@ jobs:
|
|||||||
test-python-backend:
|
test-python-backend:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: python:3.12-slim
|
container: python:3.12-slim
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.backend == 'true'
|
||||||
env:
|
env:
|
||||||
CI: "true"
|
CI: "true"
|
||||||
steps:
|
steps:
|
||||||
@@ -284,6 +338,8 @@ jobs:
|
|||||||
test-python-document-crawler:
|
test-python-document-crawler:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: python:3.12-slim
|
container: python:3.12-slim
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.crawler == 'true'
|
||||||
env:
|
env:
|
||||||
CI: "true"
|
CI: "true"
|
||||||
steps:
|
steps:
|
||||||
@@ -303,6 +359,8 @@ jobs:
|
|||||||
test-python-dsms-gateway:
|
test-python-dsms-gateway:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: python:3.12-slim
|
container: python:3.12-slim
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.dsms_gateway == 'true'
|
||||||
env:
|
env:
|
||||||
CI: "true"
|
CI: "true"
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.text()
|
||||||
|
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/compare`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(300000),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json({ error: `Backend: ${response.status}` }, { status: response.status })
|
||||||
|
}
|
||||||
|
return NextResponse.json(await response.json())
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: 'Vergleich fehlgeschlagen' }, { status: 503 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Unified Compliance Check Proxy
|
||||||
|
* POST: start check for all documents, GET: poll status
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.text()
|
||||||
|
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/compliance-check`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data, { status: response.status })
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: 'Pruefung konnte nicht gestartet werden' }, { status: 503 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const checkId = request.nextUrl.searchParams.get('check_id')
|
||||||
|
if (!checkId) return NextResponse.json({ error: 'check_id required' }, { status: 400 })
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${BACKEND_URL}/api/compliance/agent/compliance-check/${checkId}`,
|
||||||
|
{ signal: AbortSignal.timeout(10000) },
|
||||||
|
)
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Status-Abfrage fehlgeschlagen' }, { status: 503 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Consent Test API Proxy
|
||||||
|
* POST /api/sdk/v1/agent/consent-test → consent-tester:8094/scan → email via backend
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const CONSENT_TESTER_URL = process.env.CONSENT_TESTER_URL || 'http://bp-compliance-consent-tester:8094'
|
||||||
|
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
interface Violation { service: string; severity: string; text: string; legal_ref: string }
|
||||||
|
|
||||||
|
function buildEmailHtml(data: any): string {
|
||||||
|
const url = data.url || ''
|
||||||
|
const banner = data.banner_detected ? data.banner_provider : 'Nicht erkannt'
|
||||||
|
const phases = data.phases || {}
|
||||||
|
const summary = data.summary || {}
|
||||||
|
|
||||||
|
const sev = (s: string) => s === 'CRITICAL'
|
||||||
|
? '<span style="background:#991b1b;color:white;padding:2px 6px;border-radius:3px;font-size:11px;">KRITISCH</span>'
|
||||||
|
: '<span style="background:#ea580c;color:white;padding:2px 6px;border-radius:3px;font-size:11px;">HOCH</span>'
|
||||||
|
|
||||||
|
const violationRows = (violations: Violation[]) => violations.length === 0
|
||||||
|
? '<tr><td colspan="3" style="padding:6px;color:#16a34a;">✓ Keine Verstoesse</td></tr>'
|
||||||
|
: violations.map(v =>
|
||||||
|
`<tr><td style="padding:6px;">${sev(v.severity)}</td><td style="padding:6px;font-weight:600;">${v.service}</td><td style="padding:6px;">${v.text}<br><span style="color:#6b7280;font-size:11px;">${v.legal_ref}</span></td></tr>`
|
||||||
|
).join('')
|
||||||
|
|
||||||
|
const undocRows = (items: string[]) => items.length === 0
|
||||||
|
? ''
|
||||||
|
: items.map(s => `<tr><td style="padding:6px;">⚠</td><td style="padding:6px;font-weight:600;">${s}</td><td style="padding:6px;">Nicht in Cookie-Policy dokumentiert</td></tr>`).join('')
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="font-family:-apple-system,sans-serif;max-width:700px;margin:0 auto;">
|
||||||
|
<div style="background:linear-gradient(135deg,#1e1b4b,#312e81);color:white;padding:20px 24px;border-radius:12px 12px 0 0;">
|
||||||
|
<h2 style="margin:0;font-size:18px;">Cookie-Consent-Test</h2>
|
||||||
|
<p style="margin:4px 0 0;opacity:0.8;font-size:13px;">${url}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding:20px 24px;border:1px solid #e2e8f0;border-top:none;">
|
||||||
|
<table style="width:100%;border-collapse:collapse;margin-bottom:20px;">
|
||||||
|
<tr><td style="padding:6px 0;color:#64748b;width:160px;">Cookie-Banner</td><td style="padding:6px 0;font-weight:600;">${data.banner_detected ? '✓ ' + banner : '✗ Nicht erkannt'}</td></tr>
|
||||||
|
<tr><td style="padding:6px 0;color:#64748b;">Kritische Verstoesse</td><td style="padding:6px 0;"><strong style="color:${summary.critical > 0 ? '#dc2626' : '#16a34a'}">${summary.critical || 0}</strong></td></tr>
|
||||||
|
<tr><td style="padding:6px 0;color:#64748b;">Hohe Verstoesse</td><td style="padding:6px 0;"><strong style="color:${summary.high > 0 ? '#ea580c' : '#16a34a'}">${summary.high || 0}</strong></td></tr>
|
||||||
|
<tr><td style="padding:6px 0;color:#64748b;">Undokumentiert</td><td style="padding:6px 0;">${summary.undocumented || 0}</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3 style="color:#1e293b;font-size:14px;margin:20px 0 8px;border-bottom:2px solid #e2e8f0;padding-bottom:6px;">
|
||||||
|
🔍 Phase A: Vor Einwilligung
|
||||||
|
</h3>
|
||||||
|
<p style="color:#64748b;font-size:12px;margin:0 0 8px;">Was laedt OHNE dass der Nutzer etwas geklickt hat?</p>
|
||||||
|
<table style="width:100%;border-collapse:collapse;">${violationRows(phases.before_consent?.violations || [])}</table>
|
||||||
|
|
||||||
|
${data.banner_detected ? `
|
||||||
|
<h3 style="color:#1e293b;font-size:14px;margin:20px 0 8px;border-bottom:2px solid #e2e8f0;padding-bottom:6px;">
|
||||||
|
🚫 Phase B: Nach Ablehnung
|
||||||
|
</h3>
|
||||||
|
<p style="color:#64748b;font-size:12px;margin:0 0 8px;">Was laedt NACHDEM der Nutzer "Nur notwendige" geklickt hat?</p>
|
||||||
|
<table style="width:100%;border-collapse:collapse;">${violationRows(phases.after_reject?.violations || [])}</table>
|
||||||
|
|
||||||
|
<h3 style="color:#1e293b;font-size:14px;margin:20px 0 8px;border-bottom:2px solid #e2e8f0;padding-bottom:6px;">
|
||||||
|
✅ Phase C: Nach Zustimmung
|
||||||
|
</h3>
|
||||||
|
<p style="color:#64748b;font-size:12px;margin:0 0 8px;">Was laedt NACHDEM der Nutzer "Alle akzeptieren" geklickt hat?</p>
|
||||||
|
<table style="width:100%;border-collapse:collapse;">${undocRows(phases.after_accept?.undocumented || [])}</table>
|
||||||
|
${(phases.after_accept?.undocumented?.length || 0) === 0 ? '<p style="color:#16a34a;font-size:13px;">✓ Alle Dienste dokumentiert</p>' : ''}
|
||||||
|
` : `
|
||||||
|
<div style="background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:12px;margin:12px 0;">
|
||||||
|
<strong style="color:#dc2626;">Kein Cookie-Banner erkannt.</strong>
|
||||||
|
Alle Tracking-Dienste laden ohne Einwilligung — Verstoss gegen §25 TDDDG.
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
|
||||||
|
${(summary.critical || 0) > 0 ? `
|
||||||
|
<div style="background:#fef2f2;border-left:4px solid #dc2626;padding:12px 16px;margin-top:20px;">
|
||||||
|
<strong style="color:#991b1b;">⚠ KRITISCH:</strong> Tracking-Dienste laden trotz Ablehnung.
|
||||||
|
Dies ist ein schwerer Verstoss gegen §25 TDDDG und kann als Dark Pattern gewertet werden.
|
||||||
|
Sofortige Korrektur der Cookie-Banner-Konfiguration empfohlen.
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background:#f8fafc;padding:12px 24px;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px;">
|
||||||
|
<p style="color:#94a3b8;font-size:11px;margin:0;">
|
||||||
|
Automatisch erstellt vom BreakPilot Compliance Agent (Playwright + Chromium)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const url = body.url
|
||||||
|
|
||||||
|
// Step 1: Run consent test
|
||||||
|
const response = await fetch(`${CONSENT_TESTER_URL}/scan`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(180000),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Consent-Tester: ${response.status}`, detail: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
// Step 2: Send email with phase-structured findings
|
||||||
|
try {
|
||||||
|
const total = (data.summary?.total_violations || 0)
|
||||||
|
const severity = (data.summary?.critical || 0) > 0 ? 'KRITISCH' : total > 0 ? 'FINDINGS' : 'OK'
|
||||||
|
await fetch(`${BACKEND_URL}/api/compliance/agent/notify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
recipient: body.recipient || 'dsb@breakpilot.local',
|
||||||
|
subject: `[COOKIE-TEST] [${severity}] ${url} — ${total} Verstoesse`,
|
||||||
|
body_html: buildEmailHtml({ ...data, url }),
|
||||||
|
role: total > 0 ? 'Datenschutzbeauftragter' : 'Kein Handlungsbedarf',
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
})
|
||||||
|
} catch (emailErr) {
|
||||||
|
console.warn('Email send failed (non-blocking):', emailErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Consent test proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Cookie-Test fehlgeschlagen oder Timeout' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Text Extraction Proxy — extract text from a URL via consent-tester
|
||||||
|
* POST: { url: string } -> { text, word_count, title, error }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.text()
|
||||||
|
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/extract-text`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(120000),
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data, { status: response.status })
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ text: '', word_count: 0, title: '', error: 'Text-Extraktion fehlgeschlagen' },
|
||||||
|
{ status: 503 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Agent Notify API Proxy
|
||||||
|
* POST /api/sdk/v1/agent/notify → backend-compliance /api/compliance/agent/notify
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.text()
|
||||||
|
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/notify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
return NextResponse.json({ error: errorText }, { status: response.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await response.json())
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Agent notify proxy error:', error)
|
||||||
|
return NextResponse.json({ error: 'Email-Versand fehlgeschlagen' }, { status: 503 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ export async function POST(request: NextRequest) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body,
|
body,
|
||||||
signal: AbortSignal.timeout(30000), // 30s — just needs to start the job
|
signal: AbortSignal.timeout(300000), // 5 min — multi-page scan + LLM calls
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* PDF Export Proxy
|
||||||
|
* POST /api/sdk/v1/agent/scans/pdf → backend /api/compliance/agent/scans/pdf
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.text()
|
||||||
|
|
||||||
|
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/scans/pdf`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json({ error: 'PDF generation failed' }, { status: response.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfBytes = await response.arrayBuffer()
|
||||||
|
return new NextResponse(pdfBytes, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': 'attachment; filename="compliance-report.pdf"',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PDF proxy error:', error)
|
||||||
|
return NextResponse.json({ error: 'PDF generation failed' }, { status: 503 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* DSMS Gateway Proxy — forwards verify/history requests to dsms-gateway.
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const DSMS_URL = process.env.DSMS_GATEWAY_URL || 'http://dsms-gateway:8082'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path } = await params
|
||||||
|
const target = `${DSMS_URL}/api/v1/${path.join('/')}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(target, {
|
||||||
|
headers: { Authorization: 'Bearer system-frontend' },
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
})
|
||||||
|
const data = await resp.json()
|
||||||
|
return NextResponse.json(data, { status: resp.status })
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'DSMS not available' }, { status: 503 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -55,6 +55,8 @@ export function BannerCheckTab() {
|
|||||||
try { const s = localStorage.getItem('banner-check-result'); return s ? JSON.parse(s) : null } catch { 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 [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 }[]>(() => {
|
const [history, setHistory] = useState<{ url: string; date: string; provider: string; violations: number; pct: number; resultKey: string }[]>(() => {
|
||||||
if (typeof window === 'undefined') return []
|
if (typeof window === 'undefined') return []
|
||||||
try { return JSON.parse(localStorage.getItem('banner-check-history') || '[]') } catch { return [] }
|
try { return JSON.parse(localStorage.getItem('banner-check-history') || '[]') } catch { return [] }
|
||||||
@@ -97,6 +99,36 @@ export function BannerCheckTab() {
|
|||||||
setResult(data)
|
setResult(data)
|
||||||
localStorage.setItem('banner-check-result', JSON.stringify(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
|
// Add to history with persistent result
|
||||||
const violations = data.structured_checks?.filter((c: CheckItem) => !c.passed && !c.skipped).length || 0
|
const violations = data.structured_checks?.filter((c: CheckItem) => !c.passed && !c.skipped).length || 0
|
||||||
const resultKey = `banner-check-result-${Date.now()}`
|
const resultKey = `banner-check-result-${Date.now()}`
|
||||||
@@ -162,6 +194,16 @@ export function BannerCheckTab() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 (381 Cookie-MCs)' : 'KI-Agent aus'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleScan} className="space-y-3">
|
<form onSubmit={handleScan} className="space-y-3">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<input
|
<input
|
||||||
@@ -268,6 +310,14 @@ export function BannerCheckTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* MC Agent Results (Cookie-Richtlinie) */}
|
||||||
|
{mcResults?.results && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-800 mb-3">KI-Agent: Cookie-Richtlinie (381 MCs)</h4>
|
||||||
|
<ChecklistView results={mcResults.results} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!result.banner_detected && !hasStructured && (
|
{!result.banner_detected && !hasStructured && (
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ interface DocResult {
|
|||||||
checks: CheckItem[]
|
checks: CheckItem[]
|
||||||
findings_count: number
|
findings_count: number
|
||||||
error: string
|
error: string
|
||||||
|
scenario?: string // regenerate | fix | import | skip
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCENARIO_LABELS: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
|
regenerate: { label: 'Neugenerierung', color: 'text-red-700', bg: 'bg-red-100' },
|
||||||
|
fix: { label: 'Korrekturen', color: 'text-amber-700', bg: 'bg-amber-100' },
|
||||||
|
import: { label: 'Konform', color: 'text-green-700', bg: 'bg-green-100' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const DOC_TYPE_LABELS: Record<string, string> = {
|
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||||
@@ -46,7 +53,7 @@ function groupChecks(checks: CheckItem[]): GroupedCheck[] {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function CheckIcon({ passed, skipped }: { passed: boolean; skipped?: boolean }) {
|
function CheckIcon({ passed, skipped, isInfo }: { passed: boolean; skipped?: boolean; isInfo?: boolean }) {
|
||||||
if (skipped) {
|
if (skipped) {
|
||||||
return (
|
return (
|
||||||
<svg className="w-4 h-4 text-gray-300 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 text-gray-300 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -61,6 +68,13 @@ function CheckIcon({ passed, skipped }: { passed: boolean; skipped?: boolean })
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (isInfo) {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 text-gray-400 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<svg className="w-4 h-4 text-red-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 text-red-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
@@ -84,14 +98,23 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
|||||||
|
|
||||||
if (!results || results.length === 0) return null
|
if (!results || results.length === 0) return null
|
||||||
|
|
||||||
const totalOk = results.filter(r => r.completeness_pct === 100).length
|
const scenarioCounts = {
|
||||||
|
regenerate: results.filter(r => r.scenario === 'regenerate').length,
|
||||||
|
fix: results.filter(r => r.scenario === 'fix').length,
|
||||||
|
import: results.filter(r => r.scenario === 'import').length,
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
<h3 className="text-sm font-semibold text-gray-800">
|
<h3 className="text-sm font-semibold text-gray-800">
|
||||||
Dokumenten-Pruefung ({results.length} Dokumente, {totalOk} vollstaendig)
|
Dokumenten-Pruefung ({results.length} Dokumente)
|
||||||
</h3>
|
</h3>
|
||||||
|
<div className="flex gap-2 text-[10px]">
|
||||||
|
{scenarioCounts.import > 0 && <span className="bg-green-100 text-green-700 px-2 py-0.5 rounded-full">{scenarioCounts.import} konform</span>}
|
||||||
|
{scenarioCounts.fix > 0 && <span className="bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full">{scenarioCounts.fix} Korrekturen</span>}
|
||||||
|
{scenarioCounts.regenerate > 0 && <span className="bg-red-100 text-red-700 px-2 py-0.5 rounded-full">{scenarioCounts.regenerate} Neugenerierung</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -104,8 +127,9 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
|||||||
const typeLabel = DOC_TYPE_LABELS[r.doc_type] || r.doc_type
|
const typeLabel = DOC_TYPE_LABELS[r.doc_type] || r.doc_type
|
||||||
const grouped = groupChecks(r.checks)
|
const grouped = groupChecks(r.checks)
|
||||||
const l1Checks = r.checks.filter(c => (c.level ?? 1) === 1)
|
const l1Checks = r.checks.filter(c => (c.level ?? 1) === 1)
|
||||||
|
const l1Scoreable = l1Checks.filter(c => c.severity !== 'INFO')
|
||||||
const l2Active = r.checks.filter(c => (c.level ?? 1) === 2 && !c.skipped)
|
const l2Active = r.checks.filter(c => (c.level ?? 1) === 2 && !c.skipped)
|
||||||
const l1Passed = l1Checks.filter(c => c.passed).length
|
const l1Passed = l1Scoreable.filter(c => c.passed).length
|
||||||
const l2Passed = l2Active.filter(c => c.passed).length
|
const l2Passed = l2Active.filter(c => c.passed).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -123,10 +147,17 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
|||||||
{typeLabel}
|
{typeLabel}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-sm font-medium text-gray-900 truncate">{r.label}</div>
|
<div className="text-sm font-medium text-gray-900 truncate flex items-center gap-2">
|
||||||
|
{r.label}
|
||||||
|
{r.scenario && SCENARIO_LABELS[r.scenario] && (
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${SCENARIO_LABELS[r.scenario].bg} ${SCENARIO_LABELS[r.scenario].color}`}>
|
||||||
|
{SCENARIO_LABELS[r.scenario].label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="text-xs text-gray-500 truncate">
|
<div className="text-xs text-gray-500 truncate">
|
||||||
{l1Checks.length > 0
|
{l1Checks.length > 0
|
||||||
? `${l1Passed}/${l1Checks.length} Pflichtangaben`
|
? `${l1Passed}/${l1Scoreable.length} Pflichtangaben`
|
||||||
+ (l2Active.length > 0 ? `, ${l2Passed}/${l2Active.length} Detailpruefungen` : '')
|
+ (l2Active.length > 0 ? `, ${l2Passed}/${l2Active.length} Detailpruefungen` : '')
|
||||||
: r.url}
|
: r.url}
|
||||||
</div>
|
</div>
|
||||||
@@ -137,8 +168,9 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
|||||||
<span className="text-xs text-red-600 font-medium">Fehler</span>
|
<span className="text-xs text-red-600 font-medium">Fehler</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2" title={`Pflichtangaben: ${l1Passed}/${l1Scoreable.length}`}>
|
||||||
<div className="w-16 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
<span className="text-[10px] text-gray-400 w-7">Pflicht</span>
|
||||||
|
<div className="w-14 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||||
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${pct}%` }} />
|
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${pct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-xs font-medium w-10 text-right ${
|
<span className={`text-xs font-medium w-10 text-right ${
|
||||||
@@ -146,8 +178,9 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
|||||||
}`}>{pct}%</span>
|
}`}>{pct}%</span>
|
||||||
</div>
|
</div>
|
||||||
{l2Active.length > 0 && (
|
{l2Active.length > 0 && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2" title={`Detailpruefung: ${l2Passed}/${l2Active.length}`}>
|
||||||
<div className="w-16 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
<span className="text-[10px] text-gray-400 w-7">Detail</span>
|
||||||
|
<div className="w-14 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||||
<div className={`h-full rounded-full ${cBarColor}`} style={{ width: `${cpct}%` }} />
|
<div className={`h-full rounded-full ${cBarColor}`} style={{ width: `${cpct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-medium w-10 text-right text-blue-600">{cpct}%</span>
|
<span className="text-xs font-medium w-10 text-right text-blue-600">{cpct}%</span>
|
||||||
@@ -164,13 +197,18 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
|||||||
<p className="text-sm text-red-600">{r.error}</p>
|
<p className="text-sm text-red-600">{r.error}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{grouped.map((g) => (
|
{grouped.map((g) => {
|
||||||
|
const l1Info = g.check.severity === 'INFO' && !g.check.passed
|
||||||
|
return (
|
||||||
<div key={g.check.id}>
|
<div key={g.check.id}>
|
||||||
{/* L1 check */}
|
{/* L1 check */}
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<CheckIcon passed={g.check.passed} />
|
<CheckIcon passed={g.check.passed} isInfo={l1Info} />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className={`text-sm ${g.check.passed ? 'text-gray-700' : 'text-red-700 font-medium'}`}>
|
<div className={`text-sm ${
|
||||||
|
g.check.passed ? 'text-gray-700'
|
||||||
|
: l1Info ? 'text-gray-500' : 'text-red-700 font-medium'
|
||||||
|
}`}>
|
||||||
{g.check.label}
|
{g.check.label}
|
||||||
{g.children.length > 0 && <L2Summary>{g.children}</L2Summary>}
|
{g.children.length > 0 && <L2Summary>{g.children}</L2Summary>}
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +218,7 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!g.check.passed && g.check.hint && (
|
{!g.check.passed && g.check.hint && (
|
||||||
<div className="text-xs text-red-600/80 mt-0.5">
|
<div className={`text-xs mt-0.5 ${l1Info ? 'text-gray-400' : 'text-red-600/80'}`}>
|
||||||
{g.check.hint}
|
{g.check.hint}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -190,13 +228,16 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
|||||||
{/* L2 children — always visible */}
|
{/* L2 children — always visible */}
|
||||||
{g.children.length > 0 && (
|
{g.children.length > 0 && (
|
||||||
<div className="ml-6 mt-0.5 mb-1 space-y-0.5 border-l-2 border-gray-200 pl-3">
|
<div className="ml-6 mt-0.5 mb-1 space-y-0.5 border-l-2 border-gray-200 pl-3">
|
||||||
{g.children.map((ch) => (
|
{g.children.map((ch) => {
|
||||||
|
const chInfo = ch.severity === 'INFO' && !ch.passed && !ch.skipped
|
||||||
|
return (
|
||||||
<div key={ch.id} className="flex items-start gap-2">
|
<div key={ch.id} className="flex items-start gap-2">
|
||||||
<CheckIcon passed={ch.passed} skipped={ch.skipped} />
|
<CheckIcon passed={ch.passed} skipped={ch.skipped} isInfo={chInfo} />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className={`text-xs ${
|
<div className={`text-xs ${
|
||||||
ch.skipped ? 'text-gray-400 italic'
|
ch.skipped ? 'text-gray-400 italic'
|
||||||
: ch.passed ? 'text-gray-600' : 'text-red-600 font-medium'
|
: ch.passed ? 'text-gray-600'
|
||||||
|
: chInfo ? 'text-gray-400' : 'text-red-600 font-medium'
|
||||||
}`}>
|
}`}>
|
||||||
{ch.label}
|
{ch.label}
|
||||||
{ch.skipped && ' (uebersprungen)'}
|
{ch.skipped && ' (uebersprungen)'}
|
||||||
@@ -207,17 +248,19 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!ch.passed && !ch.skipped && ch.hint && (
|
{!ch.passed && !ch.skipped && ch.hint && (
|
||||||
<div className="text-xs text-red-500/80 mt-0.5">
|
<div className={`text-xs mt-0.5 ${chInfo ? 'text-gray-400' : 'text-red-500/80'}`}>
|
||||||
{ch.hint}
|
{ch.hint}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
{r.word_count > 0 && (
|
{r.word_count > 0 && (
|
||||||
<div className="text-xs text-gray-400 mt-2 pt-2 border-t border-gray-200">
|
<div className="text-xs text-gray-400 mt-2 pt-2 border-t border-gray-200">
|
||||||
{r.word_count} Woerter analysiert
|
{r.word_count} Woerter analysiert
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface SiteResult {
|
||||||
|
url: string
|
||||||
|
domain: string
|
||||||
|
risk_level: string
|
||||||
|
risk_score: number
|
||||||
|
findings_count: number
|
||||||
|
services_count: number
|
||||||
|
has_impressum: boolean
|
||||||
|
has_datenschutz: boolean
|
||||||
|
has_cookie_banner: boolean
|
||||||
|
has_google_fonts: boolean
|
||||||
|
scan_status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const RISK_COLOR: Record<string, string> = {
|
||||||
|
MINIMAL: 'text-green-700 bg-green-50',
|
||||||
|
LOW: 'text-yellow-700 bg-yellow-50',
|
||||||
|
LIMITED: 'text-orange-700 bg-orange-50',
|
||||||
|
HIGH: 'text-red-700 bg-red-50',
|
||||||
|
UNACCEPTABLE: 'text-red-900 bg-red-100',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompareResult({ sites }: { sites: SiteResult[] }) {
|
||||||
|
if (!sites.length) return null
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
{ key: 'has_datenschutz', label: 'Datenschutzerklaerung' },
|
||||||
|
{ key: 'has_impressum', label: 'Impressum' },
|
||||||
|
{ key: 'has_cookie_banner', label: 'Cookie-Banner' },
|
||||||
|
{ key: 'has_google_fonts', label: 'Google Fonts (lokal?)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50">
|
||||||
|
<th className="text-left px-3 py-2 text-xs font-medium text-gray-500 w-44">Pruefung</th>
|
||||||
|
{sites.map((s, i) => (
|
||||||
|
<th key={i} className="text-center px-3 py-2 text-xs font-medium text-gray-700">
|
||||||
|
{s.domain}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-2 text-gray-600">Risiko-Score</td>
|
||||||
|
{sites.map((s, i) => (
|
||||||
|
<td key={i} className="px-3 py-2 text-center">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${RISK_COLOR[s.risk_level] || 'text-gray-600 bg-gray-50'}`}>
|
||||||
|
{s.risk_level || '?'} ({s.risk_score}/100)
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-2 text-gray-600">Findings</td>
|
||||||
|
{sites.map((s, i) => (
|
||||||
|
<td key={i} className={`px-3 py-2 text-center font-medium ${s.findings_count > 0 ? 'text-red-700' : 'text-green-700'}`}>
|
||||||
|
{s.findings_count}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-2 text-gray-600">Dienste erkannt</td>
|
||||||
|
{sites.map((s, i) => (
|
||||||
|
<td key={i} className="px-3 py-2 text-center text-gray-700">{s.services_count}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
{checks.map(check => (
|
||||||
|
<tr key={check.key}>
|
||||||
|
<td className="px-3 py-2 text-gray-600">{check.label}</td>
|
||||||
|
{sites.map((s, i) => {
|
||||||
|
const val = (s as any)[check.key]
|
||||||
|
const isInverted = check.key === 'has_google_fonts'
|
||||||
|
const good = isInverted ? !val : val
|
||||||
|
return (
|
||||||
|
<td key={i} className={`px-3 py-2 text-center font-medium ${good ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{good ? '✓' : '✗'}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from 'react'
|
||||||
|
import { ChecklistView } from './ChecklistView'
|
||||||
|
import { DocumentRow } from './DocumentRow'
|
||||||
|
|
||||||
|
const DOCUMENT_TYPES = [
|
||||||
|
{ id: 'dse', label: 'DSI (Datenschutzinformation)', required: true },
|
||||||
|
{ id: 'impressum', label: 'Impressum', required: true },
|
||||||
|
{ id: 'social_media', label: 'Social Media DSE', required: false },
|
||||||
|
{ id: 'cookie', label: 'Cookie-Richtlinie', required: false },
|
||||||
|
{ id: 'agb', label: 'AGB', required: false },
|
||||||
|
{ id: 'nutzungsbedingungen', label: 'Nutzungsbedingungen', required: false },
|
||||||
|
{ id: 'widerruf', label: 'Widerrufsbelehrung', required: false },
|
||||||
|
{ id: 'dsb', label: 'DSB-Kontakt', required: false },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type DocTypeId = typeof DOCUMENT_TYPES[number]['id']
|
||||||
|
|
||||||
|
interface DocState {
|
||||||
|
url: string
|
||||||
|
text: string
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocsState = Record<DocTypeId, DocState>
|
||||||
|
|
||||||
|
const STORAGE_KEY_STATE = 'compliance-check-state'
|
||||||
|
const STORAGE_KEY_RESULTS = 'compliance-check-results'
|
||||||
|
const STORAGE_KEY_HISTORY = 'compliance-check-history'
|
||||||
|
const STORAGE_KEY_CHECK_ID = 'compliance-check-active-id'
|
||||||
|
|
||||||
|
function emptyDocState(): DocState {
|
||||||
|
return { url: '', text: '', loading: false, error: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function initState(): DocsState {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return Object.fromEntries(DOCUMENT_TYPES.map(d => [d.id, emptyDocState()])) as DocsState
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY_STATE)
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved) as Record<string, { url?: string; text?: string }>
|
||||||
|
return Object.fromEntries(
|
||||||
|
DOCUMENT_TYPES.map(d => [d.id, {
|
||||||
|
url: parsed[d.id]?.url || '',
|
||||||
|
text: parsed[d.id]?.text || '',
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}])
|
||||||
|
) as DocsState
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return Object.fromEntries(DOCUMENT_TYPES.map(d => [d.id, emptyDocState()])) as DocsState
|
||||||
|
}
|
||||||
|
|
||||||
|
function countWords(text: string): number {
|
||||||
|
if (!text.trim()) return 0
|
||||||
|
return text.trim().split(/\s+/).length
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryEntry {
|
||||||
|
date: string
|
||||||
|
docCount: number
|
||||||
|
findings: number
|
||||||
|
resultKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComplianceCheckTab() {
|
||||||
|
const [docs, setDocs] = useState<DocsState>(initState)
|
||||||
|
const [useAgent, setUseAgent] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [progress, setProgress] = useState('')
|
||||||
|
const [results, setResults] = useState<any>(() => {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
try { const s = localStorage.getItem(STORAGE_KEY_RESULTS); return s ? JSON.parse(s) : null } catch { return null }
|
||||||
|
})
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [activeCheckId, setActiveCheckId] = useState<string>(() =>
|
||||||
|
typeof window !== 'undefined' ? localStorage.getItem(STORAGE_KEY_CHECK_ID) || '' : ''
|
||||||
|
)
|
||||||
|
const [history, setHistory] = useState<HistoryEntry[]>(() => {
|
||||||
|
if (typeof window === 'undefined') return []
|
||||||
|
try { return JSON.parse(localStorage.getItem(STORAGE_KEY_HISTORY) || '[]') } catch { return [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Persist URLs and texts (not loading/error state)
|
||||||
|
React.useEffect(() => {
|
||||||
|
const toSave: Record<string, { url: string; text: string }> = {}
|
||||||
|
for (const [key, val] of Object.entries(docs)) {
|
||||||
|
toSave[key] = { url: val.url, text: val.text }
|
||||||
|
}
|
||||||
|
try { localStorage.setItem(STORAGE_KEY_STATE, JSON.stringify(toSave)) } catch { /* quota */ }
|
||||||
|
}, [docs])
|
||||||
|
|
||||||
|
// Resume polling if check was in progress when navigating away
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!activeCheckId || results) return
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
setProgress('Pruefung laeuft noch...')
|
||||||
|
const poll = async () => {
|
||||||
|
while (!cancelled) {
|
||||||
|
await new Promise(r => setTimeout(r, 3000))
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/agent/compliance-check?check_id=${activeCheckId}`)
|
||||||
|
if (!res.ok) continue
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.progress) setProgress(data.progress)
|
||||||
|
if (data.status === 'completed' && data.result) {
|
||||||
|
setResults(data.result); setProgress(''); setLoading(false)
|
||||||
|
localStorage.setItem(STORAGE_KEY_RESULTS, JSON.stringify(data.result))
|
||||||
|
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.status === 'failed' || data.status === 'not_found') {
|
||||||
|
if (data.status === 'failed') setError(data.error || 'Pruefung fehlgeschlagen')
|
||||||
|
setProgress(''); setLoading(false)
|
||||||
|
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch { /* retry */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
poll()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const updateDoc = useCallback((docType: DocTypeId, patch: Partial<DocState>) => {
|
||||||
|
setDocs(prev => ({ ...prev, [docType]: { ...prev[docType], ...patch } }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleFetchText = useCallback(async (docType: DocTypeId) => {
|
||||||
|
const url = docs[docType].url.trim()
|
||||||
|
if (!url) return
|
||||||
|
|
||||||
|
updateDoc(docType, { loading: true, error: null })
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sdk/v1/agent/extract-text', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = res.status === 404
|
||||||
|
? 'Seite nicht erreichbar'
|
||||||
|
: `Fehler beim Laden (${res.status})`
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
updateDoc(docType, { text: data.text || '', loading: false })
|
||||||
|
} catch (e) {
|
||||||
|
updateDoc(docType, {
|
||||||
|
loading: false,
|
||||||
|
error: e instanceof Error ? e.message : 'Text konnte nicht geladen werden',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [docs, updateDoc])
|
||||||
|
|
||||||
|
const handleFileUpload = useCallback(async (docType: DocTypeId, file: File) => {
|
||||||
|
// For now, read as text. PDF/DOCX parsing can be added server-side later.
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
updateDoc(docType, { text: reader.result as string })
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
}, [updateDoc])
|
||||||
|
|
||||||
|
const filledCount = Object.values(docs).filter(d => d.url.trim() || d.text.trim()).length
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (filledCount === 0) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setResults(null)
|
||||||
|
setProgress('Compliance-Check wird gestartet...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = DOCUMENT_TYPES
|
||||||
|
.filter(dt => docs[dt.id].url.trim() || docs[dt.id].text.trim())
|
||||||
|
.map(dt => ({
|
||||||
|
doc_type: dt.id,
|
||||||
|
label: dt.label,
|
||||||
|
url: docs[dt.id].url.trim(),
|
||||||
|
text: docs[dt.id].text.trim() || undefined,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const startRes = await fetch('/api/sdk/v1/agent/compliance-check', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
documents: entries,
|
||||||
|
use_agent: useAgent,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!startRes.ok) throw new Error(`Pruefung konnte nicht gestartet werden: ${startRes.status}`)
|
||||||
|
const { check_id } = await startRes.json()
|
||||||
|
if (!check_id) throw new Error('Keine Check-ID erhalten')
|
||||||
|
setActiveCheckId(check_id)
|
||||||
|
localStorage.setItem(STORAGE_KEY_CHECK_ID, check_id)
|
||||||
|
|
||||||
|
// Poll for results (max 25 min = 500 polls x 3s)
|
||||||
|
let attempts = 0
|
||||||
|
while (attempts < 500) {
|
||||||
|
await new Promise(r => setTimeout(r, 3000))
|
||||||
|
const pollRes = await fetch(`/api/sdk/v1/agent/compliance-check?check_id=${check_id}`)
|
||||||
|
if (!pollRes.ok) { attempts++; continue }
|
||||||
|
const pollData = await pollRes.json()
|
||||||
|
if (pollData.progress) setProgress(pollData.progress)
|
||||||
|
if (pollData.status === 'completed' && pollData.result) {
|
||||||
|
setResults(pollData.result)
|
||||||
|
setProgress('')
|
||||||
|
localStorage.setItem(STORAGE_KEY_RESULTS, JSON.stringify(pollData.result))
|
||||||
|
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||||
|
|
||||||
|
const resultKey = `compliance-check-result-${Date.now()}`
|
||||||
|
try { localStorage.setItem(resultKey, JSON.stringify(pollData.result)) } catch { /* quota */ }
|
||||||
|
const entry: HistoryEntry = {
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
docCount: entries.length,
|
||||||
|
findings: pollData.result.total_findings || 0,
|
||||||
|
resultKey,
|
||||||
|
}
|
||||||
|
const updated = [entry, ...history].slice(0, 30)
|
||||||
|
setHistory(updated)
|
||||||
|
localStorage.setItem(STORAGE_KEY_HISTORY, JSON.stringify(updated))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (pollData.status === 'failed') {
|
||||||
|
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||||
|
throw new Error(pollData.error || 'Pruefung fehlgeschlagen')
|
||||||
|
}
|
||||||
|
attempts++
|
||||||
|
}
|
||||||
|
if (attempts >= 500) {
|
||||||
|
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||||
|
throw new Error('Zeitlimit ueberschritten (15 Min)')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||||
|
setProgress('')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFromHistory = (entry: HistoryEntry) => {
|
||||||
|
if (entry.resultKey) {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(entry.resultKey)
|
||||||
|
if (saved) { setResults(JSON.parse(saved)); return }
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const last = localStorage.getItem(STORAGE_KEY_RESULTS)
|
||||||
|
if (last) setResults(JSON.parse(last))
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Info box */}
|
||||||
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-purple-900">Compliance-Check (Alle Dokumente)</h3>
|
||||||
|
<p className="text-xs text-purple-700 mt-1">
|
||||||
|
Geben Sie die URLs Ihrer Rechtstexte ein oder laden Sie die Dokumente hoch.
|
||||||
|
Das System prueft alle Pflichtangaben nach DSGVO, TDDDG, TMG und UWG.
|
||||||
|
Pflichtdokumente sind mit * markiert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Document rows */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{DOCUMENT_TYPES.map(dt => (
|
||||||
|
<DocumentRow
|
||||||
|
key={dt.id}
|
||||||
|
label={dt.label}
|
||||||
|
docType={dt.id}
|
||||||
|
required={dt.required}
|
||||||
|
url={docs[dt.id].url}
|
||||||
|
text={docs[dt.id].text}
|
||||||
|
loading={docs[dt.id].loading}
|
||||||
|
error={docs[dt.id].error}
|
||||||
|
wordCount={countWords(docs[dt.id].text)}
|
||||||
|
onUrlChange={url => updateDoc(dt.id, { url })}
|
||||||
|
onFetchText={() => handleFetchText(dt.id)}
|
||||||
|
onTextChange={text => updateDoc(dt.id, { text })}
|
||||||
|
onFileUpload={file => handleFileUpload(dt.id, file)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent toggle + submit */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUseAgent(!useAgent)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
|
||||||
|
useAgent
|
||||||
|
? 'bg-emerald-100 border-emerald-300 text-emerald-800'
|
||||||
|
: 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`w-2 h-2 rounded-full ${useAgent ? 'bg-emerald-500' : 'bg-gray-300'}`} />
|
||||||
|
{useAgent ? 'KI-Agent aktiv (alle MCs)' : 'KI-Agent aus'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{filledCount} von {DOCUMENT_TYPES.length} Dokumenten ausgefuellt
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit button */}
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading || filledCount === 0}
|
||||||
|
className="w-full px-4 py-3 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50 transition-colors text-sm flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
Pruefe...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
`Compliance-Check starten (${filledCount} Dokument${filledCount !== 1 ? 'e' : ''})`
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
{progress && (
|
||||||
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm text-purple-700 flex items-center gap-3">
|
||||||
|
<svg className="animate-spin w-4 h-4 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
{progress}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{results && results.results && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||||
|
{/* Business Profile */}
|
||||||
|
{results.business_profile && (
|
||||||
|
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-xs">
|
||||||
|
<div className="font-semibold text-blue-900 mb-1">Erkanntes Geschaeftsmodell</div>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-blue-700">
|
||||||
|
<span>Typ: <strong>{results.business_profile.business_type?.toUpperCase()}</strong></span>
|
||||||
|
<span>Branche: {results.business_profile.industry}</span>
|
||||||
|
{results.business_profile.has_online_shop && <span className="text-amber-700">Online-Shop</span>}
|
||||||
|
{results.business_profile.is_regulated_profession && <span className="text-amber-700">Regulierter Beruf ({results.business_profile.regulated_profession_type})</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Extracted Profile — pre-fill suggestion */}
|
||||||
|
{results.extracted_profile?.company_profile && Object.keys(results.extracted_profile.company_profile).length > 0 && (
|
||||||
|
<div className="mb-4 p-3 bg-emerald-50 border border-emerald-200 rounded-lg text-xs">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="font-semibold text-emerald-900">Aus Dokumenten extrahiert</span>
|
||||||
|
<button className="text-emerald-700 hover:text-emerald-900 text-xs font-medium underline"
|
||||||
|
onClick={() => { /* TODO: navigate to company profile with pre-fill */ }}>
|
||||||
|
In Company Profile uebernehmen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-emerald-700">
|
||||||
|
{results.extracted_profile.company_profile.companyName && (
|
||||||
|
<span>Firma: <strong>{results.extracted_profile.company_profile.companyName}</strong></span>
|
||||||
|
)}
|
||||||
|
{results.extracted_profile.company_profile.legalForm && (
|
||||||
|
<span>Rechtsform: {results.extracted_profile.company_profile.legalForm.toUpperCase()}</span>
|
||||||
|
)}
|
||||||
|
{results.extracted_profile.company_profile.headquartersCity && (
|
||||||
|
<span>Sitz: {results.extracted_profile.company_profile.headquartersZip} {results.extracted_profile.company_profile.headquartersCity}</span>
|
||||||
|
)}
|
||||||
|
{results.extracted_profile.company_profile.dpoEmail && (
|
||||||
|
<span>DSB: {results.extracted_profile.company_profile.dpoEmail}</span>
|
||||||
|
)}
|
||||||
|
{results.extracted_profile.company_profile.ustIdNr && (
|
||||||
|
<span>USt-IdNr: {results.extracted_profile.company_profile.ustIdNr}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{results.extracted_profile.compliance_scope_hints?.length > 0 && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-emerald-200 text-emerald-600">
|
||||||
|
<span className="font-medium">Scope-Hinweise: </span>
|
||||||
|
{results.extracted_profile.compliance_scope_hints.map((h: any, i: number) => (
|
||||||
|
<span key={i} className="inline-block bg-emerald-100 rounded px-1.5 py-0.5 mr-1 mb-1">
|
||||||
|
{h.source}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Banner Check Result */}
|
||||||
|
{results.banner_result && (
|
||||||
|
<div className={`mb-4 p-3 rounded-lg border text-xs ${
|
||||||
|
results.banner_result.violations > 0
|
||||||
|
? 'bg-amber-50 border-amber-200'
|
||||||
|
: results.banner_result.detected
|
||||||
|
? 'bg-green-50 border-green-200'
|
||||||
|
: 'bg-gray-50 border-gray-200'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${
|
||||||
|
results.banner_result.violations > 0 ? 'bg-amber-500'
|
||||||
|
: results.banner_result.detected ? 'bg-green-500' : 'bg-gray-400'
|
||||||
|
}`} />
|
||||||
|
<span className="font-semibold text-gray-900">
|
||||||
|
Cookie-Banner-Check (automatisch)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-gray-600 ml-4">
|
||||||
|
{results.banner_result.detected ? (
|
||||||
|
<>
|
||||||
|
Banner erkannt{results.banner_result.provider ? ` (${results.banner_result.provider})` : ''}.
|
||||||
|
{results.banner_result.violations > 0
|
||||||
|
? ` ${results.banner_result.violations} Auffaelligkeit${results.banner_result.violations !== 1 ? 'en' : ''} gefunden.`
|
||||||
|
: ' Keine Auffaelligkeiten.'}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Kein Cookie-Banner erkannt oder Banner-Check nicht moeglich.'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ChecklistView results={results.results} />
|
||||||
|
|
||||||
|
{/* Email status */}
|
||||||
|
{results.email_status && (
|
||||||
|
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${results.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
|
||||||
|
E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* History */}
|
||||||
|
{history.length > 0 && (
|
||||||
|
<div className="border border-gray-200 rounded-xl p-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">Letzte Compliance-Checks</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{history.map((h, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => loadFromHistory(h)}
|
||||||
|
className="w-full flex items-center justify-between text-sm py-2 px-2 rounded-lg border border-gray-50 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left"
|
||||||
|
>
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{new Date(h.date).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-gray-500">{h.docCount} Dok.</span>
|
||||||
|
<span className={`text-xs font-medium ${h.findings > 0 ? 'text-amber-600' : 'text-green-600'}`}>
|
||||||
|
{h.findings} Findings
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ export function ImpressumCheckTab() {
|
|||||||
try { return JSON.parse(localStorage.getItem('impressum-check-history') || '[]') } catch { 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])
|
React.useEffect(() => { localStorage.setItem('impressum-check-url', url) }, [url])
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
@@ -43,6 +45,7 @@ export function ImpressumCheckTab() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
entries: [{ doc_type: 'impressum', label: 'Impressum', url: url.trim() }],
|
entries: [{ doc_type: 'impressum', label: 'Impressum', url: url.trim() }],
|
||||||
recipient: 'dsb@breakpilot.local',
|
recipient: 'dsb@breakpilot.local',
|
||||||
|
use_agent: useAgent,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!startRes.ok) throw new Error(`Fehler: ${startRes.status}`)
|
if (!startRes.ok) throw new Error(`Fehler: ${startRes.status}`)
|
||||||
@@ -91,6 +94,16 @@ export function ImpressumCheckTab() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<form onSubmit={handleSubmit} className="flex gap-3">
|
||||||
<input type="url" value={url} onChange={e => setUrl(e.target.value)}
|
<input type="url" value={url} onChange={e => setUrl(e.target.value)}
|
||||||
placeholder="https://www.example.com/impressum"
|
placeholder="https://www.example.com/impressum"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
|
import { TextReference } from './TextReference'
|
||||||
|
|
||||||
interface ServiceInfo {
|
interface ServiceInfo {
|
||||||
name: string
|
name: string
|
||||||
@@ -14,22 +15,27 @@ interface ServiceInfo {
|
|||||||
status: string
|
status: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TextRef {
|
||||||
|
found: boolean
|
||||||
|
source_url: string
|
||||||
|
document_type: string
|
||||||
|
section_heading: string
|
||||||
|
section_number: string
|
||||||
|
parent_section: string
|
||||||
|
paragraph_index: number
|
||||||
|
original_text: string
|
||||||
|
issue: string
|
||||||
|
correction_type: string
|
||||||
|
correction_text: string
|
||||||
|
insert_after: string
|
||||||
|
}
|
||||||
|
|
||||||
interface ScanFinding {
|
interface ScanFinding {
|
||||||
code: string
|
code: string
|
||||||
severity: string
|
severity: string
|
||||||
text: string
|
text: string
|
||||||
correction: string
|
correction: string
|
||||||
doc_title: string
|
text_reference: TextRef | null
|
||||||
}
|
|
||||||
|
|
||||||
interface DiscoveredDocument {
|
|
||||||
title: string
|
|
||||||
url: string
|
|
||||||
doc_type: string
|
|
||||||
language: string
|
|
||||||
word_count: number
|
|
||||||
completeness_pct: number
|
|
||||||
findings_count: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScanData {
|
interface ScanData {
|
||||||
@@ -249,7 +255,12 @@ export function ScanResult({ data }: { data: ScanData }) {
|
|||||||
</span>
|
</span>
|
||||||
<p className="text-sm text-gray-800 flex-1">{f.text}</p>
|
<p className="text-sm text-gray-800 flex-1">{f.text}</p>
|
||||||
</div>
|
</div>
|
||||||
{f.correction && (
|
{/* Text Reference (original text + position + correction) */}
|
||||||
|
{f.text_reference && (
|
||||||
|
<TextReference ref={f.text_reference} correction={f.correction} />
|
||||||
|
)}
|
||||||
|
{/* Fallback: correction without text reference */}
|
||||||
|
{!f.text_reference && f.correction && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<button onClick={() => setExpandedCorrection(isExp ? null : corrKey)}
|
<button onClick={() => setExpandedCorrection(isExp ? null : corrKey)}
|
||||||
className="text-xs text-purple-600 hover:text-purple-800 font-medium">
|
className="text-xs text-purple-600 hover:text-purple-800 font-medium">
|
||||||
@@ -272,14 +283,35 @@ export function ScanResult({ data }: { data: ScanData }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* PDF Export Button */}
|
||||||
{/* Email Status */}
|
<div className="pt-4 border-t flex gap-3">
|
||||||
{data.email_status && (
|
<button
|
||||||
<div className="text-xs text-gray-500 flex items-center gap-2">
|
onClick={async () => {
|
||||||
<span className={`w-2 h-2 rounded-full ${data.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
|
try {
|
||||||
E-Mail: {data.email_status === 'sent' ? 'Gesendet' : data.email_status}
|
const res = await fetch('/api/sdk/v1/agent/scans/pdf', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: '', scan_type: 'scan', analysis_mode: 'post_launch', result: data }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const blob = await res.blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'compliance-report.pdf'
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('PDF export failed:', e) }
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-purple-700 bg-purple-50 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
PDF herunterladen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,23 +2,21 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { ScanResult } from './_components/ScanResult'
|
import { ScanResult } from './_components/ScanResult'
|
||||||
import { DocCheckTab } from './_components/DocCheckTab'
|
import { ComplianceCheckTab } from './_components/ComplianceCheckTab'
|
||||||
import { BannerCheckTab } from './_components/BannerCheckTab'
|
import { BannerCheckTab } from './_components/BannerCheckTab'
|
||||||
import { ImpressumCheckTab } from './_components/ImpressumCheckTab'
|
|
||||||
import { ComplianceFAQ } from './_components/ComplianceFAQ'
|
import { ComplianceFAQ } from './_components/ComplianceFAQ'
|
||||||
|
|
||||||
type AnalysisTab = 'scan' | 'doc-check' | 'banner-check' | 'impressum-check'
|
type AnalysisTab = 'scan' | 'compliance-check' | 'banner-check'
|
||||||
|
|
||||||
const TABS: { id: AnalysisTab; label: string; desc: string }[] = [
|
const TABS: { id: AnalysisTab; label: string; desc: string }[] = [
|
||||||
{ id: 'scan', label: 'Website-Scan', desc: 'Rechtliche Dokumente finden + Dienstleister erkennen' },
|
{ id: 'scan', label: 'Website-Scan', desc: 'Rechtliche Dokumente finden + Dienstleister erkennen' },
|
||||||
{ id: 'doc-check', label: 'Dokumenten-Pruefung', desc: 'DSI, AGB, Cookie-Richtlinie inhaltlich pruefen' },
|
{ 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' },
|
{ id: 'banner-check', label: 'Banner-Check', desc: 'Cookie-Banner auf DSGVO-Konformitaet testen' },
|
||||||
{ id: 'impressum-check', label: 'Impressum-Check', desc: 'Impressum auf §5 TMG Pflichtangaben pruefen' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function AgentPage() {
|
export default function AgentPage() {
|
||||||
const [url, setUrl] = useState(() => typeof window !== 'undefined' ? localStorage.getItem('agent-scan-url') || '' : '')
|
const [url, setUrl] = useState(() => typeof window !== 'undefined' ? localStorage.getItem('agent-scan-url') || '' : '')
|
||||||
const [tab, setTab] = useState<AnalysisTab>(() => (typeof window !== 'undefined' ? localStorage.getItem('agent-scan-tab') as AnalysisTab : null) || 'scan')
|
const [tab, setTab] = useState<AnalysisTab>(() => (typeof window !== 'undefined' ? localStorage.getItem('agent-scan-tab') as AnalysisTab : null) || 'compliance-check')
|
||||||
const [scanLoading, setScanLoading] = useState(false)
|
const [scanLoading, setScanLoading] = useState(false)
|
||||||
const [scanError, setScanError] = useState<string | null>(null)
|
const [scanError, setScanError] = useState<string | null>(null)
|
||||||
const [scanData, setScanData] = useState<any>(() => {
|
const [scanData, setScanData] = useState<any>(() => {
|
||||||
@@ -50,24 +48,17 @@ export default function AgentPage() {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.progress) setScanProgress(data.progress)
|
if (data.progress) setScanProgress(data.progress)
|
||||||
if (data.status === 'completed' && data.result) {
|
if (data.status === 'completed' && data.result) {
|
||||||
setScanData(data.result)
|
setScanData(data.result); setScanProgress(''); setScanLoading(false)
|
||||||
setScanProgress('')
|
|
||||||
setScanLoading(false)
|
|
||||||
localStorage.setItem('agent-scan-result', JSON.stringify(data.result))
|
localStorage.setItem('agent-scan-result', JSON.stringify(data.result))
|
||||||
localStorage.removeItem('agent-scan-id')
|
localStorage.removeItem('agent-scan-id'); setActiveScanId('')
|
||||||
setActiveScanId('')
|
_addToHistory(data.result); return
|
||||||
_addToHistory(data.result)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (data.status === 'failed' || data.status === 'not_found') {
|
if (data.status === 'failed' || data.status === 'not_found') {
|
||||||
if (data.status === 'failed') setScanError(data.error || 'Scan fehlgeschlagen')
|
if (data.status === 'failed') setScanError(data.error || 'Scan fehlgeschlagen')
|
||||||
setScanProgress('')
|
setScanProgress(''); setScanLoading(false)
|
||||||
setScanLoading(false)
|
localStorage.removeItem('agent-scan-id'); setActiveScanId(''); return
|
||||||
localStorage.removeItem('agent-scan-id')
|
|
||||||
setActiveScanId('')
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
} catch { /* retry */ }
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
poll()
|
poll()
|
||||||
@@ -77,37 +68,21 @@ export default function AgentPage() {
|
|||||||
const _addToHistory = (result: any) => {
|
const _addToHistory = (result: any) => {
|
||||||
const resultKey = `scan-result-${Date.now()}`
|
const resultKey = `scan-result-${Date.now()}`
|
||||||
try { localStorage.setItem(resultKey, JSON.stringify(result)) } catch {}
|
try { localStorage.setItem(resultKey, JSON.stringify(result)) } catch {}
|
||||||
const entry = {
|
const entry = { url: url || result.url || '', date: new Date().toISOString(), findings: result.findings?.length || 0, docs: result.discovered_documents?.length || 0, resultKey }
|
||||||
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)
|
const updated = [entry, ...scanHistory].slice(0, 30)
|
||||||
setScanHistory(updated)
|
setScanHistory(updated); localStorage.setItem('agent-scan-history', JSON.stringify(updated))
|
||||||
localStorage.setItem('agent-scan-history', JSON.stringify(updated))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScan = async (e: React.FormEvent) => {
|
const handleScan = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!url.trim()) return
|
if (!url.trim()) return
|
||||||
setScanLoading(true)
|
setScanLoading(true); setScanError(null); setScanData(null); setScanProgress('Scan wird gestartet...')
|
||||||
setScanError(null)
|
|
||||||
setScanData(null)
|
|
||||||
setScanProgress('Scan wird gestartet...')
|
|
||||||
try {
|
try {
|
||||||
const startRes = await fetch('/api/sdk/v1/agent/scan', {
|
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' }) })
|
||||||
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}`)
|
if (!startRes.ok) throw new Error(`Scan konnte nicht gestartet werden: ${startRes.status}`)
|
||||||
const { scan_id } = await startRes.json()
|
const { scan_id } = await startRes.json()
|
||||||
if (!scan_id) throw new Error('Keine Scan-ID erhalten')
|
if (!scan_id) throw new Error('Keine Scan-ID erhalten')
|
||||||
setActiveScanId(scan_id)
|
setActiveScanId(scan_id); localStorage.setItem('agent-scan-id', scan_id)
|
||||||
localStorage.setItem('agent-scan-id', scan_id)
|
|
||||||
|
|
||||||
let attempts = 0
|
let attempts = 0
|
||||||
while (attempts < 120) {
|
while (attempts < 120) {
|
||||||
await new Promise(r => setTimeout(r, 5000))
|
await new Promise(r => setTimeout(r, 5000))
|
||||||
@@ -116,41 +91,24 @@ export default function AgentPage() {
|
|||||||
const pollData = await pollRes.json()
|
const pollData = await pollRes.json()
|
||||||
if (pollData.progress) setScanProgress(pollData.progress)
|
if (pollData.progress) setScanProgress(pollData.progress)
|
||||||
if (pollData.status === 'completed' && pollData.result) {
|
if (pollData.status === 'completed' && pollData.result) {
|
||||||
setScanData(pollData.result)
|
setScanData(pollData.result); setScanProgress('')
|
||||||
setScanProgress('')
|
|
||||||
localStorage.setItem('agent-scan-result', JSON.stringify(pollData.result))
|
localStorage.setItem('agent-scan-result', JSON.stringify(pollData.result))
|
||||||
localStorage.removeItem('agent-scan-id')
|
localStorage.removeItem('agent-scan-id'); setActiveScanId(''); _addToHistory(pollData.result); break
|
||||||
setActiveScanId('')
|
|
||||||
_addToHistory(pollData.result)
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
if (pollData.status === 'failed') throw new Error(pollData.error || 'Scan fehlgeschlagen')
|
if (pollData.status === 'failed') throw new Error(pollData.error || 'Scan fehlgeschlagen')
|
||||||
attempts++
|
attempts++
|
||||||
}
|
}
|
||||||
if (attempts >= 120) throw new Error('Scan-Timeout (10 Minuten)')
|
if (attempts >= 120) throw new Error('Scan-Timeout (10 Minuten)')
|
||||||
} catch (e) {
|
} catch (e) { setScanError(e instanceof Error ? e.message : 'Unbekannter Fehler'); setScanProgress('') }
|
||||||
setScanError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
finally { setScanLoading(false) }
|
||||||
setScanProgress('')
|
|
||||||
} finally {
|
|
||||||
setScanLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to a specialized tab with a pre-filled URL
|
|
||||||
const navigateToCheck = (targetTab: AnalysisTab, checkUrl: string) => {
|
const navigateToCheck = (targetTab: AnalysisTab, checkUrl: string) => {
|
||||||
// Store the URL in the target tab's localStorage key
|
const keyMap: Record<string, string> = { 'doc-check': 'doc-check-prefill-url', 'banner-check': 'banner-check-url', 'impressum-check': 'impressum-check-url' }
|
||||||
const keyMap: Record<string, string> = {
|
if (keyMap[targetTab]) localStorage.setItem(keyMap[targetTab], checkUrl)
|
||||||
'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)
|
setTab(targetTab)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract discovered documents for quick-action buttons
|
|
||||||
const discoveredDocs = scanData?.discovered_documents || []
|
const discoveredDocs = scanData?.discovered_documents || []
|
||||||
const scannedUrl = scanData?.url || url
|
const scannedUrl = scanData?.url || url
|
||||||
|
|
||||||
@@ -161,122 +119,63 @@ export default function AgentPage() {
|
|||||||
<p className="text-gray-500 mt-1">Analysiere Webseiten und Dokumente auf DSGVO-Konformitaet.</p>
|
<p className="text-gray-500 mt-1">Analysiere Webseiten und Dokumente auf DSGVO-Konformitaet.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Selection */}
|
|
||||||
<div className="flex border-b border-gray-200 overflow-x-auto">
|
<div className="flex border-b border-gray-200 overflow-x-auto">
|
||||||
{TABS.map(t => (
|
{TABS.map(t => (
|
||||||
<button key={t.id} onClick={() => setTab(t.id)}
|
<button key={t.id} onClick={() => setTab(t.id)}
|
||||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||||
tab === t.id
|
tab === t.id ? 'border-purple-500 text-purple-700' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
||||||
? 'border-purple-500 text-purple-700'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
|
||||||
{t.label}
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Website-Scan Tab */}
|
|
||||||
{tab === 'scan' && (
|
{tab === 'scan' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-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>
|
<h3 className="text-sm font-semibold text-indigo-900">Website-Scan (Discovery)</h3>
|
||||||
<p className="text-xs text-indigo-700 mt-1">
|
<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>
|
||||||
Findet alle rechtlichen Dokumente (DSI, AGB, Impressum, Cookie, Widerruf),
|
|
||||||
erkennt eingesetzte Drittdienste und prueft ob sie in der DSE dokumentiert sind.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleScan} className="flex gap-3">
|
<form onSubmit={handleScan} className="flex gap-3">
|
||||||
<input type="url" value={url} onChange={e => setUrl(e.target.value)}
|
<input type="url" value={url} onChange={e => setUrl(e.target.value)} placeholder="https://www.example.com/"
|
||||||
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 />
|
||||||
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()}
|
<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">
|
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 ? (
|
{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'}
|
||||||
<><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>
|
</button>
|
||||||
</form>
|
</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>}
|
||||||
{scanProgress && (
|
{scanError && <div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{scanError}</div>}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quick Action Buttons — navigate to specialized tabs */}
|
|
||||||
{scanData && (
|
{scanData && (
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-4 shadow-sm">
|
<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>
|
<h4 className="text-sm font-semibold text-gray-800 mb-3">Jetzt pruefen</h4>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<button onClick={() => navigateToCheck('banner-check', scannedUrl)}
|
<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">
|
||||||
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-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>
|
<div className="text-xs text-gray-500 mt-0.5">3-Phasen Dark-Pattern-Analyse</div>
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => navigateToCheck('impressum-check', scannedUrl + '/impressum')}
|
<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">
|
||||||
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-sm font-medium text-gray-900">Impressum pruefen</div>
|
||||||
<div className="text-xs text-gray-500 mt-0.5">§5 TMG Pflichtangaben</div>
|
<div className="text-xs text-gray-500 mt-0.5">§5 TMG Pflichtangaben</div>
|
||||||
</button>
|
</button>
|
||||||
{discoveredDocs.map((doc: any, i: number) => (
|
{discoveredDocs.map((doc: any, i: number) => (
|
||||||
<button key={i} onClick={() => navigateToCheck('doc-check', doc.url)}
|
<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">
|
||||||
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-sm font-medium text-gray-900 truncate">{doc.title || doc.url}</div>
|
||||||
<div className="text-xs text-gray-500 mt-0.5">
|
<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>
|
||||||
{doc.doc_type?.toUpperCase()} · {doc.word_count || '?'} Woerter
|
|
||||||
{doc.completeness_pct != null && ` · ${doc.completeness_pct}%`}
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{scanData?.services && <div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm"><ScanResult data={scanData} /></div>}
|
||||||
{/* Full Scan Result */}
|
|
||||||
{scanData?.services && (
|
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
|
||||||
<ScanResult data={scanData} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Scan History */}
|
|
||||||
{scanHistory.length > 0 && (
|
{scanHistory.length > 0 && (
|
||||||
<div className="border border-gray-200 rounded-xl p-4">
|
<div className="border border-gray-200 rounded-xl p-4">
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Letzte Scans</h4>
|
<h4 className="text-sm font-medium text-gray-700 mb-3">Letzte Scans</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{scanHistory.map((h, i) => (
|
{scanHistory.map((h, i) => (
|
||||||
<button key={i} onClick={() => {
|
<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 {} } }}
|
||||||
setUrl(h.url)
|
|
||||||
if (h.resultKey) {
|
|
||||||
try { const s = localStorage.getItem(h.resultKey); if (s) { setScanData(JSON.parse(s)); return } } catch {}
|
|
||||||
}
|
|
||||||
try { const l = localStorage.getItem('agent-scan-result'); if (l) setScanData(JSON.parse(l)) } 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">
|
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="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="text-sm font-medium text-gray-900 truncate">{h.url}</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>
|
||||||
<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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -285,12 +184,9 @@ export default function AgentPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Specialized Tabs */}
|
{tab === 'compliance-check' && <ComplianceCheckTab />}
|
||||||
{tab === 'doc-check' && <DocCheckTab />}
|
|
||||||
{tab === 'banner-check' && <BannerCheckTab />}
|
{tab === 'banner-check' && <BannerCheckTab />}
|
||||||
{tab === 'impressum-check' && <ImpressumCheckTab />}
|
|
||||||
|
|
||||||
{/* FAQ */}
|
|
||||||
<ComplianceFAQ />
|
<ComplianceFAQ />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
id: string
|
||||||
|
entity_type: string
|
||||||
|
entity_id: string
|
||||||
|
entity_name: string
|
||||||
|
action: string
|
||||||
|
field_changed: string | null
|
||||||
|
old_value: string | null
|
||||||
|
new_value: string | null
|
||||||
|
change_summary: string | null
|
||||||
|
performed_by: string
|
||||||
|
performed_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuditTimeline() {
|
||||||
|
const [entries, setEntries] = useState<AuditEntry[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [filter, setFilter] = useState<string>('all')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadEntries()
|
||||||
|
}, [filter]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
async function loadEntries() {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ limit: '100' })
|
||||||
|
if (filter !== 'all') params.set('entity_type', filter)
|
||||||
|
const res = await fetch(`/api/sdk/v1/compliance/audit-trail?${params}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json()
|
||||||
|
setEntries(json.entries || json.audit_trail || json || [])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load audit trail:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { entries, loading, filter, setFilter }
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useAuditTimeline, type AuditEntry } from './_hooks/useAuditTimeline'
|
||||||
|
|
||||||
|
const ENTITY_LABELS: Record<string, string> = {
|
||||||
|
evidence: 'Nachweis', control: 'Control', document: 'Dokument',
|
||||||
|
dsfa: 'DSFA', vvt: 'VVT', tom: 'TOM', policy: 'Richtlinie',
|
||||||
|
dsms_archive: 'DSMS-Archiv', risk: 'Risiko',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_COLORS: Record<string, string> = {
|
||||||
|
create: 'bg-green-500', update: 'bg-blue-500', delete: 'bg-red-500',
|
||||||
|
approve: 'bg-purple-500', archive: 'bg-emerald-500', review: 'bg-yellow-500',
|
||||||
|
sign: 'bg-indigo-500', reject: 'bg-red-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTER_OPTIONS = ['all', 'evidence', 'dsms_archive', 'control', 'document', 'dsfa', 'vvt', 'tom']
|
||||||
|
|
||||||
|
export default function AuditTimelinePage() {
|
||||||
|
const { entries, loading, filter, setFilter } = useAuditTimeline()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Audit Timeline</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Chronologische Compliance-Historie mit DSMS-Nachweisen</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{FILTER_OPTIONS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => setFilter(f)}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||||
|
filter === f
|
||||||
|
? 'bg-purple-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f === 'all' ? 'Alle' : ENTITY_LABELS[f] || f}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600" />
|
||||||
|
</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<div className="text-center py-16 text-gray-500">
|
||||||
|
Keine Eintraege gefunden. Compliance-Aktionen werden automatisch protokolliert.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Timeline line */}
|
||||||
|
<div className="absolute left-6 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700" />
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<TimelineEntry key={entry.id} entry={entry} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineEntry({ entry }: { entry: AuditEntry }) {
|
||||||
|
const dotColor = ACTION_COLORS[entry.action] || 'bg-gray-400'
|
||||||
|
const isCID = entry.field_changed === 'dsms_cid' || entry.action === 'archive'
|
||||||
|
const date = new Date(entry.performed_at)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex gap-4 pl-3">
|
||||||
|
{/* Dot */}
|
||||||
|
<div className={`relative z-10 w-3 h-3 rounded-full mt-1.5 flex-shrink-0 ring-4 ring-white dark:ring-gray-900 ${dotColor}`} />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-white">{entry.entity_name}</span>
|
||||||
|
<span className="px-2 py-0.5 rounded text-[10px] font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
{ENTITY_LABELS[entry.entity_type] || entry.entity_type}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-0.5 rounded text-[10px] font-medium text-white ${dotColor}`}>
|
||||||
|
{entry.action}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{entry.change_summary && (
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">{entry.change_summary}</p>
|
||||||
|
)}
|
||||||
|
{isCID && entry.new_value && (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<svg className="w-3.5 h-3.5 text-emerald-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
<code className="text-[10px] bg-emerald-50 text-emerald-700 px-2 py-0.5 rounded font-mono dark:bg-emerald-900/30 dark:text-emerald-300">
|
||||||
|
{entry.new_value.length > 20 ? entry.new_value.slice(0, 8) + '...' + entry.new_value.slice(-6) : entry.new_value}
|
||||||
|
</code>
|
||||||
|
<span className="text-[10px] text-emerald-500">DSMS/IPFS</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right flex-shrink-0">
|
||||||
|
<div className="text-xs text-gray-400">{date.toLocaleDateString('de-DE')}</div>
|
||||||
|
<div className="text-[10px] text-gray-300">{date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</div>
|
||||||
|
<div className="text-[10px] text-gray-300 mt-0.5">{entry.performed_by}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -54,41 +54,27 @@ export default function CMPDashboardPage() {
|
|||||||
const [consentStats, setConsentStats] = useState<ConsentStats | null>(null)
|
const [consentStats, setConsentStats] = useState<ConsentStats | null>(null)
|
||||||
const [dsrStats, setDSRStats] = useState<DSRStats | null>(null)
|
const [dsrStats, setDSRStats] = useState<DSRStats | null>(null)
|
||||||
const [sites, setSites] = useState<any[]>([])
|
const [sites, setSites] = useState<any[]>([])
|
||||||
const [selectedSite, setSelectedSite] = useState<string>('')
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
const fb = (path: string) => fetch(`${BANNER_API}/${path}`, { headers: HEADERS }).then(r => r.ok ? r.json() : null).catch(() => null)
|
|
||||||
|
|
||||||
// Load sites + consent/dsr stats on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
|
const fb = (path: string) => fetch(`${BANNER_API}/${path}`, { headers: HEADERS }).then(r => r.ok ? r.json() : null).catch(() => null)
|
||||||
const fa = (path: string) => fetch(`/api/sdk/v1/compliance/${path}`, { headers: HEADERS }).then(r => r.ok ? r.json() : null).catch(() => null)
|
const fa = (path: string) => fetch(`/api/sdk/v1/compliance/${path}`, { headers: HEADERS }).then(r => r.ok ? r.json() : null).catch(() => null)
|
||||||
const [consent, dsr, siteList] = await Promise.all([
|
const [banner, consent, dsr, siteList] = await Promise.all([
|
||||||
|
fb('admin/stats/preview-test-site'),
|
||||||
fa('einwilligungen/consents/stats'),
|
fa('einwilligungen/consents/stats'),
|
||||||
fa('dsr/stats'),
|
fa('dsr/stats'),
|
||||||
fb('admin/sites'),
|
fb('admin/sites'),
|
||||||
])
|
])
|
||||||
|
setBannerStats(banner)
|
||||||
setConsentStats(consent)
|
setConsentStats(consent)
|
||||||
setDSRStats(dsr)
|
setDSRStats(dsr)
|
||||||
const loadedSites = Array.isArray(siteList) ? siteList : []
|
setSites(siteList || [])
|
||||||
setSites(loadedSites)
|
|
||||||
// Auto-select first site
|
|
||||||
if (loadedSites.length > 0) {
|
|
||||||
setSelectedSite(loadedSites[0].site_id || loadedSites[0].siteId || '')
|
|
||||||
}
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load banner stats when selected site changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedSite) return
|
|
||||||
fb(`admin/stats/${selectedSite}`).then(setBannerStats)
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [selectedSite])
|
|
||||||
|
|
||||||
const totalConsents = (bannerStats?.total_consents || 0) + (consentStats?.total_consents || 0)
|
const totalConsents = (bannerStats?.total_consents || 0) + (consentStats?.total_consents || 0)
|
||||||
const dsrOpen = dsrStats ? (dsrStats.by_status?.intake || 0) + (dsrStats.by_status?.processing || 0) + (dsrStats.by_status?.identity_verification || 0) : 0
|
const dsrOpen = dsrStats ? (dsrStats.by_status?.intake || 0) + (dsrStats.by_status?.processing || 0) + (dsrStats.by_status?.identity_verification || 0) : 0
|
||||||
const dsrOverdue = dsrStats?.overdue || 0
|
const dsrOverdue = dsrStats?.overdue || 0
|
||||||
@@ -100,28 +86,13 @@ export default function CMPDashboardPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Consent Management Platform</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Consent Management Platform</h1>
|
||||||
<p className="text-gray-500 mt-1">Überblick über Einwilligungen, Betroffenenrechte und Vendor-Compliance</p>
|
<p className="text-gray-500 mt-1">Ueberblick ueber Einwilligungen, Betroffenenrechte und Vendor-Compliance</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{sites.length > 0 && (
|
|
||||||
<select
|
|
||||||
value={selectedSite}
|
|
||||||
onChange={e => setSelectedSite(e.target.value)}
|
|
||||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
|
||||||
>
|
|
||||||
{sites.map((s: any) => (
|
|
||||||
<option key={s.site_id || s.siteId} value={s.site_id || s.siteId}>
|
|
||||||
{s.site_name || s.siteName || s.site_id || s.siteId}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
<Link href="/sdk/cookie-banner/preview"
|
<Link href="/sdk/cookie-banner/preview"
|
||||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 transition-colors">
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 transition-colors">
|
||||||
Banner testen
|
Banner testen
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* KPI Cards */}
|
{/* KPI Cards */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
@@ -203,6 +174,44 @@ export default function CMPDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Banner-Bedarf Hinweis (TTDSG § 25) */}
|
||||||
|
{bannerStats && Object.keys(bannerStats.category_acceptance).length === 0 && sites.length === 0 && (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-xl p-5 flex items-start gap-4">
|
||||||
|
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-green-800">Kein Cookie-Banner erforderlich</h3>
|
||||||
|
<p className="text-sm text-green-700 mt-1">
|
||||||
|
Es wurden keine Cookies, Tracker oder Analytics-Dienste erkannt. Gemaess TTDSG § 25 ist kein
|
||||||
|
Cookie-Banner erforderlich, da keine Informationen auf dem Endgeraet gespeichert werden.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-green-600 mt-2">
|
||||||
|
<strong>Weiterhin Pflicht:</strong> Impressum (DDG § 5) und Datenschutzerklaerung (DSGVO Art. 13)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Banner-Warnung wenn Tracker ohne Banner */}
|
||||||
|
{bannerStats && Object.keys(bannerStats.category_acceptance).length > 0 && sites.length === 0 && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-xl p-5 flex items-start gap-4">
|
||||||
|
<div className="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-red-800">Cookie-Banner fehlt!</h3>
|
||||||
|
<p className="text-sm text-red-700 mt-1">
|
||||||
|
Es wurden Tracking-Dienste erkannt, aber kein Cookie-Banner ist konfiguriert.
|
||||||
|
Gemaess TTDSG § 25 ist eine Einwilligung erforderlich.
|
||||||
|
</p>
|
||||||
|
<Link href="/sdk/cookie-banner" className="inline-block mt-2 text-sm text-red-700 font-medium underline">
|
||||||
|
Jetzt Cookie-Banner einrichten
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Compliance Status */}
|
{/* Compliance Status */}
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
<h3 className="font-semibold text-gray-900 mb-1">Compliance-Status</h3>
|
<h3 className="font-semibold text-gray-900 mb-1">Compliance-Status</h3>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { COMPANY_PROFILE_PRESETS, type CompanyProfilePreset } from '@/lib/sdk/company-profile-presets'
|
||||||
|
|
||||||
|
interface PresetSelectorProps {
|
||||||
|
onSelect: (preset: CompanyProfilePreset) => void
|
||||||
|
onSkip: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PresetSelector({ onSelect, onSkip }: PresetSelectorProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">Welcher Unternehmenstyp passt zu Ihnen?</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
|
Waehlen Sie eine Vorlage fuer Ihre Branche — alle Felder werden vorbefuellt
|
||||||
|
und Sie koennen anschliessend anpassen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||||
|
{COMPANY_PROFILE_PRESETS.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.id}
|
||||||
|
onClick={() => onSelect(preset)}
|
||||||
|
className="flex flex-col items-center gap-2 p-4 bg-white border border-gray-200 rounded-xl hover:border-purple-400 hover:shadow-md transition-all text-center group"
|
||||||
|
>
|
||||||
|
<span className="text-3xl">{preset.icon}</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900 group-hover:text-purple-700">
|
||||||
|
{preset.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 leading-tight">
|
||||||
|
{preset.description}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
onClick={onSkip}
|
||||||
|
className="text-sm text-gray-400 hover:text-gray-600 underline"
|
||||||
|
>
|
||||||
|
Manuell ausfuellen (ohne Vorlage)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -78,6 +78,14 @@ export default function ComplianceScopePage() {
|
|||||||
const [supervisoryAuthorities, setSupervisoryAuthorities] = useState<SupervisoryAuthorityInfo[]>([])
|
const [supervisoryAuthorities, setSupervisoryAuthorities] = useState<SupervisoryAuthorityInfo[]>([])
|
||||||
const [regulationAssessmentLoading, setRegulationAssessmentLoading] = useState(false)
|
const [regulationAssessmentLoading, setRegulationAssessmentLoading] = useState(false)
|
||||||
|
|
||||||
|
// Enabled compliance modules (derived from applicable regulations)
|
||||||
|
const [enabledModules, setEnabledModules] = useState<string[]>([])
|
||||||
|
|
||||||
|
// Auto-enable all applicable regulations when they load
|
||||||
|
const handleToggleModule = (moduleId: string, enabled: boolean) => {
|
||||||
|
setEnabledModules(prev => enabled ? [...prev, moduleId] : prev.filter(id => id !== moduleId))
|
||||||
|
}
|
||||||
|
|
||||||
// Sync from SDK context when it becomes available (handles async loading).
|
// Sync from SDK context when it becomes available (handles async loading).
|
||||||
// The SDK context loads state from server/localStorage asynchronously, so
|
// The SDK context loads state from server/localStorage asynchronously, so
|
||||||
// sdkState.complianceScope may arrive AFTER this page has already mounted.
|
// sdkState.complianceScope may arrive AFTER this page has already mounted.
|
||||||
@@ -159,6 +167,10 @@ export default function ComplianceScopePage() {
|
|||||||
// Set applicable regulations from response
|
// Set applicable regulations from response
|
||||||
const regs: ApplicableRegulation[] = data.overview?.applicable_regulations || data.applicable_regulations || []
|
const regs: ApplicableRegulation[] = data.overview?.applicable_regulations || data.applicable_regulations || []
|
||||||
setApplicableRegulations(regs)
|
setApplicableRegulations(regs)
|
||||||
|
// Auto-enable all applicable regulations as modules
|
||||||
|
if (enabledModules.length === 0) {
|
||||||
|
setEnabledModules(regs.map(r => r.id))
|
||||||
|
}
|
||||||
|
|
||||||
// Derive supervisory authorities
|
// Derive supervisory authorities
|
||||||
const regIds = regs.map(r => r.id)
|
const regIds = regs.map(r => r.id)
|
||||||
@@ -375,6 +387,8 @@ export default function ComplianceScopePage() {
|
|||||||
supervisoryAuthorities={supervisoryAuthorities}
|
supervisoryAuthorities={supervisoryAuthorities}
|
||||||
regulationAssessmentLoading={regulationAssessmentLoading}
|
regulationAssessmentLoading={regulationAssessmentLoading}
|
||||||
onGoToObligations={() => { window.location.href = '/sdk/obligations' }}
|
onGoToObligations={() => { window.location.href = '/sdk/obligations' }}
|
||||||
|
enabledModules={enabledModules}
|
||||||
|
onToggleModule={handleToggleModule}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -141,16 +141,24 @@ export default function ConsentManagementPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'emails' && (
|
{activeTab === 'emails' && (
|
||||||
<EmailsTab
|
<div className="bg-purple-50 border border-purple-200 rounded-xl p-8 text-center">
|
||||||
apiEmailTemplates={apiEmailTemplates}
|
<div className="w-14 h-14 mx-auto mb-4 bg-purple-100 rounded-xl flex items-center justify-center">
|
||||||
templatesLoading={templatesLoading}
|
<svg className="w-7 h-7 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
savingTemplateId={savingTemplateId}
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
savedTemplates={savedTemplates}
|
</svg>
|
||||||
setShowCreateTemplateModal={setShowCreateTemplateModal}
|
</div>
|
||||||
saveApiEmailTemplate={saveApiEmailTemplate}
|
<h3 className="font-semibold text-gray-900 mb-2">E-Mail-Templates wurden zentralisiert</h3>
|
||||||
setPreviewTemplate={setPreviewTemplate}
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
setEditingTemplate={setEditingTemplate}
|
Alle E-Mail-Vorlagen (DSR, Consent, Breach, Training, etc.) werden jetzt zentral
|
||||||
/>
|
im E-Mail-Template-Modul verwaltet — mit Versionierung, Freigabe-Workflow und Audit-Log.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/sdk/email-templates')}
|
||||||
|
className="px-6 py-2.5 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 transition-colors"
|
||||||
|
>
|
||||||
|
Zu E-Mail-Templates
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'gdpr' && (
|
{activeTab === 'gdpr' && (
|
||||||
|
|||||||
@@ -212,14 +212,14 @@ export function ControlDetail({
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{ctrl.requirements.length > 0 && (
|
{Array.isArray(ctrl.requirements) && ctrl.requirements.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
|
||||||
<ol className="list-decimal list-inside space-y-1">{ctrl.requirements.map((r, i) => <li key={i} className="text-sm text-gray-700">{r}</li>)}</ol>
|
<ol className="list-decimal list-inside space-y-1">{ctrl.requirements.map((r, i) => <li key={i} className="text-sm text-gray-700">{r}</li>)}</ol>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ctrl.test_procedure.length > 0 && (
|
{Array.isArray(ctrl.test_procedure) && ctrl.test_procedure.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
|
||||||
<ol className="list-decimal list-inside space-y-1">{ctrl.test_procedure.map((s, i) => <li key={i} className="text-sm text-gray-700">{s}</li>)}</ol>
|
<ol className="list-decimal list-inside space-y-1">{ctrl.test_procedure.map((s, i) => <li key={i} className="text-sm text-gray-700">{s}</li>)}</ol>
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ export interface ControlsMeta {
|
|||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
export function useControlLibraryState() {
|
export function useControlLibraryState(backendUrlOverride?: string) {
|
||||||
|
const backendUrl = backendUrlOverride || BACKEND_URL
|
||||||
const [frameworks, setFrameworks] = useState<Framework[]>([])
|
const [frameworks, setFrameworks] = useState<Framework[]>([])
|
||||||
const [controls, setControls] = useState<CanonicalControl[]>([])
|
const [controls, setControls] = useState<CanonicalControl[]>([])
|
||||||
const [totalCount, setTotalCount] = useState(0)
|
const [totalCount, setTotalCount] = useState(0)
|
||||||
@@ -100,7 +101,7 @@ export function useControlLibraryState() {
|
|||||||
|
|
||||||
const loadFrameworks = useCallback(async () => {
|
const loadFrameworks = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BACKEND_URL}?endpoint=frameworks`)
|
const res = await fetch(`${backendUrl}?endpoint=frameworks`)
|
||||||
if (res.ok) setFrameworks(await res.json())
|
if (res.ok) setFrameworks(await res.json())
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}, [])
|
}, [])
|
||||||
@@ -111,7 +112,7 @@ export function useControlLibraryState() {
|
|||||||
metaAbortRef.current = controller
|
metaAbortRef.current = controller
|
||||||
try {
|
try {
|
||||||
const qs = buildParams()
|
const qs = buildParams()
|
||||||
const res = await fetch(`${BACKEND_URL}?endpoint=controls-meta${qs ? `&${qs}` : ''}`, { signal: controller.signal })
|
const res = await fetch(`${backendUrl}?endpoint=controls-meta${qs ? `&${qs}` : ''}`, { signal: controller.signal })
|
||||||
if (res.ok && !controller.signal.aborted) setMeta(await res.json())
|
if (res.ok && !controller.signal.aborted) setMeta(await res.json())
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof DOMException && e.name === 'AbortError') return
|
if (e instanceof DOMException && e.name === 'AbortError') return
|
||||||
@@ -130,8 +131,8 @@ export function useControlLibraryState() {
|
|||||||
const qs = buildParams({ sort: sortField, order: sortOrder, limit: String(PAGE_SIZE), offset: String(offset) })
|
const qs = buildParams({ sort: sortField, order: sortOrder, limit: String(PAGE_SIZE), offset: String(offset) })
|
||||||
const countQs = buildParams()
|
const countQs = buildParams()
|
||||||
const [ctrlRes, countRes] = await Promise.all([
|
const [ctrlRes, countRes] = await Promise.all([
|
||||||
fetch(`${BACKEND_URL}?endpoint=controls&${qs}`, { signal: controller.signal }),
|
fetch(`${backendUrl}?endpoint=controls&${qs}`, { signal: controller.signal }),
|
||||||
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`, { signal: controller.signal }),
|
fetch(`${backendUrl}?endpoint=controls-count&${countQs}`, { signal: controller.signal }),
|
||||||
])
|
])
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
if (ctrlRes.ok) setControls(await ctrlRes.json())
|
if (ctrlRes.ok) setControls(await ctrlRes.json())
|
||||||
@@ -147,7 +148,7 @@ export function useControlLibraryState() {
|
|||||||
|
|
||||||
const loadReviewCount = useCallback(async () => {
|
const loadReviewCount = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BACKEND_URL}?endpoint=controls-count&release_state=needs_review`)
|
const res = await fetch(`${backendUrl}?endpoint=controls-count&release_state=needs_review`)
|
||||||
if (res.ok) { const data = await res.json(); setReviewCount(data.total || 0) }
|
if (res.ok) { const data = await res.json(); setReviewCount(data.total || 0) }
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}, [])
|
}, [])
|
||||||
@@ -165,14 +166,14 @@ export function useControlLibraryState() {
|
|||||||
|
|
||||||
const loadProcessedStats = async () => {
|
const loadProcessedStats = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BACKEND_URL}?endpoint=processed-stats`)
|
const res = await fetch(`${backendUrl}?endpoint=processed-stats`)
|
||||||
if (res.ok) { const data = await res.json(); setProcessedStats(data.stats || []) }
|
if (res.ok) { const data = await res.json(); setProcessedStats(data.stats || []) }
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const enterReviewMode = async () => {
|
const enterReviewMode = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BACKEND_URL}?endpoint=controls&release_state=needs_review&limit=1000`)
|
const res = await fetch(`${backendUrl}?endpoint=controls&release_state=needs_review&limit=1000`)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const items: CanonicalControl[] = await res.json()
|
const items: CanonicalControl[] = await res.json()
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
interface Variant {
|
||||||
|
id: string
|
||||||
|
variant_name: string
|
||||||
|
variant_key: string
|
||||||
|
traffic_percent: number
|
||||||
|
is_control: boolean
|
||||||
|
banner_title: string | null
|
||||||
|
banner_description: string | null
|
||||||
|
position: string | null
|
||||||
|
primary_color: string | null
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VariantStat {
|
||||||
|
variant_id: string
|
||||||
|
variant_key: string
|
||||||
|
variant_name: string
|
||||||
|
traffic_percent: number
|
||||||
|
is_control: boolean
|
||||||
|
total: number
|
||||||
|
accepted: number
|
||||||
|
opt_in_rate: number
|
||||||
|
is_winner?: boolean
|
||||||
|
significance?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const API = '/api/sdk/v1/compliance/banner/ab'
|
||||||
|
|
||||||
|
export function ABTestPanel({ siteConfigId }: { siteConfigId?: string }) {
|
||||||
|
const [variants, setVariants] = useState<Variant[]>([])
|
||||||
|
const [stats, setStats] = useState<VariantStat[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [newVariant, setNewVariant] = useState({ variant_name: '', variant_key: 'B', traffic_percent: 50, banner_title: '', primary_color: '' })
|
||||||
|
|
||||||
|
const scid = siteConfigId || ''
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (!scid) { setLoading(false); return }
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [v, s] = await Promise.all([
|
||||||
|
fetch(`${API}/${scid}/variants`).then(r => r.ok ? r.json() : []),
|
||||||
|
fetch(`${API}/${scid}/stats`).then(r => r.ok ? r.json() : []),
|
||||||
|
])
|
||||||
|
setVariants(v)
|
||||||
|
setStats(s)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setLoading(false)
|
||||||
|
}, [scid])
|
||||||
|
|
||||||
|
useEffect(() => { loadData() }, [loadData])
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!scid || !newVariant.variant_name) return
|
||||||
|
await fetch(`${API}/${scid}/variants`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(newVariant),
|
||||||
|
})
|
||||||
|
setShowCreate(false)
|
||||||
|
setNewVariant({ variant_name: '', variant_key: 'B', traffic_percent: 50, banner_title: '', primary_color: '' })
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
await fetch(`${API}/variants/${id}`, { method: 'DELETE' })
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTrafficChange = async (id: string, pct: number) => {
|
||||||
|
await fetch(`${API}/variants/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ traffic_percent: pct }),
|
||||||
|
})
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scid) {
|
||||||
|
return <div className="text-center py-8 text-gray-400">Bitte waehlen Sie zuerst eine Site aus.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="text-center py-8 text-gray-400">Lade A/B-Test...</div>
|
||||||
|
|
||||||
|
const maxRate = Math.max(...stats.map(s => s.opt_in_rate), 1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">A/B-Test Varianten</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Testen Sie verschiedene Banner-Konfigurationen um die Opt-In-Rate zu optimieren.</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowCreate(!showCreate)}
|
||||||
|
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||||
|
+ Variante erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Form */}
|
||||||
|
{showCreate && (
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-xl p-4 space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<input value={newVariant.variant_name} onChange={e => setNewVariant({ ...newVariant, variant_name: e.target.value })}
|
||||||
|
placeholder="Name (z.B. Variante B)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
|
||||||
|
<input value={newVariant.variant_key} onChange={e => setNewVariant({ ...newVariant, variant_key: e.target.value })}
|
||||||
|
placeholder="Key (z.B. B)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
|
||||||
|
<input value={newVariant.banner_title} onChange={e => setNewVariant({ ...newVariant, banner_title: e.target.value })}
|
||||||
|
placeholder="Banner-Titel (Override)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
|
||||||
|
<input value={newVariant.primary_color} onChange={e => setNewVariant({ ...newVariant, primary_color: e.target.value })}
|
||||||
|
placeholder="Farbe (z.B. #22c55e)" type="color" className="px-3 py-2 h-10 text-sm border border-gray-200 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-sm text-gray-600">Traffic:</label>
|
||||||
|
<input type="range" min={5} max={95} value={newVariant.traffic_percent}
|
||||||
|
onChange={e => setNewVariant({ ...newVariant, traffic_percent: parseInt(e.target.value) })}
|
||||||
|
className="flex-1" />
|
||||||
|
<span className="text-sm font-medium w-12 text-right">{newVariant.traffic_percent}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={handleCreate} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">Erstellen</button>
|
||||||
|
<button onClick={() => setShowCreate(false)} className="px-4 py-2 text-sm text-gray-500 hover:bg-gray-100 rounded-lg">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Variants + Stats */}
|
||||||
|
{variants.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-white border border-gray-200 rounded-xl">
|
||||||
|
<p className="text-gray-400">Kein A/B-Test aktiv.</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">Erstellen Sie mindestens 2 Varianten um einen Test zu starten.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Comparison Chart */}
|
||||||
|
{stats.length > 0 && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||||
|
<h4 className="font-medium text-gray-900 mb-4">Opt-In-Rate Vergleich</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{stats.map(s => (
|
||||||
|
<div key={s.variant_key} className="flex items-center gap-4">
|
||||||
|
<div className="w-24 text-sm text-gray-700 truncate">
|
||||||
|
{s.variant_name}
|
||||||
|
{s.is_control && <span className="ml-1 text-[10px] text-gray-400">(Kontrolle)</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 h-8 bg-gray-100 rounded-lg overflow-hidden relative">
|
||||||
|
<div className={`h-full rounded-lg transition-all ${s.is_winner ? 'bg-green-500' : s.is_control ? 'bg-gray-400' : 'bg-purple-500'}`}
|
||||||
|
style={{ width: `${(s.opt_in_rate / maxRate) * 100}%` }} />
|
||||||
|
<span className="absolute inset-0 flex items-center px-3 text-xs font-medium text-gray-900">
|
||||||
|
{s.opt_in_rate}% ({s.accepted}/{s.total})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{s.is_winner && (
|
||||||
|
<span className="px-2 py-0.5 text-[10px] font-medium bg-green-100 text-green-700 rounded-full">
|
||||||
|
Gewinner ({s.significance}%)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Variant Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{variants.map(v => (
|
||||||
|
<div key={v.id} className={`bg-white border rounded-xl p-4 ${v.is_control ? 'border-gray-300' : 'border-purple-200'}`}>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm text-gray-900">{v.variant_name}</span>
|
||||||
|
<span className="px-1.5 py-0.5 text-[10px] bg-gray-100 text-gray-600 rounded">{v.variant_key}</span>
|
||||||
|
{v.is_control && <span className="px-1.5 py-0.5 text-[10px] bg-blue-50 text-blue-600 rounded">Kontrolle</span>}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => handleDelete(v.id)} className="text-xs text-red-500 hover:text-red-700">Loeschen</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<label className="text-xs text-gray-500">Traffic:</label>
|
||||||
|
<input type="range" min={5} max={95} value={v.traffic_percent}
|
||||||
|
onChange={e => handleTrafficChange(v.id, parseInt(e.target.value))}
|
||||||
|
className="flex-1 h-1" />
|
||||||
|
<span className="text-xs font-medium w-8 text-right">{v.traffic_percent}%</span>
|
||||||
|
</div>
|
||||||
|
{v.banner_title && <div className="text-xs text-gray-500">Titel: {v.banner_title}</div>}
|
||||||
|
{v.primary_color && (
|
||||||
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
<div className="w-3 h-3 rounded-full border" style={{ backgroundColor: v.primary_color }} />
|
||||||
|
<span className="text-xs text-gray-500">{v.primary_color}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface TimeSeriesPoint {
|
||||||
|
period: string
|
||||||
|
given: number
|
||||||
|
updated: number
|
||||||
|
withdrawn: number
|
||||||
|
total: number
|
||||||
|
opt_in_rate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryStats {
|
||||||
|
[key: string]: { count: number; total: number; rate: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceStats {
|
||||||
|
desktop: number
|
||||||
|
mobile: number
|
||||||
|
tablet: number
|
||||||
|
unknown: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverviewStats {
|
||||||
|
period_days: number
|
||||||
|
total_interactions: number
|
||||||
|
consents_given: number
|
||||||
|
consents_updated: number
|
||||||
|
consents_withdrawn: number
|
||||||
|
opt_in_rate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERIODS = [
|
||||||
|
{ value: 7, label: '7 Tage' },
|
||||||
|
{ value: 30, label: '30 Tage' },
|
||||||
|
{ value: 90, label: '90 Tage' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CAT_COLORS: Record<string, string> = {
|
||||||
|
necessary: '#22c55e',
|
||||||
|
statistics: '#eab308',
|
||||||
|
marketing: '#ef4444',
|
||||||
|
functional: '#3b82f6',
|
||||||
|
preferences: '#8b5cf6',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalyticsDashboard({ siteId }: { siteId?: string }) {
|
||||||
|
const [days, setDays] = useState(30)
|
||||||
|
const [overview, setOverview] = useState<OverviewStats | null>(null)
|
||||||
|
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([])
|
||||||
|
const [categories, setCategories] = useState<CategoryStats>({})
|
||||||
|
const [devices, setDevices] = useState<DeviceStats>({ desktop: 0, mobile: 0, tablet: 0, unknown: 0 })
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const sid = siteId || 'preview-test-site'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
|
const base = `/api/sdk/v1/compliance/banner/analytics/${sid}`
|
||||||
|
Promise.all([
|
||||||
|
fetch(`${base}/overview?days=${days}`).then(r => r.ok ? r.json() : null),
|
||||||
|
fetch(`${base}/time-series?days=${days}&period=daily`).then(r => r.ok ? r.json() : []),
|
||||||
|
fetch(`${base}/categories?days=${days}`).then(r => r.ok ? r.json() : {}),
|
||||||
|
fetch(`${base}/devices?days=${days}`).then(r => r.ok ? r.json() : {}),
|
||||||
|
]).then(([o, ts, cats, devs]) => {
|
||||||
|
setOverview(o)
|
||||||
|
setTimeSeries(ts || [])
|
||||||
|
setCategories(cats || {})
|
||||||
|
setDevices(devs || { desktop: 0, mobile: 0, tablet: 0, unknown: 0 })
|
||||||
|
}).catch(() => {}).finally(() => setLoading(false))
|
||||||
|
}, [sid, days])
|
||||||
|
|
||||||
|
const deviceTotal = devices.desktop + devices.mobile + devices.tablet + devices.unknown
|
||||||
|
|
||||||
|
if (loading) return <div className="text-center py-12 text-gray-400">Lade Analytik...</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Period Selector */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{PERIODS.map(p => (
|
||||||
|
<button key={p.value} onClick={() => setDays(p.value)}
|
||||||
|
className={`px-3 py-1.5 text-xs rounded-full border transition-colors ${
|
||||||
|
days === p.value ? 'bg-purple-100 border-purple-300 text-purple-700' : 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'
|
||||||
|
}`}>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview KPIs */}
|
||||||
|
{overview && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div className="text-xs text-gray-500">Opt-In-Rate</div>
|
||||||
|
<div className="text-2xl font-bold text-green-600">{overview.opt_in_rate}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div className="text-xs text-gray-500">Einwilligungen</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900">{overview.consents_given}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div className="text-xs text-gray-500">Aktualisiert</div>
|
||||||
|
<div className="text-2xl font-bold text-blue-600">{overview.consents_updated}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div className="text-xs text-gray-500">Widerrufen</div>
|
||||||
|
<div className="text-2xl font-bold text-red-600">{overview.consents_withdrawn}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Time Series (simple bar visualization) */}
|
||||||
|
{timeSeries.length > 0 && (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Opt-In-Rate im Zeitverlauf</h3>
|
||||||
|
<div className="flex items-end gap-1 h-32">
|
||||||
|
{timeSeries.map((pt, i) => {
|
||||||
|
const height = Math.max(pt.opt_in_rate, 2)
|
||||||
|
const date = new Date(pt.period)
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex-1 flex flex-col items-center gap-1 group relative">
|
||||||
|
<div className="w-full bg-purple-500 rounded-t transition-all hover:bg-purple-600"
|
||||||
|
style={{ height: `${height}%` }}
|
||||||
|
title={`${date.toLocaleDateString('de-DE')}: ${pt.opt_in_rate}% (${pt.total} Interaktionen)`}
|
||||||
|
/>
|
||||||
|
{i % Math.max(1, Math.floor(timeSeries.length / 6)) === 0 && (
|
||||||
|
<span className="text-[8px] text-gray-400">{date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Category Acceptance */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Akzeptanz nach Kategorie</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(categories).map(([cat, stats]) => (
|
||||||
|
<div key={cat}>
|
||||||
|
<div className="flex items-center justify-between text-sm mb-1">
|
||||||
|
<span className="text-gray-700 capitalize">{cat}</span>
|
||||||
|
<span className="font-medium text-gray-900">{stats.rate}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full rounded-full transition-all" style={{ width: `${stats.rate}%`, backgroundColor: CAT_COLORS[cat] || '#9ca3af' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{Object.keys(categories).length === 0 && (
|
||||||
|
<p className="text-xs text-gray-400">Noch keine Daten vorhanden</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device Breakdown */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Geraete-Verteilung</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[
|
||||||
|
{ key: 'desktop', label: 'Desktop', color: 'bg-blue-500' },
|
||||||
|
{ key: 'mobile', label: 'Mobile', color: 'bg-green-500' },
|
||||||
|
{ key: 'tablet', label: 'Tablet', color: 'bg-purple-500' },
|
||||||
|
].map(d => {
|
||||||
|
const count = devices[d.key as keyof DeviceStats]
|
||||||
|
const pct = deviceTotal > 0 ? Math.round(count / deviceTotal * 100) : 0
|
||||||
|
return (
|
||||||
|
<div key={d.key}>
|
||||||
|
<div className="flex items-center justify-between text-sm mb-1">
|
||||||
|
<span className="text-gray-700">{d.label}</span>
|
||||||
|
<span className="font-medium text-gray-900">{pct}% ({count})</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full rounded-full ${d.color}`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{deviceTotal === 0 && (
|
||||||
|
<p className="text-xs text-gray-400">Noch keine Geraetedaten vorhanden</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface Vendor {
|
||||||
|
vendor_name: string
|
||||||
|
vendor_url: string | null
|
||||||
|
category_key: string
|
||||||
|
description_de: string | null
|
||||||
|
cookie_names: string[]
|
||||||
|
retention_days: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const CAT_LABELS: Record<string, string> = {
|
||||||
|
necessary: 'Notwendig',
|
||||||
|
functional: 'Funktional',
|
||||||
|
statistics: 'Statistik',
|
||||||
|
marketing: 'Marketing',
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateHTML(vendors: Vendor[]): string {
|
||||||
|
const grouped = vendors.reduce<Record<string, Vendor[]>>((acc, v) => {
|
||||||
|
const key = v.category_key || 'other'
|
||||||
|
if (!acc[key]) acc[key] = []
|
||||||
|
acc[key].push(v)
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
let html = `<div style="font-family:system-ui,sans-serif;font-size:14px;color:#1f2937;">\n`
|
||||||
|
html += `<h3 style="margin:0 0 12px;font-size:16px;">Eingesetzte Dienste und Cookies</h3>\n`
|
||||||
|
|
||||||
|
for (const [catKey, catVendors] of Object.entries(grouped)) {
|
||||||
|
const label = CAT_LABELS[catKey] || catKey
|
||||||
|
html += `<h4 style="margin:16px 0 8px;font-size:14px;color:#6b21a8;">${label}</h4>\n`
|
||||||
|
html += `<table style="width:100%;border-collapse:collapse;margin-bottom:12px;font-size:13px;">\n`
|
||||||
|
html += `<tr style="background:#f9fafb;"><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Anbieter</th><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Zweck</th><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Cookies</th><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Speicherdauer</th></tr>\n`
|
||||||
|
|
||||||
|
for (const v of catVendors) {
|
||||||
|
const name = v.vendor_url
|
||||||
|
? `<a href="${v.vendor_url}" target="_blank" rel="noopener">${v.vendor_name}</a>`
|
||||||
|
: v.vendor_name
|
||||||
|
const cookies = v.cookie_names?.join(', ') || '-'
|
||||||
|
const retention = v.retention_days ? `${v.retention_days} Tage` : '-'
|
||||||
|
html += `<tr><td style="padding:6px 8px;border:1px solid #e5e7eb;">${name}</td><td style="padding:6px 8px;border:1px solid #e5e7eb;">${v.description_de || '-'}</td><td style="padding:6px 8px;border:1px solid #e5e7eb;font-family:monospace;font-size:11px;">${cookies}</td><td style="padding:6px 8px;border:1px solid #e5e7eb;">${retention}</td></tr>\n`
|
||||||
|
}
|
||||||
|
html += `</table>\n`
|
||||||
|
}
|
||||||
|
html += `</div>`
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmbeddableVendorHTML({ siteId }: { siteId?: string }) {
|
||||||
|
const [vendors, setVendors] = useState<Vendor[]>([])
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sid = siteId || 'preview-test-site'
|
||||||
|
fetch(`/api/sdk/v1/banner/admin/sites/${sid}/vendors`)
|
||||||
|
.then(r => r.ok ? r.json() : [])
|
||||||
|
.then(data => setVendors(Array.isArray(data) ? data : []))
|
||||||
|
.catch(() => {})
|
||||||
|
}, [siteId])
|
||||||
|
|
||||||
|
const html = generateHTML(vendors)
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
navigator.clipboard.writeText(html)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">Einbettbarer HTML-Code</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Kopieren Sie diesen Code in Ihre Datenschutzerklaerung oder Cookie-Richtlinie.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleCopy}
|
||||||
|
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||||
|
{copied ? 'Kopiert!' : 'HTML kopieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="border border-gray-200 rounded-lg p-4 bg-white">
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Raw HTML */}
|
||||||
|
<details className="group">
|
||||||
|
<summary className="text-xs text-gray-500 cursor-pointer hover:text-gray-700">
|
||||||
|
Quellcode anzeigen
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 p-3 bg-gray-50 border border-gray-200 rounded-lg text-xs text-gray-700 overflow-x-auto max-h-[300px] overflow-y-auto">
|
||||||
|
{html}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface Site {
|
||||||
|
id: string
|
||||||
|
site_id: string
|
||||||
|
site_name: string
|
||||||
|
site_url: string
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SiteSelectorProps {
|
||||||
|
sites: Site[]
|
||||||
|
activeSiteId: string | null
|
||||||
|
onSiteChange: (siteId: string) => void
|
||||||
|
onCreateSite: (data: { site_id: string; site_name: string; site_url: string }) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SiteSelector({ sites, activeSiteId, onSiteChange, onCreateSite }: SiteSelectorProps) {
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [newSite, setNewSite] = useState({ site_id: '', site_name: '', site_url: '' })
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!newSite.site_id || !newSite.site_name) return
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
await onCreateSite(newSite)
|
||||||
|
setNewSite({ site_id: '', site_name: '', site_url: '' })
|
||||||
|
setShowCreate(false)
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-xs font-medium text-gray-500 mb-1">Website / Domain</label>
|
||||||
|
<select value={activeSiteId || ''} onChange={e => onSiteChange(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:ring-1 focus:ring-purple-500 bg-white">
|
||||||
|
{sites.length === 0 && <option value="">Keine Sites konfiguriert</option>}
|
||||||
|
{sites.map(s => (
|
||||||
|
<option key={s.site_id} value={s.site_id}>
|
||||||
|
{s.site_name} ({s.site_url || s.site_id})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowCreate(!showCreate)}
|
||||||
|
className="mt-5 px-3 py-2 text-sm bg-purple-50 text-purple-600 border border-purple-200 rounded-lg hover:bg-purple-100">
|
||||||
|
+ Neue Seite
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-100 grid grid-cols-3 gap-3">
|
||||||
|
<input value={newSite.site_id} onChange={e => setNewSite({ ...newSite, site_id: e.target.value })}
|
||||||
|
placeholder="Site-ID (z.B. main-website)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
|
||||||
|
<input value={newSite.site_name} onChange={e => setNewSite({ ...newSite, site_name: e.target.value })}
|
||||||
|
placeholder="Name (z.B. Hauptwebsite)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input value={newSite.site_url} onChange={e => setNewSite({ ...newSite, site_url: e.target.value })}
|
||||||
|
placeholder="URL (z.B. https://example.com)" className="flex-1 px-3 py-2 text-sm border border-gray-200 rounded-lg" />
|
||||||
|
<button onClick={handleCreate} disabled={creating || !newSite.site_id}
|
||||||
|
className="px-3 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||||
|
{creating ? '...' : 'Anlegen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface IABPurpose {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
name_de: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const API = '/api/sdk/v1/compliance/tcf'
|
||||||
|
|
||||||
|
export function TCFSettings({ siteId, tcfEnabled, onToggle }: {
|
||||||
|
siteId?: string
|
||||||
|
tcfEnabled: boolean
|
||||||
|
onToggle: (enabled: boolean) => void
|
||||||
|
}) {
|
||||||
|
const [purposes, setPurposes] = useState<IABPurpose[]>([])
|
||||||
|
const [categoryMap, setCategoryMap] = useState<Record<string, number[]>>({})
|
||||||
|
const [testResult, setTestResult] = useState<string | null>(null)
|
||||||
|
const [testing, setTesting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
fetch(`${API}/purposes`).then(r => r.ok ? r.json() : []),
|
||||||
|
fetch(`${API}/category-mapping`).then(r => r.ok ? r.json() : {}),
|
||||||
|
]).then(([p, m]) => {
|
||||||
|
setPurposes(p)
|
||||||
|
setCategoryMap(m)
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleTestEncode = async () => {
|
||||||
|
setTesting(true)
|
||||||
|
setTestResult(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/encode-categories`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ categories: ['necessary', 'statistics', 'marketing'] }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setTestResult(`TC String: ${data.tc_string}\nPurposes: ${data.purposes_consented.join(', ')}`)
|
||||||
|
}
|
||||||
|
} catch { setTestResult('Fehler beim Generieren') }
|
||||||
|
setTesting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Enable/Disable */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">IAB TCF 2.2</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Transparency & Consent Framework — Standardisierte Einwilligungssignale fuer programmatische Werbung
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" checked={tcfEnabled} onChange={e => onToggle(e.target.checked)}
|
||||||
|
className="w-5 h-5 text-purple-600 rounded" />
|
||||||
|
<span className="text-sm font-medium">{tcfEnabled ? 'Aktiv' : 'Inaktiv'}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{!tcfEnabled && (
|
||||||
|
<p className="mt-3 text-xs text-amber-600 bg-amber-50 p-3 rounded-lg">
|
||||||
|
TCF ist nur erforderlich wenn Sie programmatische Werbung (AdTech) einsetzen.
|
||||||
|
Fuer die meisten Websites reicht das Standard-Cookie-Banner.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tcfEnabled && (
|
||||||
|
<>
|
||||||
|
{/* IAB Purposes */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-3">12 IAB-Zwecke (Purposes)</h4>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
|
Diese Zwecke werden automatisch aus Ihren Cookie-Kategorien abgeleitet.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
|
{purposes.map(p => {
|
||||||
|
const activeCats = Object.entries(categoryMap)
|
||||||
|
.filter(([, pids]) => pids.includes(p.id))
|
||||||
|
.map(([cat]) => cat)
|
||||||
|
return (
|
||||||
|
<div key={p.id} className={`flex items-start gap-2 p-2 rounded-lg text-xs ${activeCats.length > 0 ? 'bg-green-50' : 'bg-gray-50'}`}>
|
||||||
|
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold flex-shrink-0 ${activeCats.length > 0 ? 'bg-green-500 text-white' : 'bg-gray-300 text-white'}`}>
|
||||||
|
{p.id}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-700">{p.name_de}</div>
|
||||||
|
{activeCats.length > 0 && (
|
||||||
|
<div className="text-gray-400 mt-0.5">via: {activeCats.join(', ')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Mapping */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-3">Kategorie → Purpose Zuordnung</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(categoryMap).map(([cat, pids]) => (
|
||||||
|
<div key={cat} className="flex items-center gap-3">
|
||||||
|
<span className="text-sm font-medium text-gray-700 w-24 capitalize">{cat}</span>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{pids.length === 0 ? (
|
||||||
|
<span className="text-xs text-gray-400">Keine Einwilligung noetig</span>
|
||||||
|
) : (
|
||||||
|
pids.map(pid => (
|
||||||
|
<span key={pid} className="px-2 py-0.5 text-[10px] bg-purple-100 text-purple-700 rounded-full">
|
||||||
|
Purpose {pid}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TC String Test */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-3">TC String testen</h4>
|
||||||
|
<button onClick={handleTestEncode} disabled={testing}
|
||||||
|
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||||
|
{testing ? 'Generiere...' : 'Test TC String generieren'}
|
||||||
|
</button>
|
||||||
|
{testResult && (
|
||||||
|
<pre className="mt-3 p-3 bg-gray-50 border border-gray-200 rounded-lg text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap">
|
||||||
|
{testResult}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-400 mt-2">
|
||||||
|
Simuliert: necessary + statistics + marketing → generiert base64url-codierten TC String
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CMP Registration Info */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||||
|
<h4 className="font-semibold text-blue-800 text-sm">CMP-Registrierung</h4>
|
||||||
|
<p className="text-xs text-blue-700 mt-1">
|
||||||
|
Fuer den produktiven Einsatz muss Ihr CMP bei der IAB Europe registriert werden.
|
||||||
|
Sie erhalten eine eindeutige CMP-ID die im TC String codiert wird.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-blue-600 mt-2">
|
||||||
|
Registrierung: <a href="https://iabeurope.eu/tcf-for-cmps/" target="_blank" rel="noopener"
|
||||||
|
className="underline">iabeurope.eu/tcf-for-cmps</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useSDK } from '@/lib/sdk'
|
||||||
|
|
||||||
|
interface Vendor {
|
||||||
|
id: string
|
||||||
|
vendor_name: string
|
||||||
|
vendor_url: string | null
|
||||||
|
category_key: string
|
||||||
|
description_de: string | null
|
||||||
|
description_en: string | null
|
||||||
|
cookie_names: string[]
|
||||||
|
retention_days: number | null
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, { label: string; color: string }> = {
|
||||||
|
necessary: { label: 'Notwendig', color: 'bg-green-100 text-green-700' },
|
||||||
|
functional: { label: 'Funktional', color: 'bg-blue-100 text-blue-700' },
|
||||||
|
statistics: { label: 'Statistik', color: 'bg-yellow-100 text-yellow-700' },
|
||||||
|
marketing: { label: 'Marketing', color: 'bg-red-100 text-red-700' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorTable({ siteId }: { siteId?: string }) {
|
||||||
|
const { projectId } = useSDK()
|
||||||
|
const [vendors, setVendors] = useState<Vendor[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sid = siteId || 'preview-test-site'
|
||||||
|
fetch(`/api/sdk/v1/banner/admin/sites/${sid}/vendors`)
|
||||||
|
.then(r => r.ok ? r.json() : [])
|
||||||
|
.then(data => setVendors(Array.isArray(data) ? data : []))
|
||||||
|
.catch(() => setVendors([]))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [siteId])
|
||||||
|
|
||||||
|
// Group by category
|
||||||
|
const grouped = vendors.reduce<Record<string, Vendor[]>>((acc, v) => {
|
||||||
|
const key = v.category_key || 'other'
|
||||||
|
if (!acc[key]) acc[key] = []
|
||||||
|
acc[key].push(v)
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-center py-12 text-gray-400">Lade Verarbeiter...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vendors.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-400 mb-3">Keine Verarbeiter konfiguriert.</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Nutzen Sie den Website-Scanner oder fuegen Sie Verarbeiter manuell hinzu.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">Verarbeiter-Uebersicht</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{vendors.length} Dienste in {Object.keys(grouped).length} Kategorien</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.entries(grouped).map(([catKey, catVendors]) => {
|
||||||
|
const catInfo = CATEGORY_LABELS[catKey] || { label: catKey, color: 'bg-gray-100 text-gray-700' }
|
||||||
|
return (
|
||||||
|
<div key={catKey} className="border border-gray-200 rounded-xl overflow-hidden">
|
||||||
|
<div className="bg-gray-50 px-4 py-3 flex items-center gap-3">
|
||||||
|
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${catInfo.color}`}>
|
||||||
|
{catInfo.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">{catVendors.length} Dienste</span>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-100 text-left text-xs text-gray-500">
|
||||||
|
<th className="px-4 py-2 font-medium">Anbieter</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Zweck</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Cookies</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Aufbewahrung</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Datenschutz</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-50">
|
||||||
|
{catVendors.map(v => (
|
||||||
|
<tr key={v.id} className="hover:bg-gray-50/50">
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<button onClick={() => setExpandedId(expandedId === v.id ? null : v.id)}
|
||||||
|
className="font-medium text-gray-900 hover:text-purple-600 text-left">
|
||||||
|
{v.vendor_name}
|
||||||
|
</button>
|
||||||
|
{expandedId === v.id && v.cookie_names?.length > 0 && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{v.cookie_names.map(c => (
|
||||||
|
<span key={c} className="px-1.5 py-0.5 text-[10px] bg-gray-100 text-gray-600 rounded font-mono">
|
||||||
|
{c}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-xs text-gray-600 max-w-[200px] truncate">
|
||||||
|
{v.description_de || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-xs text-gray-500">
|
||||||
|
{v.cookie_names?.length || 0}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-xs text-gray-500">
|
||||||
|
{v.retention_days ? `${v.retention_days} Tage` : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
{v.vendor_url ? (
|
||||||
|
<a href={v.vendor_url} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="text-xs text-purple-600 hover:underline">
|
||||||
|
Link
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -96,13 +96,39 @@ const defaultBannerTexts: BannerTexts = {
|
|||||||
privacyLink: '/datenschutz',
|
privacyLink: '/datenschutz',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BannerSite {
|
||||||
|
id: string
|
||||||
|
site_id: string
|
||||||
|
site_name: string
|
||||||
|
site_url: string
|
||||||
|
is_active: boolean
|
||||||
|
tcf_enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export function useCookieBanner() {
|
export function useCookieBanner() {
|
||||||
const [categories, setCategories] = useState<CookieCategory[]>([])
|
const [categories, setCategories] = useState<CookieCategory[]>([])
|
||||||
const [config, setConfig] = useState<BannerConfig>(defaultConfig)
|
const [config, setConfig] = useState<BannerConfig>(defaultConfig)
|
||||||
const [bannerTexts, setBannerTexts] = useState<BannerTexts>(defaultBannerTexts)
|
const [bannerTexts, setBannerTexts] = useState<BannerTexts>(defaultBannerTexts)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [exportToast, setExportToast] = useState<string | null>(null)
|
const [exportToast, setExportToast] = useState<string | null>(null)
|
||||||
|
const [sites, setSites] = useState<BannerSite[]>([])
|
||||||
|
const [activeSiteId, setActiveSiteId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Load sites list
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetch('/api/sdk/v1/banner/admin/sites')
|
||||||
|
.then(r => r.ok ? r.json() : [])
|
||||||
|
.then(data => {
|
||||||
|
const siteList = Array.isArray(data) ? data : []
|
||||||
|
setSites(siteList)
|
||||||
|
if (siteList.length > 0 && !activeSiteId) {
|
||||||
|
setActiveSiteId(siteList[0].site_id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load config for active site
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const loadConfig = async () => {
|
const loadConfig = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -125,7 +151,20 @@ export function useCookieBanner() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadConfig()
|
loadConfig()
|
||||||
}, [])
|
}, [activeSiteId])
|
||||||
|
|
||||||
|
const createSite = async (data: { site_id: string; site_name: string; site_url: string }) => {
|
||||||
|
const res = await fetch('/api/sdk/v1/banner/admin/sites', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const newSite = await res.json()
|
||||||
|
setSites(prev => [...prev, newSite])
|
||||||
|
setActiveSiteId(newSite.site_id || data.site_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleCategoryToggle = async (categoryId: string, enabled: boolean) => {
|
const handleCategoryToggle = async (categoryId: string, enabled: boolean) => {
|
||||||
setCategories(prev =>
|
setCategories(prev =>
|
||||||
@@ -180,5 +219,6 @@ export function useCookieBanner() {
|
|||||||
categories, config, bannerTexts, isSaving, exportToast,
|
categories, config, bannerTexts, isSaving, exportToast,
|
||||||
setConfig, setBannerTexts,
|
setConfig, setBannerTexts,
|
||||||
handleCategoryToggle, handleExportCode, handleSaveConfig,
|
handleCategoryToggle, handleExportCode, handleSaveConfig,
|
||||||
|
sites, activeSiteId, setActiveSiteId, createSite,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useSDK } from '@/lib/sdk'
|
import { useSDK } from '@/lib/sdk'
|
||||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||||
import { useCookieBanner } from './_hooks/useCookieBanner'
|
import { useCookieBanner } from './_hooks/useCookieBanner'
|
||||||
import { BannerPreview } from './_components/BannerPreview'
|
import { BannerPreview } from './_components/BannerPreview'
|
||||||
import { CategoryCard } from './_components/CategoryCard'
|
import { CategoryCard } from './_components/CategoryCard'
|
||||||
|
import { VendorTable } from './_components/VendorTable'
|
||||||
|
import { EmbeddableVendorHTML } from './_components/EmbeddableVendorHTML'
|
||||||
|
import { SiteSelector } from './_components/SiteSelector'
|
||||||
|
import { AnalyticsDashboard } from './_components/AnalyticsDashboard'
|
||||||
|
import { ABTestPanel } from './_components/ABTestPanel'
|
||||||
|
import { TCFSettings } from './_components/TCFSettings'
|
||||||
|
|
||||||
|
type BannerTab = 'config' | 'vendors' | 'embed' | 'analytics' | 'abtest' | 'tcf'
|
||||||
|
|
||||||
export default function CookieBannerPage() {
|
export default function CookieBannerPage() {
|
||||||
const { state } = useSDK()
|
const { state } = useSDK()
|
||||||
|
const [activeTab, setActiveTab] = useState<BannerTab>('config')
|
||||||
const {
|
const {
|
||||||
categories, config, bannerTexts, isSaving, exportToast,
|
categories, config, bannerTexts, isSaving, exportToast,
|
||||||
setConfig, setBannerTexts,
|
setConfig, setBannerTexts,
|
||||||
handleCategoryToggle, handleExportCode, handleSaveConfig,
|
handleCategoryToggle, handleExportCode, handleSaveConfig,
|
||||||
|
sites, activeSiteId, setActiveSiteId, createSite,
|
||||||
} = useCookieBanner()
|
} = useCookieBanner()
|
||||||
|
|
||||||
const totalCookies = categories.reduce((sum, cat) => sum + cat.cookies.length, 0)
|
const totalCookies = categories.reduce((sum, cat) => sum + cat.cookies.length, 0)
|
||||||
@@ -57,6 +67,58 @@ export default function CookieBannerPage() {
|
|||||||
</div>
|
</div>
|
||||||
</StepHeader>
|
</StepHeader>
|
||||||
|
|
||||||
|
{/* Site Selector */}
|
||||||
|
{sites.length > 0 && (
|
||||||
|
<SiteSelector sites={sites} activeSiteId={activeSiteId} onSiteChange={setActiveSiteId} onCreateSite={createSite} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b border-gray-200">
|
||||||
|
{([
|
||||||
|
{ id: 'config' as const, label: 'Konfiguration' },
|
||||||
|
{ id: 'vendors' as const, label: 'Verarbeiter' },
|
||||||
|
{ id: 'embed' as const, label: 'Einbettung' },
|
||||||
|
{ id: 'analytics' as const, label: 'Analytik' },
|
||||||
|
{ id: 'abtest' as const, label: 'A/B-Test' },
|
||||||
|
{ id: 'tcf' as const, label: 'TCF/IAB' },
|
||||||
|
]).map(tab => (
|
||||||
|
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
||||||
|
activeTab === tab.id ? 'text-purple-600 border-purple-600' : 'text-gray-500 border-transparent hover:text-gray-700'
|
||||||
|
}`}>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab: Verarbeiter */}
|
||||||
|
{activeTab === 'vendors' && <VendorTable siteId={activeSiteId || undefined} />}
|
||||||
|
|
||||||
|
{/* Tab: Einbettung */}
|
||||||
|
{activeTab === 'embed' && <EmbeddableVendorHTML siteId={activeSiteId || undefined} />}
|
||||||
|
|
||||||
|
{/* Tab: Analytik */}
|
||||||
|
{activeTab === 'analytics' && <AnalyticsDashboard siteId={activeSiteId || undefined} />}
|
||||||
|
|
||||||
|
{/* Tab: A/B-Test */}
|
||||||
|
{activeTab === 'abtest' && <ABTestPanel siteConfigId={activeSiteId || undefined} />}
|
||||||
|
|
||||||
|
{/* Tab: TCF/IAB */}
|
||||||
|
{activeTab === 'tcf' && (
|
||||||
|
<TCFSettings siteId={activeSiteId || undefined} tcfEnabled={sites.find(s => s.site_id === activeSiteId)?.tcf_enabled ?? false}
|
||||||
|
onToggle={(enabled) => {
|
||||||
|
if (activeSiteId) {
|
||||||
|
fetch(`/api/sdk/v1/banner/admin/sites/${activeSiteId}`, {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tcf_enabled: enabled }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab: Konfiguration */}
|
||||||
|
{activeTab !== 'config' ? null : (<>
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
@@ -207,6 +269,7 @@ export default function CookieBannerPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import { LegalTemplateResult } from '@/lib/sdk/types'
|
import { LegalTemplateResult } from '@/lib/sdk/types'
|
||||||
import { RuleEngineResult } from '../ruleEngine'
|
import { RuleEngineResult } from '../ruleEngine'
|
||||||
|
import ReviewAssignmentPanel from './ReviewAssignmentPanel'
|
||||||
|
|
||||||
interface GeneratorPreviewTabProps {
|
interface GeneratorPreviewTabProps {
|
||||||
template: LegalTemplateResult
|
template: LegalTemplateResult
|
||||||
@@ -10,8 +12,76 @@ interface GeneratorPreviewTabProps {
|
|||||||
missing: string[]
|
missing: string[]
|
||||||
onCopy: () => void
|
onCopy: () => void
|
||||||
onExportMarkdown: () => void
|
onExportMarkdown: () => void
|
||||||
|
onSaveToWorkflow?: () => void
|
||||||
|
saveStatus?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Lightweight Markdown → HTML (no dependency needed)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function markdownToHtml(md: string): string {
|
||||||
|
let html = md
|
||||||
|
// Escape HTML entities first
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
|
||||||
|
// Headings
|
||||||
|
html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>')
|
||||||
|
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||||
|
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||||
|
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||||
|
|
||||||
|
// Horizontal rules
|
||||||
|
html = html.replace(/^---$/gm, '<hr/>')
|
||||||
|
|
||||||
|
// Bold + Italic
|
||||||
|
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
|
||||||
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||||
|
|
||||||
|
// Links
|
||||||
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-purple-600 underline">$1</a>')
|
||||||
|
|
||||||
|
// Tables (simple)
|
||||||
|
html = html.replace(/^\|(.+)\|$/gm, (match) => {
|
||||||
|
const cells = match.split('|').filter(c => c.trim())
|
||||||
|
const isHeader = cells.every(c => /^[\s-:]+$/.test(c))
|
||||||
|
if (isHeader) return '<!-- separator -->'
|
||||||
|
const tag = 'td'
|
||||||
|
return '<tr>' + cells.map(c => `<${tag}>${c.trim()}</${tag}>`).join('') + '</tr>'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wrap consecutive table rows
|
||||||
|
html = html.replace(/((?:<tr>.*<\/tr>\n?<!-- separator -->\n?)?(?:<tr>.*<\/tr>\n?)+)/g, (block) => {
|
||||||
|
const rows = block.split('\n').filter(r => r.startsWith('<tr>'))
|
||||||
|
if (rows.length === 0) return block
|
||||||
|
const headerRow = rows[0].replace(/<td>/g, '<th>').replace(/<\/td>/g, '</th>')
|
||||||
|
const bodyRows = rows.slice(1).join('\n')
|
||||||
|
return `<table><thead>${headerRow}</thead><tbody>${bodyRows}</tbody></table>`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove separator comments
|
||||||
|
html = html.replace(/<!-- separator -->\n?/g, '')
|
||||||
|
|
||||||
|
// Unordered lists
|
||||||
|
html = html.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||||
|
html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>')
|
||||||
|
|
||||||
|
// Paragraphs (lines that aren't already HTML)
|
||||||
|
html = html.replace(/^(?!<[a-z/]|$)(.+)$/gm, '<p>$1</p>')
|
||||||
|
|
||||||
|
// Clean up empty paragraphs
|
||||||
|
html = html.replace(/<p>\s*<\/p>/g, '')
|
||||||
|
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
export default function GeneratorPreviewTab({
|
export default function GeneratorPreviewTab({
|
||||||
template,
|
template,
|
||||||
ruleResult,
|
ruleResult,
|
||||||
@@ -19,13 +89,20 @@ export default function GeneratorPreviewTab({
|
|||||||
missing,
|
missing,
|
||||||
onCopy,
|
onCopy,
|
||||||
onExportMarkdown,
|
onExportMarkdown,
|
||||||
|
onSaveToWorkflow,
|
||||||
|
saveStatus,
|
||||||
}: GeneratorPreviewTabProps) {
|
}: GeneratorPreviewTabProps) {
|
||||||
|
const [viewMode, setViewMode] = useState<'preview' | 'markdown'>('preview')
|
||||||
|
|
||||||
|
const htmlContent = markdownToHtml(renderedContent)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* Violations */}
|
||||||
{ruleResult && ruleResult.violations.length > 0 && (
|
{ruleResult && ruleResult.violations.length > 0 && (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||||
<p className="text-sm font-semibold text-red-700 mb-2">
|
<p className="text-sm font-semibold text-red-700 mb-2">
|
||||||
🔴 {ruleResult.violations.length} Fehler
|
{ruleResult.violations.length} Fehler
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{ruleResult.violations.map((v) => (
|
{ruleResult.violations.map((v) => (
|
||||||
@@ -36,6 +113,8 @@ export default function GeneratorPreviewTab({
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Warnings */}
|
||||||
{ruleResult && ruleResult.warnings.filter((w) => w.id !== 'WARN_LEGAL_REVIEW').length > 0 && (
|
{ruleResult && ruleResult.warnings.filter((w) => w.id !== 'WARN_LEGAL_REVIEW').length > 0 && (
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4">
|
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4">
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
@@ -43,69 +122,156 @@ export default function GeneratorPreviewTab({
|
|||||||
.filter((w) => w.id !== 'WARN_LEGAL_REVIEW')
|
.filter((w) => w.id !== 'WARN_LEGAL_REVIEW')
|
||||||
.map((w) => (
|
.map((w) => (
|
||||||
<li key={w.id} className="text-xs text-yellow-700">
|
<li key={w.id} className="text-xs text-yellow-700">
|
||||||
🟡 <span className="font-mono font-medium">[{w.id}]</span> {w.message}
|
<span className="font-mono font-medium">[{w.id}]</span> {w.message}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Legal notice */}
|
||||||
{ruleResult && (
|
{ruleResult && (
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3">
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3">
|
||||||
<p className="text-xs text-blue-700">
|
<p className="text-xs text-blue-700">
|
||||||
ℹ️ Rechtlicher Hinweis: Diese Vorlage ist MIT-lizenziert. Vor Produktionseinsatz
|
Rechtlicher Hinweis: Diese Vorlage ist MIT-lizenziert. Vor Produktionseinsatz
|
||||||
wird eine rechtliche Überprüfung dringend empfohlen.
|
wird eine rechtliche Ueberpruefung dringend empfohlen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{ruleResult && ruleResult.appliedDefaults.length > 0 && (
|
|
||||||
<p className="text-xs text-gray-400">
|
|
||||||
Defaults angewendet: {ruleResult.appliedDefaults.join(', ')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
<span className="text-sm text-gray-600">
|
<div className="flex gap-1 bg-gray-100 rounded-lg p-0.5">
|
||||||
{missing.length > 0 && (
|
|
||||||
<span className="text-orange-600">
|
|
||||||
⚠ {missing.length} Platzhalter noch nicht ausgefüllt
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
<button
|
||||||
onClick={onCopy}
|
onClick={() => setViewMode('preview')}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 transition-colors"
|
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
viewMode === 'preview' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
Vorschau
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
Kopieren
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onExportMarkdown}
|
onClick={() => setViewMode('markdown')}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 transition-colors"
|
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
viewMode === 'markdown' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
Markdown
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
</button>
|
||||||
</svg>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{missing.length > 0 && (
|
||||||
|
<span className="text-xs text-orange-600">
|
||||||
|
{missing.length} Platzhalter offen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button onClick={onCopy} className="px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600">
|
||||||
|
Kopieren
|
||||||
|
</button>
|
||||||
|
<button onClick={onExportMarkdown} className="px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600">
|
||||||
Markdown
|
Markdown
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.print()}
|
onClick={() => {
|
||||||
className="flex items-center gap-1.5 px-4 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
const printWindow = window.open('', '_blank')
|
||||||
|
if (!printWindow) return
|
||||||
|
printWindow.document.write(`<!DOCTYPE html><html><head><title>${template.documentTitle || 'Dokument'}</title><style>
|
||||||
|
@page { size: A4; margin: 25mm 20mm; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 11pt; line-height: 1.6; color: #1a202c; max-width: 170mm; margin: 0 auto; }
|
||||||
|
h1 { font-size: 18pt; color: #5b21b6; margin: 24pt 0 8pt; border-bottom: 2px solid #7c3aed; padding-bottom: 4pt; }
|
||||||
|
h2 { font-size: 14pt; color: #1f2937; margin: 18pt 0 6pt; }
|
||||||
|
h3 { font-size: 12pt; color: #374151; margin: 12pt 0 4pt; }
|
||||||
|
h4 { font-size: 11pt; color: #4b5563; margin: 10pt 0 4pt; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin: 8pt 0; font-size: 10pt; }
|
||||||
|
th { background: #f5f3ff; color: #5b21b6; font-weight: 600; text-align: left; padding: 6pt 8pt; border: 1px solid #e5e7eb; }
|
||||||
|
td { padding: 5pt 8pt; border: 1px solid #e5e7eb; vertical-align: top; }
|
||||||
|
ul { padding-left: 20pt; }
|
||||||
|
li { margin: 2pt 0; }
|
||||||
|
hr { border: none; border-top: 1px solid #e5e7eb; margin: 16pt 0; }
|
||||||
|
a { color: #7c3aed; }
|
||||||
|
p { margin: 4pt 0; }
|
||||||
|
strong { font-weight: 600; }
|
||||||
|
</style></head><body>${htmlContent}</body></html>`)
|
||||||
|
printWindow.document.close()
|
||||||
|
printWindow.print()
|
||||||
|
}}
|
||||||
|
className="px-4 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
|
|
||||||
</svg>
|
|
||||||
PDF drucken
|
PDF drucken
|
||||||
</button>
|
</button>
|
||||||
|
{onSaveToWorkflow && (
|
||||||
|
<button
|
||||||
|
onClick={onSaveToWorkflow}
|
||||||
|
disabled={saveStatus === 'saving'}
|
||||||
|
className={`px-4 py-1.5 text-xs rounded-lg transition-colors ${
|
||||||
|
saveStatus === 'saved' ? 'bg-green-600 text-white' :
|
||||||
|
saveStatus === 'error' ? 'bg-red-600 text-white' :
|
||||||
|
'bg-indigo-600 text-white hover:bg-indigo-700'
|
||||||
|
} disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{saveStatus === 'saving' ? 'Speichern...' :
|
||||||
|
saveStatus === 'saved' ? 'Gespeichert!' :
|
||||||
|
saveStatus === 'error' ? 'Fehler' :
|
||||||
|
'Als Version speichern'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6 max-h-[600px] overflow-y-auto">
|
|
||||||
<pre className="text-sm text-gray-800 whitespace-pre-wrap leading-relaxed font-sans">
|
{/* Content */}
|
||||||
|
{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}
|
{renderedContent}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-100 rounded-xl p-8 flex justify-center overflow-y-auto max-h-[85vh]">
|
||||||
|
{/* A4 Page */}
|
||||||
|
<div
|
||||||
|
className="bg-white shadow-lg border border-gray-300"
|
||||||
|
style={{
|
||||||
|
width: '210mm',
|
||||||
|
minHeight: '297mm',
|
||||||
|
padding: '25mm 20mm',
|
||||||
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
|
fontSize: '11pt',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
color: '#1a202c',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style>{`
|
||||||
|
.a4-content h1 { font-size: 18pt; color: #5b21b6; margin: 24pt 0 8pt; border-bottom: 2px solid #7c3aed; padding-bottom: 4pt; }
|
||||||
|
.a4-content h2 { font-size: 14pt; color: #1f2937; margin: 18pt 0 6pt; }
|
||||||
|
.a4-content h3 { font-size: 12pt; color: #374151; margin: 12pt 0 4pt; }
|
||||||
|
.a4-content h4 { font-size: 11pt; color: #4b5563; margin: 10pt 0 4pt; }
|
||||||
|
.a4-content table { width: 100%; border-collapse: collapse; margin: 8pt 0; font-size: 10pt; }
|
||||||
|
.a4-content th { background: #f5f3ff; color: #5b21b6; font-weight: 600; text-align: left; padding: 6pt 8pt; border: 1px solid #e5e7eb; }
|
||||||
|
.a4-content td { padding: 5pt 8pt; border: 1px solid #e5e7eb; vertical-align: top; }
|
||||||
|
.a4-content ul { padding-left: 20pt; margin: 4pt 0; }
|
||||||
|
.a4-content li { margin: 2pt 0; }
|
||||||
|
.a4-content hr { border: none; border-top: 1px solid #e5e7eb; margin: 16pt 0; }
|
||||||
|
.a4-content a { color: #7c3aed; text-decoration: underline; }
|
||||||
|
.a4-content p { margin: 4pt 0; }
|
||||||
|
.a4-content strong { font-weight: 600; }
|
||||||
|
`}</style>
|
||||||
|
<div
|
||||||
|
className="a4-content"
|
||||||
|
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Review Assignment */}
|
||||||
|
<ReviewAssignmentPanel
|
||||||
|
documentType={template.templateType || ''}
|
||||||
|
documentTitle={template.documentTitle || 'Dokument'}
|
||||||
|
documentContent={renderedContent}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Attribution */}
|
||||||
{template.attributionRequired && template.attributionText && (
|
{template.attributionRequired && template.attributionText && (
|
||||||
<div className="text-xs text-orange-600 bg-orange-50 p-3 rounded-lg border border-orange-200">
|
<div className="text-xs text-orange-600 bg-orange-50 p-3 rounded-lg border border-orange-200">
|
||||||
<strong>Attribution erforderlich:</strong> {template.attributionText}
|
<strong>Attribution erforderlich:</strong> {template.attributionText}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function GeneratorSection({
|
|||||||
const [activeTab, setActiveTab] = useState<'placeholders' | 'preview'>('placeholders')
|
const [activeTab, setActiveTab] = useState<'placeholders' | 'preview'>('placeholders')
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['PROVIDER', 'LEGAL']))
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['PROVIDER', 'LEGAL']))
|
||||||
|
|
||||||
const placeholders = template.placeholders || []
|
const placeholders = Array.isArray(template.placeholders) ? template.placeholders : []
|
||||||
const relevantSections = useMemo(() => getRelevantSections(placeholders), [placeholders])
|
const relevantSections = useMemo(() => getRelevantSections(placeholders), [placeholders])
|
||||||
const uncovered = useMemo(() => getUncoveredPlaceholders(placeholders, context), [placeholders, context])
|
const uncovered = useMemo(() => getUncoveredPlaceholders(placeholders, context), [placeholders, context])
|
||||||
const missing = useMemo(() => getMissingRequired(placeholders, context), [placeholders, context])
|
const missing = useMemo(() => getMissingRequired(placeholders, context), [placeholders, context])
|
||||||
@@ -101,6 +101,45 @@ export default function GeneratorSection({
|
|||||||
|
|
||||||
const handleCopy = () => navigator.clipboard.writeText(renderedContent)
|
const handleCopy = () => navigator.clipboard.writeText(renderedContent)
|
||||||
|
|
||||||
|
const [saveStatus, setSaveStatus] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleSaveToWorkflow = async () => {
|
||||||
|
setSaveStatus('saving')
|
||||||
|
try {
|
||||||
|
// 1. Create or find document
|
||||||
|
const docRes = await fetch('/api/sdk/v1/compliance/legal-documents/documents', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: template.templateType || 'custom',
|
||||||
|
name: template.documentTitle || 'Dokument',
|
||||||
|
description: `Generiert aus Template: ${template.templateType}`,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!docRes.ok) throw new Error('Dokument konnte nicht erstellt werden')
|
||||||
|
const doc = await docRes.json()
|
||||||
|
|
||||||
|
// 2. Create version
|
||||||
|
const verRes = await fetch('/api/sdk/v1/compliance/legal-documents/versions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
document_id: doc.id,
|
||||||
|
title: template.documentTitle || 'Dokument',
|
||||||
|
content: renderedContent,
|
||||||
|
language: template.language || 'de',
|
||||||
|
version: '1.0',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!verRes.ok) throw new Error('Version konnte nicht erstellt werden')
|
||||||
|
setSaveStatus('saved')
|
||||||
|
setTimeout(() => setSaveStatus(null), 3000)
|
||||||
|
} catch (e) {
|
||||||
|
setSaveStatus('error')
|
||||||
|
setTimeout(() => setSaveStatus(null), 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleExportMarkdown = () => {
|
const handleExportMarkdown = () => {
|
||||||
const blob = new Blob([renderedContent], { type: 'text/markdown' })
|
const blob = new Blob([renderedContent], { type: 'text/markdown' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
@@ -160,6 +199,33 @@ export default function GeneratorSection({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// Load example data for current template type
|
||||||
|
const templateType = template.templateType || ''
|
||||||
|
const lang = template.language || 'de'
|
||||||
|
const exampleFile = `/sdk/document-generator/examples/${templateType}_${lang}.json`
|
||||||
|
fetch(exampleFile)
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(data => {
|
||||||
|
if (!data?.context) return
|
||||||
|
const ctx = data.context
|
||||||
|
for (const [section, fields] of Object.entries(ctx)) {
|
||||||
|
if (typeof fields === 'object' && fields) {
|
||||||
|
for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
|
||||||
|
onContextChange(section as keyof TemplateContext, key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {/* no example available */})
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 text-xs bg-blue-50 text-blue-600 border border-blue-200 rounded-lg hover:bg-blue-100 transition-colors"
|
||||||
|
>
|
||||||
|
Beispieldaten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors shrink-0" aria-label="Schließen">
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors shrink-0" aria-label="Schließen">
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
@@ -223,6 +289,8 @@ export default function GeneratorSection({
|
|||||||
missing={missing}
|
missing={missing}
|
||||||
onCopy={handleCopy}
|
onCopy={handleCopy}
|
||||||
onExportMarkdown={handleExportMarkdown}
|
onExportMarkdown={handleExportMarkdown}
|
||||||
|
onSaveToWorkflow={handleSaveToWorkflow}
|
||||||
|
saveStatus={saveStatus}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useSDK } from '@/lib/sdk'
|
||||||
|
import { evaluateTemplateRecommendations, type TemplateRecommendation } from '../templateRecommendations'
|
||||||
|
import { getProfileLabel } from '../scopeDefaults'
|
||||||
|
import type { LegalTemplateResult } from '@/lib/sdk/types'
|
||||||
|
import type { ComplianceDepthLevel } from '@/lib/sdk/compliance-scope-types/core-levels'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
allTemplates: LegalTemplateResult[]
|
||||||
|
onUseTemplate: (t: LegalTemplateResult) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecommendedDocuments({ allTemplates, onUseTemplate }: Props) {
|
||||||
|
const { state } = useSDK()
|
||||||
|
const [showOptional, setShowOptional] = useState(false)
|
||||||
|
|
||||||
|
const level = state?.complianceScope?.determinedLevel as ComplianceDepthLevel | undefined
|
||||||
|
const scopeAnswers = state?.complianceScope?.answers || []
|
||||||
|
|
||||||
|
const recommendations = useMemo(() => {
|
||||||
|
if (!level) return null
|
||||||
|
return evaluateTemplateRecommendations(
|
||||||
|
scopeAnswers,
|
||||||
|
level,
|
||||||
|
(state?.companyProfile as Record<string, unknown>) || {},
|
||||||
|
)
|
||||||
|
}, [level, scopeAnswers, state?.companyProfile])
|
||||||
|
|
||||||
|
if (!level || !recommendations || recommendations.length === 0) return null
|
||||||
|
|
||||||
|
// Match recommendations to actual templates in the library
|
||||||
|
const templateMap = new Map<string, LegalTemplateResult>()
|
||||||
|
for (const t of allTemplates) {
|
||||||
|
if (t.templateType) templateMap.set(t.templateType, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
const required = recommendations.filter((r) => r.requirement === 'required')
|
||||||
|
const recommended = recommendations.filter((r) => r.requirement === 'recommended')
|
||||||
|
const optional = recommendations.filter((r) => r.requirement === 'optional')
|
||||||
|
|
||||||
|
const renderCard = (rec: TemplateRecommendation) => {
|
||||||
|
const template = templateMap.get(rec.templateType)
|
||||||
|
const exists = !!template
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rec.templateType}
|
||||||
|
className={`rounded-lg border p-3 text-sm ${
|
||||||
|
exists
|
||||||
|
? 'border-gray-200 bg-white hover:border-purple-300 cursor-pointer'
|
||||||
|
: 'border-dashed border-gray-300 bg-gray-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => exists && template && onUseTemplate(template)}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-gray-900 truncate">{rec.label}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
{exists ? (
|
||||||
|
<span className="text-purple-600">Vorlage verfuegbar</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">Noch nicht erstellt</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gradient-to-br from-purple-50 to-white rounded-xl border border-purple-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
Empfohlene Dokumente fuer Ihr Unternehmen
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Basierend auf Ihrem Compliance-Profil ({getProfileLabel(level)})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-700">
|
||||||
|
{level}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Required */}
|
||||||
|
{required.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-sm font-medium text-red-700">Pflicht</span>
|
||||||
|
<span className="text-xs text-gray-400">({required.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
|
||||||
|
{required.map(renderCard)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recommended */}
|
||||||
|
{recommended.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-sm font-medium text-amber-700">Empfohlen</span>
|
||||||
|
<span className="text-xs text-gray-400">({recommended.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
|
||||||
|
{recommended.map(renderCard)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Optional (collapsed by default) */}
|
||||||
|
{optional.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowOptional(!showOptional)}
|
||||||
|
className="text-sm text-gray-500 hover:text-purple-600 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span>{showOptional ? '▼' : '▶'}</span>
|
||||||
|
<span>Optional ({optional.length})</span>
|
||||||
|
</button>
|
||||||
|
{showOptional && (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2 mt-2">
|
||||||
|
{optional.map(renderCard)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useSDK } from '@/lib/sdk'
|
||||||
|
|
||||||
|
interface ReviewerInfo {
|
||||||
|
role_key: string
|
||||||
|
role_label?: string
|
||||||
|
person_name?: string | null
|
||||||
|
person_email?: string | null
|
||||||
|
is_primary?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewRecord {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
reviewer_role_key: string
|
||||||
|
reviewer_name: string | null
|
||||||
|
email_sent: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
pending: 'bg-gray-100 text-gray-700',
|
||||||
|
in_review: 'bg-blue-100 text-blue-700',
|
||||||
|
approved: 'bg-green-100 text-green-700',
|
||||||
|
rejected: 'bg-red-100 text-red-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
pending: 'Ausstehend',
|
||||||
|
in_review: 'In Pruefung',
|
||||||
|
approved: 'Freigegeben',
|
||||||
|
rejected: 'Abgelehnt',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReviewAssignmentPanel({
|
||||||
|
documentType,
|
||||||
|
documentTitle,
|
||||||
|
documentContent,
|
||||||
|
}: {
|
||||||
|
documentType: string
|
||||||
|
documentTitle: string
|
||||||
|
documentContent: string
|
||||||
|
}) {
|
||||||
|
const { projectId } = useSDK()
|
||||||
|
const [reviewers, setReviewers] = useState<ReviewerInfo[]>([])
|
||||||
|
const [existingReviews, setExistingReviews] = useState<ReviewRecord[]>([])
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
const [result, setResult] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Load reviewers for this document type
|
||||||
|
useEffect(() => {
|
||||||
|
if (!documentType) return
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (projectId) qs.set('project_id', projectId)
|
||||||
|
qs.set('document_type', documentType)
|
||||||
|
|
||||||
|
// Load mapping + existing reviews
|
||||||
|
Promise.all([
|
||||||
|
fetch(`/api/sdk/v1/compliance/org-roles/mapping`).then(r => r.ok ? r.json() : []),
|
||||||
|
fetch(`/api/sdk/v1/compliance/org-roles${projectId ? `?project_id=${projectId}` : ''}`).then(r => r.ok ? r.json() : []),
|
||||||
|
fetch(`/api/sdk/v1/compliance/document-reviews/for-document?${qs}`).then(r => r.ok ? r.json() : []),
|
||||||
|
]).then(([mappings, roles, reviews]) => {
|
||||||
|
// Filter mappings for this document type
|
||||||
|
const relevant = (mappings as Array<{ document_type: string; role_key: string; is_primary: boolean }>)
|
||||||
|
.filter(m => m.document_type === documentType)
|
||||||
|
// Enrich with role info
|
||||||
|
const enriched: ReviewerInfo[] = relevant.map(m => {
|
||||||
|
const role = (roles as Array<{ role_key: string; role_label: string; person_name: string | null; person_email: string | null }>)
|
||||||
|
.find(r => r.role_key === m.role_key)
|
||||||
|
return { ...m, role_label: role?.role_label, person_name: role?.person_name, person_email: role?.person_email }
|
||||||
|
})
|
||||||
|
setReviewers(enriched)
|
||||||
|
setExistingReviews(reviews)
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [documentType, projectId])
|
||||||
|
|
||||||
|
const handleSendForReview = async () => {
|
||||||
|
setSending(true)
|
||||||
|
setResult(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sdk/v1/compliance/document-reviews', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
document_type: documentType,
|
||||||
|
document_title: documentTitle,
|
||||||
|
document_content: documentContent,
|
||||||
|
project_id: projectId,
|
||||||
|
review_link: window.location.href,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Fehler beim Erstellen')
|
||||||
|
const reviews = await res.json()
|
||||||
|
|
||||||
|
// Send email for each review
|
||||||
|
let sentCount = 0
|
||||||
|
for (const review of reviews) {
|
||||||
|
if (review.reviewer_email) {
|
||||||
|
const sendRes = await fetch(`/api/sdk/v1/compliance/document-reviews/${review.id}/send`, { method: 'POST' })
|
||||||
|
if (sendRes.ok) sentCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setResult(`${reviews.length} Review(s) erstellt, ${sentCount} E-Mail(s) gesendet`)
|
||||||
|
// Refresh
|
||||||
|
const qs = new URLSearchParams({ document_type: documentType })
|
||||||
|
if (projectId) qs.set('project_id', projectId)
|
||||||
|
const updated = await fetch(`/api/sdk/v1/compliance/document-reviews/for-document?${qs}`).then(r => r.json())
|
||||||
|
setExistingReviews(updated)
|
||||||
|
} catch (e) {
|
||||||
|
setResult(e instanceof Error ? e.message : 'Fehler')
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reviewers.length === 0 && existingReviews.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-purple-200 rounded-lg p-4 bg-purple-50/50 space-y-3">
|
||||||
|
<h4 className="font-semibold text-sm text-gray-900 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
Pruefung & Freigabe
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Assigned reviewers */}
|
||||||
|
{reviewers.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{reviewers.map(r => (
|
||||||
|
<div key={r.role_key} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="font-medium text-gray-700">{r.role_label || r.role_key}:</span>
|
||||||
|
{r.person_name ? (
|
||||||
|
<span className="text-gray-600">{r.person_name} ({r.person_email || 'keine E-Mail'})</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 italic">Nicht zugewiesen</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Existing reviews */}
|
||||||
|
{existingReviews.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{existingReviews.map(r => (
|
||||||
|
<div key={r.id} className="flex items-center gap-2">
|
||||||
|
<span className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${STATUS_COLORS[r.status] || ''}`}>
|
||||||
|
{STATUS_LABELS[r.status] || r.status}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-600">{r.reviewer_name || r.reviewer_role_key}</span>
|
||||||
|
{r.email_sent && <span className="text-[10px] text-green-600">E-Mail gesendet</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Send for review */}
|
||||||
|
<button onClick={handleSendForReview} disabled={sending || reviewers.length === 0}
|
||||||
|
className="w-full px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors">
|
||||||
|
{sending ? 'Sende...' : 'Zur Pruefung senden'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<p className={`text-xs ${result.includes('Fehler') ? 'text-red-600' : 'text-green-600'}`}>{result}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,22 +6,64 @@ import { TemplateContext } from './contextBridge'
|
|||||||
|
|
||||||
export const CATEGORIES: { key: string; label: string; types: string[] | null }[] = [
|
export const CATEGORIES: { key: string; label: string; types: string[] | null }[] = [
|
||||||
{ key: 'all', label: 'Alle', types: null },
|
{ key: 'all', label: 'Alle', types: null },
|
||||||
{ key: 'privacy_policy', label: 'Datenschutz', types: ['privacy_policy'] },
|
|
||||||
{ key: 'terms', label: 'AGB', types: ['terms_of_service', 'agb', 'clause'] },
|
// ── Nach Nutzungskontext sortiert ──────────────────────────────────────
|
||||||
{ key: 'impressum', label: 'Impressum', types: ['impressum'] },
|
|
||||||
{ key: 'dpa', label: 'AVV/DPA', types: ['dpa'] },
|
// Jede Website / App braucht:
|
||||||
{ key: 'nda', label: 'NDA', types: ['nda'] },
|
{ key: 'website', label: 'Website / App', types: ['privacy_policy', 'impressum', 'cookie_policy', 'cookie_banner', 'social_media_dsi'] },
|
||||||
{ key: 'sla', label: 'SLA', types: ['sla'] },
|
|
||||||
{ key: 'acceptable_use', label: 'AUP', types: ['acceptable_use'] },
|
// Online-Shop / E-Commerce:
|
||||||
{ key: 'widerruf', label: 'Widerruf', types: ['widerruf'] },
|
{ key: 'shop', label: 'Online-Shop', types: ['agb', 'widerruf', 'privacy_policy', 'impressum', 'cookie_policy', 'cookie_banner'] },
|
||||||
{ key: 'cookie', label: 'Cookie', types: ['cookie_policy', 'cookie_banner'] },
|
|
||||||
{ key: 'cloud', label: 'Cloud', types: ['cloud_service_agreement'] },
|
// SaaS / Cloud-Dienst:
|
||||||
{ key: 'misc', label: 'Weitere', types: ['community_guidelines', 'copyright_policy', 'data_usage_clause'] },
|
{ key: 'saas', label: 'SaaS / Cloud', types: ['agb', 'dpa', 'sla', 'cloud_service_agreement', 'privacy_policy', 'terms_of_use'] },
|
||||||
{ key: 'dsfa', label: 'DSFA', types: ['dsfa'] },
|
|
||||||
{ key: 'dsr', label: 'DSR-Prozesse', types: [
|
// App / Plattform mit Nutzern:
|
||||||
|
{ key: 'platform', label: 'App / Plattform', types: ['terms_of_use', 'community_guidelines', 'privacy_policy', 'agb', 'acceptable_use', 'media_content_policy', 'copyright_policy'] },
|
||||||
|
|
||||||
|
// Vertraege mit Geschaeftspartnern:
|
||||||
|
{ key: 'contracts', label: 'Vertraege (B2B)', types: ['dpa', 'nda', 'sla', 'cloud_service_agreement', 'data_usage_clause'] },
|
||||||
|
|
||||||
|
// Drittlandtransfer:
|
||||||
|
{ key: 'third_country', label: 'Drittlandtransfer', types: ['transfer_impact_assessment', 'scc_companion'] },
|
||||||
|
|
||||||
|
// ── Interne Compliance-Dokumente ──────────────────────────────────────
|
||||||
|
|
||||||
|
// DSGVO-Kernpflichten:
|
||||||
|
{ key: 'dsgvo_core', label: 'DSGVO-Pflichten', types: ['tom_documentation', 'vvt_register', 'loeschkonzept', 'dsfa', 'pflichtenregister'] },
|
||||||
|
|
||||||
|
// Betroffenenrechte:
|
||||||
|
{ key: 'dsr', label: 'Betroffenenrechte', types: [
|
||||||
'dsr_process_art15', 'dsr_process_art16', 'dsr_process_art17',
|
'dsr_process_art15', 'dsr_process_art16', 'dsr_process_art17',
|
||||||
'dsr_process_art18', 'dsr_process_art19', 'dsr_process_art20', 'dsr_process_art21',
|
'dsr_process_art18', 'dsr_process_art19', 'dsr_process_art20', 'dsr_process_art21',
|
||||||
]},
|
]},
|
||||||
|
|
||||||
|
// Datenschutz-Informationen (alle DSI-Typen):
|
||||||
|
{ key: 'dsi', label: 'Datenschutzinfos', types: ['privacy_policy', 'applicant_dsi', 'employee_dsi', 'social_media_dsi', 'video_conference_dsi', 'informationspflichten'] },
|
||||||
|
|
||||||
|
// Einwilligungen:
|
||||||
|
{ key: 'consent', label: 'Einwilligungen', types: ['consent_texts', 'cookie_banner', 'verpflichtungserklaerung'] },
|
||||||
|
|
||||||
|
// ── Sicherheit & IT ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{ key: 'security_concepts', label: 'Sicherheitskonzepte', types: ['it_security_concept', 'data_protection_concept', 'backup_recovery_concept', 'logging_concept', 'incident_response_plan', 'access_control_concept', 'risk_management_concept', 'isms_manual'] },
|
||||||
|
|
||||||
|
{ key: 'security_policies', label: 'Sicherheitsrichtlinien', types: [
|
||||||
|
'information_security_policy', 'access_control_policy', 'password_policy', 'encryption_policy',
|
||||||
|
'cybersecurity_policy', 'incident_response_policy', 'logging_policy', 'patch_management_policy',
|
||||||
|
'vulnerability_management_policy', 'secrets_management_policy', 'devsecops_policy',
|
||||||
|
'cloud_security_policy', 'change_management_policy', 'asset_management_policy', 'backup_policy',
|
||||||
|
]},
|
||||||
|
|
||||||
|
// ── Organisation & HR ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{ key: 'hr', label: 'HR & Mitarbeiter', types: ['applicant_dsi', 'employee_dsi', 'employee_security_policy', 'security_awareness_policy', 'remote_work_policy', 'offboarding_policy', 'byod_policy', 'ai_usage_policy', 'whistleblower_policy', 'verpflichtungserklaerung'] },
|
||||||
|
|
||||||
|
{ key: 'data_governance', label: 'Daten-Governance', types: ['data_protection_policy', 'data_classification_policy', 'data_retention_policy', 'data_transfer_policy', 'privacy_incident_policy'] },
|
||||||
|
|
||||||
|
{ key: 'vendor', label: 'Lieferanten / Vendor', types: ['vendor_risk_management_policy', 'third_party_security_policy', 'supplier_security_policy', 'dpa'] },
|
||||||
|
|
||||||
|
{ key: 'bcm', label: 'BCM / Notfall', types: ['business_continuity_policy', 'disaster_recovery_policy', 'crisis_management_policy', 'incident_response_plan'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -41,6 +83,8 @@ export const SECTION_LABELS: Record<keyof TemplateContext, string> = {
|
|||||||
CONSENT: 'Cookie / Einwilligung',
|
CONSENT: 'Cookie / Einwilligung',
|
||||||
HOSTING: 'Hosting-Provider',
|
HOSTING: 'Hosting-Provider',
|
||||||
FEATURES: 'Dokument-Features & Textbausteine',
|
FEATURES: 'Dokument-Features & Textbausteine',
|
||||||
|
TOM: 'TOM-Dokumentation',
|
||||||
|
DPA: 'AVV / Auftragsverarbeitung',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FieldType = 'text' | 'email' | 'number' | 'select' | 'textarea' | 'boolean'
|
export type FieldType = 'text' | 'email' | 'number' | 'select' | 'textarea' | 'boolean'
|
||||||
@@ -186,6 +230,192 @@ export const SECTION_FIELDS: Record<keyof TemplateContext, FieldDef[]> = {
|
|||||||
{ key: 'EDITORIAL_RESPONSIBLE_ADDRESS', label: 'V.i.S.d.P. Adresse' },
|
{ key: 'EDITORIAL_RESPONSIBLE_ADDRESS', label: 'V.i.S.d.P. Adresse' },
|
||||||
{ key: 'HAS_DISPUTE_RESOLUTION', label: 'Streitbeilegungshinweis', type: 'boolean' },
|
{ key: 'HAS_DISPUTE_RESOLUTION', label: 'Streitbeilegungshinweis', type: 'boolean' },
|
||||||
{ key: 'DISPUTE_RESOLUTION_TEXT', label: 'Streitbeilegungstext', type: 'textarea', span: true },
|
{ key: 'DISPUTE_RESOLUTION_TEXT', label: 'Streitbeilegungstext', type: 'textarea', span: true },
|
||||||
|
// ── SaaS AGB v2 ─────────────────────────────────────────────────────────
|
||||||
|
{ key: 'B2B_ONLY', label: 'Nur B2B (keine Verbraucher)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_END_USERS', label: 'Endkunden-Weitergabe (B2B2C)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_MODULAR_PACKAGES', label: 'Modulare Leistungspakete', type: 'boolean' },
|
||||||
|
{ key: 'HAS_STORAGE', label: 'Speicherplatz als Leistung', type: 'boolean' },
|
||||||
|
{ key: 'HAS_STORAGE_LIMITS', label: 'Speicherplatz begrenzt', type: 'boolean' },
|
||||||
|
{ key: 'HAS_TRIAL', label: 'Kostenlose Testphase', type: 'boolean' },
|
||||||
|
{ key: 'TRIAL_DAYS', label: 'Testphase (Tage)', type: 'select', opts: ['7', '14', '30'] },
|
||||||
|
{ key: 'HAS_PRICE_ADJUSTMENT', label: 'Preisanpassungsklausel', type: 'boolean' },
|
||||||
|
{ key: 'PRICE_ADJUSTMENT_NOTICE_WEEKS', label: 'Ankündigung Preisanpassung (Wo.)', type: 'select', opts: ['4', '8', '12'] },
|
||||||
|
{ key: 'PRICE_INCREASE_THRESHOLD_PERCENT', label: 'Schwelle Sonderkündigung (%)', type: 'select', opts: ['5', '10', '15'] },
|
||||||
|
{ key: 'HAS_UPLOAD', label: 'Datei-Upload Funktion', type: 'boolean' },
|
||||||
|
{ key: 'NO_AUDIT_PROOF_STORAGE', label: 'Keine revisionssichere Speicherung', type: 'boolean' },
|
||||||
|
{ key: 'HAS_API_ACCESS', label: 'API-Zugang', type: 'boolean' },
|
||||||
|
{ key: 'HAS_MAINTENANCE_ACCESS', label: 'Fernwartungszugang (On-Premise)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_MAX_DOWNTIME', label: 'Max. Ausfalldauer begrenzt', type: 'boolean' },
|
||||||
|
{ key: 'MAX_DOWNTIME_DAYS', label: 'Max. Ausfalldauer (Tage)', type: 'number' },
|
||||||
|
{ key: 'HAS_IP_INDEMNIFICATION', label: 'IP-Freistellung (Schutzrechte)', type: 'boolean' },
|
||||||
|
{ key: 'LIABILITY_MULTIPLIER', label: 'Haftungsdeckel (x Jahreslizenz)', type: 'select', opts: ['1', '2', '3'] },
|
||||||
|
{ key: 'HAS_REFERENCE_MARKETING', label: 'Referenzmarketing (Logo-Nutzung)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_WHITELABEL', label: 'Whitelabel-Paket vorhanden', type: 'boolean' },
|
||||||
|
{ key: 'HAS_FORCE_MAJEURE', label: 'Force-Majeure-Klausel', type: 'boolean' },
|
||||||
|
{ key: 'HAS_COMMUNITY_GUIDELINES', label: 'Community Guidelines als Bestandteil', type: 'boolean' },
|
||||||
|
// ── Community Guidelines (modular) ──────────────────────────────────────
|
||||||
|
{ key: 'TONE_FRIENDLY', label: 'Ton: Freundlich/Einladend', type: 'boolean' },
|
||||||
|
{ key: 'TONE_EDITORIAL', label: 'Ton: Editorial/Sachlich', type: 'boolean' },
|
||||||
|
{ key: 'TONE_FORMAL', label: 'Ton: Formal/Juristisch', type: 'boolean' },
|
||||||
|
{ key: 'HAS_MEDIA_UPLOADS', label: 'Plattform: Medien-Uploads (Bilder/Videos)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_MESSAGING', label: 'Plattform: Messaging/Chat', type: 'boolean' },
|
||||||
|
{ key: 'HAS_MARKETPLACE', label: 'Plattform: Marketplace/Handel', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_ILLEGAL', label: '↳ Details: Rechtswidrige Inhalte', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_HATE_SPEECH', label: '↳ Details: Hassrede', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_FRAUD', label: '↳ Details: Betrug/Deepfakes', type: 'boolean' },
|
||||||
|
{ key: 'EXCEPTIONS_FRAUD', label: '↳ Ausnahmen: Parodie/Satire/Kunst', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_PRIVACY', label: '↳ Details: Sicherheit/Privatsphäre', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_VIOLENCE', label: '↳ Details: Gewalt (bei Medien-Uploads)', type: 'boolean' },
|
||||||
|
{ key: 'EXCEPTIONS_VIOLENCE', label: '↳ Ausnahmen: Kampfsport/Journalismus/Kunst', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_PORNOGRAPHY', label: '↳ Details: Pornografie (bei Medien-Uploads)', type: 'boolean' },
|
||||||
|
{ key: 'EXCEPTIONS_PORNOGRAPHY', label: '↳ Ausnahmen: Bodypainting/Stillen/Medizin', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_SELF_HARM', label: '↳ Details: Suizid/Selbstverletzung', type: 'boolean' },
|
||||||
|
{ key: 'EXCEPTIONS_SELF_HARM', label: '↳ Ausnahmen: Prävention/Journalismus', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_EXPLOITATION', label: '↳ Details: Ausbeutung/Missbrauch/CSAM', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_HARASSMENT', label: '↳ Details: Sexuelle Belästigung (bei Messaging)', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_DANGEROUS_PRODUCTS', label: '↳ Details: Gefährliche Produkte (bei Marketplace)', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_TERRORISM', label: '↳ Details: Terrorismus/Gefährliche Gruppen', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_DANGEROUS_ACTIVITIES', label: '↳ Details: Gefährdende Aktivitäten', type: 'boolean' },
|
||||||
|
{ key: 'GUIDELINES_URL', label: 'URL der Richtlinien' },
|
||||||
|
// ── Medien & Content Module ─────────────────────────────────────────────
|
||||||
|
{ key: 'IS_JOURNALISTIC_MEDIA', label: 'Journalistisches Medium (MStV §§ 18-22)', type: 'boolean' },
|
||||||
|
{ key: 'EDITORIAL_EMAIL', label: 'Redaktions-E-Mail (Gegendarstellung)', type: 'email' },
|
||||||
|
{ key: 'HAS_AI_GENERATED_CONTENT', label: 'KI-generierte Inhalte (AI Act Art. 50)', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_AI_LABELING', label: '↳ Detaillierte KI-Kennzeichnungstabelle', type: 'boolean' },
|
||||||
|
{ key: 'HAS_SPONSORED_CONTENT', label: 'Bezahlte/werbliche Inhalte (§ 5a UWG)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_PRESS_COUNCIL', label: 'Pressekodex-Selbstverpflichtung (Presserat)', type: 'boolean' },
|
||||||
|
// ── Nutzungsbedingungen ─────────────────────────────────────────────────
|
||||||
|
{ key: 'HAS_UGC', label: 'User Generated Content', type: 'boolean' },
|
||||||
|
{ key: 'HAS_CONTENT_LICENSING', label: 'Content Licensing (Nutzer-zu-Nutzer)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_TDM_OPTOUT', label: 'Text- und Data-Mining Opt-out', type: 'boolean' },
|
||||||
|
{ key: 'HAS_CONTENT_AUTHENTICITY', label: 'Content Authenticity (kryptogr. Herkunft)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_TIPPING', label: 'Tipping/Anerkennungsfunktion', type: 'boolean' },
|
||||||
|
{ key: 'HAS_CRYPTO_PAYMENTS', label: 'Krypto-Zahlungen', type: 'boolean' },
|
||||||
|
{ key: 'HAS_INTEGRATED_WALLET', label: 'Integriertes Wallet (Non-Custodial)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_IDENTITY_VERIFICATION', label: 'Identitätsprüfung erforderlich', type: 'boolean' },
|
||||||
|
{ key: 'HAS_COPYRIGHT_TAKEDOWN', label: 'Copyright Takedown-Verfahren', type: 'boolean' },
|
||||||
|
{ key: 'HAS_PAID_USER_ACCOUNTS', label: 'Kostenpflichtige Nutzeraccounts', type: 'boolean' },
|
||||||
|
{ key: 'HAS_EU_USERS', label: 'EU-weite Nutzer (Verbraucherschutz)', type: 'boolean' },
|
||||||
|
{ key: 'MFA_REQUIRED', label: 'MFA verpflichtend für Nutzer', type: 'boolean' },
|
||||||
|
{ key: 'DATA_EXPORT_BEFORE_DELETION', label: 'Datenexport vor Kontolöschung', type: 'boolean' },
|
||||||
|
{ key: 'EXPORT_BEFORE_DELETION_DAYS', label: 'Exportfrist (Tage)', type: 'select', opts: ['7', '14', '30'] },
|
||||||
|
{ key: 'MIN_AGE', label: 'Mindestalter', type: 'select', opts: ['13', '16', '18'] },
|
||||||
|
{ key: 'ALLOWS_MINORS', label: 'Minderjährige mit Eltern-Einwilligung', type: 'boolean' },
|
||||||
|
{ key: 'TIPPING_FEE_PERCENT', label: 'Tipping-Gebühr (%)', type: 'number' },
|
||||||
|
{ key: 'SUPPORTED_CURRENCIES', label: 'Unterstützte Währungen/Token' },
|
||||||
|
// ── Widerrufsbelehrung ──────────────────────────────────────────────────
|
||||||
|
{ key: 'HAS_PHYSICAL_GOODS', label: 'Physische Waren (Rücksendung)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_COMBO_PACKAGE', label: 'Kombi-Paket (Hardware + Software)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_DIGITAL_CONTENT', label: 'Digitale Inhalte (§ 356 Abs. 5 BGB)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_SAAS_SERVICE', label: 'SaaS-Dienstleistung (§ 356 Abs. 4 BGB)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_IOT_BUNDLE', label: 'Verbundenes Produkt (Hardware + App/Cloud)', type: 'boolean' },
|
||||||
|
{ key: 'IOT_SEPARATE_CONTRACTS', label: '↳ HW und Cloud getrennt widerrufbar', type: 'boolean' },
|
||||||
|
{ key: 'RETURN_ADDRESS', label: 'Rücksendeadresse (Servicecenter)' },
|
||||||
|
// ── Social Media DSI ────────────────────────────────────────────────────
|
||||||
|
{ key: 'HAS_FACEBOOK', label: 'Facebook & Instagram', type: 'boolean' },
|
||||||
|
{ key: 'HAS_YOUTUBE', label: 'YouTube', type: 'boolean' },
|
||||||
|
{ key: 'HAS_LINKEDIN', label: 'LinkedIn', type: 'boolean' },
|
||||||
|
{ key: 'HAS_TIKTOK', label: 'TikTok', type: 'boolean' },
|
||||||
|
{ key: 'HAS_X_TWITTER', label: 'X (Twitter)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_META_PIXEL', label: 'Meta Pixel (Konversionsmessung)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_RECRUITING_VIA_SOCIAL', label: 'Personalgewinnung über Social Media', type: 'boolean' },
|
||||||
|
{ key: 'SOCIAL_MEDIA_PLATFORMS_LIST', label: 'Plattform-Liste (Text)', type: 'textarea', span: true },
|
||||||
|
// ── DSI Erweiterungen ───────────────────────────────────────────────────
|
||||||
|
{ key: 'DSI_TITLE', label: 'Titel', type: 'select', opts: ['Datenschutzerklaerung', 'Datenschutzinformation'] },
|
||||||
|
{ key: 'SERVICE_SCOPE_DESCRIPTION', label: 'Geltungsbereich (z.B. "die App xy" / "den Online-Shop")' },
|
||||||
|
{ key: 'HAS_ONLINE_SHOP', label: 'Online-Shop Funktionen', type: 'boolean' },
|
||||||
|
{ key: 'HAS_PICKUP_STATION', label: 'Abholstationen', type: 'boolean' },
|
||||||
|
{ key: 'HAS_SUBSCRIPTION', label: 'Abonnement-Modell', type: 'boolean' },
|
||||||
|
{ key: 'HAS_PRODUCT_REVIEWS', label: 'Produktbewertungen', type: 'boolean' },
|
||||||
|
{ key: 'HAS_PARENT_COMPANY', label: 'Konzernstruktur (Mutter-/Tochtergesellschaft)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_LOCATION', label: 'Standortdaten erhoben', type: 'boolean' },
|
||||||
|
{ key: 'HAS_E2E_ENCRYPTION', label: 'Ende-zu-Ende-Verschlüsselung (Messaging)', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_RIGHTS', label: 'Ausführliche Rechte-Beschreibung', type: 'boolean' },
|
||||||
|
{ key: 'PROCESSOR_LIST_URL', label: 'URL Auftragsverarbeiter-Liste' },
|
||||||
|
// ── Whistleblower ───────────────────────────────────────────────────────
|
||||||
|
{ key: 'WHISTLEBLOWER_CONTACT_NAME', label: 'Meldestelle: Ansprechperson' },
|
||||||
|
{ key: 'WHISTLEBLOWER_CONTACT_ROLE', label: 'Meldestelle: Funktion/Rolle' },
|
||||||
|
{ key: 'WHISTLEBLOWER_EMAIL', label: 'Meldestelle: E-Mail', type: 'email' },
|
||||||
|
{ key: 'WHISTLEBLOWER_PHONE', label: 'Meldestelle: Telefon' },
|
||||||
|
{ key: 'WHISTLEBLOWER_URL', label: 'Meldestelle: Online-Formular URL' },
|
||||||
|
{ key: 'HAS_ANONYMOUS_REPORTING', label: 'Anonyme Meldungen möglich', type: 'boolean' },
|
||||||
|
{ key: 'HAS_EXTERNAL_REPORTING', label: 'Externe Meldestelle (BfJ) erwähnen', type: 'boolean' },
|
||||||
|
// ── Bewerber-DSI ────────────────────────────────────────────────────────
|
||||||
|
{ key: 'HAS_VIDEO_INTERVIEW', label: 'Video-Interviews', type: 'boolean' },
|
||||||
|
{ key: 'HAS_ASSESSMENT', label: 'Assessment-Center/Tests', type: 'boolean' },
|
||||||
|
{ key: 'HAS_TALENT_POOL', label: 'Talentpool (Einwilligung)', type: 'boolean' },
|
||||||
|
{ key: 'TALENT_POOL_MONTHS', label: 'Talentpool Aufbewahrung (Monate)', type: 'select', opts: ['6', '12', '24'] },
|
||||||
|
{ key: 'HAS_RECRUITING_AGENCY', label: 'Personalvermittler', type: 'boolean' },
|
||||||
|
{ key: 'HAS_RECRUITING_SOFTWARE', label: 'Bewerbermanagement-Software', type: 'boolean' },
|
||||||
|
{ key: 'HAS_EMPLOYEE_REFERRAL', label: 'Mitarbeiterempfehlungen', type: 'boolean' },
|
||||||
|
// ── Mitarbeiter-DSI ─────────────────────────────────────────────────────
|
||||||
|
{ key: 'HAS_IT_USAGE_MONITORING', label: 'IT-Nutzungsüberwachung', type: 'boolean' },
|
||||||
|
{ key: 'HAS_COMPANY_VEHICLE', label: 'Dienstfahrzeuge/Fuhrpark', type: 'boolean' },
|
||||||
|
{ key: 'HAS_ACCESS_CONTROL', label: 'Zutrittskontrolle (Chipkarte)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_VIDEO_SURVEILLANCE', label: 'Videoüberwachung (Arbeitsplatz)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_COMPANY_PENSION', label: 'Betriebliche Altersvorsorge', type: 'boolean' },
|
||||||
|
{ key: 'HAS_EXTERNAL_HR_SOFTWARE', label: 'Externe HR-Software', type: 'boolean' },
|
||||||
|
{ key: 'HAS_WORKS_COUNCIL', label: 'Betriebsrat vorhanden', type: 'boolean' },
|
||||||
|
{ key: 'HAS_SPECIAL_CATEGORIES_EMPLOYEES', label: 'Besondere Datenkategorien (Gesundheit, Religion)', type: 'boolean' },
|
||||||
|
],
|
||||||
|
// ── TOM ─────────────────────────────────────────────────────────────────
|
||||||
|
TOM: [
|
||||||
|
{ key: 'ISB_NAME', label: 'IT-Sicherheitsbeauftragter' },
|
||||||
|
{ key: 'GF_NAME', label: 'Geschäftsführung' },
|
||||||
|
{ key: 'DOCUMENT_VERSION', label: 'Dokumentversion' },
|
||||||
|
{ key: 'NEXT_REVIEW_DATE', label: 'Nächste Prüfung (JJJJ-MM-TT)' },
|
||||||
|
{ key: 'HAS_MFA', label: 'Multi-Faktor-Authentifizierung aktiv', type: 'boolean' },
|
||||||
|
{ key: 'HAS_USB_LOCKED', label: 'USB-Schnittstellen physisch gesperrt', type: 'boolean' },
|
||||||
|
{ key: 'HAS_MOBILE_MEDIA', label: 'Mobile Datenträger im Einsatz', type: 'boolean' },
|
||||||
|
{ key: 'HAS_FOUR_EYES_DELETE', label: 'Vier-Augen-Prinzip für Löschungen', type: 'boolean' },
|
||||||
|
{ key: 'LOG_RETENTION_MONTHS', label: 'Log-Aufbewahrung (Monate)', type: 'select', opts: ['3', '6', '12', '24'] },
|
||||||
|
{ key: 'DIN_66399_LEVEL', label: 'Vernichtungsstufe (DIN 66399)', type: 'select', opts: ['1', '2', '3', '4', '5', '6', '7'] },
|
||||||
|
{ key: 'HAS_EXTERNAL_DESTRUCTION', label: 'Externer Vernichtungsdienstleister', type: 'boolean' },
|
||||||
|
{ key: 'HAS_PHYSICAL_TRANSPORT', label: 'Physischer Datenträgertransport', type: 'boolean' },
|
||||||
|
{ key: 'HAS_THIRD_COUNTRY_TRANSFER', label: 'Datenübermittlung in Drittländer', type: 'boolean' },
|
||||||
|
{ key: 'AVAILABILITY_TARGET', label: 'Verfügbarkeitsziel', type: 'select', opts: ['99.0', '99.5', '99.9', '99.99'] },
|
||||||
|
{ key: 'HAS_USV', label: 'USV vorhanden', type: 'boolean' },
|
||||||
|
{ key: 'HAS_REDUNDANCY', label: 'Redundante Systeme / Failover', type: 'boolean' },
|
||||||
|
{ key: 'HAS_GEO_REDUNDANCY', label: 'Georedundanter Standort', type: 'boolean' },
|
||||||
|
{ key: 'HAS_OWN_SERVER_ROOM', label: 'Eigener Serverraum', type: 'boolean' },
|
||||||
|
{ key: 'HAS_CLOUD_SERVICES', label: 'Cloud-Dienste im Einsatz', type: 'boolean' },
|
||||||
|
{ key: 'HAS_MULTI_TENANT', label: 'Multi-Tenant-System', type: 'boolean' },
|
||||||
|
{ key: 'SEPARATION_TYPE', label: 'Art der Mandantentrennung', type: 'select', opts: ['logisch', 'physisch', 'eigene Infrastruktur'] },
|
||||||
|
{ key: 'HAS_TEST_DATA_ANONYMIZED', label: 'Testdaten anonymisiert/synthetisch', type: 'boolean' },
|
||||||
|
],
|
||||||
|
// ── DPA / AVV ─────────────────────────────────────────────────────────
|
||||||
|
DPA: [
|
||||||
|
{ key: 'AG_NAME', label: 'Auftraggeber (Name/Firma)' },
|
||||||
|
{ key: 'AG_STRASSE', label: 'Auftraggeber Straße' },
|
||||||
|
{ key: 'AG_PLZ_ORT', label: 'Auftraggeber PLZ Ort' },
|
||||||
|
{ key: 'AN_NAME', label: 'Auftragnehmer (Name/Firma)' },
|
||||||
|
{ key: 'AN_STRASSE', label: 'Auftragnehmer Straße' },
|
||||||
|
{ key: 'AN_PLZ_ORT', label: 'Auftragnehmer PLZ Ort' },
|
||||||
|
{ key: 'VERARBEITUNGSGEGENSTAND', label: 'Gegenstand der Verarbeitung', type: 'textarea', span: true },
|
||||||
|
{ key: 'VERARBEITUNGSZWECK', label: 'Zweck der Verarbeitung', type: 'textarea', span: true },
|
||||||
|
{ key: 'VERARBEITUNGSARTEN', label: 'Art der Verarbeitung (Erheben, Speichern, …)', type: 'textarea', span: true },
|
||||||
|
{ key: 'DATENKATEGORIEN', label: 'Datenkategorien', type: 'textarea', span: true },
|
||||||
|
{ key: 'PERSONENKATEGORIEN', label: 'Betroffene Personenkategorien', type: 'textarea', span: true },
|
||||||
|
{ key: 'BREACH_NOTIFICATION_HOURS', label: 'Meldefrist Datenschutzverletzung (h)', type: 'select', opts: ['12', '24', '48'] },
|
||||||
|
{ key: 'INSTRUCTION_RETENTION_YEARS', label: 'Aufbewahrung Weisungen (Jahre)', type: 'select', opts: ['3', '5', '10'] },
|
||||||
|
{ key: 'SUB_PROCESSOR_NOTICE_WEEKS', label: 'Ankündigung Sub-AV (Wochen)', type: 'select', opts: ['2', '4', '6'] },
|
||||||
|
{ key: 'SUB_PROCESSOR_OBJECTION_WEEKS', label: 'Widerspruchsfrist Sub-AV (Wochen)', type: 'select', opts: ['2', '4'] },
|
||||||
|
{ key: 'DATA_EXPORT_FORMAT', label: 'Datenformat bei Rückgabe', type: 'select', opts: ['CSV/JSON', 'CSV', 'JSON', 'XML', 'nach Vereinbarung'] },
|
||||||
|
{ key: 'RETURN_CHOICE_WEEKS', label: 'Frist Rückgabe-Wahl (Wochen)', type: 'select', opts: ['2', '4', '8'] },
|
||||||
|
{ key: 'DELETION_DAYS', label: 'Löschfrist nach Vertragsende (Tage)', type: 'select', opts: ['30', '60', '90'] },
|
||||||
|
{ key: 'AN_DSB_NAME', label: 'DSB Auftragnehmer Name' },
|
||||||
|
{ key: 'AN_DSB_EMAIL', label: 'DSB Auftragnehmer E-Mail', type: 'email' },
|
||||||
|
{ key: 'VERTRAGSDATUM', label: 'Vertragsdatum (JJJJ-MM-TT)' },
|
||||||
|
{ key: 'GERICHTSSTAND', label: 'Gerichtsstand' },
|
||||||
|
{ key: 'HAS_LIABILITY_PROTECTION', label: 'Haftungsschutz bei Weisung (§ 4.1a)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_SUPPORT_COST_CLAUSE', label: 'Kostenregelung Unterstützung (§ 7.4)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_SUB_PROCESSOR_SILENCE_APPROVAL', label: 'Zustimmungsfiktion bei Sub-AV (§ 8.2a)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_SUB_PROCESSOR_TERMINATION_RIGHT', label: 'Kündigungsrecht bei Sub-AV-Widerspruch (§ 8.3)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_REACTIVATION_PERIOD', label: 'Reaktivierungszeitraum (§ 10.1)', type: 'boolean' },
|
||||||
|
{ key: 'REACTIVATION_MONTHS', label: 'Reaktivierung (Monate)', type: 'select', opts: ['1', '3', '6'] },
|
||||||
|
{ key: 'HAS_RETURN_COST_CLAUSE', label: 'Kosten für Datenrückgabe (§ 10.5)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_GERICHTSSTAND_CLAUSE', label: 'Gerichtsstandklausel (§ 11.1)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_UNILATERAL_CHANGE_RIGHT', label: '⚠️ Einseitiges Änderungsrecht AN (§ 11.6)', type: 'boolean' },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type {
|
|||||||
TemplateContext,
|
TemplateContext,
|
||||||
ProviderCtx,
|
ProviderCtx,
|
||||||
ComputedFlags,
|
ComputedFlags,
|
||||||
|
TOMCtx,
|
||||||
|
DPACtx,
|
||||||
} from './contextBridge'
|
} from './contextBridge'
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -44,6 +46,8 @@ export function contextToPlaceholders(ctx: TemplateContext): Record<string, stri
|
|||||||
const con = ctx.CONSENT
|
const con = ctx.CONSENT
|
||||||
const h = ctx.HOSTING
|
const h = ctx.HOSTING
|
||||||
const f = ctx.FEATURES
|
const f = ctx.FEATURES
|
||||||
|
const tom = ctx.TOM
|
||||||
|
const dpa = ctx.DPA
|
||||||
|
|
||||||
const address = providerAddress(p)
|
const address = providerAddress(p)
|
||||||
|
|
||||||
@@ -180,6 +184,86 @@ export function contextToPlaceholders(ctx: TemplateContext): Record<string, stri
|
|||||||
'{{LIMITATION_CAP_TEXT}}': str(f.LIMITATION_CAP_TEXT),
|
'{{LIMITATION_CAP_TEXT}}': str(f.LIMITATION_CAP_TEXT),
|
||||||
'{{CONSUMER_WITHDRAWAL_TEXT}}': str(f.CONSUMER_WITHDRAWAL_TEXT),
|
'{{CONSUMER_WITHDRAWAL_TEXT}}': str(f.CONSUMER_WITHDRAWAL_TEXT),
|
||||||
'{{SUPPORT_CHANNELS_TEXT}}': str(f.SUPPORT_CHANNELS_TEXT),
|
'{{SUPPORT_CHANNELS_TEXT}}': str(f.SUPPORT_CHANNELS_TEXT),
|
||||||
|
|
||||||
|
// --- TOM ---
|
||||||
|
'{{ISB_NAME}}': str(tom.ISB_NAME),
|
||||||
|
'{{GF_NAME}}': str(tom.GF_NAME),
|
||||||
|
'{{DOCUMENT_VERSION}}': str(tom.DOCUMENT_VERSION),
|
||||||
|
'{{NEXT_REVIEW_DATE}}': str(tom.NEXT_REVIEW_DATE),
|
||||||
|
|
||||||
|
// --- DPA / AVV ---
|
||||||
|
'{{AG_NAME}}': str(dpa.AG_NAME) || str(c.LEGAL_NAME),
|
||||||
|
'{{AG_STRASSE}}': str(dpa.AG_STRASSE) || str(c.ADDRESS_LINE),
|
||||||
|
'{{AG_PLZ_ORT}}': str(dpa.AG_PLZ_ORT) || [c.POSTAL_CODE, c.CITY].filter(Boolean).join(' '),
|
||||||
|
'{{AN_NAME}}': str(dpa.AN_NAME) || str(p.LEGAL_NAME),
|
||||||
|
'{{AN_STRASSE}}': str(dpa.AN_STRASSE) || str(p.ADDRESS_LINE),
|
||||||
|
'{{AN_PLZ_ORT}}': str(dpa.AN_PLZ_ORT) || [p.POSTAL_CODE, p.CITY].filter(Boolean).join(' '),
|
||||||
|
'{{VERARBEITUNGSGEGENSTAND}}': str(dpa.VERARBEITUNGSGEGENSTAND),
|
||||||
|
'{{VERARBEITUNGSZWECK}}': str(dpa.VERARBEITUNGSZWECK),
|
||||||
|
'{{VERARBEITUNGSARTEN}}': str(dpa.VERARBEITUNGSARTEN),
|
||||||
|
'{{DATENKATEGORIEN}}': str(dpa.DATENKATEGORIEN),
|
||||||
|
'{{PERSONENKATEGORIEN}}': str(dpa.PERSONENKATEGORIEN),
|
||||||
|
'{{BREACH_NOTIFICATION_HOURS}}': str(dpa.BREACH_NOTIFICATION_HOURS) || str(sec.INCIDENT_NOTICE_HOURS),
|
||||||
|
'{{INSTRUCTION_RETENTION_YEARS}}': str(dpa.INSTRUCTION_RETENTION_YEARS),
|
||||||
|
'{{SUB_PROCESSOR_NOTICE_WEEKS}}': str(dpa.SUB_PROCESSOR_NOTICE_WEEKS),
|
||||||
|
'{{SUB_PROCESSOR_OBJECTION_WEEKS}}': str(dpa.SUB_PROCESSOR_OBJECTION_WEEKS),
|
||||||
|
'{{DATA_EXPORT_FORMAT}}': str(dpa.DATA_EXPORT_FORMAT),
|
||||||
|
'{{RETURN_CHOICE_WEEKS}}': str(dpa.RETURN_CHOICE_WEEKS),
|
||||||
|
'{{DELETION_DAYS}}': str(dpa.DELETION_DAYS),
|
||||||
|
'{{REACTIVATION_MONTHS}}': str(dpa.REACTIVATION_MONTHS),
|
||||||
|
'{{TERMINATION_WEEKS}}': str(dpa.TERMINATION_WEEKS),
|
||||||
|
'{{CHANGE_NOTICE_WEEKS}}': str(dpa.CHANGE_NOTICE_WEEKS),
|
||||||
|
'{{THIRD_COUNTRY_OBJECTION_WEEKS}}': str(dpa.THIRD_COUNTRY_OBJECTION_WEEKS),
|
||||||
|
'{{AN_DSB_NAME}}': str(dpa.AN_DSB_NAME) || str(prv.DPO_NAME),
|
||||||
|
'{{AN_DSB_EMAIL}}': str(dpa.AN_DSB_EMAIL) || str(prv.DPO_EMAIL),
|
||||||
|
'{{AG_ORT}}': str(dpa.AG_ORT),
|
||||||
|
'{{AN_ORT}}': str(dpa.AN_ORT),
|
||||||
|
'{{VERTRAGSDATUM}}': str(dpa.VERTRAGSDATUM) || str(l.VERSION_DATE),
|
||||||
|
'{{AG_UNTERZEICHNER_NAME}}': str(dpa.AG_UNTERZEICHNER_NAME),
|
||||||
|
'{{AG_UNTERZEICHNER_FUNKTION}}': str(dpa.AG_UNTERZEICHNER_FUNKTION),
|
||||||
|
'{{AN_UNTERZEICHNER_NAME}}': str(dpa.AN_UNTERZEICHNER_NAME) || str(p.CEO_NAME),
|
||||||
|
'{{AN_UNTERZEICHNER_FUNKTION}}': str(dpa.AN_UNTERZEICHNER_FUNKTION),
|
||||||
|
'{{GERICHTSSTAND}}': str(dpa.GERICHTSSTAND) || str(l.JURISDICTION_CITY),
|
||||||
|
|
||||||
|
// --- FEATURES: Whistleblower ---
|
||||||
|
'{{WHISTLEBLOWER_CONTACT_NAME}}': str(f.WHISTLEBLOWER_CONTACT_NAME),
|
||||||
|
'{{WHISTLEBLOWER_CONTACT_ROLE}}': str(f.WHISTLEBLOWER_CONTACT_ROLE),
|
||||||
|
'{{WHISTLEBLOWER_EMAIL}}': str(f.WHISTLEBLOWER_EMAIL),
|
||||||
|
'{{WHISTLEBLOWER_PHONE}}': str(f.WHISTLEBLOWER_PHONE),
|
||||||
|
'{{WHISTLEBLOWER_URL}}': str(f.WHISTLEBLOWER_URL),
|
||||||
|
// --- FEATURES: Video Conference ---
|
||||||
|
'{{VIDEO_PROVIDER_NAME}}': str(f.VIDEO_PROVIDER_NAME),
|
||||||
|
'{{VIDEO_PROVIDER_COUNTRY}}': str(f.VIDEO_PROVIDER_COUNTRY),
|
||||||
|
'{{VIDEO_PROVIDER_ROLE}}': str(f.VIDEO_PROVIDER_ROLE),
|
||||||
|
'{{VIDEO_PROVIDER_PRIVACY_URL}}': str(f.VIDEO_PROVIDER_PRIVACY_URL),
|
||||||
|
'{{RECORDING_RETENTION_DAYS}}': str(f.RECORDING_RETENTION_DAYS),
|
||||||
|
// --- FEATURES: KI/AI ---
|
||||||
|
'{{APPROVED_AI_SYSTEMS}}': str(f.APPROVED_AI_SYSTEMS),
|
||||||
|
// --- FEATURES: BYOD ---
|
||||||
|
'{{BYOD_COST_DETAILS}}': str(f.BYOD_COST_DETAILS),
|
||||||
|
// --- FEATURES: Consent ---
|
||||||
|
'{{NEWSLETTER_SIGNUP_URL}}': str(f.NEWSLETTER_SIGNUP_URL),
|
||||||
|
// --- FEATURES: Social Media ---
|
||||||
|
'{{SOCIAL_MEDIA_PLATFORMS_LIST}}': str(f.SOCIAL_MEDIA_PLATFORMS_LIST),
|
||||||
|
'{{EDITORIAL_EMAIL}}': str(f.EDITORIAL_EMAIL),
|
||||||
|
// --- FEATURES: Transfer/SCC ---
|
||||||
|
'{{RECIPIENT_NAME}}': str(f.RECIPIENT_NAME),
|
||||||
|
'{{RECIPIENT_COUNTRY}}': str(f.RECIPIENT_COUNTRY),
|
||||||
|
'{{RECIPIENT_ADDRESS}}': str(f.RECIPIENT_ADDRESS),
|
||||||
|
'{{RECIPIENT_CONTACT}}': str(f.RECIPIENT_CONTACT),
|
||||||
|
'{{RECIPIENT_EMAIL}}': str(f.RECIPIENT_EMAIL),
|
||||||
|
'{{RECIPIENT_ROLE}}': str(f.RECIPIENT_ROLE),
|
||||||
|
'{{TRANSFER_PURPOSE}}': str(f.TRANSFER_PURPOSE),
|
||||||
|
'{{TRANSFER_MECHANISM}}': str(f.TRANSFER_MECHANISM),
|
||||||
|
'{{DATA_CATEGORIES_TRANSFERRED}}': str(f.DATA_CATEGORIES_TRANSFERRED),
|
||||||
|
'{{DATA_SUBJECTS}}': str(f.DATA_SUBJECTS),
|
||||||
|
'{{TRANSFER_FREQUENCY}}': str(f.TRANSFER_FREQUENCY),
|
||||||
|
// --- FEATURES: DSI ---
|
||||||
|
'{{DSI_TITLE}}': str(f.DSI_TITLE) || 'Datenschutzerklaerung',
|
||||||
|
'{{SERVICE_SCOPE_DESCRIPTION}}': str(f.SERVICE_SCOPE_DESCRIPTION),
|
||||||
|
'{{FULFILLMENT_LOCATION}}': str(f.FULFILLMENT_LOCATION),
|
||||||
|
'{{GUIDELINES_URL}}': str(f.GUIDELINES_URL),
|
||||||
|
'{{PROCESSOR_LIST_URL}}': str(f.PROCESSOR_LIST_URL),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +300,9 @@ const SECTION_COVERS: Record<keyof TemplateContext, string[]> = {
|
|||||||
NDA: ['{{PURPOSE}}', '{{DURATION_YEARS}}', '{{PENALTY_AMOUNT}}'],
|
NDA: ['{{PURPOSE}}', '{{DURATION_YEARS}}', '{{PENALTY_AMOUNT}}'],
|
||||||
CONSENT: ['{{WEBSITE_NAME}}', '{{ANALYTICS_TOOLS}}', '{{MARKETING_PARTNERS}}', '{{ANALYTICS_TOOLS_LIST}}', '{{MARKETING_PARTNERS_LIST}}'],
|
CONSENT: ['{{WEBSITE_NAME}}', '{{ANALYTICS_TOOLS}}', '{{MARKETING_PARTNERS}}', '{{ANALYTICS_TOOLS_LIST}}', '{{MARKETING_PARTNERS_LIST}}'],
|
||||||
HOSTING: ['{{HOSTING_PROVIDER_NAME}}', '{{HOSTING_PROVIDER_COUNTRY}}', '{{HOSTING_PROVIDER_CONTRACT_TYPE}}'],
|
HOSTING: ['{{HOSTING_PROVIDER_NAME}}', '{{HOSTING_PROVIDER_COUNTRY}}', '{{HOSTING_PROVIDER_CONTRACT_TYPE}}'],
|
||||||
FEATURES: ['{{CONSENT_WITHDRAWAL_PATH}}', '{{SECURITY_MEASURES_SUMMARY}}', '{{DATA_SUBJECT_REQUEST_CHANNEL}}', '{{TRANSFER_GUARDS}}', '{{REGULATED_PROFESSION_TEXT}}', '{{EDITORIAL_RESPONSIBLE_NAME}}', '{{EDITORIAL_RESPONSIBLE_ADDRESS}}', '{{DISPUTE_RESOLUTION_TEXT}}', '{{NEWSLETTER_PROVIDER_DETAIL}}', '{{PAYMENT_PROVIDER_DETAIL}}', '{{SOCIAL_MEDIA_DETAIL}}', '{{ANALYTICS_TOOLS_DETAIL}}', '{{MARKETING_TOOLS_DETAIL}}', '{{CMP_NAME}}', '{{PRICES_TEXT}}', '{{PAYMENT_TERMS_TEXT}}', '{{CONTRACT_TERM_TEXT}}', '{{SLA_URL}}', '{{EXPORT_POLICY_TEXT}}', '{{LIMITATION_CAP_TEXT}}', '{{CONSUMER_WITHDRAWAL_TEXT}}', '{{SUPPORT_CHANNELS_TEXT}}'],
|
FEATURES: ['{{CONSENT_WITHDRAWAL_PATH}}', '{{SECURITY_MEASURES_SUMMARY}}', '{{DATA_SUBJECT_REQUEST_CHANNEL}}', '{{TRANSFER_GUARDS}}', '{{REGULATED_PROFESSION_TEXT}}', '{{EDITORIAL_RESPONSIBLE_NAME}}', '{{EDITORIAL_RESPONSIBLE_ADDRESS}}', '{{DISPUTE_RESOLUTION_TEXT}}', '{{NEWSLETTER_PROVIDER_DETAIL}}', '{{PAYMENT_PROVIDER_DETAIL}}', '{{SOCIAL_MEDIA_DETAIL}}', '{{ANALYTICS_TOOLS_DETAIL}}', '{{MARKETING_TOOLS_DETAIL}}', '{{CMP_NAME}}', '{{PRICES_TEXT}}', '{{PAYMENT_TERMS_TEXT}}', '{{CONTRACT_TERM_TEXT}}', '{{SLA_URL}}', '{{EXPORT_POLICY_TEXT}}', '{{LIMITATION_CAP_TEXT}}', '{{CONSUMER_WITHDRAWAL_TEXT}}', '{{SUPPORT_CHANNELS_TEXT}}', '{{WHISTLEBLOWER_CONTACT_NAME}}', '{{WHISTLEBLOWER_EMAIL}}', '{{WHISTLEBLOWER_URL}}', '{{VIDEO_PROVIDER_NAME}}', '{{APPROVED_AI_SYSTEMS}}', '{{SOCIAL_MEDIA_PLATFORMS_LIST}}', '{{DSI_TITLE}}', '{{SERVICE_SCOPE_DESCRIPTION}}', '{{GUIDELINES_URL}}', '{{PROCESSOR_LIST_URL}}', '{{RECIPIENT_NAME}}', '{{RECIPIENT_COUNTRY}}', '{{TRANSFER_PURPOSE}}', '{{TRANSFER_MECHANISM}}'],
|
||||||
|
TOM: ['{{ISB_NAME}}', '{{GF_NAME}}', '{{DOCUMENT_VERSION}}', '{{NEXT_REVIEW_DATE}}'],
|
||||||
|
DPA: ['{{AG_NAME}}', '{{AG_STRASSE}}', '{{AG_PLZ_ORT}}', '{{AN_NAME}}', '{{AN_STRASSE}}', '{{AN_PLZ_ORT}}', '{{VERARBEITUNGSGEGENSTAND}}', '{{VERARBEITUNGSZWECK}}', '{{VERARBEITUNGSARTEN}}', '{{DATENKATEGORIEN}}', '{{PERSONENKATEGORIEN}}', '{{BREACH_NOTIFICATION_HOURS}}', '{{INSTRUCTION_RETENTION_YEARS}}', '{{SUB_PROCESSOR_NOTICE_WEEKS}}', '{{SUB_PROCESSOR_OBJECTION_WEEKS}}', '{{DATA_EXPORT_FORMAT}}', '{{RETURN_CHOICE_WEEKS}}', '{{DELETION_DAYS}}', '{{REACTIVATION_MONTHS}}', '{{TERMINATION_WEEKS}}', '{{AN_DSB_NAME}}', '{{AN_DSB_EMAIL}}', '{{AG_ORT}}', '{{AN_ORT}}', '{{VERTRAGSDATUM}}', '{{AG_UNTERZEICHNER_NAME}}', '{{AG_UNTERZEICHNER_FUNKTION}}', '{{AN_UNTERZEICHNER_NAME}}', '{{AN_UNTERZEICHNER_FUNKTION}}', '{{GERICHTSSTAND}}'],
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -167,6 +167,84 @@ export interface FeaturesCtx {
|
|||||||
SUPPORT_CHANNELS_TEXT: string
|
SUPPORT_CHANNELS_TEXT: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TOMCtx {
|
||||||
|
ISB_NAME: string
|
||||||
|
GF_NAME: string
|
||||||
|
DOCUMENT_VERSION: string
|
||||||
|
NEXT_REVIEW_DATE: string
|
||||||
|
// Conditional blocks
|
||||||
|
HAS_PHYSICAL_TRANSPORT: boolean
|
||||||
|
HAS_THIRD_COUNTRY_TRANSFER: boolean
|
||||||
|
HAS_CLOUD_SERVICES: boolean
|
||||||
|
HAS_MFA: boolean
|
||||||
|
HAS_USB_LOCKED: boolean
|
||||||
|
HAS_MOBILE_MEDIA: boolean
|
||||||
|
HAS_FOUR_EYES_DELETE: boolean
|
||||||
|
HAS_EXTERNAL_DESTRUCTION: boolean
|
||||||
|
HAS_REDUNDANCY: boolean
|
||||||
|
HAS_GEO_REDUNDANCY: boolean
|
||||||
|
HAS_USV: boolean
|
||||||
|
HAS_OWN_SERVER_ROOM: boolean
|
||||||
|
HAS_MULTI_TENANT: boolean
|
||||||
|
HAS_TEST_DATA_ANONYMIZED: boolean
|
||||||
|
// Selects
|
||||||
|
LOG_RETENTION_MONTHS: number | ''
|
||||||
|
DIN_66399_LEVEL: string
|
||||||
|
AVAILABILITY_TARGET: string
|
||||||
|
SEPARATION_TYPE: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DPACtx {
|
||||||
|
// Parties
|
||||||
|
AG_NAME: string
|
||||||
|
AG_STRASSE: string
|
||||||
|
AG_PLZ_ORT: string
|
||||||
|
AN_NAME: string
|
||||||
|
AN_STRASSE: string
|
||||||
|
AN_PLZ_ORT: string
|
||||||
|
// Processing details
|
||||||
|
VERARBEITUNGSGEGENSTAND: string
|
||||||
|
VERARBEITUNGSZWECK: string
|
||||||
|
VERARBEITUNGSARTEN: string
|
||||||
|
DATENKATEGORIEN: string
|
||||||
|
PERSONENKATEGORIEN: string
|
||||||
|
// Timings
|
||||||
|
BREACH_NOTIFICATION_HOURS: number | ''
|
||||||
|
INSTRUCTION_RETENTION_YEARS: number | ''
|
||||||
|
SUB_PROCESSOR_NOTICE_WEEKS: number | ''
|
||||||
|
SUB_PROCESSOR_OBJECTION_WEEKS: number | ''
|
||||||
|
RETURN_CHOICE_WEEKS: number | ''
|
||||||
|
DELETION_DAYS: number | ''
|
||||||
|
REACTIVATION_MONTHS: number | ''
|
||||||
|
TERMINATION_WEEKS: number | ''
|
||||||
|
CHANGE_NOTICE_WEEKS: number | ''
|
||||||
|
THIRD_COUNTRY_OBJECTION_WEEKS: number | ''
|
||||||
|
// Data return
|
||||||
|
DATA_EXPORT_FORMAT: string
|
||||||
|
// DSB
|
||||||
|
AN_DSB_NAME: string
|
||||||
|
AN_DSB_EMAIL: string
|
||||||
|
// Signatures
|
||||||
|
AG_ORT: string
|
||||||
|
AN_ORT: string
|
||||||
|
VERTRAGSDATUM: string
|
||||||
|
AG_UNTERZEICHNER_NAME: string
|
||||||
|
AG_UNTERZEICHNER_FUNKTION: string
|
||||||
|
AN_UNTERZEICHNER_NAME: string
|
||||||
|
AN_UNTERZEICHNER_FUNKTION: string
|
||||||
|
GERICHTSSTAND: string
|
||||||
|
// Optional clauses
|
||||||
|
HAS_LIABILITY_PROTECTION: boolean
|
||||||
|
HAS_SUPPORT_COST_CLAUSE: boolean
|
||||||
|
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: boolean
|
||||||
|
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: boolean
|
||||||
|
HAS_REACTIVATION_PERIOD: boolean
|
||||||
|
HAS_RETURN_COST_CLAUSE: boolean
|
||||||
|
HAS_GERICHTSSTAND_CLAUSE: boolean
|
||||||
|
HAS_UNILATERAL_CHANGE_RIGHT: boolean
|
||||||
|
HAS_THIRD_COUNTRY_OBJECTION: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface TemplateContext {
|
export interface TemplateContext {
|
||||||
PROVIDER: ProviderCtx
|
PROVIDER: ProviderCtx
|
||||||
CUSTOMER: CustomerCtx
|
CUSTOMER: CustomerCtx
|
||||||
@@ -180,6 +258,8 @@ export interface TemplateContext {
|
|||||||
CONSENT: ConsentCtx
|
CONSENT: ConsentCtx
|
||||||
HOSTING: HostingCtx
|
HOSTING: HostingCtx
|
||||||
FEATURES: FeaturesCtx
|
FEATURES: FeaturesCtx
|
||||||
|
TOM: TOMCtx
|
||||||
|
DPA: DPACtx
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComputedFlags {
|
export interface ComputedFlags {
|
||||||
@@ -263,6 +343,37 @@ export const EMPTY_CONTEXT: TemplateContext = {
|
|||||||
LIMITATION_CAP_TEXT: '', HAS_WITHDRAWAL: false, CONSUMER_WITHDRAWAL_TEXT: '',
|
LIMITATION_CAP_TEXT: '', HAS_WITHDRAWAL: false, CONSUMER_WITHDRAWAL_TEXT: '',
|
||||||
SUPPORT_CHANNELS_TEXT: '',
|
SUPPORT_CHANNELS_TEXT: '',
|
||||||
},
|
},
|
||||||
|
TOM: {
|
||||||
|
ISB_NAME: '', GF_NAME: '', DOCUMENT_VERSION: '1.0.0', NEXT_REVIEW_DATE: '',
|
||||||
|
HAS_PHYSICAL_TRANSPORT: false, HAS_THIRD_COUNTRY_TRANSFER: false,
|
||||||
|
HAS_CLOUD_SERVICES: false, HAS_MFA: true, HAS_USB_LOCKED: false,
|
||||||
|
HAS_MOBILE_MEDIA: false, HAS_FOUR_EYES_DELETE: false,
|
||||||
|
HAS_EXTERNAL_DESTRUCTION: false, HAS_REDUNDANCY: false,
|
||||||
|
HAS_GEO_REDUNDANCY: false, HAS_USV: true, HAS_OWN_SERVER_ROOM: false,
|
||||||
|
HAS_MULTI_TENANT: false, HAS_TEST_DATA_ANONYMIZED: true,
|
||||||
|
LOG_RETENTION_MONTHS: 6, DIN_66399_LEVEL: '3',
|
||||||
|
AVAILABILITY_TARGET: '99.5', SEPARATION_TYPE: 'logisch',
|
||||||
|
},
|
||||||
|
DPA: {
|
||||||
|
AG_NAME: '', AG_STRASSE: '', AG_PLZ_ORT: '',
|
||||||
|
AN_NAME: '', AN_STRASSE: '', AN_PLZ_ORT: '',
|
||||||
|
VERARBEITUNGSGEGENSTAND: '', VERARBEITUNGSZWECK: '', VERARBEITUNGSARTEN: '',
|
||||||
|
DATENKATEGORIEN: '', PERSONENKATEGORIEN: '',
|
||||||
|
BREACH_NOTIFICATION_HOURS: 24, INSTRUCTION_RETENTION_YEARS: 3,
|
||||||
|
SUB_PROCESSOR_NOTICE_WEEKS: 2, SUB_PROCESSOR_OBJECTION_WEEKS: 2,
|
||||||
|
RETURN_CHOICE_WEEKS: 4, DELETION_DAYS: 90, REACTIVATION_MONTHS: 3,
|
||||||
|
TERMINATION_WEEKS: 4, CHANGE_NOTICE_WEEKS: 4, THIRD_COUNTRY_OBJECTION_WEEKS: 3,
|
||||||
|
DATA_EXPORT_FORMAT: 'CSV/JSON', AN_DSB_NAME: '', AN_DSB_EMAIL: '',
|
||||||
|
AG_ORT: '', AN_ORT: '', VERTRAGSDATUM: '',
|
||||||
|
AG_UNTERZEICHNER_NAME: '', AG_UNTERZEICHNER_FUNKTION: 'Geschaeftsfuehrer',
|
||||||
|
AN_UNTERZEICHNER_NAME: '', AN_UNTERZEICHNER_FUNKTION: 'Geschaeftsfuehrer',
|
||||||
|
GERICHTSSTAND: '',
|
||||||
|
HAS_LIABILITY_PROTECTION: false, HAS_SUPPORT_COST_CLAUSE: false,
|
||||||
|
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: true, HAS_SUB_PROCESSOR_TERMINATION_RIGHT: false,
|
||||||
|
HAS_REACTIVATION_PERIOD: true, HAS_RETURN_COST_CLAUSE: false,
|
||||||
|
HAS_GERICHTSSTAND_CLAUSE: true, HAS_UNILATERAL_CHANGE_RIGHT: false,
|
||||||
|
HAS_THIRD_COUNTRY_OBJECTION: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"document_type": "ai_usage_policy",
|
||||||
|
"language": "de",
|
||||||
|
"context": {
|
||||||
|
"PROVIDER": { "LEGAL_NAME": "Muster GmbH" },
|
||||||
|
"FEATURES": {
|
||||||
|
"APPROVED_AI_SYSTEMS": "ChatGPT (OpenAI), GitHub Copilot, DeepL Pro",
|
||||||
|
"HAS_APPROVED_AI_LIST": true,
|
||||||
|
"HAS_AI_LABELING_INTERNAL": true,
|
||||||
|
"HAS_TDM_OPTOUT": true
|
||||||
|
},
|
||||||
|
"TOM": { "DOCUMENT_VERSION": "1.0.0", "NEXT_REVIEW_DATE": "2026-11-01" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"document_type": "dpa",
|
||||||
|
"language": "de",
|
||||||
|
"context": {
|
||||||
|
"DPA": {
|
||||||
|
"AG_NAME": "Muster GmbH",
|
||||||
|
"AG_STRASSE": "Musterstrasse 1",
|
||||||
|
"AG_PLZ_ORT": "10115 Berlin",
|
||||||
|
"AN_NAME": "BreakPilot GmbH",
|
||||||
|
"AN_STRASSE": "Hardtring 6",
|
||||||
|
"AN_PLZ_ORT": "78224 Singen",
|
||||||
|
"VERARBEITUNGSGEGENSTAND": "Bereitstellung und Betrieb einer SaaS-Compliance-Plattform",
|
||||||
|
"VERARBEITUNGSZWECK": "Compliance-Management, Dokumentengenerierung, Risikobewertung",
|
||||||
|
"VERARBEITUNGSARTEN": "Erheben, Speichern, Veraendern, Auslesen, Abfragen, Uebermitteln, Loeschen",
|
||||||
|
"DATENKATEGORIEN": "Stammdaten, Kontaktdaten, Vertragsdaten, Nutzungsdaten, Kommunikationsdaten",
|
||||||
|
"PERSONENKATEGORIEN": "Mitarbeitende des Auftraggebers, Kunden des Auftraggebers, Ansprechpartner",
|
||||||
|
"BREACH_NOTIFICATION_HOURS": 24,
|
||||||
|
"INSTRUCTION_RETENTION_YEARS": 3,
|
||||||
|
"SUB_PROCESSOR_NOTICE_WEEKS": 4,
|
||||||
|
"SUB_PROCESSOR_OBJECTION_WEEKS": 2,
|
||||||
|
"DATA_EXPORT_FORMAT": "CSV/JSON",
|
||||||
|
"RETURN_CHOICE_WEEKS": 4,
|
||||||
|
"DELETION_DAYS": 90,
|
||||||
|
"AN_DSB_NAME": "Max Mustermann",
|
||||||
|
"AN_DSB_EMAIL": "datenschutz@breakpilot.ai",
|
||||||
|
"VERTRAGSDATUM": "2026-05-01",
|
||||||
|
"AG_ORT": "Berlin",
|
||||||
|
"AN_ORT": "Singen",
|
||||||
|
"AG_UNTERZEICHNER_NAME": "Anna Beispiel",
|
||||||
|
"AG_UNTERZEICHNER_FUNKTION": "Geschaeftsfuehrerin",
|
||||||
|
"AN_UNTERZEICHNER_NAME": "Benjamin Boenisch",
|
||||||
|
"AN_UNTERZEICHNER_FUNKTION": "Geschaeftsfuehrer",
|
||||||
|
"GERICHTSSTAND": "Singen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"document_type": "employee_dsi",
|
||||||
|
"language": "de",
|
||||||
|
"context": {
|
||||||
|
"PROVIDER": {
|
||||||
|
"LEGAL_NAME": "Muster GmbH",
|
||||||
|
"LEGAL_FORM": "GmbH",
|
||||||
|
"ADDRESS_LINE": "Musterstrasse 1",
|
||||||
|
"POSTAL_CODE": "10115",
|
||||||
|
"CITY": "Berlin",
|
||||||
|
"COUNTRY": "DE",
|
||||||
|
"EMAIL": "info@muster.de",
|
||||||
|
"PHONE": "+49 30 123456"
|
||||||
|
},
|
||||||
|
"PRIVACY": {
|
||||||
|
"DPO_NAME": "Dr. Datenschutz",
|
||||||
|
"DPO_EMAIL": "dsb@muster.de",
|
||||||
|
"SUPERVISORY_AUTHORITY_NAME": "Berliner Beauftragte fuer Datenschutz"
|
||||||
|
},
|
||||||
|
"FEATURES": {
|
||||||
|
"HAS_IT_USAGE_MONITORING": true,
|
||||||
|
"HAS_COMPANY_VEHICLE": false,
|
||||||
|
"HAS_ACCESS_CONTROL": true,
|
||||||
|
"HAS_VIDEO_SURVEILLANCE": false,
|
||||||
|
"HAS_COMPANY_PENSION": true,
|
||||||
|
"HAS_EXTERNAL_HR_SOFTWARE": true,
|
||||||
|
"HAS_WORKS_COUNCIL": false,
|
||||||
|
"HAS_SPECIAL_CATEGORIES_EMPLOYEES": true,
|
||||||
|
"DATA_SUBJECT_REQUEST_CHANNEL": "per E-Mail an dsb@muster.de"
|
||||||
|
},
|
||||||
|
"SECURITY": { "LOG_RETENTION_DAYS": 90 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"document_type": "social_media_dsi",
|
||||||
|
"language": "de",
|
||||||
|
"context": {
|
||||||
|
"PROVIDER": {
|
||||||
|
"LEGAL_NAME": "Muster GmbH",
|
||||||
|
"WEBSITE_URL": "https://www.muster.de",
|
||||||
|
"EMAIL": "info@muster.de",
|
||||||
|
"PHONE": "+49 30 123456"
|
||||||
|
},
|
||||||
|
"PRIVACY": {
|
||||||
|
"DPO_EMAIL": "dsb@muster.de",
|
||||||
|
"SUPERVISORY_AUTHORITY_NAME": "Berliner Beauftragte fuer Datenschutz",
|
||||||
|
"SUPERVISORY_AUTHORITY_ADDRESS": "Friedrichstr. 219, 10969 Berlin"
|
||||||
|
},
|
||||||
|
"FEATURES": {
|
||||||
|
"HAS_FACEBOOK": true,
|
||||||
|
"HAS_YOUTUBE": true,
|
||||||
|
"HAS_LINKEDIN": true,
|
||||||
|
"HAS_TIKTOK": false,
|
||||||
|
"HAS_X_TWITTER": false,
|
||||||
|
"HAS_META_PIXEL": true,
|
||||||
|
"HAS_RECRUITING_VIA_SOCIAL": true,
|
||||||
|
"SOCIAL_MEDIA_PLATFORMS_LIST": "Facebook, Instagram, YouTube und LinkedIn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"document_type": "transfer_impact_assessment",
|
||||||
|
"language": "de",
|
||||||
|
"context": {
|
||||||
|
"PROVIDER": { "LEGAL_NAME": "Muster GmbH" },
|
||||||
|
"PRIVACY": { "DPO_NAME": "Dr. Datenschutz", "DPO_EMAIL": "dsb@muster.de" },
|
||||||
|
"FEATURES": {
|
||||||
|
"RECIPIENT_NAME": "Cloud Provider Inc.",
|
||||||
|
"RECIPIENT_COUNTRY": "US",
|
||||||
|
"RECIPIENT_ROLE": "Auftragsverarbeiter",
|
||||||
|
"TRANSFER_PURPOSE": "Hosting der Anwendungsdaten",
|
||||||
|
"TRANSFER_MECHANISM": "EU-Standardvertragsklauseln (SCC) + EU-US DPF",
|
||||||
|
"DATA_CATEGORIES_TRANSFERRED": "Stammdaten, Kontaktdaten, Nutzungsdaten",
|
||||||
|
"DATA_SUBJECTS": "Kunden, Nutzer der Plattform",
|
||||||
|
"TRANSFER_FREQUENCY": "Kontinuierlich (Echtzeit-Datenverarbeitung)"
|
||||||
|
},
|
||||||
|
"TOM": { "GF_NAME": "Max Geschaeftsfuehrer", "DOCUMENT_VERSION": "1.0.0", "NEXT_REVIEW_DATE": "2027-05-01" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"document_type": "tom_documentation",
|
||||||
|
"language": "de",
|
||||||
|
"context": {
|
||||||
|
"TOM": {
|
||||||
|
"ISB_NAME": "Thomas Sicher",
|
||||||
|
"GF_NAME": "Benjamin Boenisch",
|
||||||
|
"DOCUMENT_VERSION": "2.0.0",
|
||||||
|
"NEXT_REVIEW_DATE": "2027-05-01",
|
||||||
|
"HAS_MFA": true,
|
||||||
|
"HAS_USB_LOCKED": false,
|
||||||
|
"HAS_MOBILE_MEDIA": false,
|
||||||
|
"HAS_FOUR_EYES_DELETE": true,
|
||||||
|
"HAS_EXTERNAL_DESTRUCTION": true,
|
||||||
|
"HAS_PHYSICAL_TRANSPORT": false,
|
||||||
|
"HAS_THIRD_COUNTRY_TRANSFER": false,
|
||||||
|
"HAS_CLOUD_SERVICES": true,
|
||||||
|
"HAS_REDUNDANCY": true,
|
||||||
|
"HAS_GEO_REDUNDANCY": false,
|
||||||
|
"HAS_USV": true,
|
||||||
|
"HAS_OWN_SERVER_ROOM": true,
|
||||||
|
"HAS_MULTI_TENANT": true,
|
||||||
|
"HAS_TEST_DATA_ANONYMIZED": true,
|
||||||
|
"LOG_RETENTION_MONTHS": 12,
|
||||||
|
"DIN_66399_LEVEL": "4",
|
||||||
|
"AVAILABILITY_TARGET": "99.9",
|
||||||
|
"SEPARATION_TYPE": "logisch"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"document_type": "whistleblower_policy",
|
||||||
|
"language": "de",
|
||||||
|
"context": {
|
||||||
|
"PROVIDER": {
|
||||||
|
"LEGAL_NAME": "Muster GmbH"
|
||||||
|
},
|
||||||
|
"FEATURES": {
|
||||||
|
"WHISTLEBLOWER_CONTACT_NAME": "Dr. Maria Compliance",
|
||||||
|
"WHISTLEBLOWER_CONTACT_ROLE": "Compliance-Beauftragte / Meldestellenbeauftragte",
|
||||||
|
"WHISTLEBLOWER_EMAIL": "meldestelle@muster.de",
|
||||||
|
"WHISTLEBLOWER_PHONE": "+49 123 456789",
|
||||||
|
"WHISTLEBLOWER_URL": "https://muster.de/meldestelle",
|
||||||
|
"HAS_ANONYMOUS_REPORTING": true,
|
||||||
|
"HAS_EXTERNAL_REPORTING": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,8 +11,10 @@ import { generateAllPlaceholders } from '@/lib/sdk/document-generator/datapoint-
|
|||||||
import { loadAllTemplates } from './searchTemplates'
|
import { loadAllTemplates } from './searchTemplates'
|
||||||
import { TemplateContext, EMPTY_CONTEXT } from './contextBridge'
|
import { TemplateContext, EMPTY_CONTEXT } from './contextBridge'
|
||||||
import { CATEGORIES } from './_constants'
|
import { CATEGORIES } from './_constants'
|
||||||
|
import { getGeneratorDefaults, getProfileLabel } from './scopeDefaults'
|
||||||
import TemplateLibrary from './_components/TemplateLibrary'
|
import TemplateLibrary from './_components/TemplateLibrary'
|
||||||
import GeneratorSection from './_components/GeneratorSection'
|
import GeneratorSection from './_components/GeneratorSection'
|
||||||
|
import RecommendedDocuments from './_components/RecommendedDocuments'
|
||||||
|
|
||||||
function DocumentGeneratorPageInner() {
|
function DocumentGeneratorPageInner() {
|
||||||
const { state } = useSDK()
|
const { state } = useSDK()
|
||||||
@@ -86,6 +88,119 @@ function DocumentGeneratorPageInner() {
|
|||||||
}
|
}
|
||||||
}, [state?.companyProfile])
|
}, [state?.companyProfile])
|
||||||
|
|
||||||
|
// Pre-fill TOM/DPA context from Compliance Scope Engine
|
||||||
|
useEffect(() => {
|
||||||
|
const scopeLevel = state?.complianceScope?.determinedLevel
|
||||||
|
if (scopeLevel) {
|
||||||
|
const defaults = getGeneratorDefaults(scopeLevel, state?.companyProfile as never)
|
||||||
|
setContext((prev) => ({
|
||||||
|
...prev,
|
||||||
|
TOM: { ...prev.TOM, ...defaults.tom },
|
||||||
|
DPA: { ...prev.DPA, ...defaults.dpa },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}, [state?.complianceScope?.determinedLevel, state?.companyProfile])
|
||||||
|
|
||||||
|
// ── MODULE WIRING: Backend Banner-Config → CONSENT + FEATURES ────────────
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch real vendor/category data from backend if SDK state has no banner
|
||||||
|
if (state?.cookieBanner) return // SDK state takes priority
|
||||||
|
fetch('/api/sdk/v1/banner/admin/sites', { headers: { 'x-tenant-id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' } })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then((sites: Array<{ site_id: string }>) => {
|
||||||
|
if (!sites?.length) return
|
||||||
|
return fetch(`/api/sdk/v1/banner/config/${sites[0].site_id}`, { headers: { 'x-tenant-id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' } })
|
||||||
|
})
|
||||||
|
.then(r => r?.json())
|
||||||
|
.then(config => {
|
||||||
|
if (!config?.vendors?.length) return
|
||||||
|
const analytics = config.vendors.filter((v: { category_key: string }) => v.category_key === 'statistics' || v.category_key === 'analytics').map((v: { vendor_name: string }) => v.vendor_name)
|
||||||
|
const marketing = config.vendors.filter((v: { category_key: string }) => v.category_key === 'marketing').map((v: { vendor_name: string }) => v.vendor_name)
|
||||||
|
setContext(prev => ({
|
||||||
|
...prev,
|
||||||
|
CONSENT: {
|
||||||
|
...prev.CONSENT,
|
||||||
|
ANALYTICS_TOOLS: analytics.length > 0 ? analytics.join(', ') : prev.CONSENT.ANALYTICS_TOOLS,
|
||||||
|
MARKETING_PARTNERS: marketing.length > 0 ? marketing.join(', ') : prev.CONSENT.MARKETING_PARTNERS,
|
||||||
|
},
|
||||||
|
FEATURES: { ...prev.FEATURES, CMP_NAME: 'BreakPilot CMP', CMP_LOGS_CONSENTS: true },
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [state?.cookieBanner])
|
||||||
|
|
||||||
|
// ── MODULE WIRING: CookieBanner SDK State → CONSENT + FEATURES ──────────
|
||||||
|
useEffect(() => {
|
||||||
|
const banner = state?.cookieBanner
|
||||||
|
if (!banner) return
|
||||||
|
const cats = banner.categories || []
|
||||||
|
const analyticsTools = cats
|
||||||
|
.filter((c) => c.id === 'analytics' || c.id === 'statistics')
|
||||||
|
.flatMap((c) => c.cookies?.map((ck) => ck.name) ?? [])
|
||||||
|
const marketingTools = cats
|
||||||
|
.filter((c) => c.id === 'marketing')
|
||||||
|
.flatMap((c) => c.cookies?.map((ck) => ck.name) ?? [])
|
||||||
|
const hasFunctional = cats.some((c) => c.id === 'functional')
|
||||||
|
|
||||||
|
setContext((prev) => ({
|
||||||
|
...prev,
|
||||||
|
CONSENT: {
|
||||||
|
...prev.CONSENT,
|
||||||
|
ANALYTICS_TOOLS: analyticsTools.length > 0 ? analyticsTools.join(', ') : prev.CONSENT.ANALYTICS_TOOLS,
|
||||||
|
MARKETING_PARTNERS: marketingTools.length > 0 ? marketingTools.join(', ') : prev.CONSENT.MARKETING_PARTNERS,
|
||||||
|
},
|
||||||
|
FEATURES: {
|
||||||
|
...prev.FEATURES,
|
||||||
|
CMP_NAME: 'BreakPilot CMP',
|
||||||
|
CMP_LOGS_CONSENTS: true,
|
||||||
|
HAS_FUNCTIONAL_COOKIES: hasFunctional || prev.FEATURES.HAS_FUNCTIONAL_COOKIES,
|
||||||
|
CONSENT_WITHDRAWAL_PATH: 'Footer-Link "Cookie-Einstellungen"',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}, [state?.cookieBanner])
|
||||||
|
|
||||||
|
// ── MODULE WIRING: Loeschfristen → PRIVACY retention ──────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const policies = state?.retentionPolicies
|
||||||
|
if (!policies || policies.length === 0) return
|
||||||
|
const maxMonths = policies.reduce((max, p) => {
|
||||||
|
const match = p.retentionPeriod?.match(/(\d+)\s*(Monat|Jahr|Tag)/i)
|
||||||
|
if (!match) return max
|
||||||
|
const val = parseInt(match[1], 10)
|
||||||
|
const unit = match[2].toLowerCase()
|
||||||
|
const months = unit.startsWith('jahr') ? val * 12 : unit.startsWith('tag') ? Math.ceil(val / 30) : val
|
||||||
|
return Math.max(max, months)
|
||||||
|
}, 0)
|
||||||
|
if (maxMonths > 0) {
|
||||||
|
setContext((prev) => ({
|
||||||
|
...prev,
|
||||||
|
PRIVACY: { ...prev.PRIVACY, ANALYTICS_RETENTION_MONTHS: maxMonths },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}, [state?.retentionPolicies])
|
||||||
|
|
||||||
|
// ── MODULE WIRING: UseCases → FEATURES flags ─────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const useCases = state?.useCases
|
||||||
|
if (!useCases || useCases.length === 0) return
|
||||||
|
const allText = useCases.map((uc) => `${uc.name} ${uc.description}`).join(' ').toLowerCase()
|
||||||
|
const hasAccount = allText.includes('account') || allText.includes('konto') || allText.includes('registrier')
|
||||||
|
const hasPayments = allText.includes('zahlung') || allText.includes('payment') || allText.includes('stripe') || allText.includes('paypal')
|
||||||
|
const hasNewsletter = allText.includes('newsletter') || allText.includes('mailchimp') || allText.includes('e-mail-marketing')
|
||||||
|
const hasSocial = allText.includes('social') || allText.includes('linkedin') || allText.includes('facebook') || allText.includes('instagram')
|
||||||
|
|
||||||
|
setContext((prev) => ({
|
||||||
|
...prev,
|
||||||
|
FEATURES: {
|
||||||
|
...prev.FEATURES,
|
||||||
|
HAS_ACCOUNT: hasAccount || prev.FEATURES.HAS_ACCOUNT,
|
||||||
|
HAS_PAYMENTS: hasPayments || prev.FEATURES.HAS_PAYMENTS,
|
||||||
|
HAS_NEWSLETTER: hasNewsletter || prev.FEATURES.HAS_NEWSLETTER,
|
||||||
|
HAS_SOCIAL_MEDIA: hasSocial || prev.FEATURES.HAS_SOCIAL_MEDIA,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}, [state?.useCases])
|
||||||
|
|
||||||
// Pre-fill extra placeholders from Einwilligungen data points
|
// Pre-fill extra placeholders from Einwilligungen data points
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedDataPointsData && selectedDataPointsData.length > 0) {
|
if (selectedDataPointsData && selectedDataPointsData.length > 0) {
|
||||||
@@ -177,6 +292,12 @@ function DocumentGeneratorPageInner() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Recommended documents based on scope profile */}
|
||||||
|
<RecommendedDocuments
|
||||||
|
allTemplates={allTemplates}
|
||||||
|
onUseTemplate={handleUseTemplate}
|
||||||
|
/>
|
||||||
|
|
||||||
<TemplateLibrary
|
<TemplateLibrary
|
||||||
allTemplates={allTemplates}
|
allTemplates={allTemplates}
|
||||||
filteredTemplates={filteredTemplates}
|
filteredTemplates={filteredTemplates}
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
/**
|
||||||
|
* Scope-basierte Generator-Defaults
|
||||||
|
*
|
||||||
|
* Nimmt ScopeDecision.determinedLevel + CompanyProfile und liefert
|
||||||
|
* vorausgefuellte TOM/DPA-Context-Werte. Alle Felder bleiben vom
|
||||||
|
* Kunden aenderbar — die Defaults sind Empfehlungen.
|
||||||
|
*
|
||||||
|
* Mapping:
|
||||||
|
* L1 = Lean Startup (≤10 MA, Cloud-only, Home Office)
|
||||||
|
* L2 = KMU Standard (11-249 MA)
|
||||||
|
* L3 = Erweitert (risikoreich oder >100 MA)
|
||||||
|
* L4 = Zertifizierungsbereit (≥250 MA oder regulierte Branche)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ComplianceDepthLevel } from '../../lib/sdk/compliance-scope-types/core-levels'
|
||||||
|
import type { CompanyProfile } from '../../lib/sdk/types'
|
||||||
|
import type { TOMCtx, DPACtx } from './contextBridge'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TOM Defaults per Level
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const TOM_DEFAULTS: Record<ComplianceDepthLevel, Partial<TOMCtx>> = {
|
||||||
|
L1: {
|
||||||
|
// Lean Startup: Cloud-only, kein eigener Serverraum, Home Office
|
||||||
|
HAS_MFA: true,
|
||||||
|
HAS_USB_LOCKED: false,
|
||||||
|
HAS_MOBILE_MEDIA: false,
|
||||||
|
HAS_FOUR_EYES_DELETE: false,
|
||||||
|
HAS_EXTERNAL_DESTRUCTION: false,
|
||||||
|
HAS_PHYSICAL_TRANSPORT: false,
|
||||||
|
HAS_THIRD_COUNTRY_TRANSFER: false,
|
||||||
|
HAS_CLOUD_SERVICES: true,
|
||||||
|
HAS_REDUNDANCY: false,
|
||||||
|
HAS_GEO_REDUNDANCY: false,
|
||||||
|
HAS_USV: false,
|
||||||
|
HAS_OWN_SERVER_ROOM: false,
|
||||||
|
HAS_MULTI_TENANT: false,
|
||||||
|
HAS_TEST_DATA_ANONYMIZED: true,
|
||||||
|
LOG_RETENTION_MONTHS: 3,
|
||||||
|
DIN_66399_LEVEL: '3',
|
||||||
|
AVAILABILITY_TARGET: '99.0',
|
||||||
|
SEPARATION_TYPE: 'logisch',
|
||||||
|
},
|
||||||
|
L2: {
|
||||||
|
// KMU Standard
|
||||||
|
HAS_MFA: true,
|
||||||
|
HAS_USB_LOCKED: false,
|
||||||
|
HAS_MOBILE_MEDIA: false,
|
||||||
|
HAS_FOUR_EYES_DELETE: false,
|
||||||
|
HAS_EXTERNAL_DESTRUCTION: false,
|
||||||
|
HAS_PHYSICAL_TRANSPORT: false,
|
||||||
|
HAS_THIRD_COUNTRY_TRANSFER: false,
|
||||||
|
HAS_CLOUD_SERVICES: true,
|
||||||
|
HAS_REDUNDANCY: false,
|
||||||
|
HAS_GEO_REDUNDANCY: false,
|
||||||
|
HAS_USV: false,
|
||||||
|
HAS_OWN_SERVER_ROOM: false,
|
||||||
|
HAS_MULTI_TENANT: false,
|
||||||
|
HAS_TEST_DATA_ANONYMIZED: true,
|
||||||
|
LOG_RETENTION_MONTHS: 6,
|
||||||
|
DIN_66399_LEVEL: '3',
|
||||||
|
AVAILABILITY_TARGET: '99.5',
|
||||||
|
SEPARATION_TYPE: 'logisch',
|
||||||
|
},
|
||||||
|
L3: {
|
||||||
|
// Erweitert
|
||||||
|
HAS_MFA: true,
|
||||||
|
HAS_USB_LOCKED: false,
|
||||||
|
HAS_MOBILE_MEDIA: false,
|
||||||
|
HAS_FOUR_EYES_DELETE: true,
|
||||||
|
HAS_EXTERNAL_DESTRUCTION: true,
|
||||||
|
HAS_PHYSICAL_TRANSPORT: false,
|
||||||
|
HAS_THIRD_COUNTRY_TRANSFER: false,
|
||||||
|
HAS_CLOUD_SERVICES: true,
|
||||||
|
HAS_REDUNDANCY: true,
|
||||||
|
HAS_GEO_REDUNDANCY: false,
|
||||||
|
HAS_USV: true,
|
||||||
|
HAS_OWN_SERVER_ROOM: true,
|
||||||
|
HAS_MULTI_TENANT: true,
|
||||||
|
HAS_TEST_DATA_ANONYMIZED: true,
|
||||||
|
LOG_RETENTION_MONTHS: 12,
|
||||||
|
DIN_66399_LEVEL: '4',
|
||||||
|
AVAILABILITY_TARGET: '99.9',
|
||||||
|
SEPARATION_TYPE: 'logisch',
|
||||||
|
},
|
||||||
|
L4: {
|
||||||
|
// Zertifizierungsbereit / Enterprise
|
||||||
|
HAS_MFA: true,
|
||||||
|
HAS_USB_LOCKED: true,
|
||||||
|
HAS_MOBILE_MEDIA: false,
|
||||||
|
HAS_FOUR_EYES_DELETE: true,
|
||||||
|
HAS_EXTERNAL_DESTRUCTION: true,
|
||||||
|
HAS_PHYSICAL_TRANSPORT: false,
|
||||||
|
HAS_THIRD_COUNTRY_TRANSFER: false,
|
||||||
|
HAS_CLOUD_SERVICES: true,
|
||||||
|
HAS_REDUNDANCY: true,
|
||||||
|
HAS_GEO_REDUNDANCY: true,
|
||||||
|
HAS_USV: true,
|
||||||
|
HAS_OWN_SERVER_ROOM: true,
|
||||||
|
HAS_MULTI_TENANT: true,
|
||||||
|
HAS_TEST_DATA_ANONYMIZED: true,
|
||||||
|
LOG_RETENTION_MONTHS: 24,
|
||||||
|
DIN_66399_LEVEL: '5',
|
||||||
|
AVAILABILITY_TARGET: '99.99',
|
||||||
|
SEPARATION_TYPE: 'logisch',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DPA Defaults per Level
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const DPA_DEFAULTS: Record<ComplianceDepthLevel, Partial<DPACtx>> = {
|
||||||
|
L1: {
|
||||||
|
BREACH_NOTIFICATION_HOURS: 48,
|
||||||
|
INSTRUCTION_RETENTION_YEARS: 3,
|
||||||
|
SUB_PROCESSOR_NOTICE_WEEKS: 2,
|
||||||
|
SUB_PROCESSOR_OBJECTION_WEEKS: 2,
|
||||||
|
DATA_EXPORT_FORMAT: 'CSV/JSON',
|
||||||
|
RETURN_CHOICE_WEEKS: 4,
|
||||||
|
DELETION_DAYS: 90,
|
||||||
|
HAS_LIABILITY_PROTECTION: false,
|
||||||
|
HAS_SUPPORT_COST_CLAUSE: false,
|
||||||
|
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: true,
|
||||||
|
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: false,
|
||||||
|
HAS_REACTIVATION_PERIOD: true,
|
||||||
|
REACTIVATION_MONTHS: 3,
|
||||||
|
HAS_RETURN_COST_CLAUSE: false,
|
||||||
|
HAS_GERICHTSSTAND_CLAUSE: false,
|
||||||
|
HAS_UNILATERAL_CHANGE_RIGHT: false,
|
||||||
|
HAS_THIRD_COUNTRY_OBJECTION: false,
|
||||||
|
},
|
||||||
|
L2: {
|
||||||
|
BREACH_NOTIFICATION_HOURS: 24,
|
||||||
|
INSTRUCTION_RETENTION_YEARS: 3,
|
||||||
|
SUB_PROCESSOR_NOTICE_WEEKS: 4,
|
||||||
|
SUB_PROCESSOR_OBJECTION_WEEKS: 2,
|
||||||
|
DATA_EXPORT_FORMAT: 'CSV/JSON',
|
||||||
|
RETURN_CHOICE_WEEKS: 4,
|
||||||
|
DELETION_DAYS: 90,
|
||||||
|
HAS_LIABILITY_PROTECTION: false,
|
||||||
|
HAS_SUPPORT_COST_CLAUSE: false,
|
||||||
|
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: true,
|
||||||
|
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: false,
|
||||||
|
HAS_REACTIVATION_PERIOD: true,
|
||||||
|
REACTIVATION_MONTHS: 3,
|
||||||
|
HAS_RETURN_COST_CLAUSE: false,
|
||||||
|
HAS_GERICHTSSTAND_CLAUSE: true,
|
||||||
|
HAS_UNILATERAL_CHANGE_RIGHT: false,
|
||||||
|
HAS_THIRD_COUNTRY_OBJECTION: false,
|
||||||
|
},
|
||||||
|
L3: {
|
||||||
|
BREACH_NOTIFICATION_HOURS: 24,
|
||||||
|
INSTRUCTION_RETENTION_YEARS: 5,
|
||||||
|
SUB_PROCESSOR_NOTICE_WEEKS: 4,
|
||||||
|
SUB_PROCESSOR_OBJECTION_WEEKS: 4,
|
||||||
|
DATA_EXPORT_FORMAT: 'CSV/JSON',
|
||||||
|
RETURN_CHOICE_WEEKS: 4,
|
||||||
|
DELETION_DAYS: 60,
|
||||||
|
HAS_LIABILITY_PROTECTION: true,
|
||||||
|
HAS_SUPPORT_COST_CLAUSE: true,
|
||||||
|
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: true,
|
||||||
|
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: true,
|
||||||
|
HAS_REACTIVATION_PERIOD: true,
|
||||||
|
REACTIVATION_MONTHS: 3,
|
||||||
|
HAS_RETURN_COST_CLAUSE: true,
|
||||||
|
HAS_GERICHTSSTAND_CLAUSE: true,
|
||||||
|
HAS_UNILATERAL_CHANGE_RIGHT: false,
|
||||||
|
HAS_THIRD_COUNTRY_OBJECTION: false,
|
||||||
|
},
|
||||||
|
L4: {
|
||||||
|
BREACH_NOTIFICATION_HOURS: 12,
|
||||||
|
INSTRUCTION_RETENTION_YEARS: 5,
|
||||||
|
SUB_PROCESSOR_NOTICE_WEEKS: 6,
|
||||||
|
SUB_PROCESSOR_OBJECTION_WEEKS: 4,
|
||||||
|
DATA_EXPORT_FORMAT: 'CSV/JSON',
|
||||||
|
RETURN_CHOICE_WEEKS: 8,
|
||||||
|
DELETION_DAYS: 30,
|
||||||
|
HAS_LIABILITY_PROTECTION: true,
|
||||||
|
HAS_SUPPORT_COST_CLAUSE: true,
|
||||||
|
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: false,
|
||||||
|
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: true,
|
||||||
|
HAS_REACTIVATION_PERIOD: false,
|
||||||
|
REACTIVATION_MONTHS: 3,
|
||||||
|
HAS_RETURN_COST_CLAUSE: true,
|
||||||
|
HAS_GERICHTSSTAND_CLAUSE: true,
|
||||||
|
HAS_UNILATERAL_CHANGE_RIGHT: false,
|
||||||
|
HAS_THIRD_COUNTRY_OBJECTION: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Public API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface GeneratorDefaults {
|
||||||
|
tom: Partial<TOMCtx>
|
||||||
|
dpa: Partial<DPACtx>
|
||||||
|
/** Which fields were set by the scope engine (for UI highlighting) */
|
||||||
|
scopeSet: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechnet Generator-Defaults basierend auf dem Compliance-Level
|
||||||
|
* und dem CompanyProfile. Alle Werte sind Vorschlaege — der Kunde
|
||||||
|
* kann sie aendern.
|
||||||
|
*/
|
||||||
|
export function getGeneratorDefaults(
|
||||||
|
level: ComplianceDepthLevel,
|
||||||
|
profile?: CompanyProfile | null,
|
||||||
|
): GeneratorDefaults {
|
||||||
|
const tomBase = { ...TOM_DEFAULTS[level] }
|
||||||
|
const dpaBase = { ...DPA_DEFAULTS[level] }
|
||||||
|
const scopeSet = new Set<string>()
|
||||||
|
|
||||||
|
// CompanyProfile-Felder in TOM/DPA uebernehmen
|
||||||
|
if (profile) {
|
||||||
|
if (profile.company_name) {
|
||||||
|
dpaBase.AN_NAME = profile.company_name
|
||||||
|
scopeSet.add('DPA.AN_NAME')
|
||||||
|
}
|
||||||
|
if (profile.address) {
|
||||||
|
dpaBase.AN_STRASSE = profile.address
|
||||||
|
scopeSet.add('DPA.AN_STRASSE')
|
||||||
|
}
|
||||||
|
if (profile.city && profile.postal_code) {
|
||||||
|
dpaBase.AN_PLZ_ORT = `${profile.postal_code} ${profile.city}`
|
||||||
|
scopeSet.add('DPA.AN_PLZ_ORT')
|
||||||
|
}
|
||||||
|
if (profile.dpo_name) {
|
||||||
|
tomBase.ISB_NAME = tomBase.ISB_NAME || ''
|
||||||
|
dpaBase.AN_DSB_NAME = profile.dpo_name
|
||||||
|
scopeSet.add('DPA.AN_DSB_NAME')
|
||||||
|
}
|
||||||
|
if (profile.dpo_email) {
|
||||||
|
dpaBase.AN_DSB_EMAIL = profile.dpo_email
|
||||||
|
scopeSet.add('DPA.AN_DSB_EMAIL')
|
||||||
|
}
|
||||||
|
if (profile.ceo_name) {
|
||||||
|
dpaBase.AN_UNTERZEICHNER_NAME = profile.ceo_name
|
||||||
|
tomBase.GF_NAME = profile.ceo_name
|
||||||
|
scopeSet.add('DPA.AN_UNTERZEICHNER_NAME')
|
||||||
|
scopeSet.add('TOM.GF_NAME')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle gesetzten TOM/DPA Felder als scope-set markieren
|
||||||
|
for (const key of Object.keys(tomBase)) {
|
||||||
|
scopeSet.add(`TOM.${key}`)
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(dpaBase)) {
|
||||||
|
scopeSet.add(`DPA.${key}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tom: tomBase, dpa: dpaBase, scopeSet }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt das empfohlene Profil-Label zurueck (fuer UI-Anzeige).
|
||||||
|
*/
|
||||||
|
export function getProfileLabel(level: ComplianceDepthLevel): string {
|
||||||
|
const labels: Record<ComplianceDepthLevel, string> = {
|
||||||
|
L1: 'Startup / Kleinstunternehmen',
|
||||||
|
L2: 'KMU Standard',
|
||||||
|
L3: 'Erweiterte Compliance',
|
||||||
|
L4: 'Zertifizierungsbereit / Enterprise',
|
||||||
|
}
|
||||||
|
return labels[level]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empfiehlt relevante Dokumenttypen basierend auf dem Compliance-Level.
|
||||||
|
* Hilft dem Kunden zu verstehen, welche Dokumente er braucht.
|
||||||
|
*/
|
||||||
|
export function getRecommendedDocuments(level: ComplianceDepthLevel): {
|
||||||
|
required: string[]
|
||||||
|
recommended: string[]
|
||||||
|
optional: string[]
|
||||||
|
} {
|
||||||
|
const always = [
|
||||||
|
'privacy_policy', 'impressum', 'agb', 'cookie_banner', 'cookie_policy',
|
||||||
|
]
|
||||||
|
const l2plus = [
|
||||||
|
'dpa', 'tom_documentation', 'vvt_register', 'loeschkonzept',
|
||||||
|
'community_guidelines', 'terms_of_use',
|
||||||
|
]
|
||||||
|
const l3plus = [
|
||||||
|
'it_security_concept', 'data_protection_concept', 'incident_response_plan',
|
||||||
|
'access_control_concept', 'backup_recovery_concept', 'logging_concept',
|
||||||
|
'risk_management_concept', 'pflichtenregister',
|
||||||
|
'password_policy', 'encryption_policy', 'information_security_policy',
|
||||||
|
'access_control_policy', 'whistleblower_policy',
|
||||||
|
'employee_dsi', 'applicant_dsi', 'ai_usage_policy',
|
||||||
|
]
|
||||||
|
const l4only = [
|
||||||
|
'isms_manual', 'cybersecurity_policy', 'byod_policy',
|
||||||
|
'dsfa', 'social_media_dsi', 'media_content_policy',
|
||||||
|
'video_conference_dsi', 'consent_texts',
|
||||||
|
'data_protection_policy', 'data_classification_policy',
|
||||||
|
'data_retention_policy', 'data_transfer_policy',
|
||||||
|
'privacy_incident_policy', 'employee_security_policy',
|
||||||
|
'security_awareness_policy', 'remote_work_policy',
|
||||||
|
'offboarding_policy', 'vendor_risk_management_policy',
|
||||||
|
'third_party_security_policy', 'supplier_security_policy',
|
||||||
|
'business_continuity_policy', 'disaster_recovery_policy',
|
||||||
|
'crisis_management_policy',
|
||||||
|
]
|
||||||
|
|
||||||
|
switch (level) {
|
||||||
|
case 'L1':
|
||||||
|
return { required: always, recommended: [], optional: l2plus }
|
||||||
|
case 'L2':
|
||||||
|
return { required: always, recommended: l2plus, optional: l3plus }
|
||||||
|
case 'L3':
|
||||||
|
return { required: [...always, ...l2plus], recommended: l3plus, optional: l4only }
|
||||||
|
case 'L4':
|
||||||
|
return { required: [...always, ...l2plus, ...l3plus], recommended: l4only, optional: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
/**
|
||||||
|
* Template Recommendations — Maps scope answers to document templates
|
||||||
|
*
|
||||||
|
* Bridges the gap between the Compliance Scope Engine (23 ScopeDocumentTypes)
|
||||||
|
* and the Document Generator (70+ database templates).
|
||||||
|
*
|
||||||
|
* The scope engine recommends high-level document categories (vvt, tom, dsfa...).
|
||||||
|
* This module recommends SPECIFIC templates based on additional context from
|
||||||
|
* the CompanyProfile and scope answers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ComplianceDepthLevel } from '../../lib/sdk/compliance-scope-types/core-levels'
|
||||||
|
import type { ScopeProfilingAnswer } from '../../lib/sdk/compliance-scope-types/state'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Template recommendation rules
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface TemplateRule {
|
||||||
|
/** Database document_type */
|
||||||
|
templateType: string
|
||||||
|
/** Human-readable label */
|
||||||
|
label: string
|
||||||
|
/** When to recommend this template */
|
||||||
|
condition: (answers: Map<string, string>, level: ComplianceDepthLevel, profile: Record<string, unknown>) => 'required' | 'recommended' | 'optional' | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rules that map scope answers + profile to specific template recommendations.
|
||||||
|
* These cover templates NOT directly output by the scope engine.
|
||||||
|
*/
|
||||||
|
const TEMPLATE_RULES: TemplateRule[] = [
|
||||||
|
// ── HR-DSI ──────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'employee_dsi',
|
||||||
|
label: 'Mitarbeiter-Datenschutzinformation',
|
||||||
|
condition: (answers, level) => {
|
||||||
|
const hasEmployees = answers.get('org_has_employees')
|
||||||
|
const empCount = answers.get('org_employee_count')
|
||||||
|
if (hasEmployees === 'yes' || (empCount && empCount !== 'none' && empCount !== '0')) {
|
||||||
|
return level >= 'L2' ? 'required' : 'recommended'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'applicant_dsi',
|
||||||
|
label: 'Bewerber-Datenschutzinformation',
|
||||||
|
condition: (answers, level) => {
|
||||||
|
const hasEmployees = answers.get('org_has_employees')
|
||||||
|
const empCount = answers.get('org_employee_count')
|
||||||
|
if (hasEmployees === 'yes' || (empCount && empCount !== 'none' && empCount !== '0')) {
|
||||||
|
return level >= 'L2' ? 'recommended' : 'optional'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Whistleblower ───────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'whistleblower_policy',
|
||||||
|
label: 'Hinweisgeberrichtlinie (HinSchG)',
|
||||||
|
condition: (answers) => {
|
||||||
|
const empCount = answers.get('org_employee_count')
|
||||||
|
// HinSchG Pflicht ab 50 MA
|
||||||
|
if (empCount === '50_249' || empCount === '250_999' || empCount === '1000_plus') return 'required'
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── KI ──────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'ai_usage_policy',
|
||||||
|
label: 'KI-Nutzungsrichtlinie',
|
||||||
|
condition: (answers) => {
|
||||||
|
const aiUsage = answers.get('proc_ai_usage') || answers.get('proc_uses_ai_tools')
|
||||||
|
if (aiUsage && aiUsage !== 'none' && aiUsage !== 'no') return 'required'
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── BYOD ────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'byod_policy',
|
||||||
|
label: 'BYOD-Richtlinie',
|
||||||
|
condition: (answers, level) => {
|
||||||
|
const byod = answers.get('proc_byod_allowed')
|
||||||
|
if (byod === 'yes') return 'required'
|
||||||
|
if (level >= 'L3') return 'recommended'
|
||||||
|
return 'optional'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Social Media ────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'social_media_dsi',
|
||||||
|
label: 'Social-Media-Datenschutzinformation',
|
||||||
|
condition: (answers, level) => {
|
||||||
|
const sm = answers.get('org_has_social_media')
|
||||||
|
if (sm === 'yes') return 'required'
|
||||||
|
return level >= 'L2' ? 'recommended' : 'optional'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Videokonferenzen ────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'video_conference_dsi',
|
||||||
|
label: 'Videokonferenz-Datenschutzinformation',
|
||||||
|
condition: (answers, level) => {
|
||||||
|
const video = answers.get('org_has_video_conferencing')
|
||||||
|
if (video === 'yes') return 'recommended'
|
||||||
|
if (level >= 'L3') return 'recommended'
|
||||||
|
return 'optional'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Security Policies (nur ab L3/L4) ───────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'information_security_policy',
|
||||||
|
label: 'Informationssicherheitsrichtlinie',
|
||||||
|
condition: (_answers, level) => {
|
||||||
|
if (level >= 'L3') return 'required'
|
||||||
|
if (level === 'L2') return 'recommended'
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'password_policy',
|
||||||
|
label: 'Passwortrichtlinie',
|
||||||
|
condition: (_answers, level) => level >= 'L2' ? 'recommended' : 'optional',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'encryption_policy',
|
||||||
|
label: 'Verschluesselungsrichtlinie',
|
||||||
|
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'access_control_policy',
|
||||||
|
label: 'Zugriffskontrollrichtlinie',
|
||||||
|
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Security Concepts (nur ab L3) ──────────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'it_security_concept',
|
||||||
|
label: 'IT-Sicherheitskonzept',
|
||||||
|
condition: (_answers, level) => level >= 'L3' ? 'required' : 'optional',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'backup_recovery_concept',
|
||||||
|
label: 'Backup-Recovery-Konzept',
|
||||||
|
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'logging_concept',
|
||||||
|
label: 'Logging-Konzept',
|
||||||
|
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'access_control_concept',
|
||||||
|
label: 'Zugriffskonzept',
|
||||||
|
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Plattform/UGC ──────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'community_guidelines',
|
||||||
|
label: 'Gemeinschaftsrichtlinien',
|
||||||
|
condition: (answers) => {
|
||||||
|
const model = answers.get('org_business_model')
|
||||||
|
const ugc = answers.get('prod_ugc_platform')
|
||||||
|
if (ugc === 'yes' || model === 'platform' || model === 'marketplace' || model === 'social') return 'required'
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'terms_of_use',
|
||||||
|
label: 'Nutzungsbedingungen',
|
||||||
|
condition: (answers) => {
|
||||||
|
const model = answers.get('org_business_model')
|
||||||
|
const ugc = answers.get('prod_ugc_platform')
|
||||||
|
if (ugc === 'yes' || model === 'platform' || model === 'marketplace' || model === 'social' || model === 'saas') return 'required'
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'media_content_policy',
|
||||||
|
label: 'Medien- und Inhalte-Richtlinie',
|
||||||
|
condition: (answers) => {
|
||||||
|
const model = answers.get('org_business_model')
|
||||||
|
if (model === 'platform' || model === 'media') return 'recommended'
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── E-Commerce ─────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'widerruf',
|
||||||
|
label: 'Widerrufsbelehrung',
|
||||||
|
condition: (answers) => {
|
||||||
|
const shop = answers.get('prod_webshop')
|
||||||
|
if (shop && shop !== 'no') return 'required'
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'consent_texts',
|
||||||
|
label: 'Einwilligungstexte (Double-Opt-In)',
|
||||||
|
condition: (answers) => {
|
||||||
|
const consent = answers.get('prod_consent_management')
|
||||||
|
if (consent && consent !== 'no') return 'recommended'
|
||||||
|
return 'optional'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Impressum + Cookie ─────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'impressum',
|
||||||
|
label: 'Impressum',
|
||||||
|
condition: () => 'required', // Immer Pflicht
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'cookie_policy',
|
||||||
|
label: 'Cookie-Richtlinie',
|
||||||
|
condition: () => 'required', // Immer Pflicht bei Websites
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Drittlandtransfer (SCC + TIA) ───────────────────────────────────────
|
||||||
|
// SCC+TIA nur erforderlich wenn Drittlandtransfer OHNE Angemessenheitsbeschluss/DPF
|
||||||
|
{
|
||||||
|
templateType: 'transfer_impact_assessment',
|
||||||
|
label: 'Transfer Impact Assessment (TIA)',
|
||||||
|
condition: (answers) => {
|
||||||
|
const thirdCountry = answers.get('tech_third_country')
|
||||||
|
if (!thirdCountry || thirdCountry === 'no') return null
|
||||||
|
// Wenn nur DPF-zertifizierte US-Anbieter: empfohlen statt pflicht
|
||||||
|
if (thirdCountry === 'us_dpf_only') return 'optional'
|
||||||
|
// Wenn nur Laender mit Angemessenheitsbeschluss: nicht noetig
|
||||||
|
if (thirdCountry === 'adequate_only') return null
|
||||||
|
return 'required'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'scc_companion',
|
||||||
|
label: 'Standardvertragsklauseln (SCC) — Anhaenge',
|
||||||
|
condition: (answers) => {
|
||||||
|
const thirdCountry = answers.get('tech_third_country')
|
||||||
|
if (!thirdCountry || thirdCountry === 'no') return null
|
||||||
|
if (thirdCountry === 'us_dpf_only') return 'optional'
|
||||||
|
if (thirdCountry === 'adequate_only') return null
|
||||||
|
return 'required'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── ISMS (nur bei Zertifizierungsziel) ─────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'isms_manual',
|
||||||
|
label: 'ISMS-Handbuch',
|
||||||
|
condition: (answers) => {
|
||||||
|
const cert = answers.get('org_cert_target')
|
||||||
|
if (cert === 'iso27001' || cert === 'iso27701' || cert === 'tisax') return 'required'
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Vendor/BCM (nur ab L4 oder bei Vendor-Management) ─────────────────
|
||||||
|
{
|
||||||
|
templateType: 'vendor_risk_management_policy',
|
||||||
|
label: 'Vendor-Risikomanagement',
|
||||||
|
condition: (answers, level) => {
|
||||||
|
const vendor = answers.get('comp_vendor_management')
|
||||||
|
if (vendor && vendor !== 'no') return 'recommended'
|
||||||
|
if (level === 'L4') return 'required'
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'business_continuity_policy',
|
||||||
|
label: 'Business-Continuity-Richtlinie',
|
||||||
|
condition: (_answers, level) => level === 'L4' ? 'required' : 'optional',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Public API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface TemplateRecommendation {
|
||||||
|
templateType: string
|
||||||
|
label: string
|
||||||
|
requirement: 'required' | 'recommended' | 'optional'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates all template rules against the user's scope answers and profile.
|
||||||
|
* Returns a prioritized list of template recommendations.
|
||||||
|
*/
|
||||||
|
export function evaluateTemplateRecommendations(
|
||||||
|
scopeAnswers: ScopeProfilingAnswer[],
|
||||||
|
level: ComplianceDepthLevel,
|
||||||
|
profile: Record<string, unknown> = {},
|
||||||
|
): TemplateRecommendation[] {
|
||||||
|
const answerMap = new Map<string, string>()
|
||||||
|
for (const a of scopeAnswers) {
|
||||||
|
answerMap.set(a.questionId, String(a.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: TemplateRecommendation[] = []
|
||||||
|
|
||||||
|
for (const rule of TEMPLATE_RULES) {
|
||||||
|
const requirement = rule.condition(answerMap, level, profile)
|
||||||
|
if (requirement) {
|
||||||
|
results.push({
|
||||||
|
templateType: rule.templateType,
|
||||||
|
label: rule.label,
|
||||||
|
requirement,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: required first, then recommended, then optional
|
||||||
|
const order = { required: 0, recommended: 1, optional: 2 }
|
||||||
|
results.sort((a, b) => order[a.requirement] - order[b.requirement])
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
@@ -2,16 +2,38 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import type { DSFA } from './DSFACard'
|
import type { DSFA } from './DSFACard'
|
||||||
|
import type { DSFAPrefillResult } from '@/lib/sdk/dsfa/prefill-from-scope'
|
||||||
|
|
||||||
export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; onSubmit: (data: Partial<DSFA>) => Promise<void> }) {
|
interface GeneratorWizardProps {
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (data: Partial<DSFA>) => Promise<void>
|
||||||
|
prefill?: DSFAPrefillResult | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GeneratorWizard({ onClose, onSubmit, prefill }: GeneratorWizardProps) {
|
||||||
const [step, setStep] = useState(1)
|
const [step, setStep] = useState(1)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState(prefill?.title || '')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState(prefill?.description || '')
|
||||||
const [processingActivity, setProcessingActivity] = useState('')
|
const [processingActivity, setProcessingActivity] = useState(prefill?.processingActivity || '')
|
||||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
|
const [selectedCategories, setSelectedCategories] = useState<string[]>(prefill?.dataCategories || [])
|
||||||
const [riskLevel, setRiskLevel] = useState<'low' | 'medium' | 'high' | 'critical'>('low')
|
const riskMap2: Record<string, 'low' | 'medium' | 'high' | 'critical'> = { niedrig: 'low', mittel: 'medium', hoch: 'high', kritisch: 'critical' }
|
||||||
const [selectedMeasures, setSelectedMeasures] = useState<string[]>([])
|
const [riskLevel, setRiskLevel] = useState<'low' | 'medium' | 'high' | 'critical'>(riskMap2[prefill?.riskLevel || ''] || 'low')
|
||||||
|
const [residualRisk, setResidualRisk] = useState<'low' | 'medium' | 'high' | 'critical'>('low')
|
||||||
|
const [selectedMeasures, setSelectedMeasures] = useState<string[]>(prefill?.measures || [])
|
||||||
|
const [linkedVvtId, setLinkedVvtId] = useState('')
|
||||||
|
const [vvtActivities, setVvtActivities] = useState<Array<{ id: string; name: string }>>([])
|
||||||
|
|
||||||
|
// Load VVT activities for linking
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetch('/api/sdk/v1/compliance/vvt')
|
||||||
|
.then(r => r.ok ? r.json() : [])
|
||||||
|
.then(data => {
|
||||||
|
const items = Array.isArray(data) ? data : data.activities || []
|
||||||
|
setVvtActivities(items.map((a: any) => ({ id: a.id, name: a.name || a.processing_name || a.title || 'Unbenannt' })))
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const riskMap: Record<string, 'low' | 'medium' | 'high' | 'critical'> = {
|
const riskMap: Record<string, 'low' | 'medium' | 'high' | 'critical'> = {
|
||||||
Niedrig: 'low', Mittel: 'medium', Hoch: 'high', Kritisch: 'critical',
|
Niedrig: 'low', Mittel: 'medium', Hoch: 'high', Kritisch: 'critical',
|
||||||
@@ -28,7 +50,12 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
|
|||||||
riskLevel,
|
riskLevel,
|
||||||
measures: selectedMeasures,
|
measures: selectedMeasures,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
})
|
...(prefill?.federalState ? { federal_state: prefill.federalState } : {}),
|
||||||
|
...(prefill?.involvesAi ? { involves_ai: true } : {}),
|
||||||
|
...(prefill?.legalBasis ? { legal_basis: prefill.legalBasis } : {}),
|
||||||
|
...(linkedVvtId ? { linked_vvt_id: linkedVvtId } : {}),
|
||||||
|
...(residualRisk !== 'low' ? { residual_risk_level: residualRisk } : {}),
|
||||||
|
} as Partial<DSFA>)
|
||||||
onClose()
|
onClose()
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
@@ -48,7 +75,7 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
|
|||||||
|
|
||||||
{/* Progress Steps */}
|
{/* Progress Steps */}
|
||||||
<div className="flex items-center gap-2 mb-6">
|
<div className="flex items-center gap-2 mb-6">
|
||||||
{[1, 2, 3, 4].map(s => (
|
{[1, 2, 3, 4, 5].map(s => (
|
||||||
<React.Fragment key={s}>
|
<React.Fragment key={s}>
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||||
s < step ? 'bg-green-500 text-white' :
|
s < step ? 'bg-green-500 text-white' :
|
||||||
@@ -60,7 +87,7 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
|
|||||||
</svg>
|
</svg>
|
||||||
) : s}
|
) : s}
|
||||||
</div>
|
</div>
|
||||||
{s < 4 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
|
{s < 5 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -89,6 +116,20 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
|
|||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{vvtActivities.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Verknuepfte VVT-Aktivitaet (Art. 30)</label>
|
||||||
|
<select value={linkedVvtId} onChange={e => {
|
||||||
|
setLinkedVvtId(e.target.value)
|
||||||
|
const selected = vvtActivities.find(a => a.id === e.target.value)
|
||||||
|
if (selected && !processingActivity) setProcessingActivity(selected.name)
|
||||||
|
}} className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 bg-white">
|
||||||
|
<option value="">— Keine Verknuepfung —</option>
|
||||||
|
{vvtActivities.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">Ordnen Sie diese DSFA einer VVT-Verarbeitungstaetigkeit zu.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verarbeitungstaetigkeit</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Verarbeitungstaetigkeit</label>
|
||||||
<input
|
<input
|
||||||
@@ -167,6 +208,43 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{step === 5 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">Restrisiko nach Massnahmen</label>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
|
Bewerten Sie das verbleibende Risiko NACH Umsetzung der Schutzmassnahmen.
|
||||||
|
Bei hohem Restrisiko → Art. 36 Vorabkonsultation der Aufsichtsbehoerde.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{[
|
||||||
|
{ value: 'low' as const, label: 'Niedrig', desc: 'Risiko ausreichend gemindert', color: 'border-green-300 bg-green-50' },
|
||||||
|
{ value: 'medium' as const, label: 'Mittel', desc: 'Akzeptables Restrisiko', color: 'border-yellow-300 bg-yellow-50' },
|
||||||
|
{ value: 'high' as const, label: 'Hoch', desc: 'Art. 36 Konsultation pruefen', color: 'border-orange-300 bg-orange-50' },
|
||||||
|
{ value: 'critical' as const, label: 'Kritisch', desc: 'Art. 36 Konsultation PFLICHT', color: 'border-red-300 bg-red-50' },
|
||||||
|
].map(r => (
|
||||||
|
<label key={r.value} className={`flex items-start gap-2 p-3 border-2 rounded-lg cursor-pointer ${
|
||||||
|
residualRisk === r.value ? r.color : 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}>
|
||||||
|
<input type="radio" name="residualRisk" value={r.value} checked={residualRisk === r.value}
|
||||||
|
onChange={() => setResidualRisk(r.value)} className="mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">{r.label}</span>
|
||||||
|
<p className="text-xs text-gray-500">{r.desc}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{(residualRisk === 'high' || residualRisk === 'critical') && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-red-700 font-medium">Vorabkonsultation erforderlich (Art. 36 DSGVO)</p>
|
||||||
|
<p className="text-xs text-red-600 mt-1">
|
||||||
|
Bei hohem Restrisiko muss die Aufsichtsbehoerde VOR Beginn der Verarbeitung konsultiert werden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
@@ -179,11 +257,11 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
|
|||||||
{step === 1 ? 'Abbrechen' : 'Zurueck'}
|
{step === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => step < 4 ? setStep(step + 1) : handleSubmit()}
|
onClick={() => step < 5 ? setStep(step + 1) : handleSubmit()}
|
||||||
disabled={saving || (step === 1 && !title.trim())}
|
disabled={saving || (step === 1 && !title.trim())}
|
||||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{step === 4 ? (saving ? 'Wird erstellt...' : 'DSFA erstellen') : 'Weiter'}
|
{step === 5 ? (saving ? 'Wird erstellt...' : 'DSFA erstellen') : 'Weiter'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from 'react'
|
import { useState, useCallback, useEffect, useMemo } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useSDK } from '@/lib/sdk'
|
import { useSDK } from '@/lib/sdk'
|
||||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||||
import { DocumentUploadSection, type UploadedDocument } from '@/components/sdk'
|
import { DocumentUploadSection, type UploadedDocument } from '@/components/sdk'
|
||||||
import { DSFACard, type DSFA } from './_components/DSFACard'
|
import { DSFACard, type DSFA } from './_components/DSFACard'
|
||||||
import { GeneratorWizard } from './_components/GeneratorWizard'
|
import { GeneratorWizard } from './_components/GeneratorWizard'
|
||||||
|
import { prefillDSFAFromScope, isDSFARequired } from '@/lib/sdk/dsfa/prefill-from-scope'
|
||||||
|
|
||||||
export default function DSFAPage() {
|
export default function DSFAPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -17,6 +18,17 @@ export default function DSFAPage() {
|
|||||||
const [showGenerator, setShowGenerator] = useState(false)
|
const [showGenerator, setShowGenerator] = useState(false)
|
||||||
const [filter, setFilter] = useState<string>('all')
|
const [filter, setFilter] = useState<string>('all')
|
||||||
|
|
||||||
|
// Pre-fill from Company Profile + Scope answers
|
||||||
|
const scopeAnswers = state.complianceScope?.answers || []
|
||||||
|
const prefill = useMemo(
|
||||||
|
() => prefillDSFAFromScope(state.companyProfile || null, scopeAnswers),
|
||||||
|
[state.companyProfile, scopeAnswers]
|
||||||
|
)
|
||||||
|
const dsfaCheck = useMemo(
|
||||||
|
() => isDSFARequired(scopeAnswers, state.companyProfile?.headquartersState),
|
||||||
|
[scopeAnswers, state.companyProfile?.headquartersState]
|
||||||
|
)
|
||||||
|
|
||||||
const loadDSFAs = useCallback(async () => {
|
const loadDSFAs = useCallback(async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -120,10 +132,42 @@ export default function DSFAPage() {
|
|||||||
)}
|
)}
|
||||||
</StepHeader>
|
</StepHeader>
|
||||||
|
|
||||||
|
{/* DSFA Requirement Check */}
|
||||||
|
{dsfaCheck.required && dsfas.length === 0 && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-xl p-5">
|
||||||
|
<h3 className="font-semibold text-red-800">DSFA erforderlich (Art. 35 DSGVO)</h3>
|
||||||
|
<p className="text-sm text-red-700 mt-1">Basierend auf Ihrem Scope-Profiling wurde festgestellt:</p>
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{dsfaCheck.triggers.map(t => (
|
||||||
|
<li key={t} className="text-sm text-red-600 flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0" />
|
||||||
|
{t}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{dsfaCheck.blacklistMatches.length > 0 && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-red-200">
|
||||||
|
<p className="text-xs font-medium text-red-800 mb-1">
|
||||||
|
Blacklist {dsfaCheck.authority || 'Aufsichtsbehoerde'} (Art. 35 Abs. 4):
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{dsfaCheck.blacklistMatches.map(m => (
|
||||||
|
<li key={m} className="text-xs text-red-600 flex items-center gap-2">
|
||||||
|
<span className="w-1 h-1 bg-red-400 rounded-full flex-shrink-0" />
|
||||||
|
{m}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showGenerator && (
|
{showGenerator && (
|
||||||
<GeneratorWizard
|
<GeneratorWizard
|
||||||
onClose={() => setShowGenerator(false)}
|
onClose={() => setShowGenerator(false)}
|
||||||
onSubmit={handleCreateDSFA}
|
onSubmit={handleCreateDSFA}
|
||||||
|
prefill={prefill}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export function ActionButtons({
|
|||||||
onExtendDeadline,
|
onExtendDeadline,
|
||||||
onComplete,
|
onComplete,
|
||||||
onReject,
|
onReject,
|
||||||
onAssign
|
onAssign,
|
||||||
|
onRejectArt11,
|
||||||
}: {
|
}: {
|
||||||
request: DSRRequest
|
request: DSRRequest
|
||||||
onVerifyIdentity: () => void
|
onVerifyIdentity: () => void
|
||||||
@@ -17,15 +18,31 @@ export function ActionButtons({
|
|||||||
onComplete: () => void
|
onComplete: () => void
|
||||||
onReject: () => void
|
onReject: () => void
|
||||||
onAssign: () => void
|
onAssign: () => void
|
||||||
|
onRejectArt11?: () => void
|
||||||
}) {
|
}) {
|
||||||
const isTerminal = request.status === 'completed' || request.status === 'rejected' || request.status === 'cancelled'
|
const isTerminal = request.status === 'completed' || request.status === 'rejected' || request.status === 'cancelled'
|
||||||
|
|
||||||
if (isTerminal) {
|
if (isTerminal) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<button className="w-full px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-sm">
|
<button
|
||||||
|
onClick={() => window.open(`/api/sdk/v1/dsr/${request.id}/export-user-data?format=pdf`, '_blank')}
|
||||||
|
className="w-full px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
PDF exportieren
|
PDF exportieren
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.open(`/api/sdk/v1/dsr/${request.id}/export-user-data?format=json`, '_blank')}
|
||||||
|
className="w-full px-4 py-2 text-purple-600 bg-purple-50 hover:bg-purple-100 rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
|
JSON exportieren (Art. 20)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.open(`/api/sdk/v1/dsr/${request.id}/export-user-data?format=csv`, '_blank')}
|
||||||
|
className="w-full px-4 py-2 text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
|
CSV exportieren
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -33,12 +50,23 @@ export function ActionButtons({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{!request.identityVerification.verified && (
|
{!request.identityVerification.verified && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={onVerifyIdentity}
|
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"
|
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
|
Identitaet verifizieren
|
||||||
</button>
|
</button>
|
||||||
|
{onRejectArt11 && (
|
||||||
|
<button
|
||||||
|
onClick={onRejectArt11}
|
||||||
|
className="w-full px-4 py-2 text-gray-600 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg transition-colors text-sm"
|
||||||
|
title="Person kann anhand der gespeicherten Daten nicht identifiziert werden (Art. 11 DSGVO)"
|
||||||
|
>
|
||||||
|
Nicht identifizierbar (Art. 11)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { useBannerConsents } from '../_hooks/useBannerConsents'
|
import { useBannerConsents } from '../_hooks/useBannerConsents'
|
||||||
import { BannerConsentRecord, PAGE_SIZE } from '../_types'
|
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 {
|
function formatDate(iso: string | null): string {
|
||||||
if (!iso) return '—'
|
if (!iso) return '—'
|
||||||
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
@@ -42,12 +45,35 @@ const methodColors: Record<string, string> = {
|
|||||||
export default function BannerConsentsTab() {
|
export default function BannerConsentsTab() {
|
||||||
const {
|
const {
|
||||||
records, sites, selectedSite, changeSite,
|
records, sites, selectedSite, changeSite,
|
||||||
stats, currentPage, setCurrentPage, totalRecords, loading,
|
stats, currentPage, setCurrentPage, totalRecords, loading, reload,
|
||||||
} = useBannerConsents()
|
} = useBannerConsents()
|
||||||
|
|
||||||
const [detail, setDetail] = useState<BannerConsentRecord | null>(null)
|
const [detail, setDetail] = useState<BannerConsentRecord | null>(null)
|
||||||
|
const [linkEmailInput, setLinkEmailInput] = useState('')
|
||||||
|
const [linkingEmail, setLinkingEmail] = useState(false)
|
||||||
const totalPages = Math.ceil(totalRecords / PAGE_SIZE)
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Stats + Site Selector */}
|
{/* Stats + Site Selector */}
|
||||||
@@ -184,6 +210,18 @@ export default function BannerConsentsTab() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">Methode</span>
|
<span className="text-gray-500">Methode</span>
|
||||||
<span>{detail.consent_method ? (
|
<span>{detail.consent_method ? (
|
||||||
@@ -192,9 +230,28 @@ export default function BannerConsentsTab() {
|
|||||||
</span>
|
</span>
|
||||||
) : '—'}</span>
|
) : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-gray-500">Verknüpft mit</span>
|
<span className="text-gray-500">Verknüpft mit</span>
|
||||||
<span>{detail.linked_email || '— (anonym)'}</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>
|
||||||
<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">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">Ablauf</span><span>{formatDate(detail.expires_at)}</span></div>
|
||||||
@@ -223,6 +280,37 @@ export default function BannerConsentsTab() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Technische Details */}
|
||||||
<div className="border-t border-gray-100 pt-3">
|
<div className="border-t border-gray-100 pt-3">
|
||||||
<p className="text-xs font-semibold text-gray-700 mb-2">Technisch</p>
|
<p className="text-xs font-semibold text-gray-700 mb-2">Technisch</p>
|
||||||
@@ -233,6 +321,16 @@ export default function BannerConsentsTab() {
|
|||||||
{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>}
|
{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>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export interface BannerConsentRecord {
|
|||||||
device_fingerprint: string
|
device_fingerprint: string
|
||||||
categories: string[]
|
categories: string[]
|
||||||
vendors: string[]
|
vendors: string[]
|
||||||
|
vendor_consents: Record<string, boolean>
|
||||||
ip_hash: string | null
|
ip_hash: string | null
|
||||||
user_agent: string | null
|
user_agent: string | null
|
||||||
linked_email: string | null
|
linked_email: string | null
|
||||||
@@ -126,6 +127,10 @@ export interface BannerConsentRecord {
|
|||||||
os: string | null
|
os: string | null
|
||||||
screen_resolution: string | null
|
screen_resolution: string | null
|
||||||
session_id: 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
|
expires_at: string | null
|
||||||
created_at: string | null
|
created_at: string | null
|
||||||
updated_at: string | null
|
updated_at: string | null
|
||||||
@@ -140,4 +145,5 @@ export interface BannerSite {
|
|||||||
site_id: string
|
site_id: string
|
||||||
site_name: string
|
site_name: string
|
||||||
site_url: string
|
site_url: string
|
||||||
|
tcf_enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
interface GapReport {
|
interface GapReport {
|
||||||
|
dsms_cid?: string
|
||||||
profile_name: string
|
profile_name: string
|
||||||
regulations: Array<{
|
regulations: Array<{
|
||||||
id: string
|
id: string
|
||||||
@@ -79,6 +80,20 @@ export function GapDashboard({ report, onBack }: Props) {
|
|||||||
← Neue Analyse
|
← Neue Analyse
|
||||||
</button>
|
</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 */}
|
{/* Summary Cards */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
|
import { IstAssessment } from './IstAssessment'
|
||||||
|
|
||||||
const PRODUCT_TYPES = [
|
const PRODUCT_TYPES = [
|
||||||
{ value: 'iot', label: 'IoT / Connected Device' },
|
{ value: 'iot', label: 'IoT / Connected Device' },
|
||||||
@@ -60,6 +61,20 @@ export function ProductWizard({ onAnalyze, loading }: Props) {
|
|||||||
const [usesAI, setUsesAI] = useState(false)
|
const [usesAI, setUsesAI] = useState(false)
|
||||||
const [processesPersonalData, setProcessesPersonalData] = useState(false)
|
const [processesPersonalData, setProcessesPersonalData] = useState(false)
|
||||||
const [isCriticalInfra, setIsCriticalInfra] = 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 = (
|
const toggleArrayValue = (
|
||||||
arr: string[],
|
arr: string[],
|
||||||
@@ -83,11 +98,59 @@ export function ProductWizard({ onAnalyze, loading }: Props) {
|
|||||||
processes_personal_data: processesPersonalData,
|
processes_personal_data: processesPersonalData,
|
||||||
is_critical_infra_supplier: isCriticalInfra,
|
is_critical_infra_supplier: isCriticalInfra,
|
||||||
existing_certifications: certifications,
|
existing_certifications: certifications,
|
||||||
|
...istData,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
|
<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 */}
|
{/* Produktname */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
@@ -225,14 +288,15 @@ export function ProductWizard({ onAnalyze, loading }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submit */}
|
{/* Next Step */}
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={() => setStep(2)}
|
||||||
disabled={!productType || loading}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
{loading ? 'Analyse laeuft...' : 'Gap-Analyse starten'}
|
Weiter: IST-Zustand erfassen →
|
||||||
</button>
|
</button>
|
||||||
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { ProductWizard } from './_components/ProductWizard'
|
import { ProductWizard } from './_components/ProductWizard'
|
||||||
import { GapDashboard } from './_components/GapDashboard'
|
import { GapDashboard } from './_components/GapDashboard'
|
||||||
|
|
||||||
|
interface GapProject {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
product_type: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
interface GapReport {
|
interface GapReport {
|
||||||
profile_id: string
|
profile_id: string
|
||||||
profile_name: string
|
profile_name: string
|
||||||
@@ -39,23 +47,80 @@ interface GapReport {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
export default function GapAnalysisPage() {
|
||||||
|
const [view, setView] = useState<View>('projects')
|
||||||
|
const [projects, setProjects] = useState<GapProject[]>([])
|
||||||
const [report, setReport] = useState<GapReport | null>(null)
|
const [report, setReport] = useState<GapReport | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const handleAnalyze = async (profile: Record<string, unknown>) => {
|
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)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/sdk/v1/gap/analyze', {
|
// Save project
|
||||||
|
const createRes = await fetch('/api/sdk/v1/gap/projects', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Tenant-ID': '00000000-0000-0000-0000-000000000001',
|
||||||
|
},
|
||||||
body: JSON.stringify(profile),
|
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())
|
if (!res.ok) throw new Error(await res.text())
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setReport(data)
|
setReport(data)
|
||||||
|
setView('dashboard')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Analyse fehlgeschlagen')
|
setError(e instanceof Error ? e.message : 'Analyse fehlgeschlagen')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -66,29 +131,88 @@ export default function GapAnalysisPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 py-8">
|
<div className="min-h-screen bg-gray-50 py-8">
|
||||||
<div className="max-w-6xl mx-auto px-4">
|
<div className="max-w-6xl mx-auto px-4">
|
||||||
<div className="mb-8">
|
<div className="mb-8 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">
|
<h1 className="text-3xl font-bold text-gray-900">
|
||||||
Regulatory Gap-Analyse
|
Regulatory Gap-Analyse
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 mt-2">
|
<p className="text-gray-600 mt-2">
|
||||||
Beschreiben Sie Ihr Produkt und erhalten Sie eine priorisierte
|
Produkt beschreiben, Regulierungen erkennen, Prioritaeten setzen.
|
||||||
Liste der Compliance-Anforderungen.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 && (
|
{error && (
|
||||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
<p className="text-red-700">{error}</p>
|
<p className="text-red-700">{error}</p>
|
||||||
|
<button onClick={() => setError('')} className="text-sm text-red-500 mt-1 underline">
|
||||||
|
Schliessen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!report ? (
|
{view === 'projects' && (
|
||||||
<ProductWizard onAnalyze={handleAnalyze} loading={loading} />
|
<div>
|
||||||
) : (
|
{/* New project button */}
|
||||||
<GapDashboard
|
<button
|
||||||
report={report}
|
onClick={() => setView('wizard')}
|
||||||
onBack={() => setReport(null)}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface DeltaResult {
|
||||||
|
added_patterns?: Array<{ pattern_name: string; hazard_cats: string[] }>
|
||||||
|
removed_patterns?: Array<{ pattern_name: string; hazard_cats: string[] }>
|
||||||
|
added_hazards?: Array<{ name: string; category: string }>
|
||||||
|
removed_hazards?: Array<{ name: string; category: string }>
|
||||||
|
added_measures?: Array<{ id: string; name: string }>
|
||||||
|
removed_measures?: Array<{ id: string; name: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeltaPreviewModalProps {
|
||||||
|
projectId: string
|
||||||
|
currentInput: {
|
||||||
|
component_library_ids: string[]
|
||||||
|
energy_source_ids: string[]
|
||||||
|
operational_states?: string[]
|
||||||
|
human_roles?: string[]
|
||||||
|
}
|
||||||
|
proposedInput: {
|
||||||
|
component_library_ids: string[]
|
||||||
|
energy_source_ids: string[]
|
||||||
|
operational_states?: string[]
|
||||||
|
human_roles?: string[]
|
||||||
|
}
|
||||||
|
onClose: () => void
|
||||||
|
onApply: () => void
|
||||||
|
changeDescription: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeltaPreviewModal({
|
||||||
|
projectId,
|
||||||
|
currentInput,
|
||||||
|
proposedInput,
|
||||||
|
onClose,
|
||||||
|
onApply,
|
||||||
|
changeDescription,
|
||||||
|
}: DeltaPreviewModalProps) {
|
||||||
|
const [result, setResult] = useState<DeltaResult | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// Auto-run delta analysis on mount
|
||||||
|
useState(() => {
|
||||||
|
runDelta()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function runDelta() {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/delta-analysis`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ current: currentInput, proposed: proposedInput }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
setError('Delta-Analyse fehlgeschlagen')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setResult(await res.json())
|
||||||
|
} catch {
|
||||||
|
setError('Verbindung fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addedP = result?.added_patterns?.length || 0
|
||||||
|
const removedP = result?.removed_patterns?.length || 0
|
||||||
|
const addedH = result?.added_hazards?.length || 0
|
||||||
|
const removedH = result?.removed_hazards?.length || 0
|
||||||
|
const addedM = result?.added_measures?.length || 0
|
||||||
|
const removedM = result?.removed_measures?.length || 0
|
||||||
|
const hasChanges = addedP + removedP + addedH + removedH + addedM + removedM > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-lg mx-4 max-h-[80vh] overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Delta-Vorschau</h2>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">{changeDescription}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600" />
|
||||||
|
<span className="ml-3 text-sm text-gray-500">Berechne Auswirkungen...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 text-red-700 rounded-lg p-3 text-sm">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && !loading && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Summary Grid */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<DeltaStat label="Patterns" added={addedP} removed={removedP} />
|
||||||
|
<DeltaStat label="Gefaehrdungen" added={addedH} removed={removedH} />
|
||||||
|
<DeltaStat label="Massnahmen" added={addedM} removed={removedM} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasChanges && (
|
||||||
|
<p className="text-sm text-gray-400 italic text-center py-2">
|
||||||
|
Keine Auswirkungen erkannt — die Aenderung beeinflusst keine Patterns.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Added Hazards */}
|
||||||
|
{addedH > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold text-green-700 mb-1">+ Neue Gefaehrdungen</h3>
|
||||||
|
<ul className="space-y-0.5 max-h-32 overflow-y-auto">
|
||||||
|
{result!.added_hazards!.slice(0, 15).map((h, i) => (
|
||||||
|
<li key={i} className="text-xs text-gray-600 flex items-center gap-1">
|
||||||
|
<span className="text-green-500 flex-shrink-0">+</span>
|
||||||
|
<span className="truncate">{h.name || h.category}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{addedH > 15 && <li className="text-xs text-gray-400">... und {addedH - 15} weitere</li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Removed Hazards */}
|
||||||
|
{removedH > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold text-red-700 mb-1">- Entfallene Gefaehrdungen</h3>
|
||||||
|
<ul className="space-y-0.5 max-h-32 overflow-y-auto">
|
||||||
|
{result!.removed_hazards!.slice(0, 10).map((h, i) => (
|
||||||
|
<li key={i} className="text-xs text-gray-600 flex items-center gap-1">
|
||||||
|
<span className="text-red-500 flex-shrink-0">-</span>
|
||||||
|
<span className="truncate">{h.name || h.category}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onApply}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-5 py-2 text-sm font-medium bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Aenderung uebernehmen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeltaStat({ label, added, removed }: { label: string; added: number; removed: number }) {
|
||||||
|
return (
|
||||||
|
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<div className="text-xs text-gray-500 mb-1">{label}</div>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
{added > 0 && <span className="text-sm font-bold text-green-600">+{added}</span>}
|
||||||
|
{removed > 0 && <span className="text-sm font-bold text-red-600">-{removed}</span>}
|
||||||
|
{added === 0 && removed === 0 && <span className="text-sm text-gray-400">0</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -95,13 +95,13 @@ export default function IACEFlowFAB() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end">
|
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end pointer-events-none">
|
||||||
{/* Expanded Panel */}
|
{/* Expanded Panel */}
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className={`mb-3 w-[300px] max-h-[70vh] overflow-y-auto bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 transition-all duration-200 origin-bottom-right ${
|
className={`mb-3 w-[300px] max-h-[70vh] overflow-y-auto bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 transition-all duration-200 origin-bottom-right ${
|
||||||
isOpen
|
isOpen
|
||||||
? 'opacity-100 scale-100 translate-y-0'
|
? 'opacity-100 scale-100 translate-y-0 pointer-events-auto'
|
||||||
: 'opacity-0 scale-95 translate-y-2 pointer-events-none'
|
: 'opacity-0 scale-95 translate-y-2 pointer-events-none'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -223,7 +223,7 @@ export default function IACEFlowFAB() {
|
|||||||
<button
|
<button
|
||||||
ref={fabRef}
|
ref={fabRef}
|
||||||
onClick={() => setIsOpen((o) => !o)}
|
onClick={() => setIsOpen((o) => !o)}
|
||||||
className="w-14 h-14 rounded-full bg-gradient-to-br from-purple-600 to-indigo-600 text-white shadow-lg hover:shadow-xl hover:scale-105 active:scale-95 transition-all flex items-center justify-center"
|
className="pointer-events-auto w-14 h-14 rounded-full bg-gradient-to-br from-purple-600 to-indigo-600 text-white shadow-lg hover:shadow-xl hover:scale-105 active:scale-95 transition-all flex items-center justify-center"
|
||||||
title="CE-Prozessschritte"
|
title="CE-Prozessschritte"
|
||||||
>
|
>
|
||||||
{/* Steps/flow icon */}
|
{/* Steps/flow icon */}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import type { CategoryScore } from '../_hooks/useBenchmark'
|
||||||
|
|
||||||
|
interface Props { breakdown: CategoryScore[] }
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
'mechanische gefaehrdungen': 'Mechanisch',
|
||||||
|
'elektrische gefaehrdungen': 'Elektrisch',
|
||||||
|
'thermische gefaehrdungen': 'Thermisch',
|
||||||
|
'laerm': 'Laerm',
|
||||||
|
'vibration': 'Vibration',
|
||||||
|
'strahlung': 'Strahlung',
|
||||||
|
'materialien und substanzen': 'Materialien/Substanzen',
|
||||||
|
'ergonomische gefaehrdungen': 'Ergonomie',
|
||||||
|
'einsatzumgebung': 'Einsatzumgebung',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryBreakdown({ breakdown }: Props) {
|
||||||
|
if (!breakdown || breakdown.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Coverage nach Gefaehrdungsgruppe</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{breakdown.map((cat) => {
|
||||||
|
const label = CATEGORY_LABELS[cat.category] || cat.category
|
||||||
|
const pct = Math.round(cat.coverage * 100)
|
||||||
|
const barColor = pct >= 80 ? 'bg-green-500' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500'
|
||||||
|
return (
|
||||||
|
<div key={cat.category}>
|
||||||
|
<div className="flex justify-between text-xs text-gray-600 dark:text-gray-400 mb-0.5">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span>{cat.match_count}/{cat.gt_count} ({pct}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full ${barColor} rounded-full transition-all`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react'
|
||||||
|
import type { GroundTruthEntry } from '../_hooks/useBenchmark'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onImport: (gt: { entries: GroundTruthEntry[]; source_file?: string; description?: string }) => Promise<void>
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GTImportForm({ onImport, loading }: Props) {
|
||||||
|
const [jsonText, setJsonText] = useState('')
|
||||||
|
const [parseError, setParseError] = useState<string | null>(null)
|
||||||
|
const [preview, setPreview] = useState<{ count: number; groups: Record<string, number> } | null>(null)
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
function tryParse(text: string) {
|
||||||
|
setJsonText(text)
|
||||||
|
setParseError(null)
|
||||||
|
setPreview(null)
|
||||||
|
if (!text.trim()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text)
|
||||||
|
const entries: GroundTruthEntry[] = parsed.entries || parsed
|
||||||
|
if (!Array.isArray(entries) || entries.length === 0) {
|
||||||
|
setParseError('JSON muss ein Array "entries" enthalten')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Validate first entry has required fields
|
||||||
|
const first = entries[0]
|
||||||
|
if (!first.hazard_type && !first.hazard_group) {
|
||||||
|
setParseError('Eintraege muessen hazard_type oder hazard_group enthalten')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Build preview
|
||||||
|
const groups: Record<string, number> = {}
|
||||||
|
for (const e of entries) {
|
||||||
|
const g = e.hazard_group || 'Unbekannt'
|
||||||
|
groups[g] = (groups[g] || 0) + 1
|
||||||
|
}
|
||||||
|
setPreview({ count: entries.length, groups })
|
||||||
|
} catch (err) {
|
||||||
|
setParseError('Ungueltiges JSON: ' + (err instanceof Error ? err.message : String(err)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
if (!jsonText.trim()) return
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonText)
|
||||||
|
const gt = parsed.entries ? parsed : { entries: parsed }
|
||||||
|
await onImport(gt)
|
||||||
|
setJsonText('')
|
||||||
|
setPreview(null)
|
||||||
|
} catch (err) {
|
||||||
|
setParseError(err instanceof Error ? err.message : 'Import fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
const text = ev.target?.result as string
|
||||||
|
tryParse(text)
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Ground Truth importieren</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
|
JSON-Datei mit der professionellen Risikobeurteilung einfuegen oder hochladen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<button
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
className="px-3 py-1.5 text-xs bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
JSON-Datei waehlen
|
||||||
|
</button>
|
||||||
|
<input ref={fileRef} type="file" accept=".json" onChange={handleFileUpload} className="hidden" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={jsonText}
|
||||||
|
onChange={(e) => tryParse(e.target.value)}
|
||||||
|
placeholder='{"entries": [...], "source_file": "...", "description": "..."}'
|
||||||
|
rows={6}
|
||||||
|
className="w-full text-xs font-mono border border-gray-300 dark:border-gray-600 rounded-md p-2 bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-200 resize-y"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{parseError && (
|
||||||
|
<div className="mt-2 px-3 py-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs text-red-600">
|
||||||
|
{parseError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<div className="mt-2 px-3 py-2 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded text-xs text-green-700 dark:text-green-400">
|
||||||
|
<strong>{preview.count} Eintraege</strong> erkannt:
|
||||||
|
{Object.entries(preview.groups).map(([g, c]) => (
|
||||||
|
<span key={g} className="ml-2">{g}: {c}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={loading || !preview}
|
||||||
|
className="mt-3 w-full px-4 py-2 text-sm font-medium bg-purple-600 hover:bg-purple-700 disabled:bg-gray-300 text-white rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Importiere...' : 'Ground Truth importieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+280
@@ -0,0 +1,280 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import type { HazardMatchPair, GroundTruthEntry, HazardSummary } from '../_hooks/useBenchmark'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
matched: HazardMatchPair[]
|
||||||
|
missing: GroundTruthEntry[]
|
||||||
|
extra: HazardSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabType = 'matched' | 'missing' | 'extra'
|
||||||
|
|
||||||
|
export function HazardComparisonTable({ matched, missing, extra }: Props) {
|
||||||
|
const [tab, setTab] = useState<TabType>('matched')
|
||||||
|
|
||||||
|
// Split matches: >= 50% are real matches, < 50% are weak (shown separately)
|
||||||
|
const realMatched = matched.filter(p => p.match_score >= 0.5)
|
||||||
|
const weakMatched = matched.filter(p => p.match_score < 0.5)
|
||||||
|
|
||||||
|
// Weak matches: GT entries go to "missing", engine entries go to "extra"
|
||||||
|
const allMissing = [...missing, ...weakMatched.map(w => w.gt_entry)]
|
||||||
|
const allExtra = [...extra, ...weakMatched.map(w => w.engine_hazard)]
|
||||||
|
|
||||||
|
const greenCount = realMatched.filter(p => p.match_score >= 0.7).length
|
||||||
|
const yellowCount = realMatched.filter(p => p.match_score >= 0.5 && p.match_score < 0.7).length
|
||||||
|
|
||||||
|
const tabs: { id: TabType; label: string; count: number; color: string }[] = [
|
||||||
|
{ id: 'matched', label: `Zugeordnet (${greenCount} exakt, ${yellowCount} aehnlich)`, count: realMatched.length, color: 'text-green-600' },
|
||||||
|
{ id: 'missing', label: 'Fehlend', count: allMissing.length, color: 'text-red-600' },
|
||||||
|
{ id: 'extra', label: 'Engine Findings', count: allExtra.length, color: 'text-blue-500' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
className={`flex-1 px-4 py-2.5 text-xs font-medium transition-colors ${
|
||||||
|
tab === t.id
|
||||||
|
? 'border-b-2 border-purple-600 text-purple-700 dark:text-purple-400'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label} <span className={t.color}>({t.count})</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
{tab === 'matched' && <MatchedTable pairs={realMatched} />}
|
||||||
|
{tab === 'missing' && <MissingTable entries={allMissing} />}
|
||||||
|
{tab === 'extra' && <ExtraTable entries={allExtra} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) {
|
||||||
|
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
|
||||||
|
if (pairs.length === 0) return <EmptyState text="Keine Zuordnungen gefunden" />
|
||||||
|
return (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50 dark:bg-gray-700/50">
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-500">Nr.</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-500">Ground Truth</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-500">R</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-500">Engine</th>
|
||||||
|
<th className="px-3 py-2 text-center font-medium text-gray-500">Score</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-500">Qualitaet</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{pairs.map((p, i) => {
|
||||||
|
const quality = p.match_score >= 0.7 ? 'green' : p.match_score >= 0.4 ? 'yellow' : 'red'
|
||||||
|
const rowBg = quality === 'green' ? 'bg-green-50/30 dark:bg-green-900/5'
|
||||||
|
: quality === 'yellow' ? 'bg-yellow-50/30 dark:bg-yellow-900/5' : ''
|
||||||
|
const isOpen = expanded[i]
|
||||||
|
return (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
<tr className={`hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer ${rowBg}`}
|
||||||
|
onClick={() => setExpanded(prev => ({ ...prev, [i]: !prev[i] }))}>
|
||||||
|
<td className="px-3 py-2 text-gray-400">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<svg className={`w-3 h-3 text-gray-400 transition-transform ${isOpen ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
{p.gt_entry.nr}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="font-medium text-gray-800 dark:text-gray-200">{p.gt_entry.hazard_type}</div>
|
||||||
|
<div className="text-gray-400 truncate max-w-[250px]">{p.gt_entry.component_zone}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
<RiskBadge risk={p.gt_entry.risk_in.r} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="font-medium text-gray-800 dark:text-gray-200">{p.engine_hazard.name}</div>
|
||||||
|
<div className="text-gray-400">{p.engine_hazard.category}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-center"><ScoreBadge score={p.match_score} /></td>
|
||||||
|
<td className="px-3 py-2"><QualityBadge quality={quality} /></td>
|
||||||
|
</tr>
|
||||||
|
{isOpen && (
|
||||||
|
<tr className="bg-gray-50/70 dark:bg-gray-850">
|
||||||
|
<td colSpan={6} className="px-4 py-3">
|
||||||
|
<DetailComparison gt={p.gt_entry} engine={p.engine_hazard} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIFECYCLE_LABELS: Record<string, string> = {
|
||||||
|
startup: 'Hochfahren', homing: 'Referenzfahrt', automatic_operation: 'Automatikbetrieb',
|
||||||
|
manual_operation: 'Handbetrieb', teach_mode: 'Einrichtbetrieb', maintenance: 'Wartung',
|
||||||
|
cleaning: 'Reinigung', emergency_stop: 'Not-Halt', recovery_mode: 'Wiederanlauf',
|
||||||
|
normal_operation: 'Automatikbetrieb', setup: 'Einrichten', changeover: 'Umruesten',
|
||||||
|
fault_clearing: 'Fehlersuche/Stoerungsbeseitigung', commissioning: 'Inbetriebnahme',
|
||||||
|
decommissioning: 'Demontage/Ausserbetriebnahme', transport: 'Transport',
|
||||||
|
assembly: 'Montage/Installation', inspection: 'Inspektion/Pruefung',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLifecycles(raw: string): string {
|
||||||
|
if (!raw) return '-'
|
||||||
|
return raw.split(',').map(s => s.trim()).map(s => LIFECYCLE_LABELS[s] || s).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Side-by-side detail comparison of GT entry vs. Engine hazard */
|
||||||
|
function DetailComparison({ gt, engine }: { gt: GroundTruthEntry; engine: HazardSummary }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||||
|
{/* Left: Ground Truth */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="font-semibold text-red-700 dark:text-red-400 uppercase text-[10px]">Ground Truth (Fachmann)</div>
|
||||||
|
<DetailRow label="Gefaehrdung" gt={gt.hazard_type} />
|
||||||
|
<DetailRow label="Ursache" gt={gt.hazard_cause} />
|
||||||
|
<DetailRow label="Gefahrenstelle" gt={gt.component_zone} />
|
||||||
|
<DetailRow label="Lebensphasen" gt={gt.lifecycle_phases?.join(', ') || '-'} />
|
||||||
|
<DetailRow label="Risiko" gt={`F=${gt.risk_in.f} W=${gt.risk_in.w} P=${gt.risk_in.p} S=${gt.risk_in.s} => R=${gt.risk_in.r}`} />
|
||||||
|
{gt.risk_out.r > 0 && (
|
||||||
|
<DetailRow label="Restrisiko" gt={`F=${gt.risk_out.f} W=${gt.risk_out.w} P=${gt.risk_out.p} S=${gt.risk_out.s} => R=${gt.risk_out.r}`} />
|
||||||
|
)}
|
||||||
|
<DetailRow label="Massnahmen" gt={gt.measures?.join('\n') || '-'} multiline />
|
||||||
|
<DetailRow label="Typ" gt={gt.measure_type || '-'} />
|
||||||
|
{gt.norm_references?.length > 0 && (
|
||||||
|
<DetailRow label="Normen" gt={gt.norm_references.join(', ')} />
|
||||||
|
)}
|
||||||
|
<DetailRow label="Hinreichend" gt={gt.sufficient ? 'JA' : 'NEIN'} />
|
||||||
|
{gt.comment && <DetailRow label="Kommentar" gt={gt.comment} />}
|
||||||
|
</div>
|
||||||
|
{/* Right: Engine */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="font-semibold text-purple-700 dark:text-purple-400 uppercase text-[10px]">Engine (automatisch)</div>
|
||||||
|
<DetailRow label="Gefaehrdung" gt={engine.name} />
|
||||||
|
<DetailRow label="Szenario" gt={engine.scenario || engine.description || '-'} />
|
||||||
|
<DetailRow label="Gefahrenstelle" gt={engine.zone || '-'} />
|
||||||
|
{engine.lifecycle_phase && (
|
||||||
|
<DetailRow label="Lebensphasen" gt={formatLifecycles(engine.lifecycle_phase)} />
|
||||||
|
)}
|
||||||
|
<DetailRow label="Moeglicher Schaden" gt={engine.possible_harm || '-'} />
|
||||||
|
<DetailRow label="Trigger" gt={engine.trigger_event || '-'} />
|
||||||
|
{engine.affected_person && (
|
||||||
|
<DetailRow label="Betroffene Personen" gt={engine.affected_person} />
|
||||||
|
)}
|
||||||
|
{engine.mitigations && engine.mitigations.length > 0 ? (
|
||||||
|
<DetailRow label="Massnahmen" gt={engine.mitigations.join('\n')} multiline />
|
||||||
|
) : (
|
||||||
|
<DetailRow label="Massnahmen" gt="(keine zugeordnet)" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, gt, multiline }: { label: string; gt: string; multiline?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-medium text-gray-500 uppercase">{label}</div>
|
||||||
|
{multiline ? (
|
||||||
|
<pre className="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap font-sans mt-0.5">{gt}</pre>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-gray-700 dark:text-gray-300 mt-0.5">{gt}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MissingTable({ entries }: { entries: GroundTruthEntry[] }) {
|
||||||
|
if (entries.length === 0) return <EmptyState text="Keine fehlenden Gefaehrdungen" />
|
||||||
|
return (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-red-50 dark:bg-red-900/20">
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-red-600">Nr.</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-red-600">Gefaehrdung</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-red-600">Ursache</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-red-600">Zone</th>
|
||||||
|
<th className="px-3 py-2 text-center font-medium text-red-600">R</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-red-600">Typ</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{entries.map((e, i) => (
|
||||||
|
<tr key={i} className="hover:bg-red-50/50">
|
||||||
|
<td className="px-3 py-2 text-gray-400">{e.nr}</td>
|
||||||
|
<td className="px-3 py-2 font-medium text-gray-800 dark:text-gray-200">{e.hazard_type}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-600 truncate max-w-[200px]">{e.hazard_cause}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-500">{e.component_zone}</td>
|
||||||
|
<td className="px-3 py-2 text-center"><RiskBadge risk={e.risk_in.r} /></td>
|
||||||
|
<td className="px-3 py-2 text-gray-500">{e.measure_type}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExtraTable({ entries }: { entries: HazardSummary[] }) {
|
||||||
|
if (entries.length === 0) return <EmptyState text="Keine zusaetzlichen Engine-Gefaehrdungen" />
|
||||||
|
return (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50 dark:bg-gray-700/50">
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-500">Name</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-500">Kategorie</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-500">Zone</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{entries.map((e, i) => (
|
||||||
|
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||||
|
<td className="px-3 py-2 text-gray-800 dark:text-gray-200">{e.name}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-500">{e.category}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400">{e.zone || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RiskBadge({ risk }: { risk: number }) {
|
||||||
|
const color = risk >= 30 ? 'bg-red-100 text-red-700' : risk >= 15 ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'
|
||||||
|
return <span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold ${color}`}>{risk}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScoreBadge({ score }: { score: number }) {
|
||||||
|
const pct = Math.round(score * 100)
|
||||||
|
const color = pct >= 70 ? 'text-green-600' : pct >= 50 ? 'text-yellow-600' : 'text-red-600'
|
||||||
|
return <span className={`font-bold ${color}`}>{pct}%</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function QualityBadge({ quality }: { quality: 'green' | 'yellow' | 'red' }) {
|
||||||
|
const styles = {
|
||||||
|
green: 'bg-green-100 text-green-700 border-green-200',
|
||||||
|
yellow: 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||||
|
red: 'bg-red-100 text-red-700 border-red-200',
|
||||||
|
}
|
||||||
|
const labels = { green: 'Exakt', yellow: 'Aehnlich', red: 'Schwach' }
|
||||||
|
return (
|
||||||
|
<span className={`inline-block px-1.5 py-0.5 rounded border text-[10px] font-medium ${styles[quality]}`}>
|
||||||
|
{labels[quality]}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ text }: { text: string }) {
|
||||||
|
return <div className="px-4 py-8 text-center text-sm text-gray-400">{text}</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
export interface GTRisk { f: number; w: number; p: number; s: number; r: number }
|
||||||
|
export interface GTPLr { s: string; f: string; p: string; ew?: string; plr: string }
|
||||||
|
|
||||||
|
export interface GroundTruthEntry {
|
||||||
|
nr: string
|
||||||
|
hazard_group: string
|
||||||
|
hazard_group_applicable: boolean
|
||||||
|
hazard_subgroup: string
|
||||||
|
hazard_type: string
|
||||||
|
hazard_cause: string
|
||||||
|
lifecycle_phases: string[]
|
||||||
|
component_zone: string
|
||||||
|
risk_in: GTRisk
|
||||||
|
plr?: GTPLr | null
|
||||||
|
measures: string[]
|
||||||
|
measure_type: string
|
||||||
|
risk_out: GTRisk
|
||||||
|
norm_references: string[]
|
||||||
|
sufficient: boolean
|
||||||
|
comment?: string
|
||||||
|
reduction_steps?: {
|
||||||
|
risk_in: GTRisk; measures: string[]; measure_type: string
|
||||||
|
risk_out: GTRisk; norm_references: string[]; sufficient: boolean
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HazardSummary {
|
||||||
|
id: string; name: string; category: string
|
||||||
|
component?: string; zone?: string; risk_level?: string
|
||||||
|
description?: string; scenario?: string
|
||||||
|
possible_harm?: string; trigger_event?: string
|
||||||
|
affected_person?: string; lifecycle_phase?: string
|
||||||
|
mitigations?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HazardMatchPair {
|
||||||
|
gt_entry: GroundTruthEntry
|
||||||
|
engine_hazard: HazardSummary
|
||||||
|
match_score: number
|
||||||
|
match_reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryScore {
|
||||||
|
category: string; gt_count: number; match_count: number; coverage: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BenchmarkResult {
|
||||||
|
coverage_score: number
|
||||||
|
measure_coverage: number
|
||||||
|
total_gt: number
|
||||||
|
total_engine: number
|
||||||
|
matched_pairs: HazardMatchPair[]
|
||||||
|
missing_from_engine: GroundTruthEntry[]
|
||||||
|
extra_in_engine: HazardSummary[]
|
||||||
|
category_breakdown: CategoryScore[]
|
||||||
|
risk_rank_pairs: { gt_rank: number; engine_rank: number; hazard_name: string; gt_risk_score: number }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseBenchmarkReturn {
|
||||||
|
result: BenchmarkResult | null
|
||||||
|
gtLoaded: boolean
|
||||||
|
gtEntryCount: number
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
importGT: (gt: { entries: GroundTruthEntry[]; source_file?: string; description?: string }) => Promise<void>
|
||||||
|
runBenchmark: (gtProjectId?: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBenchmark(projectId: string): UseBenchmarkReturn {
|
||||||
|
const [result, setResult] = useState<BenchmarkResult | null>(null)
|
||||||
|
const [gtLoaded, setGtLoaded] = useState(false)
|
||||||
|
const [gtEntryCount, setGtEntryCount] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const importGT = useCallback(async (gt: { entries: GroundTruthEntry[]; source_file?: string; description?: string }) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/benchmark/import-gt`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(gt),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(await res.text())
|
||||||
|
const data = await res.json()
|
||||||
|
setGtLoaded(true)
|
||||||
|
setGtEntryCount(data.entry_count || gt.entries.length)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Import failed')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
const runBenchmark = useCallback(async (gtProjectId?: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const params = gtProjectId ? `?gt_project_id=${gtProjectId}` : ''
|
||||||
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/benchmark${params}`)
|
||||||
|
if (!res.ok) throw new Error(await res.text())
|
||||||
|
const data: BenchmarkResult = await res.json()
|
||||||
|
setResult(data)
|
||||||
|
setGtLoaded(true)
|
||||||
|
setGtEntryCount(data.total_gt)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Benchmark failed')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
return { result, gtLoaded, gtEntryCount, loading, error, importGT, runBenchmark }
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { useBenchmark } from './_hooks/useBenchmark'
|
||||||
|
import { GTImportForm } from './_components/GTImportForm'
|
||||||
|
import { HazardComparisonTable } from './_components/HazardComparisonTable'
|
||||||
|
import { CategoryBreakdown } from './_components/CategoryBreakdown'
|
||||||
|
|
||||||
|
export default function BenchmarkPage() {
|
||||||
|
const { projectId } = useParams<{ projectId: string }>()
|
||||||
|
const { result, gtLoaded, gtEntryCount, loading, error, importGT, runBenchmark } = useBenchmark(projectId)
|
||||||
|
const [gtProjectId, setGtProjectId] = useState('')
|
||||||
|
|
||||||
|
// Only count matches >= 50% as real coverage
|
||||||
|
const realMatchCount = result ? (result.matched_pairs?.filter(m => m.match_score >= 0.5).length || 0) : 0
|
||||||
|
const coveragePct = result ? Math.round(realMatchCount * 100 / Math.max(result.total_gt, 1)) : 0
|
||||||
|
const measurePct = result ? Math.round(result.measure_coverage * 100) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-[1200px]">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold text-gray-900 dark:text-white">Ground Truth Benchmark</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Vergleich der Engine-Ergebnisse mit einer professionellen Risikobeurteilung
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="px-4 py-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-600">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* GT Import or Cross-Project Reference */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<GTImportForm onImport={importGT} loading={loading} />
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Benchmark ausfuehren</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
|
GT aus diesem Projekt verwenden, oder eine Projekt-ID mit importierter GT angeben.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={gtProjectId}
|
||||||
|
onChange={(e) => setGtProjectId(e.target.value)}
|
||||||
|
placeholder="GT-Projekt-ID (optional — leer = dieses Projekt)"
|
||||||
|
className="w-full text-xs border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-gray-50 dark:bg-gray-900"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => runBenchmark(gtProjectId || undefined)}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full px-4 py-2 text-sm font-medium bg-purple-600 hover:bg-purple-700 disabled:bg-gray-300 text-white rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Vergleiche...' : 'Benchmark starten'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gtLoaded && !result && (
|
||||||
|
<div className="mt-3 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 rounded text-xs text-blue-600">
|
||||||
|
{gtEntryCount} GT-Eintraege geladen. Klicke "Benchmark starten".
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{result && (
|
||||||
|
<>
|
||||||
|
{/* Score Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<ScoreCard
|
||||||
|
label="Hazard Coverage"
|
||||||
|
value={`${coveragePct}%`}
|
||||||
|
sub={`${realMatchCount} / ${result.total_gt} erkannt (>= 50% Match)`}
|
||||||
|
color={coveragePct >= 80 ? 'green' : coveragePct >= 50 ? 'yellow' : 'red'}
|
||||||
|
/>
|
||||||
|
<ScoreCard
|
||||||
|
label="Massnahmen-Coverage"
|
||||||
|
value={`${measurePct}%`}
|
||||||
|
sub="der zugeordneten Gefaehrdungen"
|
||||||
|
color={measurePct >= 80 ? 'green' : measurePct >= 50 ? 'yellow' : 'red'}
|
||||||
|
/>
|
||||||
|
<ScoreCard
|
||||||
|
label="GT Eintraege"
|
||||||
|
value={String(result.total_gt)}
|
||||||
|
sub="professionelle Beurteilung"
|
||||||
|
color="gray"
|
||||||
|
/>
|
||||||
|
<ScoreCard
|
||||||
|
label="Engine Eintraege"
|
||||||
|
value={String(result.total_engine)}
|
||||||
|
sub={`${result.extra_in_engine?.length || 0} zusaetzlich`}
|
||||||
|
color="gray"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Breakdown */}
|
||||||
|
<CategoryBreakdown breakdown={result.category_breakdown || []} />
|
||||||
|
|
||||||
|
{/* Hazard Comparison Table */}
|
||||||
|
<HazardComparisonTable
|
||||||
|
matched={result.matched_pairs || []}
|
||||||
|
missing={result.missing_from_engine || []}
|
||||||
|
extra={result.extra_in_engine || []}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Business Impact */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Business Impact</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">2,5 Tage</div>
|
||||||
|
<div className="text-xs text-gray-500">Manueller Aufwand</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-purple-600">
|
||||||
|
{(coveragePct / 100 * 2.5).toFixed(1)} Tage
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">Eingespart bei {coveragePct}% Coverage</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-green-600">
|
||||||
|
{Math.round(coveragePct / 100 * 2.5 * 8 * 100)} EUR
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">Einsparung (100 EUR/h)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScoreCard({ label, value, sub, color }: {
|
||||||
|
label: string; value: string; sub: string
|
||||||
|
color: 'green' | 'yellow' | 'red' | 'gray'
|
||||||
|
}) {
|
||||||
|
const colors = {
|
||||||
|
green: 'border-green-200 dark:border-green-800',
|
||||||
|
yellow: 'border-yellow-200 dark:border-yellow-800',
|
||||||
|
red: 'border-red-200 dark:border-red-800',
|
||||||
|
gray: 'border-gray-200 dark:border-gray-700',
|
||||||
|
}
|
||||||
|
const textColors = {
|
||||||
|
green: 'text-green-600', yellow: 'text-yellow-600',
|
||||||
|
red: 'text-red-600', gray: 'text-gray-900 dark:text-white',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-white dark:bg-gray-800 rounded-lg border-2 ${colors[color]} p-4 text-center`}>
|
||||||
|
<div className={`text-2xl font-bold ${textColors[color]}`}>{value}</div>
|
||||||
|
<div className="text-xs font-medium text-gray-700 dark:text-gray-300 mt-1">{label}</div>
|
||||||
|
<div className="text-[10px] text-gray-400 mt-0.5">{sub}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ export function ComponentForm({
|
|||||||
version: initialData?.version || '',
|
version: initialData?.version || '',
|
||||||
description: initialData?.description || '',
|
description: initialData?.description || '',
|
||||||
safety_relevant: initialData?.safety_relevant || false,
|
safety_relevant: initialData?.safety_relevant || false,
|
||||||
|
ce_marked: initialData?.ce_marked || false,
|
||||||
parent_id: parentId || initialData?.parent_id || null,
|
parent_id: parentId || initialData?.parent_id || null,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -73,6 +74,19 @@ export function ComponentForm({
|
|||||||
</label>
|
</label>
|
||||||
<span className="text-sm text-gray-700 dark:text-gray-300">Sicherheitsrelevant</span>
|
<span className="text-sm text-gray-700 dark:text-gray-300">Sicherheitsrelevant</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3 pt-6">
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.ce_marked}
|
||||||
|
onChange={(e) => setFormData({ ...formData, ce_marked: e.target.checked })}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-green-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-green-500" />
|
||||||
|
</label>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Bereits CE-gekennzeichnet</span>
|
||||||
|
<span className="text-[10px] text-gray-400">(Nur Schnittstellen bewerten)</span>
|
||||||
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ export interface Component {
|
|||||||
version: string
|
version: string
|
||||||
description: string
|
description: string
|
||||||
safety_relevant: boolean
|
safety_relevant: boolean
|
||||||
|
ce_marked?: boolean
|
||||||
parent_id: string | null
|
parent_id: string | null
|
||||||
children: Component[]
|
children: Component[]
|
||||||
library_component_id?: string
|
library_component_id?: string
|
||||||
energy_source_ids?: string[]
|
energy_source_ids?: string[]
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LibraryComponent {
|
export interface LibraryComponent {
|
||||||
@@ -41,6 +43,7 @@ export interface ComponentFormData {
|
|||||||
version: string
|
version: string
|
||||||
description: string
|
description: string
|
||||||
safety_relevant: boolean
|
safety_relevant: boolean
|
||||||
|
ce_marked: boolean
|
||||||
parent_id: string | null
|
parent_id: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
export interface FailureMode {
|
||||||
|
id: string
|
||||||
|
component_type: string
|
||||||
|
mode: string
|
||||||
|
name_de: string
|
||||||
|
name_en: string
|
||||||
|
effect: string
|
||||||
|
detection_hint: string
|
||||||
|
default_severity: number
|
||||||
|
default_occurrence: number
|
||||||
|
default_detection: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Component {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
component_type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FMEARow {
|
||||||
|
component: Component
|
||||||
|
failureMode: FailureMode
|
||||||
|
severity: number
|
||||||
|
occurrence: number
|
||||||
|
detection: number
|
||||||
|
rpz: number
|
||||||
|
ap: 'H' | 'M' | 'L'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AIAG-VDA Action Priority (2019 Handbook) */
|
||||||
|
export function calculateAP(s: number, o: number, d: number): 'H' | 'M' | 'L' {
|
||||||
|
if (s >= 9) return (o >= 4 || d >= 7) ? 'H' : (o >= 2 || d >= 5) ? 'M' : 'L'
|
||||||
|
if (s >= 7) return (o >= 5 || d >= 8) ? 'H' : (o >= 3 || d >= 5) ? 'M' : 'L'
|
||||||
|
if (s >= 5) return (o >= 7 || d >= 9) ? 'H' : (o >= 4 || d >= 7) ? 'M' : 'L'
|
||||||
|
return (o >= 8 && d >= 9) ? 'H' : (o >= 6 || d >= 8) ? 'M' : 'L'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFMEA(projectId: string) {
|
||||||
|
const [rows, setRows] = useState<FMEARow[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [projectId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
// Load project components
|
||||||
|
const compRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`)
|
||||||
|
if (!compRes.ok) return
|
||||||
|
const compJson = await compRes.json()
|
||||||
|
const components: Component[] = (compJson.components || compJson || []).map(
|
||||||
|
(c: Record<string, unknown>) => ({
|
||||||
|
id: c.id as string,
|
||||||
|
name: c.name as string,
|
||||||
|
component_type: c.component_type as string || 'mechanical',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Load ALL failure modes, then match by component type + name keywords
|
||||||
|
const allRes = await fetch('/api/sdk/v1/iace/failure-modes')
|
||||||
|
let allFMs: FailureMode[] = []
|
||||||
|
if (allRes.ok) {
|
||||||
|
const json = await allRes.json()
|
||||||
|
allFMs = json.failure_modes || []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive the best FM component_type from component name keywords
|
||||||
|
const nameToFMTypes: Record<string, string[]> = {
|
||||||
|
sensor: ['sensor'], scanner: ['sensor'], kamera: ['sensor'],
|
||||||
|
motor: ['actuator', 'electrical'], antrieb: ['actuator'],
|
||||||
|
steuerung: ['controller'], sps: ['controller'], plc: ['controller'],
|
||||||
|
software: ['software'], firmware: ['software'],
|
||||||
|
ventil: ['actuator', 'mechanical'], greifer: ['actuator', 'mechanical'],
|
||||||
|
roboter: ['actuator', 'mechanical'], hydraulik: ['actuator'],
|
||||||
|
netzwerk: ['network'], ethernet: ['network'],
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFMTypesForComp(comp: Component): string[] {
|
||||||
|
const types = [comp.component_type]
|
||||||
|
const nameLower = comp.name.toLowerCase()
|
||||||
|
for (const [kw, fmTypes] of Object.entries(nameToFMTypes)) {
|
||||||
|
if (nameLower.includes(kw)) types.push(...fmTypes)
|
||||||
|
}
|
||||||
|
return [...new Set(types)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build FMEA rows: each component × its matching failure modes
|
||||||
|
const fmeaRows: FMEARow[] = []
|
||||||
|
for (const comp of components) {
|
||||||
|
const compTypes = getFMTypesForComp(comp)
|
||||||
|
const compFMs = allFMs.filter((fm) => compTypes.includes(fm.component_type))
|
||||||
|
// Use matched FMs, or fallback to mechanical FMs
|
||||||
|
const relevantFMs = compFMs.length > 0 ? compFMs : allFMs.filter((fm) => fm.component_type === 'mechanical').slice(0, 3)
|
||||||
|
|
||||||
|
for (const fm of relevantFMs) {
|
||||||
|
const s = fm.default_severity || 5
|
||||||
|
const o = fm.default_occurrence || 5
|
||||||
|
const d = fm.default_detection || 5
|
||||||
|
fmeaRows.push({
|
||||||
|
component: comp,
|
||||||
|
failureMode: fm,
|
||||||
|
severity: s,
|
||||||
|
occurrence: o,
|
||||||
|
detection: d,
|
||||||
|
rpz: s * o * d,
|
||||||
|
ap: calculateAP(s, o, d),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by RPZ descending (highest risk first)
|
||||||
|
fmeaRows.sort((a, b) => b.rpz - a.rpz)
|
||||||
|
setRows(fmeaRows)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load FMEA data:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: rows.length,
|
||||||
|
critical: rows.filter((r) => r.rpz > 200).length,
|
||||||
|
actionRequired: rows.filter((r) => r.rpz > 100 && r.rpz <= 200).length,
|
||||||
|
acceptable: rows.filter((r) => r.rpz <= 100).length,
|
||||||
|
}
|
||||||
|
|
||||||
|
const [suggesting, setSuggesting] = useState(false)
|
||||||
|
const [suggestions, setSuggestions] = useState<FailureMode[]>([])
|
||||||
|
const [suggestSource, setSuggestSource] = useState<string>('')
|
||||||
|
|
||||||
|
async function suggestFMs(componentId: string) {
|
||||||
|
setSuggesting(true)
|
||||||
|
setSuggestions([])
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components/${componentId}/suggest-fms`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json()
|
||||||
|
setSuggestions(json.suggestions || [])
|
||||||
|
setSuggestSource(json.source || 'unknown')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('FM suggest failed:', err)
|
||||||
|
} finally {
|
||||||
|
setSuggesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unique components for the suggest button
|
||||||
|
const components = [...new Map(rows.map((r) => [r.component.id, r.component])).values()]
|
||||||
|
|
||||||
|
return { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions }
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { useFMEA, type FMEARow } from './_hooks/useFMEA'
|
||||||
|
|
||||||
|
const COMP_TYPE_LABELS: Record<string, string> = {
|
||||||
|
mechanical: 'Mechanisch', electrical: 'Elektrisch', sensor: 'Sensor',
|
||||||
|
actuator: 'Aktor', software: 'Software', firmware: 'Firmware',
|
||||||
|
ai_model: 'KI-Modell', hmi: 'HMI', network: 'Netzwerk',
|
||||||
|
hydraulic: 'Hydraulik', pneumatic: 'Pneumatik', safety: 'Sicherheit',
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpzColor(rpz: number): string {
|
||||||
|
if (rpz > 200) return 'bg-red-100 text-red-800 border-red-200'
|
||||||
|
if (rpz > 100) return 'bg-orange-100 text-orange-800 border-orange-200'
|
||||||
|
if (rpz > 50) return 'bg-yellow-100 text-yellow-800 border-yellow-200'
|
||||||
|
return 'bg-green-100 text-green-800 border-green-200'
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpzLabel(rpz: number): string {
|
||||||
|
if (rpz > 200) return 'Kritisch'
|
||||||
|
if (rpz > 100) return 'Handlungsbedarf'
|
||||||
|
if (rpz > 50) return 'Beobachten'
|
||||||
|
return 'Akzeptabel'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FMEAPage() {
|
||||||
|
const { projectId } = useParams<{ projectId: string }>()
|
||||||
|
const { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions } = useFMEA(projectId)
|
||||||
|
const [suggestComp, setSuggestComp] = useState<string | null>(null)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">FMEA-Worksheet</h1>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
Fehlermoeglich­keits- und Einflussanalyse — RPZ = Severity x Occurrence x Detection
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<FMEAInfoBox />
|
||||||
|
|
||||||
|
{/* KI-Vorschlag + Export */}
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={suggestComp || ''}
|
||||||
|
onChange={(e) => setSuggestComp(e.target.value || null)}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="">Komponente waehlen...</option>
|
||||||
|
{components.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => suggestComp && suggestFMs(suggestComp)}
|
||||||
|
disabled={!suggestComp || suggesting}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{suggesting ? (
|
||||||
|
<span className="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full" />
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
KI-Vorschlag
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<a
|
||||||
|
href={`/api/sdk/v1/iace/projects/${projectId}/fmea/export`}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm font-medium transition-colors"
|
||||||
|
download
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
VDA Excel exportieren
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suggest Results */}
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-xl p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-purple-800 dark:text-purple-300">
|
||||||
|
KI-Vorschlaege ({suggestions.length}) — {suggestSource === 'llm' ? 'LLM-generiert' : 'Bibliothek'}
|
||||||
|
</h3>
|
||||||
|
<button onClick={() => setSuggestions([])} className="text-xs text-purple-600 hover:text-purple-800">Schliessen</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{suggestions.map((fm, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg p-3 border border-purple-100 dark:border-purple-800">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">{fm.name_de}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">{fm.effect}</div>
|
||||||
|
<div className="flex gap-3 mt-1 text-xs text-gray-400">
|
||||||
|
<span>S={fm.default_severity}</span>
|
||||||
|
<span>O={fm.default_occurrence}</span>
|
||||||
|
<span>D={fm.default_detection}</span>
|
||||||
|
<span className="font-bold">RPZ={fm.default_severity * fm.default_occurrence * fm.default_detection}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<StatCard label="Gesamt" value={stats.total} color="gray" />
|
||||||
|
<StatCard label="Kritisch (RPZ > 200)" value={stats.critical} color="red" />
|
||||||
|
<StatCard label="Handlungsbedarf (RPZ > 100)" value={stats.actionRequired} color="orange" />
|
||||||
|
<StatCard label="Akzeptabel (RPZ ≤ 100)" value={stats.acceptable} color="green" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RPZ Threshold Info */}
|
||||||
|
<div className="p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-xs text-amber-800 dark:text-amber-300">
|
||||||
|
<strong>RPZ-Schwellen:</strong> Kritisch > 200 | Handlungsbedarf > 100 | Beobachten > 50 | Akzeptabel ≤ 50.
|
||||||
|
Massnahmen sind erforderlich ab RPZ > 100.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FMEA Table */}
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
Keine Failure Modes gefunden. Bitte zuerst Komponenten erfassen.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Komponente</th>
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Typ</th>
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Fehlerart</th>
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Auswirkung</th>
|
||||||
|
<th className="px-3 py-2.5 text-center text-xs font-medium text-gray-500 uppercase w-12">S</th>
|
||||||
|
<th className="px-3 py-2.5 text-center text-xs font-medium text-gray-500 uppercase w-12">O</th>
|
||||||
|
<th className="px-3 py-2.5 text-center text-xs font-medium text-gray-500 uppercase w-12">D</th>
|
||||||
|
<th className="px-3 py-2.5 text-center text-xs font-medium text-gray-500 uppercase w-16">RPZ</th>
|
||||||
|
<th className="px-3 py-2.5 text-center text-xs font-medium text-gray-500 uppercase w-12">AP</th>
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Bewertung</th>
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Erkennung</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{rows.map((row, idx) => (
|
||||||
|
<FMEATableRow key={`${row.component.id}-${row.failureMode.id}-${idx}`} row={row} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FMEATableRow({ row }: { row: FMEARow }) {
|
||||||
|
const color = rpzColor(row.rpz)
|
||||||
|
return (
|
||||||
|
<tr className={`hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors ${row.rpz > 100 ? 'bg-red-50/30 dark:bg-red-900/10' : ''}`}>
|
||||||
|
<td className="px-3 py-2.5 text-sm font-medium text-gray-900 dark:text-white">{row.component.name}</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
||||||
|
{COMP_TYPE_LABELS[row.component.component_type] || row.component.component_type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<div className="text-sm text-gray-900 dark:text-white">{row.failureMode.name_de}</div>
|
||||||
|
<div className="text-[10px] text-gray-400">{row.failureMode.id}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-xs text-gray-600 dark:text-gray-400 max-w-[200px] truncate" title={row.failureMode.effect}>
|
||||||
|
{row.failureMode.effect}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-sm text-center font-medium text-gray-900 dark:text-white">{row.severity}</td>
|
||||||
|
<td className="px-3 py-2.5 text-sm text-center font-medium text-gray-900 dark:text-white">{row.occurrence}</td>
|
||||||
|
<td className="px-3 py-2.5 text-sm text-center font-medium text-gray-900 dark:text-white">{row.detection}</td>
|
||||||
|
<td className="px-3 py-2.5 text-center">
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-sm font-bold border ${color}`}>
|
||||||
|
{row.rpz}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-center">
|
||||||
|
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-bold ${
|
||||||
|
row.ap === 'H' ? 'bg-red-600 text-white' :
|
||||||
|
row.ap === 'M' ? 'bg-yellow-500 text-white' :
|
||||||
|
'bg-green-500 text-white'
|
||||||
|
}`}>
|
||||||
|
{row.ap}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${color}`}>{rpzLabel(row.rpz)}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-xs text-gray-500 dark:text-gray-400 max-w-[150px] truncate" title={row.failureMode.detection_hint}>
|
||||||
|
{row.failureMode.detection_hint || '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FMEAInfoBox() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
return (
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl overflow-hidden">
|
||||||
|
<button onClick={() => setOpen(!open)} className="w-full flex items-center justify-between px-4 py-3 text-left">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium text-blue-800 dark:text-blue-300">Was ist FMEA? — Anleitung & Beispiel</span>
|
||||||
|
</div>
|
||||||
|
<svg className={`w-4 h-4 text-blue-600 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="px-4 pb-4 text-xs text-blue-800 dark:text-blue-300 space-y-3">
|
||||||
|
<p><strong>FMEA</strong> (Fehlermoeglich- und Einflussanalyse) ist eine systematische Methode zur vorbeugenden Qualitaetssicherung nach AIAG-VDA (2019).</p>
|
||||||
|
<div>
|
||||||
|
<strong>Bewertungsskalen (je 1-10):</strong>
|
||||||
|
<ul className="mt-1 ml-4 space-y-0.5 list-disc">
|
||||||
|
<li><strong>S (Severity)</strong> — Schwere der Auswirkung: 1 = kaum merkbar, 10 = katastrophal (Lebensgefahr)</li>
|
||||||
|
<li><strong>O (Occurrence)</strong> — Auftretenswahrscheinlichkeit: 1 = praktisch ausgeschlossen, 10 = sehr haeufig</li>
|
||||||
|
<li><strong>D (Detection)</strong> — Entdeckbarkeit: 1 = sofort erkennbar, 10 = nicht erkennbar</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Kennzahlen:</strong>
|
||||||
|
<ul className="mt-1 ml-4 space-y-0.5 list-disc">
|
||||||
|
<li><strong>RPZ</strong> = S x O x D (1-1000). Ab RPZ > 100: Massnahme erforderlich.</li>
|
||||||
|
<li><strong>AP (Action Priority)</strong> — AIAG-VDA Standard: <span className="inline-block px-1.5 py-0.5 bg-red-600 text-white rounded text-[10px] font-bold">H</span> = sofort handeln, <span className="inline-block px-1.5 py-0.5 bg-yellow-500 text-white rounded text-[10px] font-bold">M</span> = planen, <span className="inline-block px-1.5 py-0.5 bg-green-500 text-white rounded text-[10px] font-bold">L</span> = beobachten</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Beispiel:</strong> SPS-Steuerung → Kommunikationsausfall (S=8, O=3, D=5) → RPZ=120, AP=M → Massnahme: Redundante Kommunikation implementieren.
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Workflow:</strong> 1. Komponente waehlen → 2. Fehlerart identifizieren → 3. S/O/D bewerten → 4. AP pruefen → 5. Bei H/M: Massnahme definieren → 6. Nach Massnahme: neu bewerten
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
gray: 'bg-gray-50 text-gray-700 border-gray-200',
|
||||||
|
red: 'bg-red-50 text-red-700 border-red-200',
|
||||||
|
orange: 'bg-orange-50 text-orange-700 border-orange-200',
|
||||||
|
green: 'bg-green-50 text-green-700 border-green-200',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border p-4 ${colors[color] || colors.gray}`}>
|
||||||
|
<div className="text-2xl font-bold">{value}</div>
|
||||||
|
<div className="text-xs mt-1">{label}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+286
@@ -0,0 +1,286 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react'
|
||||||
|
import { Hazard } from './types'
|
||||||
|
import { RiskAssessmentTable } from './RiskAssessmentTable'
|
||||||
|
|
||||||
|
interface BlockData {
|
||||||
|
parent_hazard: { hazard: { id: string } }
|
||||||
|
children: { hazard: { id: string } }[]
|
||||||
|
children_covered_by_parent: boolean
|
||||||
|
block_key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BlockInfo {
|
||||||
|
isParent: boolean
|
||||||
|
isChild: boolean
|
||||||
|
isCovered: boolean
|
||||||
|
blockKey: string
|
||||||
|
parentId: string
|
||||||
|
childCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string
|
||||||
|
hazards: Hazard[]
|
||||||
|
onReassess?: () => void
|
||||||
|
decisions?: Record<string, boolean | null>
|
||||||
|
onDecision?: (hazardId: string, acceptable: boolean | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps RiskAssessmentTable with block-awareness:
|
||||||
|
* - Injects block metadata into hazards so the table can show grouping
|
||||||
|
* - Provides controls to ungroup/promote children
|
||||||
|
*/
|
||||||
|
export function BlockAwareRiskTable({ projectId, hazards, onReassess, decisions, onDecision }: Props) {
|
||||||
|
const [blocks, setBlocks] = useState<BlockData[]>([])
|
||||||
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||||
|
const [ungrouped, setUngrouped] = useState<Record<string, boolean>>({})
|
||||||
|
const [pendingAction, setPendingAction] = useState<{ childId: string; childName: string } | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazard-blocks`)
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(d => { if (d?.blocks) setBlocks(d.blocks) })
|
||||||
|
.catch(() => {})
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
// Build lookup: hazardId → block info
|
||||||
|
const blockMap = useMemo(() => {
|
||||||
|
const map: Record<string, BlockInfo> = {}
|
||||||
|
for (const b of blocks) {
|
||||||
|
if (b.children.length === 0) continue
|
||||||
|
const pid = b.parent_hazard.hazard.id
|
||||||
|
map[pid] = {
|
||||||
|
isParent: true, isChild: false, isCovered: false,
|
||||||
|
blockKey: b.block_key, parentId: pid, childCount: b.children.length,
|
||||||
|
}
|
||||||
|
for (const c of b.children) {
|
||||||
|
if (ungrouped[c.hazard.id]) continue
|
||||||
|
map[c.hazard.id] = {
|
||||||
|
isParent: false, isChild: true,
|
||||||
|
isCovered: b.children_covered_by_parent,
|
||||||
|
blockKey: b.block_key, parentId: pid, childCount: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [blocks, ungrouped])
|
||||||
|
|
||||||
|
// Sort hazards: parents first, then their children, then standalone
|
||||||
|
const sortedHazards = useMemo(() => {
|
||||||
|
const parents: Hazard[] = []
|
||||||
|
const childrenByParent: Record<string, Hazard[]> = {}
|
||||||
|
const standalone: Hazard[] = []
|
||||||
|
|
||||||
|
for (const h of hazards) {
|
||||||
|
const info = blockMap[h.id]
|
||||||
|
if (!info) {
|
||||||
|
standalone.push(h)
|
||||||
|
} else if (info.isParent) {
|
||||||
|
parents.push(h)
|
||||||
|
childrenByParent[h.id] = []
|
||||||
|
} else if (info.isChild) {
|
||||||
|
const arr = childrenByParent[info.parentId]
|
||||||
|
if (arr) arr.push(h)
|
||||||
|
else standalone.push(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort parents by risk desc
|
||||||
|
parents.sort((a, b) => (b.r_inherent || 0) - (a.r_inherent || 0))
|
||||||
|
standalone.sort((a, b) => (b.r_inherent || 0) - (a.r_inherent || 0))
|
||||||
|
|
||||||
|
// Interleave: parent → children → parent → children → ... → standalone
|
||||||
|
const result: Hazard[] = []
|
||||||
|
for (const p of parents) {
|
||||||
|
result.push(p)
|
||||||
|
const isCollapsed = collapsed[p.id]
|
||||||
|
if (!isCollapsed && childrenByParent[p.id]) {
|
||||||
|
result.push(...childrenByParent[p.id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push(...standalone)
|
||||||
|
return result
|
||||||
|
}, [hazards, blockMap, collapsed])
|
||||||
|
|
||||||
|
const toggleCollapse = (parentId: string) => {
|
||||||
|
setCollapsed(prev => ({ ...prev, [parentId]: !prev[parentId] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUngroup = (childId: string) => {
|
||||||
|
setUngrouped(prev => ({ ...prev, [childId]: true }))
|
||||||
|
setPendingAction(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRegroup = (childId: string) => {
|
||||||
|
setUngrouped(prev => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[childId]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count blocks with children
|
||||||
|
const blockCount = blocks.filter(b => b.children.length > 0).length
|
||||||
|
const coveredCount = Object.values(blockMap).filter(b => b.isChild && b.isCovered).length
|
||||||
|
const ungroupedCount = Object.keys(ungrouped).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Confirmation dialog */}
|
||||||
|
{pendingAction && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 p-5 max-w-md w-full mx-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">Gefaehrdung aus Block entfernen?</h3>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
<strong>{pendingAction.childName}</strong>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
|
Der Punkt wird als eigenstaendige Gefaehrdung gefuehrt und muss separat bewertet werden.
|
||||||
|
Sie koennen ihn jederzeit ueber "Zurueck in Block" wieder zuordnen.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => handleUngroup(pendingAction.childId)}
|
||||||
|
className="flex-1 px-3 py-2 text-xs font-medium bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||||
|
Als eigenen Punkt fuehren
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setPendingAction(null)}
|
||||||
|
className="flex-1 px-3 py-2 text-xs font-medium bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 transition-colors">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Block info bar */}
|
||||||
|
{blockCount > 0 && (
|
||||||
|
<div className="flex items-center gap-4 px-4 py-2 bg-purple-50 dark:bg-purple-900/20 rounded-lg text-xs">
|
||||||
|
<span className="font-medium text-purple-700 dark:text-purple-300">
|
||||||
|
{blockCount} Bloecke erkannt
|
||||||
|
</span>
|
||||||
|
{coveredCount > 0 && (
|
||||||
|
<span className="text-green-600">
|
||||||
|
{coveredCount} Kinder durch Mutter abgedeckt
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{ungroupedCount > 0 && (
|
||||||
|
<button onClick={() => setUngrouped({})}
|
||||||
|
className="text-orange-600 hover:text-orange-700 underline">
|
||||||
|
{ungroupedCount} entgruppiert — alle zuruecksetzen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Enhanced table with block decorations */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs whitespace-nowrap">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-100 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th className="w-8 px-1 py-1.5"></th>
|
||||||
|
<th colSpan={2} className="px-3 py-1.5 text-left font-semibold text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-600">Gefaehrdung</th>
|
||||||
|
<th colSpan={4} className="px-3 py-1.5 text-center font-semibold text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-600">Risiko (S x F x P)</th>
|
||||||
|
<th className="px-3 py-1.5 text-center font-semibold text-gray-700 dark:text-gray-300">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{sortedHazards.map(h => {
|
||||||
|
const info = blockMap[h.id]
|
||||||
|
const isParent = info?.isParent
|
||||||
|
const isChild = info?.isChild
|
||||||
|
const isCovered = info?.isCovered
|
||||||
|
const childCount = info?.childCount || 0
|
||||||
|
const isCollapsedParent = isParent && collapsed[h.id]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={h.id} className={`transition-colors ${
|
||||||
|
isChild ? 'bg-gray-50/50 dark:bg-gray-850' :
|
||||||
|
isParent ? 'bg-white dark:bg-gray-800' : ''
|
||||||
|
} ${isCovered ? 'opacity-60' : ''} hover:bg-gray-50 dark:hover:bg-gray-750`}>
|
||||||
|
{/* Block indicator */}
|
||||||
|
<td className="px-1 py-2 text-center">
|
||||||
|
{isParent && (
|
||||||
|
<button onClick={() => toggleCollapse(h.id)}
|
||||||
|
className="w-5 h-5 flex items-center justify-center rounded hover:bg-purple-100 text-purple-600 transition-colors"
|
||||||
|
title={`${childCount} Kinder ${isCollapsedParent ? 'anzeigen' : 'verbergen'}`}>
|
||||||
|
<svg className={`w-3 h-3 transition-transform ${isCollapsedParent ? '' : 'rotate-90'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isChild && (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<button onClick={() => setPendingAction({ childId: h.id, childName: h.name })}
|
||||||
|
className="w-5 h-5 flex items-center justify-center rounded hover:bg-orange-100 text-gray-300 hover:text-orange-500 transition-colors"
|
||||||
|
title="Aus Block entfernen (mit Bestaetigung)">
|
||||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Show regroup button for ungrouped items */}
|
||||||
|
{!isParent && !isChild && ungrouped[h.id] && (
|
||||||
|
<button onClick={() => handleRegroup(h.id)}
|
||||||
|
className="w-5 h-5 flex items-center justify-center rounded hover:bg-green-100 text-orange-400 hover:text-green-600 transition-colors"
|
||||||
|
title="Zurueck in Block">
|
||||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{/* Name */}
|
||||||
|
<td className={`px-3 py-2 ${isChild ? 'pl-8' : ''}`}>
|
||||||
|
<div className={`font-medium ${isParent ? 'text-purple-800 dark:text-purple-300' : 'text-gray-900 dark:text-white'}`}>
|
||||||
|
{h.name}
|
||||||
|
{isParent && <span className="ml-1 text-[10px] text-purple-500">({childCount})</span>}
|
||||||
|
</div>
|
||||||
|
{h.hazardous_zone && <div className="text-[10px] text-gray-400 truncate max-w-[200px]">{h.hazardous_zone}</div>}
|
||||||
|
</td>
|
||||||
|
{/* Category */}
|
||||||
|
<td className="px-3 py-2 border-r border-gray-200 dark:border-gray-600 text-gray-500">
|
||||||
|
{h.category?.replace(/_/g, ' ')}
|
||||||
|
</td>
|
||||||
|
{/* Risk */}
|
||||||
|
<td className="px-2 py-2 text-center">{h.severity || '-'}</td>
|
||||||
|
<td className="px-2 py-2 text-center">{h.exposure || '-'}</td>
|
||||||
|
<td className="px-2 py-2 text-center">{h.probability || '-'}</td>
|
||||||
|
<td className="px-2 py-2 text-center font-bold border-r border-gray-200 dark:border-gray-600">
|
||||||
|
{h.r_inherent || '-'}
|
||||||
|
</td>
|
||||||
|
{/* Status */}
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
{isCovered ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-green-100 text-green-700 text-[10px] font-medium">
|
||||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
Abgedeckt
|
||||||
|
</span>
|
||||||
|
) : h.r_inherent ? (
|
||||||
|
<span className={`inline-block px-1.5 py-0.5 rounded-full text-[10px] font-medium ${
|
||||||
|
(h.r_inherent || 0) <= 20 ? 'bg-green-100 text-green-700' :
|
||||||
|
(h.r_inherent || 0) <= 60 ? 'bg-yellow-100 text-yellow-700' :
|
||||||
|
'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{(h.r_inherent || 0) <= 20 ? 'Niedrig' : (h.r_inherent || 0) <= 60 ? 'Mittel' : 'Hoch'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">Offen</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { CATEGORY_LABELS } from './types'
|
||||||
|
import { RiskBadge } from './RiskBadge'
|
||||||
|
|
||||||
|
interface BlockHazard {
|
||||||
|
hazard: {
|
||||||
|
id: string; name: string; description: string; category: string
|
||||||
|
hazardous_zone: string; scenario?: string; possible_harm?: string
|
||||||
|
}
|
||||||
|
assessment?: { severity: number; exposure: number; probability: number; inherent_risk: number; risk_level: string } | null
|
||||||
|
mitigation_ids: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HazardBlock {
|
||||||
|
parent_hazard: BlockHazard
|
||||||
|
children: BlockHazard[]
|
||||||
|
block_key: string
|
||||||
|
shared_measure_count: number
|
||||||
|
children_covered_by_parent: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BlockSummary {
|
||||||
|
total_blocks: number
|
||||||
|
parent_only_blocks: number
|
||||||
|
blocks_with_children: number
|
||||||
|
total_hazards: number
|
||||||
|
covered_children: number
|
||||||
|
uncovered_children: number
|
||||||
|
assessments_needed: number
|
||||||
|
assessments_saved: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HazardBlockView() {
|
||||||
|
const { projectId } = useParams<{ projectId: string }>()
|
||||||
|
const [blocks, setBlocks] = useState<HazardBlock[]>([])
|
||||||
|
const [summary, setSummary] = useState<BlockSummary | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId) return
|
||||||
|
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazard-blocks`)
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(data => {
|
||||||
|
if (data) {
|
||||||
|
setBlocks(data.blocks || [])
|
||||||
|
setSummary(data.summary || null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
const toggle = (key: string) => setExpanded(prev => ({ ...prev, [key]: !prev[key] }))
|
||||||
|
|
||||||
|
if (loading) return <div className="text-sm text-gray-400 py-8 text-center">Lade Bloecke...</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Summary Cards */}
|
||||||
|
{summary && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<SummaryCard label="Bloecke" value={summary.total_blocks} sub={`${summary.total_hazards} Gefaehrdungen`} />
|
||||||
|
<SummaryCard label="Mit Kindern" value={summary.blocks_with_children} sub={`${summary.covered_children} abgedeckt`} color="green" />
|
||||||
|
<SummaryCard label="Bewertungen noetig" value={summary.assessments_needed} sub={`von ${summary.total_hazards}`} color="purple" />
|
||||||
|
<SummaryCard label="Eingespart" value={summary.assessments_saved} sub="durch Gruppierung" color="green" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Block List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{blocks.map((block) => {
|
||||||
|
const isOpen = expanded[block.block_key]
|
||||||
|
const parent = block.parent_hazard
|
||||||
|
const childCount = block.children.length
|
||||||
|
const covered = block.children_covered_by_parent
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={block.block_key} className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
{/* Parent Row */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors ${childCount > 0 ? '' : 'opacity-90'}`}
|
||||||
|
onClick={() => childCount > 0 && toggle(block.block_key)}
|
||||||
|
>
|
||||||
|
{/* Expand Arrow */}
|
||||||
|
{childCount > 0 ? (
|
||||||
|
<svg className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<div className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Name + Category */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">{parent.hazard.name}</span>
|
||||||
|
<span className="text-xs text-gray-400">{CATEGORY_LABELS[parent.hazard.category] || parent.hazard.category}</span>
|
||||||
|
</div>
|
||||||
|
{parent.hazard.hazardous_zone && (
|
||||||
|
<div className="text-xs text-gray-500 truncate">{parent.hazard.hazardous_zone}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Risk */}
|
||||||
|
{parent.assessment ? (
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="text-gray-500">R={parent.assessment.inherent_risk}</span>
|
||||||
|
<RiskBadge level={parent.assessment.risk_level} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">Nicht bewertet</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Child count badge */}
|
||||||
|
{childCount > 0 && (
|
||||||
|
<div className={`flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
covered
|
||||||
|
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||||
|
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||||
|
}`}>
|
||||||
|
+{childCount}
|
||||||
|
{covered && (
|
||||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Measures count */}
|
||||||
|
<span className="text-xs text-gray-400">{block.shared_measure_count} M.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Children (expanded) */}
|
||||||
|
{isOpen && childCount > 0 && (
|
||||||
|
<div className="border-t border-gray-100 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-850">
|
||||||
|
{covered && (
|
||||||
|
<div className="px-4 py-2 text-xs text-green-600 dark:text-green-400 bg-green-50/50 dark:bg-green-900/10 border-b border-green-100 dark:border-green-900/30">
|
||||||
|
Alle Untergefaehrdungen durch Massnahmen der Muttergefaehrdung abgedeckt — keine separate Bewertung noetig.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{block.children.map((child) => (
|
||||||
|
<div key={child.hazard.id} className="flex items-center gap-3 px-4 py-2 pl-12 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-xs text-gray-700 dark:text-gray-300">{child.hazard.name}</span>
|
||||||
|
{child.hazard.hazardous_zone && (
|
||||||
|
<span className="text-xs text-gray-400 ml-2">[{child.hazard.hazardous_zone}]</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{child.assessment ? (
|
||||||
|
<span className="text-xs text-gray-500">R={child.assessment.inherent_risk}</span>
|
||||||
|
) : covered ? (
|
||||||
|
<span className="text-xs text-green-500">Abgedeckt</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-yellow-500">Offen</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryCard({ label, value, sub, color }: { label: string; value: number; sub: string; color?: string }) {
|
||||||
|
const textColor = color === 'green' ? 'text-green-600' : color === 'purple' ? 'text-purple-600' : 'text-gray-900 dark:text-white'
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-3 text-center">
|
||||||
|
<div className={`text-xl font-bold ${textColor}`}>{value}</div>
|
||||||
|
<div className="text-xs font-medium text-gray-600 dark:text-gray-400">{label}</div>
|
||||||
|
<div className="text-[10px] text-gray-400">{sub}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,12 @@
|
|||||||
import { Hazard, LifecyclePhase, CATEGORY_LABELS, STATUS_LABELS } from './types'
|
import { Hazard, LifecyclePhase, CATEGORY_LABELS, STATUS_LABELS } from './types'
|
||||||
import { RiskBadge, ReviewStatusBadge } from './RiskBadge'
|
import { RiskBadge, ReviewStatusBadge } from './RiskBadge'
|
||||||
|
|
||||||
|
const OP_STATE_LABELS: Record<string, string> = {
|
||||||
|
startup: 'Hochfahren', homing: 'Referenzfahrt', automatic_operation: 'Automatik',
|
||||||
|
manual_operation: 'Handbetrieb', teach_mode: 'Einrichten', maintenance: 'Wartung',
|
||||||
|
cleaning: 'Reinigung', emergency_stop: 'Not-Halt', recovery_mode: 'Wiederanlauf',
|
||||||
|
}
|
||||||
|
|
||||||
export function HazardTable({ hazards, lifecyclePhases, onDelete }: {
|
export function HazardTable({ hazards, lifecyclePhases, onDelete }: {
|
||||||
hazards: Hazard[]
|
hazards: Hazard[]
|
||||||
lifecyclePhases: LifecyclePhase[]
|
lifecyclePhases: LifecyclePhase[]
|
||||||
@@ -47,6 +53,15 @@ export function HazardTable({ hazards, lifecyclePhases, onDelete }: {
|
|||||||
{lifecyclePhases.find(p => p.id === hazard.lifecycle_phase)?.label_de || hazard.lifecycle_phase}
|
{lifecyclePhases.find(p => p.id === hazard.lifecycle_phase)?.label_de || hazard.lifecycle_phase}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{hazard.operational_states && hazard.operational_states.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{hazard.operational_states.map((s) => (
|
||||||
|
<span key={s} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800">
|
||||||
|
{OP_STATE_LABELS[s] || s}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">{CATEGORY_LABELS[hazard.category] || hazard.category}</td>
|
<td className="px-4 py-3 text-sm text-gray-600">{CATEGORY_LABELS[hazard.category] || hazard.category}</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-medium">{hazard.severity}</td>
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-medium">{hazard.severity}</td>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface Hazard {
|
|||||||
created_at: string
|
created_at: string
|
||||||
source?: string
|
source?: string
|
||||||
match_reasons?: { type: string; tag: string; met: boolean }[]
|
match_reasons?: { type: string; tag: string; met: boolean }[]
|
||||||
|
operational_states?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LibraryHazard {
|
export interface LibraryHazard {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import React, { useState, useMemo, useCallback } from 'react'
|
|||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { HazardForm } from './_components/HazardForm'
|
import { HazardForm } from './_components/HazardForm'
|
||||||
import { HazardTable } from './_components/HazardTable'
|
import { HazardTable } from './_components/HazardTable'
|
||||||
|
import { HazardBlockView } from './_components/HazardBlockView'
|
||||||
|
import { BlockAwareRiskTable } from './_components/BlockAwareRiskTable'
|
||||||
import { RiskAssessmentTable } from './_components/RiskAssessmentTable'
|
import { RiskAssessmentTable } from './_components/RiskAssessmentTable'
|
||||||
import { ResidualRiskPanel, getResidualStatus } from './_components/ResidualRiskPanel'
|
import { ResidualRiskPanel, getResidualStatus } from './_components/ResidualRiskPanel'
|
||||||
import type { ResidualFilter } from './_components/ResidualRiskPanel'
|
import type { ResidualFilter } from './_components/ResidualRiskPanel'
|
||||||
@@ -12,7 +14,7 @@ import { AutoSuggestPanel } from './_components/AutoSuggestPanel'
|
|||||||
import { CustomHazardModal } from './_components/CustomHazardModal'
|
import { CustomHazardModal } from './_components/CustomHazardModal'
|
||||||
import { useHazards } from './_hooks/useHazards'
|
import { useHazards } from './_hooks/useHazards'
|
||||||
|
|
||||||
type ViewMode = 'list' | 'risk'
|
type ViewMode = 'list' | 'risk' | 'blocks'
|
||||||
|
|
||||||
export default function HazardsPage() {
|
export default function HazardsPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -69,6 +71,10 @@ export default function HazardsPage() {
|
|||||||
className={`px-3 py-1.5 font-medium transition-colors border-l border-gray-200 dark:border-gray-600 ${view === 'risk' ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-50'}`}>
|
className={`px-3 py-1.5 font-medium transition-colors border-l border-gray-200 dark:border-gray-600 ${view === 'risk' ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-50'}`}>
|
||||||
Risikobewertung
|
Risikobewertung
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={() => setView('blocks')}
|
||||||
|
className={`px-3 py-1.5 font-medium transition-colors border-l border-gray-200 dark:border-gray-600 ${view === 'blocks' ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-50'}`}>
|
||||||
|
Bloecke
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -169,9 +175,11 @@ export default function HazardsPage() {
|
|||||||
<>
|
<>
|
||||||
<ResidualRiskPanel hazards={h.hazards} decisions={decisions}
|
<ResidualRiskPanel hazards={h.hazards} decisions={decisions}
|
||||||
activeFilter={residualFilter} onFilterChange={setResidualFilter} />
|
activeFilter={residualFilter} onFilterChange={setResidualFilter} />
|
||||||
<RiskAssessmentTable projectId={projectId} hazards={filteredHazards}
|
<BlockAwareRiskTable projectId={projectId} hazards={filteredHazards}
|
||||||
onReassess={h.refetch} decisions={decisions} onDecision={handleDecision} />
|
onReassess={h.refetch} decisions={decisions} onDecision={handleDecision} />
|
||||||
</>
|
</>
|
||||||
|
) : view === 'blocks' ? (
|
||||||
|
<HazardBlockView />
|
||||||
) : (
|
) : (
|
||||||
<HazardTable hazards={h.hazards} lifecyclePhases={h.lifecyclePhases} onDelete={h.handleDelete} />
|
<HazardTable hazards={h.hazards} lifecyclePhases={h.lifecyclePhases} onDelete={h.handleDelete} />
|
||||||
)
|
)
|
||||||
|
|||||||
+17
@@ -7,6 +7,7 @@ import {
|
|||||||
AREA_OF_USE_OPTIONS,
|
AREA_OF_USE_OPTIONS,
|
||||||
OPERATING_MODE_OPTIONS,
|
OPERATING_MODE_OPTIONS,
|
||||||
PERSON_GROUP_OPTIONS,
|
PERSON_GROUP_OPTIONS,
|
||||||
|
INDUSTRY_SECTOR_OPTIONS,
|
||||||
type LimitsFormData,
|
type LimitsFormData,
|
||||||
} from '../_types'
|
} from '../_types'
|
||||||
|
|
||||||
@@ -204,6 +205,22 @@ export function LimitsFormSections({ data, onChange, prefilled }: LimitsFormSect
|
|||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Section 7: Einsatzbereich / Branche */}
|
||||||
|
<SectionCard section={FORM_SECTIONS[6]}>
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-2">
|
||||||
|
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||||
|
Die Branchenauswahl steuert welche branchenspezifischen Gefaehrdungsmuster (z.B. Medizintechnik, Lebensmittel, Aufzuege) bei der Risikoanalyse beruecksichtigt werden. Branchenfremde Muster werden automatisch ausgeblendet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<CheckboxGroup
|
||||||
|
label="Einsatzbereiche"
|
||||||
|
values={data.industry_sectors}
|
||||||
|
onChange={(v) => onChange('industry_sectors', v)}
|
||||||
|
options={INDUSTRY_SECTOR_OPTIONS}
|
||||||
|
helpText="Waehlen Sie alle zutreffenden Branchen. Bei Mehrfachauswahl werden alle relevanten Gefaehrdungen beruecksichtigt."
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ export interface LimitsFormData {
|
|||||||
// Section 6: Betroffene Personen
|
// Section 6: Betroffene Personen
|
||||||
person_groups: string[]
|
person_groups: string[]
|
||||||
qualification_requirements: string
|
qualification_requirements: string
|
||||||
|
|
||||||
|
// Section 7: Einsatzbereich / Branche (fuer Pattern-Filterung)
|
||||||
|
industry_sectors: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EMPTY_LIMITS_FORM: LimitsFormData = {
|
export const EMPTY_LIMITS_FORM: LimitsFormData = {
|
||||||
@@ -59,6 +62,7 @@ export const EMPTY_LIMITS_FORM: LimitsFormData = {
|
|||||||
pneumatic_hydraulic_interfaces: '',
|
pneumatic_hydraulic_interfaces: '',
|
||||||
person_groups: [],
|
person_groups: [],
|
||||||
qualification_requirements: '',
|
qualification_requirements: '',
|
||||||
|
industry_sectors: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AREA_OF_USE_OPTIONS = [
|
export const AREA_OF_USE_OPTIONS = [
|
||||||
@@ -77,6 +81,43 @@ export const OPERATING_MODE_OPTIONS = [
|
|||||||
'Wartung',
|
'Wartung',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const INDUSTRY_SECTOR_OPTIONS = [
|
||||||
|
'Allgemeiner Maschinenbau',
|
||||||
|
'Automobil / Zulieferer',
|
||||||
|
'Robotik / Cobot',
|
||||||
|
'Medizintechnik',
|
||||||
|
'Lebensmittel / Getraenke',
|
||||||
|
'Verpackung',
|
||||||
|
'Pharma / Chemie',
|
||||||
|
'Bau / Baumaschinen',
|
||||||
|
'Forst / Holzbearbeitung',
|
||||||
|
'Aufzuege / Foerdertechnik',
|
||||||
|
'Textil',
|
||||||
|
'Landmaschinen',
|
||||||
|
'Druck / Papier',
|
||||||
|
'Metall / CNC',
|
||||||
|
'Schweissen / Oberflaechentechnik',
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Maps display labels to MachineTypes for pattern engine filtering */
|
||||||
|
export const INDUSTRY_TO_MACHINE_TYPES: Record<string, string[]> = {
|
||||||
|
'Allgemeiner Maschinenbau': ['general_industry'],
|
||||||
|
'Automobil / Zulieferer': ['automotive'],
|
||||||
|
'Robotik / Cobot': ['robotics_cobot', 'cobot'],
|
||||||
|
'Medizintechnik': ['medical_device', 'infusion_pump', 'ventilator', 'patient_monitor'],
|
||||||
|
'Lebensmittel / Getraenke': ['food_processing'],
|
||||||
|
'Verpackung': ['packaging'],
|
||||||
|
'Pharma / Chemie': ['chemical', 'pharmaceutical'],
|
||||||
|
'Bau / Baumaschinen': ['construction', 'crane', 'excavator'],
|
||||||
|
'Forst / Holzbearbeitung': ['forestry', 'woodworking', 'circular_saw'],
|
||||||
|
'Aufzuege / Foerdertechnik': ['elevator', 'lift', 'escalator', 'conveyor'],
|
||||||
|
'Textil': ['textile', 'spinning', 'weaving', 'finishing'],
|
||||||
|
'Landmaschinen': ['agricultural', 'tractor', 'harvester'],
|
||||||
|
'Druck / Papier': ['printing'],
|
||||||
|
'Metall / CNC': ['cnc', 'metalworking', 'lathe', 'milling'],
|
||||||
|
'Schweissen / Oberflaechentechnik': ['welding', 'surface_treatment'],
|
||||||
|
}
|
||||||
|
|
||||||
export const PERSON_GROUP_OPTIONS = [
|
export const PERSON_GROUP_OPTIONS = [
|
||||||
'Bedienpersonal',
|
'Bedienpersonal',
|
||||||
'Einrichter',
|
'Einrichter',
|
||||||
@@ -93,7 +134,7 @@ export interface FormSection {
|
|||||||
number: number
|
number: number
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
icon: 'clipboard' | 'target' | 'alert' | 'box' | 'link' | 'users'
|
icon: 'clipboard' | 'target' | 'alert' | 'box' | 'link' | 'users' | 'briefcase'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FORM_SECTIONS: FormSection[] = [
|
export const FORM_SECTIONS: FormSection[] = [
|
||||||
@@ -139,4 +180,11 @@ export const FORM_SECTIONS: FormSection[] = [
|
|||||||
description: 'Personengruppen und Qualifikationsanforderungen',
|
description: 'Personengruppen und Qualifikationsanforderungen',
|
||||||
icon: 'users',
|
icon: 'users',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'industry_sector',
|
||||||
|
number: 7,
|
||||||
|
title: 'Einsatzbereich / Branche',
|
||||||
|
description: 'Branche bestimmt welche branchenspezifischen Gefaehrdungen beruecksichtigt werden',
|
||||||
|
icon: 'briefcase',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -236,6 +236,47 @@ export default function IACEInterviewPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={initStatus === 'running' || completionPct < 30}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm('Alle bestehenden Gefaehrdungen und Massnahmen loeschen und neu erstellen?')) return
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current)
|
||||||
|
await saveToBackend(latestFormRef.current)
|
||||||
|
}
|
||||||
|
setInitStatus('running')
|
||||||
|
setInitResult(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/initialize?force=true`, { method: 'POST' })
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}))
|
||||||
|
alert(err.error || 'Neu-Initialisierung fehlgeschlagen')
|
||||||
|
setInitStatus('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
setInitResult(data)
|
||||||
|
setInitStatus('done')
|
||||||
|
} catch {
|
||||||
|
setInitStatus('error')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{initStatus === 'running' ? (
|
||||||
|
<>
|
||||||
|
<span className="animate-spin inline-block w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full" />
|
||||||
|
Laeuft...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
Neu initialisieren
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (saveTimerRef.current) {
|
if (saveTimerRef.current) {
|
||||||
|
|||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
|
|
||||||
|
interface Component { id: string; name: string; component_type: string }
|
||||||
|
interface Hazard { id: string; name: string; category: string; operational_states?: string[] }
|
||||||
|
interface Mitigation { id: string; name?: string; title?: string; reduction_type: string; hazard_id?: string; linked_hazard_ids?: string[] }
|
||||||
|
|
||||||
|
export interface GraphNode {
|
||||||
|
id: string
|
||||||
|
type: 'component' | 'hazard' | 'mitigation'
|
||||||
|
label: string
|
||||||
|
subLabel?: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphEdge {
|
||||||
|
id: string
|
||||||
|
source: string
|
||||||
|
target: string
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_COLORS: Record<string, string> = {
|
||||||
|
component: '#6366F1', // indigo
|
||||||
|
hazard: '#EF4444', // red
|
||||||
|
mitigation: '#10B981', // green
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKnowledgeGraph(projectId: string) {
|
||||||
|
const [components, setComponents] = useState<Component[]>([])
|
||||||
|
const [hazards, setHazards] = useState<Hazard[]>([])
|
||||||
|
const [mitigations, setMitigations] = useState<Mitigation[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [projectId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
const [compRes, hazRes, mitRes] = await Promise.all([
|
||||||
|
fetch(`/api/sdk/v1/iace/projects/${projectId}/components`),
|
||||||
|
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
|
||||||
|
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (compRes.ok) {
|
||||||
|
const j = await compRes.json()
|
||||||
|
setComponents((j.components || j || []).map((c: Record<string, unknown>) => ({
|
||||||
|
id: c.id as string, name: c.name as string, component_type: c.component_type as string || '',
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
if (hazRes.ok) {
|
||||||
|
const j = await hazRes.json()
|
||||||
|
setHazards((j.hazards || j || []).map((h: Record<string, unknown>) => ({
|
||||||
|
id: h.id as string, name: h.name as string, category: h.category as string || '',
|
||||||
|
operational_states: (h.operational_states || []) as string[],
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
if (mitRes.ok) {
|
||||||
|
const j = await mitRes.json()
|
||||||
|
setMitigations((j.mitigations || j || []).map((m: Record<string, unknown>) => ({
|
||||||
|
id: m.id as string, name: (m.name || m.title || '') as string,
|
||||||
|
title: (m.title || m.name || '') as string,
|
||||||
|
reduction_type: (m.reduction_type || '') as string,
|
||||||
|
hazard_id: (m.hazard_id || '') as string,
|
||||||
|
linked_hazard_ids: (m.linked_hazard_ids || []) as string[],
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load graph data:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { nodes, edges } = useMemo(() => {
|
||||||
|
const graphNodes: GraphNode[] = []
|
||||||
|
const graphEdges: GraphEdge[] = []
|
||||||
|
|
||||||
|
// Component nodes
|
||||||
|
components.forEach((c) => {
|
||||||
|
graphNodes.push({
|
||||||
|
id: `comp-${c.id}`, type: 'component',
|
||||||
|
label: c.name, subLabel: c.component_type,
|
||||||
|
color: NODE_COLORS.component,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Hazard nodes
|
||||||
|
hazards.forEach((h) => {
|
||||||
|
graphNodes.push({
|
||||||
|
id: `haz-${h.id}`, type: 'hazard',
|
||||||
|
label: h.name, subLabel: h.category,
|
||||||
|
color: NODE_COLORS.hazard,
|
||||||
|
})
|
||||||
|
// Edge: first component → hazard (simplified — could be per component_id)
|
||||||
|
if (components.length > 0) {
|
||||||
|
graphEdges.push({
|
||||||
|
id: `e-comp-haz-${h.id}`,
|
||||||
|
source: `comp-${components[0].id}`,
|
||||||
|
target: `haz-${h.id}`,
|
||||||
|
label: 'erzeugt',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mitigation nodes
|
||||||
|
mitigations.forEach((m) => {
|
||||||
|
graphNodes.push({
|
||||||
|
id: `mit-${m.id}`, type: 'mitigation',
|
||||||
|
label: m.title || m.name || m.id,
|
||||||
|
subLabel: m.reduction_type,
|
||||||
|
color: NODE_COLORS.mitigation,
|
||||||
|
})
|
||||||
|
// Edge: mitigation → hazard
|
||||||
|
const hazardIds = m.linked_hazard_ids?.length ? m.linked_hazard_ids : m.hazard_id ? [m.hazard_id] : []
|
||||||
|
hazardIds.forEach((hid) => {
|
||||||
|
graphEdges.push({
|
||||||
|
id: `e-mit-haz-${m.id}-${hid}`,
|
||||||
|
source: `mit-${m.id}`,
|
||||||
|
target: `haz-${hid}`,
|
||||||
|
label: 'schuetzt',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return { nodes: graphNodes, edges: graphEdges }
|
||||||
|
}, [components, hazards, mitigations])
|
||||||
|
|
||||||
|
return { nodes, edges, loading, stats: { components: components.length, hazards: hazards.length, mitigations: mitigations.length } }
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useMemo } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import {
|
||||||
|
ReactFlow,
|
||||||
|
Background,
|
||||||
|
Controls,
|
||||||
|
MiniMap,
|
||||||
|
useNodesState,
|
||||||
|
useEdgesState,
|
||||||
|
type Node,
|
||||||
|
type Edge,
|
||||||
|
MarkerType,
|
||||||
|
} from '@xyflow/react'
|
||||||
|
import '@xyflow/react/dist/style.css'
|
||||||
|
import { useKnowledgeGraph } from './_hooks/useKnowledgeGraph'
|
||||||
|
|
||||||
|
const TYPE_STYLES: Record<string, { bg: string; border: string }> = {
|
||||||
|
component: { bg: '#EEF2FF', border: '#6366F1' },
|
||||||
|
hazard: { bg: '#FEF2F2', border: '#EF4444' },
|
||||||
|
mitigation: { bg: '#ECFDF5', border: '#10B981' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
|
component: 'Komponente',
|
||||||
|
hazard: 'Gefaehrdung',
|
||||||
|
mitigation: 'Massnahme',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KnowledgeGraphPage() {
|
||||||
|
const { projectId } = useParams<{ projectId: string }>()
|
||||||
|
const { nodes: graphNodes, edges: graphEdges, loading, stats } = useKnowledgeGraph(projectId)
|
||||||
|
|
||||||
|
// Convert to React Flow nodes with layout
|
||||||
|
const rfNodes = useMemo((): Node[] => {
|
||||||
|
const compNodes = graphNodes.filter((n) => n.type === 'component')
|
||||||
|
const hazNodes = graphNodes.filter((n) => n.type === 'hazard')
|
||||||
|
const mitNodes = graphNodes.filter((n) => n.type === 'mitigation')
|
||||||
|
|
||||||
|
const nodes: Node[] = []
|
||||||
|
const colWidth = 300
|
||||||
|
const rowHeight = 80
|
||||||
|
|
||||||
|
// Column 1: Components
|
||||||
|
compNodes.forEach((n, i) => {
|
||||||
|
nodes.push({
|
||||||
|
id: n.id,
|
||||||
|
position: { x: 0, y: i * rowHeight },
|
||||||
|
data: { label: n.label, subLabel: n.subLabel, nodeType: n.type },
|
||||||
|
style: {
|
||||||
|
background: TYPE_STYLES.component.bg,
|
||||||
|
border: `2px solid ${TYPE_STYLES.component.border}`,
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
width: 200,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Column 2: Hazards
|
||||||
|
hazNodes.forEach((n, i) => {
|
||||||
|
nodes.push({
|
||||||
|
id: n.id,
|
||||||
|
position: { x: colWidth, y: i * rowHeight },
|
||||||
|
data: { label: n.label, subLabel: n.subLabel, nodeType: n.type },
|
||||||
|
style: {
|
||||||
|
background: TYPE_STYLES.hazard.bg,
|
||||||
|
border: `2px solid ${TYPE_STYLES.hazard.border}`,
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
width: 220,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Column 3: Mitigations
|
||||||
|
mitNodes.forEach((n, i) => {
|
||||||
|
nodes.push({
|
||||||
|
id: n.id,
|
||||||
|
position: { x: colWidth * 2, y: i * rowHeight },
|
||||||
|
data: { label: n.label, subLabel: n.subLabel, nodeType: n.type },
|
||||||
|
style: {
|
||||||
|
background: TYPE_STYLES.mitigation.bg,
|
||||||
|
border: `2px solid ${TYPE_STYLES.mitigation.border}`,
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
width: 220,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return nodes
|
||||||
|
}, [graphNodes])
|
||||||
|
|
||||||
|
const rfEdges = useMemo((): Edge[] => {
|
||||||
|
return graphEdges.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
source: e.source,
|
||||||
|
target: e.target,
|
||||||
|
label: e.label,
|
||||||
|
type: 'smoothstep',
|
||||||
|
animated: true,
|
||||||
|
style: { stroke: '#94A3B8', strokeWidth: 1.5 },
|
||||||
|
labelStyle: { fontSize: 10, fill: '#64748B' },
|
||||||
|
markerEnd: { type: MarkerType.ArrowClosed, color: '#94A3B8' },
|
||||||
|
}))
|
||||||
|
}, [graphEdges])
|
||||||
|
|
||||||
|
const [nodes, setNodes, onNodesChange] = useNodesState(rfNodes)
|
||||||
|
const [edges, setEdges, onEdgesChange] = useEdgesState(rfEdges)
|
||||||
|
|
||||||
|
// Update when data loads
|
||||||
|
const onInit = useCallback(() => {
|
||||||
|
if (rfNodes.length > 0) {
|
||||||
|
setNodes(rfNodes)
|
||||||
|
setEdges(rfEdges)
|
||||||
|
}
|
||||||
|
}, [rfNodes, rfEdges, setNodes, setEdges])
|
||||||
|
|
||||||
|
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-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Safety Knowledge Graph</h1>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
Interaktive Visualisierung: Komponente → Gefaehrdung → Massnahme
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend + Stats */}
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
{(['component', 'hazard', 'mitigation'] as const).map((t) => (
|
||||||
|
<div key={t} className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: TYPE_STYLES[t].border }} />
|
||||||
|
<span className="text-xs text-gray-600">{TYPE_LABELS[t]} ({
|
||||||
|
t === 'component' ? stats.components : t === 'hazard' ? stats.hazards : stats.mitigations
|
||||||
|
})</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Graph */}
|
||||||
|
{graphNodes.length === 0 ? (
|
||||||
|
<div className="text-center py-16 text-gray-500">
|
||||||
|
Keine Daten — bitte zuerst Projekt initialisieren.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden" style={{ height: '600px' }}>
|
||||||
|
<ReactFlow
|
||||||
|
nodes={rfNodes}
|
||||||
|
edges={rfEdges}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
onInit={onInit}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.2 }}
|
||||||
|
minZoom={0.3}
|
||||||
|
maxZoom={2}
|
||||||
|
nodesDraggable
|
||||||
|
nodesConnectable={false}
|
||||||
|
>
|
||||||
|
<Background gap={20} size={1} color="#f0f0f0" />
|
||||||
|
<Controls />
|
||||||
|
<MiniMap
|
||||||
|
nodeColor={(node) => {
|
||||||
|
const t = (node.data as { nodeType?: string })?.nodeType || 'component'
|
||||||
|
return TYPE_STYLES[t]?.border || '#94A3B8'
|
||||||
|
}}
|
||||||
|
maskColor="rgba(0,0,0,0.05)"
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+16
-1
@@ -3,6 +3,12 @@
|
|||||||
import { Mitigation } from './types'
|
import { Mitigation } from './types'
|
||||||
import { StatusBadge } from './StatusBadge'
|
import { StatusBadge } from './StatusBadge'
|
||||||
|
|
||||||
|
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 MitigationCard({
|
export function MitigationCard({
|
||||||
mitigation,
|
mitigation,
|
||||||
onVerify,
|
onVerify,
|
||||||
@@ -26,7 +32,16 @@ export function MitigationCard({
|
|||||||
<StatusBadge status={mitigation.status} />
|
<StatusBadge status={mitigation.status} />
|
||||||
</div>
|
</div>
|
||||||
{mitigation.description && (
|
{mitigation.description && (
|
||||||
<p className="text-xs text-gray-500 mb-3">{mitigation.description}</p>
|
<p className="text-xs text-gray-500 mb-2">{mitigation.description}</p>
|
||||||
|
)}
|
||||||
|
{mitigation.operational_states && mitigation.operational_states.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
|
{mitigation.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>
|
||||||
)}
|
)}
|
||||||
{(mitigation.linked_hazard_names || []).length > 0 && (
|
{(mitigation.linked_hazard_names || []).length > 0 && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface Mitigation {
|
|||||||
verified_at: string | null
|
verified_at: string | null
|
||||||
verified_by: string | null
|
verified_by: string | null
|
||||||
source?: string
|
source?: string
|
||||||
|
operational_states?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Hazard {
|
export interface Hazard {
|
||||||
@@ -19,6 +20,7 @@ export interface Hazard {
|
|||||||
name: string
|
name: string
|
||||||
risk_level: string
|
risk_level: string
|
||||||
category?: string
|
category?: string
|
||||||
|
operational_states?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProtectiveMeasure {
|
export interface ProtectiveMeasure {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function useMitigations(projectId: string) {
|
|||||||
let hazardList: Hazard[] = []
|
let hazardList: Hazard[] = []
|
||||||
if (hazRes.ok) {
|
if (hazRes.ok) {
|
||||||
const json = await hazRes.json()
|
const json = await hazRes.json()
|
||||||
hazardList = (json.hazards || json || []).map((h: Hazard) => ({ id: h.id, name: h.name, risk_level: h.risk_level, category: h.category }))
|
hazardList = (json.hazards || json || []).map((h: Record<string, unknown>) => ({ id: h.id as string, name: h.name as string, risk_level: h.risk_level as string, category: h.category as string, operational_states: (h.operational_states || []) as string[] }))
|
||||||
setHazards(hazardList)
|
setHazards(hazardList)
|
||||||
}
|
}
|
||||||
if (mitRes.ok) {
|
if (mitRes.ok) {
|
||||||
@@ -31,6 +31,7 @@ export function useMitigations(projectId: string) {
|
|||||||
const raw = json.mitigations || json || []
|
const raw = json.mitigations || json || []
|
||||||
// Map API fields (name, hazard_id) to frontend fields (title, linked_hazard_ids/names)
|
// Map API fields (name, hazard_id) to frontend fields (title, linked_hazard_ids/names)
|
||||||
const hazardMap = Object.fromEntries(hazardList.map((h) => [h.id, h.name]))
|
const hazardMap = Object.fromEntries(hazardList.map((h) => [h.id, h.name]))
|
||||||
|
const hazardStatesMap = Object.fromEntries(hazardList.map((h) => [h.id, (h as Record<string, unknown>).operational_states || []]))
|
||||||
const mits: Mitigation[] = raw.map((m: Record<string, unknown>) => ({
|
const mits: Mitigation[] = raw.map((m: Record<string, unknown>) => ({
|
||||||
id: m.id as string,
|
id: m.id as string,
|
||||||
title: (m.title || m.name || '') as string,
|
title: (m.title || m.name || '') as string,
|
||||||
@@ -44,6 +45,12 @@ export function useMitigations(projectId: string) {
|
|||||||
created_at: (m.created_at || '') as string,
|
created_at: (m.created_at || '') as string,
|
||||||
verified_at: (m.verified_at || null) as string | null,
|
verified_at: (m.verified_at || null) as string | null,
|
||||||
verified_by: (m.verified_by || null) as string | null,
|
verified_by: (m.verified_by || null) as string | null,
|
||||||
|
operational_states: (() => {
|
||||||
|
const ids = m.linked_hazard_ids ? (m.linked_hazard_ids as string[]) : m.hazard_id ? [m.hazard_id as string] : []
|
||||||
|
const states = new Set<string>()
|
||||||
|
ids.forEach((id) => { ((hazardStatesMap[id] || []) as string[]).forEach((s) => states.add(s)) })
|
||||||
|
return [...states]
|
||||||
|
})(),
|
||||||
}))
|
}))
|
||||||
setMitigations(mits)
|
setMitigations(mits)
|
||||||
validateHierarchy(mits)
|
validateHierarchy(mits)
|
||||||
@@ -146,7 +153,7 @@ export function useMitigations(projectId: string) {
|
|||||||
|
|
||||||
const byType = {
|
const byType = {
|
||||||
design: mitigations.filter((m) => m.reduction_type === 'design'),
|
design: mitigations.filter((m) => m.reduction_type === 'design'),
|
||||||
protection: mitigations.filter((m) => m.reduction_type === 'protection' || m.reduction_type === 'protective'),
|
protection: mitigations.filter((m) => m.reduction_type === 'protection'),
|
||||||
information: mitigations.filter((m) => m.reduction_type === 'information'),
|
information: mitigations.filter((m) => m.reduction_type === 'information'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+208
@@ -0,0 +1,208 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────
|
||||||
|
export interface OperationalStateInfo {
|
||||||
|
id: string
|
||||||
|
label_de: string
|
||||||
|
label_en: string
|
||||||
|
sort_order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeltaResult {
|
||||||
|
added_patterns: number
|
||||||
|
removed_patterns: number
|
||||||
|
added_hazards: string[]
|
||||||
|
removed_hazards: string[]
|
||||||
|
added_measures: string[]
|
||||||
|
removed_measures: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectMetadata {
|
||||||
|
limits_form?: Record<string, unknown>
|
||||||
|
operational_states?: string[]
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hook ───────────────────────────────────────────────────
|
||||||
|
export function useOperationalStates(projectId: string) {
|
||||||
|
const [allStates, setAllStates] = useState<OperationalStateInfo[]>([])
|
||||||
|
const [transitions, setTransitions] = useState<string[]>([])
|
||||||
|
const [selectedStates, setSelectedStates] = useState<string[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [deltaResult, setDeltaResult] = useState<DeltaResult | null>(null)
|
||||||
|
const [deltaLoading, setDeltaLoading] = useState(false)
|
||||||
|
const metadataRef = useRef<ProjectMetadata>({})
|
||||||
|
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
return () => {
|
||||||
|
if (savedTimerRef.current) clearTimeout(savedTimerRef.current)
|
||||||
|
}
|
||||||
|
}, [projectId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
const [statesRes, projRes] = await Promise.all([
|
||||||
|
fetch('/api/sdk/v1/iace/operational-states'),
|
||||||
|
fetch(`/api/sdk/v1/iace/projects/${projectId}`),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (statesRes.ok) {
|
||||||
|
const json = await statesRes.json()
|
||||||
|
setAllStates(json.operational_states || [])
|
||||||
|
setTransitions(json.transitions || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projRes.ok) {
|
||||||
|
const proj = await projRes.json()
|
||||||
|
const meta: ProjectMetadata = proj.metadata || {}
|
||||||
|
metadataRef.current = meta
|
||||||
|
setSelectedStates(meta.operational_states || [])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load operational states:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleState = useCallback((stateId: string) => {
|
||||||
|
setSelectedStates((prev) => {
|
||||||
|
const next = prev.includes(stateId)
|
||||||
|
? prev.filter((s) => s !== stateId)
|
||||||
|
: [...prev, stateId]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setDeltaResult(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const saveSelection = useCallback(async (states: string[]) => {
|
||||||
|
setSaving(true)
|
||||||
|
setSaved(false)
|
||||||
|
try {
|
||||||
|
const newMetadata = { ...metadataRef.current, operational_states: states }
|
||||||
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ metadata: newMetadata }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
metadataRef.current = newMetadata
|
||||||
|
setSaved(true)
|
||||||
|
if (savedTimerRef.current) clearTimeout(savedTimerRef.current)
|
||||||
|
savedTimerRef.current = setTimeout(() => setSaved(false), 2000)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save:', err)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
const runDeltaAnalysis = useCallback(async (states: string[]) => {
|
||||||
|
setDeltaLoading(true)
|
||||||
|
setDeltaResult(null)
|
||||||
|
try {
|
||||||
|
// Build MatchInput from project's components — derive tags from names/types
|
||||||
|
const compRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`)
|
||||||
|
let componentTags: string[] = []
|
||||||
|
let energyIds: string[] = []
|
||||||
|
if (compRes.ok) {
|
||||||
|
const cj = await compRes.json()
|
||||||
|
const comps = (cj.components || cj || []) as Array<{ library_id?: string; component_type?: string; name?: string; energy_source_ids?: string[] }>
|
||||||
|
// Use library_ids if available, otherwise derive tags from component names/types
|
||||||
|
const libIds = comps.map((c) => c.library_id).filter(Boolean) as string[]
|
||||||
|
if (libIds.length > 0) {
|
||||||
|
componentTags = libIds
|
||||||
|
} else {
|
||||||
|
// Derive tags from component names for pattern matching
|
||||||
|
const tagMap: Record<string, string[]> = {
|
||||||
|
sensor: ['sensor', 'has_sensor'], software: ['software', 'has_software'],
|
||||||
|
firmware: ['firmware', 'has_firmware'], ai_model: ['has_ai', 'ai_model'],
|
||||||
|
hmi: ['hmi', 'display'], electrical: ['electric_motor', 'electric_drive'],
|
||||||
|
network: ['networked', 'ethernet'], actuator: ['actuator', 'hydraulic'],
|
||||||
|
mechanical: ['moving_mechanical_parts'], hydraulic: ['hydraulic'],
|
||||||
|
}
|
||||||
|
const nameKeywords: Record<string, string[]> = {
|
||||||
|
roboter: ['cobot', 'robot_arm'], motor: ['electric_motor', 'electric_drive'],
|
||||||
|
scanner: ['sensor', 'safety_scanner'], sps: ['controller', 'plc'],
|
||||||
|
steuerung: ['controller', 'plc'], greifer: ['actuator', 'gripper'],
|
||||||
|
schutzzaun: ['safety_fence'], lichtgitter: ['light_curtain'],
|
||||||
|
kamera: ['camera', 'sensor'], ventil: ['valve', 'pneumatic'],
|
||||||
|
}
|
||||||
|
const tags = new Set<string>()
|
||||||
|
for (const c of comps) {
|
||||||
|
const typeTags = tagMap[c.component_type || ''] || ['moving_mechanical_parts']
|
||||||
|
typeTags.forEach((t) => tags.add(t))
|
||||||
|
const nameLower = (c.name || '').toLowerCase()
|
||||||
|
for (const [kw, kwTags] of Object.entries(nameKeywords)) {
|
||||||
|
if (nameLower.includes(kw)) kwTags.forEach((t) => tags.add(t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
componentTags = [...tags]
|
||||||
|
}
|
||||||
|
energyIds = comps.flatMap((c) => c.energy_source_ids || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/delta-analysis`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
current: {
|
||||||
|
component_library_ids: componentTags,
|
||||||
|
energy_source_ids: energyIds,
|
||||||
|
custom_tags: componentTags,
|
||||||
|
operational_states: metadataRef.current.operational_states || [],
|
||||||
|
},
|
||||||
|
proposed: {
|
||||||
|
component_library_ids: componentTags,
|
||||||
|
energy_source_ids: energyIds,
|
||||||
|
custom_tags: componentTags,
|
||||||
|
operational_states: states,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json()
|
||||||
|
setDeltaResult({
|
||||||
|
added_patterns: json.added_patterns?.length || 0,
|
||||||
|
removed_patterns: json.removed_patterns?.length || 0,
|
||||||
|
added_hazards: (json.added_hazards || []).map((h: { name?: string }) => h.name || ''),
|
||||||
|
removed_hazards: (json.removed_hazards || []).map((h: { name?: string }) => h.name || ''),
|
||||||
|
added_measures: (json.added_measures || []).map((m: { id?: string }) => m.id || ''),
|
||||||
|
removed_measures: (json.removed_measures || []).map((m: { id?: string }) => m.id || ''),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delta analysis failed:', err)
|
||||||
|
} finally {
|
||||||
|
setDeltaLoading(false)
|
||||||
|
}
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
/** Transitions that involve only selected states */
|
||||||
|
const activeTransitions = transitions.filter((t) => {
|
||||||
|
const [from, to] = t.split('\u2192')
|
||||||
|
return selectedStates.includes(from) && selectedStates.includes(to)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
allStates,
|
||||||
|
transitions,
|
||||||
|
activeTransitions,
|
||||||
|
selectedStates,
|
||||||
|
toggleState,
|
||||||
|
saveSelection,
|
||||||
|
runDeltaAnalysis,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
saved,
|
||||||
|
deltaResult,
|
||||||
|
deltaLoading,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { useOperationalStates, type OperationalStateInfo } from './_hooks/useOperationalStates'
|
||||||
|
|
||||||
|
// ── State descriptions for ISO 12100 context ───────────────
|
||||||
|
const STATE_DESCRIPTIONS: Record<string, string> = {
|
||||||
|
startup: 'Erstmaliges oder wiederholtes Einschalten der Maschine',
|
||||||
|
homing: 'Referenzfahrt der Achsen nach dem Einschalten',
|
||||||
|
automatic_operation: 'Vollautomatischer Produktionsbetrieb',
|
||||||
|
manual_operation: 'Manuell gesteuerter Betrieb (Handrad, Tippbetrieb)',
|
||||||
|
teach_mode: 'Programmierung und Einrichten bei reduzierter Geschwindigkeit',
|
||||||
|
maintenance: 'Geplante Wartungs- und Instandhaltungsarbeiten',
|
||||||
|
cleaning: 'Reinigung der Maschine und des Arbeitsbereichs',
|
||||||
|
emergency_stop: 'Not-Halt ausgeloest — Maschine im sicheren Zustand',
|
||||||
|
recovery_mode: 'Wiederanlauf nach Not-Halt oder Stoerung',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE_ICONS: Record<string, string> = {
|
||||||
|
startup: 'M13 10V3L4 14h7v7l9-11h-7z',
|
||||||
|
homing: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4',
|
||||||
|
automatic_operation: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15',
|
||||||
|
manual_operation: 'M7 11.5V14m0-2.5v-6a1.5 1.5 0 113 0m-3 6a1.5 1.5 0 00-3 0v2a7.5 7.5 0 0015 0v-5a1.5 1.5 0 00-3 0m-6-3V11m0-5.5v-1a1.5 1.5 0 013 0v1m0 0V11m0-5.5a1.5 1.5 0 013 0v3m0 0V11',
|
||||||
|
teach_mode: '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',
|
||||||
|
maintenance: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',
|
||||||
|
cleaning: 'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z',
|
||||||
|
emergency_stop: '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',
|
||||||
|
recovery_mode: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE_COLORS: Record<string, { bg: string; border: string; text: string }> = {
|
||||||
|
startup: { bg: 'bg-blue-50 dark:bg-blue-900/20', border: 'border-blue-200 dark:border-blue-800', text: 'text-blue-700 dark:text-blue-300' },
|
||||||
|
homing: { bg: 'bg-indigo-50 dark:bg-indigo-900/20', border: 'border-indigo-200 dark:border-indigo-800', text: 'text-indigo-700 dark:text-indigo-300' },
|
||||||
|
automatic_operation: { bg: 'bg-green-50 dark:bg-green-900/20', border: 'border-green-200 dark:border-green-800', text: 'text-green-700 dark:text-green-300' },
|
||||||
|
manual_operation: { bg: 'bg-yellow-50 dark:bg-yellow-900/20', border: 'border-yellow-200 dark:border-yellow-800', text: 'text-yellow-700 dark:text-yellow-300' },
|
||||||
|
teach_mode: { bg: 'bg-orange-50 dark:bg-orange-900/20', border: 'border-orange-200 dark:border-orange-800', text: 'text-orange-700 dark:text-orange-300' },
|
||||||
|
maintenance: { bg: 'bg-purple-50 dark:bg-purple-900/20', border: 'border-purple-200 dark:border-purple-800', text: 'text-purple-700 dark:text-purple-300' },
|
||||||
|
cleaning: { bg: 'bg-cyan-50 dark:bg-cyan-900/20', border: 'border-cyan-200 dark:border-cyan-800', text: 'text-cyan-700 dark:text-cyan-300' },
|
||||||
|
emergency_stop: { bg: 'bg-red-50 dark:bg-red-900/20', border: 'border-red-200 dark:border-red-800', text: 'text-red-700 dark:text-red-300' },
|
||||||
|
recovery_mode: { bg: 'bg-amber-50 dark:bg-amber-900/20', border: 'border-amber-200 dark:border-amber-800', text: 'text-amber-700 dark:text-amber-300' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OperationalStatesPage() {
|
||||||
|
const { projectId } = useParams<{ projectId: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
const {
|
||||||
|
allStates,
|
||||||
|
activeTransitions,
|
||||||
|
selectedStates,
|
||||||
|
toggleState,
|
||||||
|
saveSelection,
|
||||||
|
runDeltaAnalysis,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
saved,
|
||||||
|
deltaResult,
|
||||||
|
deltaLoading,
|
||||||
|
} = useOperationalStates(projectId)
|
||||||
|
|
||||||
|
const [initStatus, setInitStatus] = useState<'idle' | 'running' | 'done' | 'error'>('idle')
|
||||||
|
const [initResult, setInitResult] = useState<{ steps: { name: string; status: string; count: number; details?: string }[]; summary?: Record<string, number> } | null>(null)
|
||||||
|
|
||||||
|
async function handleInitialize(force: boolean) {
|
||||||
|
// Save current selection first
|
||||||
|
await saveSelection(selectedStates)
|
||||||
|
setInitStatus('running')
|
||||||
|
setInitResult(null)
|
||||||
|
try {
|
||||||
|
const url = `/api/sdk/v1/iace/projects/${projectId}/initialize${force ? '?force=true' : ''}`
|
||||||
|
const res = await fetch(url, { method: 'POST' })
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}))
|
||||||
|
alert(err.error || 'Initialisierung fehlgeschlagen')
|
||||||
|
setInitStatus('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
setInitResult(data)
|
||||||
|
setInitStatus('done')
|
||||||
|
} catch {
|
||||||
|
setInitStatus('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasChanges = true // always allow save (metadata merge is idempotent)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Betriebszustaende</h1>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
Waehlen Sie die relevanten Betriebszustaende fuer diese Maschine (ISO 12100 Abschnitt 5)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{saved && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
|
||||||
|
<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="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
Gespeichert
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="px-2.5 py-1 rounded-full text-xs font-medium bg-purple-50 text-purple-700 border border-purple-200 dark:bg-purple-900/30 dark:text-purple-300 dark:border-purple-700">
|
||||||
|
{selectedStates.length} / {allStates.length} aktiv
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* State Selection Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{allStates.map((state) => (
|
||||||
|
<StateCard
|
||||||
|
key={state.id}
|
||||||
|
state={state}
|
||||||
|
selected={selectedStates.includes(state.id)}
|
||||||
|
onToggle={() => toggleState(state.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Transitions */}
|
||||||
|
{selectedStates.length >= 2 && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
Zustandsuebergaenge ({activeTransitions.length})
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
Gueltige Uebergaenge zwischen den ausgewaehlten Betriebszustaenden gemaess ISO 12100 State Graph
|
||||||
|
</p>
|
||||||
|
{activeTransitions.length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-400 italic">
|
||||||
|
Keine direkten Uebergaenge zwischen den ausgewaehlten Zustaenden.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{activeTransitions.map((t) => {
|
||||||
|
const [from, to] = t.split('\u2192')
|
||||||
|
const fromLabel = allStates.find((s) => s.id === from)?.label_de || from
|
||||||
|
const toLabel = allStates.find((s) => s.id === to)?.label_de || to
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={t}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-50 dark:bg-gray-700 rounded-lg text-xs"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-gray-700 dark:text-gray-300">{fromLabel}</span>
|
||||||
|
<svg className="w-3.5 h-3.5 text-purple-500" 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>
|
||||||
|
<span className="font-medium text-gray-700 dark:text-gray-300">{toLabel}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delta Analysis */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">Delta-Vorschau</h2>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
Zeigt die Auswirkungen der Zustandsaenderung auf Gefaehrdungen und Massnahmen
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => runDeltaAnalysis(selectedStates)}
|
||||||
|
disabled={deltaLoading || selectedStates.length === 0}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{deltaLoading ? (
|
||||||
|
<>
|
||||||
|
<span className="animate-spin inline-block w-3.5 h-3.5 border-2 border-gray-400 border-t-transparent rounded-full" />
|
||||||
|
Analyse...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||||
|
</svg>
|
||||||
|
Delta berechnen
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deltaResult && (
|
||||||
|
<div className="space-y-3 mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<DeltaStat label="Neue Muster" value={deltaResult.added_patterns} positive />
|
||||||
|
<DeltaStat label="Entfernte Muster" value={deltaResult.removed_patterns} positive={false} />
|
||||||
|
<DeltaStat label="Neue Gefaehrdungen" value={deltaResult.added_hazards.length} positive />
|
||||||
|
<DeltaStat label="Entfernte Gefaehrdungen" value={deltaResult.removed_hazards.length} positive={false} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deltaResult.added_hazards.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-medium text-green-700 dark:text-green-400 mb-1">
|
||||||
|
+ Neue Gefaehrdungen
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{deltaResult.added_hazards.slice(0, 10).map((h, i) => (
|
||||||
|
<li key={i} className="text-xs text-gray-600 dark:text-gray-400 flex items-center gap-1">
|
||||||
|
<span className="text-green-500">+</span> {h}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{deltaResult.added_hazards.length > 10 && (
|
||||||
|
<li className="text-xs text-gray-400">... und {deltaResult.added_hazards.length - 10} weitere</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deltaResult.added_measures.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-medium text-blue-700 dark:text-blue-400 mb-1">
|
||||||
|
+ Neue Massnahmen ({deltaResult.added_measures.length})
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deltaResult.added_patterns === 0 && deltaResult.removed_patterns === 0 && (
|
||||||
|
<p className="text-xs text-gray-400 italic">Keine Aenderungen erkannt — die Zustandsauswahl hat keinen Einfluss auf die aktuellen Patterns.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Initialize Section */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Projekt initialisieren</h2>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
Erzeugt Gefaehrdungen und Massnahmen basierend auf Maschinenbeschreibung, Komponenten und den ausgewaehlten Betriebszustaenden.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
disabled={initStatus === 'running' || selectedStates.length === 0}
|
||||||
|
onClick={() => handleInitialize(false)}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{initStatus === 'running' ? (
|
||||||
|
<>
|
||||||
|
<span className="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full" />
|
||||||
|
Analyse laeuft...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
Initialisieren
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={initStatus === 'running' || selectedStates.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
if (!confirm('Alle bestehenden Gefaehrdungen und Massnahmen loeschen und neu erstellen?')) return
|
||||||
|
handleInitialize(true)
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
Neu initialisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{initResult && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-green-800 dark:text-green-300">Initialisierung abgeschlossen</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{initResult.steps.map((s, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className={s.status === 'done' ? 'text-green-600' : s.status === 'skipped' ? 'text-gray-400' : 'text-red-500'}>
|
||||||
|
{s.status === 'done' ? '\u2713' : s.status === 'skipped' ? '\u25CB' : '\u2717'}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">{s.name}</span>
|
||||||
|
{s.count > 0 && <span className="text-gray-400">({s.count})</span>}
|
||||||
|
{s.details && <span className="text-gray-400 text-[10px]">— {s.details}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/sdk/iace/${projectId}/interview`)}
|
||||||
|
className="px-4 py-2 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
Zurueck zu Grenzen
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => saveSelection(selectedStates)}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<span className="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full" />
|
||||||
|
Speichern...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Speichern'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
saveSelection(selectedStates)
|
||||||
|
router.push(`/sdk/iace/${projectId}/components`)
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Weiter zu Komponenten
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sub-components ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function StateCard({
|
||||||
|
state,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
state: OperationalStateInfo
|
||||||
|
selected: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
}) {
|
||||||
|
const colors = STATE_COLORS[state.id] || STATE_COLORS.startup
|
||||||
|
const description = STATE_DESCRIPTIONS[state.id] || ''
|
||||||
|
const iconPath = STATE_ICONS[state.id] || STATE_ICONS.startup
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className={`relative text-left p-4 rounded-xl border-2 transition-all ${
|
||||||
|
selected
|
||||||
|
? `${colors.bg} ${colors.border} shadow-sm`
|
||||||
|
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={`w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
||||||
|
selected ? colors.bg : 'bg-gray-100 dark:bg-gray-700'
|
||||||
|
}`}>
|
||||||
|
<svg
|
||||||
|
className={`w-5 h-5 ${selected ? colors.text : 'text-gray-400'}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={iconPath} />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className={`text-sm font-medium ${
|
||||||
|
selected ? colors.text : 'text-gray-900 dark:text-white'
|
||||||
|
}`}>
|
||||||
|
{state.label_de}
|
||||||
|
</span>
|
||||||
|
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
||||||
|
selected
|
||||||
|
? 'bg-purple-600 border-purple-600'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'
|
||||||
|
}`}>
|
||||||
|
{selected && (
|
||||||
|
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-gray-400 uppercase tracking-wider">{state.label_en}</span>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 leading-relaxed">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeltaStat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
positive,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
positive: boolean
|
||||||
|
}) {
|
||||||
|
const color = value === 0
|
||||||
|
? 'text-gray-400'
|
||||||
|
: positive
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: 'text-red-600 dark:text-red-400'
|
||||||
|
return (
|
||||||
|
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<div className={`text-xl font-bold ${color}`}>
|
||||||
|
{value > 0 && positive ? '+' : ''}{value}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-gray-500 mt-0.5">{label}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+52
-1
@@ -119,10 +119,61 @@ export function ReportPrintView({ data }: ReportPrintViewProps) {
|
|||||||
Herstellers nach EU Maschinenverordnung 2023/1230 Art. 10.
|
Herstellers nach EU Maschinenverordnung 2023/1230 Art. 10.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2. Inhaltsverzeichnis */}
|
{/* 2. Methodik der Risikobeurteilung (Erklaerteil) */}
|
||||||
|
<div className="section-break">
|
||||||
|
<h2>Methodik der Risikobeurteilung</h2>
|
||||||
|
<p>
|
||||||
|
Diese Risikobeurteilung orientiert sich an den Grundprinzipien der EN ISO 12100,
|
||||||
|
EN 62061 und EN ISO 13849-1. Bewertet werden Grenzen des Produkts, identifizierte
|
||||||
|
Gefaehrdungen, die jeweilige Risikohoehe sowie das Restrisiko nach Anwendung von
|
||||||
|
Schutzmassnahmen.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Der Prozess ist iterativ: Reicht eine Massnahme nicht aus, werden weitere ergriffen
|
||||||
|
und das Restrisiko erneut bewertet, bis ein akzeptables Niveau erreicht ist.
|
||||||
|
</p>
|
||||||
|
<h3>Risikoberechnung</h3>
|
||||||
|
<p>Das Ausgangsrisiko ergibt sich aus: <strong>R = S × F × P × A</strong></p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Faktor</th><th>Beschreibung</th><th>Skala</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><strong>S</strong></td><td>Schadensschwere</td><td>1 (Erste Hilfe) – 5 (toedlich)</td></tr>
|
||||||
|
<tr><td><strong>F</strong></td><td>Expositionshaeufigkeit</td><td>1 (selten/kurz) – 5 (dauerhaft)</td></tr>
|
||||||
|
<tr><td><strong>P</strong></td><td>Eintrittswahrscheinlichkeit</td><td>1 (vernachlaessigbar) – 5 (fast sicher)</td></tr>
|
||||||
|
<tr><td><strong>A</strong></td><td>Vermeidbarkeit</td><td>1 (leicht vermeidbar) – 5 (unvermeidbar)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p>
|
||||||
|
Bei Sicherheitskreisen wird der Performance Level (PLr) ueber einen Risikographen
|
||||||
|
abgeleitet und dem Safety Integrity Level (SIL) zugeordnet.
|
||||||
|
</p>
|
||||||
|
<h3>Dreistufenmethode</h3>
|
||||||
|
<p>Schutzmassnahmen werden priorisiert angewandt:</p>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Konstruktive Massnahmen (KM)</strong> — Inhaerent sichere Gestaltung</li>
|
||||||
|
<li><strong>Technische Schutzmassnahmen (TM)</strong> — Schutzeinrichtungen, Sicherheitssteuerungen</li>
|
||||||
|
<li><strong>Benutzerinformationen (BI)</strong> — Warnhinweise, Betriebsanleitung</li>
|
||||||
|
</ol>
|
||||||
|
<h3>Akzeptanz des Restrisikos</h3>
|
||||||
|
<p>
|
||||||
|
Ein Restrisiko gilt als hinreichend gemindert, wenn alle praktisch umsetzbaren Massnahmen
|
||||||
|
ausgeschoepft wurden und Anwender ueber verbleibende Restrisiken informiert sind.
|
||||||
|
Die Akzeptanz wird pro Gefaehrdung mit <strong>JA</strong> / <strong>NEIN</strong> dokumentiert.
|
||||||
|
</p>
|
||||||
|
<p style={{ fontStyle: 'italic', fontSize: '9pt', color: '#374151' }}>
|
||||||
|
„Die Moeglichkeit, einen hoeheren Sicherheitsgrad zu erreichen, oder die Verfuegbarkeit
|
||||||
|
anderer Produkte, die ein geringeres Risiko darstellen, ist kein ausreichender Grund,
|
||||||
|
ein Produkt als gefaehrlich anzusehen.“ — § 3 Abs. 2 ProdSG
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Inhaltsverzeichnis */}
|
||||||
<div className="section-break">
|
<div className="section-break">
|
||||||
<h2>Inhaltsverzeichnis</h2>
|
<h2>Inhaltsverzeichnis</h2>
|
||||||
<ol className="toc">
|
<ol className="toc">
|
||||||
|
<li>Methodik der Risikobeurteilung</li>
|
||||||
<li>Maschinenbeschreibung</li>
|
<li>Maschinenbeschreibung</li>
|
||||||
<li>Angewandte Normen</li>
|
<li>Angewandte Normen</li>
|
||||||
<li>Gefaehrdungsliste</li>
|
<li>Gefaehrdungsliste</li>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const IACE_NAV_ITEMS = [
|
|||||||
{ id: 'overview', label: 'Uebersicht', href: '', icon: 'grid' },
|
{ id: 'overview', label: 'Uebersicht', href: '', icon: 'grid' },
|
||||||
{ id: 'order', label: 'Auftrag', href: '/order', icon: 'briefcase' },
|
{ id: 'order', label: 'Auftrag', href: '/order', icon: 'briefcase' },
|
||||||
{ id: 'interview', label: 'Grenzen & Verwendung', href: '/interview', icon: 'chat' },
|
{ id: 'interview', label: 'Grenzen & Verwendung', href: '/interview', icon: 'chat' },
|
||||||
|
{ id: 'operational-states', label: 'Betriebszustaende', href: '/operational-states', icon: 'activity' },
|
||||||
{ id: 'norms', label: 'Normenrecherche', href: '/norms', icon: 'book' },
|
{ id: 'norms', label: 'Normenrecherche', href: '/norms', icon: 'book' },
|
||||||
{ id: 'components', label: 'Komponenten', href: '/components', icon: 'cube' },
|
{ id: 'components', label: 'Komponenten', href: '/components', icon: 'cube' },
|
||||||
{ id: 'hazards', label: 'Hazard Log', href: '/hazards', icon: 'warning' },
|
{ id: 'hazards', label: 'Hazard Log', href: '/hazards', icon: 'warning' },
|
||||||
@@ -19,8 +20,11 @@ const IACE_NAV_ITEMS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const IACE_EXTRA_ITEMS = [
|
const IACE_EXTRA_ITEMS = [
|
||||||
|
{ id: 'fmea', label: 'FMEA', href: '/fmea', icon: 'grid' },
|
||||||
|
{ id: 'knowledge-graph', label: 'Knowledge Graph', href: '/knowledge-graph', icon: 'activity' },
|
||||||
{ id: 'classification', label: 'Klassifikation', href: '/classification', icon: 'tag' },
|
{ id: 'classification', label: 'Klassifikation', href: '/classification', icon: 'tag' },
|
||||||
{ id: 'monitoring', label: 'Monitoring', href: '/monitoring', icon: 'activity' },
|
{ id: 'monitoring', label: 'Monitoring', href: '/monitoring', icon: 'activity' },
|
||||||
|
{ id: 'benchmark', label: 'Benchmark', href: '/benchmark', icon: 'check' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function NavIcon({ icon, className }: { icon: string; className?: string }) {
|
function NavIcon({ icon, className }: { icon: string; className?: string }) {
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useMemo, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
type InformationAsset,
|
||||||
|
type AssetCategory,
|
||||||
|
type AssetClassification,
|
||||||
|
type ProtectionLevel,
|
||||||
|
ASSET_CATEGORY_LABELS,
|
||||||
|
CLASSIFICATION_LABELS,
|
||||||
|
PROTECTION_LABELS,
|
||||||
|
} from '../_types'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Local storage key (persisted in SDK state via JSONB)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'isms_assets'
|
||||||
|
|
||||||
|
function loadAssets(): InformationAsset[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
return raw ? JSON.parse(raw) : []
|
||||||
|
} catch { return [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAssets(assets: InformationAsset[]) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(assets))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Protection level colors
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const protectionColors: Record<ProtectionLevel, string> = {
|
||||||
|
normal: 'bg-green-100 text-green-800',
|
||||||
|
high: 'bg-amber-100 text-amber-800',
|
||||||
|
very_high: 'bg-red-100 text-red-800',
|
||||||
|
}
|
||||||
|
|
||||||
|
const classificationColors: Record<AssetClassification, string> = {
|
||||||
|
PUBLIC: 'bg-gray-100 text-gray-600',
|
||||||
|
INTERNAL: 'bg-blue-100 text-blue-700',
|
||||||
|
CONFIDENTIAL: 'bg-amber-100 text-amber-800',
|
||||||
|
STRICTLY_CONFIDENTIAL: 'bg-red-100 text-red-800',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function AssetsTab() {
|
||||||
|
const [assets, setAssets] = useState<InformationAsset[]>(() => loadAssets())
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [filterCategory, setFilterCategory] = useState<AssetCategory | 'ALL'>('ALL')
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [form, setForm] = useState<Partial<InformationAsset>>({
|
||||||
|
category: 'SOFTWARE',
|
||||||
|
classification: 'INTERNAL',
|
||||||
|
protectionNeed: { confidentiality: 'normal', integrity: 'normal', availability: 'normal' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (filterCategory === 'ALL') return assets
|
||||||
|
return assets.filter((a) => a.category === filterCategory)
|
||||||
|
}, [assets, filterCategory])
|
||||||
|
|
||||||
|
const stats = useMemo(() => ({
|
||||||
|
total: assets.length,
|
||||||
|
byCategory: Object.entries(ASSET_CATEGORY_LABELS).map(([cat, label]) => ({
|
||||||
|
category: cat,
|
||||||
|
label,
|
||||||
|
count: assets.filter((a) => a.category === cat).length,
|
||||||
|
})),
|
||||||
|
highProtection: assets.filter(
|
||||||
|
(a) =>
|
||||||
|
a.protectionNeed.confidentiality === 'very_high' ||
|
||||||
|
a.protectionNeed.integrity === 'very_high' ||
|
||||||
|
a.protectionNeed.availability === 'very_high'
|
||||||
|
).length,
|
||||||
|
}), [assets])
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
if (!form.name || !form.category || !form.owner) return
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const asset: InformationAsset = {
|
||||||
|
id: editingId || `asset_${Date.now()}`,
|
||||||
|
name: form.name || '',
|
||||||
|
category: form.category as AssetCategory,
|
||||||
|
description: form.description || '',
|
||||||
|
owner: form.owner || '',
|
||||||
|
location: form.location || '',
|
||||||
|
classification: form.classification as AssetClassification || 'INTERNAL',
|
||||||
|
protectionNeed: form.protectionNeed || { confidentiality: 'normal', integrity: 'normal', availability: 'normal' },
|
||||||
|
vendor: form.vendor,
|
||||||
|
notes: form.notes,
|
||||||
|
createdAt: editingId ? (assets.find((a) => a.id === editingId)?.createdAt || now) : now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = editingId
|
||||||
|
? assets.map((a) => (a.id === editingId ? asset : a))
|
||||||
|
: [...assets, asset]
|
||||||
|
|
||||||
|
setAssets(updated)
|
||||||
|
saveAssets(updated)
|
||||||
|
setShowForm(false)
|
||||||
|
setEditingId(null)
|
||||||
|
setForm({
|
||||||
|
category: 'SOFTWARE',
|
||||||
|
classification: 'INTERNAL',
|
||||||
|
protectionNeed: { confidentiality: 'normal', integrity: 'normal', availability: 'normal' },
|
||||||
|
})
|
||||||
|
}, [form, editingId, assets])
|
||||||
|
|
||||||
|
const handleDelete = useCallback((id: string) => {
|
||||||
|
const updated = assets.filter((a) => a.id !== id)
|
||||||
|
setAssets(updated)
|
||||||
|
saveAssets(updated)
|
||||||
|
}, [assets])
|
||||||
|
|
||||||
|
const handleEdit = useCallback((asset: InformationAsset) => {
|
||||||
|
setForm(asset)
|
||||||
|
setEditingId(asset.id)
|
||||||
|
setShowForm(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleExport = useCallback(() => {
|
||||||
|
const csv = [
|
||||||
|
['Name', 'Kategorie', 'Eigentuemer', 'Standort', 'Klassifizierung', 'C', 'I', 'A', 'Beschreibung'].join(';'),
|
||||||
|
...assets.map((a) =>
|
||||||
|
[a.name, ASSET_CATEGORY_LABELS[a.category], a.owner, a.location,
|
||||||
|
CLASSIFICATION_LABELS[a.classification],
|
||||||
|
PROTECTION_LABELS[a.protectionNeed.confidentiality],
|
||||||
|
PROTECTION_LABELS[a.protectionNeed.integrity],
|
||||||
|
PROTECTION_LABELS[a.protectionNeed.availability],
|
||||||
|
a.description].join(';')
|
||||||
|
),
|
||||||
|
].join('\n')
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `asset-register-${new Date().toISOString().slice(0, 10)}.csv`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}, [assets])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div className="text-xs text-gray-500 uppercase">Gesamt</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900 mt-1">{stats.total}</div>
|
||||||
|
</div>
|
||||||
|
{stats.byCategory.filter((s) => s.count > 0).slice(0, 2).map((s) => (
|
||||||
|
<div key={s.category} className="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div className="text-xs text-gray-500 uppercase">{s.label}</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900 mt-1">{s.count}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="bg-white rounded-xl border border-red-200 p-4">
|
||||||
|
<div className="text-xs text-red-600 uppercase">Sehr hoher Schutzbedarf</div>
|
||||||
|
<div className="text-2xl font-bold text-red-700 mt-1">{stats.highProtection}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['ALL', ...Object.keys(ASSET_CATEGORY_LABELS)] as const).map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
onClick={() => setFilterCategory(cat as AssetCategory | 'ALL')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
filterCategory === cat ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cat === 'ALL' ? 'Alle' : ASSET_CATEGORY_LABELS[cat as AssetCategory]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={handleExport} className="px-3 py-1.5 rounded-lg text-sm font-medium bg-gray-100 text-gray-600 hover:bg-gray-200">
|
||||||
|
CSV Export
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setShowForm(true); setEditingId(null) }} className="px-4 py-1.5 rounded-lg text-sm font-medium bg-purple-600 text-white hover:bg-purple-700">
|
||||||
|
+ Asset hinzufuegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="bg-white rounded-xl border border-purple-200 p-6 space-y-4">
|
||||||
|
<h3 className="font-semibold text-gray-900">{editingId ? 'Asset bearbeiten' : 'Neues Asset'}</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||||
|
<input value={form.name || ''} onChange={(e) => setForm({ ...form, name: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" placeholder="z.B. PostgreSQL Produktions-DB" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie *</label>
|
||||||
|
<select value={form.category || 'SOFTWARE'} onChange={(e) => setForm({ ...form, category: e.target.value as AssetCategory })} className="w-full border rounded-lg px-3 py-2 text-sm">
|
||||||
|
{Object.entries(ASSET_CATEGORY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Eigentuemer *</label>
|
||||||
|
<input value={form.owner || ''} onChange={(e) => setForm({ ...form, owner: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" placeholder="Person oder Abteilung" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Standort</label>
|
||||||
|
<input value={form.location || ''} onChange={(e) => setForm({ ...form, location: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" placeholder="z.B. Hetzner Cloud EU" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Klassifizierung</label>
|
||||||
|
<select value={form.classification || 'INTERNAL'} onChange={(e) => setForm({ ...form, classification: e.target.value as AssetClassification })} className="w-full border rounded-lg px-3 py-2 text-sm">
|
||||||
|
{Object.entries(CLASSIFICATION_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Vendor/Anbieter</label>
|
||||||
|
<input value={form.vendor || ''} onChange={(e) => setForm({ ...form, vendor: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Protection need */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">Schutzbedarf (CIA)</label>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{(['confidentiality', 'integrity', 'availability'] as const).map((dim) => (
|
||||||
|
<div key={dim}>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">
|
||||||
|
{dim === 'confidentiality' ? 'Vertraulichkeit' : dim === 'integrity' ? 'Integritaet' : 'Verfuegbarkeit'}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={form.protectionNeed?.[dim] || 'normal'}
|
||||||
|
onChange={(e) => setForm({ ...form, protectionNeed: { ...form.protectionNeed!, [dim]: e.target.value as ProtectionLevel } })}
|
||||||
|
className="w-full border rounded-lg px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{Object.entries(PROTECTION_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||||
|
<textarea value={form.description || ''} onChange={(e) => setForm({ ...form, description: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" rows={2} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button onClick={() => { setShowForm(false); setEditingId(null) }} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
|
||||||
|
<button onClick={handleSave} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-gray-500">Name</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-gray-500">Kategorie</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-gray-500">Eigentuemer</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-gray-500">Klassifizierung</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-gray-500">C</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-gray-500">I</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-gray-500">A</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-gray-500">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
||||||
|
Keine Assets erfasst. Klicken Sie auf "Asset hinzufuegen".
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filtered.map((a) => (
|
||||||
|
<tr key={a.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 font-medium text-gray-900">{a.name}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600">{ASSET_CATEGORY_LABELS[a.category]}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600">{a.owner}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${classificationColors[a.classification]}`}>
|
||||||
|
{CLASSIFICATION_LABELS[a.classification]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3"><span className={`px-2 py-0.5 rounded-full text-xs ${protectionColors[a.protectionNeed.confidentiality]}`}>{PROTECTION_LABELS[a.protectionNeed.confidentiality]}</span></td>
|
||||||
|
<td className="px-4 py-3"><span className={`px-2 py-0.5 rounded-full text-xs ${protectionColors[a.protectionNeed.integrity]}`}>{PROTECTION_LABELS[a.protectionNeed.integrity]}</span></td>
|
||||||
|
<td className="px-4 py-3"><span className={`px-2 py-0.5 rounded-full text-xs ${protectionColors[a.protectionNeed.availability]}`}>{PROTECTION_LABELS[a.protectionNeed.availability]}</span></td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => handleEdit(a)} className="text-xs text-blue-600 hover:text-blue-800">Bearbeiten</button>
|
||||||
|
<button onClick={() => handleDelete(a.id)} className="text-xs text-red-500 hover:text-red-700">Loeschen</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -211,6 +211,56 @@ export interface PotentialFinding {
|
|||||||
iso_reference: string
|
iso_reference: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TabId = 'overview' | 'policies' | 'soa' | 'objectives' | 'audits' | 'reviews'
|
export type TabId = 'overview' | 'policies' | 'soa' | 'objectives' | 'audits' | 'reviews' | 'assets'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ASSET REGISTER (ISO 27001 Annex A.5.9)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export type AssetCategory = 'HARDWARE' | 'SOFTWARE' | 'DATA' | 'SERVICE' | 'PEOPLE' | 'FACILITY'
|
||||||
|
export type AssetClassification = 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL' | 'STRICTLY_CONFIDENTIAL'
|
||||||
|
export type ProtectionLevel = 'normal' | 'high' | 'very_high'
|
||||||
|
|
||||||
|
export interface InformationAsset {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
category: AssetCategory
|
||||||
|
description: string
|
||||||
|
owner: string
|
||||||
|
location: string
|
||||||
|
classification: AssetClassification
|
||||||
|
protectionNeed: {
|
||||||
|
confidentiality: ProtectionLevel
|
||||||
|
integrity: ProtectionLevel
|
||||||
|
availability: ProtectionLevel
|
||||||
|
}
|
||||||
|
vendor?: string
|
||||||
|
relatedProcessingActivities?: string[]
|
||||||
|
notes?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ASSET_CATEGORY_LABELS: Record<AssetCategory, string> = {
|
||||||
|
HARDWARE: 'Hardware',
|
||||||
|
SOFTWARE: 'Software',
|
||||||
|
DATA: 'Daten',
|
||||||
|
SERVICE: 'Dienst/Cloud',
|
||||||
|
PEOPLE: 'Personen',
|
||||||
|
FACILITY: 'Standort/Raum',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CLASSIFICATION_LABELS: Record<AssetClassification, string> = {
|
||||||
|
PUBLIC: 'Oeffentlich',
|
||||||
|
INTERNAL: 'Intern',
|
||||||
|
CONFIDENTIAL: 'Vertraulich',
|
||||||
|
STRICTLY_CONFIDENTIAL: 'Streng Vertraulich',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROTECTION_LABELS: Record<ProtectionLevel, string> = {
|
||||||
|
normal: 'Normal',
|
||||||
|
high: 'Hoch',
|
||||||
|
very_high: 'Sehr hoch',
|
||||||
|
}
|
||||||
|
|
||||||
export const API = '/api/sdk/v1/isms'
|
export const API = '/api/sdk/v1/isms'
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user