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.
|
||||
consent-sdk/src/mobile/flutter/consent_sdk.dart
|
||||
consent-sdk/src/mobile/ios/ConsentManager.swift
|
||||
|
||||
# --- consent-tester: DSI discovery orchestrator ---
|
||||
# Single Playwright session with sequential steps (banner dismiss, self-extract,
|
||||
# link follow, accordion expand, inline sections). Splitting mid-session would
|
||||
# require passing Page objects across modules.
|
||||
consent-tester/services/dsi_discovery.py
|
||||
|
||||
# --- backend-compliance: unified compliance check orchestrator ---
|
||||
# Sequential 7-step pipeline (text resolve, profile detect, check documents,
|
||||
# banner scan, cross-check, profile extract, report). Phase 5 split target.
|
||||
backend-compliance/compliance/api/agent_compliance_check_routes.py
|
||||
|
||||
# --- docs-src: binary office files (not source code) ---
|
||||
# (Also excluded by extension in scripts/check-loc.sh — kept here for legibility.)
|
||||
docs-src/Breakpilot ComplAI Finanzplan.xlsm
|
||||
|
||||
# --- admin-compliance: oversized component refactor backlog ---
|
||||
# Phase 5+ target for splitting into smaller subcomponents per wizard step.
|
||||
admin-compliance/components/sdk/ai-act/DecisionTreeWizard.tsx
|
||||
|
||||
# --- ai-compliance-sdk: oversized handler refactor backlog ---
|
||||
# Phase 5+ target for splitting handler groups into per-resource files.
|
||||
ai-compliance-sdk/internal/api/handlers/tender_handlers.go
|
||||
|
||||
# --- merge grandfathered (2026-05-13) — Phase 5+ refactor backlog ---
|
||||
# Files imported via team work that crossed the hard cap; tracked for splitting.
|
||||
consent-tester/checks/banner_checks.py
|
||||
consent-tester/services/banner_detector.py
|
||||
backend-compliance/compliance/api/agent_doc_check_routes.py
|
||||
backend-compliance/compliance/services/service_registry.py
|
||||
backend-compliance/compliance/services/dsr_workflow_service.py
|
||||
ai-compliance-sdk/internal/iace/hazard_patterns_forestry_conveyor.go
|
||||
admin-compliance/app/sdk/compliance-scope/page.tsx
|
||||
|
||||
# --- zeroclaw: ground-truth corpus (test fixture data, not source) ---
|
||||
zeroclaw/docs/ground-truth/06-spiegel-dsi-fulltext.txt
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Build + push compliance service images to registry.meghsakha.com
|
||||
# and trigger orca redeploy on every push to main that touches a service.
|
||||
# and trigger orca redeploy after CI passes on main.
|
||||
#
|
||||
# This workflow is gated on the CI workflow completing successfully.
|
||||
# It does not run independently — if CI fails, builds + deploy are skipped.
|
||||
# Per-service builds are gated on detect-changes so only services with
|
||||
# modified files are rebuilt; trigger-orca runs only if at least one build
|
||||
# succeeded and none failed.
|
||||
#
|
||||
# Requires Gitea Actions secrets:
|
||||
# REGISTRY_USERNAME / REGISTRY_PASSWORD — registry.meghsakha.com credentials
|
||||
@@ -8,24 +14,68 @@
|
||||
name: Build + Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'admin-compliance/**'
|
||||
- 'backend-compliance/**'
|
||||
- 'ai-compliance-sdk/**'
|
||||
- 'developer-portal/**'
|
||||
- 'compliance-tts-service/**'
|
||||
- 'document-crawler/**'
|
||||
- 'dsms-gateway/**'
|
||||
- 'dsms-node/**'
|
||||
|
||||
jobs:
|
||||
# ── per-service builds run in parallel ────────────────────────────────────
|
||||
# ── gate: only proceed if CI succeeded ────────────────────────────────────
|
||||
ci-passed:
|
||||
runs-on: docker
|
||||
container: alpine:3.20
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
steps:
|
||||
- name: CI passed, proceeding with build + deploy
|
||||
run: echo "CI run ${{ github.event.workflow_run.id }} succeeded on ${{ github.event.workflow_run.head_branch }} @ ${{ github.event.workflow_run.head_sha }}"
|
||||
|
||||
# ── detect which services changed since the last successful build ────────
|
||||
# Diff base = the last-build/main git tag, set by mark-last-build at the
|
||||
# end of every successful run. Works across squash merges, multi-commit
|
||||
# raw pushes, and force pushes (force pushes leave a stale tag → diff
|
||||
# shows symmetric differences → safe over-rebuild). If the tag doesn't
|
||||
# exist yet, scripts/detect-changes.sh falls back to rebuilding all.
|
||||
detect-changes:
|
||||
runs-on: docker
|
||||
container: alpine:3.20
|
||||
needs: ci-passed
|
||||
outputs:
|
||||
admin: ${{ steps.diff.outputs.admin }}
|
||||
backend: ${{ steps.diff.outputs.backend }}
|
||||
sdk: ${{ steps.diff.outputs.sdk }}
|
||||
portal: ${{ steps.diff.outputs.portal }}
|
||||
tts: ${{ steps.diff.outputs.tts }}
|
||||
crawler: ${{ steps.diff.outputs.crawler }}
|
||||
dsms_gateway: ${{ steps.diff.outputs.dsms_gateway }}
|
||||
dsms_node: ${{ steps.diff.outputs.dsms_node }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git bash
|
||||
git clone --depth 200 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
git fetch --tags origin || true
|
||||
- name: Resolve base SHA from last-build/main tag
|
||||
run: |
|
||||
BASE=$(git rev-parse --verify refs/tags/last-build/main 2>/dev/null || true)
|
||||
echo "Base SHA: ${BASE:-<none, will rebuild all>}"
|
||||
# Deepen if base isn't yet in the shallow clone.
|
||||
if [ -n "$BASE" ] && ! git rev-parse --verify "${BASE}^{commit}" >/dev/null 2>&1; then
|
||||
git fetch --unshallow origin 2>/dev/null \
|
||||
|| git fetch --depth=10000 origin 2>/dev/null \
|
||||
|| true
|
||||
fi
|
||||
echo "BASE_SHA=${BASE}" >> "$GITHUB_ENV"
|
||||
- name: Detect changes
|
||||
id: diff
|
||||
run: bash scripts/detect-changes.sh
|
||||
|
||||
# ── per-service builds run in parallel (only changed services) ────────────
|
||||
|
||||
build-admin-compliance:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.admin == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -49,6 +99,8 @@ jobs:
|
||||
build-backend-compliance:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.backend == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -72,6 +124,8 @@ jobs:
|
||||
build-ai-sdk:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.sdk == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -95,6 +149,8 @@ jobs:
|
||||
build-developer-portal:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.portal == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -118,6 +174,8 @@ jobs:
|
||||
build-tts:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.tts == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -141,6 +199,8 @@ jobs:
|
||||
build-document-crawler:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.crawler == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -164,6 +224,8 @@ jobs:
|
||||
build-dsms-gateway:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.dsms_gateway == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -187,6 +249,8 @@ jobs:
|
||||
build-dsms-node:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.dsms_node == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -207,7 +271,52 @@ jobs:
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-dsms-node:latest
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-dsms-node:${SHORT_SHA}
|
||||
|
||||
# ── orca redeploy (only after all builds succeed) ─────────────────────────
|
||||
# ── advance the last-build/main tag — the diff base for future runs ──────
|
||||
# Runs when no build failed. Covers two cases:
|
||||
# - at least one service was rebuilt → mark this SHA as the new baseline
|
||||
# - all services were skipped (nothing changed) → still advance the tag
|
||||
# so we don't keep re-evaluating the same skipped commits forever
|
||||
# Skips if any build failed → tag stays put → next push retries those
|
||||
# services from the previous known-good base.
|
||||
mark-last-build:
|
||||
runs-on: docker
|
||||
container: alpine:3.20
|
||||
needs:
|
||||
- build-admin-compliance
|
||||
- build-backend-compliance
|
||||
- build-ai-sdk
|
||||
- build-developer-portal
|
||||
- build-tts
|
||||
- build-document-crawler
|
||||
- build-dsms-gateway
|
||||
- build-dsms-node
|
||||
if: |
|
||||
always() &&
|
||||
!contains(needs.*.result, 'failure') &&
|
||||
!contains(needs.*.result, 'cancelled')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Force-push last-build/main tag
|
||||
run: |
|
||||
set -e
|
||||
SHA="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
||||
echo "Advancing last-build/main → ${SHA}"
|
||||
git tag -f last-build/main "$SHA"
|
||||
# Encode token into the push URL (no on-disk credential persistence).
|
||||
PUSH_URL="${GITHUB_SERVER_URL/https:\/\//https:\/\/x-access-token:${GITEA_TOKEN}@}/${GITHUB_REPOSITORY}.git"
|
||||
git push --force "$PUSH_URL" "refs/tags/last-build/main"
|
||||
echo "Tag last-build/main now at ${SHA}"
|
||||
|
||||
# ── orca redeploy — runs only if at least one build succeeded ─────────────
|
||||
# `always()` lets this run when some builds are skipped (unchanged services).
|
||||
# The contains() checks ensure we only redeploy when something actually built
|
||||
# and no build failed.
|
||||
|
||||
trigger-orca:
|
||||
runs-on: docker
|
||||
@@ -221,6 +330,11 @@ jobs:
|
||||
- build-document-crawler
|
||||
- build-dsms-gateway
|
||||
- build-dsms-node
|
||||
if: |
|
||||
always() &&
|
||||
contains(needs.*.result, 'success') &&
|
||||
!contains(needs.*.result, 'failure') &&
|
||||
!contains(needs.*.result, 'cancelled')
|
||||
steps:
|
||||
- name: Checkout (for SHA)
|
||||
run: |
|
||||
|
||||
@@ -19,6 +19,49 @@ on:
|
||||
|
||||
jobs:
|
||||
|
||||
# ── Change detection (always runs first) ─────────────────────────────────
|
||||
# Diff base:
|
||||
# PR → merge-base with the PR base branch
|
||||
# push → last-build/main tag (set by build-push-deploy after a green build)
|
||||
# Falls back to "rebuild all" when the base is missing or unreachable.
|
||||
detect-changes:
|
||||
runs-on: docker
|
||||
container: alpine:3.20
|
||||
outputs:
|
||||
admin: ${{ steps.diff.outputs.admin }}
|
||||
backend: ${{ steps.diff.outputs.backend }}
|
||||
sdk: ${{ steps.diff.outputs.sdk }}
|
||||
portal: ${{ steps.diff.outputs.portal }}
|
||||
tts: ${{ steps.diff.outputs.tts }}
|
||||
crawler: ${{ steps.diff.outputs.crawler }}
|
||||
dsms_gateway: ${{ steps.diff.outputs.dsms_gateway }}
|
||||
dsms_node: ${{ steps.diff.outputs.dsms_node }}
|
||||
any_python: ${{ steps.diff.outputs.any_python }}
|
||||
any_node: ${{ steps.diff.outputs.any_node }}
|
||||
any: ${{ steps.diff.outputs.any }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git bash
|
||||
git clone --depth 200 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
|
||||
git fetch --depth 200 origin "${GITHUB_BASE_REF}" || true
|
||||
else
|
||||
git fetch --tags origin || true
|
||||
fi
|
||||
- name: Resolve base SHA
|
||||
run: |
|
||||
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
|
||||
BASE=$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD 2>/dev/null || true)
|
||||
else
|
||||
BASE=$(git rev-parse --verify refs/tags/last-build/main 2>/dev/null || true)
|
||||
fi
|
||||
echo "Base SHA: ${BASE:-<none>}"
|
||||
echo "BASE_SHA=${BASE}" >> "$GITHUB_ENV"
|
||||
- name: Detect changes
|
||||
id: diff
|
||||
run: bash scripts/detect-changes.sh
|
||||
|
||||
# ── Branch naming convention (PR only) ──────────────────────────────────
|
||||
branch-name:
|
||||
runs-on: docker
|
||||
@@ -55,10 +98,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── LOC budget (always) ──────────────────────────────────────────────────
|
||||
# ── LOC budget (only if files changed) ───────────────────────────────────
|
||||
loc-budget:
|
||||
runs-on: docker
|
||||
container: alpine:3.20
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.any == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -86,10 +131,11 @@ jobs:
|
||||
--redact \
|
||||
|| { echo "::error::Secrets detected — remove them before merging."; exit 1; }
|
||||
|
||||
# ── Go lint + build (PR only) ────────────────────────────────────────────
|
||||
# ── Go lint + build (PR only, gated on ai-compliance-sdk changes) ────────
|
||||
go-lint:
|
||||
runs-on: docker
|
||||
if: github.event_name == 'pull_request'
|
||||
needs: detect-changes
|
||||
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.sdk == 'true'
|
||||
container: golangci/golangci-lint:v1.62-alpine
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -107,10 +153,11 @@ jobs:
|
||||
cd ai-compliance-sdk
|
||||
go build ./...
|
||||
|
||||
# ── Python lint + import check (PR only) ────────────────────────────────
|
||||
# ── Python lint + import check (PR only, gated on python service changes) ─
|
||||
python-lint:
|
||||
runs-on: docker
|
||||
if: github.event_name == 'pull_request'
|
||||
needs: detect-changes
|
||||
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any_python == 'true'
|
||||
container: python:3.12-slim
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -137,10 +184,11 @@ jobs:
|
||||
python -c "import compliance; print('Import OK')" \
|
||||
|| { echo "::error::compliance package fails to import — missing import or syntax error."; exit 1; }
|
||||
|
||||
# ── Node.js lint + type-check (PR only) ─────────────────────────────────
|
||||
# ── Node.js lint + type-check (PR only, gated on Next.js service changes) ─
|
||||
nodejs-lint:
|
||||
runs-on: docker
|
||||
if: github.event_name == 'pull_request'
|
||||
needs: detect-changes
|
||||
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any_node == 'true'
|
||||
container: node:20-alpine
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -158,10 +206,12 @@ jobs:
|
||||
done
|
||||
exit $fail
|
||||
|
||||
# ── Node.js build — next build (PR + push to main) ───────────────────────
|
||||
# ── Node.js build — next build (gated on Next.js service changes) ───────
|
||||
nodejs-build:
|
||||
runs-on: docker
|
||||
container: node:20-alpine
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.any_node == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -244,10 +294,12 @@ jobs:
|
||||
- name: Vulnerability scan (fail on high+)
|
||||
run: grype sbom:sbom-out/sbom.cdx.json --fail-on high -q
|
||||
|
||||
# ── Tests (PR + push to main) ─────────────────────────────────────────────
|
||||
# ── Tests (gated per service) ────────────────────────────────────────────
|
||||
test-go:
|
||||
runs-on: docker
|
||||
container: golang:1.24-alpine
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.sdk == 'true'
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
steps:
|
||||
@@ -265,6 +317,8 @@ jobs:
|
||||
test-python-backend:
|
||||
runs-on: docker
|
||||
container: python:3.12-slim
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.backend == 'true'
|
||||
env:
|
||||
CI: "true"
|
||||
steps:
|
||||
@@ -284,6 +338,8 @@ jobs:
|
||||
test-python-document-crawler:
|
||||
runs-on: docker
|
||||
container: python:3.12-slim
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.crawler == 'true'
|
||||
env:
|
||||
CI: "true"
|
||||
steps:
|
||||
@@ -303,6 +359,8 @@ jobs:
|
||||
test-python-dsms-gateway:
|
||||
runs-on: docker
|
||||
container: python:3.12-slim
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.dsms_gateway == 'true'
|
||||
env:
|
||||
CI: "true"
|
||||
steps:
|
||||
|
||||
@@ -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',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
signal: AbortSignal.timeout(30000), // 30s — just needs to start the job
|
||||
signal: AbortSignal.timeout(300000), // 5 min — multi-page scan + LLM calls
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* PDF Export Proxy
|
||||
* POST /api/sdk/v1/agent/scans/pdf → backend /api/compliance/agent/scans/pdf
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.text()
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/scans/pdf`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: 'PDF generation failed' }, { status: response.status })
|
||||
}
|
||||
|
||||
const pdfBytes = await response.arrayBuffer()
|
||||
return new NextResponse(pdfBytes, {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': 'attachment; filename="compliance-report.pdf"',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('PDF proxy error:', error)
|
||||
return NextResponse.json({ error: 'PDF generation failed' }, { status: 503 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* DSMS Gateway Proxy — forwards verify/history requests to dsms-gateway.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const DSMS_URL = process.env.DSMS_GATEWAY_URL || 'http://dsms-gateway:8082'
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
const { path } = await params
|
||||
const target = `${DSMS_URL}/api/v1/${path.join('/')}`
|
||||
|
||||
try {
|
||||
const resp = await fetch(target, {
|
||||
headers: { Authorization: 'Bearer system-frontend' },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'DSMS not available' }, { status: 503 })
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
const [categories, setCategories] = useState<string[]>(['all'])
|
||||
const [useAgent, setUseAgent] = useState(false)
|
||||
const [mcResults, setMcResults] = useState<any>(null)
|
||||
const [history, setHistory] = useState<{ url: string; date: string; provider: string; violations: number; pct: number; resultKey: string }[]>(() => {
|
||||
if (typeof window === 'undefined') return []
|
||||
try { return JSON.parse(localStorage.getItem('banner-check-history') || '[]') } catch { return [] }
|
||||
@@ -97,6 +99,36 @@ export function BannerCheckTab() {
|
||||
setResult(data)
|
||||
localStorage.setItem('banner-check-result', JSON.stringify(data))
|
||||
|
||||
// If agent mode: also run cookie doc-check with 381 MCs
|
||||
if (useAgent) {
|
||||
setProgress('KI-Agent prueft Cookie-Richtlinie (381 MCs)...')
|
||||
try {
|
||||
const mcRes = await fetch('/api/sdk/v1/agent/doc-check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
entries: [{ doc_type: 'cookie', label: 'Cookie-Richtlinie', url: url.trim() }],
|
||||
recipient: 'dsb@breakpilot.local',
|
||||
use_agent: true,
|
||||
}),
|
||||
})
|
||||
if (mcRes.ok) {
|
||||
const { check_id } = await mcRes.json()
|
||||
if (check_id) {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
const poll = await fetch(`/api/sdk/v1/agent/doc-check?check_id=${check_id}`)
|
||||
if (!poll.ok) continue
|
||||
const pd = await poll.json()
|
||||
if (pd.progress) setProgress(`KI-Agent: ${pd.progress}`)
|
||||
if (pd.status === 'completed' && pd.result) { setMcResults(pd.result); break }
|
||||
if (pd.status === 'failed') break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* agent check is optional */ }
|
||||
}
|
||||
|
||||
// Add to history with persistent result
|
||||
const violations = data.structured_checks?.filter((c: CheckItem) => !c.passed && !c.skipped).length || 0
|
||||
const resultKey = `banner-check-result-${Date.now()}`
|
||||
@@ -162,6 +194,16 @@ export function BannerCheckTab() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" onClick={() => setUseAgent(!useAgent)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
|
||||
useAgent ? 'bg-emerald-100 border-emerald-300 text-emerald-800' : 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-gray-100'
|
||||
}`}>
|
||||
<span className={`w-2 h-2 rounded-full ${useAgent ? 'bg-emerald-500' : 'bg-gray-300'}`} />
|
||||
{useAgent ? 'KI-Agent aktiv (381 Cookie-MCs)' : 'KI-Agent aus'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleScan} className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
@@ -268,6 +310,14 @@ export function BannerCheckTab() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MC Agent Results (Cookie-Richtlinie) */}
|
||||
{mcResults?.results && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||
<h4 className="text-sm font-semibold text-gray-800 mb-3">KI-Agent: Cookie-Richtlinie (381 MCs)</h4>
|
||||
<ChecklistView results={mcResults.results} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!result.banner_detected && !hasStructured && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||
<p className="text-sm text-gray-500">
|
||||
|
||||
@@ -24,6 +24,13 @@ interface DocResult {
|
||||
checks: CheckItem[]
|
||||
findings_count: number
|
||||
error: string
|
||||
scenario?: string // regenerate | fix | import | skip
|
||||
}
|
||||
|
||||
const SCENARIO_LABELS: Record<string, { label: string; color: string; bg: string }> = {
|
||||
regenerate: { label: 'Neugenerierung', color: 'text-red-700', bg: 'bg-red-100' },
|
||||
fix: { label: 'Korrekturen', color: 'text-amber-700', bg: 'bg-amber-100' },
|
||||
import: { label: 'Konform', color: 'text-green-700', bg: 'bg-green-100' },
|
||||
}
|
||||
|
||||
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||
@@ -46,7 +53,7 @@ function groupChecks(checks: CheckItem[]): GroupedCheck[] {
|
||||
}))
|
||||
}
|
||||
|
||||
function CheckIcon({ passed, skipped }: { passed: boolean; skipped?: boolean }) {
|
||||
function CheckIcon({ passed, skipped, isInfo }: { passed: boolean; skipped?: boolean; isInfo?: boolean }) {
|
||||
if (skipped) {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-gray-300 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -61,6 +68,13 @@ function CheckIcon({ passed, skipped }: { passed: boolean; skipped?: boolean })
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
if (isInfo) {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-gray-400 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<svg className="w-4 h-4 text-red-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -84,14 +98,23 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
|
||||
if (!results || results.length === 0) return null
|
||||
|
||||
const totalOk = results.filter(r => r.completeness_pct === 100).length
|
||||
const scenarioCounts = {
|
||||
regenerate: results.filter(r => r.scenario === 'regenerate').length,
|
||||
fix: results.filter(r => r.scenario === 'fix').length,
|
||||
import: results.filter(r => r.scenario === 'import').length,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<h3 className="text-sm font-semibold text-gray-800">
|
||||
Dokumenten-Pruefung ({results.length} Dokumente, {totalOk} vollstaendig)
|
||||
Dokumenten-Pruefung ({results.length} Dokumente)
|
||||
</h3>
|
||||
<div className="flex gap-2 text-[10px]">
|
||||
{scenarioCounts.import > 0 && <span className="bg-green-100 text-green-700 px-2 py-0.5 rounded-full">{scenarioCounts.import} konform</span>}
|
||||
{scenarioCounts.fix > 0 && <span className="bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full">{scenarioCounts.fix} Korrekturen</span>}
|
||||
{scenarioCounts.regenerate > 0 && <span className="bg-red-100 text-red-700 px-2 py-0.5 rounded-full">{scenarioCounts.regenerate} Neugenerierung</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -104,8 +127,9 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
const typeLabel = DOC_TYPE_LABELS[r.doc_type] || r.doc_type
|
||||
const grouped = groupChecks(r.checks)
|
||||
const l1Checks = r.checks.filter(c => (c.level ?? 1) === 1)
|
||||
const l1Scoreable = l1Checks.filter(c => c.severity !== 'INFO')
|
||||
const l2Active = r.checks.filter(c => (c.level ?? 1) === 2 && !c.skipped)
|
||||
const l1Passed = l1Checks.filter(c => c.passed).length
|
||||
const l1Passed = l1Scoreable.filter(c => c.passed).length
|
||||
const l2Passed = l2Active.filter(c => c.passed).length
|
||||
|
||||
return (
|
||||
@@ -123,10 +147,17 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
{typeLabel}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">{r.label}</div>
|
||||
<div className="text-sm font-medium text-gray-900 truncate flex items-center gap-2">
|
||||
{r.label}
|
||||
{r.scenario && SCENARIO_LABELS[r.scenario] && (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${SCENARIO_LABELS[r.scenario].bg} ${SCENARIO_LABELS[r.scenario].color}`}>
|
||||
{SCENARIO_LABELS[r.scenario].label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
{l1Checks.length > 0
|
||||
? `${l1Passed}/${l1Checks.length} Pflichtangaben`
|
||||
? `${l1Passed}/${l1Scoreable.length} Pflichtangaben`
|
||||
+ (l2Active.length > 0 ? `, ${l2Passed}/${l2Active.length} Detailpruefungen` : '')
|
||||
: r.url}
|
||||
</div>
|
||||
@@ -137,8 +168,9 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
<span className="text-xs text-red-600 font-medium">Fehler</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-16 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div className="flex items-center gap-2" title={`Pflichtangaben: ${l1Passed}/${l1Scoreable.length}`}>
|
||||
<span className="text-[10px] text-gray-400 w-7">Pflicht</span>
|
||||
<div className="w-14 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className={`text-xs font-medium w-10 text-right ${
|
||||
@@ -146,8 +178,9 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
}`}>{pct}%</span>
|
||||
</div>
|
||||
{l2Active.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-16 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div className="flex items-center gap-2" title={`Detailpruefung: ${l2Passed}/${l2Active.length}`}>
|
||||
<span className="text-[10px] text-gray-400 w-7">Detail</span>
|
||||
<div className="w-14 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${cBarColor}`} style={{ width: `${cpct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs font-medium w-10 text-right text-blue-600">{cpct}%</span>
|
||||
@@ -164,13 +197,18 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
<p className="text-sm text-red-600">{r.error}</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{grouped.map((g) => (
|
||||
{grouped.map((g) => {
|
||||
const l1Info = g.check.severity === 'INFO' && !g.check.passed
|
||||
return (
|
||||
<div key={g.check.id}>
|
||||
{/* L1 check */}
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckIcon passed={g.check.passed} />
|
||||
<CheckIcon passed={g.check.passed} isInfo={l1Info} />
|
||||
<div className="flex-1">
|
||||
<div className={`text-sm ${g.check.passed ? 'text-gray-700' : 'text-red-700 font-medium'}`}>
|
||||
<div className={`text-sm ${
|
||||
g.check.passed ? 'text-gray-700'
|
||||
: l1Info ? 'text-gray-500' : 'text-red-700 font-medium'
|
||||
}`}>
|
||||
{g.check.label}
|
||||
{g.children.length > 0 && <L2Summary>{g.children}</L2Summary>}
|
||||
</div>
|
||||
@@ -180,7 +218,7 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
</div>
|
||||
)}
|
||||
{!g.check.passed && g.check.hint && (
|
||||
<div className="text-xs text-red-600/80 mt-0.5">
|
||||
<div className={`text-xs mt-0.5 ${l1Info ? 'text-gray-400' : 'text-red-600/80'}`}>
|
||||
{g.check.hint}
|
||||
</div>
|
||||
)}
|
||||
@@ -190,13 +228,16 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
{/* L2 children — always visible */}
|
||||
{g.children.length > 0 && (
|
||||
<div className="ml-6 mt-0.5 mb-1 space-y-0.5 border-l-2 border-gray-200 pl-3">
|
||||
{g.children.map((ch) => (
|
||||
{g.children.map((ch) => {
|
||||
const chInfo = ch.severity === 'INFO' && !ch.passed && !ch.skipped
|
||||
return (
|
||||
<div key={ch.id} className="flex items-start gap-2">
|
||||
<CheckIcon passed={ch.passed} skipped={ch.skipped} />
|
||||
<CheckIcon passed={ch.passed} skipped={ch.skipped} isInfo={chInfo} />
|
||||
<div className="flex-1">
|
||||
<div className={`text-xs ${
|
||||
ch.skipped ? 'text-gray-400 italic'
|
||||
: ch.passed ? 'text-gray-600' : 'text-red-600 font-medium'
|
||||
: ch.passed ? 'text-gray-600'
|
||||
: chInfo ? 'text-gray-400' : 'text-red-600 font-medium'
|
||||
}`}>
|
||||
{ch.label}
|
||||
{ch.skipped && ' (uebersprungen)'}
|
||||
@@ -207,17 +248,19 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
</div>
|
||||
)}
|
||||
{!ch.passed && !ch.skipped && ch.hint && (
|
||||
<div className="text-xs text-red-500/80 mt-0.5">
|
||||
<div className={`text-xs mt-0.5 ${chInfo ? 'text-gray-400' : 'text-red-500/80'}`}>
|
||||
{ch.hint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{r.word_count > 0 && (
|
||||
<div className="text-xs text-gray-400 mt-2 pt-2 border-t border-gray-200">
|
||||
{r.word_count} Woerter analysiert
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface SiteResult {
|
||||
url: string
|
||||
domain: string
|
||||
risk_level: string
|
||||
risk_score: number
|
||||
findings_count: number
|
||||
services_count: number
|
||||
has_impressum: boolean
|
||||
has_datenschutz: boolean
|
||||
has_cookie_banner: boolean
|
||||
has_google_fonts: boolean
|
||||
scan_status: string
|
||||
}
|
||||
|
||||
const RISK_COLOR: Record<string, string> = {
|
||||
MINIMAL: 'text-green-700 bg-green-50',
|
||||
LOW: 'text-yellow-700 bg-yellow-50',
|
||||
LIMITED: 'text-orange-700 bg-orange-50',
|
||||
HIGH: 'text-red-700 bg-red-50',
|
||||
UNACCEPTABLE: 'text-red-900 bg-red-100',
|
||||
}
|
||||
|
||||
export function CompareResult({ sites }: { sites: SiteResult[] }) {
|
||||
if (!sites.length) return null
|
||||
|
||||
const checks = [
|
||||
{ key: 'has_datenschutz', label: 'Datenschutzerklaerung' },
|
||||
{ key: 'has_impressum', label: 'Impressum' },
|
||||
{ key: 'has_cookie_banner', label: 'Cookie-Banner' },
|
||||
{ key: 'has_google_fonts', label: 'Google Fonts (lokal?)' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="text-left px-3 py-2 text-xs font-medium text-gray-500 w-44">Pruefung</th>
|
||||
{sites.map((s, i) => (
|
||||
<th key={i} className="text-center px-3 py-2 text-xs font-medium text-gray-700">
|
||||
{s.domain}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
<tr>
|
||||
<td className="px-3 py-2 text-gray-600">Risiko-Score</td>
|
||||
{sites.map((s, i) => (
|
||||
<td key={i} className="px-3 py-2 text-center">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${RISK_COLOR[s.risk_level] || 'text-gray-600 bg-gray-50'}`}>
|
||||
{s.risk_level || '?'} ({s.risk_score}/100)
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-3 py-2 text-gray-600">Findings</td>
|
||||
{sites.map((s, i) => (
|
||||
<td key={i} className={`px-3 py-2 text-center font-medium ${s.findings_count > 0 ? 'text-red-700' : 'text-green-700'}`}>
|
||||
{s.findings_count}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-3 py-2 text-gray-600">Dienste erkannt</td>
|
||||
{sites.map((s, i) => (
|
||||
<td key={i} className="px-3 py-2 text-center text-gray-700">{s.services_count}</td>
|
||||
))}
|
||||
</tr>
|
||||
{checks.map(check => (
|
||||
<tr key={check.key}>
|
||||
<td className="px-3 py-2 text-gray-600">{check.label}</td>
|
||||
{sites.map((s, i) => {
|
||||
const val = (s as any)[check.key]
|
||||
const isInverted = check.key === 'has_google_fonts'
|
||||
const good = isInverted ? !val : val
|
||||
return (
|
||||
<td key={i} className={`px-3 py-2 text-center font-medium ${good ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{good ? '✓' : '✗'}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { ChecklistView } from './ChecklistView'
|
||||
import { DocumentRow } from './DocumentRow'
|
||||
|
||||
const DOCUMENT_TYPES = [
|
||||
{ id: 'dse', label: 'DSI (Datenschutzinformation)', required: true },
|
||||
{ id: 'impressum', label: 'Impressum', required: true },
|
||||
{ id: 'social_media', label: 'Social Media DSE', required: false },
|
||||
{ id: 'cookie', label: 'Cookie-Richtlinie', required: false },
|
||||
{ id: 'agb', label: 'AGB', required: false },
|
||||
{ id: 'nutzungsbedingungen', label: 'Nutzungsbedingungen', required: false },
|
||||
{ id: 'widerruf', label: 'Widerrufsbelehrung', required: false },
|
||||
{ id: 'dsb', label: 'DSB-Kontakt', required: false },
|
||||
] as const
|
||||
|
||||
type DocTypeId = typeof DOCUMENT_TYPES[number]['id']
|
||||
|
||||
interface DocState {
|
||||
url: string
|
||||
text: string
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
type DocsState = Record<DocTypeId, DocState>
|
||||
|
||||
const STORAGE_KEY_STATE = 'compliance-check-state'
|
||||
const STORAGE_KEY_RESULTS = 'compliance-check-results'
|
||||
const STORAGE_KEY_HISTORY = 'compliance-check-history'
|
||||
const STORAGE_KEY_CHECK_ID = 'compliance-check-active-id'
|
||||
|
||||
function emptyDocState(): DocState {
|
||||
return { url: '', text: '', loading: false, error: null }
|
||||
}
|
||||
|
||||
function initState(): DocsState {
|
||||
if (typeof window === 'undefined') {
|
||||
return Object.fromEntries(DOCUMENT_TYPES.map(d => [d.id, emptyDocState()])) as DocsState
|
||||
}
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY_STATE)
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as Record<string, { url?: string; text?: string }>
|
||||
return Object.fromEntries(
|
||||
DOCUMENT_TYPES.map(d => [d.id, {
|
||||
url: parsed[d.id]?.url || '',
|
||||
text: parsed[d.id]?.text || '',
|
||||
loading: false,
|
||||
error: null,
|
||||
}])
|
||||
) as DocsState
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return Object.fromEntries(DOCUMENT_TYPES.map(d => [d.id, emptyDocState()])) as DocsState
|
||||
}
|
||||
|
||||
function countWords(text: string): number {
|
||||
if (!text.trim()) return 0
|
||||
return text.trim().split(/\s+/).length
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
date: string
|
||||
docCount: number
|
||||
findings: number
|
||||
resultKey: string
|
||||
}
|
||||
|
||||
export function ComplianceCheckTab() {
|
||||
const [docs, setDocs] = useState<DocsState>(initState)
|
||||
const [useAgent, setUseAgent] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [progress, setProgress] = useState('')
|
||||
const [results, setResults] = useState<any>(() => {
|
||||
if (typeof window === 'undefined') return null
|
||||
try { const s = localStorage.getItem(STORAGE_KEY_RESULTS); return s ? JSON.parse(s) : null } catch { return null }
|
||||
})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [activeCheckId, setActiveCheckId] = useState<string>(() =>
|
||||
typeof window !== 'undefined' ? localStorage.getItem(STORAGE_KEY_CHECK_ID) || '' : ''
|
||||
)
|
||||
const [history, setHistory] = useState<HistoryEntry[]>(() => {
|
||||
if (typeof window === 'undefined') return []
|
||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY_HISTORY) || '[]') } catch { return [] }
|
||||
})
|
||||
|
||||
// Persist URLs and texts (not loading/error state)
|
||||
React.useEffect(() => {
|
||||
const toSave: Record<string, { url: string; text: string }> = {}
|
||||
for (const [key, val] of Object.entries(docs)) {
|
||||
toSave[key] = { url: val.url, text: val.text }
|
||||
}
|
||||
try { localStorage.setItem(STORAGE_KEY_STATE, JSON.stringify(toSave)) } catch { /* quota */ }
|
||||
}, [docs])
|
||||
|
||||
// Resume polling if check was in progress when navigating away
|
||||
React.useEffect(() => {
|
||||
if (!activeCheckId || results) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setProgress('Pruefung laeuft noch...')
|
||||
const poll = async () => {
|
||||
while (!cancelled) {
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/agent/compliance-check?check_id=${activeCheckId}`)
|
||||
if (!res.ok) continue
|
||||
const data = await res.json()
|
||||
if (data.progress) setProgress(data.progress)
|
||||
if (data.status === 'completed' && data.result) {
|
||||
setResults(data.result); setProgress(''); setLoading(false)
|
||||
localStorage.setItem(STORAGE_KEY_RESULTS, JSON.stringify(data.result))
|
||||
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||
return
|
||||
}
|
||||
if (data.status === 'failed' || data.status === 'not_found') {
|
||||
if (data.status === 'failed') setError(data.error || 'Pruefung fehlgeschlagen')
|
||||
setProgress(''); setLoading(false)
|
||||
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||
return
|
||||
}
|
||||
} catch { /* retry */ }
|
||||
}
|
||||
}
|
||||
poll()
|
||||
return () => { cancelled = true }
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const updateDoc = useCallback((docType: DocTypeId, patch: Partial<DocState>) => {
|
||||
setDocs(prev => ({ ...prev, [docType]: { ...prev[docType], ...patch } }))
|
||||
}, [])
|
||||
|
||||
const handleFetchText = useCallback(async (docType: DocTypeId) => {
|
||||
const url = docs[docType].url.trim()
|
||||
if (!url) return
|
||||
|
||||
updateDoc(docType, { loading: true, error: null })
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/agent/extract-text', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const msg = res.status === 404
|
||||
? 'Seite nicht erreichbar'
|
||||
: `Fehler beim Laden (${res.status})`
|
||||
throw new Error(msg)
|
||||
}
|
||||
const data = await res.json()
|
||||
updateDoc(docType, { text: data.text || '', loading: false })
|
||||
} catch (e) {
|
||||
updateDoc(docType, {
|
||||
loading: false,
|
||||
error: e instanceof Error ? e.message : 'Text konnte nicht geladen werden',
|
||||
})
|
||||
}
|
||||
}, [docs, updateDoc])
|
||||
|
||||
const handleFileUpload = useCallback(async (docType: DocTypeId, file: File) => {
|
||||
// For now, read as text. PDF/DOCX parsing can be added server-side later.
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
updateDoc(docType, { text: reader.result as string })
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}, [updateDoc])
|
||||
|
||||
const filledCount = Object.values(docs).filter(d => d.url.trim() || d.text.trim()).length
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (filledCount === 0) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setResults(null)
|
||||
setProgress('Compliance-Check wird gestartet...')
|
||||
|
||||
try {
|
||||
const entries = DOCUMENT_TYPES
|
||||
.filter(dt => docs[dt.id].url.trim() || docs[dt.id].text.trim())
|
||||
.map(dt => ({
|
||||
doc_type: dt.id,
|
||||
label: dt.label,
|
||||
url: docs[dt.id].url.trim(),
|
||||
text: docs[dt.id].text.trim() || undefined,
|
||||
}))
|
||||
|
||||
const startRes = await fetch('/api/sdk/v1/agent/compliance-check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
documents: entries,
|
||||
use_agent: useAgent,
|
||||
}),
|
||||
})
|
||||
if (!startRes.ok) throw new Error(`Pruefung konnte nicht gestartet werden: ${startRes.status}`)
|
||||
const { check_id } = await startRes.json()
|
||||
if (!check_id) throw new Error('Keine Check-ID erhalten')
|
||||
setActiveCheckId(check_id)
|
||||
localStorage.setItem(STORAGE_KEY_CHECK_ID, check_id)
|
||||
|
||||
// Poll for results (max 25 min = 500 polls x 3s)
|
||||
let attempts = 0
|
||||
while (attempts < 500) {
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
const pollRes = await fetch(`/api/sdk/v1/agent/compliance-check?check_id=${check_id}`)
|
||||
if (!pollRes.ok) { attempts++; continue }
|
||||
const pollData = await pollRes.json()
|
||||
if (pollData.progress) setProgress(pollData.progress)
|
||||
if (pollData.status === 'completed' && pollData.result) {
|
||||
setResults(pollData.result)
|
||||
setProgress('')
|
||||
localStorage.setItem(STORAGE_KEY_RESULTS, JSON.stringify(pollData.result))
|
||||
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||
|
||||
const resultKey = `compliance-check-result-${Date.now()}`
|
||||
try { localStorage.setItem(resultKey, JSON.stringify(pollData.result)) } catch { /* quota */ }
|
||||
const entry: HistoryEntry = {
|
||||
date: new Date().toISOString(),
|
||||
docCount: entries.length,
|
||||
findings: pollData.result.total_findings || 0,
|
||||
resultKey,
|
||||
}
|
||||
const updated = [entry, ...history].slice(0, 30)
|
||||
setHistory(updated)
|
||||
localStorage.setItem(STORAGE_KEY_HISTORY, JSON.stringify(updated))
|
||||
break
|
||||
}
|
||||
if (pollData.status === 'failed') {
|
||||
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||
throw new Error(pollData.error || 'Pruefung fehlgeschlagen')
|
||||
}
|
||||
attempts++
|
||||
}
|
||||
if (attempts >= 500) {
|
||||
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||
throw new Error('Zeitlimit ueberschritten (15 Min)')
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||
setProgress('')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromHistory = (entry: HistoryEntry) => {
|
||||
if (entry.resultKey) {
|
||||
try {
|
||||
const saved = localStorage.getItem(entry.resultKey)
|
||||
if (saved) { setResults(JSON.parse(saved)); return }
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
try {
|
||||
const last = localStorage.getItem(STORAGE_KEY_RESULTS)
|
||||
if (last) setResults(JSON.parse(last))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Info box */}
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-purple-900">Compliance-Check (Alle Dokumente)</h3>
|
||||
<p className="text-xs text-purple-700 mt-1">
|
||||
Geben Sie die URLs Ihrer Rechtstexte ein oder laden Sie die Dokumente hoch.
|
||||
Das System prueft alle Pflichtangaben nach DSGVO, TDDDG, TMG und UWG.
|
||||
Pflichtdokumente sind mit * markiert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Document rows */}
|
||||
<div className="space-y-2">
|
||||
{DOCUMENT_TYPES.map(dt => (
|
||||
<DocumentRow
|
||||
key={dt.id}
|
||||
label={dt.label}
|
||||
docType={dt.id}
|
||||
required={dt.required}
|
||||
url={docs[dt.id].url}
|
||||
text={docs[dt.id].text}
|
||||
loading={docs[dt.id].loading}
|
||||
error={docs[dt.id].error}
|
||||
wordCount={countWords(docs[dt.id].text)}
|
||||
onUrlChange={url => updateDoc(dt.id, { url })}
|
||||
onFetchText={() => handleFetchText(dt.id)}
|
||||
onTextChange={text => updateDoc(dt.id, { text })}
|
||||
onFileUpload={file => handleFileUpload(dt.id, file)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Agent toggle + submit */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUseAgent(!useAgent)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
|
||||
useAgent
|
||||
? 'bg-emerald-100 border-emerald-300 text-emerald-800'
|
||||
: 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full ${useAgent ? 'bg-emerald-500' : 'bg-gray-300'}`} />
|
||||
{useAgent ? 'KI-Agent aktiv (alle MCs)' : 'KI-Agent aus'}
|
||||
</button>
|
||||
|
||||
<span className="text-xs text-gray-500">
|
||||
{filledCount} von {DOCUMENT_TYPES.length} Dokumenten ausgefuellt
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || filledCount === 0}
|
||||
className="w-full px-4 py-3 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50 transition-colors text-sm flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Pruefe...
|
||||
</>
|
||||
) : (
|
||||
`Compliance-Check starten (${filledCount} Dokument${filledCount !== 1 ? 'e' : ''})`
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Progress */}
|
||||
{progress && (
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm text-purple-700 flex items-center gap-3">
|
||||
<svg className="animate-spin w-4 h-4 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{progress}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{results && results.results && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||
{/* Business Profile */}
|
||||
{results.business_profile && (
|
||||
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-xs">
|
||||
<div className="font-semibold text-blue-900 mb-1">Erkanntes Geschaeftsmodell</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-blue-700">
|
||||
<span>Typ: <strong>{results.business_profile.business_type?.toUpperCase()}</strong></span>
|
||||
<span>Branche: {results.business_profile.industry}</span>
|
||||
{results.business_profile.has_online_shop && <span className="text-amber-700">Online-Shop</span>}
|
||||
{results.business_profile.is_regulated_profession && <span className="text-amber-700">Regulierter Beruf ({results.business_profile.regulated_profession_type})</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extracted Profile — pre-fill suggestion */}
|
||||
{results.extracted_profile?.company_profile && Object.keys(results.extracted_profile.company_profile).length > 0 && (
|
||||
<div className="mb-4 p-3 bg-emerald-50 border border-emerald-200 rounded-lg text-xs">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-semibold text-emerald-900">Aus Dokumenten extrahiert</span>
|
||||
<button className="text-emerald-700 hover:text-emerald-900 text-xs font-medium underline"
|
||||
onClick={() => { /* TODO: navigate to company profile with pre-fill */ }}>
|
||||
In Company Profile uebernehmen
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-emerald-700">
|
||||
{results.extracted_profile.company_profile.companyName && (
|
||||
<span>Firma: <strong>{results.extracted_profile.company_profile.companyName}</strong></span>
|
||||
)}
|
||||
{results.extracted_profile.company_profile.legalForm && (
|
||||
<span>Rechtsform: {results.extracted_profile.company_profile.legalForm.toUpperCase()}</span>
|
||||
)}
|
||||
{results.extracted_profile.company_profile.headquartersCity && (
|
||||
<span>Sitz: {results.extracted_profile.company_profile.headquartersZip} {results.extracted_profile.company_profile.headquartersCity}</span>
|
||||
)}
|
||||
{results.extracted_profile.company_profile.dpoEmail && (
|
||||
<span>DSB: {results.extracted_profile.company_profile.dpoEmail}</span>
|
||||
)}
|
||||
{results.extracted_profile.company_profile.ustIdNr && (
|
||||
<span>USt-IdNr: {results.extracted_profile.company_profile.ustIdNr}</span>
|
||||
)}
|
||||
</div>
|
||||
{results.extracted_profile.compliance_scope_hints?.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-emerald-200 text-emerald-600">
|
||||
<span className="font-medium">Scope-Hinweise: </span>
|
||||
{results.extracted_profile.compliance_scope_hints.map((h: any, i: number) => (
|
||||
<span key={i} className="inline-block bg-emerald-100 rounded px-1.5 py-0.5 mr-1 mb-1">
|
||||
{h.source}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Banner Check Result */}
|
||||
{results.banner_result && (
|
||||
<div className={`mb-4 p-3 rounded-lg border text-xs ${
|
||||
results.banner_result.violations > 0
|
||||
? 'bg-amber-50 border-amber-200'
|
||||
: results.banner_result.detected
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-gray-50 border-gray-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
results.banner_result.violations > 0 ? 'bg-amber-500'
|
||||
: results.banner_result.detected ? 'bg-green-500' : 'bg-gray-400'
|
||||
}`} />
|
||||
<span className="font-semibold text-gray-900">
|
||||
Cookie-Banner-Check (automatisch)
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-gray-600 ml-4">
|
||||
{results.banner_result.detected ? (
|
||||
<>
|
||||
Banner erkannt{results.banner_result.provider ? ` (${results.banner_result.provider})` : ''}.
|
||||
{results.banner_result.violations > 0
|
||||
? ` ${results.banner_result.violations} Auffaelligkeit${results.banner_result.violations !== 1 ? 'en' : ''} gefunden.`
|
||||
: ' Keine Auffaelligkeiten.'}
|
||||
</>
|
||||
) : (
|
||||
'Kein Cookie-Banner erkannt oder Banner-Check nicht moeglich.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChecklistView results={results.results} />
|
||||
|
||||
{/* Email status */}
|
||||
{results.email_status && (
|
||||
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${results.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
|
||||
E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
{history.length > 0 && (
|
||||
<div className="border border-gray-200 rounded-xl p-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Letzte Compliance-Checks</h4>
|
||||
<div className="space-y-1">
|
||||
{history.map((h, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => loadFromHistory(h)}
|
||||
className="w-full flex items-center justify-between text-sm py-2 px-2 rounded-lg border border-gray-50 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left"
|
||||
>
|
||||
<span className="text-gray-600">
|
||||
{new Date(h.date).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500">{h.docCount} Dok.</span>
|
||||
<span className={`text-xs font-medium ${h.findings > 0 ? 'text-amber-600' : 'text-green-600'}`}>
|
||||
{h.findings} Findings
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 [] }
|
||||
})
|
||||
|
||||
const [useAgent, setUseAgent] = useState(false)
|
||||
|
||||
React.useEffect(() => { localStorage.setItem('impressum-check-url', url) }, [url])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@@ -43,6 +45,7 @@ export function ImpressumCheckTab() {
|
||||
body: JSON.stringify({
|
||||
entries: [{ doc_type: 'impressum', label: 'Impressum', url: url.trim() }],
|
||||
recipient: 'dsb@breakpilot.local',
|
||||
use_agent: useAgent,
|
||||
}),
|
||||
})
|
||||
if (!startRes.ok) throw new Error(`Fehler: ${startRes.status}`)
|
||||
@@ -91,6 +94,16 @@ export function ImpressumCheckTab() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" onClick={() => setUseAgent(!useAgent)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
|
||||
useAgent ? 'bg-emerald-100 border-emerald-300 text-emerald-800' : 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-gray-100'
|
||||
}`}>
|
||||
<span className={`w-2 h-2 rounded-full ${useAgent ? 'bg-emerald-500' : 'bg-gray-300'}`} />
|
||||
{useAgent ? 'KI-Agent aktiv (75 MCs)' : 'KI-Agent aus'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex gap-3">
|
||||
<input type="url" value={url} onChange={e => setUrl(e.target.value)}
|
||||
placeholder="https://www.example.com/impressum"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { TextReference } from './TextReference'
|
||||
|
||||
interface ServiceInfo {
|
||||
name: string
|
||||
@@ -14,22 +15,27 @@ interface ServiceInfo {
|
||||
status: string
|
||||
}
|
||||
|
||||
interface TextRef {
|
||||
found: boolean
|
||||
source_url: string
|
||||
document_type: string
|
||||
section_heading: string
|
||||
section_number: string
|
||||
parent_section: string
|
||||
paragraph_index: number
|
||||
original_text: string
|
||||
issue: string
|
||||
correction_type: string
|
||||
correction_text: string
|
||||
insert_after: string
|
||||
}
|
||||
|
||||
interface ScanFinding {
|
||||
code: string
|
||||
severity: string
|
||||
text: string
|
||||
correction: string
|
||||
doc_title: string
|
||||
}
|
||||
|
||||
interface DiscoveredDocument {
|
||||
title: string
|
||||
url: string
|
||||
doc_type: string
|
||||
language: string
|
||||
word_count: number
|
||||
completeness_pct: number
|
||||
findings_count: number
|
||||
text_reference: TextRef | null
|
||||
}
|
||||
|
||||
interface ScanData {
|
||||
@@ -249,7 +255,12 @@ export function ScanResult({ data }: { data: ScanData }) {
|
||||
</span>
|
||||
<p className="text-sm text-gray-800 flex-1">{f.text}</p>
|
||||
</div>
|
||||
{f.correction && (
|
||||
{/* Text Reference (original text + position + correction) */}
|
||||
{f.text_reference && (
|
||||
<TextReference ref={f.text_reference} correction={f.correction} />
|
||||
)}
|
||||
{/* Fallback: correction without text reference */}
|
||||
{!f.text_reference && f.correction && (
|
||||
<div className="mt-2">
|
||||
<button onClick={() => setExpandedCorrection(isExp ? null : corrKey)}
|
||||
className="text-xs text-purple-600 hover:text-purple-800 font-medium">
|
||||
@@ -272,14 +283,35 @@ export function ScanResult({ data }: { data: ScanData }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Status */}
|
||||
{data.email_status && (
|
||||
<div className="text-xs text-gray-500 flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${data.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
|
||||
E-Mail: {data.email_status === 'sent' ? 'Gesendet' : data.email_status}
|
||||
</div>
|
||||
)}
|
||||
{/* PDF Export Button */}
|
||||
<div className="pt-4 border-t flex gap-3">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/agent/scans/pdf', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: '', scan_type: 'scan', analysis_mode: 'post_launch', result: data }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'compliance-report.pdf'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
} catch (e) { console.error('PDF export failed:', e) }
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-purple-700 bg-purple-50 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
PDF herunterladen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface TextRef {
|
||||
found: boolean
|
||||
source_url: string
|
||||
document_type: string
|
||||
section_heading: string
|
||||
section_number: string
|
||||
parent_section: string
|
||||
paragraph_index: number
|
||||
original_text: string
|
||||
issue: string
|
||||
correction_type: string
|
||||
correction_text: string
|
||||
insert_after: string
|
||||
}
|
||||
|
||||
const ISSUE_LABELS: Record<string, { label: string; color: string }> = {
|
||||
missing: { label: 'Fehlt in der DSE', color: 'text-red-700 bg-red-50' },
|
||||
incomplete: { label: 'Unvollstaendig', color: 'text-yellow-700 bg-yellow-50' },
|
||||
incorrect: { label: 'Fehlerhaft', color: 'text-orange-700 bg-orange-50' },
|
||||
}
|
||||
|
||||
const CORRECTION_LABELS: Record<string, string> = {
|
||||
insert: 'Neuen Abschnitt einfuegen',
|
||||
append: 'Am Ende des Absatzes ergaenzen',
|
||||
replace: 'Absatz ersetzen',
|
||||
}
|
||||
|
||||
export function TextReference({ ref, correction }: { ref: TextRef; correction?: string }) {
|
||||
const [showCorrection, setShowCorrection] = useState(false)
|
||||
const issue = ISSUE_LABELS[ref.issue] || null
|
||||
const correctionText = correction || ref.correction_text
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{/* Original Text Block */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1 flex items-center gap-1">
|
||||
<span>📄</span> Originaltextblock:
|
||||
</p>
|
||||
<div className={`rounded-lg p-3 border ${ref.found ? 'bg-gray-50 border-gray-200' : 'bg-red-50 border-red-200'}`}>
|
||||
{ref.found ? (
|
||||
<p className="text-gray-700 text-xs whitespace-pre-wrap">{ref.original_text || '(Textinhalt konnte nicht extrahiert werden)'}</p>
|
||||
) : (
|
||||
<p className="text-red-600 text-xs italic">Nicht vorhanden — Eintrag fehlt in der {ref.document_type}.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Position */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1 flex items-center gap-1">
|
||||
<span>📍</span> Position:
|
||||
</p>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-2 text-xs text-blue-800">
|
||||
{ref.found ? (
|
||||
<>
|
||||
<span className="font-semibold">{ref.section_heading || 'Abschnitt unbekannt'}</span>
|
||||
{ref.section_number && <span className="text-blue-600 ml-1">(Nr. {ref.section_number})</span>}
|
||||
{ref.parent_section && <span className="text-blue-500 ml-1">in: {ref.parent_section}</span>}
|
||||
{ref.paragraph_index > 0 && <span className="text-blue-500 ml-1">| Absatz {ref.paragraph_index}</span>}
|
||||
</>
|
||||
) : ref.insert_after ? (
|
||||
<span><strong>{CORRECTION_LABELS[ref.correction_type] || 'Einfuegen'}</strong> nach Abschnitt "{ref.insert_after}"</span>
|
||||
) : (
|
||||
<span>Neuen Abschnitt in der {ref.document_type} anlegen</span>
|
||||
)}
|
||||
{ref.source_url && (
|
||||
<div className="text-blue-400 mt-1 truncate">in: {ref.source_url}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Correction */}
|
||||
{correctionText && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowCorrection(!showCorrection)}
|
||||
className="text-xs text-purple-600 hover:text-purple-800 font-medium flex items-center gap-1"
|
||||
>
|
||||
<span>{showCorrection ? '▼' : '▶'}</span>
|
||||
<span>✏️</span> Korrekturvorschlag {showCorrection ? 'ausblenden' : 'anzeigen'}
|
||||
</button>
|
||||
{showCorrection && (
|
||||
<div className="mt-2 bg-white border border-purple-200 rounded-lg p-3 relative">
|
||||
{issue && (
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full font-medium mb-2 inline-block ${issue.color}`}>
|
||||
{CORRECTION_LABELS[ref.correction_type] || issue.label}
|
||||
</span>
|
||||
)}
|
||||
<pre className="text-xs text-gray-700 whitespace-pre-wrap font-sans mt-1">{correctionText}</pre>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(correctionText)}
|
||||
className="absolute top-2 right-2 text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded transition-colors"
|
||||
title="In Zwischenablage kopieren"
|
||||
>
|
||||
📋 Kopieren
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,23 +2,21 @@
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { ScanResult } from './_components/ScanResult'
|
||||
import { DocCheckTab } from './_components/DocCheckTab'
|
||||
import { ComplianceCheckTab } from './_components/ComplianceCheckTab'
|
||||
import { BannerCheckTab } from './_components/BannerCheckTab'
|
||||
import { ImpressumCheckTab } from './_components/ImpressumCheckTab'
|
||||
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 }[] = [
|
||||
{ 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: 'impressum-check', label: 'Impressum-Check', desc: 'Impressum auf §5 TMG Pflichtangaben pruefen' },
|
||||
]
|
||||
|
||||
export default function AgentPage() {
|
||||
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 [scanError, setScanError] = useState<string | null>(null)
|
||||
const [scanData, setScanData] = useState<any>(() => {
|
||||
@@ -50,24 +48,17 @@ export default function AgentPage() {
|
||||
const data = await res.json()
|
||||
if (data.progress) setScanProgress(data.progress)
|
||||
if (data.status === 'completed' && data.result) {
|
||||
setScanData(data.result)
|
||||
setScanProgress('')
|
||||
setScanLoading(false)
|
||||
setScanData(data.result); setScanProgress(''); setScanLoading(false)
|
||||
localStorage.setItem('agent-scan-result', JSON.stringify(data.result))
|
||||
localStorage.removeItem('agent-scan-id')
|
||||
setActiveScanId('')
|
||||
_addToHistory(data.result)
|
||||
return
|
||||
localStorage.removeItem('agent-scan-id'); setActiveScanId('')
|
||||
_addToHistory(data.result); return
|
||||
}
|
||||
if (data.status === 'failed' || data.status === 'not_found') {
|
||||
if (data.status === 'failed') setScanError(data.error || 'Scan fehlgeschlagen')
|
||||
setScanProgress('')
|
||||
setScanLoading(false)
|
||||
localStorage.removeItem('agent-scan-id')
|
||||
setActiveScanId('')
|
||||
return
|
||||
setScanProgress(''); setScanLoading(false)
|
||||
localStorage.removeItem('agent-scan-id'); setActiveScanId(''); return
|
||||
}
|
||||
} catch { /* retry */ }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
poll()
|
||||
@@ -77,37 +68,21 @@ export default function AgentPage() {
|
||||
const _addToHistory = (result: any) => {
|
||||
const resultKey = `scan-result-${Date.now()}`
|
||||
try { localStorage.setItem(resultKey, JSON.stringify(result)) } catch {}
|
||||
const entry = {
|
||||
url: url || result.url || '',
|
||||
date: new Date().toISOString(),
|
||||
findings: result.findings?.length || 0,
|
||||
docs: result.discovered_documents?.length || 0,
|
||||
resultKey,
|
||||
}
|
||||
const entry = { url: url || result.url || '', date: new Date().toISOString(), findings: result.findings?.length || 0, docs: result.discovered_documents?.length || 0, resultKey }
|
||||
const updated = [entry, ...scanHistory].slice(0, 30)
|
||||
setScanHistory(updated)
|
||||
localStorage.setItem('agent-scan-history', JSON.stringify(updated))
|
||||
setScanHistory(updated); localStorage.setItem('agent-scan-history', JSON.stringify(updated))
|
||||
}
|
||||
|
||||
const handleScan = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!url.trim()) return
|
||||
setScanLoading(true)
|
||||
setScanError(null)
|
||||
setScanData(null)
|
||||
setScanProgress('Scan wird gestartet...')
|
||||
setScanLoading(true); setScanError(null); setScanData(null); setScanProgress('Scan wird gestartet...')
|
||||
try {
|
||||
const startRes = await fetch('/api/sdk/v1/agent/scan', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: url.trim(), mode: 'post_launch' }),
|
||||
})
|
||||
const startRes = await fetch('/api/sdk/v1/agent/scan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: url.trim(), mode: 'post_launch' }) })
|
||||
if (!startRes.ok) throw new Error(`Scan konnte nicht gestartet werden: ${startRes.status}`)
|
||||
const { scan_id } = await startRes.json()
|
||||
if (!scan_id) throw new Error('Keine Scan-ID erhalten')
|
||||
setActiveScanId(scan_id)
|
||||
localStorage.setItem('agent-scan-id', scan_id)
|
||||
|
||||
setActiveScanId(scan_id); localStorage.setItem('agent-scan-id', scan_id)
|
||||
let attempts = 0
|
||||
while (attempts < 120) {
|
||||
await new Promise(r => setTimeout(r, 5000))
|
||||
@@ -116,41 +91,24 @@ export default function AgentPage() {
|
||||
const pollData = await pollRes.json()
|
||||
if (pollData.progress) setScanProgress(pollData.progress)
|
||||
if (pollData.status === 'completed' && pollData.result) {
|
||||
setScanData(pollData.result)
|
||||
setScanProgress('')
|
||||
setScanData(pollData.result); setScanProgress('')
|
||||
localStorage.setItem('agent-scan-result', JSON.stringify(pollData.result))
|
||||
localStorage.removeItem('agent-scan-id')
|
||||
setActiveScanId('')
|
||||
_addToHistory(pollData.result)
|
||||
break
|
||||
localStorage.removeItem('agent-scan-id'); setActiveScanId(''); _addToHistory(pollData.result); break
|
||||
}
|
||||
if (pollData.status === 'failed') throw new Error(pollData.error || 'Scan fehlgeschlagen')
|
||||
attempts++
|
||||
}
|
||||
if (attempts >= 120) throw new Error('Scan-Timeout (10 Minuten)')
|
||||
} catch (e) {
|
||||
setScanError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||
setScanProgress('')
|
||||
} finally {
|
||||
setScanLoading(false)
|
||||
}
|
||||
} catch (e) { setScanError(e instanceof Error ? e.message : 'Unbekannter Fehler'); setScanProgress('') }
|
||||
finally { setScanLoading(false) }
|
||||
}
|
||||
|
||||
// Navigate to a specialized tab with a pre-filled URL
|
||||
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',
|
||||
}
|
||||
if (keyMap[targetTab]) {
|
||||
localStorage.setItem(keyMap[targetTab], checkUrl)
|
||||
}
|
||||
const keyMap: Record<string, string> = { 'doc-check': 'doc-check-prefill-url', 'banner-check': 'banner-check-url', 'impressum-check': 'impressum-check-url' }
|
||||
if (keyMap[targetTab]) localStorage.setItem(keyMap[targetTab], checkUrl)
|
||||
setTab(targetTab)
|
||||
}
|
||||
|
||||
// Extract discovered documents for quick-action buttons
|
||||
const discoveredDocs = scanData?.discovered_documents || []
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Tab Selection */}
|
||||
<div className="flex border-b border-gray-200 overflow-x-auto">
|
||||
{TABS.map(t => (
|
||||
<button key={t.id} onClick={() => setTab(t.id)}
|
||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
tab === t.id
|
||||
? 'border-purple-500 text-purple-700'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
||||
tab === t.id ? 'border-purple-500 text-purple-700' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Website-Scan Tab */}
|
||||
{tab === 'scan' && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-indigo-900">Website-Scan (Discovery)</h3>
|
||||
<p className="text-xs text-indigo-700 mt-1">
|
||||
Findet alle rechtlichen Dokumente (DSI, AGB, Impressum, Cookie, Widerruf),
|
||||
erkennt eingesetzte Drittdienste und prueft ob sie in der DSE dokumentiert sind.
|
||||
</p>
|
||||
<p className="text-xs text-indigo-700 mt-1">Findet alle rechtlichen Dokumente (DSI, AGB, Impressum, Cookie, Widerruf), erkennt eingesetzte Drittdienste und prueft ob sie in der DSE dokumentiert sind.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleScan} className="flex gap-3">
|
||||
<input type="url" value={url} onChange={e => setUrl(e.target.value)}
|
||||
placeholder="https://www.example.com/"
|
||||
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm"
|
||||
disabled={scanLoading} required />
|
||||
<input type="url" value={url} onChange={e => setUrl(e.target.value)} placeholder="https://www.example.com/"
|
||||
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm" disabled={scanLoading} required />
|
||||
<button type="submit" disabled={scanLoading || !url.trim()}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center gap-2 text-sm font-medium whitespace-nowrap">
|
||||
{scanLoading ? (
|
||||
<><svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>Scanne...</>
|
||||
) : 'Website scannen'}
|
||||
{scanLoading ? (<><svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>Scanne...</>) : 'Website scannen'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{scanProgress && (
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 text-sm text-purple-700 flex items-center gap-3">
|
||||
<svg className="animate-spin w-5 h-5 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{scanProgress}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scanError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{scanError}</div>
|
||||
)}
|
||||
|
||||
{/* Quick Action Buttons — navigate to specialized tabs */}
|
||||
{scanProgress && <div className="bg-purple-50 border border-purple-200 rounded-lg p-4 text-sm text-purple-700 flex items-center gap-3"><svg className="animate-spin w-5 h-5 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>{scanProgress}</div>}
|
||||
{scanError && <div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{scanError}</div>}
|
||||
{scanData && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4 shadow-sm">
|
||||
<h4 className="text-sm font-semibold text-gray-800 mb-3">Jetzt pruefen</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button onClick={() => navigateToCheck('banner-check', scannedUrl)}
|
||||
className="p-3 rounded-lg border border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition-all text-left">
|
||||
<button onClick={() => navigateToCheck('banner-check', scannedUrl)} className="p-3 rounded-lg border border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition-all text-left">
|
||||
<div className="text-sm font-medium text-gray-900">Cookie-Banner pruefen</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">3-Phasen Dark-Pattern-Analyse</div>
|
||||
</button>
|
||||
<button onClick={() => navigateToCheck('impressum-check', scannedUrl + '/impressum')}
|
||||
className="p-3 rounded-lg border border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition-all text-left">
|
||||
<button onClick={() => navigateToCheck('impressum-check', scannedUrl + '/impressum')} className="p-3 rounded-lg border border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition-all text-left">
|
||||
<div className="text-sm font-medium text-gray-900">Impressum pruefen</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">§5 TMG Pflichtangaben</div>
|
||||
</button>
|
||||
{discoveredDocs.map((doc: any, i: number) => (
|
||||
<button key={i} onClick={() => navigateToCheck('doc-check', doc.url)}
|
||||
className="p-3 rounded-lg border border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition-all text-left">
|
||||
<button key={i} onClick={() => navigateToCheck('doc-check', doc.url)} className="p-3 rounded-lg border border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition-all text-left">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">{doc.title || doc.url}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
{doc.doc_type?.toUpperCase()} · {doc.word_count || '?'} Woerter
|
||||
{doc.completeness_pct != null && ` · ${doc.completeness_pct}%`}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{doc.doc_type?.toUpperCase()} · {doc.word_count || '?'} Woerter{doc.completeness_pct != null && ` · ${doc.completeness_pct}%`}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 */}
|
||||
{scanData?.services && <div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm"><ScanResult data={scanData} /></div>}
|
||||
{scanHistory.length > 0 && (
|
||||
<div className="border border-gray-200 rounded-xl p-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Letzte Scans</h4>
|
||||
<div className="space-y-2">
|
||||
{scanHistory.map((h, i) => (
|
||||
<button key={i} onClick={() => {
|
||||
setUrl(h.url)
|
||||
if (h.resultKey) {
|
||||
try { const s = localStorage.getItem(h.resultKey); if (s) { setScanData(JSON.parse(s)); return } } catch {}
|
||||
}
|
||||
try { const l = localStorage.getItem('agent-scan-result'); if (l) setScanData(JSON.parse(l)) } catch {}
|
||||
}}
|
||||
<button key={i} onClick={() => { setUrl(h.url); if (h.resultKey) { try { const s = localStorage.getItem(h.resultKey); if (s) { setScanData(JSON.parse(s)); return } } catch {} } }}
|
||||
className="w-full flex items-center justify-between p-3 rounded-lg border border-gray-100 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">{h.url}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date(h.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0 ml-3">
|
||||
{h.docs > 0 && <span className="text-xs text-purple-600">{h.docs} Dok.</span>}
|
||||
<span className={`text-xs font-medium ${h.findings > 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{h.findings} Findings
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1"><div className="text-sm font-medium text-gray-900 truncate">{h.url}</div><div className="text-xs text-gray-500">{new Date(h.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}</div></div>
|
||||
<div className="flex items-center gap-3 shrink-0 ml-3">{h.docs > 0 && <span className="text-xs text-purple-600">{h.docs} Dok.</span>}<span className={`text-xs font-medium ${h.findings > 0 ? 'text-red-600' : 'text-green-600'}`}>{h.findings} Findings</span></div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -285,12 +184,9 @@ export default function AgentPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Specialized Tabs */}
|
||||
{tab === 'doc-check' && <DocCheckTab />}
|
||||
{tab === 'compliance-check' && <ComplianceCheckTab />}
|
||||
{tab === 'banner-check' && <BannerCheckTab />}
|
||||
{tab === 'impressum-check' && <ImpressumCheckTab />}
|
||||
|
||||
{/* FAQ */}
|
||||
<ComplianceFAQ />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export interface AuditEntry {
|
||||
id: string
|
||||
entity_type: string
|
||||
entity_id: string
|
||||
entity_name: string
|
||||
action: string
|
||||
field_changed: string | null
|
||||
old_value: string | null
|
||||
new_value: string | null
|
||||
change_summary: string | null
|
||||
performed_by: string
|
||||
performed_at: string
|
||||
}
|
||||
|
||||
export function useAuditTimeline() {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
|
||||
useEffect(() => {
|
||||
loadEntries()
|
||||
}, [filter]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function loadEntries() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: '100' })
|
||||
if (filter !== 'all') params.set('entity_type', filter)
|
||||
const res = await fetch(`/api/sdk/v1/compliance/audit-trail?${params}`)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setEntries(json.entries || json.audit_trail || json || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load audit trail:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, loading, filter, setFilter }
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
|
||||
import { useAuditTimeline, type AuditEntry } from './_hooks/useAuditTimeline'
|
||||
|
||||
const ENTITY_LABELS: Record<string, string> = {
|
||||
evidence: 'Nachweis', control: 'Control', document: 'Dokument',
|
||||
dsfa: 'DSFA', vvt: 'VVT', tom: 'TOM', policy: 'Richtlinie',
|
||||
dsms_archive: 'DSMS-Archiv', risk: 'Risiko',
|
||||
}
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
create: 'bg-green-500', update: 'bg-blue-500', delete: 'bg-red-500',
|
||||
approve: 'bg-purple-500', archive: 'bg-emerald-500', review: 'bg-yellow-500',
|
||||
sign: 'bg-indigo-500', reject: 'bg-red-400',
|
||||
}
|
||||
|
||||
const FILTER_OPTIONS = ['all', 'evidence', 'dsms_archive', 'control', 'document', 'dsfa', 'vvt', 'tom']
|
||||
|
||||
export default function AuditTimelinePage() {
|
||||
const { entries, loading, filter, setFilter } = useAuditTimeline()
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Audit Timeline</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Chronologische Compliance-Historie mit DSMS-Nachweisen</p>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{FILTER_OPTIONS.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' : ENTITY_LABELS[f] || f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-500">
|
||||
Keine Eintraege gefunden. Compliance-Aktionen werden automatisch protokolliert.
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-6 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{entries.map((entry) => (
|
||||
<TimelineEntry key={entry.id} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineEntry({ entry }: { entry: AuditEntry }) {
|
||||
const dotColor = ACTION_COLORS[entry.action] || 'bg-gray-400'
|
||||
const isCID = entry.field_changed === 'dsms_cid' || entry.action === 'archive'
|
||||
const date = new Date(entry.performed_at)
|
||||
|
||||
return (
|
||||
<div className="relative flex gap-4 pl-3">
|
||||
{/* Dot */}
|
||||
<div className={`relative z-10 w-3 h-3 rounded-full mt-1.5 flex-shrink-0 ring-4 ring-white dark:ring-gray-900 ${dotColor}`} />
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 min-w-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">{entry.entity_name}</span>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
{ENTITY_LABELS[entry.entity_type] || entry.entity_type}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-medium text-white ${dotColor}`}>
|
||||
{entry.action}
|
||||
</span>
|
||||
</div>
|
||||
{entry.change_summary && (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">{entry.change_summary}</p>
|
||||
)}
|
||||
{isCID && entry.new_value && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<svg className="w-3.5 h-3.5 text-emerald-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<code className="text-[10px] bg-emerald-50 text-emerald-700 px-2 py-0.5 rounded font-mono dark:bg-emerald-900/30 dark:text-emerald-300">
|
||||
{entry.new_value.length > 20 ? entry.new_value.slice(0, 8) + '...' + entry.new_value.slice(-6) : entry.new_value}
|
||||
</code>
|
||||
<span className="text-[10px] text-emerald-500">DSMS/IPFS</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<div className="text-xs text-gray-400">{date.toLocaleDateString('de-DE')}</div>
|
||||
<div className="text-[10px] text-gray-300">{date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</div>
|
||||
<div className="text-[10px] text-gray-300 mt-0.5">{entry.performed_by}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -54,41 +54,27 @@ export default function CMPDashboardPage() {
|
||||
const [consentStats, setConsentStats] = useState<ConsentStats | null>(null)
|
||||
const [dsrStats, setDSRStats] = useState<DSRStats | null>(null)
|
||||
const [sites, setSites] = useState<any[]>([])
|
||||
const [selectedSite, setSelectedSite] = useState<string>('')
|
||||
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(() => {
|
||||
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 [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('dsr/stats'),
|
||||
fb('admin/sites'),
|
||||
])
|
||||
setBannerStats(banner)
|
||||
setConsentStats(consent)
|
||||
setDSRStats(dsr)
|
||||
const loadedSites = Array.isArray(siteList) ? siteList : []
|
||||
setSites(loadedSites)
|
||||
// Auto-select first site
|
||||
if (loadedSites.length > 0) {
|
||||
setSelectedSite(loadedSites[0].site_id || loadedSites[0].siteId || '')
|
||||
}
|
||||
setSites(siteList || [])
|
||||
setLoading(false)
|
||||
}
|
||||
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 dsrOpen = dsrStats ? (dsrStats.by_status?.intake || 0) + (dsrStats.by_status?.processing || 0) + (dsrStats.by_status?.identity_verification || 0) : 0
|
||||
const dsrOverdue = dsrStats?.overdue || 0
|
||||
@@ -100,27 +86,12 @@ export default function CMPDashboardPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
</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"
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 transition-colors">
|
||||
Banner testen
|
||||
</Link>
|
||||
<p className="text-gray-500 mt-1">Ueberblick ueber Einwilligungen, Betroffenenrechte und Vendor-Compliance</p>
|
||||
</div>
|
||||
<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">
|
||||
Banner testen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
@@ -203,6 +174,44 @@ export default function CMPDashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Banner-Bedarf Hinweis (TTDSG § 25) */}
|
||||
{bannerStats && Object.keys(bannerStats.category_acceptance).length === 0 && sites.length === 0 && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-5 flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-green-800">Kein Cookie-Banner erforderlich</h3>
|
||||
<p className="text-sm text-green-700 mt-1">
|
||||
Es wurden keine Cookies, Tracker oder Analytics-Dienste erkannt. Gemaess TTDSG § 25 ist kein
|
||||
Cookie-Banner erforderlich, da keine Informationen auf dem Endgeraet gespeichert werden.
|
||||
</p>
|
||||
<p className="text-xs text-green-600 mt-2">
|
||||
<strong>Weiterhin Pflicht:</strong> Impressum (DDG § 5) und Datenschutzerklaerung (DSGVO Art. 13)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Banner-Warnung wenn Tracker ohne Banner */}
|
||||
{bannerStats && Object.keys(bannerStats.category_acceptance).length > 0 && sites.length === 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-5 flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-red-800">Cookie-Banner fehlt!</h3>
|
||||
<p className="text-sm text-red-700 mt-1">
|
||||
Es wurden Tracking-Dienste erkannt, aber kein Cookie-Banner ist konfiguriert.
|
||||
Gemaess TTDSG § 25 ist eine Einwilligung erforderlich.
|
||||
</p>
|
||||
<Link href="/sdk/cookie-banner" className="inline-block mt-2 text-sm text-red-700 font-medium underline">
|
||||
Jetzt Cookie-Banner einrichten
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compliance Status */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-1">Compliance-Status</h3>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { COMPANY_PROFILE_PRESETS, type CompanyProfilePreset } from '@/lib/sdk/company-profile-presets'
|
||||
|
||||
interface PresetSelectorProps {
|
||||
onSelect: (preset: CompanyProfilePreset) => void
|
||||
onSkip: () => void
|
||||
}
|
||||
|
||||
export function PresetSelector({ onSelect, onSkip }: PresetSelectorProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-bold text-gray-900">Welcher Unternehmenstyp passt zu Ihnen?</h2>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Waehlen Sie eine Vorlage fuer Ihre Branche — alle Felder werden vorbefuellt
|
||||
und Sie koennen anschliessend anpassen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{COMPANY_PROFILE_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={() => onSelect(preset)}
|
||||
className="flex flex-col items-center gap-2 p-4 bg-white border border-gray-200 rounded-xl hover:border-purple-400 hover:shadow-md transition-all text-center group"
|
||||
>
|
||||
<span className="text-3xl">{preset.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900 group-hover:text-purple-700">
|
||||
{preset.label}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 leading-tight">
|
||||
{preset.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={onSkip}
|
||||
className="text-sm text-gray-400 hover:text-gray-600 underline"
|
||||
>
|
||||
Manuell ausfuellen (ohne Vorlage)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -78,6 +78,14 @@ export default function ComplianceScopePage() {
|
||||
const [supervisoryAuthorities, setSupervisoryAuthorities] = useState<SupervisoryAuthorityInfo[]>([])
|
||||
const [regulationAssessmentLoading, setRegulationAssessmentLoading] = useState(false)
|
||||
|
||||
// Enabled compliance modules (derived from applicable regulations)
|
||||
const [enabledModules, setEnabledModules] = useState<string[]>([])
|
||||
|
||||
// Auto-enable all applicable regulations when they load
|
||||
const handleToggleModule = (moduleId: string, enabled: boolean) => {
|
||||
setEnabledModules(prev => enabled ? [...prev, moduleId] : prev.filter(id => id !== moduleId))
|
||||
}
|
||||
|
||||
// Sync from SDK context when it becomes available (handles async loading).
|
||||
// The SDK context loads state from server/localStorage asynchronously, so
|
||||
// sdkState.complianceScope may arrive AFTER this page has already mounted.
|
||||
@@ -159,6 +167,10 @@ export default function ComplianceScopePage() {
|
||||
// Set applicable regulations from response
|
||||
const regs: ApplicableRegulation[] = data.overview?.applicable_regulations || data.applicable_regulations || []
|
||||
setApplicableRegulations(regs)
|
||||
// Auto-enable all applicable regulations as modules
|
||||
if (enabledModules.length === 0) {
|
||||
setEnabledModules(regs.map(r => r.id))
|
||||
}
|
||||
|
||||
// Derive supervisory authorities
|
||||
const regIds = regs.map(r => r.id)
|
||||
@@ -375,6 +387,8 @@ export default function ComplianceScopePage() {
|
||||
supervisoryAuthorities={supervisoryAuthorities}
|
||||
regulationAssessmentLoading={regulationAssessmentLoading}
|
||||
onGoToObligations={() => { window.location.href = '/sdk/obligations' }}
|
||||
enabledModules={enabledModules}
|
||||
onToggleModule={handleToggleModule}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -141,16 +141,24 @@ export default function ConsentManagementPage() {
|
||||
)}
|
||||
|
||||
{activeTab === 'emails' && (
|
||||
<EmailsTab
|
||||
apiEmailTemplates={apiEmailTemplates}
|
||||
templatesLoading={templatesLoading}
|
||||
savingTemplateId={savingTemplateId}
|
||||
savedTemplates={savedTemplates}
|
||||
setShowCreateTemplateModal={setShowCreateTemplateModal}
|
||||
saveApiEmailTemplate={saveApiEmailTemplate}
|
||||
setPreviewTemplate={setPreviewTemplate}
|
||||
setEditingTemplate={setEditingTemplate}
|
||||
/>
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-xl p-8 text-center">
|
||||
<div className="w-14 h-14 mx-auto mb-4 bg-purple-100 rounded-xl flex items-center justify-center">
|
||||
<svg className="w-7 h-7 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">E-Mail-Templates wurden zentralisiert</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Alle E-Mail-Vorlagen (DSR, Consent, Breach, Training, etc.) werden jetzt zentral
|
||||
im E-Mail-Template-Modul verwaltet — mit Versionierung, Freigabe-Workflow und Audit-Log.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push('/sdk/email-templates')}
|
||||
className="px-6 py-2.5 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Zu E-Mail-Templates
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'gdpr' && (
|
||||
|
||||
@@ -212,14 +212,14 @@ export function ControlDetail({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{ctrl.requirements.length > 0 && (
|
||||
{Array.isArray(ctrl.requirements) && ctrl.requirements.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
|
||||
<ol className="list-decimal list-inside space-y-1">{ctrl.requirements.map((r, i) => <li key={i} className="text-sm text-gray-700">{r}</li>)}</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{ctrl.test_procedure.length > 0 && (
|
||||
{Array.isArray(ctrl.test_procedure) && ctrl.test_procedure.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
|
||||
<ol className="list-decimal list-inside space-y-1">{ctrl.test_procedure.map((s, i) => <li key={i} className="text-sm text-gray-700">{s}</li>)}</ol>
|
||||
|
||||
@@ -18,7 +18,8 @@ export interface ControlsMeta {
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export function useControlLibraryState() {
|
||||
export function useControlLibraryState(backendUrlOverride?: string) {
|
||||
const backendUrl = backendUrlOverride || BACKEND_URL
|
||||
const [frameworks, setFrameworks] = useState<Framework[]>([])
|
||||
const [controls, setControls] = useState<CanonicalControl[]>([])
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
@@ -100,7 +101,7 @@ export function useControlLibraryState() {
|
||||
|
||||
const loadFrameworks = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=frameworks`)
|
||||
const res = await fetch(`${backendUrl}?endpoint=frameworks`)
|
||||
if (res.ok) setFrameworks(await res.json())
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
@@ -111,7 +112,7 @@ export function useControlLibraryState() {
|
||||
metaAbortRef.current = controller
|
||||
try {
|
||||
const qs = buildParams()
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=controls-meta${qs ? `&${qs}` : ''}`, { signal: controller.signal })
|
||||
const res = await fetch(`${backendUrl}?endpoint=controls-meta${qs ? `&${qs}` : ''}`, { signal: controller.signal })
|
||||
if (res.ok && !controller.signal.aborted) setMeta(await res.json())
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') return
|
||||
@@ -130,8 +131,8 @@ export function useControlLibraryState() {
|
||||
const qs = buildParams({ sort: sortField, order: sortOrder, limit: String(PAGE_SIZE), offset: String(offset) })
|
||||
const countQs = buildParams()
|
||||
const [ctrlRes, countRes] = await Promise.all([
|
||||
fetch(`${BACKEND_URL}?endpoint=controls&${qs}`, { signal: controller.signal }),
|
||||
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`, { signal: controller.signal }),
|
||||
fetch(`${backendUrl}?endpoint=controls&${qs}`, { signal: controller.signal }),
|
||||
fetch(`${backendUrl}?endpoint=controls-count&${countQs}`, { signal: controller.signal }),
|
||||
])
|
||||
if (!controller.signal.aborted) {
|
||||
if (ctrlRes.ok) setControls(await ctrlRes.json())
|
||||
@@ -147,7 +148,7 @@ export function useControlLibraryState() {
|
||||
|
||||
const loadReviewCount = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=controls-count&release_state=needs_review`)
|
||||
const res = await fetch(`${backendUrl}?endpoint=controls-count&release_state=needs_review`)
|
||||
if (res.ok) { const data = await res.json(); setReviewCount(data.total || 0) }
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
@@ -165,14 +166,14 @@ export function useControlLibraryState() {
|
||||
|
||||
const loadProcessedStats = async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=processed-stats`)
|
||||
const res = await fetch(`${backendUrl}?endpoint=processed-stats`)
|
||||
if (res.ok) { const data = await res.json(); setProcessedStats(data.stats || []) }
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const enterReviewMode = async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=controls&release_state=needs_review&limit=1000`)
|
||||
const res = await fetch(`${backendUrl}?endpoint=controls&release_state=needs_review&limit=1000`)
|
||||
if (res.ok) {
|
||||
const items: CanonicalControl[] = await res.json()
|
||||
if (items.length > 0) {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
interface Variant {
|
||||
id: string
|
||||
variant_name: string
|
||||
variant_key: string
|
||||
traffic_percent: number
|
||||
is_control: boolean
|
||||
banner_title: string | null
|
||||
banner_description: string | null
|
||||
position: string | null
|
||||
primary_color: string | null
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
interface VariantStat {
|
||||
variant_id: string
|
||||
variant_key: string
|
||||
variant_name: string
|
||||
traffic_percent: number
|
||||
is_control: boolean
|
||||
total: number
|
||||
accepted: number
|
||||
opt_in_rate: number
|
||||
is_winner?: boolean
|
||||
significance?: number
|
||||
}
|
||||
|
||||
const API = '/api/sdk/v1/compliance/banner/ab'
|
||||
|
||||
export function ABTestPanel({ siteConfigId }: { siteConfigId?: string }) {
|
||||
const [variants, setVariants] = useState<Variant[]>([])
|
||||
const [stats, setStats] = useState<VariantStat[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newVariant, setNewVariant] = useState({ variant_name: '', variant_key: 'B', traffic_percent: 50, banner_title: '', primary_color: '' })
|
||||
|
||||
const scid = siteConfigId || ''
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!scid) { setLoading(false); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
const [v, s] = await Promise.all([
|
||||
fetch(`${API}/${scid}/variants`).then(r => r.ok ? r.json() : []),
|
||||
fetch(`${API}/${scid}/stats`).then(r => r.ok ? r.json() : []),
|
||||
])
|
||||
setVariants(v)
|
||||
setStats(s)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [scid])
|
||||
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!scid || !newVariant.variant_name) return
|
||||
await fetch(`${API}/${scid}/variants`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newVariant),
|
||||
})
|
||||
setShowCreate(false)
|
||||
setNewVariant({ variant_name: '', variant_key: 'B', traffic_percent: 50, banner_title: '', primary_color: '' })
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await fetch(`${API}/variants/${id}`, { method: 'DELETE' })
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleTrafficChange = async (id: string, pct: number) => {
|
||||
await fetch(`${API}/variants/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ traffic_percent: pct }),
|
||||
})
|
||||
loadData()
|
||||
}
|
||||
|
||||
if (!scid) {
|
||||
return <div className="text-center py-8 text-gray-400">Bitte waehlen Sie zuerst eine Site aus.</div>
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-center py-8 text-gray-400">Lade A/B-Test...</div>
|
||||
|
||||
const maxRate = Math.max(...stats.map(s => s.opt_in_rate), 1)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">A/B-Test Varianten</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">Testen Sie verschiedene Banner-Konfigurationen um die Opt-In-Rate zu optimieren.</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(!showCreate)}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||
+ Variante erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create Form */}
|
||||
{showCreate && (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-xl p-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input value={newVariant.variant_name} onChange={e => setNewVariant({ ...newVariant, variant_name: e.target.value })}
|
||||
placeholder="Name (z.B. Variante B)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
|
||||
<input value={newVariant.variant_key} onChange={e => setNewVariant({ ...newVariant, variant_key: e.target.value })}
|
||||
placeholder="Key (z.B. B)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
|
||||
<input value={newVariant.banner_title} onChange={e => setNewVariant({ ...newVariant, banner_title: e.target.value })}
|
||||
placeholder="Banner-Titel (Override)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
|
||||
<input value={newVariant.primary_color} onChange={e => setNewVariant({ ...newVariant, primary_color: e.target.value })}
|
||||
placeholder="Farbe (z.B. #22c55e)" type="color" className="px-3 py-2 h-10 text-sm border border-gray-200 rounded-lg" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-gray-600">Traffic:</label>
|
||||
<input type="range" min={5} max={95} value={newVariant.traffic_percent}
|
||||
onChange={e => setNewVariant({ ...newVariant, traffic_percent: parseInt(e.target.value) })}
|
||||
className="flex-1" />
|
||||
<span className="text-sm font-medium w-12 text-right">{newVariant.traffic_percent}%</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleCreate} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">Erstellen</button>
|
||||
<button onClick={() => setShowCreate(false)} className="px-4 py-2 text-sm text-gray-500 hover:bg-gray-100 rounded-lg">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Variants + Stats */}
|
||||
{variants.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white border border-gray-200 rounded-xl">
|
||||
<p className="text-gray-400">Kein A/B-Test aktiv.</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Erstellen Sie mindestens 2 Varianten um einen Test zu starten.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Comparison Chart */}
|
||||
{stats.length > 0 && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h4 className="font-medium text-gray-900 mb-4">Opt-In-Rate Vergleich</h4>
|
||||
<div className="space-y-3">
|
||||
{stats.map(s => (
|
||||
<div key={s.variant_key} className="flex items-center gap-4">
|
||||
<div className="w-24 text-sm text-gray-700 truncate">
|
||||
{s.variant_name}
|
||||
{s.is_control && <span className="ml-1 text-[10px] text-gray-400">(Kontrolle)</span>}
|
||||
</div>
|
||||
<div className="flex-1 h-8 bg-gray-100 rounded-lg overflow-hidden relative">
|
||||
<div className={`h-full rounded-lg transition-all ${s.is_winner ? 'bg-green-500' : s.is_control ? 'bg-gray-400' : 'bg-purple-500'}`}
|
||||
style={{ width: `${(s.opt_in_rate / maxRate) * 100}%` }} />
|
||||
<span className="absolute inset-0 flex items-center px-3 text-xs font-medium text-gray-900">
|
||||
{s.opt_in_rate}% ({s.accepted}/{s.total})
|
||||
</span>
|
||||
</div>
|
||||
{s.is_winner && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-medium bg-green-100 text-green-700 rounded-full">
|
||||
Gewinner ({s.significance}%)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Variant Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{variants.map(v => (
|
||||
<div key={v.id} className={`bg-white border rounded-xl p-4 ${v.is_control ? 'border-gray-300' : 'border-purple-200'}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-gray-900">{v.variant_name}</span>
|
||||
<span className="px-1.5 py-0.5 text-[10px] bg-gray-100 text-gray-600 rounded">{v.variant_key}</span>
|
||||
{v.is_control && <span className="px-1.5 py-0.5 text-[10px] bg-blue-50 text-blue-600 rounded">Kontrolle</span>}
|
||||
</div>
|
||||
<button onClick={() => handleDelete(v.id)} className="text-xs text-red-500 hover:text-red-700">Loeschen</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<label className="text-xs text-gray-500">Traffic:</label>
|
||||
<input type="range" min={5} max={95} value={v.traffic_percent}
|
||||
onChange={e => handleTrafficChange(v.id, parseInt(e.target.value))}
|
||||
className="flex-1 h-1" />
|
||||
<span className="text-xs font-medium w-8 text-right">{v.traffic_percent}%</span>
|
||||
</div>
|
||||
{v.banner_title && <div className="text-xs text-gray-500">Titel: {v.banner_title}</div>}
|
||||
{v.primary_color && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<div className="w-3 h-3 rounded-full border" style={{ backgroundColor: v.primary_color }} />
|
||||
<span className="text-xs text-gray-500">{v.primary_color}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface TimeSeriesPoint {
|
||||
period: string
|
||||
given: number
|
||||
updated: number
|
||||
withdrawn: number
|
||||
total: number
|
||||
opt_in_rate: number
|
||||
}
|
||||
|
||||
interface CategoryStats {
|
||||
[key: string]: { count: number; total: number; rate: number }
|
||||
}
|
||||
|
||||
interface DeviceStats {
|
||||
desktop: number
|
||||
mobile: number
|
||||
tablet: number
|
||||
unknown: number
|
||||
}
|
||||
|
||||
interface OverviewStats {
|
||||
period_days: number
|
||||
total_interactions: number
|
||||
consents_given: number
|
||||
consents_updated: number
|
||||
consents_withdrawn: number
|
||||
opt_in_rate: number
|
||||
}
|
||||
|
||||
const PERIODS = [
|
||||
{ value: 7, label: '7 Tage' },
|
||||
{ value: 30, label: '30 Tage' },
|
||||
{ value: 90, label: '90 Tage' },
|
||||
]
|
||||
|
||||
const CAT_COLORS: Record<string, string> = {
|
||||
necessary: '#22c55e',
|
||||
statistics: '#eab308',
|
||||
marketing: '#ef4444',
|
||||
functional: '#3b82f6',
|
||||
preferences: '#8b5cf6',
|
||||
}
|
||||
|
||||
export function AnalyticsDashboard({ siteId }: { siteId?: string }) {
|
||||
const [days, setDays] = useState(30)
|
||||
const [overview, setOverview] = useState<OverviewStats | null>(null)
|
||||
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([])
|
||||
const [categories, setCategories] = useState<CategoryStats>({})
|
||||
const [devices, setDevices] = useState<DeviceStats>({ desktop: 0, mobile: 0, tablet: 0, unknown: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const sid = siteId || 'preview-test-site'
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
const base = `/api/sdk/v1/compliance/banner/analytics/${sid}`
|
||||
Promise.all([
|
||||
fetch(`${base}/overview?days=${days}`).then(r => r.ok ? r.json() : null),
|
||||
fetch(`${base}/time-series?days=${days}&period=daily`).then(r => r.ok ? r.json() : []),
|
||||
fetch(`${base}/categories?days=${days}`).then(r => r.ok ? r.json() : {}),
|
||||
fetch(`${base}/devices?days=${days}`).then(r => r.ok ? r.json() : {}),
|
||||
]).then(([o, ts, cats, devs]) => {
|
||||
setOverview(o)
|
||||
setTimeSeries(ts || [])
|
||||
setCategories(cats || {})
|
||||
setDevices(devs || { desktop: 0, mobile: 0, tablet: 0, unknown: 0 })
|
||||
}).catch(() => {}).finally(() => setLoading(false))
|
||||
}, [sid, days])
|
||||
|
||||
const deviceTotal = devices.desktop + devices.mobile + devices.tablet + devices.unknown
|
||||
|
||||
if (loading) return <div className="text-center py-12 text-gray-400">Lade Analytik...</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Period Selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
{PERIODS.map(p => (
|
||||
<button key={p.value} onClick={() => setDays(p.value)}
|
||||
className={`px-3 py-1.5 text-xs rounded-full border transition-colors ${
|
||||
days === p.value ? 'bg-purple-100 border-purple-300 text-purple-700' : 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'
|
||||
}`}>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Overview KPIs */}
|
||||
{overview && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-xs text-gray-500">Opt-In-Rate</div>
|
||||
<div className="text-2xl font-bold text-green-600">{overview.opt_in_rate}%</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-xs text-gray-500">Einwilligungen</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{overview.consents_given}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-xs text-gray-500">Aktualisiert</div>
|
||||
<div className="text-2xl font-bold text-blue-600">{overview.consents_updated}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-xs text-gray-500">Widerrufen</div>
|
||||
<div className="text-2xl font-bold text-red-600">{overview.consents_withdrawn}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time Series (simple bar visualization) */}
|
||||
{timeSeries.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Opt-In-Rate im Zeitverlauf</h3>
|
||||
<div className="flex items-end gap-1 h-32">
|
||||
{timeSeries.map((pt, i) => {
|
||||
const height = Math.max(pt.opt_in_rate, 2)
|
||||
const date = new Date(pt.period)
|
||||
return (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-1 group relative">
|
||||
<div className="w-full bg-purple-500 rounded-t transition-all hover:bg-purple-600"
|
||||
style={{ height: `${height}%` }}
|
||||
title={`${date.toLocaleDateString('de-DE')}: ${pt.opt_in_rate}% (${pt.total} Interaktionen)`}
|
||||
/>
|
||||
{i % Math.max(1, Math.floor(timeSeries.length / 6)) === 0 && (
|
||||
<span className="text-[8px] text-gray-400">{date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Category Acceptance */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Akzeptanz nach Kategorie</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(categories).map(([cat, stats]) => (
|
||||
<div key={cat}>
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-gray-700 capitalize">{cat}</span>
|
||||
<span className="font-medium text-gray-900">{stats.rate}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full transition-all" style={{ width: `${stats.rate}%`, backgroundColor: CAT_COLORS[cat] || '#9ca3af' }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(categories).length === 0 && (
|
||||
<p className="text-xs text-gray-400">Noch keine Daten vorhanden</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Device Breakdown */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Geraete-Verteilung</h3>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ key: 'desktop', label: 'Desktop', color: 'bg-blue-500' },
|
||||
{ key: 'mobile', label: 'Mobile', color: 'bg-green-500' },
|
||||
{ key: 'tablet', label: 'Tablet', color: 'bg-purple-500' },
|
||||
].map(d => {
|
||||
const count = devices[d.key as keyof DeviceStats]
|
||||
const pct = deviceTotal > 0 ? Math.round(count / deviceTotal * 100) : 0
|
||||
return (
|
||||
<div key={d.key}>
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-gray-700">{d.label}</span>
|
||||
<span className="font-medium text-gray-900">{pct}% ({count})</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${d.color}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{deviceTotal === 0 && (
|
||||
<p className="text-xs text-gray-400">Noch keine Geraetedaten vorhanden</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface Vendor {
|
||||
vendor_name: string
|
||||
vendor_url: string | null
|
||||
category_key: string
|
||||
description_de: string | null
|
||||
cookie_names: string[]
|
||||
retention_days: number | null
|
||||
}
|
||||
|
||||
const CAT_LABELS: Record<string, string> = {
|
||||
necessary: 'Notwendig',
|
||||
functional: 'Funktional',
|
||||
statistics: 'Statistik',
|
||||
marketing: 'Marketing',
|
||||
}
|
||||
|
||||
function generateHTML(vendors: Vendor[]): string {
|
||||
const grouped = vendors.reduce<Record<string, Vendor[]>>((acc, v) => {
|
||||
const key = v.category_key || 'other'
|
||||
if (!acc[key]) acc[key] = []
|
||||
acc[key].push(v)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
let html = `<div style="font-family:system-ui,sans-serif;font-size:14px;color:#1f2937;">\n`
|
||||
html += `<h3 style="margin:0 0 12px;font-size:16px;">Eingesetzte Dienste und Cookies</h3>\n`
|
||||
|
||||
for (const [catKey, catVendors] of Object.entries(grouped)) {
|
||||
const label = CAT_LABELS[catKey] || catKey
|
||||
html += `<h4 style="margin:16px 0 8px;font-size:14px;color:#6b21a8;">${label}</h4>\n`
|
||||
html += `<table style="width:100%;border-collapse:collapse;margin-bottom:12px;font-size:13px;">\n`
|
||||
html += `<tr style="background:#f9fafb;"><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Anbieter</th><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Zweck</th><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Cookies</th><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Speicherdauer</th></tr>\n`
|
||||
|
||||
for (const v of catVendors) {
|
||||
const name = v.vendor_url
|
||||
? `<a href="${v.vendor_url}" target="_blank" rel="noopener">${v.vendor_name}</a>`
|
||||
: v.vendor_name
|
||||
const cookies = v.cookie_names?.join(', ') || '-'
|
||||
const retention = v.retention_days ? `${v.retention_days} Tage` : '-'
|
||||
html += `<tr><td style="padding:6px 8px;border:1px solid #e5e7eb;">${name}</td><td style="padding:6px 8px;border:1px solid #e5e7eb;">${v.description_de || '-'}</td><td style="padding:6px 8px;border:1px solid #e5e7eb;font-family:monospace;font-size:11px;">${cookies}</td><td style="padding:6px 8px;border:1px solid #e5e7eb;">${retention}</td></tr>\n`
|
||||
}
|
||||
html += `</table>\n`
|
||||
}
|
||||
html += `</div>`
|
||||
return html
|
||||
}
|
||||
|
||||
export function EmbeddableVendorHTML({ siteId }: { siteId?: string }) {
|
||||
const [vendors, setVendors] = useState<Vendor[]>([])
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const sid = siteId || 'preview-test-site'
|
||||
fetch(`/api/sdk/v1/banner/admin/sites/${sid}/vendors`)
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then(data => setVendors(Array.isArray(data) ? data : []))
|
||||
.catch(() => {})
|
||||
}, [siteId])
|
||||
|
||||
const html = generateHTML(vendors)
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(html)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">Einbettbarer HTML-Code</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Kopieren Sie diesen Code in Ihre Datenschutzerklaerung oder Cookie-Richtlinie.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={handleCopy}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||
{copied ? 'Kopiert!' : 'HTML kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="border border-gray-200 rounded-lg p-4 bg-white">
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
|
||||
{/* Raw HTML */}
|
||||
<details className="group">
|
||||
<summary className="text-xs text-gray-500 cursor-pointer hover:text-gray-700">
|
||||
Quellcode anzeigen
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-gray-50 border border-gray-200 rounded-lg text-xs text-gray-700 overflow-x-auto max-h-[300px] overflow-y-auto">
|
||||
{html}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
interface Site {
|
||||
id: string
|
||||
site_id: string
|
||||
site_name: string
|
||||
site_url: string
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
interface SiteSelectorProps {
|
||||
sites: Site[]
|
||||
activeSiteId: string | null
|
||||
onSiteChange: (siteId: string) => void
|
||||
onCreateSite: (data: { site_id: string; site_name: string; site_url: string }) => Promise<void>
|
||||
}
|
||||
|
||||
export function SiteSelector({ sites, activeSiteId, onSiteChange, onCreateSite }: SiteSelectorProps) {
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newSite, setNewSite] = useState({ site_id: '', site_name: '', site_url: '' })
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newSite.site_id || !newSite.site_name) return
|
||||
setCreating(true)
|
||||
try {
|
||||
await onCreateSite(newSite)
|
||||
setNewSite({ site_id: '', site_name: '', site_url: '' })
|
||||
setShowCreate(false)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Website / Domain</label>
|
||||
<select value={activeSiteId || ''} onChange={e => onSiteChange(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:ring-1 focus:ring-purple-500 bg-white">
|
||||
{sites.length === 0 && <option value="">Keine Sites konfiguriert</option>}
|
||||
{sites.map(s => (
|
||||
<option key={s.site_id} value={s.site_id}>
|
||||
{s.site_name} ({s.site_url || s.site_id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(!showCreate)}
|
||||
className="mt-5 px-3 py-2 text-sm bg-purple-50 text-purple-600 border border-purple-200 rounded-lg hover:bg-purple-100">
|
||||
+ Neue Seite
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 grid grid-cols-3 gap-3">
|
||||
<input value={newSite.site_id} onChange={e => setNewSite({ ...newSite, site_id: e.target.value })}
|
||||
placeholder="Site-ID (z.B. main-website)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
|
||||
<input value={newSite.site_name} onChange={e => setNewSite({ ...newSite, site_name: e.target.value })}
|
||||
placeholder="Name (z.B. Hauptwebsite)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
|
||||
<div className="flex gap-2">
|
||||
<input value={newSite.site_url} onChange={e => setNewSite({ ...newSite, site_url: e.target.value })}
|
||||
placeholder="URL (z.B. https://example.com)" className="flex-1 px-3 py-2 text-sm border border-gray-200 rounded-lg" />
|
||||
<button onClick={handleCreate} disabled={creating || !newSite.site_id}
|
||||
className="px-3 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
{creating ? '...' : 'Anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface IABPurpose {
|
||||
id: number
|
||||
name: string
|
||||
name_de: string
|
||||
}
|
||||
|
||||
const API = '/api/sdk/v1/compliance/tcf'
|
||||
|
||||
export function TCFSettings({ siteId, tcfEnabled, onToggle }: {
|
||||
siteId?: string
|
||||
tcfEnabled: boolean
|
||||
onToggle: (enabled: boolean) => void
|
||||
}) {
|
||||
const [purposes, setPurposes] = useState<IABPurpose[]>([])
|
||||
const [categoryMap, setCategoryMap] = useState<Record<string, number[]>>({})
|
||||
const [testResult, setTestResult] = useState<string | null>(null)
|
||||
const [testing, setTesting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch(`${API}/purposes`).then(r => r.ok ? r.json() : []),
|
||||
fetch(`${API}/category-mapping`).then(r => r.ok ? r.json() : {}),
|
||||
]).then(([p, m]) => {
|
||||
setPurposes(p)
|
||||
setCategoryMap(m)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleTestEncode = async () => {
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
const res = await fetch(`${API}/encode-categories`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ categories: ['necessary', 'statistics', 'marketing'] }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setTestResult(`TC String: ${data.tc_string}\nPurposes: ${data.purposes_consented.join(', ')}`)
|
||||
}
|
||||
} catch { setTestResult('Fehler beim Generieren') }
|
||||
setTesting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Enable/Disable */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">IAB TCF 2.2</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Transparency & Consent Framework — Standardisierte Einwilligungssignale fuer programmatische Werbung
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={tcfEnabled} onChange={e => onToggle(e.target.checked)}
|
||||
className="w-5 h-5 text-purple-600 rounded" />
|
||||
<span className="text-sm font-medium">{tcfEnabled ? 'Aktiv' : 'Inaktiv'}</span>
|
||||
</label>
|
||||
</div>
|
||||
{!tcfEnabled && (
|
||||
<p className="mt-3 text-xs text-amber-600 bg-amber-50 p-3 rounded-lg">
|
||||
TCF ist nur erforderlich wenn Sie programmatische Werbung (AdTech) einsetzen.
|
||||
Fuer die meisten Websites reicht das Standard-Cookie-Banner.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tcfEnabled && (
|
||||
<>
|
||||
{/* IAB Purposes */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="font-semibold text-gray-900 mb-3">12 IAB-Zwecke (Purposes)</h4>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Diese Zwecke werden automatisch aus Ihren Cookie-Kategorien abgeleitet.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{purposes.map(p => {
|
||||
const activeCats = Object.entries(categoryMap)
|
||||
.filter(([, pids]) => pids.includes(p.id))
|
||||
.map(([cat]) => cat)
|
||||
return (
|
||||
<div key={p.id} className={`flex items-start gap-2 p-2 rounded-lg text-xs ${activeCats.length > 0 ? 'bg-green-50' : 'bg-gray-50'}`}>
|
||||
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold flex-shrink-0 ${activeCats.length > 0 ? 'bg-green-500 text-white' : 'bg-gray-300 text-white'}`}>
|
||||
{p.id}
|
||||
</span>
|
||||
<div>
|
||||
<div className="font-medium text-gray-700">{p.name_de}</div>
|
||||
{activeCats.length > 0 && (
|
||||
<div className="text-gray-400 mt-0.5">via: {activeCats.join(', ')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Mapping */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="font-semibold text-gray-900 mb-3">Kategorie → Purpose Zuordnung</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(categoryMap).map(([cat, pids]) => (
|
||||
<div key={cat} className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-gray-700 w-24 capitalize">{cat}</span>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{pids.length === 0 ? (
|
||||
<span className="text-xs text-gray-400">Keine Einwilligung noetig</span>
|
||||
) : (
|
||||
pids.map(pid => (
|
||||
<span key={pid} className="px-2 py-0.5 text-[10px] bg-purple-100 text-purple-700 rounded-full">
|
||||
Purpose {pid}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TC String Test */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="font-semibold text-gray-900 mb-3">TC String testen</h4>
|
||||
<button onClick={handleTestEncode} disabled={testing}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
{testing ? 'Generiere...' : 'Test TC String generieren'}
|
||||
</button>
|
||||
{testResult && (
|
||||
<pre className="mt-3 p-3 bg-gray-50 border border-gray-200 rounded-lg text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap">
|
||||
{testResult}
|
||||
</pre>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Simuliert: necessary + statistics + marketing → generiert base64url-codierten TC String
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* CMP Registration Info */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<h4 className="font-semibold text-blue-800 text-sm">CMP-Registrierung</h4>
|
||||
<p className="text-xs text-blue-700 mt-1">
|
||||
Fuer den produktiven Einsatz muss Ihr CMP bei der IAB Europe registriert werden.
|
||||
Sie erhalten eine eindeutige CMP-ID die im TC String codiert wird.
|
||||
</p>
|
||||
<p className="text-xs text-blue-600 mt-2">
|
||||
Registrierung: <a href="https://iabeurope.eu/tcf-for-cmps/" target="_blank" rel="noopener"
|
||||
className="underline">iabeurope.eu/tcf-for-cmps</a>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
|
||||
interface Vendor {
|
||||
id: string
|
||||
vendor_name: string
|
||||
vendor_url: string | null
|
||||
category_key: string
|
||||
description_de: string | null
|
||||
description_en: string | null
|
||||
cookie_names: string[]
|
||||
retention_days: number | null
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, { label: string; color: string }> = {
|
||||
necessary: { label: 'Notwendig', color: 'bg-green-100 text-green-700' },
|
||||
functional: { label: 'Funktional', color: 'bg-blue-100 text-blue-700' },
|
||||
statistics: { label: 'Statistik', color: 'bg-yellow-100 text-yellow-700' },
|
||||
marketing: { label: 'Marketing', color: 'bg-red-100 text-red-700' },
|
||||
}
|
||||
|
||||
export function VendorTable({ siteId }: { siteId?: string }) {
|
||||
const { projectId } = useSDK()
|
||||
const [vendors, setVendors] = useState<Vendor[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const sid = siteId || 'preview-test-site'
|
||||
fetch(`/api/sdk/v1/banner/admin/sites/${sid}/vendors`)
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then(data => setVendors(Array.isArray(data) ? data : []))
|
||||
.catch(() => setVendors([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, [siteId])
|
||||
|
||||
// Group by category
|
||||
const grouped = vendors.reduce<Record<string, Vendor[]>>((acc, v) => {
|
||||
const key = v.category_key || 'other'
|
||||
if (!acc[key]) acc[key] = []
|
||||
acc[key].push(v)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-12 text-gray-400">Lade Verarbeiter...</div>
|
||||
}
|
||||
|
||||
if (vendors.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-400 mb-3">Keine Verarbeiter konfiguriert.</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
Nutzen Sie den Website-Scanner oder fuegen Sie Verarbeiter manuell hinzu.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">Verarbeiter-Uebersicht</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">{vendors.length} Dienste in {Object.keys(grouped).length} Kategorien</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Object.entries(grouped).map(([catKey, catVendors]) => {
|
||||
const catInfo = CATEGORY_LABELS[catKey] || { label: catKey, color: 'bg-gray-100 text-gray-700' }
|
||||
return (
|
||||
<div key={catKey} className="border border-gray-200 rounded-xl overflow-hidden">
|
||||
<div className="bg-gray-50 px-4 py-3 flex items-center gap-3">
|
||||
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${catInfo.color}`}>
|
||||
{catInfo.label}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">{catVendors.length} Dienste</span>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 text-left text-xs text-gray-500">
|
||||
<th className="px-4 py-2 font-medium">Anbieter</th>
|
||||
<th className="px-4 py-2 font-medium">Zweck</th>
|
||||
<th className="px-4 py-2 font-medium">Cookies</th>
|
||||
<th className="px-4 py-2 font-medium">Aufbewahrung</th>
|
||||
<th className="px-4 py-2 font-medium">Datenschutz</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{catVendors.map(v => (
|
||||
<tr key={v.id} className="hover:bg-gray-50/50">
|
||||
<td className="px-4 py-2.5">
|
||||
<button onClick={() => setExpandedId(expandedId === v.id ? null : v.id)}
|
||||
className="font-medium text-gray-900 hover:text-purple-600 text-left">
|
||||
{v.vendor_name}
|
||||
</button>
|
||||
{expandedId === v.id && v.cookie_names?.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{v.cookie_names.map(c => (
|
||||
<span key={c} className="px-1.5 py-0.5 text-[10px] bg-gray-100 text-gray-600 rounded font-mono">
|
||||
{c}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-600 max-w-[200px] truncate">
|
||||
{v.description_de || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500">
|
||||
{v.cookie_names?.length || 0}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500">
|
||||
{v.retention_days ? `${v.retention_days} Tage` : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{v.vendor_url ? (
|
||||
<a href={v.vendor_url} target="_blank" rel="noopener noreferrer"
|
||||
className="text-xs text-purple-600 hover:underline">
|
||||
Link
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -96,13 +96,39 @@ const defaultBannerTexts: BannerTexts = {
|
||||
privacyLink: '/datenschutz',
|
||||
}
|
||||
|
||||
export interface BannerSite {
|
||||
id: string
|
||||
site_id: string
|
||||
site_name: string
|
||||
site_url: string
|
||||
is_active: boolean
|
||||
tcf_enabled?: boolean
|
||||
}
|
||||
|
||||
export function useCookieBanner() {
|
||||
const [categories, setCategories] = useState<CookieCategory[]>([])
|
||||
const [config, setConfig] = useState<BannerConfig>(defaultConfig)
|
||||
const [bannerTexts, setBannerTexts] = useState<BannerTexts>(defaultBannerTexts)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [exportToast, setExportToast] = useState<string | null>(null)
|
||||
const [sites, setSites] = useState<BannerSite[]>([])
|
||||
const [activeSiteId, setActiveSiteId] = useState<string | null>(null)
|
||||
|
||||
// Load sites list
|
||||
React.useEffect(() => {
|
||||
fetch('/api/sdk/v1/banner/admin/sites')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then(data => {
|
||||
const siteList = Array.isArray(data) ? data : []
|
||||
setSites(siteList)
|
||||
if (siteList.length > 0 && !activeSiteId) {
|
||||
setActiveSiteId(siteList[0].site_id)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Load config for active site
|
||||
React.useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
@@ -125,7 +151,20 @@ export function useCookieBanner() {
|
||||
}
|
||||
}
|
||||
loadConfig()
|
||||
}, [])
|
||||
}, [activeSiteId])
|
||||
|
||||
const createSite = async (data: { site_id: string; site_name: string; site_url: string }) => {
|
||||
const res = await fetch('/api/sdk/v1/banner/admin/sites', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (res.ok) {
|
||||
const newSite = await res.json()
|
||||
setSites(prev => [...prev, newSite])
|
||||
setActiveSiteId(newSite.site_id || data.site_id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCategoryToggle = async (categoryId: string, enabled: boolean) => {
|
||||
setCategories(prev =>
|
||||
@@ -180,5 +219,6 @@ export function useCookieBanner() {
|
||||
categories, config, bannerTexts, isSaving, exportToast,
|
||||
setConfig, setBannerTexts,
|
||||
handleCategoryToggle, handleExportCode, handleSaveConfig,
|
||||
sites, activeSiteId, setActiveSiteId, createSite,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import { useCookieBanner } from './_hooks/useCookieBanner'
|
||||
import { BannerPreview } from './_components/BannerPreview'
|
||||
import { CategoryCard } from './_components/CategoryCard'
|
||||
import { VendorTable } from './_components/VendorTable'
|
||||
import { EmbeddableVendorHTML } from './_components/EmbeddableVendorHTML'
|
||||
import { SiteSelector } from './_components/SiteSelector'
|
||||
import { AnalyticsDashboard } from './_components/AnalyticsDashboard'
|
||||
import { ABTestPanel } from './_components/ABTestPanel'
|
||||
import { TCFSettings } from './_components/TCFSettings'
|
||||
|
||||
type BannerTab = 'config' | 'vendors' | 'embed' | 'analytics' | 'abtest' | 'tcf'
|
||||
|
||||
export default function CookieBannerPage() {
|
||||
const { state } = useSDK()
|
||||
const [activeTab, setActiveTab] = useState<BannerTab>('config')
|
||||
const {
|
||||
categories, config, bannerTexts, isSaving, exportToast,
|
||||
setConfig, setBannerTexts,
|
||||
handleCategoryToggle, handleExportCode, handleSaveConfig,
|
||||
sites, activeSiteId, setActiveSiteId, createSite,
|
||||
} = useCookieBanner()
|
||||
|
||||
const totalCookies = categories.reduce((sum, cat) => sum + cat.cookies.length, 0)
|
||||
@@ -57,6 +67,58 @@ export default function CookieBannerPage() {
|
||||
</div>
|
||||
</StepHeader>
|
||||
|
||||
{/* Site Selector */}
|
||||
{sites.length > 0 && (
|
||||
<SiteSelector sites={sites} activeSiteId={activeSiteId} onSiteChange={setActiveSiteId} onCreateSite={createSite} />
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200">
|
||||
{([
|
||||
{ id: 'config' as const, label: 'Konfiguration' },
|
||||
{ id: 'vendors' as const, label: 'Verarbeiter' },
|
||||
{ id: 'embed' as const, label: 'Einbettung' },
|
||||
{ id: 'analytics' as const, label: 'Analytik' },
|
||||
{ id: 'abtest' as const, label: 'A/B-Test' },
|
||||
{ id: 'tcf' as const, label: 'TCF/IAB' },
|
||||
]).map(tab => (
|
||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
||||
activeTab === tab.id ? 'text-purple-600 border-purple-600' : 'text-gray-500 border-transparent hover:text-gray-700'
|
||||
}`}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab: Verarbeiter */}
|
||||
{activeTab === 'vendors' && <VendorTable siteId={activeSiteId || undefined} />}
|
||||
|
||||
{/* Tab: Einbettung */}
|
||||
{activeTab === 'embed' && <EmbeddableVendorHTML siteId={activeSiteId || undefined} />}
|
||||
|
||||
{/* Tab: Analytik */}
|
||||
{activeTab === 'analytics' && <AnalyticsDashboard siteId={activeSiteId || undefined} />}
|
||||
|
||||
{/* Tab: A/B-Test */}
|
||||
{activeTab === 'abtest' && <ABTestPanel siteConfigId={activeSiteId || undefined} />}
|
||||
|
||||
{/* Tab: TCF/IAB */}
|
||||
{activeTab === 'tcf' && (
|
||||
<TCFSettings siteId={activeSiteId || undefined} tcfEnabled={sites.find(s => s.site_id === activeSiteId)?.tcf_enabled ?? false}
|
||||
onToggle={(enabled) => {
|
||||
if (activeSiteId) {
|
||||
fetch(`/api/sdk/v1/banner/admin/sites/${activeSiteId}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tcf_enabled: enabled }),
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tab: Konfiguration */}
|
||||
{activeTab !== 'config' ? null : (<>
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
@@ -207,6 +269,7 @@ export default function CookieBannerPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { LegalTemplateResult } from '@/lib/sdk/types'
|
||||
import { RuleEngineResult } from '../ruleEngine'
|
||||
import ReviewAssignmentPanel from './ReviewAssignmentPanel'
|
||||
|
||||
interface GeneratorPreviewTabProps {
|
||||
template: LegalTemplateResult
|
||||
@@ -10,8 +12,76 @@ interface GeneratorPreviewTabProps {
|
||||
missing: string[]
|
||||
onCopy: () => void
|
||||
onExportMarkdown: () => void
|
||||
onSaveToWorkflow?: () => void
|
||||
saveStatus?: string | null
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lightweight Markdown → HTML (no dependency needed)
|
||||
// ============================================================================
|
||||
|
||||
function markdownToHtml(md: string): string {
|
||||
let html = md
|
||||
// Escape HTML entities first
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
// Headings
|
||||
html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>')
|
||||
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
|
||||
// Horizontal rules
|
||||
html = html.replace(/^---$/gm, '<hr/>')
|
||||
|
||||
// Bold + Italic
|
||||
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
|
||||
// Links
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-purple-600 underline">$1</a>')
|
||||
|
||||
// Tables (simple)
|
||||
html = html.replace(/^\|(.+)\|$/gm, (match) => {
|
||||
const cells = match.split('|').filter(c => c.trim())
|
||||
const isHeader = cells.every(c => /^[\s-:]+$/.test(c))
|
||||
if (isHeader) return '<!-- separator -->'
|
||||
const tag = 'td'
|
||||
return '<tr>' + cells.map(c => `<${tag}>${c.trim()}</${tag}>`).join('') + '</tr>'
|
||||
})
|
||||
|
||||
// Wrap consecutive table rows
|
||||
html = html.replace(/((?:<tr>.*<\/tr>\n?<!-- separator -->\n?)?(?:<tr>.*<\/tr>\n?)+)/g, (block) => {
|
||||
const rows = block.split('\n').filter(r => r.startsWith('<tr>'))
|
||||
if (rows.length === 0) return block
|
||||
const headerRow = rows[0].replace(/<td>/g, '<th>').replace(/<\/td>/g, '</th>')
|
||||
const bodyRows = rows.slice(1).join('\n')
|
||||
return `<table><thead>${headerRow}</thead><tbody>${bodyRows}</tbody></table>`
|
||||
})
|
||||
|
||||
// Remove separator comments
|
||||
html = html.replace(/<!-- separator -->\n?/g, '')
|
||||
|
||||
// Unordered lists
|
||||
html = html.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||
html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>')
|
||||
|
||||
// Paragraphs (lines that aren't already HTML)
|
||||
html = html.replace(/^(?!<[a-z/]|$)(.+)$/gm, '<p>$1</p>')
|
||||
|
||||
// Clean up empty paragraphs
|
||||
html = html.replace(/<p>\s*<\/p>/g, '')
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export default function GeneratorPreviewTab({
|
||||
template,
|
||||
ruleResult,
|
||||
@@ -19,13 +89,20 @@ export default function GeneratorPreviewTab({
|
||||
missing,
|
||||
onCopy,
|
||||
onExportMarkdown,
|
||||
onSaveToWorkflow,
|
||||
saveStatus,
|
||||
}: GeneratorPreviewTabProps) {
|
||||
const [viewMode, setViewMode] = useState<'preview' | 'markdown'>('preview')
|
||||
|
||||
const htmlContent = markdownToHtml(renderedContent)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Violations */}
|
||||
{ruleResult && ruleResult.violations.length > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||
<p className="text-sm font-semibold text-red-700 mb-2">
|
||||
🔴 {ruleResult.violations.length} Fehler
|
||||
{ruleResult.violations.length} Fehler
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{ruleResult.violations.map((v) => (
|
||||
@@ -36,6 +113,8 @@ export default function GeneratorPreviewTab({
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{ruleResult && ruleResult.warnings.filter((w) => w.id !== 'WARN_LEGAL_REVIEW').length > 0 && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4">
|
||||
<ul className="space-y-1">
|
||||
@@ -43,69 +122,156 @@ export default function GeneratorPreviewTab({
|
||||
.filter((w) => w.id !== 'WARN_LEGAL_REVIEW')
|
||||
.map((w) => (
|
||||
<li key={w.id} className="text-xs text-yellow-700">
|
||||
🟡 <span className="font-mono font-medium">[{w.id}]</span> {w.message}
|
||||
<span className="font-mono font-medium">[{w.id}]</span> {w.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legal notice */}
|
||||
{ruleResult && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3">
|
||||
<p className="text-xs text-blue-700">
|
||||
ℹ️ Rechtlicher Hinweis: Diese Vorlage ist MIT-lizenziert. Vor Produktionseinsatz
|
||||
wird eine rechtliche Überprüfung dringend empfohlen.
|
||||
Rechtlicher Hinweis: Diese Vorlage ist MIT-lizenziert. Vor Produktionseinsatz
|
||||
wird eine rechtliche Ueberpruefung dringend empfohlen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{ruleResult && ruleResult.appliedDefaults.length > 0 && (
|
||||
<p className="text-xs text-gray-400">
|
||||
Defaults angewendet: {ruleResult.appliedDefaults.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<span className="text-sm text-gray-600">
|
||||
{missing.length > 0 && (
|
||||
<span className="text-orange-600">
|
||||
⚠ {missing.length} Platzhalter noch nicht ausgefüllt
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-1 bg-gray-100 rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={onCopy}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 transition-colors"
|
||||
onClick={() => setViewMode('preview')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||
viewMode === 'preview' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Kopieren
|
||||
Vorschau
|
||||
</button>
|
||||
<button
|
||||
onClick={onExportMarkdown}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 transition-colors"
|
||||
onClick={() => setViewMode('markdown')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||
viewMode === 'markdown' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Markdown
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{missing.length > 0 && (
|
||||
<span className="text-xs text-orange-600">
|
||||
{missing.length} Platzhalter offen
|
||||
</span>
|
||||
)}
|
||||
<button onClick={onCopy} className="px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600">
|
||||
Kopieren
|
||||
</button>
|
||||
<button onClick={onExportMarkdown} className="px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600">
|
||||
Markdown
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
onClick={() => {
|
||||
const printWindow = window.open('', '_blank')
|
||||
if (!printWindow) return
|
||||
printWindow.document.write(`<!DOCTYPE html><html><head><title>${template.documentTitle || 'Dokument'}</title><style>
|
||||
@page { size: A4; margin: 25mm 20mm; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 11pt; line-height: 1.6; color: #1a202c; max-width: 170mm; margin: 0 auto; }
|
||||
h1 { font-size: 18pt; color: #5b21b6; margin: 24pt 0 8pt; border-bottom: 2px solid #7c3aed; padding-bottom: 4pt; }
|
||||
h2 { font-size: 14pt; color: #1f2937; margin: 18pt 0 6pt; }
|
||||
h3 { font-size: 12pt; color: #374151; margin: 12pt 0 4pt; }
|
||||
h4 { font-size: 11pt; color: #4b5563; margin: 10pt 0 4pt; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 8pt 0; font-size: 10pt; }
|
||||
th { background: #f5f3ff; color: #5b21b6; font-weight: 600; text-align: left; padding: 6pt 8pt; border: 1px solid #e5e7eb; }
|
||||
td { padding: 5pt 8pt; border: 1px solid #e5e7eb; vertical-align: top; }
|
||||
ul { padding-left: 20pt; }
|
||||
li { margin: 2pt 0; }
|
||||
hr { border: none; border-top: 1px solid #e5e7eb; margin: 16pt 0; }
|
||||
a { color: #7c3aed; }
|
||||
p { margin: 4pt 0; }
|
||||
strong { font-weight: 600; }
|
||||
</style></head><body>${htmlContent}</body></html>`)
|
||||
printWindow.document.close()
|
||||
printWindow.print()
|
||||
}}
|
||||
className="px-4 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
|
||||
</svg>
|
||||
PDF drucken
|
||||
</button>
|
||||
{onSaveToWorkflow && (
|
||||
<button
|
||||
onClick={onSaveToWorkflow}
|
||||
disabled={saveStatus === 'saving'}
|
||||
className={`px-4 py-1.5 text-xs rounded-lg transition-colors ${
|
||||
saveStatus === 'saved' ? 'bg-green-600 text-white' :
|
||||
saveStatus === 'error' ? 'bg-red-600 text-white' :
|
||||
'bg-indigo-600 text-white hover:bg-indigo-700'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{saveStatus === 'saving' ? 'Speichern...' :
|
||||
saveStatus === 'saved' ? 'Gespeichert!' :
|
||||
saveStatus === 'error' ? 'Fehler' :
|
||||
'Als Version speichern'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6 max-h-[600px] overflow-y-auto">
|
||||
<pre className="text-sm text-gray-800 whitespace-pre-wrap leading-relaxed font-sans">
|
||||
{renderedContent}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{viewMode === 'markdown' ? (
|
||||
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6 max-h-[800px] overflow-y-auto">
|
||||
<pre className="text-sm text-gray-800 whitespace-pre-wrap leading-relaxed font-mono">
|
||||
{renderedContent}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-100 rounded-xl p-8 flex justify-center overflow-y-auto max-h-[85vh]">
|
||||
{/* A4 Page */}
|
||||
<div
|
||||
className="bg-white shadow-lg border border-gray-300"
|
||||
style={{
|
||||
width: '210mm',
|
||||
minHeight: '297mm',
|
||||
padding: '25mm 20mm',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
fontSize: '11pt',
|
||||
lineHeight: '1.6',
|
||||
color: '#1a202c',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
.a4-content h1 { font-size: 18pt; color: #5b21b6; margin: 24pt 0 8pt; border-bottom: 2px solid #7c3aed; padding-bottom: 4pt; }
|
||||
.a4-content h2 { font-size: 14pt; color: #1f2937; margin: 18pt 0 6pt; }
|
||||
.a4-content h3 { font-size: 12pt; color: #374151; margin: 12pt 0 4pt; }
|
||||
.a4-content h4 { font-size: 11pt; color: #4b5563; margin: 10pt 0 4pt; }
|
||||
.a4-content table { width: 100%; border-collapse: collapse; margin: 8pt 0; font-size: 10pt; }
|
||||
.a4-content th { background: #f5f3ff; color: #5b21b6; font-weight: 600; text-align: left; padding: 6pt 8pt; border: 1px solid #e5e7eb; }
|
||||
.a4-content td { padding: 5pt 8pt; border: 1px solid #e5e7eb; vertical-align: top; }
|
||||
.a4-content ul { padding-left: 20pt; margin: 4pt 0; }
|
||||
.a4-content li { margin: 2pt 0; }
|
||||
.a4-content hr { border: none; border-top: 1px solid #e5e7eb; margin: 16pt 0; }
|
||||
.a4-content a { color: #7c3aed; text-decoration: underline; }
|
||||
.a4-content p { margin: 4pt 0; }
|
||||
.a4-content strong { font-weight: 600; }
|
||||
`}</style>
|
||||
<div
|
||||
className="a4-content"
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review Assignment */}
|
||||
<ReviewAssignmentPanel
|
||||
documentType={template.templateType || ''}
|
||||
documentTitle={template.documentTitle || 'Dokument'}
|
||||
documentContent={renderedContent}
|
||||
/>
|
||||
|
||||
{/* Attribution */}
|
||||
{template.attributionRequired && template.attributionText && (
|
||||
<div className="text-xs text-orange-600 bg-orange-50 p-3 rounded-lg border border-orange-200">
|
||||
<strong>Attribution erforderlich:</strong> {template.attributionText}
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function GeneratorSection({
|
||||
const [activeTab, setActiveTab] = useState<'placeholders' | 'preview'>('placeholders')
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['PROVIDER', 'LEGAL']))
|
||||
|
||||
const placeholders = template.placeholders || []
|
||||
const placeholders = Array.isArray(template.placeholders) ? template.placeholders : []
|
||||
const relevantSections = useMemo(() => getRelevantSections(placeholders), [placeholders])
|
||||
const uncovered = useMemo(() => getUncoveredPlaceholders(placeholders, context), [placeholders, context])
|
||||
const missing = useMemo(() => getMissingRequired(placeholders, context), [placeholders, context])
|
||||
@@ -101,6 +101,45 @@ export default function GeneratorSection({
|
||||
|
||||
const handleCopy = () => navigator.clipboard.writeText(renderedContent)
|
||||
|
||||
const [saveStatus, setSaveStatus] = useState<string | null>(null)
|
||||
|
||||
const handleSaveToWorkflow = async () => {
|
||||
setSaveStatus('saving')
|
||||
try {
|
||||
// 1. Create or find document
|
||||
const docRes = await fetch('/api/sdk/v1/compliance/legal-documents/documents', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: template.templateType || 'custom',
|
||||
name: template.documentTitle || 'Dokument',
|
||||
description: `Generiert aus Template: ${template.templateType}`,
|
||||
}),
|
||||
})
|
||||
if (!docRes.ok) throw new Error('Dokument konnte nicht erstellt werden')
|
||||
const doc = await docRes.json()
|
||||
|
||||
// 2. Create version
|
||||
const verRes = await fetch('/api/sdk/v1/compliance/legal-documents/versions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
document_id: doc.id,
|
||||
title: template.documentTitle || 'Dokument',
|
||||
content: renderedContent,
|
||||
language: template.language || 'de',
|
||||
version: '1.0',
|
||||
}),
|
||||
})
|
||||
if (!verRes.ok) throw new Error('Version konnte nicht erstellt werden')
|
||||
setSaveStatus('saved')
|
||||
setTimeout(() => setSaveStatus(null), 3000)
|
||||
} catch (e) {
|
||||
setSaveStatus('error')
|
||||
setTimeout(() => setSaveStatus(null), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportMarkdown = () => {
|
||||
const blob = new Blob([renderedContent], { type: 'text/markdown' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
@@ -160,6 +199,33 @@ export default function GeneratorSection({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
// Load example data for current template type
|
||||
const templateType = template.templateType || ''
|
||||
const lang = template.language || 'de'
|
||||
const exampleFile = `/sdk/document-generator/examples/${templateType}_${lang}.json`
|
||||
fetch(exampleFile)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (!data?.context) return
|
||||
const ctx = data.context
|
||||
for (const [section, fields] of Object.entries(ctx)) {
|
||||
if (typeof fields === 'object' && fields) {
|
||||
for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
|
||||
onContextChange(section as keyof TemplateContext, key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {/* no example available */})
|
||||
}}
|
||||
className="px-3 py-1 text-xs bg-blue-50 text-blue-600 border border-blue-200 rounded-lg hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
Beispieldaten
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors shrink-0" aria-label="Schließen">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -223,6 +289,8 @@ export default function GeneratorSection({
|
||||
missing={missing}
|
||||
onCopy={handleCopy}
|
||||
onExportMarkdown={handleExportMarkdown}
|
||||
onSaveToWorkflow={handleSaveToWorkflow}
|
||||
saveStatus={saveStatus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { evaluateTemplateRecommendations, type TemplateRecommendation } from '../templateRecommendations'
|
||||
import { getProfileLabel } from '../scopeDefaults'
|
||||
import type { LegalTemplateResult } from '@/lib/sdk/types'
|
||||
import type { ComplianceDepthLevel } from '@/lib/sdk/compliance-scope-types/core-levels'
|
||||
|
||||
interface Props {
|
||||
allTemplates: LegalTemplateResult[]
|
||||
onUseTemplate: (t: LegalTemplateResult) => void
|
||||
}
|
||||
|
||||
export default function RecommendedDocuments({ allTemplates, onUseTemplate }: Props) {
|
||||
const { state } = useSDK()
|
||||
const [showOptional, setShowOptional] = useState(false)
|
||||
|
||||
const level = state?.complianceScope?.determinedLevel as ComplianceDepthLevel | undefined
|
||||
const scopeAnswers = state?.complianceScope?.answers || []
|
||||
|
||||
const recommendations = useMemo(() => {
|
||||
if (!level) return null
|
||||
return evaluateTemplateRecommendations(
|
||||
scopeAnswers,
|
||||
level,
|
||||
(state?.companyProfile as Record<string, unknown>) || {},
|
||||
)
|
||||
}, [level, scopeAnswers, state?.companyProfile])
|
||||
|
||||
if (!level || !recommendations || recommendations.length === 0) return null
|
||||
|
||||
// Match recommendations to actual templates in the library
|
||||
const templateMap = new Map<string, LegalTemplateResult>()
|
||||
for (const t of allTemplates) {
|
||||
if (t.templateType) templateMap.set(t.templateType, t)
|
||||
}
|
||||
|
||||
const required = recommendations.filter((r) => r.requirement === 'required')
|
||||
const recommended = recommendations.filter((r) => r.requirement === 'recommended')
|
||||
const optional = recommendations.filter((r) => r.requirement === 'optional')
|
||||
|
||||
const renderCard = (rec: TemplateRecommendation) => {
|
||||
const template = templateMap.get(rec.templateType)
|
||||
const exists = !!template
|
||||
|
||||
return (
|
||||
<div
|
||||
key={rec.templateType}
|
||||
className={`rounded-lg border p-3 text-sm ${
|
||||
exists
|
||||
? 'border-gray-200 bg-white hover:border-purple-300 cursor-pointer'
|
||||
: 'border-dashed border-gray-300 bg-gray-50'
|
||||
}`}
|
||||
onClick={() => exists && template && onUseTemplate(template)}
|
||||
>
|
||||
<div className="font-medium text-gray-900 truncate">{rec.label}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{exists ? (
|
||||
<span className="text-purple-600">Vorlage verfuegbar</span>
|
||||
) : (
|
||||
<span className="text-gray-400">Noch nicht erstellt</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-purple-50 to-white rounded-xl border border-purple-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Empfohlene Dokumente fuer Ihr Unternehmen
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Basierend auf Ihrem Compliance-Profil ({getProfileLabel(level)})
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-700">
|
||||
{level}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Required */}
|
||||
{required.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-sm font-medium text-red-700">Pflicht</span>
|
||||
<span className="text-xs text-gray-400">({required.length})</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
|
||||
{required.map(renderCard)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommended */}
|
||||
{recommended.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-sm font-medium text-amber-700">Empfohlen</span>
|
||||
<span className="text-xs text-gray-400">({recommended.length})</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
|
||||
{recommended.map(renderCard)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optional (collapsed by default) */}
|
||||
{optional.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowOptional(!showOptional)}
|
||||
className="text-sm text-gray-500 hover:text-purple-600 flex items-center gap-1"
|
||||
>
|
||||
<span>{showOptional ? '▼' : '▶'}</span>
|
||||
<span>Optional ({optional.length})</span>
|
||||
</button>
|
||||
{showOptional && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2 mt-2">
|
||||
{optional.map(renderCard)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
|
||||
interface ReviewerInfo {
|
||||
role_key: string
|
||||
role_label?: string
|
||||
person_name?: string | null
|
||||
person_email?: string | null
|
||||
is_primary?: boolean
|
||||
}
|
||||
|
||||
interface ReviewRecord {
|
||||
id: string
|
||||
status: string
|
||||
reviewer_role_key: string
|
||||
reviewer_name: string | null
|
||||
email_sent: boolean
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: 'bg-gray-100 text-gray-700',
|
||||
in_review: 'bg-blue-100 text-blue-700',
|
||||
approved: 'bg-green-100 text-green-700',
|
||||
rejected: 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
pending: 'Ausstehend',
|
||||
in_review: 'In Pruefung',
|
||||
approved: 'Freigegeben',
|
||||
rejected: 'Abgelehnt',
|
||||
}
|
||||
|
||||
export default function ReviewAssignmentPanel({
|
||||
documentType,
|
||||
documentTitle,
|
||||
documentContent,
|
||||
}: {
|
||||
documentType: string
|
||||
documentTitle: string
|
||||
documentContent: string
|
||||
}) {
|
||||
const { projectId } = useSDK()
|
||||
const [reviewers, setReviewers] = useState<ReviewerInfo[]>([])
|
||||
const [existingReviews, setExistingReviews] = useState<ReviewRecord[]>([])
|
||||
const [sending, setSending] = useState(false)
|
||||
const [result, setResult] = useState<string | null>(null)
|
||||
|
||||
// Load reviewers for this document type
|
||||
useEffect(() => {
|
||||
if (!documentType) return
|
||||
const qs = new URLSearchParams()
|
||||
if (projectId) qs.set('project_id', projectId)
|
||||
qs.set('document_type', documentType)
|
||||
|
||||
// Load mapping + existing reviews
|
||||
Promise.all([
|
||||
fetch(`/api/sdk/v1/compliance/org-roles/mapping`).then(r => r.ok ? r.json() : []),
|
||||
fetch(`/api/sdk/v1/compliance/org-roles${projectId ? `?project_id=${projectId}` : ''}`).then(r => r.ok ? r.json() : []),
|
||||
fetch(`/api/sdk/v1/compliance/document-reviews/for-document?${qs}`).then(r => r.ok ? r.json() : []),
|
||||
]).then(([mappings, roles, reviews]) => {
|
||||
// Filter mappings for this document type
|
||||
const relevant = (mappings as Array<{ document_type: string; role_key: string; is_primary: boolean }>)
|
||||
.filter(m => m.document_type === documentType)
|
||||
// Enrich with role info
|
||||
const enriched: ReviewerInfo[] = relevant.map(m => {
|
||||
const role = (roles as Array<{ role_key: string; role_label: string; person_name: string | null; person_email: string | null }>)
|
||||
.find(r => r.role_key === m.role_key)
|
||||
return { ...m, role_label: role?.role_label, person_name: role?.person_name, person_email: role?.person_email }
|
||||
})
|
||||
setReviewers(enriched)
|
||||
setExistingReviews(reviews)
|
||||
}).catch(() => {})
|
||||
}, [documentType, projectId])
|
||||
|
||||
const handleSendForReview = async () => {
|
||||
setSending(true)
|
||||
setResult(null)
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/document-reviews', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
document_type: documentType,
|
||||
document_title: documentTitle,
|
||||
document_content: documentContent,
|
||||
project_id: projectId,
|
||||
review_link: window.location.href,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error('Fehler beim Erstellen')
|
||||
const reviews = await res.json()
|
||||
|
||||
// Send email for each review
|
||||
let sentCount = 0
|
||||
for (const review of reviews) {
|
||||
if (review.reviewer_email) {
|
||||
const sendRes = await fetch(`/api/sdk/v1/compliance/document-reviews/${review.id}/send`, { method: 'POST' })
|
||||
if (sendRes.ok) sentCount++
|
||||
}
|
||||
}
|
||||
setResult(`${reviews.length} Review(s) erstellt, ${sentCount} E-Mail(s) gesendet`)
|
||||
// Refresh
|
||||
const qs = new URLSearchParams({ document_type: documentType })
|
||||
if (projectId) qs.set('project_id', projectId)
|
||||
const updated = await fetch(`/api/sdk/v1/compliance/document-reviews/for-document?${qs}`).then(r => r.json())
|
||||
setExistingReviews(updated)
|
||||
} catch (e) {
|
||||
setResult(e instanceof Error ? e.message : 'Fehler')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (reviewers.length === 0 && existingReviews.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="border border-purple-200 rounded-lg p-4 bg-purple-50/50 space-y-3">
|
||||
<h4 className="font-semibold text-sm text-gray-900 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Pruefung & Freigabe
|
||||
</h4>
|
||||
|
||||
{/* Assigned reviewers */}
|
||||
{reviewers.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{reviewers.map(r => (
|
||||
<div key={r.role_key} className="flex items-center gap-2 text-xs">
|
||||
<span className="font-medium text-gray-700">{r.role_label || r.role_key}:</span>
|
||||
{r.person_name ? (
|
||||
<span className="text-gray-600">{r.person_name} ({r.person_email || 'keine E-Mail'})</span>
|
||||
) : (
|
||||
<span className="text-gray-400 italic">Nicht zugewiesen</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Existing reviews */}
|
||||
{existingReviews.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{existingReviews.map(r => (
|
||||
<div key={r.id} className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${STATUS_COLORS[r.status] || ''}`}>
|
||||
{STATUS_LABELS[r.status] || r.status}
|
||||
</span>
|
||||
<span className="text-xs text-gray-600">{r.reviewer_name || r.reviewer_role_key}</span>
|
||||
{r.email_sent && <span className="text-[10px] text-green-600">E-Mail gesendet</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Send for review */}
|
||||
<button onClick={handleSendForReview} disabled={sending || reviewers.length === 0}
|
||||
className="w-full px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors">
|
||||
{sending ? 'Sende...' : 'Zur Pruefung senden'}
|
||||
</button>
|
||||
|
||||
{result && (
|
||||
<p className={`text-xs ${result.includes('Fehler') ? 'text-red-600' : 'text-green-600'}`}>{result}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,22 +6,64 @@ import { TemplateContext } from './contextBridge'
|
||||
|
||||
export const CATEGORIES: { key: string; label: string; types: string[] | null }[] = [
|
||||
{ key: 'all', label: 'Alle', types: null },
|
||||
{ key: 'privacy_policy', label: 'Datenschutz', types: ['privacy_policy'] },
|
||||
{ key: 'terms', label: 'AGB', types: ['terms_of_service', 'agb', 'clause'] },
|
||||
{ key: 'impressum', label: 'Impressum', types: ['impressum'] },
|
||||
{ key: 'dpa', label: 'AVV/DPA', types: ['dpa'] },
|
||||
{ key: 'nda', label: 'NDA', types: ['nda'] },
|
||||
{ key: 'sla', label: 'SLA', types: ['sla'] },
|
||||
{ key: 'acceptable_use', label: 'AUP', types: ['acceptable_use'] },
|
||||
{ key: 'widerruf', label: 'Widerruf', types: ['widerruf'] },
|
||||
{ key: 'cookie', label: 'Cookie', types: ['cookie_policy', 'cookie_banner'] },
|
||||
{ key: 'cloud', label: 'Cloud', types: ['cloud_service_agreement'] },
|
||||
{ key: 'misc', label: 'Weitere', types: ['community_guidelines', 'copyright_policy', 'data_usage_clause'] },
|
||||
{ key: 'dsfa', label: 'DSFA', types: ['dsfa'] },
|
||||
{ key: 'dsr', label: 'DSR-Prozesse', types: [
|
||||
|
||||
// ── Nach Nutzungskontext sortiert ──────────────────────────────────────
|
||||
|
||||
// Jede Website / App braucht:
|
||||
{ key: 'website', label: 'Website / App', types: ['privacy_policy', 'impressum', 'cookie_policy', 'cookie_banner', 'social_media_dsi'] },
|
||||
|
||||
// Online-Shop / E-Commerce:
|
||||
{ key: 'shop', label: 'Online-Shop', types: ['agb', 'widerruf', 'privacy_policy', 'impressum', 'cookie_policy', 'cookie_banner'] },
|
||||
|
||||
// SaaS / Cloud-Dienst:
|
||||
{ key: 'saas', label: 'SaaS / Cloud', types: ['agb', 'dpa', 'sla', 'cloud_service_agreement', 'privacy_policy', 'terms_of_use'] },
|
||||
|
||||
// App / Plattform mit Nutzern:
|
||||
{ key: 'platform', label: 'App / Plattform', types: ['terms_of_use', 'community_guidelines', 'privacy_policy', 'agb', 'acceptable_use', 'media_content_policy', 'copyright_policy'] },
|
||||
|
||||
// Vertraege mit Geschaeftspartnern:
|
||||
{ key: 'contracts', label: 'Vertraege (B2B)', types: ['dpa', 'nda', 'sla', 'cloud_service_agreement', 'data_usage_clause'] },
|
||||
|
||||
// Drittlandtransfer:
|
||||
{ key: 'third_country', label: 'Drittlandtransfer', types: ['transfer_impact_assessment', 'scc_companion'] },
|
||||
|
||||
// ── Interne Compliance-Dokumente ──────────────────────────────────────
|
||||
|
||||
// DSGVO-Kernpflichten:
|
||||
{ key: 'dsgvo_core', label: 'DSGVO-Pflichten', types: ['tom_documentation', 'vvt_register', 'loeschkonzept', 'dsfa', 'pflichtenregister'] },
|
||||
|
||||
// Betroffenenrechte:
|
||||
{ key: 'dsr', label: 'Betroffenenrechte', types: [
|
||||
'dsr_process_art15', 'dsr_process_art16', 'dsr_process_art17',
|
||||
'dsr_process_art18', 'dsr_process_art19', 'dsr_process_art20', 'dsr_process_art21',
|
||||
]},
|
||||
|
||||
// Datenschutz-Informationen (alle DSI-Typen):
|
||||
{ key: 'dsi', label: 'Datenschutzinfos', types: ['privacy_policy', 'applicant_dsi', 'employee_dsi', 'social_media_dsi', 'video_conference_dsi', 'informationspflichten'] },
|
||||
|
||||
// Einwilligungen:
|
||||
{ key: 'consent', label: 'Einwilligungen', types: ['consent_texts', 'cookie_banner', 'verpflichtungserklaerung'] },
|
||||
|
||||
// ── Sicherheit & IT ───────────────────────────────────────────────────
|
||||
|
||||
{ key: 'security_concepts', label: 'Sicherheitskonzepte', types: ['it_security_concept', 'data_protection_concept', 'backup_recovery_concept', 'logging_concept', 'incident_response_plan', 'access_control_concept', 'risk_management_concept', 'isms_manual'] },
|
||||
|
||||
{ key: 'security_policies', label: 'Sicherheitsrichtlinien', types: [
|
||||
'information_security_policy', 'access_control_policy', 'password_policy', 'encryption_policy',
|
||||
'cybersecurity_policy', 'incident_response_policy', 'logging_policy', 'patch_management_policy',
|
||||
'vulnerability_management_policy', 'secrets_management_policy', 'devsecops_policy',
|
||||
'cloud_security_policy', 'change_management_policy', 'asset_management_policy', 'backup_policy',
|
||||
]},
|
||||
|
||||
// ── Organisation & HR ─────────────────────────────────────────────────
|
||||
|
||||
{ key: 'hr', label: 'HR & Mitarbeiter', types: ['applicant_dsi', 'employee_dsi', 'employee_security_policy', 'security_awareness_policy', 'remote_work_policy', 'offboarding_policy', 'byod_policy', 'ai_usage_policy', 'whistleblower_policy', 'verpflichtungserklaerung'] },
|
||||
|
||||
{ key: 'data_governance', label: 'Daten-Governance', types: ['data_protection_policy', 'data_classification_policy', 'data_retention_policy', 'data_transfer_policy', 'privacy_incident_policy'] },
|
||||
|
||||
{ key: 'vendor', label: 'Lieferanten / Vendor', types: ['vendor_risk_management_policy', 'third_party_security_policy', 'supplier_security_policy', 'dpa'] },
|
||||
|
||||
{ key: 'bcm', label: 'BCM / Notfall', types: ['business_continuity_policy', 'disaster_recovery_policy', 'crisis_management_policy', 'incident_response_plan'] },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
@@ -41,6 +83,8 @@ export const SECTION_LABELS: Record<keyof TemplateContext, string> = {
|
||||
CONSENT: 'Cookie / Einwilligung',
|
||||
HOSTING: 'Hosting-Provider',
|
||||
FEATURES: 'Dokument-Features & Textbausteine',
|
||||
TOM: 'TOM-Dokumentation',
|
||||
DPA: 'AVV / Auftragsverarbeitung',
|
||||
}
|
||||
|
||||
export type FieldType = 'text' | 'email' | 'number' | 'select' | 'textarea' | 'boolean'
|
||||
@@ -186,6 +230,192 @@ export const SECTION_FIELDS: Record<keyof TemplateContext, FieldDef[]> = {
|
||||
{ key: 'EDITORIAL_RESPONSIBLE_ADDRESS', label: 'V.i.S.d.P. Adresse' },
|
||||
{ key: 'HAS_DISPUTE_RESOLUTION', label: 'Streitbeilegungshinweis', type: 'boolean' },
|
||||
{ key: 'DISPUTE_RESOLUTION_TEXT', label: 'Streitbeilegungstext', type: 'textarea', span: true },
|
||||
// ── SaaS AGB v2 ─────────────────────────────────────────────────────────
|
||||
{ key: 'B2B_ONLY', label: 'Nur B2B (keine Verbraucher)', type: 'boolean' },
|
||||
{ key: 'HAS_END_USERS', label: 'Endkunden-Weitergabe (B2B2C)', type: 'boolean' },
|
||||
{ key: 'HAS_MODULAR_PACKAGES', label: 'Modulare Leistungspakete', type: 'boolean' },
|
||||
{ key: 'HAS_STORAGE', label: 'Speicherplatz als Leistung', type: 'boolean' },
|
||||
{ key: 'HAS_STORAGE_LIMITS', label: 'Speicherplatz begrenzt', type: 'boolean' },
|
||||
{ key: 'HAS_TRIAL', label: 'Kostenlose Testphase', type: 'boolean' },
|
||||
{ key: 'TRIAL_DAYS', label: 'Testphase (Tage)', type: 'select', opts: ['7', '14', '30'] },
|
||||
{ key: 'HAS_PRICE_ADJUSTMENT', label: 'Preisanpassungsklausel', type: 'boolean' },
|
||||
{ key: 'PRICE_ADJUSTMENT_NOTICE_WEEKS', label: 'Ankündigung Preisanpassung (Wo.)', type: 'select', opts: ['4', '8', '12'] },
|
||||
{ key: 'PRICE_INCREASE_THRESHOLD_PERCENT', label: 'Schwelle Sonderkündigung (%)', type: 'select', opts: ['5', '10', '15'] },
|
||||
{ key: 'HAS_UPLOAD', label: 'Datei-Upload Funktion', type: 'boolean' },
|
||||
{ key: 'NO_AUDIT_PROOF_STORAGE', label: 'Keine revisionssichere Speicherung', type: 'boolean' },
|
||||
{ key: 'HAS_API_ACCESS', label: 'API-Zugang', type: 'boolean' },
|
||||
{ key: 'HAS_MAINTENANCE_ACCESS', label: 'Fernwartungszugang (On-Premise)', type: 'boolean' },
|
||||
{ key: 'HAS_MAX_DOWNTIME', label: 'Max. Ausfalldauer begrenzt', type: 'boolean' },
|
||||
{ key: 'MAX_DOWNTIME_DAYS', label: 'Max. Ausfalldauer (Tage)', type: 'number' },
|
||||
{ key: 'HAS_IP_INDEMNIFICATION', label: 'IP-Freistellung (Schutzrechte)', type: 'boolean' },
|
||||
{ key: 'LIABILITY_MULTIPLIER', label: 'Haftungsdeckel (x Jahreslizenz)', type: 'select', opts: ['1', '2', '3'] },
|
||||
{ key: 'HAS_REFERENCE_MARKETING', label: 'Referenzmarketing (Logo-Nutzung)', type: 'boolean' },
|
||||
{ key: 'HAS_WHITELABEL', label: 'Whitelabel-Paket vorhanden', type: 'boolean' },
|
||||
{ key: 'HAS_FORCE_MAJEURE', label: 'Force-Majeure-Klausel', type: 'boolean' },
|
||||
{ key: 'HAS_COMMUNITY_GUIDELINES', label: 'Community Guidelines als Bestandteil', type: 'boolean' },
|
||||
// ── Community Guidelines (modular) ──────────────────────────────────────
|
||||
{ key: 'TONE_FRIENDLY', label: 'Ton: Freundlich/Einladend', type: 'boolean' },
|
||||
{ key: 'TONE_EDITORIAL', label: 'Ton: Editorial/Sachlich', type: 'boolean' },
|
||||
{ key: 'TONE_FORMAL', label: 'Ton: Formal/Juristisch', type: 'boolean' },
|
||||
{ key: 'HAS_MEDIA_UPLOADS', label: 'Plattform: Medien-Uploads (Bilder/Videos)', type: 'boolean' },
|
||||
{ key: 'HAS_MESSAGING', label: 'Plattform: Messaging/Chat', type: 'boolean' },
|
||||
{ key: 'HAS_MARKETPLACE', label: 'Plattform: Marketplace/Handel', type: 'boolean' },
|
||||
{ key: 'DETAILED_ILLEGAL', label: '↳ Details: Rechtswidrige Inhalte', type: 'boolean' },
|
||||
{ key: 'DETAILED_HATE_SPEECH', label: '↳ Details: Hassrede', type: 'boolean' },
|
||||
{ key: 'DETAILED_FRAUD', label: '↳ Details: Betrug/Deepfakes', type: 'boolean' },
|
||||
{ key: 'EXCEPTIONS_FRAUD', label: '↳ Ausnahmen: Parodie/Satire/Kunst', type: 'boolean' },
|
||||
{ key: 'DETAILED_PRIVACY', label: '↳ Details: Sicherheit/Privatsphäre', type: 'boolean' },
|
||||
{ key: 'DETAILED_VIOLENCE', label: '↳ Details: Gewalt (bei Medien-Uploads)', type: 'boolean' },
|
||||
{ key: 'EXCEPTIONS_VIOLENCE', label: '↳ Ausnahmen: Kampfsport/Journalismus/Kunst', type: 'boolean' },
|
||||
{ key: 'DETAILED_PORNOGRAPHY', label: '↳ Details: Pornografie (bei Medien-Uploads)', type: 'boolean' },
|
||||
{ key: 'EXCEPTIONS_PORNOGRAPHY', label: '↳ Ausnahmen: Bodypainting/Stillen/Medizin', type: 'boolean' },
|
||||
{ key: 'DETAILED_SELF_HARM', label: '↳ Details: Suizid/Selbstverletzung', type: 'boolean' },
|
||||
{ key: 'EXCEPTIONS_SELF_HARM', label: '↳ Ausnahmen: Prävention/Journalismus', type: 'boolean' },
|
||||
{ key: 'DETAILED_EXPLOITATION', label: '↳ Details: Ausbeutung/Missbrauch/CSAM', type: 'boolean' },
|
||||
{ key: 'DETAILED_HARASSMENT', label: '↳ Details: Sexuelle Belästigung (bei Messaging)', type: 'boolean' },
|
||||
{ key: 'DETAILED_DANGEROUS_PRODUCTS', label: '↳ Details: Gefährliche Produkte (bei Marketplace)', type: 'boolean' },
|
||||
{ key: 'DETAILED_TERRORISM', label: '↳ Details: Terrorismus/Gefährliche Gruppen', type: 'boolean' },
|
||||
{ key: 'DETAILED_DANGEROUS_ACTIVITIES', label: '↳ Details: Gefährdende Aktivitäten', type: 'boolean' },
|
||||
{ key: 'GUIDELINES_URL', label: 'URL der Richtlinien' },
|
||||
// ── Medien & Content Module ─────────────────────────────────────────────
|
||||
{ key: 'IS_JOURNALISTIC_MEDIA', label: 'Journalistisches Medium (MStV §§ 18-22)', type: 'boolean' },
|
||||
{ key: 'EDITORIAL_EMAIL', label: 'Redaktions-E-Mail (Gegendarstellung)', type: 'email' },
|
||||
{ key: 'HAS_AI_GENERATED_CONTENT', label: 'KI-generierte Inhalte (AI Act Art. 50)', type: 'boolean' },
|
||||
{ key: 'DETAILED_AI_LABELING', label: '↳ Detaillierte KI-Kennzeichnungstabelle', type: 'boolean' },
|
||||
{ key: 'HAS_SPONSORED_CONTENT', label: 'Bezahlte/werbliche Inhalte (§ 5a UWG)', type: 'boolean' },
|
||||
{ key: 'HAS_PRESS_COUNCIL', label: 'Pressekodex-Selbstverpflichtung (Presserat)', type: 'boolean' },
|
||||
// ── Nutzungsbedingungen ─────────────────────────────────────────────────
|
||||
{ key: 'HAS_UGC', label: 'User Generated Content', type: 'boolean' },
|
||||
{ key: 'HAS_CONTENT_LICENSING', label: 'Content Licensing (Nutzer-zu-Nutzer)', type: 'boolean' },
|
||||
{ key: 'HAS_TDM_OPTOUT', label: 'Text- und Data-Mining Opt-out', type: 'boolean' },
|
||||
{ key: 'HAS_CONTENT_AUTHENTICITY', label: 'Content Authenticity (kryptogr. Herkunft)', type: 'boolean' },
|
||||
{ key: 'HAS_TIPPING', label: 'Tipping/Anerkennungsfunktion', type: 'boolean' },
|
||||
{ key: 'HAS_CRYPTO_PAYMENTS', label: 'Krypto-Zahlungen', type: 'boolean' },
|
||||
{ key: 'HAS_INTEGRATED_WALLET', label: 'Integriertes Wallet (Non-Custodial)', type: 'boolean' },
|
||||
{ key: 'HAS_IDENTITY_VERIFICATION', label: 'Identitätsprüfung erforderlich', type: 'boolean' },
|
||||
{ key: 'HAS_COPYRIGHT_TAKEDOWN', label: 'Copyright Takedown-Verfahren', type: 'boolean' },
|
||||
{ key: 'HAS_PAID_USER_ACCOUNTS', label: 'Kostenpflichtige Nutzeraccounts', type: 'boolean' },
|
||||
{ key: 'HAS_EU_USERS', label: 'EU-weite Nutzer (Verbraucherschutz)', type: 'boolean' },
|
||||
{ key: 'MFA_REQUIRED', label: 'MFA verpflichtend für Nutzer', type: 'boolean' },
|
||||
{ key: 'DATA_EXPORT_BEFORE_DELETION', label: 'Datenexport vor Kontolöschung', type: 'boolean' },
|
||||
{ key: 'EXPORT_BEFORE_DELETION_DAYS', label: 'Exportfrist (Tage)', type: 'select', opts: ['7', '14', '30'] },
|
||||
{ key: 'MIN_AGE', label: 'Mindestalter', type: 'select', opts: ['13', '16', '18'] },
|
||||
{ key: 'ALLOWS_MINORS', label: 'Minderjährige mit Eltern-Einwilligung', type: 'boolean' },
|
||||
{ key: 'TIPPING_FEE_PERCENT', label: 'Tipping-Gebühr (%)', type: 'number' },
|
||||
{ key: 'SUPPORTED_CURRENCIES', label: 'Unterstützte Währungen/Token' },
|
||||
// ── Widerrufsbelehrung ──────────────────────────────────────────────────
|
||||
{ key: 'HAS_PHYSICAL_GOODS', label: 'Physische Waren (Rücksendung)', type: 'boolean' },
|
||||
{ key: 'HAS_COMBO_PACKAGE', label: 'Kombi-Paket (Hardware + Software)', type: 'boolean' },
|
||||
{ key: 'HAS_DIGITAL_CONTENT', label: 'Digitale Inhalte (§ 356 Abs. 5 BGB)', type: 'boolean' },
|
||||
{ key: 'HAS_SAAS_SERVICE', label: 'SaaS-Dienstleistung (§ 356 Abs. 4 BGB)', type: 'boolean' },
|
||||
{ key: 'HAS_IOT_BUNDLE', label: 'Verbundenes Produkt (Hardware + App/Cloud)', type: 'boolean' },
|
||||
{ key: 'IOT_SEPARATE_CONTRACTS', label: '↳ HW und Cloud getrennt widerrufbar', type: 'boolean' },
|
||||
{ key: 'RETURN_ADDRESS', label: 'Rücksendeadresse (Servicecenter)' },
|
||||
// ── Social Media DSI ────────────────────────────────────────────────────
|
||||
{ key: 'HAS_FACEBOOK', label: 'Facebook & Instagram', type: 'boolean' },
|
||||
{ key: 'HAS_YOUTUBE', label: 'YouTube', type: 'boolean' },
|
||||
{ key: 'HAS_LINKEDIN', label: 'LinkedIn', type: 'boolean' },
|
||||
{ key: 'HAS_TIKTOK', label: 'TikTok', type: 'boolean' },
|
||||
{ key: 'HAS_X_TWITTER', label: 'X (Twitter)', type: 'boolean' },
|
||||
{ key: 'HAS_META_PIXEL', label: 'Meta Pixel (Konversionsmessung)', type: 'boolean' },
|
||||
{ key: 'HAS_RECRUITING_VIA_SOCIAL', label: 'Personalgewinnung über Social Media', type: 'boolean' },
|
||||
{ key: 'SOCIAL_MEDIA_PLATFORMS_LIST', label: 'Plattform-Liste (Text)', type: 'textarea', span: true },
|
||||
// ── DSI Erweiterungen ───────────────────────────────────────────────────
|
||||
{ key: 'DSI_TITLE', label: 'Titel', type: 'select', opts: ['Datenschutzerklaerung', 'Datenschutzinformation'] },
|
||||
{ key: 'SERVICE_SCOPE_DESCRIPTION', label: 'Geltungsbereich (z.B. "die App xy" / "den Online-Shop")' },
|
||||
{ key: 'HAS_ONLINE_SHOP', label: 'Online-Shop Funktionen', type: 'boolean' },
|
||||
{ key: 'HAS_PICKUP_STATION', label: 'Abholstationen', type: 'boolean' },
|
||||
{ key: 'HAS_SUBSCRIPTION', label: 'Abonnement-Modell', type: 'boolean' },
|
||||
{ key: 'HAS_PRODUCT_REVIEWS', label: 'Produktbewertungen', type: 'boolean' },
|
||||
{ key: 'HAS_PARENT_COMPANY', label: 'Konzernstruktur (Mutter-/Tochtergesellschaft)', type: 'boolean' },
|
||||
{ key: 'HAS_LOCATION', label: 'Standortdaten erhoben', type: 'boolean' },
|
||||
{ key: 'HAS_E2E_ENCRYPTION', label: 'Ende-zu-Ende-Verschlüsselung (Messaging)', type: 'boolean' },
|
||||
{ key: 'DETAILED_RIGHTS', label: 'Ausführliche Rechte-Beschreibung', type: 'boolean' },
|
||||
{ key: 'PROCESSOR_LIST_URL', label: 'URL Auftragsverarbeiter-Liste' },
|
||||
// ── Whistleblower ───────────────────────────────────────────────────────
|
||||
{ key: 'WHISTLEBLOWER_CONTACT_NAME', label: 'Meldestelle: Ansprechperson' },
|
||||
{ key: 'WHISTLEBLOWER_CONTACT_ROLE', label: 'Meldestelle: Funktion/Rolle' },
|
||||
{ key: 'WHISTLEBLOWER_EMAIL', label: 'Meldestelle: E-Mail', type: 'email' },
|
||||
{ key: 'WHISTLEBLOWER_PHONE', label: 'Meldestelle: Telefon' },
|
||||
{ key: 'WHISTLEBLOWER_URL', label: 'Meldestelle: Online-Formular URL' },
|
||||
{ key: 'HAS_ANONYMOUS_REPORTING', label: 'Anonyme Meldungen möglich', type: 'boolean' },
|
||||
{ key: 'HAS_EXTERNAL_REPORTING', label: 'Externe Meldestelle (BfJ) erwähnen', type: 'boolean' },
|
||||
// ── Bewerber-DSI ────────────────────────────────────────────────────────
|
||||
{ key: 'HAS_VIDEO_INTERVIEW', label: 'Video-Interviews', type: 'boolean' },
|
||||
{ key: 'HAS_ASSESSMENT', label: 'Assessment-Center/Tests', type: 'boolean' },
|
||||
{ key: 'HAS_TALENT_POOL', label: 'Talentpool (Einwilligung)', type: 'boolean' },
|
||||
{ key: 'TALENT_POOL_MONTHS', label: 'Talentpool Aufbewahrung (Monate)', type: 'select', opts: ['6', '12', '24'] },
|
||||
{ key: 'HAS_RECRUITING_AGENCY', label: 'Personalvermittler', type: 'boolean' },
|
||||
{ key: 'HAS_RECRUITING_SOFTWARE', label: 'Bewerbermanagement-Software', type: 'boolean' },
|
||||
{ key: 'HAS_EMPLOYEE_REFERRAL', label: 'Mitarbeiterempfehlungen', type: 'boolean' },
|
||||
// ── Mitarbeiter-DSI ─────────────────────────────────────────────────────
|
||||
{ key: 'HAS_IT_USAGE_MONITORING', label: 'IT-Nutzungsüberwachung', type: 'boolean' },
|
||||
{ key: 'HAS_COMPANY_VEHICLE', label: 'Dienstfahrzeuge/Fuhrpark', type: 'boolean' },
|
||||
{ key: 'HAS_ACCESS_CONTROL', label: 'Zutrittskontrolle (Chipkarte)', type: 'boolean' },
|
||||
{ key: 'HAS_VIDEO_SURVEILLANCE', label: 'Videoüberwachung (Arbeitsplatz)', type: 'boolean' },
|
||||
{ key: 'HAS_COMPANY_PENSION', label: 'Betriebliche Altersvorsorge', type: 'boolean' },
|
||||
{ key: 'HAS_EXTERNAL_HR_SOFTWARE', label: 'Externe HR-Software', type: 'boolean' },
|
||||
{ key: 'HAS_WORKS_COUNCIL', label: 'Betriebsrat vorhanden', type: 'boolean' },
|
||||
{ key: 'HAS_SPECIAL_CATEGORIES_EMPLOYEES', label: 'Besondere Datenkategorien (Gesundheit, Religion)', type: 'boolean' },
|
||||
],
|
||||
// ── TOM ─────────────────────────────────────────────────────────────────
|
||||
TOM: [
|
||||
{ key: 'ISB_NAME', label: 'IT-Sicherheitsbeauftragter' },
|
||||
{ key: 'GF_NAME', label: 'Geschäftsführung' },
|
||||
{ key: 'DOCUMENT_VERSION', label: 'Dokumentversion' },
|
||||
{ key: 'NEXT_REVIEW_DATE', label: 'Nächste Prüfung (JJJJ-MM-TT)' },
|
||||
{ key: 'HAS_MFA', label: 'Multi-Faktor-Authentifizierung aktiv', type: 'boolean' },
|
||||
{ key: 'HAS_USB_LOCKED', label: 'USB-Schnittstellen physisch gesperrt', type: 'boolean' },
|
||||
{ key: 'HAS_MOBILE_MEDIA', label: 'Mobile Datenträger im Einsatz', type: 'boolean' },
|
||||
{ key: 'HAS_FOUR_EYES_DELETE', label: 'Vier-Augen-Prinzip für Löschungen', type: 'boolean' },
|
||||
{ key: 'LOG_RETENTION_MONTHS', label: 'Log-Aufbewahrung (Monate)', type: 'select', opts: ['3', '6', '12', '24'] },
|
||||
{ key: 'DIN_66399_LEVEL', label: 'Vernichtungsstufe (DIN 66399)', type: 'select', opts: ['1', '2', '3', '4', '5', '6', '7'] },
|
||||
{ key: 'HAS_EXTERNAL_DESTRUCTION', label: 'Externer Vernichtungsdienstleister', type: 'boolean' },
|
||||
{ key: 'HAS_PHYSICAL_TRANSPORT', label: 'Physischer Datenträgertransport', type: 'boolean' },
|
||||
{ key: 'HAS_THIRD_COUNTRY_TRANSFER', label: 'Datenübermittlung in Drittländer', type: 'boolean' },
|
||||
{ key: 'AVAILABILITY_TARGET', label: 'Verfügbarkeitsziel', type: 'select', opts: ['99.0', '99.5', '99.9', '99.99'] },
|
||||
{ key: 'HAS_USV', label: 'USV vorhanden', type: 'boolean' },
|
||||
{ key: 'HAS_REDUNDANCY', label: 'Redundante Systeme / Failover', type: 'boolean' },
|
||||
{ key: 'HAS_GEO_REDUNDANCY', label: 'Georedundanter Standort', type: 'boolean' },
|
||||
{ key: 'HAS_OWN_SERVER_ROOM', label: 'Eigener Serverraum', type: 'boolean' },
|
||||
{ key: 'HAS_CLOUD_SERVICES', label: 'Cloud-Dienste im Einsatz', type: 'boolean' },
|
||||
{ key: 'HAS_MULTI_TENANT', label: 'Multi-Tenant-System', type: 'boolean' },
|
||||
{ key: 'SEPARATION_TYPE', label: 'Art der Mandantentrennung', type: 'select', opts: ['logisch', 'physisch', 'eigene Infrastruktur'] },
|
||||
{ key: 'HAS_TEST_DATA_ANONYMIZED', label: 'Testdaten anonymisiert/synthetisch', type: 'boolean' },
|
||||
],
|
||||
// ── DPA / AVV ─────────────────────────────────────────────────────────
|
||||
DPA: [
|
||||
{ key: 'AG_NAME', label: 'Auftraggeber (Name/Firma)' },
|
||||
{ key: 'AG_STRASSE', label: 'Auftraggeber Straße' },
|
||||
{ key: 'AG_PLZ_ORT', label: 'Auftraggeber PLZ Ort' },
|
||||
{ key: 'AN_NAME', label: 'Auftragnehmer (Name/Firma)' },
|
||||
{ key: 'AN_STRASSE', label: 'Auftragnehmer Straße' },
|
||||
{ key: 'AN_PLZ_ORT', label: 'Auftragnehmer PLZ Ort' },
|
||||
{ key: 'VERARBEITUNGSGEGENSTAND', label: 'Gegenstand der Verarbeitung', type: 'textarea', span: true },
|
||||
{ key: 'VERARBEITUNGSZWECK', label: 'Zweck der Verarbeitung', type: 'textarea', span: true },
|
||||
{ key: 'VERARBEITUNGSARTEN', label: 'Art der Verarbeitung (Erheben, Speichern, …)', type: 'textarea', span: true },
|
||||
{ key: 'DATENKATEGORIEN', label: 'Datenkategorien', type: 'textarea', span: true },
|
||||
{ key: 'PERSONENKATEGORIEN', label: 'Betroffene Personenkategorien', type: 'textarea', span: true },
|
||||
{ key: 'BREACH_NOTIFICATION_HOURS', label: 'Meldefrist Datenschutzverletzung (h)', type: 'select', opts: ['12', '24', '48'] },
|
||||
{ key: 'INSTRUCTION_RETENTION_YEARS', label: 'Aufbewahrung Weisungen (Jahre)', type: 'select', opts: ['3', '5', '10'] },
|
||||
{ key: 'SUB_PROCESSOR_NOTICE_WEEKS', label: 'Ankündigung Sub-AV (Wochen)', type: 'select', opts: ['2', '4', '6'] },
|
||||
{ key: 'SUB_PROCESSOR_OBJECTION_WEEKS', label: 'Widerspruchsfrist Sub-AV (Wochen)', type: 'select', opts: ['2', '4'] },
|
||||
{ key: 'DATA_EXPORT_FORMAT', label: 'Datenformat bei Rückgabe', type: 'select', opts: ['CSV/JSON', 'CSV', 'JSON', 'XML', 'nach Vereinbarung'] },
|
||||
{ key: 'RETURN_CHOICE_WEEKS', label: 'Frist Rückgabe-Wahl (Wochen)', type: 'select', opts: ['2', '4', '8'] },
|
||||
{ key: 'DELETION_DAYS', label: 'Löschfrist nach Vertragsende (Tage)', type: 'select', opts: ['30', '60', '90'] },
|
||||
{ key: 'AN_DSB_NAME', label: 'DSB Auftragnehmer Name' },
|
||||
{ key: 'AN_DSB_EMAIL', label: 'DSB Auftragnehmer E-Mail', type: 'email' },
|
||||
{ key: 'VERTRAGSDATUM', label: 'Vertragsdatum (JJJJ-MM-TT)' },
|
||||
{ key: 'GERICHTSSTAND', label: 'Gerichtsstand' },
|
||||
{ key: 'HAS_LIABILITY_PROTECTION', label: 'Haftungsschutz bei Weisung (§ 4.1a)', type: 'boolean' },
|
||||
{ key: 'HAS_SUPPORT_COST_CLAUSE', label: 'Kostenregelung Unterstützung (§ 7.4)', type: 'boolean' },
|
||||
{ key: 'HAS_SUB_PROCESSOR_SILENCE_APPROVAL', label: 'Zustimmungsfiktion bei Sub-AV (§ 8.2a)', type: 'boolean' },
|
||||
{ key: 'HAS_SUB_PROCESSOR_TERMINATION_RIGHT', label: 'Kündigungsrecht bei Sub-AV-Widerspruch (§ 8.3)', type: 'boolean' },
|
||||
{ key: 'HAS_REACTIVATION_PERIOD', label: 'Reaktivierungszeitraum (§ 10.1)', type: 'boolean' },
|
||||
{ key: 'REACTIVATION_MONTHS', label: 'Reaktivierung (Monate)', type: 'select', opts: ['1', '3', '6'] },
|
||||
{ key: 'HAS_RETURN_COST_CLAUSE', label: 'Kosten für Datenrückgabe (§ 10.5)', type: 'boolean' },
|
||||
{ key: 'HAS_GERICHTSSTAND_CLAUSE', label: 'Gerichtsstandklausel (§ 11.1)', type: 'boolean' },
|
||||
{ key: 'HAS_UNILATERAL_CHANGE_RIGHT', label: '⚠️ Einseitiges Änderungsrecht AN (§ 11.6)', type: 'boolean' },
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
TemplateContext,
|
||||
ProviderCtx,
|
||||
ComputedFlags,
|
||||
TOMCtx,
|
||||
DPACtx,
|
||||
} from './contextBridge'
|
||||
|
||||
// =============================================================================
|
||||
@@ -44,6 +46,8 @@ export function contextToPlaceholders(ctx: TemplateContext): Record<string, stri
|
||||
const con = ctx.CONSENT
|
||||
const h = ctx.HOSTING
|
||||
const f = ctx.FEATURES
|
||||
const tom = ctx.TOM
|
||||
const dpa = ctx.DPA
|
||||
|
||||
const address = providerAddress(p)
|
||||
|
||||
@@ -180,6 +184,86 @@ export function contextToPlaceholders(ctx: TemplateContext): Record<string, stri
|
||||
'{{LIMITATION_CAP_TEXT}}': str(f.LIMITATION_CAP_TEXT),
|
||||
'{{CONSUMER_WITHDRAWAL_TEXT}}': str(f.CONSUMER_WITHDRAWAL_TEXT),
|
||||
'{{SUPPORT_CHANNELS_TEXT}}': str(f.SUPPORT_CHANNELS_TEXT),
|
||||
|
||||
// --- TOM ---
|
||||
'{{ISB_NAME}}': str(tom.ISB_NAME),
|
||||
'{{GF_NAME}}': str(tom.GF_NAME),
|
||||
'{{DOCUMENT_VERSION}}': str(tom.DOCUMENT_VERSION),
|
||||
'{{NEXT_REVIEW_DATE}}': str(tom.NEXT_REVIEW_DATE),
|
||||
|
||||
// --- DPA / AVV ---
|
||||
'{{AG_NAME}}': str(dpa.AG_NAME) || str(c.LEGAL_NAME),
|
||||
'{{AG_STRASSE}}': str(dpa.AG_STRASSE) || str(c.ADDRESS_LINE),
|
||||
'{{AG_PLZ_ORT}}': str(dpa.AG_PLZ_ORT) || [c.POSTAL_CODE, c.CITY].filter(Boolean).join(' '),
|
||||
'{{AN_NAME}}': str(dpa.AN_NAME) || str(p.LEGAL_NAME),
|
||||
'{{AN_STRASSE}}': str(dpa.AN_STRASSE) || str(p.ADDRESS_LINE),
|
||||
'{{AN_PLZ_ORT}}': str(dpa.AN_PLZ_ORT) || [p.POSTAL_CODE, p.CITY].filter(Boolean).join(' '),
|
||||
'{{VERARBEITUNGSGEGENSTAND}}': str(dpa.VERARBEITUNGSGEGENSTAND),
|
||||
'{{VERARBEITUNGSZWECK}}': str(dpa.VERARBEITUNGSZWECK),
|
||||
'{{VERARBEITUNGSARTEN}}': str(dpa.VERARBEITUNGSARTEN),
|
||||
'{{DATENKATEGORIEN}}': str(dpa.DATENKATEGORIEN),
|
||||
'{{PERSONENKATEGORIEN}}': str(dpa.PERSONENKATEGORIEN),
|
||||
'{{BREACH_NOTIFICATION_HOURS}}': str(dpa.BREACH_NOTIFICATION_HOURS) || str(sec.INCIDENT_NOTICE_HOURS),
|
||||
'{{INSTRUCTION_RETENTION_YEARS}}': str(dpa.INSTRUCTION_RETENTION_YEARS),
|
||||
'{{SUB_PROCESSOR_NOTICE_WEEKS}}': str(dpa.SUB_PROCESSOR_NOTICE_WEEKS),
|
||||
'{{SUB_PROCESSOR_OBJECTION_WEEKS}}': str(dpa.SUB_PROCESSOR_OBJECTION_WEEKS),
|
||||
'{{DATA_EXPORT_FORMAT}}': str(dpa.DATA_EXPORT_FORMAT),
|
||||
'{{RETURN_CHOICE_WEEKS}}': str(dpa.RETURN_CHOICE_WEEKS),
|
||||
'{{DELETION_DAYS}}': str(dpa.DELETION_DAYS),
|
||||
'{{REACTIVATION_MONTHS}}': str(dpa.REACTIVATION_MONTHS),
|
||||
'{{TERMINATION_WEEKS}}': str(dpa.TERMINATION_WEEKS),
|
||||
'{{CHANGE_NOTICE_WEEKS}}': str(dpa.CHANGE_NOTICE_WEEKS),
|
||||
'{{THIRD_COUNTRY_OBJECTION_WEEKS}}': str(dpa.THIRD_COUNTRY_OBJECTION_WEEKS),
|
||||
'{{AN_DSB_NAME}}': str(dpa.AN_DSB_NAME) || str(prv.DPO_NAME),
|
||||
'{{AN_DSB_EMAIL}}': str(dpa.AN_DSB_EMAIL) || str(prv.DPO_EMAIL),
|
||||
'{{AG_ORT}}': str(dpa.AG_ORT),
|
||||
'{{AN_ORT}}': str(dpa.AN_ORT),
|
||||
'{{VERTRAGSDATUM}}': str(dpa.VERTRAGSDATUM) || str(l.VERSION_DATE),
|
||||
'{{AG_UNTERZEICHNER_NAME}}': str(dpa.AG_UNTERZEICHNER_NAME),
|
||||
'{{AG_UNTERZEICHNER_FUNKTION}}': str(dpa.AG_UNTERZEICHNER_FUNKTION),
|
||||
'{{AN_UNTERZEICHNER_NAME}}': str(dpa.AN_UNTERZEICHNER_NAME) || str(p.CEO_NAME),
|
||||
'{{AN_UNTERZEICHNER_FUNKTION}}': str(dpa.AN_UNTERZEICHNER_FUNKTION),
|
||||
'{{GERICHTSSTAND}}': str(dpa.GERICHTSSTAND) || str(l.JURISDICTION_CITY),
|
||||
|
||||
// --- FEATURES: Whistleblower ---
|
||||
'{{WHISTLEBLOWER_CONTACT_NAME}}': str(f.WHISTLEBLOWER_CONTACT_NAME),
|
||||
'{{WHISTLEBLOWER_CONTACT_ROLE}}': str(f.WHISTLEBLOWER_CONTACT_ROLE),
|
||||
'{{WHISTLEBLOWER_EMAIL}}': str(f.WHISTLEBLOWER_EMAIL),
|
||||
'{{WHISTLEBLOWER_PHONE}}': str(f.WHISTLEBLOWER_PHONE),
|
||||
'{{WHISTLEBLOWER_URL}}': str(f.WHISTLEBLOWER_URL),
|
||||
// --- FEATURES: Video Conference ---
|
||||
'{{VIDEO_PROVIDER_NAME}}': str(f.VIDEO_PROVIDER_NAME),
|
||||
'{{VIDEO_PROVIDER_COUNTRY}}': str(f.VIDEO_PROVIDER_COUNTRY),
|
||||
'{{VIDEO_PROVIDER_ROLE}}': str(f.VIDEO_PROVIDER_ROLE),
|
||||
'{{VIDEO_PROVIDER_PRIVACY_URL}}': str(f.VIDEO_PROVIDER_PRIVACY_URL),
|
||||
'{{RECORDING_RETENTION_DAYS}}': str(f.RECORDING_RETENTION_DAYS),
|
||||
// --- FEATURES: KI/AI ---
|
||||
'{{APPROVED_AI_SYSTEMS}}': str(f.APPROVED_AI_SYSTEMS),
|
||||
// --- FEATURES: BYOD ---
|
||||
'{{BYOD_COST_DETAILS}}': str(f.BYOD_COST_DETAILS),
|
||||
// --- FEATURES: Consent ---
|
||||
'{{NEWSLETTER_SIGNUP_URL}}': str(f.NEWSLETTER_SIGNUP_URL),
|
||||
// --- FEATURES: Social Media ---
|
||||
'{{SOCIAL_MEDIA_PLATFORMS_LIST}}': str(f.SOCIAL_MEDIA_PLATFORMS_LIST),
|
||||
'{{EDITORIAL_EMAIL}}': str(f.EDITORIAL_EMAIL),
|
||||
// --- FEATURES: Transfer/SCC ---
|
||||
'{{RECIPIENT_NAME}}': str(f.RECIPIENT_NAME),
|
||||
'{{RECIPIENT_COUNTRY}}': str(f.RECIPIENT_COUNTRY),
|
||||
'{{RECIPIENT_ADDRESS}}': str(f.RECIPIENT_ADDRESS),
|
||||
'{{RECIPIENT_CONTACT}}': str(f.RECIPIENT_CONTACT),
|
||||
'{{RECIPIENT_EMAIL}}': str(f.RECIPIENT_EMAIL),
|
||||
'{{RECIPIENT_ROLE}}': str(f.RECIPIENT_ROLE),
|
||||
'{{TRANSFER_PURPOSE}}': str(f.TRANSFER_PURPOSE),
|
||||
'{{TRANSFER_MECHANISM}}': str(f.TRANSFER_MECHANISM),
|
||||
'{{DATA_CATEGORIES_TRANSFERRED}}': str(f.DATA_CATEGORIES_TRANSFERRED),
|
||||
'{{DATA_SUBJECTS}}': str(f.DATA_SUBJECTS),
|
||||
'{{TRANSFER_FREQUENCY}}': str(f.TRANSFER_FREQUENCY),
|
||||
// --- FEATURES: DSI ---
|
||||
'{{DSI_TITLE}}': str(f.DSI_TITLE) || 'Datenschutzerklaerung',
|
||||
'{{SERVICE_SCOPE_DESCRIPTION}}': str(f.SERVICE_SCOPE_DESCRIPTION),
|
||||
'{{FULFILLMENT_LOCATION}}': str(f.FULFILLMENT_LOCATION),
|
||||
'{{GUIDELINES_URL}}': str(f.GUIDELINES_URL),
|
||||
'{{PROCESSOR_LIST_URL}}': str(f.PROCESSOR_LIST_URL),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +300,9 @@ const SECTION_COVERS: Record<keyof TemplateContext, string[]> = {
|
||||
NDA: ['{{PURPOSE}}', '{{DURATION_YEARS}}', '{{PENALTY_AMOUNT}}'],
|
||||
CONSENT: ['{{WEBSITE_NAME}}', '{{ANALYTICS_TOOLS}}', '{{MARKETING_PARTNERS}}', '{{ANALYTICS_TOOLS_LIST}}', '{{MARKETING_PARTNERS_LIST}}'],
|
||||
HOSTING: ['{{HOSTING_PROVIDER_NAME}}', '{{HOSTING_PROVIDER_COUNTRY}}', '{{HOSTING_PROVIDER_CONTRACT_TYPE}}'],
|
||||
FEATURES: ['{{CONSENT_WITHDRAWAL_PATH}}', '{{SECURITY_MEASURES_SUMMARY}}', '{{DATA_SUBJECT_REQUEST_CHANNEL}}', '{{TRANSFER_GUARDS}}', '{{REGULATED_PROFESSION_TEXT}}', '{{EDITORIAL_RESPONSIBLE_NAME}}', '{{EDITORIAL_RESPONSIBLE_ADDRESS}}', '{{DISPUTE_RESOLUTION_TEXT}}', '{{NEWSLETTER_PROVIDER_DETAIL}}', '{{PAYMENT_PROVIDER_DETAIL}}', '{{SOCIAL_MEDIA_DETAIL}}', '{{ANALYTICS_TOOLS_DETAIL}}', '{{MARKETING_TOOLS_DETAIL}}', '{{CMP_NAME}}', '{{PRICES_TEXT}}', '{{PAYMENT_TERMS_TEXT}}', '{{CONTRACT_TERM_TEXT}}', '{{SLA_URL}}', '{{EXPORT_POLICY_TEXT}}', '{{LIMITATION_CAP_TEXT}}', '{{CONSUMER_WITHDRAWAL_TEXT}}', '{{SUPPORT_CHANNELS_TEXT}}'],
|
||||
FEATURES: ['{{CONSENT_WITHDRAWAL_PATH}}', '{{SECURITY_MEASURES_SUMMARY}}', '{{DATA_SUBJECT_REQUEST_CHANNEL}}', '{{TRANSFER_GUARDS}}', '{{REGULATED_PROFESSION_TEXT}}', '{{EDITORIAL_RESPONSIBLE_NAME}}', '{{EDITORIAL_RESPONSIBLE_ADDRESS}}', '{{DISPUTE_RESOLUTION_TEXT}}', '{{NEWSLETTER_PROVIDER_DETAIL}}', '{{PAYMENT_PROVIDER_DETAIL}}', '{{SOCIAL_MEDIA_DETAIL}}', '{{ANALYTICS_TOOLS_DETAIL}}', '{{MARKETING_TOOLS_DETAIL}}', '{{CMP_NAME}}', '{{PRICES_TEXT}}', '{{PAYMENT_TERMS_TEXT}}', '{{CONTRACT_TERM_TEXT}}', '{{SLA_URL}}', '{{EXPORT_POLICY_TEXT}}', '{{LIMITATION_CAP_TEXT}}', '{{CONSUMER_WITHDRAWAL_TEXT}}', '{{SUPPORT_CHANNELS_TEXT}}', '{{WHISTLEBLOWER_CONTACT_NAME}}', '{{WHISTLEBLOWER_EMAIL}}', '{{WHISTLEBLOWER_URL}}', '{{VIDEO_PROVIDER_NAME}}', '{{APPROVED_AI_SYSTEMS}}', '{{SOCIAL_MEDIA_PLATFORMS_LIST}}', '{{DSI_TITLE}}', '{{SERVICE_SCOPE_DESCRIPTION}}', '{{GUIDELINES_URL}}', '{{PROCESSOR_LIST_URL}}', '{{RECIPIENT_NAME}}', '{{RECIPIENT_COUNTRY}}', '{{TRANSFER_PURPOSE}}', '{{TRANSFER_MECHANISM}}'],
|
||||
TOM: ['{{ISB_NAME}}', '{{GF_NAME}}', '{{DOCUMENT_VERSION}}', '{{NEXT_REVIEW_DATE}}'],
|
||||
DPA: ['{{AG_NAME}}', '{{AG_STRASSE}}', '{{AG_PLZ_ORT}}', '{{AN_NAME}}', '{{AN_STRASSE}}', '{{AN_PLZ_ORT}}', '{{VERARBEITUNGSGEGENSTAND}}', '{{VERARBEITUNGSZWECK}}', '{{VERARBEITUNGSARTEN}}', '{{DATENKATEGORIEN}}', '{{PERSONENKATEGORIEN}}', '{{BREACH_NOTIFICATION_HOURS}}', '{{INSTRUCTION_RETENTION_YEARS}}', '{{SUB_PROCESSOR_NOTICE_WEEKS}}', '{{SUB_PROCESSOR_OBJECTION_WEEKS}}', '{{DATA_EXPORT_FORMAT}}', '{{RETURN_CHOICE_WEEKS}}', '{{DELETION_DAYS}}', '{{REACTIVATION_MONTHS}}', '{{TERMINATION_WEEKS}}', '{{AN_DSB_NAME}}', '{{AN_DSB_EMAIL}}', '{{AG_ORT}}', '{{AN_ORT}}', '{{VERTRAGSDATUM}}', '{{AG_UNTERZEICHNER_NAME}}', '{{AG_UNTERZEICHNER_FUNKTION}}', '{{AN_UNTERZEICHNER_NAME}}', '{{AN_UNTERZEICHNER_FUNKTION}}', '{{GERICHTSSTAND}}'],
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -167,6 +167,84 @@ export interface FeaturesCtx {
|
||||
SUPPORT_CHANNELS_TEXT: string
|
||||
}
|
||||
|
||||
export interface TOMCtx {
|
||||
ISB_NAME: string
|
||||
GF_NAME: string
|
||||
DOCUMENT_VERSION: string
|
||||
NEXT_REVIEW_DATE: string
|
||||
// Conditional blocks
|
||||
HAS_PHYSICAL_TRANSPORT: boolean
|
||||
HAS_THIRD_COUNTRY_TRANSFER: boolean
|
||||
HAS_CLOUD_SERVICES: boolean
|
||||
HAS_MFA: boolean
|
||||
HAS_USB_LOCKED: boolean
|
||||
HAS_MOBILE_MEDIA: boolean
|
||||
HAS_FOUR_EYES_DELETE: boolean
|
||||
HAS_EXTERNAL_DESTRUCTION: boolean
|
||||
HAS_REDUNDANCY: boolean
|
||||
HAS_GEO_REDUNDANCY: boolean
|
||||
HAS_USV: boolean
|
||||
HAS_OWN_SERVER_ROOM: boolean
|
||||
HAS_MULTI_TENANT: boolean
|
||||
HAS_TEST_DATA_ANONYMIZED: boolean
|
||||
// Selects
|
||||
LOG_RETENTION_MONTHS: number | ''
|
||||
DIN_66399_LEVEL: string
|
||||
AVAILABILITY_TARGET: string
|
||||
SEPARATION_TYPE: string
|
||||
}
|
||||
|
||||
export interface DPACtx {
|
||||
// Parties
|
||||
AG_NAME: string
|
||||
AG_STRASSE: string
|
||||
AG_PLZ_ORT: string
|
||||
AN_NAME: string
|
||||
AN_STRASSE: string
|
||||
AN_PLZ_ORT: string
|
||||
// Processing details
|
||||
VERARBEITUNGSGEGENSTAND: string
|
||||
VERARBEITUNGSZWECK: string
|
||||
VERARBEITUNGSARTEN: string
|
||||
DATENKATEGORIEN: string
|
||||
PERSONENKATEGORIEN: string
|
||||
// Timings
|
||||
BREACH_NOTIFICATION_HOURS: number | ''
|
||||
INSTRUCTION_RETENTION_YEARS: number | ''
|
||||
SUB_PROCESSOR_NOTICE_WEEKS: number | ''
|
||||
SUB_PROCESSOR_OBJECTION_WEEKS: number | ''
|
||||
RETURN_CHOICE_WEEKS: number | ''
|
||||
DELETION_DAYS: number | ''
|
||||
REACTIVATION_MONTHS: number | ''
|
||||
TERMINATION_WEEKS: number | ''
|
||||
CHANGE_NOTICE_WEEKS: number | ''
|
||||
THIRD_COUNTRY_OBJECTION_WEEKS: number | ''
|
||||
// Data return
|
||||
DATA_EXPORT_FORMAT: string
|
||||
// DSB
|
||||
AN_DSB_NAME: string
|
||||
AN_DSB_EMAIL: string
|
||||
// Signatures
|
||||
AG_ORT: string
|
||||
AN_ORT: string
|
||||
VERTRAGSDATUM: string
|
||||
AG_UNTERZEICHNER_NAME: string
|
||||
AG_UNTERZEICHNER_FUNKTION: string
|
||||
AN_UNTERZEICHNER_NAME: string
|
||||
AN_UNTERZEICHNER_FUNKTION: string
|
||||
GERICHTSSTAND: string
|
||||
// Optional clauses
|
||||
HAS_LIABILITY_PROTECTION: boolean
|
||||
HAS_SUPPORT_COST_CLAUSE: boolean
|
||||
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: boolean
|
||||
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: boolean
|
||||
HAS_REACTIVATION_PERIOD: boolean
|
||||
HAS_RETURN_COST_CLAUSE: boolean
|
||||
HAS_GERICHTSSTAND_CLAUSE: boolean
|
||||
HAS_UNILATERAL_CHANGE_RIGHT: boolean
|
||||
HAS_THIRD_COUNTRY_OBJECTION: boolean
|
||||
}
|
||||
|
||||
export interface TemplateContext {
|
||||
PROVIDER: ProviderCtx
|
||||
CUSTOMER: CustomerCtx
|
||||
@@ -180,6 +258,8 @@ export interface TemplateContext {
|
||||
CONSENT: ConsentCtx
|
||||
HOSTING: HostingCtx
|
||||
FEATURES: FeaturesCtx
|
||||
TOM: TOMCtx
|
||||
DPA: DPACtx
|
||||
}
|
||||
|
||||
export interface ComputedFlags {
|
||||
@@ -263,6 +343,37 @@ export const EMPTY_CONTEXT: TemplateContext = {
|
||||
LIMITATION_CAP_TEXT: '', HAS_WITHDRAWAL: false, CONSUMER_WITHDRAWAL_TEXT: '',
|
||||
SUPPORT_CHANNELS_TEXT: '',
|
||||
},
|
||||
TOM: {
|
||||
ISB_NAME: '', GF_NAME: '', DOCUMENT_VERSION: '1.0.0', NEXT_REVIEW_DATE: '',
|
||||
HAS_PHYSICAL_TRANSPORT: false, HAS_THIRD_COUNTRY_TRANSFER: false,
|
||||
HAS_CLOUD_SERVICES: false, HAS_MFA: true, HAS_USB_LOCKED: false,
|
||||
HAS_MOBILE_MEDIA: false, HAS_FOUR_EYES_DELETE: false,
|
||||
HAS_EXTERNAL_DESTRUCTION: false, HAS_REDUNDANCY: false,
|
||||
HAS_GEO_REDUNDANCY: false, HAS_USV: true, HAS_OWN_SERVER_ROOM: false,
|
||||
HAS_MULTI_TENANT: false, HAS_TEST_DATA_ANONYMIZED: true,
|
||||
LOG_RETENTION_MONTHS: 6, DIN_66399_LEVEL: '3',
|
||||
AVAILABILITY_TARGET: '99.5', SEPARATION_TYPE: 'logisch',
|
||||
},
|
||||
DPA: {
|
||||
AG_NAME: '', AG_STRASSE: '', AG_PLZ_ORT: '',
|
||||
AN_NAME: '', AN_STRASSE: '', AN_PLZ_ORT: '',
|
||||
VERARBEITUNGSGEGENSTAND: '', VERARBEITUNGSZWECK: '', VERARBEITUNGSARTEN: '',
|
||||
DATENKATEGORIEN: '', PERSONENKATEGORIEN: '',
|
||||
BREACH_NOTIFICATION_HOURS: 24, INSTRUCTION_RETENTION_YEARS: 3,
|
||||
SUB_PROCESSOR_NOTICE_WEEKS: 2, SUB_PROCESSOR_OBJECTION_WEEKS: 2,
|
||||
RETURN_CHOICE_WEEKS: 4, DELETION_DAYS: 90, REACTIVATION_MONTHS: 3,
|
||||
TERMINATION_WEEKS: 4, CHANGE_NOTICE_WEEKS: 4, THIRD_COUNTRY_OBJECTION_WEEKS: 3,
|
||||
DATA_EXPORT_FORMAT: 'CSV/JSON', AN_DSB_NAME: '', AN_DSB_EMAIL: '',
|
||||
AG_ORT: '', AN_ORT: '', VERTRAGSDATUM: '',
|
||||
AG_UNTERZEICHNER_NAME: '', AG_UNTERZEICHNER_FUNKTION: 'Geschaeftsfuehrer',
|
||||
AN_UNTERZEICHNER_NAME: '', AN_UNTERZEICHNER_FUNKTION: 'Geschaeftsfuehrer',
|
||||
GERICHTSSTAND: '',
|
||||
HAS_LIABILITY_PROTECTION: false, HAS_SUPPORT_COST_CLAUSE: false,
|
||||
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: true, HAS_SUB_PROCESSOR_TERMINATION_RIGHT: false,
|
||||
HAS_REACTIVATION_PERIOD: true, HAS_RETURN_COST_CLAUSE: false,
|
||||
HAS_GERICHTSSTAND_CLAUSE: true, HAS_UNILATERAL_CHANGE_RIGHT: false,
|
||||
HAS_THIRD_COUNTRY_OBJECTION: false,
|
||||
},
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"document_type": "ai_usage_policy",
|
||||
"language": "de",
|
||||
"context": {
|
||||
"PROVIDER": { "LEGAL_NAME": "Muster GmbH" },
|
||||
"FEATURES": {
|
||||
"APPROVED_AI_SYSTEMS": "ChatGPT (OpenAI), GitHub Copilot, DeepL Pro",
|
||||
"HAS_APPROVED_AI_LIST": true,
|
||||
"HAS_AI_LABELING_INTERNAL": true,
|
||||
"HAS_TDM_OPTOUT": true
|
||||
},
|
||||
"TOM": { "DOCUMENT_VERSION": "1.0.0", "NEXT_REVIEW_DATE": "2026-11-01" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"document_type": "dpa",
|
||||
"language": "de",
|
||||
"context": {
|
||||
"DPA": {
|
||||
"AG_NAME": "Muster GmbH",
|
||||
"AG_STRASSE": "Musterstrasse 1",
|
||||
"AG_PLZ_ORT": "10115 Berlin",
|
||||
"AN_NAME": "BreakPilot GmbH",
|
||||
"AN_STRASSE": "Hardtring 6",
|
||||
"AN_PLZ_ORT": "78224 Singen",
|
||||
"VERARBEITUNGSGEGENSTAND": "Bereitstellung und Betrieb einer SaaS-Compliance-Plattform",
|
||||
"VERARBEITUNGSZWECK": "Compliance-Management, Dokumentengenerierung, Risikobewertung",
|
||||
"VERARBEITUNGSARTEN": "Erheben, Speichern, Veraendern, Auslesen, Abfragen, Uebermitteln, Loeschen",
|
||||
"DATENKATEGORIEN": "Stammdaten, Kontaktdaten, Vertragsdaten, Nutzungsdaten, Kommunikationsdaten",
|
||||
"PERSONENKATEGORIEN": "Mitarbeitende des Auftraggebers, Kunden des Auftraggebers, Ansprechpartner",
|
||||
"BREACH_NOTIFICATION_HOURS": 24,
|
||||
"INSTRUCTION_RETENTION_YEARS": 3,
|
||||
"SUB_PROCESSOR_NOTICE_WEEKS": 4,
|
||||
"SUB_PROCESSOR_OBJECTION_WEEKS": 2,
|
||||
"DATA_EXPORT_FORMAT": "CSV/JSON",
|
||||
"RETURN_CHOICE_WEEKS": 4,
|
||||
"DELETION_DAYS": 90,
|
||||
"AN_DSB_NAME": "Max Mustermann",
|
||||
"AN_DSB_EMAIL": "datenschutz@breakpilot.ai",
|
||||
"VERTRAGSDATUM": "2026-05-01",
|
||||
"AG_ORT": "Berlin",
|
||||
"AN_ORT": "Singen",
|
||||
"AG_UNTERZEICHNER_NAME": "Anna Beispiel",
|
||||
"AG_UNTERZEICHNER_FUNKTION": "Geschaeftsfuehrerin",
|
||||
"AN_UNTERZEICHNER_NAME": "Benjamin Boenisch",
|
||||
"AN_UNTERZEICHNER_FUNKTION": "Geschaeftsfuehrer",
|
||||
"GERICHTSSTAND": "Singen"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"document_type": "employee_dsi",
|
||||
"language": "de",
|
||||
"context": {
|
||||
"PROVIDER": {
|
||||
"LEGAL_NAME": "Muster GmbH",
|
||||
"LEGAL_FORM": "GmbH",
|
||||
"ADDRESS_LINE": "Musterstrasse 1",
|
||||
"POSTAL_CODE": "10115",
|
||||
"CITY": "Berlin",
|
||||
"COUNTRY": "DE",
|
||||
"EMAIL": "info@muster.de",
|
||||
"PHONE": "+49 30 123456"
|
||||
},
|
||||
"PRIVACY": {
|
||||
"DPO_NAME": "Dr. Datenschutz",
|
||||
"DPO_EMAIL": "dsb@muster.de",
|
||||
"SUPERVISORY_AUTHORITY_NAME": "Berliner Beauftragte fuer Datenschutz"
|
||||
},
|
||||
"FEATURES": {
|
||||
"HAS_IT_USAGE_MONITORING": true,
|
||||
"HAS_COMPANY_VEHICLE": false,
|
||||
"HAS_ACCESS_CONTROL": true,
|
||||
"HAS_VIDEO_SURVEILLANCE": false,
|
||||
"HAS_COMPANY_PENSION": true,
|
||||
"HAS_EXTERNAL_HR_SOFTWARE": true,
|
||||
"HAS_WORKS_COUNCIL": false,
|
||||
"HAS_SPECIAL_CATEGORIES_EMPLOYEES": true,
|
||||
"DATA_SUBJECT_REQUEST_CHANNEL": "per E-Mail an dsb@muster.de"
|
||||
},
|
||||
"SECURITY": { "LOG_RETENTION_DAYS": 90 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"document_type": "social_media_dsi",
|
||||
"language": "de",
|
||||
"context": {
|
||||
"PROVIDER": {
|
||||
"LEGAL_NAME": "Muster GmbH",
|
||||
"WEBSITE_URL": "https://www.muster.de",
|
||||
"EMAIL": "info@muster.de",
|
||||
"PHONE": "+49 30 123456"
|
||||
},
|
||||
"PRIVACY": {
|
||||
"DPO_EMAIL": "dsb@muster.de",
|
||||
"SUPERVISORY_AUTHORITY_NAME": "Berliner Beauftragte fuer Datenschutz",
|
||||
"SUPERVISORY_AUTHORITY_ADDRESS": "Friedrichstr. 219, 10969 Berlin"
|
||||
},
|
||||
"FEATURES": {
|
||||
"HAS_FACEBOOK": true,
|
||||
"HAS_YOUTUBE": true,
|
||||
"HAS_LINKEDIN": true,
|
||||
"HAS_TIKTOK": false,
|
||||
"HAS_X_TWITTER": false,
|
||||
"HAS_META_PIXEL": true,
|
||||
"HAS_RECRUITING_VIA_SOCIAL": true,
|
||||
"SOCIAL_MEDIA_PLATFORMS_LIST": "Facebook, Instagram, YouTube und LinkedIn"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"document_type": "transfer_impact_assessment",
|
||||
"language": "de",
|
||||
"context": {
|
||||
"PROVIDER": { "LEGAL_NAME": "Muster GmbH" },
|
||||
"PRIVACY": { "DPO_NAME": "Dr. Datenschutz", "DPO_EMAIL": "dsb@muster.de" },
|
||||
"FEATURES": {
|
||||
"RECIPIENT_NAME": "Cloud Provider Inc.",
|
||||
"RECIPIENT_COUNTRY": "US",
|
||||
"RECIPIENT_ROLE": "Auftragsverarbeiter",
|
||||
"TRANSFER_PURPOSE": "Hosting der Anwendungsdaten",
|
||||
"TRANSFER_MECHANISM": "EU-Standardvertragsklauseln (SCC) + EU-US DPF",
|
||||
"DATA_CATEGORIES_TRANSFERRED": "Stammdaten, Kontaktdaten, Nutzungsdaten",
|
||||
"DATA_SUBJECTS": "Kunden, Nutzer der Plattform",
|
||||
"TRANSFER_FREQUENCY": "Kontinuierlich (Echtzeit-Datenverarbeitung)"
|
||||
},
|
||||
"TOM": { "GF_NAME": "Max Geschaeftsfuehrer", "DOCUMENT_VERSION": "1.0.0", "NEXT_REVIEW_DATE": "2027-05-01" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"document_type": "tom_documentation",
|
||||
"language": "de",
|
||||
"context": {
|
||||
"TOM": {
|
||||
"ISB_NAME": "Thomas Sicher",
|
||||
"GF_NAME": "Benjamin Boenisch",
|
||||
"DOCUMENT_VERSION": "2.0.0",
|
||||
"NEXT_REVIEW_DATE": "2027-05-01",
|
||||
"HAS_MFA": true,
|
||||
"HAS_USB_LOCKED": false,
|
||||
"HAS_MOBILE_MEDIA": false,
|
||||
"HAS_FOUR_EYES_DELETE": true,
|
||||
"HAS_EXTERNAL_DESTRUCTION": true,
|
||||
"HAS_PHYSICAL_TRANSPORT": false,
|
||||
"HAS_THIRD_COUNTRY_TRANSFER": false,
|
||||
"HAS_CLOUD_SERVICES": true,
|
||||
"HAS_REDUNDANCY": true,
|
||||
"HAS_GEO_REDUNDANCY": false,
|
||||
"HAS_USV": true,
|
||||
"HAS_OWN_SERVER_ROOM": true,
|
||||
"HAS_MULTI_TENANT": true,
|
||||
"HAS_TEST_DATA_ANONYMIZED": true,
|
||||
"LOG_RETENTION_MONTHS": 12,
|
||||
"DIN_66399_LEVEL": "4",
|
||||
"AVAILABILITY_TARGET": "99.9",
|
||||
"SEPARATION_TYPE": "logisch"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"document_type": "whistleblower_policy",
|
||||
"language": "de",
|
||||
"context": {
|
||||
"PROVIDER": {
|
||||
"LEGAL_NAME": "Muster GmbH"
|
||||
},
|
||||
"FEATURES": {
|
||||
"WHISTLEBLOWER_CONTACT_NAME": "Dr. Maria Compliance",
|
||||
"WHISTLEBLOWER_CONTACT_ROLE": "Compliance-Beauftragte / Meldestellenbeauftragte",
|
||||
"WHISTLEBLOWER_EMAIL": "meldestelle@muster.de",
|
||||
"WHISTLEBLOWER_PHONE": "+49 123 456789",
|
||||
"WHISTLEBLOWER_URL": "https://muster.de/meldestelle",
|
||||
"HAS_ANONYMOUS_REPORTING": true,
|
||||
"HAS_EXTERNAL_REPORTING": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,10 @@ import { generateAllPlaceholders } from '@/lib/sdk/document-generator/datapoint-
|
||||
import { loadAllTemplates } from './searchTemplates'
|
||||
import { TemplateContext, EMPTY_CONTEXT } from './contextBridge'
|
||||
import { CATEGORIES } from './_constants'
|
||||
import { getGeneratorDefaults, getProfileLabel } from './scopeDefaults'
|
||||
import TemplateLibrary from './_components/TemplateLibrary'
|
||||
import GeneratorSection from './_components/GeneratorSection'
|
||||
import RecommendedDocuments from './_components/RecommendedDocuments'
|
||||
|
||||
function DocumentGeneratorPageInner() {
|
||||
const { state } = useSDK()
|
||||
@@ -86,6 +88,119 @@ function DocumentGeneratorPageInner() {
|
||||
}
|
||||
}, [state?.companyProfile])
|
||||
|
||||
// Pre-fill TOM/DPA context from Compliance Scope Engine
|
||||
useEffect(() => {
|
||||
const scopeLevel = state?.complianceScope?.determinedLevel
|
||||
if (scopeLevel) {
|
||||
const defaults = getGeneratorDefaults(scopeLevel, state?.companyProfile as never)
|
||||
setContext((prev) => ({
|
||||
...prev,
|
||||
TOM: { ...prev.TOM, ...defaults.tom },
|
||||
DPA: { ...prev.DPA, ...defaults.dpa },
|
||||
}))
|
||||
}
|
||||
}, [state?.complianceScope?.determinedLevel, state?.companyProfile])
|
||||
|
||||
// ── MODULE WIRING: Backend Banner-Config → CONSENT + FEATURES ────────────
|
||||
useEffect(() => {
|
||||
// Fetch real vendor/category data from backend if SDK state has no banner
|
||||
if (state?.cookieBanner) return // SDK state takes priority
|
||||
fetch('/api/sdk/v1/banner/admin/sites', { headers: { 'x-tenant-id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' } })
|
||||
.then(r => r.json())
|
||||
.then((sites: Array<{ site_id: string }>) => {
|
||||
if (!sites?.length) return
|
||||
return fetch(`/api/sdk/v1/banner/config/${sites[0].site_id}`, { headers: { 'x-tenant-id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' } })
|
||||
})
|
||||
.then(r => r?.json())
|
||||
.then(config => {
|
||||
if (!config?.vendors?.length) return
|
||||
const analytics = config.vendors.filter((v: { category_key: string }) => v.category_key === 'statistics' || v.category_key === 'analytics').map((v: { vendor_name: string }) => v.vendor_name)
|
||||
const marketing = config.vendors.filter((v: { category_key: string }) => v.category_key === 'marketing').map((v: { vendor_name: string }) => v.vendor_name)
|
||||
setContext(prev => ({
|
||||
...prev,
|
||||
CONSENT: {
|
||||
...prev.CONSENT,
|
||||
ANALYTICS_TOOLS: analytics.length > 0 ? analytics.join(', ') : prev.CONSENT.ANALYTICS_TOOLS,
|
||||
MARKETING_PARTNERS: marketing.length > 0 ? marketing.join(', ') : prev.CONSENT.MARKETING_PARTNERS,
|
||||
},
|
||||
FEATURES: { ...prev.FEATURES, CMP_NAME: 'BreakPilot CMP', CMP_LOGS_CONSENTS: true },
|
||||
}))
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [state?.cookieBanner])
|
||||
|
||||
// ── MODULE WIRING: CookieBanner SDK State → CONSENT + FEATURES ──────────
|
||||
useEffect(() => {
|
||||
const banner = state?.cookieBanner
|
||||
if (!banner) return
|
||||
const cats = banner.categories || []
|
||||
const analyticsTools = cats
|
||||
.filter((c) => c.id === 'analytics' || c.id === 'statistics')
|
||||
.flatMap((c) => c.cookies?.map((ck) => ck.name) ?? [])
|
||||
const marketingTools = cats
|
||||
.filter((c) => c.id === 'marketing')
|
||||
.flatMap((c) => c.cookies?.map((ck) => ck.name) ?? [])
|
||||
const hasFunctional = cats.some((c) => c.id === 'functional')
|
||||
|
||||
setContext((prev) => ({
|
||||
...prev,
|
||||
CONSENT: {
|
||||
...prev.CONSENT,
|
||||
ANALYTICS_TOOLS: analyticsTools.length > 0 ? analyticsTools.join(', ') : prev.CONSENT.ANALYTICS_TOOLS,
|
||||
MARKETING_PARTNERS: marketingTools.length > 0 ? marketingTools.join(', ') : prev.CONSENT.MARKETING_PARTNERS,
|
||||
},
|
||||
FEATURES: {
|
||||
...prev.FEATURES,
|
||||
CMP_NAME: 'BreakPilot CMP',
|
||||
CMP_LOGS_CONSENTS: true,
|
||||
HAS_FUNCTIONAL_COOKIES: hasFunctional || prev.FEATURES.HAS_FUNCTIONAL_COOKIES,
|
||||
CONSENT_WITHDRAWAL_PATH: 'Footer-Link "Cookie-Einstellungen"',
|
||||
},
|
||||
}))
|
||||
}, [state?.cookieBanner])
|
||||
|
||||
// ── MODULE WIRING: Loeschfristen → PRIVACY retention ──────────────────────
|
||||
useEffect(() => {
|
||||
const policies = state?.retentionPolicies
|
||||
if (!policies || policies.length === 0) return
|
||||
const maxMonths = policies.reduce((max, p) => {
|
||||
const match = p.retentionPeriod?.match(/(\d+)\s*(Monat|Jahr|Tag)/i)
|
||||
if (!match) return max
|
||||
const val = parseInt(match[1], 10)
|
||||
const unit = match[2].toLowerCase()
|
||||
const months = unit.startsWith('jahr') ? val * 12 : unit.startsWith('tag') ? Math.ceil(val / 30) : val
|
||||
return Math.max(max, months)
|
||||
}, 0)
|
||||
if (maxMonths > 0) {
|
||||
setContext((prev) => ({
|
||||
...prev,
|
||||
PRIVACY: { ...prev.PRIVACY, ANALYTICS_RETENTION_MONTHS: maxMonths },
|
||||
}))
|
||||
}
|
||||
}, [state?.retentionPolicies])
|
||||
|
||||
// ── MODULE WIRING: UseCases → FEATURES flags ─────────────────────────────
|
||||
useEffect(() => {
|
||||
const useCases = state?.useCases
|
||||
if (!useCases || useCases.length === 0) return
|
||||
const allText = useCases.map((uc) => `${uc.name} ${uc.description}`).join(' ').toLowerCase()
|
||||
const hasAccount = allText.includes('account') || allText.includes('konto') || allText.includes('registrier')
|
||||
const hasPayments = allText.includes('zahlung') || allText.includes('payment') || allText.includes('stripe') || allText.includes('paypal')
|
||||
const hasNewsletter = allText.includes('newsletter') || allText.includes('mailchimp') || allText.includes('e-mail-marketing')
|
||||
const hasSocial = allText.includes('social') || allText.includes('linkedin') || allText.includes('facebook') || allText.includes('instagram')
|
||||
|
||||
setContext((prev) => ({
|
||||
...prev,
|
||||
FEATURES: {
|
||||
...prev.FEATURES,
|
||||
HAS_ACCOUNT: hasAccount || prev.FEATURES.HAS_ACCOUNT,
|
||||
HAS_PAYMENTS: hasPayments || prev.FEATURES.HAS_PAYMENTS,
|
||||
HAS_NEWSLETTER: hasNewsletter || prev.FEATURES.HAS_NEWSLETTER,
|
||||
HAS_SOCIAL_MEDIA: hasSocial || prev.FEATURES.HAS_SOCIAL_MEDIA,
|
||||
},
|
||||
}))
|
||||
}, [state?.useCases])
|
||||
|
||||
// Pre-fill extra placeholders from Einwilligungen data points
|
||||
useEffect(() => {
|
||||
if (selectedDataPointsData && selectedDataPointsData.length > 0) {
|
||||
@@ -177,6 +292,12 @@ function DocumentGeneratorPageInner() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommended documents based on scope profile */}
|
||||
<RecommendedDocuments
|
||||
allTemplates={allTemplates}
|
||||
onUseTemplate={handleUseTemplate}
|
||||
/>
|
||||
|
||||
<TemplateLibrary
|
||||
allTemplates={allTemplates}
|
||||
filteredTemplates={filteredTemplates}
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Scope-basierte Generator-Defaults
|
||||
*
|
||||
* Nimmt ScopeDecision.determinedLevel + CompanyProfile und liefert
|
||||
* vorausgefuellte TOM/DPA-Context-Werte. Alle Felder bleiben vom
|
||||
* Kunden aenderbar — die Defaults sind Empfehlungen.
|
||||
*
|
||||
* Mapping:
|
||||
* L1 = Lean Startup (≤10 MA, Cloud-only, Home Office)
|
||||
* L2 = KMU Standard (11-249 MA)
|
||||
* L3 = Erweitert (risikoreich oder >100 MA)
|
||||
* L4 = Zertifizierungsbereit (≥250 MA oder regulierte Branche)
|
||||
*/
|
||||
|
||||
import type { ComplianceDepthLevel } from '../../lib/sdk/compliance-scope-types/core-levels'
|
||||
import type { CompanyProfile } from '../../lib/sdk/types'
|
||||
import type { TOMCtx, DPACtx } from './contextBridge'
|
||||
|
||||
// ============================================================================
|
||||
// TOM Defaults per Level
|
||||
// ============================================================================
|
||||
|
||||
const TOM_DEFAULTS: Record<ComplianceDepthLevel, Partial<TOMCtx>> = {
|
||||
L1: {
|
||||
// Lean Startup: Cloud-only, kein eigener Serverraum, Home Office
|
||||
HAS_MFA: true,
|
||||
HAS_USB_LOCKED: false,
|
||||
HAS_MOBILE_MEDIA: false,
|
||||
HAS_FOUR_EYES_DELETE: false,
|
||||
HAS_EXTERNAL_DESTRUCTION: false,
|
||||
HAS_PHYSICAL_TRANSPORT: false,
|
||||
HAS_THIRD_COUNTRY_TRANSFER: false,
|
||||
HAS_CLOUD_SERVICES: true,
|
||||
HAS_REDUNDANCY: false,
|
||||
HAS_GEO_REDUNDANCY: false,
|
||||
HAS_USV: false,
|
||||
HAS_OWN_SERVER_ROOM: false,
|
||||
HAS_MULTI_TENANT: false,
|
||||
HAS_TEST_DATA_ANONYMIZED: true,
|
||||
LOG_RETENTION_MONTHS: 3,
|
||||
DIN_66399_LEVEL: '3',
|
||||
AVAILABILITY_TARGET: '99.0',
|
||||
SEPARATION_TYPE: 'logisch',
|
||||
},
|
||||
L2: {
|
||||
// KMU Standard
|
||||
HAS_MFA: true,
|
||||
HAS_USB_LOCKED: false,
|
||||
HAS_MOBILE_MEDIA: false,
|
||||
HAS_FOUR_EYES_DELETE: false,
|
||||
HAS_EXTERNAL_DESTRUCTION: false,
|
||||
HAS_PHYSICAL_TRANSPORT: false,
|
||||
HAS_THIRD_COUNTRY_TRANSFER: false,
|
||||
HAS_CLOUD_SERVICES: true,
|
||||
HAS_REDUNDANCY: false,
|
||||
HAS_GEO_REDUNDANCY: false,
|
||||
HAS_USV: false,
|
||||
HAS_OWN_SERVER_ROOM: false,
|
||||
HAS_MULTI_TENANT: false,
|
||||
HAS_TEST_DATA_ANONYMIZED: true,
|
||||
LOG_RETENTION_MONTHS: 6,
|
||||
DIN_66399_LEVEL: '3',
|
||||
AVAILABILITY_TARGET: '99.5',
|
||||
SEPARATION_TYPE: 'logisch',
|
||||
},
|
||||
L3: {
|
||||
// Erweitert
|
||||
HAS_MFA: true,
|
||||
HAS_USB_LOCKED: false,
|
||||
HAS_MOBILE_MEDIA: false,
|
||||
HAS_FOUR_EYES_DELETE: true,
|
||||
HAS_EXTERNAL_DESTRUCTION: true,
|
||||
HAS_PHYSICAL_TRANSPORT: false,
|
||||
HAS_THIRD_COUNTRY_TRANSFER: false,
|
||||
HAS_CLOUD_SERVICES: true,
|
||||
HAS_REDUNDANCY: true,
|
||||
HAS_GEO_REDUNDANCY: false,
|
||||
HAS_USV: true,
|
||||
HAS_OWN_SERVER_ROOM: true,
|
||||
HAS_MULTI_TENANT: true,
|
||||
HAS_TEST_DATA_ANONYMIZED: true,
|
||||
LOG_RETENTION_MONTHS: 12,
|
||||
DIN_66399_LEVEL: '4',
|
||||
AVAILABILITY_TARGET: '99.9',
|
||||
SEPARATION_TYPE: 'logisch',
|
||||
},
|
||||
L4: {
|
||||
// Zertifizierungsbereit / Enterprise
|
||||
HAS_MFA: true,
|
||||
HAS_USB_LOCKED: true,
|
||||
HAS_MOBILE_MEDIA: false,
|
||||
HAS_FOUR_EYES_DELETE: true,
|
||||
HAS_EXTERNAL_DESTRUCTION: true,
|
||||
HAS_PHYSICAL_TRANSPORT: false,
|
||||
HAS_THIRD_COUNTRY_TRANSFER: false,
|
||||
HAS_CLOUD_SERVICES: true,
|
||||
HAS_REDUNDANCY: true,
|
||||
HAS_GEO_REDUNDANCY: true,
|
||||
HAS_USV: true,
|
||||
HAS_OWN_SERVER_ROOM: true,
|
||||
HAS_MULTI_TENANT: true,
|
||||
HAS_TEST_DATA_ANONYMIZED: true,
|
||||
LOG_RETENTION_MONTHS: 24,
|
||||
DIN_66399_LEVEL: '5',
|
||||
AVAILABILITY_TARGET: '99.99',
|
||||
SEPARATION_TYPE: 'logisch',
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DPA Defaults per Level
|
||||
// ============================================================================
|
||||
|
||||
const DPA_DEFAULTS: Record<ComplianceDepthLevel, Partial<DPACtx>> = {
|
||||
L1: {
|
||||
BREACH_NOTIFICATION_HOURS: 48,
|
||||
INSTRUCTION_RETENTION_YEARS: 3,
|
||||
SUB_PROCESSOR_NOTICE_WEEKS: 2,
|
||||
SUB_PROCESSOR_OBJECTION_WEEKS: 2,
|
||||
DATA_EXPORT_FORMAT: 'CSV/JSON',
|
||||
RETURN_CHOICE_WEEKS: 4,
|
||||
DELETION_DAYS: 90,
|
||||
HAS_LIABILITY_PROTECTION: false,
|
||||
HAS_SUPPORT_COST_CLAUSE: false,
|
||||
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: true,
|
||||
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: false,
|
||||
HAS_REACTIVATION_PERIOD: true,
|
||||
REACTIVATION_MONTHS: 3,
|
||||
HAS_RETURN_COST_CLAUSE: false,
|
||||
HAS_GERICHTSSTAND_CLAUSE: false,
|
||||
HAS_UNILATERAL_CHANGE_RIGHT: false,
|
||||
HAS_THIRD_COUNTRY_OBJECTION: false,
|
||||
},
|
||||
L2: {
|
||||
BREACH_NOTIFICATION_HOURS: 24,
|
||||
INSTRUCTION_RETENTION_YEARS: 3,
|
||||
SUB_PROCESSOR_NOTICE_WEEKS: 4,
|
||||
SUB_PROCESSOR_OBJECTION_WEEKS: 2,
|
||||
DATA_EXPORT_FORMAT: 'CSV/JSON',
|
||||
RETURN_CHOICE_WEEKS: 4,
|
||||
DELETION_DAYS: 90,
|
||||
HAS_LIABILITY_PROTECTION: false,
|
||||
HAS_SUPPORT_COST_CLAUSE: false,
|
||||
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: true,
|
||||
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: false,
|
||||
HAS_REACTIVATION_PERIOD: true,
|
||||
REACTIVATION_MONTHS: 3,
|
||||
HAS_RETURN_COST_CLAUSE: false,
|
||||
HAS_GERICHTSSTAND_CLAUSE: true,
|
||||
HAS_UNILATERAL_CHANGE_RIGHT: false,
|
||||
HAS_THIRD_COUNTRY_OBJECTION: false,
|
||||
},
|
||||
L3: {
|
||||
BREACH_NOTIFICATION_HOURS: 24,
|
||||
INSTRUCTION_RETENTION_YEARS: 5,
|
||||
SUB_PROCESSOR_NOTICE_WEEKS: 4,
|
||||
SUB_PROCESSOR_OBJECTION_WEEKS: 4,
|
||||
DATA_EXPORT_FORMAT: 'CSV/JSON',
|
||||
RETURN_CHOICE_WEEKS: 4,
|
||||
DELETION_DAYS: 60,
|
||||
HAS_LIABILITY_PROTECTION: true,
|
||||
HAS_SUPPORT_COST_CLAUSE: true,
|
||||
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: true,
|
||||
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: true,
|
||||
HAS_REACTIVATION_PERIOD: true,
|
||||
REACTIVATION_MONTHS: 3,
|
||||
HAS_RETURN_COST_CLAUSE: true,
|
||||
HAS_GERICHTSSTAND_CLAUSE: true,
|
||||
HAS_UNILATERAL_CHANGE_RIGHT: false,
|
||||
HAS_THIRD_COUNTRY_OBJECTION: false,
|
||||
},
|
||||
L4: {
|
||||
BREACH_NOTIFICATION_HOURS: 12,
|
||||
INSTRUCTION_RETENTION_YEARS: 5,
|
||||
SUB_PROCESSOR_NOTICE_WEEKS: 6,
|
||||
SUB_PROCESSOR_OBJECTION_WEEKS: 4,
|
||||
DATA_EXPORT_FORMAT: 'CSV/JSON',
|
||||
RETURN_CHOICE_WEEKS: 8,
|
||||
DELETION_DAYS: 30,
|
||||
HAS_LIABILITY_PROTECTION: true,
|
||||
HAS_SUPPORT_COST_CLAUSE: true,
|
||||
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: false,
|
||||
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: true,
|
||||
HAS_REACTIVATION_PERIOD: false,
|
||||
REACTIVATION_MONTHS: 3,
|
||||
HAS_RETURN_COST_CLAUSE: true,
|
||||
HAS_GERICHTSSTAND_CLAUSE: true,
|
||||
HAS_UNILATERAL_CHANGE_RIGHT: false,
|
||||
HAS_THIRD_COUNTRY_OBJECTION: false,
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
export interface GeneratorDefaults {
|
||||
tom: Partial<TOMCtx>
|
||||
dpa: Partial<DPACtx>
|
||||
/** Which fields were set by the scope engine (for UI highlighting) */
|
||||
scopeSet: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Generator-Defaults basierend auf dem Compliance-Level
|
||||
* und dem CompanyProfile. Alle Werte sind Vorschlaege — der Kunde
|
||||
* kann sie aendern.
|
||||
*/
|
||||
export function getGeneratorDefaults(
|
||||
level: ComplianceDepthLevel,
|
||||
profile?: CompanyProfile | null,
|
||||
): GeneratorDefaults {
|
||||
const tomBase = { ...TOM_DEFAULTS[level] }
|
||||
const dpaBase = { ...DPA_DEFAULTS[level] }
|
||||
const scopeSet = new Set<string>()
|
||||
|
||||
// CompanyProfile-Felder in TOM/DPA uebernehmen
|
||||
if (profile) {
|
||||
if (profile.company_name) {
|
||||
dpaBase.AN_NAME = profile.company_name
|
||||
scopeSet.add('DPA.AN_NAME')
|
||||
}
|
||||
if (profile.address) {
|
||||
dpaBase.AN_STRASSE = profile.address
|
||||
scopeSet.add('DPA.AN_STRASSE')
|
||||
}
|
||||
if (profile.city && profile.postal_code) {
|
||||
dpaBase.AN_PLZ_ORT = `${profile.postal_code} ${profile.city}`
|
||||
scopeSet.add('DPA.AN_PLZ_ORT')
|
||||
}
|
||||
if (profile.dpo_name) {
|
||||
tomBase.ISB_NAME = tomBase.ISB_NAME || ''
|
||||
dpaBase.AN_DSB_NAME = profile.dpo_name
|
||||
scopeSet.add('DPA.AN_DSB_NAME')
|
||||
}
|
||||
if (profile.dpo_email) {
|
||||
dpaBase.AN_DSB_EMAIL = profile.dpo_email
|
||||
scopeSet.add('DPA.AN_DSB_EMAIL')
|
||||
}
|
||||
if (profile.ceo_name) {
|
||||
dpaBase.AN_UNTERZEICHNER_NAME = profile.ceo_name
|
||||
tomBase.GF_NAME = profile.ceo_name
|
||||
scopeSet.add('DPA.AN_UNTERZEICHNER_NAME')
|
||||
scopeSet.add('TOM.GF_NAME')
|
||||
}
|
||||
}
|
||||
|
||||
// Alle gesetzten TOM/DPA Felder als scope-set markieren
|
||||
for (const key of Object.keys(tomBase)) {
|
||||
scopeSet.add(`TOM.${key}`)
|
||||
}
|
||||
for (const key of Object.keys(dpaBase)) {
|
||||
scopeSet.add(`DPA.${key}`)
|
||||
}
|
||||
|
||||
return { tom: tomBase, dpa: dpaBase, scopeSet }
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt das empfohlene Profil-Label zurueck (fuer UI-Anzeige).
|
||||
*/
|
||||
export function getProfileLabel(level: ComplianceDepthLevel): string {
|
||||
const labels: Record<ComplianceDepthLevel, string> = {
|
||||
L1: 'Startup / Kleinstunternehmen',
|
||||
L2: 'KMU Standard',
|
||||
L3: 'Erweiterte Compliance',
|
||||
L4: 'Zertifizierungsbereit / Enterprise',
|
||||
}
|
||||
return labels[level]
|
||||
}
|
||||
|
||||
/**
|
||||
* Empfiehlt relevante Dokumenttypen basierend auf dem Compliance-Level.
|
||||
* Hilft dem Kunden zu verstehen, welche Dokumente er braucht.
|
||||
*/
|
||||
export function getRecommendedDocuments(level: ComplianceDepthLevel): {
|
||||
required: string[]
|
||||
recommended: string[]
|
||||
optional: string[]
|
||||
} {
|
||||
const always = [
|
||||
'privacy_policy', 'impressum', 'agb', 'cookie_banner', 'cookie_policy',
|
||||
]
|
||||
const l2plus = [
|
||||
'dpa', 'tom_documentation', 'vvt_register', 'loeschkonzept',
|
||||
'community_guidelines', 'terms_of_use',
|
||||
]
|
||||
const l3plus = [
|
||||
'it_security_concept', 'data_protection_concept', 'incident_response_plan',
|
||||
'access_control_concept', 'backup_recovery_concept', 'logging_concept',
|
||||
'risk_management_concept', 'pflichtenregister',
|
||||
'password_policy', 'encryption_policy', 'information_security_policy',
|
||||
'access_control_policy', 'whistleblower_policy',
|
||||
'employee_dsi', 'applicant_dsi', 'ai_usage_policy',
|
||||
]
|
||||
const l4only = [
|
||||
'isms_manual', 'cybersecurity_policy', 'byod_policy',
|
||||
'dsfa', 'social_media_dsi', 'media_content_policy',
|
||||
'video_conference_dsi', 'consent_texts',
|
||||
'data_protection_policy', 'data_classification_policy',
|
||||
'data_retention_policy', 'data_transfer_policy',
|
||||
'privacy_incident_policy', 'employee_security_policy',
|
||||
'security_awareness_policy', 'remote_work_policy',
|
||||
'offboarding_policy', 'vendor_risk_management_policy',
|
||||
'third_party_security_policy', 'supplier_security_policy',
|
||||
'business_continuity_policy', 'disaster_recovery_policy',
|
||||
'crisis_management_policy',
|
||||
]
|
||||
|
||||
switch (level) {
|
||||
case 'L1':
|
||||
return { required: always, recommended: [], optional: l2plus }
|
||||
case 'L2':
|
||||
return { required: always, recommended: l2plus, optional: l3plus }
|
||||
case 'L3':
|
||||
return { required: [...always, ...l2plus], recommended: l3plus, optional: l4only }
|
||||
case 'L4':
|
||||
return { required: [...always, ...l2plus, ...l3plus], recommended: l4only, optional: [] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Template Recommendations — Maps scope answers to document templates
|
||||
*
|
||||
* Bridges the gap between the Compliance Scope Engine (23 ScopeDocumentTypes)
|
||||
* and the Document Generator (70+ database templates).
|
||||
*
|
||||
* The scope engine recommends high-level document categories (vvt, tom, dsfa...).
|
||||
* This module recommends SPECIFIC templates based on additional context from
|
||||
* the CompanyProfile and scope answers.
|
||||
*/
|
||||
|
||||
import type { ComplianceDepthLevel } from '../../lib/sdk/compliance-scope-types/core-levels'
|
||||
import type { ScopeProfilingAnswer } from '../../lib/sdk/compliance-scope-types/state'
|
||||
|
||||
// ============================================================================
|
||||
// Template recommendation rules
|
||||
// ============================================================================
|
||||
|
||||
interface TemplateRule {
|
||||
/** Database document_type */
|
||||
templateType: string
|
||||
/** Human-readable label */
|
||||
label: string
|
||||
/** When to recommend this template */
|
||||
condition: (answers: Map<string, string>, level: ComplianceDepthLevel, profile: Record<string, unknown>) => 'required' | 'recommended' | 'optional' | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules that map scope answers + profile to specific template recommendations.
|
||||
* These cover templates NOT directly output by the scope engine.
|
||||
*/
|
||||
const TEMPLATE_RULES: TemplateRule[] = [
|
||||
// ── HR-DSI ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
templateType: 'employee_dsi',
|
||||
label: 'Mitarbeiter-Datenschutzinformation',
|
||||
condition: (answers, level) => {
|
||||
const hasEmployees = answers.get('org_has_employees')
|
||||
const empCount = answers.get('org_employee_count')
|
||||
if (hasEmployees === 'yes' || (empCount && empCount !== 'none' && empCount !== '0')) {
|
||||
return level >= 'L2' ? 'required' : 'recommended'
|
||||
}
|
||||
return null
|
||||
},
|
||||
},
|
||||
{
|
||||
templateType: 'applicant_dsi',
|
||||
label: 'Bewerber-Datenschutzinformation',
|
||||
condition: (answers, level) => {
|
||||
const hasEmployees = answers.get('org_has_employees')
|
||||
const empCount = answers.get('org_employee_count')
|
||||
if (hasEmployees === 'yes' || (empCount && empCount !== 'none' && empCount !== '0')) {
|
||||
return level >= 'L2' ? 'recommended' : 'optional'
|
||||
}
|
||||
return null
|
||||
},
|
||||
},
|
||||
|
||||
// ── Whistleblower ───────────────────────────────────────────────────────
|
||||
{
|
||||
templateType: 'whistleblower_policy',
|
||||
label: 'Hinweisgeberrichtlinie (HinSchG)',
|
||||
condition: (answers) => {
|
||||
const empCount = answers.get('org_employee_count')
|
||||
// HinSchG Pflicht ab 50 MA
|
||||
if (empCount === '50_249' || empCount === '250_999' || empCount === '1000_plus') return 'required'
|
||||
return null
|
||||
},
|
||||
},
|
||||
|
||||
// ── KI ──────────────────────────────────────────────────────────────────
|
||||
{
|
||||
templateType: 'ai_usage_policy',
|
||||
label: 'KI-Nutzungsrichtlinie',
|
||||
condition: (answers) => {
|
||||
const aiUsage = answers.get('proc_ai_usage') || answers.get('proc_uses_ai_tools')
|
||||
if (aiUsage && aiUsage !== 'none' && aiUsage !== 'no') return 'required'
|
||||
return null
|
||||
},
|
||||
},
|
||||
|
||||
// ── BYOD ────────────────────────────────────────────────────────────────
|
||||
{
|
||||
templateType: 'byod_policy',
|
||||
label: 'BYOD-Richtlinie',
|
||||
condition: (answers, level) => {
|
||||
const byod = answers.get('proc_byod_allowed')
|
||||
if (byod === 'yes') return 'required'
|
||||
if (level >= 'L3') return 'recommended'
|
||||
return 'optional'
|
||||
},
|
||||
},
|
||||
|
||||
// ── Social Media ────────────────────────────────────────────────────────
|
||||
{
|
||||
templateType: 'social_media_dsi',
|
||||
label: 'Social-Media-Datenschutzinformation',
|
||||
condition: (answers, level) => {
|
||||
const sm = answers.get('org_has_social_media')
|
||||
if (sm === 'yes') return 'required'
|
||||
return level >= 'L2' ? 'recommended' : 'optional'
|
||||
},
|
||||
},
|
||||
|
||||
// ── Videokonferenzen ────────────────────────────────────────────────────
|
||||
{
|
||||
templateType: 'video_conference_dsi',
|
||||
label: 'Videokonferenz-Datenschutzinformation',
|
||||
condition: (answers, level) => {
|
||||
const video = answers.get('org_has_video_conferencing')
|
||||
if (video === 'yes') return 'recommended'
|
||||
if (level >= 'L3') return 'recommended'
|
||||
return 'optional'
|
||||
},
|
||||
},
|
||||
|
||||
// ── Security Policies (nur ab L3/L4) ───────────────────────────────────
|
||||
{
|
||||
templateType: 'information_security_policy',
|
||||
label: 'Informationssicherheitsrichtlinie',
|
||||
condition: (_answers, level) => {
|
||||
if (level >= 'L3') return 'required'
|
||||
if (level === 'L2') return 'recommended'
|
||||
return null
|
||||
},
|
||||
},
|
||||
{
|
||||
templateType: 'password_policy',
|
||||
label: 'Passwortrichtlinie',
|
||||
condition: (_answers, level) => level >= 'L2' ? 'recommended' : 'optional',
|
||||
},
|
||||
{
|
||||
templateType: 'encryption_policy',
|
||||
label: 'Verschluesselungsrichtlinie',
|
||||
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
|
||||
},
|
||||
{
|
||||
templateType: 'access_control_policy',
|
||||
label: 'Zugriffskontrollrichtlinie',
|
||||
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
|
||||
},
|
||||
|
||||
// ── Security Concepts (nur ab L3) ──────────────────────────────────────
|
||||
{
|
||||
templateType: 'it_security_concept',
|
||||
label: 'IT-Sicherheitskonzept',
|
||||
condition: (_answers, level) => level >= 'L3' ? 'required' : 'optional',
|
||||
},
|
||||
{
|
||||
templateType: 'backup_recovery_concept',
|
||||
label: 'Backup-Recovery-Konzept',
|
||||
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
|
||||
},
|
||||
{
|
||||
templateType: 'logging_concept',
|
||||
label: 'Logging-Konzept',
|
||||
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
|
||||
},
|
||||
{
|
||||
templateType: 'access_control_concept',
|
||||
label: 'Zugriffskonzept',
|
||||
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
|
||||
},
|
||||
|
||||
// ── Plattform/UGC ──────────────────────────────────────────────────────
|
||||
{
|
||||
templateType: 'community_guidelines',
|
||||
label: 'Gemeinschaftsrichtlinien',
|
||||
condition: (answers) => {
|
||||
const model = answers.get('org_business_model')
|
||||
const ugc = answers.get('prod_ugc_platform')
|
||||
if (ugc === 'yes' || model === 'platform' || model === 'marketplace' || model === 'social') return 'required'
|
||||
return null
|
||||
},
|
||||
},
|
||||
{
|
||||
templateType: 'terms_of_use',
|
||||
label: 'Nutzungsbedingungen',
|
||||
condition: (answers) => {
|
||||
const model = answers.get('org_business_model')
|
||||
const ugc = answers.get('prod_ugc_platform')
|
||||
if (ugc === 'yes' || model === 'platform' || model === 'marketplace' || model === 'social' || model === 'saas') return 'required'
|
||||
return null
|
||||
},
|
||||
},
|
||||
{
|
||||
templateType: 'media_content_policy',
|
||||
label: 'Medien- und Inhalte-Richtlinie',
|
||||
condition: (answers) => {
|
||||
const model = answers.get('org_business_model')
|
||||
if (model === 'platform' || model === 'media') return 'recommended'
|
||||
return null
|
||||
},
|
||||
},
|
||||
|
||||
// ── E-Commerce ─────────────────────────────────────────────────────────
|
||||
{
|
||||
templateType: 'widerruf',
|
||||
label: 'Widerrufsbelehrung',
|
||||
condition: (answers) => {
|
||||
const shop = answers.get('prod_webshop')
|
||||
if (shop && shop !== 'no') return 'required'
|
||||
return null
|
||||
},
|
||||
},
|
||||
{
|
||||
templateType: 'consent_texts',
|
||||
label: 'Einwilligungstexte (Double-Opt-In)',
|
||||
condition: (answers) => {
|
||||
const consent = answers.get('prod_consent_management')
|
||||
if (consent && consent !== 'no') return 'recommended'
|
||||
return 'optional'
|
||||
},
|
||||
},
|
||||
|
||||
// ── Impressum + Cookie ─────────────────────────────────────────────────
|
||||
{
|
||||
templateType: 'impressum',
|
||||
label: 'Impressum',
|
||||
condition: () => 'required', // Immer Pflicht
|
||||
},
|
||||
{
|
||||
templateType: 'cookie_policy',
|
||||
label: 'Cookie-Richtlinie',
|
||||
condition: () => 'required', // Immer Pflicht bei Websites
|
||||
},
|
||||
|
||||
// ── Drittlandtransfer (SCC + TIA) ───────────────────────────────────────
|
||||
// SCC+TIA nur erforderlich wenn Drittlandtransfer OHNE Angemessenheitsbeschluss/DPF
|
||||
{
|
||||
templateType: 'transfer_impact_assessment',
|
||||
label: 'Transfer Impact Assessment (TIA)',
|
||||
condition: (answers) => {
|
||||
const thirdCountry = answers.get('tech_third_country')
|
||||
if (!thirdCountry || thirdCountry === 'no') return null
|
||||
// Wenn nur DPF-zertifizierte US-Anbieter: empfohlen statt pflicht
|
||||
if (thirdCountry === 'us_dpf_only') return 'optional'
|
||||
// Wenn nur Laender mit Angemessenheitsbeschluss: nicht noetig
|
||||
if (thirdCountry === 'adequate_only') return null
|
||||
return 'required'
|
||||
},
|
||||
},
|
||||
{
|
||||
templateType: 'scc_companion',
|
||||
label: 'Standardvertragsklauseln (SCC) — Anhaenge',
|
||||
condition: (answers) => {
|
||||
const thirdCountry = answers.get('tech_third_country')
|
||||
if (!thirdCountry || thirdCountry === 'no') return null
|
||||
if (thirdCountry === 'us_dpf_only') return 'optional'
|
||||
if (thirdCountry === 'adequate_only') return null
|
||||
return 'required'
|
||||
},
|
||||
},
|
||||
|
||||
// ── ISMS (nur bei Zertifizierungsziel) ─────────────────────────────────
|
||||
{
|
||||
templateType: 'isms_manual',
|
||||
label: 'ISMS-Handbuch',
|
||||
condition: (answers) => {
|
||||
const cert = answers.get('org_cert_target')
|
||||
if (cert === 'iso27001' || cert === 'iso27701' || cert === 'tisax') return 'required'
|
||||
return null
|
||||
},
|
||||
},
|
||||
|
||||
// ── Vendor/BCM (nur ab L4 oder bei Vendor-Management) ─────────────────
|
||||
{
|
||||
templateType: 'vendor_risk_management_policy',
|
||||
label: 'Vendor-Risikomanagement',
|
||||
condition: (answers, level) => {
|
||||
const vendor = answers.get('comp_vendor_management')
|
||||
if (vendor && vendor !== 'no') return 'recommended'
|
||||
if (level === 'L4') return 'required'
|
||||
return null
|
||||
},
|
||||
},
|
||||
{
|
||||
templateType: 'business_continuity_policy',
|
||||
label: 'Business-Continuity-Richtlinie',
|
||||
condition: (_answers, level) => level === 'L4' ? 'required' : 'optional',
|
||||
},
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
export interface TemplateRecommendation {
|
||||
templateType: string
|
||||
label: string
|
||||
requirement: 'required' | 'recommended' | 'optional'
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates all template rules against the user's scope answers and profile.
|
||||
* Returns a prioritized list of template recommendations.
|
||||
*/
|
||||
export function evaluateTemplateRecommendations(
|
||||
scopeAnswers: ScopeProfilingAnswer[],
|
||||
level: ComplianceDepthLevel,
|
||||
profile: Record<string, unknown> = {},
|
||||
): TemplateRecommendation[] {
|
||||
const answerMap = new Map<string, string>()
|
||||
for (const a of scopeAnswers) {
|
||||
answerMap.set(a.questionId, String(a.value))
|
||||
}
|
||||
|
||||
const results: TemplateRecommendation[] = []
|
||||
|
||||
for (const rule of TEMPLATE_RULES) {
|
||||
const requirement = rule.condition(answerMap, level, profile)
|
||||
if (requirement) {
|
||||
results.push({
|
||||
templateType: rule.templateType,
|
||||
label: rule.label,
|
||||
requirement,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: required first, then recommended, then optional
|
||||
const order = { required: 0, recommended: 1, optional: 2 }
|
||||
results.sort((a, b) => order[a.requirement] - order[b.requirement])
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -2,16 +2,38 @@
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import type { DSFA } from './DSFACard'
|
||||
import type { DSFAPrefillResult } from '@/lib/sdk/dsfa/prefill-from-scope'
|
||||
|
||||
export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; onSubmit: (data: Partial<DSFA>) => Promise<void> }) {
|
||||
interface GeneratorWizardProps {
|
||||
onClose: () => void
|
||||
onSubmit: (data: Partial<DSFA>) => Promise<void>
|
||||
prefill?: DSFAPrefillResult | null
|
||||
}
|
||||
|
||||
export function GeneratorWizard({ onClose, onSubmit, prefill }: GeneratorWizardProps) {
|
||||
const [step, setStep] = useState(1)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [processingActivity, setProcessingActivity] = useState('')
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
|
||||
const [riskLevel, setRiskLevel] = useState<'low' | 'medium' | 'high' | 'critical'>('low')
|
||||
const [selectedMeasures, setSelectedMeasures] = useState<string[]>([])
|
||||
const [title, setTitle] = useState(prefill?.title || '')
|
||||
const [description, setDescription] = useState(prefill?.description || '')
|
||||
const [processingActivity, setProcessingActivity] = useState(prefill?.processingActivity || '')
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>(prefill?.dataCategories || [])
|
||||
const riskMap2: Record<string, 'low' | 'medium' | 'high' | 'critical'> = { niedrig: 'low', mittel: 'medium', hoch: 'high', kritisch: 'critical' }
|
||||
const [riskLevel, setRiskLevel] = useState<'low' | 'medium' | 'high' | 'critical'>(riskMap2[prefill?.riskLevel || ''] || 'low')
|
||||
const [residualRisk, setResidualRisk] = useState<'low' | 'medium' | 'high' | 'critical'>('low')
|
||||
const [selectedMeasures, setSelectedMeasures] = useState<string[]>(prefill?.measures || [])
|
||||
const [linkedVvtId, setLinkedVvtId] = useState('')
|
||||
const [vvtActivities, setVvtActivities] = useState<Array<{ id: string; name: string }>>([])
|
||||
|
||||
// Load VVT activities for linking
|
||||
React.useEffect(() => {
|
||||
fetch('/api/sdk/v1/compliance/vvt')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then(data => {
|
||||
const items = Array.isArray(data) ? data : data.activities || []
|
||||
setVvtActivities(items.map((a: any) => ({ id: a.id, name: a.name || a.processing_name || a.title || 'Unbenannt' })))
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const riskMap: Record<string, 'low' | 'medium' | 'high' | 'critical'> = {
|
||||
Niedrig: 'low', Mittel: 'medium', Hoch: 'high', Kritisch: 'critical',
|
||||
@@ -28,7 +50,12 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
|
||||
riskLevel,
|
||||
measures: selectedMeasures,
|
||||
status: 'draft',
|
||||
})
|
||||
...(prefill?.federalState ? { federal_state: prefill.federalState } : {}),
|
||||
...(prefill?.involvesAi ? { involves_ai: true } : {}),
|
||||
...(prefill?.legalBasis ? { legal_basis: prefill.legalBasis } : {}),
|
||||
...(linkedVvtId ? { linked_vvt_id: linkedVvtId } : {}),
|
||||
...(residualRisk !== 'low' ? { residual_risk_level: residualRisk } : {}),
|
||||
} as Partial<DSFA>)
|
||||
onClose()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
@@ -48,7 +75,7 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
{[1, 2, 3, 4].map(s => (
|
||||
{[1, 2, 3, 4, 5].map(s => (
|
||||
<React.Fragment key={s}>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
s < step ? 'bg-green-500 text-white' :
|
||||
@@ -60,7 +87,7 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
|
||||
</svg>
|
||||
) : s}
|
||||
</div>
|
||||
{s < 4 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
|
||||
{s < 5 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
@@ -89,6 +116,20 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
{vvtActivities.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verknuepfte VVT-Aktivitaet (Art. 30)</label>
|
||||
<select value={linkedVvtId} onChange={e => {
|
||||
setLinkedVvtId(e.target.value)
|
||||
const selected = vvtActivities.find(a => a.id === e.target.value)
|
||||
if (selected && !processingActivity) setProcessingActivity(selected.name)
|
||||
}} className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 bg-white">
|
||||
<option value="">— Keine Verknuepfung —</option>
|
||||
{vvtActivities.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
<p className="text-xs text-gray-400 mt-1">Ordnen Sie diese DSFA einer VVT-Verarbeitungstaetigkeit zu.</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verarbeitungstaetigkeit</label>
|
||||
<input
|
||||
@@ -167,6 +208,43 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 5 && (
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Restrisiko nach Massnahmen</label>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Bewerten Sie das verbleibende Risiko NACH Umsetzung der Schutzmassnahmen.
|
||||
Bei hohem Restrisiko → Art. 36 Vorabkonsultation der Aufsichtsbehoerde.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{ value: 'low' as const, label: 'Niedrig', desc: 'Risiko ausreichend gemindert', color: 'border-green-300 bg-green-50' },
|
||||
{ value: 'medium' as const, label: 'Mittel', desc: 'Akzeptables Restrisiko', color: 'border-yellow-300 bg-yellow-50' },
|
||||
{ value: 'high' as const, label: 'Hoch', desc: 'Art. 36 Konsultation pruefen', color: 'border-orange-300 bg-orange-50' },
|
||||
{ value: 'critical' as const, label: 'Kritisch', desc: 'Art. 36 Konsultation PFLICHT', color: 'border-red-300 bg-red-50' },
|
||||
].map(r => (
|
||||
<label key={r.value} className={`flex items-start gap-2 p-3 border-2 rounded-lg cursor-pointer ${
|
||||
residualRisk === r.value ? r.color : 'border-gray-200 hover:border-gray-300'
|
||||
}`}>
|
||||
<input type="radio" name="residualRisk" value={r.value} checked={residualRisk === r.value}
|
||||
onChange={() => setResidualRisk(r.value)} className="mt-0.5" />
|
||||
<div>
|
||||
<span className="text-sm font-medium">{r.label}</span>
|
||||
<p className="text-xs text-gray-500">{r.desc}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{(residualRisk === 'high' || residualRisk === 'critical') && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<p className="text-sm text-red-700 font-medium">Vorabkonsultation erforderlich (Art. 36 DSGVO)</p>
|
||||
<p className="text-xs text-red-600 mt-1">
|
||||
Bei hohem Restrisiko muss die Aufsichtsbehoerde VOR Beginn der Verarbeitung konsultiert werden.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
@@ -179,11 +257,11 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
|
||||
{step === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => step < 4 ? setStep(step + 1) : handleSubmit()}
|
||||
onClick={() => step < 5 ? setStep(step + 1) : handleSubmit()}
|
||||
disabled={saving || (step === 1 && !title.trim())}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{step === 4 ? (saving ? 'Wird erstellt...' : 'DSFA erstellen') : 'Weiter'}
|
||||
{step === 5 ? (saving ? 'Wird erstellt...' : 'DSFA erstellen') : 'Weiter'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import { DocumentUploadSection, type UploadedDocument } from '@/components/sdk'
|
||||
import { DSFACard, type DSFA } from './_components/DSFACard'
|
||||
import { GeneratorWizard } from './_components/GeneratorWizard'
|
||||
import { prefillDSFAFromScope, isDSFARequired } from '@/lib/sdk/dsfa/prefill-from-scope'
|
||||
|
||||
export default function DSFAPage() {
|
||||
const router = useRouter()
|
||||
@@ -17,6 +18,17 @@ export default function DSFAPage() {
|
||||
const [showGenerator, setShowGenerator] = useState(false)
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
|
||||
// Pre-fill from Company Profile + Scope answers
|
||||
const scopeAnswers = state.complianceScope?.answers || []
|
||||
const prefill = useMemo(
|
||||
() => prefillDSFAFromScope(state.companyProfile || null, scopeAnswers),
|
||||
[state.companyProfile, scopeAnswers]
|
||||
)
|
||||
const dsfaCheck = useMemo(
|
||||
() => isDSFARequired(scopeAnswers, state.companyProfile?.headquartersState),
|
||||
[scopeAnswers, state.companyProfile?.headquartersState]
|
||||
)
|
||||
|
||||
const loadDSFAs = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
@@ -120,10 +132,42 @@ export default function DSFAPage() {
|
||||
)}
|
||||
</StepHeader>
|
||||
|
||||
{/* DSFA Requirement Check */}
|
||||
{dsfaCheck.required && dsfas.length === 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-5">
|
||||
<h3 className="font-semibold text-red-800">DSFA erforderlich (Art. 35 DSGVO)</h3>
|
||||
<p className="text-sm text-red-700 mt-1">Basierend auf Ihrem Scope-Profiling wurde festgestellt:</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{dsfaCheck.triggers.map(t => (
|
||||
<li key={t} className="text-sm text-red-600 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0" />
|
||||
{t}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{dsfaCheck.blacklistMatches.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-red-200">
|
||||
<p className="text-xs font-medium text-red-800 mb-1">
|
||||
Blacklist {dsfaCheck.authority || 'Aufsichtsbehoerde'} (Art. 35 Abs. 4):
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{dsfaCheck.blacklistMatches.map(m => (
|
||||
<li key={m} className="text-xs text-red-600 flex items-center gap-2">
|
||||
<span className="w-1 h-1 bg-red-400 rounded-full flex-shrink-0" />
|
||||
{m}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showGenerator && (
|
||||
<GeneratorWizard
|
||||
onClose={() => setShowGenerator(false)}
|
||||
onSubmit={handleCreateDSFA}
|
||||
prefill={prefill}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ export function ActionButtons({
|
||||
onExtendDeadline,
|
||||
onComplete,
|
||||
onReject,
|
||||
onAssign
|
||||
onAssign,
|
||||
onRejectArt11,
|
||||
}: {
|
||||
request: DSRRequest
|
||||
onVerifyIdentity: () => void
|
||||
@@ -17,15 +18,31 @@ export function ActionButtons({
|
||||
onComplete: () => void
|
||||
onReject: () => void
|
||||
onAssign: () => void
|
||||
onRejectArt11?: () => void
|
||||
}) {
|
||||
const isTerminal = request.status === 'completed' || request.status === 'rejected' || request.status === 'cancelled'
|
||||
|
||||
if (isTerminal) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<button className="w-full px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-sm">
|
||||
<button
|
||||
onClick={() => window.open(`/api/sdk/v1/dsr/${request.id}/export-user-data?format=pdf`, '_blank')}
|
||||
className="w-full px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
PDF exportieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open(`/api/sdk/v1/dsr/${request.id}/export-user-data?format=json`, '_blank')}
|
||||
className="w-full px-4 py-2 text-purple-600 bg-purple-50 hover:bg-purple-100 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
JSON exportieren (Art. 20)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open(`/api/sdk/v1/dsr/${request.id}/export-user-data?format=csv`, '_blank')}
|
||||
className="w-full px-4 py-2 text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
CSV exportieren
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -33,12 +50,23 @@ export function ActionButtons({
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{!request.identityVerification.verified && (
|
||||
<button
|
||||
onClick={onVerifyIdentity}
|
||||
className="w-full px-4 py-2 bg-yellow-500 text-white hover:bg-yellow-600 rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
Identitaet verifizieren
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
onClick={onVerifyIdentity}
|
||||
className="w-full px-4 py-2 bg-yellow-500 text-white hover:bg-yellow-600 rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
Identitaet verifizieren
|
||||
</button>
|
||||
{onRejectArt11 && (
|
||||
<button
|
||||
onClick={onRejectArt11}
|
||||
className="w-full px-4 py-2 text-gray-600 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg transition-colors text-sm"
|
||||
title="Person kann anhand der gespeicherten Daten nicht identifiziert werden (Art. 11 DSGVO)"
|
||||
>
|
||||
Nicht identifizierbar (Art. 11)
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useBannerConsents } from '../_hooks/useBannerConsents'
|
||||
import { BannerConsentRecord, PAGE_SIZE } from '../_types'
|
||||
|
||||
const BANNER_API = '/api/sdk/v1/banner'
|
||||
const TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
@@ -42,12 +45,35 @@ const methodColors: Record<string, string> = {
|
||||
export default function BannerConsentsTab() {
|
||||
const {
|
||||
records, sites, selectedSite, changeSite,
|
||||
stats, currentPage, setCurrentPage, totalRecords, loading,
|
||||
stats, currentPage, setCurrentPage, totalRecords, loading, reload,
|
||||
} = useBannerConsents()
|
||||
|
||||
const [detail, setDetail] = useState<BannerConsentRecord | null>(null)
|
||||
const [linkEmailInput, setLinkEmailInput] = useState('')
|
||||
const [linkingEmail, setLinkingEmail] = useState(false)
|
||||
const totalPages = Math.ceil(totalRecords / PAGE_SIZE)
|
||||
|
||||
const withdrawConsent = useCallback(async (id: string) => {
|
||||
if (!confirm('Consent wirklich widerrufen? Diese Aktion kann nicht rueckgaengig gemacht werden.')) return
|
||||
await fetch(`${BANNER_API}/consent/${id}`, { method: 'DELETE', headers: { 'x-tenant-id': TENANT_ID } })
|
||||
setDetail(null)
|
||||
reload()
|
||||
}, [reload])
|
||||
|
||||
const linkEmail = useCallback(async (record: BannerConsentRecord) => {
|
||||
if (!linkEmailInput.includes('@')) return
|
||||
setLinkingEmail(true)
|
||||
await fetch(`${BANNER_API}/consent/link-email`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-tenant-id': TENANT_ID },
|
||||
body: JSON.stringify({ site_id: record.site_id, device_fingerprint: record.device_fingerprint, email: linkEmailInput }),
|
||||
})
|
||||
setLinkingEmail(false)
|
||||
setLinkEmailInput('')
|
||||
setDetail({ ...record, linked_email: linkEmailInput })
|
||||
reload()
|
||||
}, [linkEmailInput, reload])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats + Site Selector */}
|
||||
@@ -184,6 +210,18 @@ export default function BannerConsentsTab() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{detail.vendor_consents && Object.keys(detail.vendor_consents).length > 0 && (
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="text-gray-500">Vendors</span>
|
||||
<div className="flex flex-wrap gap-1 justify-end">
|
||||
{Object.entries(detail.vendor_consents).map(([name, accepted]) => (
|
||||
<span key={name} className={`text-xs px-2 py-0.5 rounded-full ${accepted ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Methode</span>
|
||||
<span>{detail.consent_method ? (
|
||||
@@ -192,9 +230,28 @@ export default function BannerConsentsTab() {
|
||||
</span>
|
||||
) : '—'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<div className="flex justify-between items-center">
|
||||
<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 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>
|
||||
@@ -223,6 +280,37 @@ export default function BannerConsentsTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scripts & Cookies */}
|
||||
{(detail.scripts_released?.length > 0 || detail.cookies_set?.length > 0) && (
|
||||
<div className="border-t border-gray-100 pt-3">
|
||||
<p className="text-xs font-semibold text-gray-700 mb-2">Scripts & Cookies</p>
|
||||
{detail.scripts_released?.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<span className="text-gray-500 text-xs">Freigegebene Scripts</span>
|
||||
{detail.scripts_released.map((s, i) => (
|
||||
<p key={i} className="text-xs text-gray-600 font-mono truncate">{s.src} <span className={`px-1 rounded ${categoryColors[s.category] || 'bg-gray-100'}`}>{s.category}</span></p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{detail.scripts_blocked?.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<span className="text-gray-500 text-xs">Blockierte Scripts</span>
|
||||
{detail.scripts_blocked.map((s, i) => (
|
||||
<p key={i} className="text-xs text-red-600 font-mono truncate">{s.src} <span className="px-1 rounded bg-red-100 text-red-700">{s.category}</span></p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{detail.cookies_set?.length > 0 && (
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">Gesetzte Cookies</span>
|
||||
{detail.cookies_set.map((c, i) => (
|
||||
<p key={i} className="text-xs text-gray-600 font-mono">{c.name} <span className="text-gray-400">({c.domain})</span> <span className={`px-1 rounded ${categoryColors[c.category] || 'bg-gray-100'}`}>{c.category}</span></p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Technische Details */}
|
||||
<div className="border-t border-gray-100 pt-3">
|
||||
<p className="text-xs font-semibold text-gray-700 mb-2">Technisch</p>
|
||||
@@ -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>}
|
||||
</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>
|
||||
|
||||
@@ -108,6 +108,7 @@ export interface BannerConsentRecord {
|
||||
device_fingerprint: string
|
||||
categories: string[]
|
||||
vendors: string[]
|
||||
vendor_consents: Record<string, boolean>
|
||||
ip_hash: string | null
|
||||
user_agent: string | null
|
||||
linked_email: string | null
|
||||
@@ -126,6 +127,10 @@ export interface BannerConsentRecord {
|
||||
os: string | null
|
||||
screen_resolution: string | null
|
||||
session_id: string | null
|
||||
// Script/Cookie-Tracking (Migration 108)
|
||||
scripts_blocked: { src: string; category: string }[]
|
||||
scripts_released: { src: string; category: string }[]
|
||||
cookies_set: { name: string; domain: string; expiry_days: number; category: string }[]
|
||||
expires_at: string | null
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
@@ -140,4 +145,5 @@ export interface BannerSite {
|
||||
site_id: string
|
||||
site_name: string
|
||||
site_url: string
|
||||
tcf_enabled?: boolean
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface GapReport {
|
||||
dsms_cid?: string
|
||||
profile_name: string
|
||||
regulations: Array<{
|
||||
id: string
|
||||
@@ -79,6 +80,20 @@ export function GapDashboard({ report, onBack }: Props) {
|
||||
← Neue Analyse
|
||||
</button>
|
||||
|
||||
{/* DSMS Archive Badge */}
|
||||
{report.dsms_cid && (
|
||||
<div className="mb-4 flex items-center gap-2 px-4 py-2.5 bg-emerald-50 border border-emerald-200 rounded-lg">
|
||||
<svg className="w-4 h-4 text-emerald-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span className="text-sm text-emerald-800 font-medium">Revisionssicher archiviert</span>
|
||||
<code className="text-xs text-emerald-600 bg-emerald-100 px-2 py-0.5 rounded font-mono">
|
||||
{report.dsms_cid.length > 20 ? report.dsms_cid.slice(0, 8) + '...' + report.dsms_cid.slice(-6) : report.dsms_cid}
|
||||
</code>
|
||||
<span className="text-[10px] text-emerald-500">DSMS/IPFS</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<SummaryCard
|
||||
|
||||
@@ -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'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { IstAssessment } from './IstAssessment'
|
||||
|
||||
const PRODUCT_TYPES = [
|
||||
{ value: 'iot', label: 'IoT / Connected Device' },
|
||||
@@ -60,6 +61,20 @@ export function ProductWizard({ onAnalyze, loading }: Props) {
|
||||
const [usesAI, setUsesAI] = useState(false)
|
||||
const [processesPersonalData, setProcessesPersonalData] = useState(false)
|
||||
const [isCriticalInfra, setIsCriticalInfra] = useState(false)
|
||||
const [step, setStep] = useState(1)
|
||||
const [istData, setIstData] = useState({
|
||||
applied_norms: [] as string[],
|
||||
has_risk_assessment: false,
|
||||
has_technical_file: false,
|
||||
has_operating_manual: false,
|
||||
has_sbom: false,
|
||||
has_vuln_management: false,
|
||||
has_update_mechanism: false,
|
||||
has_incident_response: false,
|
||||
has_supply_chain_mgmt: false,
|
||||
ce_marking_since: '',
|
||||
product_age: '',
|
||||
})
|
||||
|
||||
const toggleArrayValue = (
|
||||
arr: string[],
|
||||
@@ -83,11 +98,59 @@ export function ProductWizard({ onAnalyze, loading }: Props) {
|
||||
processes_personal_data: processesPersonalData,
|
||||
is_critical_infra_supplier: isCriticalInfra,
|
||||
existing_certifications: certifications,
|
||||
...istData,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<button
|
||||
onClick={() => setStep(1)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium ${
|
||||
step === 1 ? 'bg-blue-100 text-blue-700' : 'text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<span className="w-6 h-6 rounded-full bg-blue-600 text-white text-xs flex items-center justify-center">1</span>
|
||||
Produkt beschreiben
|
||||
</button>
|
||||
<span className="text-gray-300">→</span>
|
||||
<button
|
||||
onClick={() => productType ? setStep(2) : null}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium ${
|
||||
step === 2 ? 'bg-blue-100 text-blue-700' : 'text-gray-500 hover:bg-gray-50'
|
||||
} ${!productType ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<span className={`w-6 h-6 rounded-full text-xs flex items-center justify-center ${
|
||||
step === 2 ? 'bg-blue-600 text-white' : 'bg-gray-300 text-white'
|
||||
}`}>2</span>
|
||||
IST-Zustand
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{step === 2 && (
|
||||
<>
|
||||
<IstAssessment data={istData} onChange={setIstData} />
|
||||
<div className="flex gap-4 mt-8">
|
||||
<button
|
||||
onClick={() => setStep(1)}
|
||||
className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Zurueck
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="flex-1 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:bg-gray-300 transition-colors"
|
||||
>
|
||||
{loading ? 'Analyse laeuft...' : 'Gap-Analyse starten'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 1 && (<>
|
||||
{/* Produktname */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
@@ -225,14 +288,15 @@ export function ProductWizard({ onAnalyze, loading }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
{/* Next Step */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!productType || loading}
|
||||
onClick={() => setStep(2)}
|
||||
disabled={!productType}
|
||||
className="w-full py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? 'Analyse laeuft...' : 'Gap-Analyse starten'}
|
||||
Weiter: IST-Zustand erfassen →
|
||||
</button>
|
||||
</>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { ProductWizard } from './_components/ProductWizard'
|
||||
import { GapDashboard } from './_components/GapDashboard'
|
||||
|
||||
interface GapProject {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
product_type: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface GapReport {
|
||||
profile_id: string
|
||||
profile_name: string
|
||||
@@ -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() {
|
||||
const [view, setView] = useState<View>('projects')
|
||||
const [projects, setProjects] = useState<GapProject[]>([])
|
||||
const [report, setReport] = useState<GapReport | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const 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)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/gap/analyze', {
|
||||
// Save project
|
||||
const createRes = await fetch('/api/sdk/v1/gap/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': '00000000-0000-0000-0000-000000000001',
|
||||
},
|
||||
body: JSON.stringify(profile),
|
||||
})
|
||||
if (!createRes.ok) throw new Error('Projekt konnte nicht gespeichert werden')
|
||||
const created = await createRes.json()
|
||||
const projectId = created.project?.id
|
||||
|
||||
// Run analysis
|
||||
const analyzeRes = await fetch(`/api/sdk/v1/gap/projects/${projectId}/analyze`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000001' },
|
||||
})
|
||||
if (!analyzeRes.ok) throw new Error(await analyzeRes.text())
|
||||
const data = await analyzeRes.json()
|
||||
setReport(data)
|
||||
setView('dashboard')
|
||||
loadProjects()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Analyse fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenProject = async (projectId: string) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/gap/projects/${projectId}/analyze`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000001' },
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const data = await res.json()
|
||||
setReport(data)
|
||||
setView('dashboard')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Analyse fehlgeschlagen')
|
||||
} finally {
|
||||
@@ -66,29 +131,88 @@ export default function GapAnalysisPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Regulatory Gap-Analyse
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Beschreiben Sie Ihr Produkt und erhalten Sie eine priorisierte
|
||||
Liste der Compliance-Anforderungen.
|
||||
</p>
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Regulatory Gap-Analyse
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Produkt beschreiben, Regulierungen erkennen, Prioritaeten setzen.
|
||||
</p>
|
||||
</div>
|
||||
{view !== 'projects' && (
|
||||
<button
|
||||
onClick={() => { setView('projects'); setReport(null) }}
|
||||
className="px-4 py-2 text-sm text-blue-600 hover:text-blue-800 border border-blue-200 rounded-lg hover:bg-blue-50"
|
||||
>
|
||||
Alle Projekte
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-700">{error}</p>
|
||||
<button onClick={() => setError('')} className="text-sm text-red-500 mt-1 underline">
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!report ? (
|
||||
<ProductWizard onAnalyze={handleAnalyze} loading={loading} />
|
||||
) : (
|
||||
<GapDashboard
|
||||
report={report}
|
||||
onBack={() => setReport(null)}
|
||||
/>
|
||||
{view === 'projects' && (
|
||||
<div>
|
||||
{/* New project button */}
|
||||
<button
|
||||
onClick={() => setView('wizard')}
|
||||
className="mb-6 w-full py-4 border-2 border-dashed border-blue-300 rounded-xl text-blue-600 hover:bg-blue-50 hover:border-blue-400 transition-colors font-medium"
|
||||
>
|
||||
+ Neues Produkt analysieren
|
||||
</button>
|
||||
|
||||
{/* Project list */}
|
||||
{projects.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-gray-800">Gespeicherte Projekte</h2>
|
||||
{projects.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => handleOpenProject(p.id)}
|
||||
disabled={loading}
|
||||
className="w-full text-left bg-white rounded-xl shadow-sm border border-gray-200 p-5 hover:shadow-md hover:border-blue-300 transition-all disabled:opacity-50"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{p.name}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{p.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="px-3 py-1 bg-gray-100 text-gray-600 rounded-full text-xs font-medium">
|
||||
{PRODUCT_TYPE_LABELS[p.product_type] || p.product_type}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(p.created_at).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projects.length === 0 && (
|
||||
<p className="text-center text-gray-500 mt-8">
|
||||
Noch keine Projekte. Starten Sie Ihre erste Gap-Analyse.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'wizard' && (
|
||||
<ProductWizard onAnalyze={handleCreateAndAnalyze} loading={loading} />
|
||||
)}
|
||||
|
||||
{view === 'dashboard' && report && (
|
||||
<GapDashboard report={report} onBack={() => { setView('projects'); setReport(null) }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
interface DeltaResult {
|
||||
added_patterns?: Array<{ pattern_name: string; hazard_cats: string[] }>
|
||||
removed_patterns?: Array<{ pattern_name: string; hazard_cats: string[] }>
|
||||
added_hazards?: Array<{ name: string; category: string }>
|
||||
removed_hazards?: Array<{ name: string; category: string }>
|
||||
added_measures?: Array<{ id: string; name: string }>
|
||||
removed_measures?: Array<{ id: string; name: string }>
|
||||
}
|
||||
|
||||
interface DeltaPreviewModalProps {
|
||||
projectId: string
|
||||
currentInput: {
|
||||
component_library_ids: string[]
|
||||
energy_source_ids: string[]
|
||||
operational_states?: string[]
|
||||
human_roles?: string[]
|
||||
}
|
||||
proposedInput: {
|
||||
component_library_ids: string[]
|
||||
energy_source_ids: string[]
|
||||
operational_states?: string[]
|
||||
human_roles?: string[]
|
||||
}
|
||||
onClose: () => void
|
||||
onApply: () => void
|
||||
changeDescription: string
|
||||
}
|
||||
|
||||
export function DeltaPreviewModal({
|
||||
projectId,
|
||||
currentInput,
|
||||
proposedInput,
|
||||
onClose,
|
||||
onApply,
|
||||
changeDescription,
|
||||
}: DeltaPreviewModalProps) {
|
||||
const [result, setResult] = useState<DeltaResult | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Auto-run delta analysis on mount
|
||||
useState(() => {
|
||||
runDelta()
|
||||
})
|
||||
|
||||
async function runDelta() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/delta-analysis`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ current: currentInput, proposed: proposedInput }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
setError('Delta-Analyse fehlgeschlagen')
|
||||
return
|
||||
}
|
||||
setResult(await res.json())
|
||||
} catch {
|
||||
setError('Verbindung fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addedP = result?.added_patterns?.length || 0
|
||||
const removedP = result?.removed_patterns?.length || 0
|
||||
const addedH = result?.added_hazards?.length || 0
|
||||
const removedH = result?.removed_hazards?.length || 0
|
||||
const addedM = result?.added_measures?.length || 0
|
||||
const removedM = result?.removed_measures?.length || 0
|
||||
const hasChanges = addedP + removedP + addedH + removedH + addedM + removedM > 0
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-lg mx-4 max-h-[80vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Delta-Vorschau</h2>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{changeDescription}</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-4">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600" />
|
||||
<span className="ml-3 text-sm text-gray-500">Berechne Auswirkungen...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-700 rounded-lg p-3 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{result && !loading && (
|
||||
<div className="space-y-4">
|
||||
{/* Summary Grid */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<DeltaStat label="Patterns" added={addedP} removed={removedP} />
|
||||
<DeltaStat label="Gefaehrdungen" added={addedH} removed={removedH} />
|
||||
<DeltaStat label="Massnahmen" added={addedM} removed={removedM} />
|
||||
</div>
|
||||
|
||||
{!hasChanges && (
|
||||
<p className="text-sm text-gray-400 italic text-center py-2">
|
||||
Keine Auswirkungen erkannt — die Aenderung beeinflusst keine Patterns.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Added Hazards */}
|
||||
{addedH > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-green-700 mb-1">+ Neue Gefaehrdungen</h3>
|
||||
<ul className="space-y-0.5 max-h-32 overflow-y-auto">
|
||||
{result!.added_hazards!.slice(0, 15).map((h, i) => (
|
||||
<li key={i} className="text-xs text-gray-600 flex items-center gap-1">
|
||||
<span className="text-green-500 flex-shrink-0">+</span>
|
||||
<span className="truncate">{h.name || h.category}</span>
|
||||
</li>
|
||||
))}
|
||||
{addedH > 15 && <li className="text-xs text-gray-400">... und {addedH - 15} weitere</li>}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Removed Hazards */}
|
||||
{removedH > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-red-700 mb-1">- Entfallene Gefaehrdungen</h3>
|
||||
<ul className="space-y-0.5 max-h-32 overflow-y-auto">
|
||||
{result!.removed_hazards!.slice(0, 10).map((h, i) => (
|
||||
<li key={i} className="text-xs text-gray-600 flex items-center gap-1">
|
||||
<span className="text-red-500 flex-shrink-0">-</span>
|
||||
<span className="truncate">{h.name || h.category}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={onApply}
|
||||
disabled={loading}
|
||||
className="px-5 py-2 text-sm font-medium bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Aenderung uebernehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeltaStat({ label, added, removed }: { label: string; added: number; removed: number }) {
|
||||
return (
|
||||
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="text-xs text-gray-500 mb-1">{label}</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{added > 0 && <span className="text-sm font-bold text-green-600">+{added}</span>}
|
||||
{removed > 0 && <span className="text-sm font-bold text-red-600">-{removed}</span>}
|
||||
{added === 0 && removed === 0 && <span className="text-sm text-gray-400">0</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -95,13 +95,13 @@ export default function IACEFlowFAB() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end">
|
||||
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end pointer-events-none">
|
||||
{/* Expanded Panel */}
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`mb-3 w-[300px] max-h-[70vh] overflow-y-auto bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 transition-all duration-200 origin-bottom-right ${
|
||||
isOpen
|
||||
? 'opacity-100 scale-100 translate-y-0'
|
||||
? 'opacity-100 scale-100 translate-y-0 pointer-events-auto'
|
||||
: 'opacity-0 scale-95 translate-y-2 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
@@ -223,7 +223,7 @@ export default function IACEFlowFAB() {
|
||||
<button
|
||||
ref={fabRef}
|
||||
onClick={() => setIsOpen((o) => !o)}
|
||||
className="w-14 h-14 rounded-full bg-gradient-to-br from-purple-600 to-indigo-600 text-white shadow-lg hover:shadow-xl hover:scale-105 active:scale-95 transition-all flex items-center justify-center"
|
||||
className="pointer-events-auto w-14 h-14 rounded-full bg-gradient-to-br from-purple-600 to-indigo-600 text-white shadow-lg hover:shadow-xl hover:scale-105 active:scale-95 transition-all flex items-center justify-center"
|
||||
title="CE-Prozessschritte"
|
||||
>
|
||||
{/* Steps/flow icon */}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { CategoryScore } from '../_hooks/useBenchmark'
|
||||
|
||||
interface Props { breakdown: CategoryScore[] }
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
'mechanische gefaehrdungen': 'Mechanisch',
|
||||
'elektrische gefaehrdungen': 'Elektrisch',
|
||||
'thermische gefaehrdungen': 'Thermisch',
|
||||
'laerm': 'Laerm',
|
||||
'vibration': 'Vibration',
|
||||
'strahlung': 'Strahlung',
|
||||
'materialien und substanzen': 'Materialien/Substanzen',
|
||||
'ergonomische gefaehrdungen': 'Ergonomie',
|
||||
'einsatzumgebung': 'Einsatzumgebung',
|
||||
}
|
||||
|
||||
export function CategoryBreakdown({ breakdown }: Props) {
|
||||
if (!breakdown || breakdown.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Coverage nach Gefaehrdungsgruppe</h3>
|
||||
<div className="space-y-2">
|
||||
{breakdown.map((cat) => {
|
||||
const label = CATEGORY_LABELS[cat.category] || cat.category
|
||||
const pct = Math.round(cat.coverage * 100)
|
||||
const barColor = pct >= 80 ? 'bg-green-500' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500'
|
||||
return (
|
||||
<div key={cat.category}>
|
||||
<div className="flex justify-between text-xs text-gray-600 dark:text-gray-400 mb-0.5">
|
||||
<span>{label}</span>
|
||||
<span>{cat.match_count}/{cat.gt_count} ({pct}%)</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div className={`h-full ${barColor} rounded-full transition-all`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useRef } from 'react'
|
||||
import type { GroundTruthEntry } from '../_hooks/useBenchmark'
|
||||
|
||||
interface Props {
|
||||
onImport: (gt: { entries: GroundTruthEntry[]; source_file?: string; description?: string }) => Promise<void>
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export function GTImportForm({ onImport, loading }: Props) {
|
||||
const [jsonText, setJsonText] = useState('')
|
||||
const [parseError, setParseError] = useState<string | null>(null)
|
||||
const [preview, setPreview] = useState<{ count: number; groups: Record<string, number> } | null>(null)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
function tryParse(text: string) {
|
||||
setJsonText(text)
|
||||
setParseError(null)
|
||||
setPreview(null)
|
||||
if (!text.trim()) return
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
const entries: GroundTruthEntry[] = parsed.entries || parsed
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
setParseError('JSON muss ein Array "entries" enthalten')
|
||||
return
|
||||
}
|
||||
// Validate first entry has required fields
|
||||
const first = entries[0]
|
||||
if (!first.hazard_type && !first.hazard_group) {
|
||||
setParseError('Eintraege muessen hazard_type oder hazard_group enthalten')
|
||||
return
|
||||
}
|
||||
// Build preview
|
||||
const groups: Record<string, number> = {}
|
||||
for (const e of entries) {
|
||||
const g = e.hazard_group || 'Unbekannt'
|
||||
groups[g] = (groups[g] || 0) + 1
|
||||
}
|
||||
setPreview({ count: entries.length, groups })
|
||||
} catch (err) {
|
||||
setParseError('Ungueltiges JSON: ' + (err instanceof Error ? err.message : String(err)))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!jsonText.trim()) return
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText)
|
||||
const gt = parsed.entries ? parsed : { entries: parsed }
|
||||
await onImport(gt)
|
||||
setJsonText('')
|
||||
setPreview(null)
|
||||
} catch (err) {
|
||||
setParseError(err instanceof Error ? err.message : 'Import fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const text = ev.target?.result as string
|
||||
tryParse(text)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Ground Truth importieren</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
JSON-Datei mit der professionellen Risikobeurteilung einfuegen oder hochladen.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 mb-3">
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="px-3 py-1.5 text-xs bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-md transition-colors"
|
||||
>
|
||||
JSON-Datei waehlen
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept=".json" onChange={handleFileUpload} className="hidden" />
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => tryParse(e.target.value)}
|
||||
placeholder='{"entries": [...], "source_file": "...", "description": "..."}'
|
||||
rows={6}
|
||||
className="w-full text-xs font-mono border border-gray-300 dark:border-gray-600 rounded-md p-2 bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-200 resize-y"
|
||||
/>
|
||||
|
||||
{parseError && (
|
||||
<div className="mt-2 px-3 py-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs text-red-600">
|
||||
{parseError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<div className="mt-2 px-3 py-2 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded text-xs text-green-700 dark:text-green-400">
|
||||
<strong>{preview.count} Eintraege</strong> erkannt:
|
||||
{Object.entries(preview.groups).map(([g, c]) => (
|
||||
<span key={g} className="ml-2">{g}: {c}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={loading || !preview}
|
||||
className="mt-3 w-full px-4 py-2 text-sm font-medium bg-purple-600 hover:bg-purple-700 disabled:bg-gray-300 text-white rounded-md transition-colors"
|
||||
>
|
||||
{loading ? 'Importiere...' : 'Ground Truth importieren'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import type { HazardMatchPair, GroundTruthEntry, HazardSummary } from '../_hooks/useBenchmark'
|
||||
|
||||
interface Props {
|
||||
matched: HazardMatchPair[]
|
||||
missing: GroundTruthEntry[]
|
||||
extra: HazardSummary[]
|
||||
}
|
||||
|
||||
type TabType = 'matched' | 'missing' | 'extra'
|
||||
|
||||
export function HazardComparisonTable({ matched, missing, extra }: Props) {
|
||||
const [tab, setTab] = useState<TabType>('matched')
|
||||
|
||||
// Split matches: >= 50% are real matches, < 50% are weak (shown separately)
|
||||
const realMatched = matched.filter(p => p.match_score >= 0.5)
|
||||
const weakMatched = matched.filter(p => p.match_score < 0.5)
|
||||
|
||||
// Weak matches: GT entries go to "missing", engine entries go to "extra"
|
||||
const allMissing = [...missing, ...weakMatched.map(w => w.gt_entry)]
|
||||
const allExtra = [...extra, ...weakMatched.map(w => w.engine_hazard)]
|
||||
|
||||
const greenCount = realMatched.filter(p => p.match_score >= 0.7).length
|
||||
const yellowCount = realMatched.filter(p => p.match_score >= 0.5 && p.match_score < 0.7).length
|
||||
|
||||
const tabs: { id: TabType; label: string; count: number; color: string }[] = [
|
||||
{ id: 'matched', label: `Zugeordnet (${greenCount} exakt, ${yellowCount} aehnlich)`, count: realMatched.length, color: 'text-green-600' },
|
||||
{ id: 'missing', label: 'Fehlend', count: allMissing.length, color: 'text-red-600' },
|
||||
{ id: 'extra', label: 'Engine Findings', count: allExtra.length, color: 'text-blue-500' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
{/* Tab bar */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`flex-1 px-4 py-2.5 text-xs font-medium transition-colors ${
|
||||
tab === t.id
|
||||
? 'border-b-2 border-purple-600 text-purple-700 dark:text-purple-400'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{t.label} <span className={t.color}>({t.count})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{tab === 'matched' && <MatchedTable pairs={realMatched} />}
|
||||
{tab === 'missing' && <MissingTable entries={allMissing} />}
|
||||
{tab === 'extra' && <ExtraTable entries={allExtra} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) {
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
|
||||
if (pairs.length === 0) return <EmptyState text="Keine Zuordnungen gefunden" />
|
||||
return (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Nr.</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Ground Truth</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">R</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Engine</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-gray-500">Score</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Qualitaet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{pairs.map((p, i) => {
|
||||
const quality = p.match_score >= 0.7 ? 'green' : p.match_score >= 0.4 ? 'yellow' : 'red'
|
||||
const rowBg = quality === 'green' ? 'bg-green-50/30 dark:bg-green-900/5'
|
||||
: quality === 'yellow' ? 'bg-yellow-50/30 dark:bg-yellow-900/5' : ''
|
||||
const isOpen = expanded[i]
|
||||
return (
|
||||
<React.Fragment key={i}>
|
||||
<tr className={`hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer ${rowBg}`}
|
||||
onClick={() => setExpanded(prev => ({ ...prev, [i]: !prev[i] }))}>
|
||||
<td className="px-3 py-2 text-gray-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<svg className={`w-3 h-3 text-gray-400 transition-transform ${isOpen ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{p.gt_entry.nr}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium text-gray-800 dark:text-gray-200">{p.gt_entry.hazard_type}</div>
|
||||
<div className="text-gray-400 truncate max-w-[250px]">{p.gt_entry.component_zone}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<RiskBadge risk={p.gt_entry.risk_in.r} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium text-gray-800 dark:text-gray-200">{p.engine_hazard.name}</div>
|
||||
<div className="text-gray-400">{p.engine_hazard.category}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center"><ScoreBadge score={p.match_score} /></td>
|
||||
<td className="px-3 py-2"><QualityBadge quality={quality} /></td>
|
||||
</tr>
|
||||
{isOpen && (
|
||||
<tr className="bg-gray-50/70 dark:bg-gray-850">
|
||||
<td colSpan={6} className="px-4 py-3">
|
||||
<DetailComparison gt={p.gt_entry} engine={p.engine_hazard} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
const LIFECYCLE_LABELS: Record<string, string> = {
|
||||
startup: 'Hochfahren', homing: 'Referenzfahrt', automatic_operation: 'Automatikbetrieb',
|
||||
manual_operation: 'Handbetrieb', teach_mode: 'Einrichtbetrieb', maintenance: 'Wartung',
|
||||
cleaning: 'Reinigung', emergency_stop: 'Not-Halt', recovery_mode: 'Wiederanlauf',
|
||||
normal_operation: 'Automatikbetrieb', setup: 'Einrichten', changeover: 'Umruesten',
|
||||
fault_clearing: 'Fehlersuche/Stoerungsbeseitigung', commissioning: 'Inbetriebnahme',
|
||||
decommissioning: 'Demontage/Ausserbetriebnahme', transport: 'Transport',
|
||||
assembly: 'Montage/Installation', inspection: 'Inspektion/Pruefung',
|
||||
}
|
||||
|
||||
function formatLifecycles(raw: string): string {
|
||||
if (!raw) return '-'
|
||||
return raw.split(',').map(s => s.trim()).map(s => LIFECYCLE_LABELS[s] || s).join(', ')
|
||||
}
|
||||
|
||||
/** Side-by-side detail comparison of GT entry vs. Engine hazard */
|
||||
function DetailComparison({ gt, engine }: { gt: GroundTruthEntry; engine: HazardSummary }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||
{/* Left: Ground Truth */}
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold text-red-700 dark:text-red-400 uppercase text-[10px]">Ground Truth (Fachmann)</div>
|
||||
<DetailRow label="Gefaehrdung" gt={gt.hazard_type} />
|
||||
<DetailRow label="Ursache" gt={gt.hazard_cause} />
|
||||
<DetailRow label="Gefahrenstelle" gt={gt.component_zone} />
|
||||
<DetailRow label="Lebensphasen" gt={gt.lifecycle_phases?.join(', ') || '-'} />
|
||||
<DetailRow label="Risiko" gt={`F=${gt.risk_in.f} W=${gt.risk_in.w} P=${gt.risk_in.p} S=${gt.risk_in.s} => R=${gt.risk_in.r}`} />
|
||||
{gt.risk_out.r > 0 && (
|
||||
<DetailRow label="Restrisiko" gt={`F=${gt.risk_out.f} W=${gt.risk_out.w} P=${gt.risk_out.p} S=${gt.risk_out.s} => R=${gt.risk_out.r}`} />
|
||||
)}
|
||||
<DetailRow label="Massnahmen" gt={gt.measures?.join('\n') || '-'} multiline />
|
||||
<DetailRow label="Typ" gt={gt.measure_type || '-'} />
|
||||
{gt.norm_references?.length > 0 && (
|
||||
<DetailRow label="Normen" gt={gt.norm_references.join(', ')} />
|
||||
)}
|
||||
<DetailRow label="Hinreichend" gt={gt.sufficient ? 'JA' : 'NEIN'} />
|
||||
{gt.comment && <DetailRow label="Kommentar" gt={gt.comment} />}
|
||||
</div>
|
||||
{/* Right: Engine */}
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold text-purple-700 dark:text-purple-400 uppercase text-[10px]">Engine (automatisch)</div>
|
||||
<DetailRow label="Gefaehrdung" gt={engine.name} />
|
||||
<DetailRow label="Szenario" gt={engine.scenario || engine.description || '-'} />
|
||||
<DetailRow label="Gefahrenstelle" gt={engine.zone || '-'} />
|
||||
{engine.lifecycle_phase && (
|
||||
<DetailRow label="Lebensphasen" gt={formatLifecycles(engine.lifecycle_phase)} />
|
||||
)}
|
||||
<DetailRow label="Moeglicher Schaden" gt={engine.possible_harm || '-'} />
|
||||
<DetailRow label="Trigger" gt={engine.trigger_event || '-'} />
|
||||
{engine.affected_person && (
|
||||
<DetailRow label="Betroffene Personen" gt={engine.affected_person} />
|
||||
)}
|
||||
{engine.mitigations && engine.mitigations.length > 0 ? (
|
||||
<DetailRow label="Massnahmen" gt={engine.mitigations.join('\n')} multiline />
|
||||
) : (
|
||||
<DetailRow label="Massnahmen" gt="(keine zugeordnet)" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailRow({ label, gt, multiline }: { label: string; gt: string; multiline?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-gray-500 uppercase">{label}</div>
|
||||
{multiline ? (
|
||||
<pre className="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap font-sans mt-0.5">{gt}</pre>
|
||||
) : (
|
||||
<div className="text-xs text-gray-700 dark:text-gray-300 mt-0.5">{gt}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MissingTable({ entries }: { entries: GroundTruthEntry[] }) {
|
||||
if (entries.length === 0) return <EmptyState text="Keine fehlenden Gefaehrdungen" />
|
||||
return (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-red-50 dark:bg-red-900/20">
|
||||
<th className="px-3 py-2 text-left font-medium text-red-600">Nr.</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-red-600">Gefaehrdung</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-red-600">Ursache</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-red-600">Zone</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-red-600">R</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-red-600">Typ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{entries.map((e, i) => (
|
||||
<tr key={i} className="hover:bg-red-50/50">
|
||||
<td className="px-3 py-2 text-gray-400">{e.nr}</td>
|
||||
<td className="px-3 py-2 font-medium text-gray-800 dark:text-gray-200">{e.hazard_type}</td>
|
||||
<td className="px-3 py-2 text-gray-600 truncate max-w-[200px]">{e.hazard_cause}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{e.component_zone}</td>
|
||||
<td className="px-3 py-2 text-center"><RiskBadge risk={e.risk_in.r} /></td>
|
||||
<td className="px-3 py-2 text-gray-500">{e.measure_type}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
function ExtraTable({ entries }: { entries: HazardSummary[] }) {
|
||||
if (entries.length === 0) return <EmptyState text="Keine zusaetzlichen Engine-Gefaehrdungen" />
|
||||
return (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Name</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Kategorie</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Zone</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{entries.map((e, i) => (
|
||||
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||
<td className="px-3 py-2 text-gray-800 dark:text-gray-200">{e.name}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{e.category}</td>
|
||||
<td className="px-3 py-2 text-gray-400">{e.zone || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
function RiskBadge({ risk }: { risk: number }) {
|
||||
const color = risk >= 30 ? 'bg-red-100 text-red-700' : risk >= 15 ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'
|
||||
return <span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold ${color}`}>{risk}</span>
|
||||
}
|
||||
|
||||
function ScoreBadge({ score }: { score: number }) {
|
||||
const pct = Math.round(score * 100)
|
||||
const color = pct >= 70 ? 'text-green-600' : pct >= 50 ? 'text-yellow-600' : 'text-red-600'
|
||||
return <span className={`font-bold ${color}`}>{pct}%</span>
|
||||
}
|
||||
|
||||
function QualityBadge({ quality }: { quality: 'green' | 'yellow' | 'red' }) {
|
||||
const styles = {
|
||||
green: 'bg-green-100 text-green-700 border-green-200',
|
||||
yellow: 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||
red: 'bg-red-100 text-red-700 border-red-200',
|
||||
}
|
||||
const labels = { green: 'Exakt', yellow: 'Aehnlich', red: 'Schwach' }
|
||||
return (
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded border text-[10px] font-medium ${styles[quality]}`}>
|
||||
{labels[quality]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ text }: { text: string }) {
|
||||
return <div className="px-4 py-8 text-center text-sm text-gray-400">{text}</div>
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
export interface GTRisk { f: number; w: number; p: number; s: number; r: number }
|
||||
export interface GTPLr { s: string; f: string; p: string; ew?: string; plr: string }
|
||||
|
||||
export interface GroundTruthEntry {
|
||||
nr: string
|
||||
hazard_group: string
|
||||
hazard_group_applicable: boolean
|
||||
hazard_subgroup: string
|
||||
hazard_type: string
|
||||
hazard_cause: string
|
||||
lifecycle_phases: string[]
|
||||
component_zone: string
|
||||
risk_in: GTRisk
|
||||
plr?: GTPLr | null
|
||||
measures: string[]
|
||||
measure_type: string
|
||||
risk_out: GTRisk
|
||||
norm_references: string[]
|
||||
sufficient: boolean
|
||||
comment?: string
|
||||
reduction_steps?: {
|
||||
risk_in: GTRisk; measures: string[]; measure_type: string
|
||||
risk_out: GTRisk; norm_references: string[]; sufficient: boolean
|
||||
}[]
|
||||
}
|
||||
|
||||
export interface HazardSummary {
|
||||
id: string; name: string; category: string
|
||||
component?: string; zone?: string; risk_level?: string
|
||||
description?: string; scenario?: string
|
||||
possible_harm?: string; trigger_event?: string
|
||||
affected_person?: string; lifecycle_phase?: string
|
||||
mitigations?: string[]
|
||||
}
|
||||
|
||||
export interface HazardMatchPair {
|
||||
gt_entry: GroundTruthEntry
|
||||
engine_hazard: HazardSummary
|
||||
match_score: number
|
||||
match_reason: string
|
||||
}
|
||||
|
||||
export interface CategoryScore {
|
||||
category: string; gt_count: number; match_count: number; coverage: number
|
||||
}
|
||||
|
||||
export interface BenchmarkResult {
|
||||
coverage_score: number
|
||||
measure_coverage: number
|
||||
total_gt: number
|
||||
total_engine: number
|
||||
matched_pairs: HazardMatchPair[]
|
||||
missing_from_engine: GroundTruthEntry[]
|
||||
extra_in_engine: HazardSummary[]
|
||||
category_breakdown: CategoryScore[]
|
||||
risk_rank_pairs: { gt_rank: number; engine_rank: number; hazard_name: string; gt_risk_score: number }[]
|
||||
}
|
||||
|
||||
interface UseBenchmarkReturn {
|
||||
result: BenchmarkResult | null
|
||||
gtLoaded: boolean
|
||||
gtEntryCount: number
|
||||
loading: boolean
|
||||
error: string | null
|
||||
importGT: (gt: { entries: GroundTruthEntry[]; source_file?: string; description?: string }) => Promise<void>
|
||||
runBenchmark: (gtProjectId?: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function useBenchmark(projectId: string): UseBenchmarkReturn {
|
||||
const [result, setResult] = useState<BenchmarkResult | null>(null)
|
||||
const [gtLoaded, setGtLoaded] = useState(false)
|
||||
const [gtEntryCount, setGtEntryCount] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const importGT = useCallback(async (gt: { entries: GroundTruthEntry[]; source_file?: string; description?: string }) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/benchmark/import-gt`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(gt),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const data = await res.json()
|
||||
setGtLoaded(true)
|
||||
setGtEntryCount(data.entry_count || gt.entries.length)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Import failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
const runBenchmark = useCallback(async (gtProjectId?: string) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const params = gtProjectId ? `?gt_project_id=${gtProjectId}` : ''
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/benchmark${params}`)
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const data: BenchmarkResult = await res.json()
|
||||
setResult(data)
|
||||
setGtLoaded(true)
|
||||
setGtEntryCount(data.total_gt)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Benchmark failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
return { result, gtLoaded, gtEntryCount, loading, error, importGT, runBenchmark }
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useBenchmark } from './_hooks/useBenchmark'
|
||||
import { GTImportForm } from './_components/GTImportForm'
|
||||
import { HazardComparisonTable } from './_components/HazardComparisonTable'
|
||||
import { CategoryBreakdown } from './_components/CategoryBreakdown'
|
||||
|
||||
export default function BenchmarkPage() {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const { result, gtLoaded, gtEntryCount, loading, error, importGT, runBenchmark } = useBenchmark(projectId)
|
||||
const [gtProjectId, setGtProjectId] = useState('')
|
||||
|
||||
// Only count matches >= 50% as real coverage
|
||||
const realMatchCount = result ? (result.matched_pairs?.filter(m => m.match_score >= 0.5).length || 0) : 0
|
||||
const coveragePct = result ? Math.round(realMatchCount * 100 / Math.max(result.total_gt, 1)) : 0
|
||||
const measurePct = result ? Math.round(result.measure_coverage * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-[1200px]">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-gray-900 dark:text-white">Ground Truth Benchmark</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Vergleich der Engine-Ergebnisse mit einer professionellen Risikobeurteilung
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-4 py-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GT Import or Cross-Project Reference */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<GTImportForm onImport={importGT} loading={loading} />
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Benchmark ausfuehren</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
GT aus diesem Projekt verwenden, oder eine Projekt-ID mit importierter GT angeben.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={gtProjectId}
|
||||
onChange={(e) => setGtProjectId(e.target.value)}
|
||||
placeholder="GT-Projekt-ID (optional — leer = dieses Projekt)"
|
||||
className="w-full text-xs border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-gray-50 dark:bg-gray-900"
|
||||
/>
|
||||
<button
|
||||
onClick={() => runBenchmark(gtProjectId || undefined)}
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-2 text-sm font-medium bg-purple-600 hover:bg-purple-700 disabled:bg-gray-300 text-white rounded-md transition-colors"
|
||||
>
|
||||
{loading ? 'Vergleiche...' : 'Benchmark starten'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{gtLoaded && !result && (
|
||||
<div className="mt-3 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 rounded text-xs text-blue-600">
|
||||
{gtEntryCount} GT-Eintraege geladen. Klicke "Benchmark starten".
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<>
|
||||
{/* Score Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<ScoreCard
|
||||
label="Hazard Coverage"
|
||||
value={`${coveragePct}%`}
|
||||
sub={`${realMatchCount} / ${result.total_gt} erkannt (>= 50% Match)`}
|
||||
color={coveragePct >= 80 ? 'green' : coveragePct >= 50 ? 'yellow' : 'red'}
|
||||
/>
|
||||
<ScoreCard
|
||||
label="Massnahmen-Coverage"
|
||||
value={`${measurePct}%`}
|
||||
sub="der zugeordneten Gefaehrdungen"
|
||||
color={measurePct >= 80 ? 'green' : measurePct >= 50 ? 'yellow' : 'red'}
|
||||
/>
|
||||
<ScoreCard
|
||||
label="GT Eintraege"
|
||||
value={String(result.total_gt)}
|
||||
sub="professionelle Beurteilung"
|
||||
color="gray"
|
||||
/>
|
||||
<ScoreCard
|
||||
label="Engine Eintraege"
|
||||
value={String(result.total_engine)}
|
||||
sub={`${result.extra_in_engine?.length || 0} zusaetzlich`}
|
||||
color="gray"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Breakdown */}
|
||||
<CategoryBreakdown breakdown={result.category_breakdown || []} />
|
||||
|
||||
{/* Hazard Comparison Table */}
|
||||
<HazardComparisonTable
|
||||
matched={result.matched_pairs || []}
|
||||
missing={result.missing_from_engine || []}
|
||||
extra={result.extra_in_engine || []}
|
||||
/>
|
||||
|
||||
{/* Business Impact */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Business Impact</h3>
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">2,5 Tage</div>
|
||||
<div className="text-xs text-gray-500">Manueller Aufwand</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{(coveragePct / 100 * 2.5).toFixed(1)} Tage
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Eingespart bei {coveragePct}% Coverage</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{Math.round(coveragePct / 100 * 2.5 * 8 * 100)} EUR
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Einsparung (100 EUR/h)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScoreCard({ label, value, sub, color }: {
|
||||
label: string; value: string; sub: string
|
||||
color: 'green' | 'yellow' | 'red' | 'gray'
|
||||
}) {
|
||||
const colors = {
|
||||
green: 'border-green-200 dark:border-green-800',
|
||||
yellow: 'border-yellow-200 dark:border-yellow-800',
|
||||
red: 'border-red-200 dark:border-red-800',
|
||||
gray: 'border-gray-200 dark:border-gray-700',
|
||||
}
|
||||
const textColors = {
|
||||
green: 'text-green-600', yellow: 'text-yellow-600',
|
||||
red: 'text-red-600', gray: 'text-gray-900 dark:text-white',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg border-2 ${colors[color]} p-4 text-center`}>
|
||||
<div className={`text-2xl font-bold ${textColors[color]}`}>{value}</div>
|
||||
<div className="text-xs font-medium text-gray-700 dark:text-gray-300 mt-1">{label}</div>
|
||||
<div className="text-[10px] text-gray-400 mt-0.5">{sub}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export function ComponentForm({
|
||||
version: initialData?.version || '',
|
||||
description: initialData?.description || '',
|
||||
safety_relevant: initialData?.safety_relevant || false,
|
||||
ce_marked: initialData?.ce_marked || false,
|
||||
parent_id: parentId || initialData?.parent_id || null,
|
||||
})
|
||||
|
||||
@@ -73,6 +74,19 @@ export function ComponentForm({
|
||||
</label>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Sicherheitsrelevant</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-6">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.ce_marked}
|
||||
onChange={(e) => setFormData({ ...formData, ce_marked: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-green-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-green-500" />
|
||||
</label>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Bereits CE-gekennzeichnet</span>
|
||||
<span className="text-[10px] text-gray-400">(Nur Schnittstellen bewerten)</span>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
|
||||
@@ -5,10 +5,12 @@ export interface Component {
|
||||
version: string
|
||||
description: string
|
||||
safety_relevant: boolean
|
||||
ce_marked?: boolean
|
||||
parent_id: string | null
|
||||
children: Component[]
|
||||
library_component_id?: string
|
||||
energy_source_ids?: string[]
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface LibraryComponent {
|
||||
@@ -41,6 +43,7 @@ export interface ComponentFormData {
|
||||
version: string
|
||||
description: string
|
||||
safety_relevant: boolean
|
||||
ce_marked: boolean
|
||||
parent_id: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export interface FailureMode {
|
||||
id: string
|
||||
component_type: string
|
||||
mode: string
|
||||
name_de: string
|
||||
name_en: string
|
||||
effect: string
|
||||
detection_hint: string
|
||||
default_severity: number
|
||||
default_occurrence: number
|
||||
default_detection: number
|
||||
}
|
||||
|
||||
export interface Component {
|
||||
id: string
|
||||
name: string
|
||||
component_type: string
|
||||
}
|
||||
|
||||
export interface FMEARow {
|
||||
component: Component
|
||||
failureMode: FailureMode
|
||||
severity: number
|
||||
occurrence: number
|
||||
detection: number
|
||||
rpz: number
|
||||
ap: 'H' | 'M' | 'L'
|
||||
}
|
||||
|
||||
/** AIAG-VDA Action Priority (2019 Handbook) */
|
||||
export function calculateAP(s: number, o: number, d: number): 'H' | 'M' | 'L' {
|
||||
if (s >= 9) return (o >= 4 || d >= 7) ? 'H' : (o >= 2 || d >= 5) ? 'M' : 'L'
|
||||
if (s >= 7) return (o >= 5 || d >= 8) ? 'H' : (o >= 3 || d >= 5) ? 'M' : 'L'
|
||||
if (s >= 5) return (o >= 7 || d >= 9) ? 'H' : (o >= 4 || d >= 7) ? 'M' : 'L'
|
||||
return (o >= 8 && d >= 9) ? 'H' : (o >= 6 || d >= 8) ? 'M' : 'L'
|
||||
}
|
||||
|
||||
export function useFMEA(projectId: string) {
|
||||
const [rows, setRows] = useState<FMEARow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [projectId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
// Load project components
|
||||
const compRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`)
|
||||
if (!compRes.ok) return
|
||||
const compJson = await compRes.json()
|
||||
const components: Component[] = (compJson.components || compJson || []).map(
|
||||
(c: Record<string, unknown>) => ({
|
||||
id: c.id as string,
|
||||
name: c.name as string,
|
||||
component_type: c.component_type as string || 'mechanical',
|
||||
})
|
||||
)
|
||||
|
||||
// Load ALL failure modes, then match by component type + name keywords
|
||||
const allRes = await fetch('/api/sdk/v1/iace/failure-modes')
|
||||
let allFMs: FailureMode[] = []
|
||||
if (allRes.ok) {
|
||||
const json = await allRes.json()
|
||||
allFMs = json.failure_modes || []
|
||||
}
|
||||
|
||||
// Derive the best FM component_type from component name keywords
|
||||
const nameToFMTypes: Record<string, string[]> = {
|
||||
sensor: ['sensor'], scanner: ['sensor'], kamera: ['sensor'],
|
||||
motor: ['actuator', 'electrical'], antrieb: ['actuator'],
|
||||
steuerung: ['controller'], sps: ['controller'], plc: ['controller'],
|
||||
software: ['software'], firmware: ['software'],
|
||||
ventil: ['actuator', 'mechanical'], greifer: ['actuator', 'mechanical'],
|
||||
roboter: ['actuator', 'mechanical'], hydraulik: ['actuator'],
|
||||
netzwerk: ['network'], ethernet: ['network'],
|
||||
}
|
||||
|
||||
function getFMTypesForComp(comp: Component): string[] {
|
||||
const types = [comp.component_type]
|
||||
const nameLower = comp.name.toLowerCase()
|
||||
for (const [kw, fmTypes] of Object.entries(nameToFMTypes)) {
|
||||
if (nameLower.includes(kw)) types.push(...fmTypes)
|
||||
}
|
||||
return [...new Set(types)]
|
||||
}
|
||||
|
||||
// Build FMEA rows: each component × its matching failure modes
|
||||
const fmeaRows: FMEARow[] = []
|
||||
for (const comp of components) {
|
||||
const compTypes = getFMTypesForComp(comp)
|
||||
const compFMs = allFMs.filter((fm) => compTypes.includes(fm.component_type))
|
||||
// Use matched FMs, or fallback to mechanical FMs
|
||||
const relevantFMs = compFMs.length > 0 ? compFMs : allFMs.filter((fm) => fm.component_type === 'mechanical').slice(0, 3)
|
||||
|
||||
for (const fm of relevantFMs) {
|
||||
const s = fm.default_severity || 5
|
||||
const o = fm.default_occurrence || 5
|
||||
const d = fm.default_detection || 5
|
||||
fmeaRows.push({
|
||||
component: comp,
|
||||
failureMode: fm,
|
||||
severity: s,
|
||||
occurrence: o,
|
||||
detection: d,
|
||||
rpz: s * o * d,
|
||||
ap: calculateAP(s, o, d),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by RPZ descending (highest risk first)
|
||||
fmeaRows.sort((a, b) => b.rpz - a.rpz)
|
||||
setRows(fmeaRows)
|
||||
} catch (err) {
|
||||
console.error('Failed to load FMEA data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stats = {
|
||||
total: rows.length,
|
||||
critical: rows.filter((r) => r.rpz > 200).length,
|
||||
actionRequired: rows.filter((r) => r.rpz > 100 && r.rpz <= 200).length,
|
||||
acceptable: rows.filter((r) => r.rpz <= 100).length,
|
||||
}
|
||||
|
||||
const [suggesting, setSuggesting] = useState(false)
|
||||
const [suggestions, setSuggestions] = useState<FailureMode[]>([])
|
||||
const [suggestSource, setSuggestSource] = useState<string>('')
|
||||
|
||||
async function suggestFMs(componentId: string) {
|
||||
setSuggesting(true)
|
||||
setSuggestions([])
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components/${componentId}/suggest-fms`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setSuggestions(json.suggestions || [])
|
||||
setSuggestSource(json.source || 'unknown')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('FM suggest failed:', err)
|
||||
} finally {
|
||||
setSuggesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Get unique components for the suggest button
|
||||
const components = [...new Map(rows.map((r) => [r.component.id, r.component])).values()]
|
||||
|
||||
return { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions }
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useFMEA, type FMEARow } from './_hooks/useFMEA'
|
||||
|
||||
const COMP_TYPE_LABELS: Record<string, string> = {
|
||||
mechanical: 'Mechanisch', electrical: 'Elektrisch', sensor: 'Sensor',
|
||||
actuator: 'Aktor', software: 'Software', firmware: 'Firmware',
|
||||
ai_model: 'KI-Modell', hmi: 'HMI', network: 'Netzwerk',
|
||||
hydraulic: 'Hydraulik', pneumatic: 'Pneumatik', safety: 'Sicherheit',
|
||||
}
|
||||
|
||||
function rpzColor(rpz: number): string {
|
||||
if (rpz > 200) return 'bg-red-100 text-red-800 border-red-200'
|
||||
if (rpz > 100) return 'bg-orange-100 text-orange-800 border-orange-200'
|
||||
if (rpz > 50) return 'bg-yellow-100 text-yellow-800 border-yellow-200'
|
||||
return 'bg-green-100 text-green-800 border-green-200'
|
||||
}
|
||||
|
||||
function rpzLabel(rpz: number): string {
|
||||
if (rpz > 200) return 'Kritisch'
|
||||
if (rpz > 100) return 'Handlungsbedarf'
|
||||
if (rpz > 50) return 'Beobachten'
|
||||
return 'Akzeptabel'
|
||||
}
|
||||
|
||||
export default function FMEAPage() {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions } = useFMEA(projectId)
|
||||
const [suggestComp, setSuggestComp] = useState<string | null>(null)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">FMEA-Worksheet</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Fehlermoeglich­keits- und Einflussanalyse — RPZ = Severity x Occurrence x Detection
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<FMEAInfoBox />
|
||||
|
||||
{/* KI-Vorschlag + Export */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={suggestComp || ''}
|
||||
onChange={(e) => setSuggestComp(e.target.value || null)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="">Komponente waehlen...</option>
|
||||
{components.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => suggestComp && suggestFMs(suggestComp)}
|
||||
disabled={!suggestComp || suggesting}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{suggesting ? (
|
||||
<span className="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full" />
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
)}
|
||||
KI-Vorschlag
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<a
|
||||
href={`/api/sdk/v1/iace/projects/${projectId}/fmea/export`}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm font-medium transition-colors"
|
||||
download
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
VDA Excel exportieren
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggest Results */}
|
||||
{suggestions.length > 0 && (
|
||||
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-purple-800 dark:text-purple-300">
|
||||
KI-Vorschlaege ({suggestions.length}) — {suggestSource === 'llm' ? 'LLM-generiert' : 'Bibliothek'}
|
||||
</h3>
|
||||
<button onClick={() => setSuggestions([])} className="text-xs text-purple-600 hover:text-purple-800">Schliessen</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{suggestions.map((fm, i) => (
|
||||
<div key={i} className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg p-3 border border-purple-100 dark:border-purple-800">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{fm.name_de}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{fm.effect}</div>
|
||||
<div className="flex gap-3 mt-1 text-xs text-gray-400">
|
||||
<span>S={fm.default_severity}</span>
|
||||
<span>O={fm.default_occurrence}</span>
|
||||
<span>D={fm.default_detection}</span>
|
||||
<span className="font-bold">RPZ={fm.default_severity * fm.default_occurrence * fm.default_detection}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<StatCard label="Gesamt" value={stats.total} color="gray" />
|
||||
<StatCard label="Kritisch (RPZ > 200)" value={stats.critical} color="red" />
|
||||
<StatCard label="Handlungsbedarf (RPZ > 100)" value={stats.actionRequired} color="orange" />
|
||||
<StatCard label="Akzeptabel (RPZ ≤ 100)" value={stats.acceptable} color="green" />
|
||||
</div>
|
||||
|
||||
{/* RPZ Threshold Info */}
|
||||
<div className="p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-xs text-amber-800 dark:text-amber-300">
|
||||
<strong>RPZ-Schwellen:</strong> Kritisch > 200 | Handlungsbedarf > 100 | Beobachten > 50 | Akzeptabel ≤ 50.
|
||||
Massnahmen sind erforderlich ab RPZ > 100.
|
||||
</div>
|
||||
|
||||
{/* FMEA Table */}
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Keine Failure Modes gefunden. Bitte zuerst Komponenten erfassen.
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Komponente</th>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Typ</th>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Fehlerart</th>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Auswirkung</th>
|
||||
<th className="px-3 py-2.5 text-center text-xs font-medium text-gray-500 uppercase w-12">S</th>
|
||||
<th className="px-3 py-2.5 text-center text-xs font-medium text-gray-500 uppercase w-12">O</th>
|
||||
<th className="px-3 py-2.5 text-center text-xs font-medium text-gray-500 uppercase w-12">D</th>
|
||||
<th className="px-3 py-2.5 text-center text-xs font-medium text-gray-500 uppercase w-16">RPZ</th>
|
||||
<th className="px-3 py-2.5 text-center text-xs font-medium text-gray-500 uppercase w-12">AP</th>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Bewertung</th>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Erkennung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{rows.map((row, idx) => (
|
||||
<FMEATableRow key={`${row.component.id}-${row.failureMode.id}-${idx}`} row={row} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FMEATableRow({ row }: { row: FMEARow }) {
|
||||
const color = rpzColor(row.rpz)
|
||||
return (
|
||||
<tr className={`hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors ${row.rpz > 100 ? 'bg-red-50/30 dark:bg-red-900/10' : ''}`}>
|
||||
<td className="px-3 py-2.5 text-sm font-medium text-gray-900 dark:text-white">{row.component.name}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
||||
{COMP_TYPE_LABELS[row.component.component_type] || row.component.component_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="text-sm text-gray-900 dark:text-white">{row.failureMode.name_de}</div>
|
||||
<div className="text-[10px] text-gray-400">{row.failureMode.id}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-xs text-gray-600 dark:text-gray-400 max-w-[200px] truncate" title={row.failureMode.effect}>
|
||||
{row.failureMode.effect}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-sm text-center font-medium text-gray-900 dark:text-white">{row.severity}</td>
|
||||
<td className="px-3 py-2.5 text-sm text-center font-medium text-gray-900 dark:text-white">{row.occurrence}</td>
|
||||
<td className="px-3 py-2.5 text-sm text-center font-medium text-gray-900 dark:text-white">{row.detection}</td>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-sm font-bold border ${color}`}>
|
||||
{row.rpz}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-bold ${
|
||||
row.ap === 'H' ? 'bg-red-600 text-white' :
|
||||
row.ap === 'M' ? 'bg-yellow-500 text-white' :
|
||||
'bg-green-500 text-white'
|
||||
}`}>
|
||||
{row.ap}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${color}`}>{rpzLabel(row.rpz)}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-xs text-gray-500 dark:text-gray-400 max-w-[150px] truncate" title={row.failureMode.detection_hint}>
|
||||
{row.failureMode.detection_hint || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function FMEAInfoBox() {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl overflow-hidden">
|
||||
<button onClick={() => setOpen(!open)} className="w-full flex items-center justify-between px-4 py-3 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-blue-800 dark:text-blue-300">Was ist FMEA? — Anleitung & Beispiel</span>
|
||||
</div>
|
||||
<svg className={`w-4 h-4 text-blue-600 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-4 pb-4 text-xs text-blue-800 dark:text-blue-300 space-y-3">
|
||||
<p><strong>FMEA</strong> (Fehlermoeglich- und Einflussanalyse) ist eine systematische Methode zur vorbeugenden Qualitaetssicherung nach AIAG-VDA (2019).</p>
|
||||
<div>
|
||||
<strong>Bewertungsskalen (je 1-10):</strong>
|
||||
<ul className="mt-1 ml-4 space-y-0.5 list-disc">
|
||||
<li><strong>S (Severity)</strong> — Schwere der Auswirkung: 1 = kaum merkbar, 10 = katastrophal (Lebensgefahr)</li>
|
||||
<li><strong>O (Occurrence)</strong> — Auftretenswahrscheinlichkeit: 1 = praktisch ausgeschlossen, 10 = sehr haeufig</li>
|
||||
<li><strong>D (Detection)</strong> — Entdeckbarkeit: 1 = sofort erkennbar, 10 = nicht erkennbar</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Kennzahlen:</strong>
|
||||
<ul className="mt-1 ml-4 space-y-0.5 list-disc">
|
||||
<li><strong>RPZ</strong> = S x O x D (1-1000). Ab RPZ > 100: Massnahme erforderlich.</li>
|
||||
<li><strong>AP (Action Priority)</strong> — AIAG-VDA Standard: <span className="inline-block px-1.5 py-0.5 bg-red-600 text-white rounded text-[10px] font-bold">H</span> = sofort handeln, <span className="inline-block px-1.5 py-0.5 bg-yellow-500 text-white rounded text-[10px] font-bold">M</span> = planen, <span className="inline-block px-1.5 py-0.5 bg-green-500 text-white rounded text-[10px] font-bold">L</span> = beobachten</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Beispiel:</strong> SPS-Steuerung → Kommunikationsausfall (S=8, O=3, D=5) → RPZ=120, AP=M → Massnahme: Redundante Kommunikation implementieren.
|
||||
</div>
|
||||
<div>
|
||||
<strong>Workflow:</strong> 1. Komponente waehlen → 2. Fehlerart identifizieren → 3. S/O/D bewerten → 4. AP pruefen → 5. Bei H/M: Massnahme definieren → 6. Nach Massnahme: neu bewerten
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
gray: 'bg-gray-50 text-gray-700 border-gray-200',
|
||||
red: 'bg-red-50 text-red-700 border-red-200',
|
||||
orange: 'bg-orange-50 text-orange-700 border-orange-200',
|
||||
green: 'bg-green-50 text-green-700 border-green-200',
|
||||
}
|
||||
return (
|
||||
<div className={`rounded-xl border p-4 ${colors[color] || colors.gray}`}>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
<div className="text-xs mt-1">{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { Hazard } from './types'
|
||||
import { RiskAssessmentTable } from './RiskAssessmentTable'
|
||||
|
||||
interface BlockData {
|
||||
parent_hazard: { hazard: { id: string } }
|
||||
children: { hazard: { id: string } }[]
|
||||
children_covered_by_parent: boolean
|
||||
block_key: string
|
||||
}
|
||||
|
||||
interface BlockInfo {
|
||||
isParent: boolean
|
||||
isChild: boolean
|
||||
isCovered: boolean
|
||||
blockKey: string
|
||||
parentId: string
|
||||
childCount: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projectId: string
|
||||
hazards: Hazard[]
|
||||
onReassess?: () => void
|
||||
decisions?: Record<string, boolean | null>
|
||||
onDecision?: (hazardId: string, acceptable: boolean | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps RiskAssessmentTable with block-awareness:
|
||||
* - Injects block metadata into hazards so the table can show grouping
|
||||
* - Provides controls to ungroup/promote children
|
||||
*/
|
||||
export function BlockAwareRiskTable({ projectId, hazards, onReassess, decisions, onDecision }: Props) {
|
||||
const [blocks, setBlocks] = useState<BlockData[]>([])
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
const [ungrouped, setUngrouped] = useState<Record<string, boolean>>({})
|
||||
const [pendingAction, setPendingAction] = useState<{ childId: string; childName: string } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazard-blocks`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => { if (d?.blocks) setBlocks(d.blocks) })
|
||||
.catch(() => {})
|
||||
}, [projectId])
|
||||
|
||||
// Build lookup: hazardId → block info
|
||||
const blockMap = useMemo(() => {
|
||||
const map: Record<string, BlockInfo> = {}
|
||||
for (const b of blocks) {
|
||||
if (b.children.length === 0) continue
|
||||
const pid = b.parent_hazard.hazard.id
|
||||
map[pid] = {
|
||||
isParent: true, isChild: false, isCovered: false,
|
||||
blockKey: b.block_key, parentId: pid, childCount: b.children.length,
|
||||
}
|
||||
for (const c of b.children) {
|
||||
if (ungrouped[c.hazard.id]) continue
|
||||
map[c.hazard.id] = {
|
||||
isParent: false, isChild: true,
|
||||
isCovered: b.children_covered_by_parent,
|
||||
blockKey: b.block_key, parentId: pid, childCount: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [blocks, ungrouped])
|
||||
|
||||
// Sort hazards: parents first, then their children, then standalone
|
||||
const sortedHazards = useMemo(() => {
|
||||
const parents: Hazard[] = []
|
||||
const childrenByParent: Record<string, Hazard[]> = {}
|
||||
const standalone: Hazard[] = []
|
||||
|
||||
for (const h of hazards) {
|
||||
const info = blockMap[h.id]
|
||||
if (!info) {
|
||||
standalone.push(h)
|
||||
} else if (info.isParent) {
|
||||
parents.push(h)
|
||||
childrenByParent[h.id] = []
|
||||
} else if (info.isChild) {
|
||||
const arr = childrenByParent[info.parentId]
|
||||
if (arr) arr.push(h)
|
||||
else standalone.push(h)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort parents by risk desc
|
||||
parents.sort((a, b) => (b.r_inherent || 0) - (a.r_inherent || 0))
|
||||
standalone.sort((a, b) => (b.r_inherent || 0) - (a.r_inherent || 0))
|
||||
|
||||
// Interleave: parent → children → parent → children → ... → standalone
|
||||
const result: Hazard[] = []
|
||||
for (const p of parents) {
|
||||
result.push(p)
|
||||
const isCollapsed = collapsed[p.id]
|
||||
if (!isCollapsed && childrenByParent[p.id]) {
|
||||
result.push(...childrenByParent[p.id])
|
||||
}
|
||||
}
|
||||
result.push(...standalone)
|
||||
return result
|
||||
}, [hazards, blockMap, collapsed])
|
||||
|
||||
const toggleCollapse = (parentId: string) => {
|
||||
setCollapsed(prev => ({ ...prev, [parentId]: !prev[parentId] }))
|
||||
}
|
||||
|
||||
const handleUngroup = (childId: string) => {
|
||||
setUngrouped(prev => ({ ...prev, [childId]: true }))
|
||||
setPendingAction(null)
|
||||
}
|
||||
|
||||
const handleRegroup = (childId: string) => {
|
||||
setUngrouped(prev => {
|
||||
const next = { ...prev }
|
||||
delete next[childId]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Count blocks with children
|
||||
const blockCount = blocks.filter(b => b.children.length > 0).length
|
||||
const coveredCount = Object.values(blockMap).filter(b => b.isChild && b.isCovered).length
|
||||
const ungroupedCount = Object.keys(ungrouped).length
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Confirmation dialog */}
|
||||
{pendingAction && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 p-5 max-w-md w-full mx-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">Gefaehrdung aus Block entfernen?</h3>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mb-1">
|
||||
<strong>{pendingAction.childName}</strong>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Der Punkt wird als eigenstaendige Gefaehrdung gefuehrt und muss separat bewertet werden.
|
||||
Sie koennen ihn jederzeit ueber "Zurueck in Block" wieder zuordnen.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => handleUngroup(pendingAction.childId)}
|
||||
className="flex-1 px-3 py-2 text-xs font-medium bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||
Als eigenen Punkt fuehren
|
||||
</button>
|
||||
<button onClick={() => setPendingAction(null)}
|
||||
className="flex-1 px-3 py-2 text-xs font-medium bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Block info bar */}
|
||||
{blockCount > 0 && (
|
||||
<div className="flex items-center gap-4 px-4 py-2 bg-purple-50 dark:bg-purple-900/20 rounded-lg text-xs">
|
||||
<span className="font-medium text-purple-700 dark:text-purple-300">
|
||||
{blockCount} Bloecke erkannt
|
||||
</span>
|
||||
{coveredCount > 0 && (
|
||||
<span className="text-green-600">
|
||||
{coveredCount} Kinder durch Mutter abgedeckt
|
||||
</span>
|
||||
)}
|
||||
{ungroupedCount > 0 && (
|
||||
<button onClick={() => setUngrouped({})}
|
||||
className="text-orange-600 hover:text-orange-700 underline">
|
||||
{ungroupedCount} entgruppiert — alle zuruecksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enhanced table with block decorations */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs whitespace-nowrap">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="w-8 px-1 py-1.5"></th>
|
||||
<th colSpan={2} className="px-3 py-1.5 text-left font-semibold text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-600">Gefaehrdung</th>
|
||||
<th colSpan={4} className="px-3 py-1.5 text-center font-semibold text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-600">Risiko (S x F x P)</th>
|
||||
<th className="px-3 py-1.5 text-center font-semibold text-gray-700 dark:text-gray-300">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{sortedHazards.map(h => {
|
||||
const info = blockMap[h.id]
|
||||
const isParent = info?.isParent
|
||||
const isChild = info?.isChild
|
||||
const isCovered = info?.isCovered
|
||||
const childCount = info?.childCount || 0
|
||||
const isCollapsedParent = isParent && collapsed[h.id]
|
||||
|
||||
return (
|
||||
<tr key={h.id} className={`transition-colors ${
|
||||
isChild ? 'bg-gray-50/50 dark:bg-gray-850' :
|
||||
isParent ? 'bg-white dark:bg-gray-800' : ''
|
||||
} ${isCovered ? 'opacity-60' : ''} hover:bg-gray-50 dark:hover:bg-gray-750`}>
|
||||
{/* Block indicator */}
|
||||
<td className="px-1 py-2 text-center">
|
||||
{isParent && (
|
||||
<button onClick={() => toggleCollapse(h.id)}
|
||||
className="w-5 h-5 flex items-center justify-center rounded hover:bg-purple-100 text-purple-600 transition-colors"
|
||||
title={`${childCount} Kinder ${isCollapsedParent ? 'anzeigen' : 'verbergen'}`}>
|
||||
<svg className={`w-3 h-3 transition-transform ${isCollapsedParent ? '' : 'rotate-90'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{isChild && (
|
||||
<div className="flex items-center justify-center">
|
||||
<button onClick={() => setPendingAction({ childId: h.id, childName: h.name })}
|
||||
className="w-5 h-5 flex items-center justify-center rounded hover:bg-orange-100 text-gray-300 hover:text-orange-500 transition-colors"
|
||||
title="Aus Block entfernen (mit Bestaetigung)">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Show regroup button for ungrouped items */}
|
||||
{!isParent && !isChild && ungrouped[h.id] && (
|
||||
<button onClick={() => handleRegroup(h.id)}
|
||||
className="w-5 h-5 flex items-center justify-center rounded hover:bg-green-100 text-orange-400 hover:text-green-600 transition-colors"
|
||||
title="Zurueck in Block">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
{/* Name */}
|
||||
<td className={`px-3 py-2 ${isChild ? 'pl-8' : ''}`}>
|
||||
<div className={`font-medium ${isParent ? 'text-purple-800 dark:text-purple-300' : 'text-gray-900 dark:text-white'}`}>
|
||||
{h.name}
|
||||
{isParent && <span className="ml-1 text-[10px] text-purple-500">({childCount})</span>}
|
||||
</div>
|
||||
{h.hazardous_zone && <div className="text-[10px] text-gray-400 truncate max-w-[200px]">{h.hazardous_zone}</div>}
|
||||
</td>
|
||||
{/* Category */}
|
||||
<td className="px-3 py-2 border-r border-gray-200 dark:border-gray-600 text-gray-500">
|
||||
{h.category?.replace(/_/g, ' ')}
|
||||
</td>
|
||||
{/* Risk */}
|
||||
<td className="px-2 py-2 text-center">{h.severity || '-'}</td>
|
||||
<td className="px-2 py-2 text-center">{h.exposure || '-'}</td>
|
||||
<td className="px-2 py-2 text-center">{h.probability || '-'}</td>
|
||||
<td className="px-2 py-2 text-center font-bold border-r border-gray-200 dark:border-gray-600">
|
||||
{h.r_inherent || '-'}
|
||||
</td>
|
||||
{/* Status */}
|
||||
<td className="px-3 py-2 text-center">
|
||||
{isCovered ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-green-100 text-green-700 text-[10px] font-medium">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Abgedeckt
|
||||
</span>
|
||||
) : h.r_inherent ? (
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded-full text-[10px] font-medium ${
|
||||
(h.r_inherent || 0) <= 20 ? 'bg-green-100 text-green-700' :
|
||||
(h.r_inherent || 0) <= 60 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{(h.r_inherent || 0) <= 20 ? 'Niedrig' : (h.r_inherent || 0) <= 60 ? 'Mittel' : 'Hoch'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">Offen</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { CATEGORY_LABELS } from './types'
|
||||
import { RiskBadge } from './RiskBadge'
|
||||
|
||||
interface BlockHazard {
|
||||
hazard: {
|
||||
id: string; name: string; description: string; category: string
|
||||
hazardous_zone: string; scenario?: string; possible_harm?: string
|
||||
}
|
||||
assessment?: { severity: number; exposure: number; probability: number; inherent_risk: number; risk_level: string } | null
|
||||
mitigation_ids: string[]
|
||||
}
|
||||
|
||||
interface HazardBlock {
|
||||
parent_hazard: BlockHazard
|
||||
children: BlockHazard[]
|
||||
block_key: string
|
||||
shared_measure_count: number
|
||||
children_covered_by_parent: boolean
|
||||
}
|
||||
|
||||
interface BlockSummary {
|
||||
total_blocks: number
|
||||
parent_only_blocks: number
|
||||
blocks_with_children: number
|
||||
total_hazards: number
|
||||
covered_children: number
|
||||
uncovered_children: number
|
||||
assessments_needed: number
|
||||
assessments_saved: number
|
||||
}
|
||||
|
||||
export function HazardBlockView() {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const [blocks, setBlocks] = useState<HazardBlock[]>([])
|
||||
const [summary, setSummary] = useState<BlockSummary | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazard-blocks`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (data) {
|
||||
setBlocks(data.blocks || [])
|
||||
setSummary(data.summary || null)
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [projectId])
|
||||
|
||||
const toggle = (key: string) => setExpanded(prev => ({ ...prev, [key]: !prev[key] }))
|
||||
|
||||
if (loading) return <div className="text-sm text-gray-400 py-8 text-center">Lade Bloecke...</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary Cards */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<SummaryCard label="Bloecke" value={summary.total_blocks} sub={`${summary.total_hazards} Gefaehrdungen`} />
|
||||
<SummaryCard label="Mit Kindern" value={summary.blocks_with_children} sub={`${summary.covered_children} abgedeckt`} color="green" />
|
||||
<SummaryCard label="Bewertungen noetig" value={summary.assessments_needed} sub={`von ${summary.total_hazards}`} color="purple" />
|
||||
<SummaryCard label="Eingespart" value={summary.assessments_saved} sub="durch Gruppierung" color="green" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Block List */}
|
||||
<div className="space-y-2">
|
||||
{blocks.map((block) => {
|
||||
const isOpen = expanded[block.block_key]
|
||||
const parent = block.parent_hazard
|
||||
const childCount = block.children.length
|
||||
const covered = block.children_covered_by_parent
|
||||
|
||||
return (
|
||||
<div key={block.block_key} className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Parent Row */}
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors ${childCount > 0 ? '' : 'opacity-90'}`}
|
||||
onClick={() => childCount > 0 && toggle(block.block_key)}
|
||||
>
|
||||
{/* Expand Arrow */}
|
||||
{childCount > 0 ? (
|
||||
<svg className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<div className="w-4 h-4" />
|
||||
)}
|
||||
|
||||
{/* Name + Category */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">{parent.hazard.name}</span>
|
||||
<span className="text-xs text-gray-400">{CATEGORY_LABELS[parent.hazard.category] || parent.hazard.category}</span>
|
||||
</div>
|
||||
{parent.hazard.hazardous_zone && (
|
||||
<div className="text-xs text-gray-500 truncate">{parent.hazard.hazardous_zone}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Risk */}
|
||||
{parent.assessment ? (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-gray-500">R={parent.assessment.inherent_risk}</span>
|
||||
<RiskBadge level={parent.assessment.risk_level} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">Nicht bewertet</span>
|
||||
)}
|
||||
|
||||
{/* Child count badge */}
|
||||
{childCount > 0 && (
|
||||
<div className={`flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
covered
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
}`}>
|
||||
+{childCount}
|
||||
{covered && (
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Measures count */}
|
||||
<span className="text-xs text-gray-400">{block.shared_measure_count} M.</span>
|
||||
</div>
|
||||
|
||||
{/* Children (expanded) */}
|
||||
{isOpen && childCount > 0 && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-850">
|
||||
{covered && (
|
||||
<div className="px-4 py-2 text-xs text-green-600 dark:text-green-400 bg-green-50/50 dark:bg-green-900/10 border-b border-green-100 dark:border-green-900/30">
|
||||
Alle Untergefaehrdungen durch Massnahmen der Muttergefaehrdung abgedeckt — keine separate Bewertung noetig.
|
||||
</div>
|
||||
)}
|
||||
{block.children.map((child) => (
|
||||
<div key={child.hazard.id} className="flex items-center gap-3 px-4 py-2 pl-12 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300">{child.hazard.name}</span>
|
||||
{child.hazard.hazardous_zone && (
|
||||
<span className="text-xs text-gray-400 ml-2">[{child.hazard.hazardous_zone}]</span>
|
||||
)}
|
||||
</div>
|
||||
{child.assessment ? (
|
||||
<span className="text-xs text-gray-500">R={child.assessment.inherent_risk}</span>
|
||||
) : covered ? (
|
||||
<span className="text-xs text-green-500">Abgedeckt</span>
|
||||
) : (
|
||||
<span className="text-xs text-yellow-500">Offen</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, sub, color }: { label: string; value: number; sub: string; color?: string }) {
|
||||
const textColor = color === 'green' ? 'text-green-600' : color === 'purple' ? 'text-purple-600' : 'text-gray-900 dark:text-white'
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-3 text-center">
|
||||
<div className={`text-xl font-bold ${textColor}`}>{value}</div>
|
||||
<div className="text-xs font-medium text-gray-600 dark:text-gray-400">{label}</div>
|
||||
<div className="text-[10px] text-gray-400">{sub}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,12 @@
|
||||
import { Hazard, LifecyclePhase, CATEGORY_LABELS, STATUS_LABELS } from './types'
|
||||
import { RiskBadge, ReviewStatusBadge } from './RiskBadge'
|
||||
|
||||
const OP_STATE_LABELS: Record<string, string> = {
|
||||
startup: 'Hochfahren', homing: 'Referenzfahrt', automatic_operation: 'Automatik',
|
||||
manual_operation: 'Handbetrieb', teach_mode: 'Einrichten', maintenance: 'Wartung',
|
||||
cleaning: 'Reinigung', emergency_stop: 'Not-Halt', recovery_mode: 'Wiederanlauf',
|
||||
}
|
||||
|
||||
export function HazardTable({ hazards, lifecyclePhases, onDelete }: {
|
||||
hazards: Hazard[]
|
||||
lifecyclePhases: LifecyclePhase[]
|
||||
@@ -47,6 +53,15 @@ export function HazardTable({ hazards, lifecyclePhases, onDelete }: {
|
||||
{lifecyclePhases.find(p => p.id === hazard.lifecycle_phase)?.label_de || hazard.lifecycle_phase}
|
||||
</div>
|
||||
)}
|
||||
{hazard.operational_states && hazard.operational_states.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{hazard.operational_states.map((s) => (
|
||||
<span key={s} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800">
|
||||
{OP_STATE_LABELS[s] || s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{CATEGORY_LABELS[hazard.category] || hazard.category}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-medium">{hazard.severity}</td>
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface Hazard {
|
||||
created_at: string
|
||||
source?: string
|
||||
match_reasons?: { type: string; tag: string; met: boolean }[]
|
||||
operational_states?: string[]
|
||||
}
|
||||
|
||||
export interface LibraryHazard {
|
||||
|
||||
@@ -4,6 +4,8 @@ import React, { useState, useMemo, useCallback } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { HazardForm } from './_components/HazardForm'
|
||||
import { HazardTable } from './_components/HazardTable'
|
||||
import { HazardBlockView } from './_components/HazardBlockView'
|
||||
import { BlockAwareRiskTable } from './_components/BlockAwareRiskTable'
|
||||
import { RiskAssessmentTable } from './_components/RiskAssessmentTable'
|
||||
import { ResidualRiskPanel, getResidualStatus } from './_components/ResidualRiskPanel'
|
||||
import type { ResidualFilter } from './_components/ResidualRiskPanel'
|
||||
@@ -12,7 +14,7 @@ import { AutoSuggestPanel } from './_components/AutoSuggestPanel'
|
||||
import { CustomHazardModal } from './_components/CustomHazardModal'
|
||||
import { useHazards } from './_hooks/useHazards'
|
||||
|
||||
type ViewMode = 'list' | 'risk'
|
||||
type ViewMode = 'list' | 'risk' | 'blocks'
|
||||
|
||||
export default function HazardsPage() {
|
||||
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'}`}>
|
||||
Risikobewertung
|
||||
</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 className="flex items-center gap-2">
|
||||
@@ -169,9 +175,11 @@ export default function HazardsPage() {
|
||||
<>
|
||||
<ResidualRiskPanel hazards={h.hazards} decisions={decisions}
|
||||
activeFilter={residualFilter} onFilterChange={setResidualFilter} />
|
||||
<RiskAssessmentTable projectId={projectId} hazards={filteredHazards}
|
||||
<BlockAwareRiskTable projectId={projectId} hazards={filteredHazards}
|
||||
onReassess={h.refetch} decisions={decisions} onDecision={handleDecision} />
|
||||
</>
|
||||
) : view === 'blocks' ? (
|
||||
<HazardBlockView />
|
||||
) : (
|
||||
<HazardTable hazards={h.hazards} lifecyclePhases={h.lifecyclePhases} onDelete={h.handleDelete} />
|
||||
)
|
||||
|
||||
+17
@@ -7,6 +7,7 @@ import {
|
||||
AREA_OF_USE_OPTIONS,
|
||||
OPERATING_MODE_OPTIONS,
|
||||
PERSON_GROUP_OPTIONS,
|
||||
INDUSTRY_SECTOR_OPTIONS,
|
||||
type LimitsFormData,
|
||||
} from '../_types'
|
||||
|
||||
@@ -204,6 +205,22 @@ export function LimitsFormSections({ data, onChange, prefilled }: LimitsFormSect
|
||||
rows={4}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ export interface LimitsFormData {
|
||||
// Section 6: Betroffene Personen
|
||||
person_groups: string[]
|
||||
qualification_requirements: string
|
||||
|
||||
// Section 7: Einsatzbereich / Branche (fuer Pattern-Filterung)
|
||||
industry_sectors: string[]
|
||||
}
|
||||
|
||||
export const EMPTY_LIMITS_FORM: LimitsFormData = {
|
||||
@@ -59,6 +62,7 @@ export const EMPTY_LIMITS_FORM: LimitsFormData = {
|
||||
pneumatic_hydraulic_interfaces: '',
|
||||
person_groups: [],
|
||||
qualification_requirements: '',
|
||||
industry_sectors: [],
|
||||
}
|
||||
|
||||
export const AREA_OF_USE_OPTIONS = [
|
||||
@@ -77,6 +81,43 @@ export const OPERATING_MODE_OPTIONS = [
|
||||
'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 = [
|
||||
'Bedienpersonal',
|
||||
'Einrichter',
|
||||
@@ -93,7 +134,7 @@ export interface FormSection {
|
||||
number: number
|
||||
title: string
|
||||
description: string
|
||||
icon: 'clipboard' | 'target' | 'alert' | 'box' | 'link' | 'users'
|
||||
icon: 'clipboard' | 'target' | 'alert' | 'box' | 'link' | 'users' | 'briefcase'
|
||||
}
|
||||
|
||||
export const FORM_SECTIONS: FormSection[] = [
|
||||
@@ -139,4 +180,11 @@ export const FORM_SECTIONS: FormSection[] = [
|
||||
description: 'Personengruppen und Qualifikationsanforderungen',
|
||||
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
|
||||
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
|
||||
onClick={() => {
|
||||
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 { 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({
|
||||
mitigation,
|
||||
onVerify,
|
||||
@@ -26,7 +32,16 @@ export function MitigationCard({
|
||||
<StatusBadge status={mitigation.status} />
|
||||
</div>
|
||||
{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 && (
|
||||
<div className="mb-3">
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface Mitigation {
|
||||
verified_at: string | null
|
||||
verified_by: string | null
|
||||
source?: string
|
||||
operational_states?: string[]
|
||||
}
|
||||
|
||||
export interface Hazard {
|
||||
@@ -19,6 +20,7 @@ export interface Hazard {
|
||||
name: string
|
||||
risk_level: string
|
||||
category?: string
|
||||
operational_states?: string[]
|
||||
}
|
||||
|
||||
export interface ProtectiveMeasure {
|
||||
|
||||
@@ -23,7 +23,7 @@ export function useMitigations(projectId: string) {
|
||||
let hazardList: Hazard[] = []
|
||||
if (hazRes.ok) {
|
||||
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)
|
||||
}
|
||||
if (mitRes.ok) {
|
||||
@@ -31,6 +31,7 @@ export function useMitigations(projectId: string) {
|
||||
const raw = json.mitigations || json || []
|
||||
// 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 hazardStatesMap = Object.fromEntries(hazardList.map((h) => [h.id, (h as Record<string, unknown>).operational_states || []]))
|
||||
const mits: Mitigation[] = raw.map((m: Record<string, unknown>) => ({
|
||||
id: m.id 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,
|
||||
verified_at: (m.verified_at || 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)
|
||||
validateHierarchy(mits)
|
||||
@@ -146,7 +153,7 @@ export function useMitigations(projectId: string) {
|
||||
|
||||
const byType = {
|
||||
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'),
|
||||
}
|
||||
|
||||
|
||||
+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.
|
||||
</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">
|
||||
<h2>Inhaltsverzeichnis</h2>
|
||||
<ol className="toc">
|
||||
<li>Methodik der Risikobeurteilung</li>
|
||||
<li>Maschinenbeschreibung</li>
|
||||
<li>Angewandte Normen</li>
|
||||
<li>Gefaehrdungsliste</li>
|
||||
|
||||
@@ -9,6 +9,7 @@ const IACE_NAV_ITEMS = [
|
||||
{ id: 'overview', label: 'Uebersicht', href: '', icon: 'grid' },
|
||||
{ id: 'order', label: 'Auftrag', href: '/order', icon: 'briefcase' },
|
||||
{ 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: 'components', label: 'Komponenten', href: '/components', icon: 'cube' },
|
||||
{ id: 'hazards', label: 'Hazard Log', href: '/hazards', icon: 'warning' },
|
||||
@@ -19,8 +20,11 @@ const IACE_NAV_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: 'monitoring', label: 'Monitoring', href: '/monitoring', icon: 'activity' },
|
||||
{ id: 'benchmark', label: 'Benchmark', href: '/benchmark', icon: 'check' },
|
||||
]
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user