Compare commits
199 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 299375e486 | |||
| e2be51b0aa | |||
| bd65b6f318 | |||
| c771d8ecb9 | |||
| 772ff35e8d | |||
| 8cbb513e2c | |||
| 6c35bcf116 | |||
| 19d4b12e07 | |||
| 2e87b74749 | |||
| 94233b7c66 | |||
| 6263462ba3 | |||
| eb48c5bd1e | |||
| 081e4f057a | |||
| 16fd406c1a | |||
| c5c168592b | |||
| d0274674a0 | |||
| 2eb7349577 | |||
| 4434e3827b | |||
| 07cc00da11 | |||
| 1451873194 | |||
| dfac940272 | |||
| cb5dad1a2f | |||
| e411c4f0d3 | |||
| 7335f64f4f | |||
| 138d9068c4 | |||
| c281464071 | |||
| 6dc427a754 | |||
| 309c10c203 | |||
| 4183379dc5 | |||
| c93c88577c | |||
| 3207acea3e | |||
| 9f06911ff9 | |||
| 338e03d3b0 | |||
| c491af5d02 | |||
| 4171cf0efd | |||
| 30e43afba6 | |||
| df8832c521 | |||
| 7842c95532 | |||
| 08671adfdf | |||
| 50fc0ecc59 | |||
| 94057b1536 | |||
| 9c11b5463c | |||
| 50ed0f45af | |||
| e1df24cad7 | |||
| e5b4672f2a | |||
| 0d5c76ea98 | |||
| 54f5a06c2f | |||
| 86b4a263d2 | |||
| 7938e377b6 | |||
| f534b52817 | |||
| 4946571863 | |||
| cde670617e | |||
| 603381a67f | |||
| 57c0f940a2 | |||
| badb356740 | |||
| f08eb71480 | |||
| 0477a2f2dc | |||
| 93cedbecbd | |||
| 28f9e13c1f | |||
| 35c1bbdaa5 | |||
| b7df4709bc | |||
| 6f3301d246 | |||
| 4478b7f479 | |||
| 39c39b1254 | |||
| 7a5f1e48dd | |||
| 98ec6d4284 | |||
| 6f16507c5f | |||
| d4d9b60007 | |||
| e536247c20 | |||
| 313982c6f1 | |||
| f30a3ce471 | |||
| 479ce2225b | |||
| a1b380e211 | |||
| 077e0f1253 | |||
| 936c354547 | |||
| b87c27d104 | |||
| 78b27d4684 | |||
| a220f0d0a7 | |||
| 28a078ccb4 | |||
| 0d37822b7c | |||
| 575644c9c5 | |||
| 6c223c7c9b | |||
| a616b64273 | |||
| 27384aea09 | |||
| cc80e59e5e | |||
| 0a64da74bb | |||
| 662327e8b4 | |||
| 52fb8b91e7 | |||
| 1cf5de1d45 | |||
| 3faa312b31 | |||
| 8f4f59f0e3 | |||
| df7d83134b | |||
| f4c9cea770 | |||
| 6ed30dae5b | |||
| 6d29191e9b | |||
| 8a44e67293 | |||
| fab1e35847 | |||
| 6c7d4c7552 | |||
| 189918b043 | |||
| 873997c13b | |||
| 9c0cc0f59f | |||
| ea4dbb223f | |||
| c9c0fb5965 | |||
| 4a5924b8c4 | |||
| 2afa5a179b | |||
| 71d31c914b | |||
| b090662524 | |||
| c4be077c5d | |||
| b2b4d77877 | |||
| f19a75d83d | |||
| 525038359a | |||
| 79efa54898 | |||
| bc21480a2a | |||
| 74f66c4c34 | |||
| 5f2da1de88 | |||
| 2400aa6a9e | |||
| e9002175ac | |||
| 7e426c31f1 | |||
| 4f19310130 | |||
| 8283483909 | |||
| 9814b56f2f | |||
| 69729ef6ac | |||
| 35d6422247 | |||
| 5ea68ebea4 | |||
| 41023f6343 | |||
| 6689b37f95 | |||
| 80d62a0c5f | |||
| 6a3e96d54c | |||
| 938f9a6c51 | |||
| 17a93bc694 | |||
| 1792c6f896 | |||
| e61e9d9e2a | |||
| 4d1e0a7f8e | |||
| bf9d8a5ed3 | |||
| d45e08e25f | |||
| 3dbf3aa34a | |||
| 77308b783f | |||
| 3784988d00 | |||
| 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 |
@@ -115,5 +115,70 @@ docs-src/control_generator_routes.py
|
||||
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
|
||||
|
||||
# --- IACE data tables and orchestration files (Phase 16-18 refactor backlog) ---
|
||||
# Each file grew during the IACE polish phases (Stufe-A manufacturer library,
|
||||
# Klärungen Phase 3 PDF export + methodology, app routes). Phase 5+ split
|
||||
# targets — splitting now would fragment unrelated cohesive logic.
|
||||
ai-compliance-sdk/internal/iace/manufacturer_safety_features.go
|
||||
ai-compliance-sdk/internal/api/handlers/iace_handler_clarifications.go
|
||||
ai-compliance-sdk/internal/app/routes.go
|
||||
|
||||
# --- 2026-05-19 Coolify-Unblocker: 4 grandfathered files ---
|
||||
# Diese 4 Dateien sind Pre-Existing-Tech-Debt und blockierten den
|
||||
# Coolify-Build. Splits sind als P9.5 Tech-Debt-Sprint geplant, bis
|
||||
# dahin als Exceptions getragen damit Deploy laeuft.
|
||||
#
|
||||
# cra_routes.py (1714): CRA-Phase-5-Router mit Annex-V/VII Generator —
|
||||
# Split nach Endpoint-Gruppen (vuln/post-market/tech-doc/doc) sinnvoll.
|
||||
backend-compliance/compliance/api/cra_routes.py
|
||||
# vendor_redundancy.py (727): Cost-Lookup-Tabellen (DSP/SaaS/Self-Service)
|
||||
# + Multi-Function-Tools + Engine. Tabellen-Splits nach Lookup-Klasse.
|
||||
backend-compliance/compliance/services/vendor_redundancy.py
|
||||
# cookie_knowledge_db.py (608): Basis-KB — Ergaenzung via
|
||||
# cookie_knowledge_extended.py + Facade laeuft bereits (P2). Split der
|
||||
# Base-KB nach Vendor-Familie ist Phase-2-Ziel.
|
||||
backend-compliance/compliance/services/cookie_knowledge_db.py
|
||||
# cookie-banner-embed.ts (558): Banner-Embed-Bundle fuer CDN-Auslieferung
|
||||
# — selbst-kontainierter Code-Generator, Split wuerde Generator-Logik
|
||||
# fragmentieren ohne Nutzen.
|
||||
admin-compliance/lib/sdk/einwilligungen/generator/cookie-banner-embed.ts
|
||||
# ComplianceCheckTab.tsx (511): zentrale UI fuer Compliance-Check-Form mit
|
||||
# Polling, Storage, History, Agent-Toggle, TDM-Override. Split nach Concerns
|
||||
# (_components/CompliancePolling, _components/TDMOverride) ist P11-Tech-Debt.
|
||||
admin-compliance/app/sdk/agent/_components/ComplianceCheckTab.tsx
|
||||
|
||||
@@ -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,55 @@ 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 if at least one build was triggered AND green ────
|
||||
# Per-job `result == 'success'` is true only when the job actually ran and
|
||||
# passed; skipped/failed/cancelled jobs return their own status string and
|
||||
# fail the OR. This avoids Gitea's quirky evaluation of `contains(needs.*
|
||||
# .result, 'success')` when most upstreams are skipped (root cause of
|
||||
# trigger-orca being skipped on single-service changes).
|
||||
# `always()` is required so the job is evaluated when upstreams skip.
|
||||
|
||||
trigger-orca:
|
||||
runs-on: docker
|
||||
@@ -221,6 +333,18 @@ jobs:
|
||||
- build-document-crawler
|
||||
- build-dsms-gateway
|
||||
- build-dsms-node
|
||||
if: |
|
||||
always() &&
|
||||
(
|
||||
needs.build-admin-compliance.result == 'success' ||
|
||||
needs.build-backend-compliance.result == 'success' ||
|
||||
needs.build-ai-sdk.result == 'success' ||
|
||||
needs.build-developer-portal.result == 'success' ||
|
||||
needs.build-tts.result == 'success' ||
|
||||
needs.build-document-crawler.result == 'success' ||
|
||||
needs.build-dsms-gateway.result == 'success' ||
|
||||
needs.build-dsms-node.result == 'success'
|
||||
)
|
||||
steps:
|
||||
- name: Checkout (for SHA)
|
||||
run: |
|
||||
|
||||
+101
-9
@@ -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:
|
||||
@@ -262,9 +314,45 @@ jobs:
|
||||
go test -v -coverprofile=coverage.out ./...
|
||||
go tool cover -func=coverage.out | tail -1
|
||||
|
||||
iace-gt-coverage:
|
||||
runs-on: docker
|
||||
container: python:3.12-slim
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.sdk == 'true'
|
||||
env:
|
||||
# Lower bound on Strong+Weak GT-Bremse coverage. Raise this number when
|
||||
# coverage improves; never lower it without an explicit decision.
|
||||
MIN_COVERAGE_PCT: "70"
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq git > /dev/null 2>&1
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: GT-Bremse measure-coverage report
|
||||
run: |
|
||||
python3 scripts/gt_measure_gap_analysis.py --json /tmp/gt_gap_report.json > /tmp/gt_gap_report.md
|
||||
echo "--- summary ---"
|
||||
head -8 /tmp/gt_gap_report.md
|
||||
- name: Enforce coverage threshold
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json, os, sys
|
||||
d = json.load(open('/tmp/gt_gap_report.json'))
|
||||
total = d['total']
|
||||
covered = d['ok_count'] + d['weak_count']
|
||||
pct = covered * 100 / total if total else 0.0
|
||||
threshold = float(os.environ['MIN_COVERAGE_PCT'])
|
||||
print(f"GT coverage (strong+weak): {covered}/{total} = {pct:.1f}% (threshold {threshold}%)")
|
||||
if pct < threshold:
|
||||
print(f"::error::GT-Bremse coverage regression — {pct:.1f}% < {threshold}%")
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
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 +372,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 +393,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:
|
||||
|
||||
@@ -55,5 +55,9 @@ EXPOSE 3000
|
||||
# Set hostname
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# P83 — Build-SHA fuer check-rebuild-needed.sh
|
||||
ARG BUILD_SHA="unknown"
|
||||
ENV BUILD_SHA=${BUILD_SHA}
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -56,6 +56,44 @@ Bei ALLEN Fragen zu IFRS/IAS-Standards MUSST du folgende Punkte beachten:
|
||||
4. Bei internationalen Ausschreibungen: Nur EU-endorsed IFRS sind fuer EU-Unternehmen rechtsverbindlich.
|
||||
5. Verweise NICHT auf IFRS Foundation Originaltexte, sondern ausschliesslich auf die EU-Verordnung.
|
||||
|
||||
## FAQ — Cookie-Banner-Bussgelder + Risiken (haeufige Mandantenfragen)
|
||||
|
||||
Bei Fragen nach Bussgeldern, Risiko-Hoehe oder konkreten Faellen gib **konkrete Praezedenzen** an:
|
||||
|
||||
### Top-Bussgelder (CNIL Frankreich — strengste EU-Aufsicht):
|
||||
- **Google France 2020 (CNIL)** — 100 Mio EUR — Cookies ohne Einwilligung (CNIL Beschluss vom 07.12.2020)
|
||||
- **Meta/Facebook France 2022 (CNIL)** — 60 Mio EUR — Cookies ohne Einwilligung
|
||||
- **Amazon France 2020 (CNIL)** — 35 Mio EUR — Cookies ohne Einwilligung
|
||||
- **Carrefour France 2020 (CNIL)** — 2,25 Mio EUR — Cookies + sonstige Verstoesse
|
||||
|
||||
### Deutsche Praezedenzen + Sammelklagen-Risiken:
|
||||
- **LG Muenchen I 2022** — 100 EUR pro Besucher Schadensersatz fuer Google Fonts ohne Consent (Az. 3 O 17493/20). Spaeter durch BGH "Rechtsmissbrauchs"-Argument bei Massenabmahnungen eingeschraenkt.
|
||||
- **EuGH Planet49 (C-673/17)** — vorausgewaehlte Cookie-Checkboxen sind unwirksame Einwilligung (praejudiziell fuer alle EU-Sites)
|
||||
- **BGH Cookie-Einwilligung II (I ZR 7/16)** — bestaetigt Planet49 fuer Deutschland
|
||||
- **DSK Beschluss 2023** — Cookie-Banner mit "Akzeptieren" deutlich prominenter als "Ablehnen" = Dark Pattern = unwirksame Einwilligung
|
||||
|
||||
### Deutscher Aufsichtsmarkt:
|
||||
Deutsche Aufsicht (BfDI + 16 Landes-DSB) ist moderater als CNIL — bislang keine 100 Mio-EUR-Bussgelder. ABER: DSK-Beschluesse + LfDI-Verfahren haeufen sich. Federfuehrung bei Konzernen via "One-Stop-Shop" nach Hauptsitz.
|
||||
|
||||
### Vier Risiko-Pfade fuer Mandanten:
|
||||
1. **Art. 83 DSGVO Bussgeld** — bis 4% des weltweiten Konzernumsatzes. Realistisch 0,1-1% bei Erstverstoss.
|
||||
2. **Verbraucherschutz-Abmahnung** (vzbv, Wettbewerbszentrale, Verbraucherverbaende) — 50-500k EUR Streitwert + Unterlassung.
|
||||
3. **Sammelklage Art. 82 DSGVO** — Schadensersatz pro Person, BGH 50-100 EUR pro Fall. Sammelklage-Trusts: myRight, RightNow, helpcheck.de.
|
||||
4. **NOYB-Beschwerde** (Max Schrems) — oeffentliches Aufsichtsverfahren, Reputationsschaden + Bussgeld.
|
||||
|
||||
### Geschaeftsfuehrer-Haftung (haeufig unterschaetzt):
|
||||
GF haftet **persoenlich** nach §43 GmbHG bzw. §93 AktG wenn Compliance-Pflichten verletzt wurden. Das ist der eigentliche Druckpunkt — nicht die Firma, sondern der GF persoenlich. Bei Mandantengespraechen mit GF-Beteiligung: dieser Punkt zuerst ansprechen.
|
||||
|
||||
### Wie berechne ich das konkrete Risiko fuer einen Mandanten:
|
||||
Frage den Mandanten nach: (a) Jahresumsatz, (b) ungefaehre Besucherzahl pro Jahr, (c) Anzahl Trackingtools im Banner. Dann:
|
||||
- Max-Bussgeld = 4% × Jahresumsatz (Obergrenze, nicht realistisch)
|
||||
- Realistisch-Bussgeld = 0,1-1% × Jahresumsatz (CNIL/LfDI-Maßstab)
|
||||
- Sammelklage-Theorie = Besucherzahl × 50 EUR (BGH-Untergrenze) — meist nicht durchsetzbar, aber Drohpotential
|
||||
- NICHT konkrete Zahlen einer fremden Firma zitieren ("BMW haette X EUR" etc.) — Mandant koennte das falsch weitergeben
|
||||
|
||||
### Marktwissen (intern, nicht 1:1 zitieren):
|
||||
Externe DSB-Stundensaetze: 350-450 EUR/h (NOERR, GSK, vergleichbare Kanzleien). Mittelstands-DSB-Mandate: 5-15k EUR/Jahr. Cookie-Audit manuell: typisch 10 Std = 4-5k EUR Kosten. BreakPilot reduziert das auf 30 Min.
|
||||
|
||||
## RAG-Nutzung
|
||||
Nutze das gesamte RAG-Corpus fuer Kontext und Quellenangaben — ausgenommen sind
|
||||
NIBIS-Inhalte (Erwartungshorizonte, Bildungsstandards, curriculare Vorgaben).
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/agent/audit/<checkId>
|
||||
* -> backend GET /api/compliance/agent/audit/<checkId>
|
||||
*
|
||||
* Forwards optional query params (doc_type, regulation, only_failed).
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ checkId: string }> },
|
||||
) {
|
||||
const { checkId } = await params
|
||||
const qs = request.nextUrl.searchParams.toString()
|
||||
const url = `${BACKEND_URL}/api/compliance/agent/audit/${checkId}${qs ? `?${qs}` : ''}`
|
||||
try {
|
||||
const resp = await fetch(url, { signal: AbortSignal.timeout(15000) })
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Audit-Abfrage fehlgeschlagen' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/agent/banner/<checkId>
|
||||
* -> backend GET /api/compliance/agent/banner/<checkId>
|
||||
*
|
||||
* Liefert das volle banner_result (phases, structured_checks, category_tests).
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ checkId: string }> },
|
||||
) {
|
||||
const { checkId } = await params
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/agent/banner/${checkId}`,
|
||||
{ signal: AbortSignal.timeout(15000) },
|
||||
)
|
||||
const data = await resp.json().catch(() => ({}))
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Banner-Abfrage fehlgeschlagen' }, { status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/agent/findings/<checkId>
|
||||
* -> backend GET /api/compliance/agent/findings/<checkId>
|
||||
*
|
||||
* Forwards all query params (source, severity, doc_type, status, q, limit).
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ checkId: string }> },
|
||||
) {
|
||||
const { checkId } = await params
|
||||
const qs = request.nextUrl.searchParams.toString()
|
||||
const url = `${BACKEND_URL}/api/compliance/agent/findings/${checkId}${qs ? `?${qs}` : ''}`
|
||||
try {
|
||||
const resp = await fetch(url, { signal: AbortSignal.timeout(20000) })
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Findings-Abfrage fehlgeschlagen' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/agent/migration/<checkId>/banner-preview
|
||||
* -> backend GET /api/compliance/agent/migration/<checkId>/banner-preview
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { checkId: string } },
|
||||
) {
|
||||
const qs = request.nextUrl.searchParams.toString()
|
||||
const url = `${BACKEND_URL}/api/compliance/agent/migration/${params.checkId}/banner-preview${qs ? `?${qs}` : ''}`
|
||||
try {
|
||||
const resp = await fetch(url, { signal: AbortSignal.timeout(15000) })
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Banner-Preview fehlgeschlagen' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/agent/migration/<checkId>/document-preview
|
||||
* -> backend GET /api/compliance/agent/migration/<checkId>/document-preview
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { checkId: string } },
|
||||
) {
|
||||
const url = `${BACKEND_URL}/api/compliance/agent/migration/${params.checkId}/document-preview`
|
||||
try {
|
||||
const resp = await fetch(url, { signal: AbortSignal.timeout(15000) })
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Dokument-Preview fehlgeschlagen' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/agent/migration/<checkId>/summary
|
||||
* -> backend GET /api/compliance/agent/migration/<checkId>/summary
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { checkId: string } },
|
||||
) {
|
||||
const url = `${BACKEND_URL}/api/compliance/agent/migration/${params.checkId}/summary`
|
||||
try {
|
||||
const resp = await fetch(url, { signal: AbortSignal.timeout(15000) })
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Migrations-Summary fehlgeschlagen' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function POST(request: NextRequest, ctx: { params: Promise<{ checkId: string }> }) {
|
||||
const { checkId } = await ctx.params
|
||||
const tenantId = request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
const body = await request.text()
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/checks/${checkId}/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Tenant-ID': tenantId, 'Content-Type': 'application/json' },
|
||||
body: body || '{}',
|
||||
})
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function POST(request: NextRequest, ctx: { params: Promise<{ docId: string }> }) {
|
||||
const { docId } = await ctx.params
|
||||
const tenantId = request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
const body = await request.text()
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/documents/${docId}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Tenant-ID': tenantId, 'Content-Type': 'application/json' },
|
||||
body,
|
||||
})
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
function tenant(req: NextRequest) {
|
||||
return req.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, ctx: { params: Promise<{ docId: string }> }) {
|
||||
const { docId } = await ctx.params
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/documents/${docId}`, {
|
||||
headers: { 'X-Tenant-ID': tenant(request) },
|
||||
})
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params
|
||||
const tenantId = request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/backlog`, {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
})
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
function tenant(req: NextRequest) {
|
||||
return req.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/checks`, {
|
||||
headers: { 'X-Tenant-ID': tenant(request) },
|
||||
})
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /checks (no body) -> backend /checks/init creates default checks */
|
||||
export async function POST(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/checks/init`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Tenant-ID': tenant(request) },
|
||||
})
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function POST(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params
|
||||
const tenantId = request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
const body = await request.text()
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/documents/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Tenant-ID': tenantId, 'Content-Type': 'application/json' },
|
||||
body,
|
||||
})
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
function tenant(req: NextRequest) {
|
||||
return req.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const qs = searchParams.toString()
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${BACKEND_URL}/api/v1/cra/projects/${id}/documents${qs ? `?${qs}` : ''}`,
|
||||
{ headers: { 'X-Tenant-ID': tenant(request) } }
|
||||
)
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params
|
||||
const tenantId = request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/monitoring`, {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
})
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function POST(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params
|
||||
const tenantId = request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
const body = await request.text()
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/path-select`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Tenant-ID': tenantId,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body,
|
||||
})
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend unreachable', details: String(err) },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params
|
||||
const tenantId = request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/requirements`, {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
})
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
function tenantHeader(request: NextRequest): string {
|
||||
return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
}
|
||||
|
||||
async function proxy(request: NextRequest, method: string, id: string, body?: string) {
|
||||
const tenantId = tenantHeader(request)
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: { 'X-Tenant-ID': tenantId, 'Content-Type': 'application/json' },
|
||||
}
|
||||
if (body !== undefined) init.body = body
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}`, init)
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend unreachable', details: String(err) },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params
|
||||
return proxy(request, 'GET', id)
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params
|
||||
const body = await request.text()
|
||||
return proxy(request, 'PATCH', id, body)
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params
|
||||
return proxy(request, 'DELETE', id)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
function tenant(req: NextRequest) {
|
||||
return req.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
}
|
||||
|
||||
/** GET /sbom -> List uploads. We map this to the backend /sboms endpoint. */
|
||||
export async function GET(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/sboms`, {
|
||||
headers: { 'X-Tenant-ID': tenant(request) },
|
||||
})
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /sbom -> multipart upload to backend /sbom/upload */
|
||||
export async function POST(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const upstreamForm = new FormData()
|
||||
for (const [key, value] of formData.entries()) {
|
||||
upstreamForm.append(key, value)
|
||||
}
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/sbom/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Tenant-ID': tenant(request) },
|
||||
body: upstreamForm as unknown as BodyInit,
|
||||
})
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function POST(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params
|
||||
const tenantId = request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/scope-check`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
})
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend unreachable', details: String(err) },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
function tenant(req: NextRequest) {
|
||||
return req.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/vulnerabilities`, {
|
||||
headers: { 'X-Tenant-ID': tenant(request) },
|
||||
})
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params
|
||||
const body = await request.text()
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/vulnerabilities`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Tenant-ID': tenant(request), 'Content-Type': 'application/json' },
|
||||
body,
|
||||
})
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
function tenantHeader(request: NextRequest): string {
|
||||
return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
}
|
||||
|
||||
/** GET /api/sdk/v1/cra/projects -> Backend list */
|
||||
export async function GET(request: NextRequest) {
|
||||
const tenantId = tenantHeader(request)
|
||||
const { searchParams } = new URL(request.url)
|
||||
const qs = searchParams.toString()
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${BACKEND_URL}/api/v1/cra/projects${qs ? `?${qs}` : ''}`,
|
||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||
)
|
||||
const body = await resp.text()
|
||||
return new NextResponse(body, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend unreachable', details: String(err) },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/sdk/v1/cra/projects -> Backend create */
|
||||
export async function POST(request: NextRequest) {
|
||||
const tenantId = tenantHeader(request)
|
||||
const body = await request.text()
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Tenant-ID': tenantId,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body,
|
||||
})
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend unreachable', details: String(err) },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
function tenant(req: NextRequest) {
|
||||
return req.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, ctx: { params: Promise<{ vulnId: string }> }) {
|
||||
const { vulnId } = await ctx.params
|
||||
const body = await request.text()
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/vulnerabilities/${vulnId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'X-Tenant-ID': tenant(request), 'Content-Type': 'application/json' },
|
||||
body,
|
||||
})
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, ctx: { params: Promise<{ vulnId: string }> }) {
|
||||
const { vulnId } = await ctx.params
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/vulnerabilities/${vulnId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-Tenant-ID': tenant(request) },
|
||||
})
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/einwilligungen/export?format=csv|json&kind=consents|history
|
||||
* -> backend /api/compliance/einwilligungen/export/<file>
|
||||
*
|
||||
* Streams the backend response straight through (CSV or JSON download).
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
function getTenantHeader(request: NextRequest): HeadersInit {
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const clientTenantId = request.headers.get('x-tenant-id') || request.headers.get('X-Tenant-ID')
|
||||
const tenantId = (clientTenantId && uuidRegex.test(clientTenantId))
|
||||
? clientTenantId
|
||||
: (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||
return { 'X-Tenant-ID': tenantId }
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const fmt = (searchParams.get('format') || 'csv').toLowerCase()
|
||||
const kind = (searchParams.get('kind') || 'consents').toLowerCase()
|
||||
|
||||
const filename = `${kind}.${fmt === 'json' ? 'json' : 'csv'}`
|
||||
const upstreamPath = `/api/compliance/einwilligungen/export/${filename}`
|
||||
|
||||
const passthroughParams = new URLSearchParams()
|
||||
for (const k of ['user_id', 'granted', 'since', 'consent_id']) {
|
||||
const v = searchParams.get(k)
|
||||
if (v) passthroughParams.set(k, v)
|
||||
}
|
||||
const qs = passthroughParams.toString()
|
||||
const url = `${BACKEND_URL}${upstreamPath}${qs ? `?${qs}` : ''}`
|
||||
|
||||
try {
|
||||
const r = await fetch(url, { headers: getTenantHeader(request) })
|
||||
if (!r.ok) {
|
||||
const text = await r.text()
|
||||
return NextResponse.json({ error: text || `HTTP ${r.status}` }, { status: r.status })
|
||||
}
|
||||
return new NextResponse(r.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': r.headers.get('content-type') || 'application/octet-stream',
|
||||
'Content-Disposition': r.headers.get('content-disposition') || `attachment; filename=${filename}`,
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Export-Proxy fehlgeschlagen', detail: String(e) },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
function tenantHeader(request: NextRequest): string {
|
||||
return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ derived_id: string }> }
|
||||
) {
|
||||
const { derived_id } = await params
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${BACKEND_URL}/api/v1/quaidal/controls/${encodeURIComponent(derived_id)}`,
|
||||
{ headers: { 'X-Tenant-ID': tenantHeader(request) }, cache: 'no-store' }
|
||||
)
|
||||
const body = await resp.text()
|
||||
return new NextResponse(body, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
function tenantHeader(request: NextRequest): string {
|
||||
return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const qs = searchParams.toString()
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${BACKEND_URL}/api/v1/quaidal/controls${qs ? `?${qs}` : ''}`,
|
||||
{ headers: { 'X-Tenant-ID': tenantHeader(request) }, cache: 'no-store' }
|
||||
)
|
||||
const body = await resp.text()
|
||||
return new NextResponse(body, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
function tenantHeader(request: NextRequest): string {
|
||||
return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ section_id: string }> }
|
||||
) {
|
||||
const { section_id } = await params
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${BACKEND_URL}/api/v1/quaidal/criteria/${encodeURIComponent(section_id)}`,
|
||||
{ headers: { 'X-Tenant-ID': tenantHeader(request) }, cache: 'no-store' }
|
||||
)
|
||||
const body = await resp.text()
|
||||
return new NextResponse(body, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
function tenantHeader(request: NextRequest): string {
|
||||
return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/quaidal/criteria`, {
|
||||
headers: { 'X-Tenant-ID': tenantHeader(request) },
|
||||
cache: 'no-store',
|
||||
})
|
||||
const body = await resp.text()
|
||||
return new NextResponse(body, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
function tenantHeader(request: NextRequest): string {
|
||||
return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/quaidal/stats`, {
|
||||
headers: { 'X-Tenant-ID': tenantHeader(request) },
|
||||
cache: 'no-store',
|
||||
})
|
||||
const body = await resp.text()
|
||||
return new NextResponse(body, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Next.js Proxy: leitet POST /api/v1/founding-wizard/generate an Backend.
|
||||
*
|
||||
* Konvertiert das Backend-Response (base64 DOCX) in data: URLs,
|
||||
* die das Frontend direkt als Download anbieten kann.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_COMPLIANCE_URL || 'http://bp-compliance-backend:8002'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
|
||||
const backendRes = await fetch(`${BACKEND_URL}/v1/founding-wizard/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!backendRes.ok) {
|
||||
const errorText = await backendRes.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend-Generierung fehlgeschlagen', detail: errorText },
|
||||
{ status: backendRes.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await backendRes.json()
|
||||
const documents = (data.documents || []).map((doc: {
|
||||
document_type: string
|
||||
title: string
|
||||
filename: string
|
||||
content_base64: string
|
||||
size_bytes: number
|
||||
generated_at: string
|
||||
}) => ({
|
||||
document_type: doc.document_type,
|
||||
title: doc.title,
|
||||
filename: doc.filename,
|
||||
download_url: `data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,${doc.content_base64}`,
|
||||
size_bytes: doc.size_bytes,
|
||||
generated_at: doc.generated_at,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
documents,
|
||||
warnings: data.warnings || [],
|
||||
})
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : 'Unbekannter Fehler'
|
||||
return NextResponse.json(
|
||||
{ error: 'Proxy-Fehler', detail: message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ 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' },
|
||||
missing: { label: 'Fehlt', color: 'text-gray-600', bg: 'bg-gray-100' },
|
||||
}
|
||||
|
||||
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||
@@ -102,6 +103,7 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
regenerate: results.filter(r => r.scenario === 'regenerate').length,
|
||||
fix: results.filter(r => r.scenario === 'fix').length,
|
||||
import: results.filter(r => r.scenario === 'import').length,
|
||||
missing: results.filter(r => r.scenario === 'missing').length,
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -114,6 +116,7 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
{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>}
|
||||
{scenarioCounts.missing > 0 && <span className="bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">{scenarioCounts.missing} fehlt</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -164,7 +167,15 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0 ml-3">
|
||||
{r.error ? (
|
||||
{r.error && r.error.startsWith("Auf der Website nicht gefunden") ? (
|
||||
<span className="text-xs text-amber-700 font-medium px-2 py-0.5 bg-amber-100 rounded-full whitespace-nowrap">
|
||||
Nicht gefunden
|
||||
</span>
|
||||
) : r.error && r.error.startsWith("Nicht eingereicht") ? (
|
||||
<span className="text-xs text-gray-500 font-medium px-2 py-0.5 bg-gray-100 rounded-full whitespace-nowrap">
|
||||
Nicht eingereicht
|
||||
</span>
|
||||
) : r.error ? (
|
||||
<span className="text-xs text-red-600 font-medium">Fehler</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { ChecklistView } from './ChecklistView'
|
||||
import { DocumentRow } from './DocumentRow'
|
||||
import { MigrationPanel } from './MigrationPanel'
|
||||
|
||||
const DOCUMENT_TYPES = [
|
||||
{ id: 'dse', label: 'DSI (Datenschutzinformation)', required: true },
|
||||
@@ -66,13 +67,17 @@ interface HistoryEntry {
|
||||
docCount: number
|
||||
findings: number
|
||||
resultKey: string
|
||||
checkId?: string
|
||||
}
|
||||
|
||||
export function ComplianceCheckTab() {
|
||||
const [docs, setDocs] = useState<DocsState>(initState)
|
||||
const [useAgent, setUseAgent] = useState(false)
|
||||
const [tdmOverride, setTdmOverride] = useState(false)
|
||||
const [tdmOverrideReason, setTdmOverrideReason] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [progress, setProgress] = useState('')
|
||||
const [progressPct, setProgressPct] = useState(0)
|
||||
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 }
|
||||
@@ -109,17 +114,16 @@ export function ComplianceCheckTab() {
|
||||
if (!res.ok) continue
|
||||
const data = await res.json()
|
||||
if (data.progress) setProgress(data.progress)
|
||||
if (typeof data.progress_pct === 'number') setProgressPct(data.progress_pct)
|
||||
if (data.status === 'completed' && data.result) {
|
||||
setResults(data.result); setProgress(''); setLoading(false)
|
||||
setResults(data.result); setProgress(''); setProgressPct(0); 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
|
||||
if (['failed', 'not_found', 'skipped_tdm'].includes(data.status)) {
|
||||
if (data.status !== 'not_found') setError(data.error || (data.status === 'skipped_tdm' ? 'TDM-Vorbehalt erkannt — Crawl uebersprungen' : 'Pruefung fehlgeschlagen'))
|
||||
setProgress(''); setProgressPct(0); setLoading(false); localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId(''); return
|
||||
}
|
||||
} catch { /* retry */ }
|
||||
}
|
||||
@@ -177,6 +181,7 @@ export function ComplianceCheckTab() {
|
||||
setError(null)
|
||||
setResults(null)
|
||||
setProgress('Compliance-Check wird gestartet...')
|
||||
setProgressPct(0)
|
||||
|
||||
try {
|
||||
const entries = DOCUMENT_TYPES
|
||||
@@ -194,6 +199,8 @@ export function ComplianceCheckTab() {
|
||||
body: JSON.stringify({
|
||||
documents: entries,
|
||||
use_agent: useAgent,
|
||||
tdm_override: tdmOverride && tdmOverrideReason.trim().length >= 10,
|
||||
tdm_override_reason: tdmOverrideReason.trim(),
|
||||
}),
|
||||
})
|
||||
if (!startRes.ok) throw new Error(`Pruefung konnte nicht gestartet werden: ${startRes.status}`)
|
||||
@@ -202,17 +209,19 @@ export function ComplianceCheckTab() {
|
||||
setActiveCheckId(check_id)
|
||||
localStorage.setItem(STORAGE_KEY_CHECK_ID, check_id)
|
||||
|
||||
// Poll for results (max 15 min = 300 polls x 3s)
|
||||
// Poll for results (max 25 min = 500 polls x 3s)
|
||||
let attempts = 0
|
||||
while (attempts < 300) {
|
||||
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 (typeof pollData.progress_pct === 'number') setProgressPct(pollData.progress_pct)
|
||||
if (pollData.status === 'completed' && pollData.result) {
|
||||
setResults(pollData.result)
|
||||
setProgress('')
|
||||
setProgressPct(0)
|
||||
localStorage.setItem(STORAGE_KEY_RESULTS, JSON.stringify(pollData.result))
|
||||
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||
|
||||
@@ -229,19 +238,20 @@ export function ComplianceCheckTab() {
|
||||
localStorage.setItem(STORAGE_KEY_HISTORY, JSON.stringify(updated))
|
||||
break
|
||||
}
|
||||
if (pollData.status === 'failed') {
|
||||
if (['failed', 'skipped_tdm'].includes(pollData.status)) {
|
||||
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||
throw new Error(pollData.error || 'Pruefung fehlgeschlagen')
|
||||
throw new Error(pollData.error || (pollData.status === 'skipped_tdm' ? 'TDM-Vorbehalt' : 'Pruefung fehlgeschlagen'))
|
||||
}
|
||||
attempts++
|
||||
}
|
||||
if (attempts >= 300) {
|
||||
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('')
|
||||
setProgressPct(0)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -313,10 +323,15 @@ export function ComplianceCheckTab() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50/60 border border-amber-200 rounded-lg p-3 space-y-2">
|
||||
<label className="flex items-start gap-2 cursor-pointer"><input type="checkbox" checked={tdmOverride} onChange={e => setTdmOverride(e.target.checked)} className="mt-0.5 accent-amber-600" /><span className="text-xs text-amber-900"><strong>Schriftliche Crawl-Erlaubnis vorhanden</strong> — uebergeht TDM-Vorbehalte (robots.txt / ai.txt)</span></label>
|
||||
{tdmOverride && <input type="text" value={tdmOverrideReason} onChange={e => setTdmOverrideReason(e.target.value)} placeholder="z.B. Auftragsbeziehung Safetykon GmbH, Email Hr. X vom 18.05.2026" className="w-full px-3 py-2 text-xs border border-amber-300 rounded bg-white" />}
|
||||
{tdmOverride && tdmOverrideReason.trim().length < 10 && <p className="text-[10px] text-amber-700">Pflicht: Reason mit min. 10 Zeichen (Audit-Spur).</p>}
|
||||
</div>
|
||||
{/* Submit button */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || filledCount === 0}
|
||||
disabled={loading || filledCount === 0 || (tdmOverride && tdmOverrideReason.trim().length < 10)}
|
||||
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 ? (
|
||||
@@ -334,12 +349,21 @@ export function ComplianceCheckTab() {
|
||||
|
||||
{/* 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 className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm text-purple-700 space-y-2">
|
||||
<div className="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>
|
||||
<span className="flex-1">{progress}</span>
|
||||
<span className="text-xs font-mono text-purple-600 tabular-nums">{progressPct}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-purple-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-500 rounded-full transition-all duration-500 ease-out"
|
||||
style={{ width: `${Math.max(2, progressPct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -439,13 +463,14 @@ export function ComplianceCheckTab() {
|
||||
|
||||
<ChecklistView results={results.results} />
|
||||
|
||||
{/* Email status */}
|
||||
{/* Email + Migration + Full-audit */}
|
||||
{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>
|
||||
)}
|
||||
{results.check_id && <MigrationPanel checkId={results.check_id} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,30 +2,41 @@
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { ChecklistView } from './ChecklistView'
|
||||
import { ResultsTabsView } from './ResultsTabsView'
|
||||
import { PreScanWizard, useScanContext, isContextComplete } from './PreScanWizard'
|
||||
import { safeSetItem } from './storageHelpers'
|
||||
|
||||
interface DocEntry {
|
||||
id: string
|
||||
type: string
|
||||
label: string
|
||||
url: string
|
||||
text: string // P-Paste: User kopiert Doc-Text direkt rein
|
||||
mode: 'url' | 'text' // welcher Input wird aktiv genutzt
|
||||
}
|
||||
|
||||
const DOC_TYPES = [
|
||||
{ id: 'dse', label: 'DSI (Datenschutzinformation)' },
|
||||
{ id: 'dse', label: 'Datenschutzerklärung / DSI' },
|
||||
{ id: 'cookie', label: 'Cookie-Richtlinie' },
|
||||
{ id: 'impressum', label: 'Impressum' },
|
||||
{ id: 'agb', label: 'AGB' },
|
||||
{ id: 'nutzungsbedingungen', label: 'Nutzungsbedingungen' },
|
||||
{ id: 'widerruf', label: 'Widerrufsbelehrung' },
|
||||
{ id: 'social_media', label: 'DSE Social Media (Art. 26)' },
|
||||
{ id: 'dsfa', label: 'DSFA (Art. 35)' },
|
||||
{ id: 'agb', label: 'AGB / Nutzungsbedingungen' },
|
||||
{ id: 'impressum', label: 'Impressum' },
|
||||
{ id: 'cookie', label: 'Cookie-Richtlinie' },
|
||||
{ id: 'widerruf', label: 'Widerrufsbelehrung' },
|
||||
{ id: 'dsa', label: 'DSA / Digital Services Act' },
|
||||
{ id: 'legal_notice', label: 'Rechtliche Hinweise (IP, Forward-Looking)' },
|
||||
{ id: 'lizenzhinweise', label: 'Lizenzhinweise Dritter (OSS)' },
|
||||
{ id: 'other', label: 'Sonstiges' },
|
||||
]
|
||||
|
||||
function newEntry(): DocEntry {
|
||||
return { id: crypto.randomUUID().slice(0, 8), type: 'dse', label: '', url: '' }
|
||||
return { id: crypto.randomUUID().slice(0, 8), type: 'dse', label: '',
|
||||
url: '', text: '', mode: 'url' }
|
||||
}
|
||||
|
||||
export function DocCheckTab() {
|
||||
const [scanContext, setScanContext] = useScanContext()
|
||||
const [entries, setEntries] = useState<DocEntry[]>(() => {
|
||||
if (typeof window === 'undefined') return [newEntry()]
|
||||
try { const s = localStorage.getItem('doc-check-entries'); return s ? JSON.parse(s) : [newEntry()] } catch { return [newEntry()] }
|
||||
@@ -74,7 +85,7 @@ export function DocCheckTab() {
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const validEntries = entries.filter(e => e.url.trim())
|
||||
const validEntries = entries.filter(e => e.url.trim() || e.text.trim())
|
||||
if (validEntries.length === 0) return
|
||||
|
||||
setLoading(true)
|
||||
@@ -89,11 +100,17 @@ export function DocCheckTab() {
|
||||
body: JSON.stringify({
|
||||
entries: validEntries.map(e => ({
|
||||
doc_type: e.type,
|
||||
label: e.label || e.url.split('/').pop() || 'Dokument',
|
||||
url: e.url.trim(),
|
||||
label: e.label
|
||||
|| (e.url ? e.url.split('/').pop() : '')
|
||||
|| `${e.type}-paste`,
|
||||
url: e.mode === 'text' ? '' : e.url.trim(),
|
||||
// Backend nimmt text > url. Wenn beide gefuellt sind und
|
||||
// mode='url', schicken wir den text NICHT mit.
|
||||
text: e.mode === 'text' ? e.text.trim() : '',
|
||||
})),
|
||||
check_cookie_banner: checkCookieBanner,
|
||||
use_agent: useAgent,
|
||||
scan_context: scanContext,
|
||||
}),
|
||||
})
|
||||
if (!startRes.ok) throw new Error(`Pruefung konnte nicht gestartet werden: ${startRes.status}`)
|
||||
@@ -111,13 +128,13 @@ export function DocCheckTab() {
|
||||
if (pollData.status === 'completed' && pollData.result) {
|
||||
setResults(pollData.result)
|
||||
setProgress('')
|
||||
localStorage.setItem('doc-check-results', JSON.stringify(pollData.result))
|
||||
safeSetItem('doc-check-results', JSON.stringify(pollData.result))
|
||||
const resultKey = `doc-check-result-${Date.now()}`
|
||||
try { localStorage.setItem(resultKey, JSON.stringify(pollData.result)) } catch { /* quota */ }
|
||||
safeSetItem(resultKey, JSON.stringify(pollData.result))
|
||||
const entry = { date: new Date().toISOString(), urls: validEntries.length, findings: pollData.result.total_findings || 0, resultKey }
|
||||
const updated = [entry, ...history].slice(0, 30)
|
||||
setHistory(updated)
|
||||
localStorage.setItem('doc-check-history', JSON.stringify(updated))
|
||||
safeSetItem('doc-check-history', JSON.stringify(updated))
|
||||
break
|
||||
}
|
||||
if (pollData.status === 'failed') {
|
||||
@@ -133,43 +150,90 @@ export function DocCheckTab() {
|
||||
}
|
||||
}
|
||||
|
||||
const contextReady = isContextComplete(scanContext)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* URL Entries */}
|
||||
<div className="space-y-2">
|
||||
{/* P79 Pre-Scan-Wizard — 8 Pflichtfelder */}
|
||||
<PreScanWizard value={scanContext} onChange={setScanContext} />
|
||||
|
||||
{/* URL / Text Entries */}
|
||||
<div className="space-y-3">
|
||||
{entries.map((entry, i) => (
|
||||
<div key={entry.id} className="flex items-center gap-2">
|
||||
<select
|
||||
value={entry.type}
|
||||
onChange={e => updateEntry(entry.id, 'type', e.target.value)}
|
||||
className="w-48 px-3 py-2.5 border border-gray-300 rounded-lg text-sm bg-white shrink-0"
|
||||
>
|
||||
{DOC_TYPES.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={entry.label}
|
||||
onChange={e => updateEntry(entry.id, 'label', e.target.value)}
|
||||
placeholder={entry.type === 'other' ? 'Dokumentname' : 'Version / Stand (optional)'}
|
||||
className="w-40 px-3 py-2.5 border border-gray-300 rounded-lg text-sm shrink-0"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
value={entry.url}
|
||||
onChange={e => updateEntry(entry.id, 'url', e.target.value)}
|
||||
onBlur={() => autoLabel(entry)}
|
||||
placeholder="https://example.com/datenschutz"
|
||||
className="flex-1 px-3 py-2.5 border border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
{entries.length > 1 && (
|
||||
<button onClick={() => removeEntry(entry.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-500 shrink-0">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div key={entry.id} className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={entry.type}
|
||||
onChange={e => updateEntry(entry.id, 'type', e.target.value)}
|
||||
className="w-48 px-3 py-2.5 border border-gray-300 rounded-lg text-sm bg-white shrink-0"
|
||||
>
|
||||
{DOC_TYPES.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={entry.label}
|
||||
onChange={e => updateEntry(entry.id, 'label', e.target.value)}
|
||||
placeholder={entry.type === 'other' ? 'Dokumentname' : 'Version / Stand (optional)'}
|
||||
className="w-40 px-3 py-2.5 border border-gray-300 rounded-lg text-sm shrink-0"
|
||||
/>
|
||||
|
||||
{/* Mode-Toggle URL / Text */}
|
||||
<div className="inline-flex border border-gray-300 rounded-lg overflow-hidden text-xs shrink-0">
|
||||
<button type="button"
|
||||
onClick={() => updateEntry(entry.id, 'mode', 'url')}
|
||||
className={`px-3 py-2 ${entry.mode === 'url'
|
||||
? 'bg-purple-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'}`}>
|
||||
URL
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => updateEntry(entry.id, 'mode', 'text')}
|
||||
className={`px-3 py-2 ${entry.mode === 'text'
|
||||
? 'bg-purple-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'}`}>
|
||||
Text einfügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{entry.mode === 'url' && (
|
||||
<input
|
||||
type="url"
|
||||
value={entry.url}
|
||||
onChange={e => updateEntry(entry.id, 'url', e.target.value)}
|
||||
onBlur={() => autoLabel(entry)}
|
||||
placeholder="https://example.com/datenschutz"
|
||||
className="flex-1 px-3 py-2.5 border border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
)}
|
||||
|
||||
{entries.length > 1 && (
|
||||
<button onClick={() => removeEntry(entry.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-500 shrink-0">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{entry.mode === 'text' && (
|
||||
<div className="ml-[400px]">
|
||||
<textarea
|
||||
value={entry.text}
|
||||
onChange={e => updateEntry(entry.id, 'text', e.target.value)}
|
||||
placeholder={
|
||||
entry.type === 'cookie'
|
||||
? 'Kopiere hier die komplette Cookie-Tabelle rein (Tab-getrennt oder mit | als Trenner — wir parsen alle Spalten deterministisch)…'
|
||||
: 'Kopiere hier den vollständigen Doc-Text rein. Wir erkennen automatisch ob es zu „' + (DOC_TYPES.find(t => t.id === entry.type)?.label ?? entry.type) + '" passt.'
|
||||
}
|
||||
className="w-full h-32 px-3 py-2 border border-gray-300 rounded-lg text-xs font-mono resize-y"
|
||||
/>
|
||||
<div className="text-[10px] text-gray-500 mt-1">
|
||||
{entry.text.trim().length > 0
|
||||
? `${entry.text.trim().length.toLocaleString('de-DE')} Zeichen · ${entry.text.trim().split(/\s+/).length.toLocaleString('de-DE')} Wörter`
|
||||
: 'Der Crawler wird übersprungen — die Analyse läuft direkt auf dem eingefügten Text.'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -212,8 +276,11 @@ export function DocCheckTab() {
|
||||
{/* Submit */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || entries.every(e => !e.url.trim())}
|
||||
disabled={loading
|
||||
|| entries.every(e => !e.url.trim() && !e.text.trim())
|
||||
|| !contextReady}
|
||||
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"
|
||||
title={!contextReady ? 'Bitte zuerst die 8 Pflichtfelder ausfüllen' : undefined}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
@@ -223,6 +290,8 @@ export function DocCheckTab() {
|
||||
</svg>
|
||||
Pruefe...
|
||||
</>
|
||||
) : !contextReady ? (
|
||||
`Klassifizierung unvollständig (8 Pflichtfelder)`
|
||||
) : (
|
||||
`${entries.filter(e => e.url.trim()).length} Dokument${entries.filter(e => e.url.trim()).length !== 1 ? 'e' : ''} pruefen`
|
||||
)}
|
||||
@@ -244,41 +313,9 @@ export function DocCheckTab() {
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{/* Results — als Tab-Ansicht (Übersicht/Cookies/DSE/Impressum/AGB/Banner/Mail) */}
|
||||
{results && results.results && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||
<ChecklistView results={results.results} />
|
||||
|
||||
{/* Cookie Banner Result */}
|
||||
{results.cookie_banner_result && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<h4 className="text-sm font-semibold text-gray-800 mb-2">Cookie-Banner</h4>
|
||||
<div className="text-sm text-gray-600">
|
||||
{results.cookie_banner_result.banner_detected
|
||||
? `Banner erkannt: ${results.cookie_banner_result.banner_provider || 'unbekannt'}`
|
||||
: 'Kein Banner erkannt'}
|
||||
</div>
|
||||
{results.cookie_banner_result.banner_checks?.violations?.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{results.cookie_banner_result.banner_checks.violations.map((v: any, i: number) => (
|
||||
<div key={i} className="text-xs text-red-600 flex items-start gap-1.5">
|
||||
<span className="shrink-0 mt-0.5">!!</span>
|
||||
<span>{v.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<ResultsTabsView results={results} />
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
interface BannerFlag {
|
||||
level: 'ERROR' | 'WARNING' | 'INFO'
|
||||
vendor: string
|
||||
issue: string
|
||||
message: string
|
||||
}
|
||||
|
||||
interface BannerPreview {
|
||||
config: { categories: { id: string; cookies: { name: string }[] }[] }
|
||||
flags: BannerFlag[]
|
||||
summary: {
|
||||
vendors_total: number
|
||||
vendors_with_no_cookies: number
|
||||
cookies_total: number
|
||||
categories: Record<string, number>
|
||||
flags_error: number
|
||||
flags_warning: number
|
||||
flags_info: number
|
||||
}
|
||||
}
|
||||
|
||||
interface DocumentPreview {
|
||||
check_id: string
|
||||
vendor_count: number
|
||||
templates: Record<string, {
|
||||
templateType: string
|
||||
initialContent: string
|
||||
suggested_template_search?: string
|
||||
}>
|
||||
}
|
||||
|
||||
type Mode = 'banner' | 'documents'
|
||||
|
||||
export function MigrationPanel({ checkId }: { checkId: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [mode, setMode] = useState<Mode>('banner')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [banner, setBanner] = useState<BannerPreview | null>(null)
|
||||
const [docs, setDocs] = useState<DocumentPreview | null>(null)
|
||||
|
||||
async function loadPreview(next: Mode) {
|
||||
setMode(next)
|
||||
setOpen(true)
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const path = next === 'banner'
|
||||
? `/api/sdk/v1/agent/migration/${checkId}/banner-preview`
|
||||
: `/api/sdk/v1/agent/migration/${checkId}/document-preview`
|
||||
const r = await fetch(path)
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
const data = await r.json()
|
||||
if (next === 'banner') setBanner(data)
|
||||
else setDocs(data)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Preview-Ladefehler')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-3 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => loadPreview('banner')}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-purple-50 text-purple-700 border border-purple-200 hover:bg-purple-100">
|
||||
Cookie-Banner uebernehmen
|
||||
</button>
|
||||
<button onClick={() => loadPreview('documents')}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-amber-50 text-amber-700 border border-amber-200 hover:bg-amber-100">
|
||||
Dokumente vorbefuellen
|
||||
</button>
|
||||
</div>
|
||||
<a href={`/sdk/agent/audit/${checkId}`} target="_blank" rel="noopener"
|
||||
className="text-xs text-blue-700 hover:text-blue-900 underline">
|
||||
Voll-Audit oeffnen (alle MCs) →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 bg-black/40 flex items-start justify-center p-6 overflow-y-auto">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl p-6 mt-12">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{mode === 'banner' ? 'Cookie-Banner Migration' : 'Dokument-Vorbefuellung'}
|
||||
</h3>
|
||||
<button onClick={() => setOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className="text-sm text-gray-500">Lade Preview ...</div>}
|
||||
{error && <div className="text-sm text-red-600">Fehler: {error}</div>}
|
||||
|
||||
{!loading && !error && mode === 'banner' && banner && (
|
||||
<BannerPreviewBody data={banner} />
|
||||
)}
|
||||
|
||||
{!loading && !error && mode === 'documents' && docs && (
|
||||
<DocumentPreviewBody data={docs} />
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button onClick={() => setOpen(false)}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 hover:bg-gray-50">
|
||||
Schliessen
|
||||
</button>
|
||||
<a href={mode === 'banner' ? '/sdk/einwilligungen' : '/sdk/document-generator'}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-purple-600 text-white hover:bg-purple-700">
|
||||
Im Editor oeffnen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function BannerPreviewBody({ data }: { data: BannerPreview }) {
|
||||
const { summary, flags, config } = data
|
||||
return (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Stat label="Anbieter" value={summary.vendors_total} />
|
||||
<Stat label="Cookies" value={summary.cookies_total} />
|
||||
<Stat label="Kategorien" value={Object.values(summary.categories).filter(n => n > 0).length} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Stat label="Fehler" value={summary.flags_error} tone="red" />
|
||||
<Stat label="Warnungen" value={summary.flags_warning} tone="amber" />
|
||||
<Stat label="Hinweise" value={summary.flags_info} tone="gray" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 mb-1">Kategorien</h4>
|
||||
<ul className="text-xs text-gray-600 space-y-0.5">
|
||||
{config.categories.map(c => (
|
||||
<li key={c.id}>{c.id}: {c.cookies.length} Cookie(s)</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{flags.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 mb-1">Pruefpunkte</h4>
|
||||
<ul className="text-xs space-y-0.5 max-h-48 overflow-y-auto">
|
||||
{flags.map((f, i) => (
|
||||
<li key={i} className={f.level === 'ERROR' ? 'text-red-700' : f.level === 'WARNING' ? 'text-amber-700' : 'text-gray-600'}>
|
||||
[{f.level}] {f.vendor}: {f.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DocumentPreviewBody({ data }: { data: DocumentPreview }) {
|
||||
return (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="text-xs text-gray-600">
|
||||
{data.vendor_count} Anbieter werden in {Object.keys(data.templates).length} Vorlagen eingespielt.
|
||||
</div>
|
||||
{Object.entries(data.templates).map(([key, tpl]) => (
|
||||
<div key={key} className="border border-gray-200 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-gray-800">{tpl.templateType}</h4>
|
||||
{tpl.suggested_template_search && (
|
||||
<span className="text-xs text-gray-500">Vorschlag: {tpl.suggested_template_search}</span>
|
||||
)}
|
||||
</div>
|
||||
<pre className="text-xs bg-gray-50 rounded p-2 max-h-48 overflow-auto whitespace-pre-wrap">
|
||||
{tpl.initialContent.slice(0, 1200)}{tpl.initialContent.length > 1200 ? '\n…' : ''}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value, tone = 'gray' }: { label: string; value: number; tone?: 'red' | 'amber' | 'gray' }) {
|
||||
const color = tone === 'red' ? 'text-red-700' : tone === 'amber' ? 'text-amber-700' : 'text-gray-800'
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-2 text-center">
|
||||
<div className={`text-lg font-semibold ${color}`}>{value}</div>
|
||||
<div className="text-xs text-gray-500">{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* P79 — Pre-Scan-Wizard (8 Pflichtfelder).
|
||||
*
|
||||
* 8 Pflichtfelder die vor dem Lauf abgefragt werden. Werte landen im
|
||||
* scan_context und filtern später die MC-Auswertung (zusammen mit P72
|
||||
* scope_doc_type + applicable_industries). Erwartete Noise-Reduktion:
|
||||
* 70-80% bei falsch zugeordneten HIGH-MCs.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
export interface ScanContext {
|
||||
industry: string
|
||||
business_model: string
|
||||
direct_sales: string
|
||||
legal_form: string
|
||||
group_structure: string
|
||||
employee_count: string
|
||||
special_data: string[]
|
||||
third_country_transfer: string
|
||||
}
|
||||
|
||||
const INDUSTRIES = [
|
||||
{ id: '', label: '— bitte wählen —' },
|
||||
{ id: 'automotive', label: 'Automotive / OEM' },
|
||||
{ id: 'ecommerce', label: 'E-Commerce / Online-Handel' },
|
||||
{ id: 'saas', label: 'SaaS / Software' },
|
||||
{ id: 'banking', label: 'Banking / Finance' },
|
||||
{ id: 'insurance', label: 'Insurance / Versicherung' },
|
||||
{ id: 'healthcare', label: 'Healthcare / Gesundheit' },
|
||||
{ id: 'education', label: 'Bildung / Schule' },
|
||||
{ id: 'public', label: 'Öffentliche Verwaltung' },
|
||||
{ id: 'manufacturing', label: 'Industrie / Manufacturing' },
|
||||
{ id: 'media', label: 'Medien / Verlag' },
|
||||
{ id: 'other', label: 'Sonstige' },
|
||||
]
|
||||
|
||||
const LEGAL_FORMS = [
|
||||
{ id: '', label: '— bitte wählen —' },
|
||||
{ id: 'ag', label: 'AG (Aktiengesellschaft)' },
|
||||
{ id: 'gmbh', label: 'GmbH' },
|
||||
{ id: 'gmbh_co_kg', label: 'GmbH & Co. KG' },
|
||||
{ id: 'kg', label: 'KG' },
|
||||
{ id: 'ohg', label: 'OHG' },
|
||||
{ id: 'ug', label: 'UG (haftungsbeschränkt)' },
|
||||
{ id: 'ek', label: 'e.K. / Einzelunternehmen' },
|
||||
{ id: 'verein', label: 'Verein' },
|
||||
{ id: 'stiftung', label: 'Stiftung' },
|
||||
{ id: 'behoerde', label: 'Behörde / Körperschaft öff. Rechts' },
|
||||
{ id: 'other', label: 'Sonstige' },
|
||||
]
|
||||
|
||||
const GROUP_STRUCTURES = [
|
||||
{ id: '', label: '— bitte wählen —' },
|
||||
{ id: 'standalone', label: 'Eigenständig' },
|
||||
{ id: 'parent', label: 'Konzern-Mutter' },
|
||||
{ id: 'subsidiary', label: 'Konzern-Tochter' },
|
||||
{ id: 'joint_venture', label: 'Joint Venture' },
|
||||
{ id: 'processor', label: 'Reiner Auftragsverarbeiter' },
|
||||
]
|
||||
|
||||
const EMPLOYEE_COUNTS = [
|
||||
{ id: '', label: '— bitte wählen —' },
|
||||
{ id: 'lt10', label: 'unter 10' },
|
||||
{ id: '10_19', label: '10-19' },
|
||||
{ id: '20_49', label: '20-49 (DSB ab 20 Pflicht)' },
|
||||
{ id: '50_249', label: '50-249 (Whistleblower-Pflicht)' },
|
||||
{ id: '250_499', label: '250-499' },
|
||||
{ id: '500_999', label: '500-999' },
|
||||
{ id: '1000_plus', label: '1.000+ (Konzern)' },
|
||||
]
|
||||
|
||||
const SPECIAL_DATA_OPTIONS = [
|
||||
{ id: 'health', label: 'Gesundheitsdaten' },
|
||||
{ id: 'biometric', label: 'Biometrische Daten' },
|
||||
{ id: 'ethnicity', label: 'Religiöse / ethnische Herkunft' },
|
||||
{ id: 'sexual', label: 'Sexuelle Orientierung' },
|
||||
{ id: 'criminal', label: 'Strafrechtliche Daten' },
|
||||
{ id: 'minors', label: 'Minderjährige (<16)' },
|
||||
{ id: 'none', label: 'Keine besonderen Daten' },
|
||||
]
|
||||
|
||||
const STORAGE_KEY = 'compliance-scan-context'
|
||||
|
||||
function emptyContext(): ScanContext {
|
||||
return {
|
||||
industry: '',
|
||||
business_model: '',
|
||||
direct_sales: '',
|
||||
legal_form: '',
|
||||
group_structure: '',
|
||||
employee_count: '',
|
||||
special_data: [],
|
||||
third_country_transfer: '',
|
||||
}
|
||||
}
|
||||
|
||||
export function isContextComplete(ctx: ScanContext): boolean {
|
||||
return Boolean(
|
||||
ctx.industry &&
|
||||
ctx.business_model &&
|
||||
ctx.direct_sales &&
|
||||
ctx.legal_form &&
|
||||
ctx.group_structure &&
|
||||
ctx.employee_count &&
|
||||
ctx.special_data.length > 0 &&
|
||||
ctx.third_country_transfer
|
||||
)
|
||||
}
|
||||
|
||||
export function PreScanWizard({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: ScanContext
|
||||
onChange: (ctx: ScanContext) => void
|
||||
}) {
|
||||
const update = <K extends keyof ScanContext>(key: K, val: ScanContext[K]) => {
|
||||
onChange({ ...value, [key]: val })
|
||||
}
|
||||
|
||||
const toggleSpecialData = (id: string) => {
|
||||
const next = value.special_data.includes(id)
|
||||
? value.special_data.filter(x => x !== id)
|
||||
: [...value.special_data.filter(x => x !== 'none' || id === 'none'), id]
|
||||
onChange({ ...value, special_data: id === 'none' ? ['none'] : next.filter(x => x !== 'none') })
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#f0f9ff',
|
||||
border: '1px solid #bfdbfe',
|
||||
borderRadius: 8,
|
||||
padding: '14px 16px',
|
||||
marginBottom: 14,
|
||||
}}>
|
||||
<div style={{ fontSize: 11, color: '#1e40af', textTransform: 'uppercase',
|
||||
letterSpacing: 1.2, marginBottom: 4, fontWeight: 600 }}>
|
||||
Pflichtangaben zur Klassifizierung des Audits
|
||||
</div>
|
||||
<h3 style={{ margin: '0 0 6px', fontSize: 14, color: '#1e293b' }}>
|
||||
Vor dem Scan: 8 Angaben zum Unternehmen
|
||||
</h3>
|
||||
<p style={{ margin: '0 0 12px', fontSize: 11, color: '#475569', lineHeight: 1.5 }}>
|
||||
Diese Angaben filtern irrelevante Compliance-Themen heraus (z.B. eHealth-
|
||||
Vorschriften bei einem Autobauer) und liefern eine realistische
|
||||
Einschätzung statt pauschaler Verstoss-Listen.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 10 }}>
|
||||
<Field label="1. Branche*">
|
||||
<select value={value.industry} onChange={e => update('industry', e.target.value)} style={inputStyle}>
|
||||
{INDUSTRIES.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="2. Geschäftsmodell*">
|
||||
<select value={value.business_model} onChange={e => update('business_model', e.target.value)} style={inputStyle}>
|
||||
<option value="">— bitte wählen —</option>
|
||||
<option value="b2b">B2B</option>
|
||||
<option value="b2c">B2C</option>
|
||||
<option value="both">Beides (B2B + B2C)</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="3. Direkt-Vertrieb (Webshop/Buchung)*">
|
||||
<select value={value.direct_sales} onChange={e => update('direct_sales', e.target.value)} style={inputStyle}>
|
||||
<option value="">— bitte wählen —</option>
|
||||
<option value="yes">Ja</option>
|
||||
<option value="no">Nein</option>
|
||||
<option value="lead_funnel">Nur Lead-Funnel (Probefahrten, Anfragen)</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="4. Rechtsform*">
|
||||
<select value={value.legal_form} onChange={e => update('legal_form', e.target.value)} style={inputStyle}>
|
||||
{LEGAL_FORMS.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="5. Konzern-Struktur*">
|
||||
<select value={value.group_structure} onChange={e => update('group_structure', e.target.value)} style={inputStyle}>
|
||||
{GROUP_STRUCTURES.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="6. Mitarbeiterzahl*">
|
||||
<select value={value.employee_count} onChange={e => update('employee_count', e.target.value)} style={inputStyle}>
|
||||
{EMPLOYEE_COUNTS.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="7. Besondere Datenkategorien*" colSpan={2}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{SPECIAL_DATA_OPTIONS.map(o => (
|
||||
<label key={o.id} style={{ fontSize: 12, display: 'inline-flex',
|
||||
alignItems: 'center', gap: 4,
|
||||
padding: '4px 8px', background: '#fff',
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: 4 }}>
|
||||
<input type="checkbox"
|
||||
checked={value.special_data.includes(o.id)}
|
||||
onChange={() => toggleSpecialData(o.id)} />
|
||||
{o.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field label="8. Bekannter Drittland-Transfer*" colSpan={2}>
|
||||
<select value={value.third_country_transfer} onChange={e => update('third_country_transfer', e.target.value)} style={inputStyle}>
|
||||
<option value="">— bitte wählen —</option>
|
||||
<option value="yes">Ja (USA, CN, IN, UK, ...)</option>
|
||||
<option value="no">Nein (nur EU/EWR)</option>
|
||||
<option value="unknown">Weiß nicht (bitte automatisch prüfen)</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{!isContextComplete(value) && (
|
||||
<div style={{ marginTop: 10, fontSize: 11, color: '#92400e',
|
||||
background: '#fef3c7', padding: '6px 10px',
|
||||
borderRadius: 4, border: '1px solid #fde68a' }}>
|
||||
Bitte alle 8 Pflichtfelder ausfüllen — der Scan-Button wird erst aktiv,
|
||||
wenn die Klassifizierung komplett ist.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '6px 8px',
|
||||
fontSize: 12,
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: 4,
|
||||
background: '#fff',
|
||||
}
|
||||
|
||||
function Field({ label, children, colSpan }: { label: string; children: React.ReactNode; colSpan?: number }) {
|
||||
return (
|
||||
<div style={{ gridColumn: colSpan ? `span ${colSpan}` : undefined }}>
|
||||
<label style={{ display: 'block', fontSize: 11, color: '#475569',
|
||||
marginBottom: 4, fontWeight: 600 }}>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function useScanContext(): [ScanContext, (ctx: ScanContext) => void] {
|
||||
const [ctx, setCtx] = useState<ScanContext>(() => {
|
||||
if (typeof window === 'undefined') return emptyContext()
|
||||
try {
|
||||
const s = localStorage.getItem(STORAGE_KEY)
|
||||
return s ? { ...emptyContext(), ...JSON.parse(s) } : emptyContext()
|
||||
} catch {
|
||||
return emptyContext()
|
||||
}
|
||||
})
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(ctx)) } catch {}
|
||||
}, [ctx])
|
||||
return [ctx, setCtx]
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ResultsTabsView — strukturierte Tab-Ansicht der Audit-Ergebnisse.
|
||||
*
|
||||
* Statt einer langen Scroll-Seite gibt es:
|
||||
* 1. Übersicht (Score + GF-Kurzfassung)
|
||||
* 2. Cookies (3-Quellen-Compliance-Vergleich + Vendor-/Cookie-Listen)
|
||||
* 3. Datenschutzerklärung
|
||||
* 4. Impressum
|
||||
* 5. AGB / Widerruf
|
||||
* 6. Banner (Cookie-Banner-Checks)
|
||||
* 7. Vollständige Mail (HTML-Preview)
|
||||
*
|
||||
* Tab-Headers sticky oben, Content scrollbar unten.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { ChecklistView } from './ChecklistView'
|
||||
|
||||
interface ResultsTabsViewProps {
|
||||
results: any
|
||||
}
|
||||
|
||||
type TabId = 'overview' | 'cookies' | 'dse' | 'impressum' | 'agb' | 'banner' | 'mail'
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: string }[] = [
|
||||
{ id: 'overview', label: 'Übersicht', icon: '◉' },
|
||||
{ id: 'cookies', label: 'Cookies & VVT', icon: '🍪' },
|
||||
{ id: 'dse', label: 'Datenschutzerkl.', icon: '📄' },
|
||||
{ id: 'impressum', label: 'Impressum', icon: '🏢' },
|
||||
{ id: 'agb', label: 'AGB / Widerruf', icon: '⚖️' },
|
||||
{ id: 'banner', label: 'Cookie-Banner', icon: '🎛' },
|
||||
{ id: 'mail', label: 'Mail-Vorschau', icon: '✉️' },
|
||||
]
|
||||
|
||||
export function ResultsTabsView({ results }: ResultsTabsViewProps) {
|
||||
const [active, setActive] = useState<TabId>('overview')
|
||||
|
||||
const r = results || {}
|
||||
const docs: any[] = r.results || []
|
||||
const banner = r.banner_result || r.cookie_banner_result || {}
|
||||
const cmpVendors: any[] = r.cmp_vendors || []
|
||||
const cookieAudit = r.cookie_audit || {}
|
||||
|
||||
const docsByType = useMemo(() => {
|
||||
const m: Record<string, any> = {}
|
||||
for (const d of docs) {
|
||||
const t = (d.doc_type || '').toLowerCase()
|
||||
if (!m[t]) m[t] = d
|
||||
}
|
||||
return m
|
||||
}, [docs])
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden bg-white">
|
||||
{/* Sticky Tab-Header */}
|
||||
<div className="flex border-b border-gray-200 bg-gray-50 overflow-x-auto sticky top-0 z-10">
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setActive(t.id)}
|
||||
className={`px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors ${
|
||||
active === t.id
|
||||
? 'border-purple-600 text-purple-700 bg-white'
|
||||
: 'border-transparent text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1.5">{t.icon}</span>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab-Content */}
|
||||
<div className="p-4 min-h-[400px]">
|
||||
{active === 'overview' && <OverviewTab results={r} />}
|
||||
{active === 'cookies' && (
|
||||
<CookiesTab
|
||||
audit={cookieAudit}
|
||||
vendors={cmpVendors}
|
||||
banner={banner}
|
||||
/>
|
||||
)}
|
||||
{active === 'dse' && <DocTab doc={docsByType['dse']} label="Datenschutzerklärung" />}
|
||||
{active === 'impressum' && <DocTab doc={docsByType['impressum']} label="Impressum" />}
|
||||
{active === 'agb' && <AgbWiderrufTab docs={docsByType} />}
|
||||
{active === 'banner' && <BannerTab banner={banner} />}
|
||||
{active === 'mail' && <MailPreviewTab results={r} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Übersicht ──────────────────────────────────────────────────────────
|
||||
function OverviewTab({ results }: { results: any }) {
|
||||
const totalDocs = results.total_documents || (results.results?.length ?? 0)
|
||||
const totalFindings = results.total_findings ?? 0
|
||||
const banner = results.banner_result || results.cookie_banner_result || {}
|
||||
const score = banner.compliance_score ?? banner.completeness_pct ?? null
|
||||
const emailStatus = results.email_status
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Kpi label="Geprüfte Dokumente" value={totalDocs} />
|
||||
<Kpi label="Findings gesamt" value={totalFindings} tone={totalFindings > 5 ? 'warn' : 'ok'} />
|
||||
<Kpi label="Vendors erkannt" value={results.cmp_vendors?.length || 0} />
|
||||
<Kpi label="Score" value={score !== null ? `${score}%` : '—'}
|
||||
tone={score === null ? 'neutral' : score >= 80 ? 'ok' : score >= 60 ? 'warn' : 'bad'} />
|
||||
</div>
|
||||
|
||||
{emailStatus && (
|
||||
<div className={`text-sm px-3 py-2 rounded ${
|
||||
emailStatus === 'sent' ? 'bg-green-50 text-green-800' : 'bg-gray-100 text-gray-700'
|
||||
}`}>
|
||||
E-Mail: {emailStatus === 'sent' ? '✓ Gesendet an Empfänger' : emailStatus}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded p-3 text-xs text-blue-900">
|
||||
<strong>Wo welcher Inhalt steckt:</strong> in den Tabs oben findest du die
|
||||
Detail-Auswertung pro Doc-Typ. Im Cookie-Tab steht der 3-Quellen-Compliance-
|
||||
Vergleich (deklariert vs Browser vs Library) — das ist der wichtigste
|
||||
rechtliche Knackpunkt. Banner-Tab zeigt die echten Browser-Phasen-Checks.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Kpi({ label, value, tone = 'neutral' }: { label: string; value: any; tone?: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
ok: 'text-green-700 bg-green-50 border-green-200',
|
||||
warn: 'text-amber-700 bg-amber-50 border-amber-200',
|
||||
bad: 'text-red-700 bg-red-50 border-red-200',
|
||||
neutral: 'text-gray-700 bg-gray-50 border-gray-200',
|
||||
}
|
||||
return (
|
||||
<div className={`border rounded p-3 ${colors[tone]}`}>
|
||||
<div className="text-[10px] uppercase tracking-wider opacity-70">{label}</div>
|
||||
<div className="text-2xl font-bold mt-1">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Cookies & VVT ──────────────────────────────────────────────────────
|
||||
function CookiesTab({ audit, vendors, banner }: { audit: any; vendors: any[]; banner: any }) {
|
||||
const declared = audit?.declared_count ?? 0
|
||||
const browser = audit?.browser_count ?? 0
|
||||
const both = (audit?.compliant ?? []).length
|
||||
const undecl = (audit?.undeclared_in_browser ?? []).length
|
||||
const decOnly = (audit?.declared_not_loaded ?? []).length
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Top-Bar mit Counts */}
|
||||
<div className="grid grid-cols-3 md:grid-cols-5 gap-2">
|
||||
<Kpi label="Deklariert" value={declared} />
|
||||
<Kpi label="Im Browser" value={browser} />
|
||||
<Kpi label="Compliant" value={both} tone="ok" />
|
||||
<Kpi label="Undokumentiert" value={undecl} tone={undecl > 0 ? 'bad' : 'ok'} />
|
||||
<Kpi label="Nicht geladen" value={decOnly} tone={decOnly > 0 ? 'warn' : 'neutral'} />
|
||||
</div>
|
||||
|
||||
{/* 3-Spalten-Vergleichstabelle */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<CookieColumn
|
||||
title={`❌ Undokumentiert (${undecl})`}
|
||||
tone="bad"
|
||||
subtitle="Geladen ABER nicht in der Richtlinie — Art. 13(1)(c) DSGVO Verstoß"
|
||||
cookies={audit?.undeclared_in_browser ?? []}
|
||||
/>
|
||||
<CookieColumn
|
||||
title={`✓ Compliant (${both})`}
|
||||
tone="ok"
|
||||
subtitle="Beide Quellen stimmen überein"
|
||||
cookies={audit?.compliant ?? []}
|
||||
/>
|
||||
<CookieColumn
|
||||
title={`⚠️ Nicht geladen (${decOnly})`}
|
||||
tone="warn"
|
||||
subtitle="In Richtlinie deklariert, aber bei diesem Lauf nicht im Browser"
|
||||
cookies={audit?.declared_not_loaded ?? []}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Vendor-Liste (deduped) */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2 text-gray-800">
|
||||
Vendor-Liste ({vendors.length} unique nach Deduplizierung)
|
||||
</h3>
|
||||
<div className="overflow-x-auto border border-gray-200 rounded">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2">Vendor</th>
|
||||
<th className="text-left px-3 py-2">Kategorie</th>
|
||||
<th className="text-left px-3 py-2">Quelle</th>
|
||||
<th className="text-right px-3 py-2">Cookies</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vendors.map((v, i) => (
|
||||
<tr key={i} className="border-t border-gray-100 hover:bg-gray-50">
|
||||
<td className="px-3 py-2 font-medium">{v.name}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{v.category || '—'}</td>
|
||||
<td className="px-3 py-2 text-gray-500 font-mono text-[10px]">
|
||||
{v.source || '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{(v.cookies || []).length}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CookieColumn({ title, tone, subtitle, cookies }: {
|
||||
title: string; tone: string; subtitle: string; cookies: string[]
|
||||
}) {
|
||||
const colors: Record<string, string> = {
|
||||
bad: 'bg-red-50 border-red-200 text-red-900',
|
||||
ok: 'bg-green-50 border-green-200 text-green-900',
|
||||
warn: 'bg-amber-50 border-amber-200 text-amber-900',
|
||||
}
|
||||
return (
|
||||
<div className={`border rounded p-3 ${colors[tone]}`}>
|
||||
<div className="text-xs font-semibold mb-1">{title}</div>
|
||||
<div className="text-[10px] opacity-80 mb-2">{subtitle}</div>
|
||||
<div className="font-mono text-[10px] max-h-56 overflow-auto">
|
||||
{cookies.length === 0 && <span className="opacity-60">— keine —</span>}
|
||||
{cookies.map((c, i) => (
|
||||
<div key={i} className="py-0.5">{c}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Generic Doc-Tab ────────────────────────────────────────────────────
|
||||
function DocTab({ doc, label }: { doc: any; label: string }) {
|
||||
if (!doc) return <Empty label={label} />
|
||||
const checks = doc.checks || []
|
||||
const failed = checks.filter((c: any) => !c.passed && !c.skipped)
|
||||
const passed = checks.filter((c: any) => c.passed)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">{label}</h3>
|
||||
<div className="text-xs text-gray-600">
|
||||
{doc.word_count?.toLocaleString('de-DE') || 0} Wörter ·{' '}
|
||||
<span className="text-red-600">{failed.length} Findings</span> ·{' '}
|
||||
<span className="text-green-600">{passed.length} OK</span>
|
||||
</div>
|
||||
</div>
|
||||
{doc.url && (
|
||||
<a href={doc.url} target="_blank" rel="noreferrer"
|
||||
className="text-xs text-blue-600 hover:underline break-all">
|
||||
{doc.url}
|
||||
</a>
|
||||
)}
|
||||
<ChecklistView results={[doc]} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgbWiderrufTab({ docs }: { docs: Record<string, any> }) {
|
||||
const agb = docs['agb'] || docs['nutzungsbedingungen']
|
||||
const wid = docs['widerruf']
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">AGB / Nutzungsbedingungen</h3>
|
||||
{agb ? <ChecklistView results={[agb]} /> : <Empty label="AGB" inline />}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Widerrufsbelehrung</h3>
|
||||
{wid ? <ChecklistView results={[wid]} /> : <Empty label="Widerruf" inline />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BannerTab({ banner }: { banner: any }) {
|
||||
if (!banner || Object.keys(banner).length === 0) return <Empty label="Cookie-Banner" />
|
||||
const phases = banner.phases || {}
|
||||
const violations = banner.banner_checks?.violations || []
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-gray-700">
|
||||
Banner erkannt: <strong>{banner.banner_detected ? 'Ja' : 'Nein'}</strong> ·{' '}
|
||||
Provider: <strong>{banner.banner_provider || '—'}</strong> ·{' '}
|
||||
Verstöße: <strong>{violations.length}</strong>
|
||||
</div>
|
||||
{violations.length > 0 && (
|
||||
<div className="border border-red-200 bg-red-50 rounded p-3">
|
||||
<div className="text-xs font-semibold text-red-800 mb-2">Verstöße</div>
|
||||
<ul className="text-xs text-red-900 space-y-1">
|
||||
{violations.map((v: any, i: number) => (
|
||||
<li key={i}>• {v.label || v.message || JSON.stringify(v)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{Object.entries(phases).map(([name, ph]: [string, any]) => (
|
||||
<div key={name} className="border border-gray-200 rounded p-2">
|
||||
<div className="text-[10px] uppercase text-gray-500">{name}</div>
|
||||
<div className="text-xs mt-1">
|
||||
Cookies: <strong>{ph.cookies?.length || 0}</strong>
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
Vendors: <strong>{ph.vendors?.length || 0}</strong>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MailPreviewTab({ results }: { results: any }) {
|
||||
return (
|
||||
<div className="text-xs text-gray-600 space-y-2">
|
||||
<p>
|
||||
Die vollständige Mail wurde {results.email_status === 'sent' ? 'gesendet' : 'erstellt'}.
|
||||
Snapshot-ID:{' '}
|
||||
<code className="bg-gray-100 px-1.5 py-0.5 rounded">{results.check_id || '—'}</code>
|
||||
</p>
|
||||
{results.check_id && (
|
||||
<a
|
||||
href={`/api/compliance/agent/snapshots/${results.check_id}/pdf`}
|
||||
target="_blank" rel="noreferrer"
|
||||
className="inline-block text-purple-600 hover:underline"
|
||||
>
|
||||
→ PDF der Mail herunterladen
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Empty({ label, inline }: { label: string; inline?: boolean }) {
|
||||
return (
|
||||
<div className={`text-xs text-gray-500 ${inline ? '' : 'py-8 text-center'}`}>
|
||||
Keine Daten für „{label}" in diesem Lauf.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* P47 — localStorage-Quota-Management.
|
||||
*
|
||||
* Wenn alte Compliance-Check-Ergebnisse den Browser-Storage fuellen,
|
||||
* versucht das setItem mit QuotaExceededError zu fangen, prunet
|
||||
* alte doc-check-result-*-Eintraege (oldest first) und retried.
|
||||
*
|
||||
* Wird von DocCheckTab/BannerCheckTab/etc beim Persistieren der
|
||||
* Result-Bloebs benutzt.
|
||||
*/
|
||||
|
||||
const RESULT_KEY_PREFIX = 'doc-check-result-'
|
||||
const MAX_KEEP = 10 // Maximal 10 alte Result-Bloebs behalten.
|
||||
|
||||
export function safeSetItem(key: string, value: string): boolean {
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
return true
|
||||
} catch (err: any) {
|
||||
if (err?.name !== 'QuotaExceededError'
|
||||
&& err?.code !== 22 && err?.code !== 1014) {
|
||||
console.warn('localStorage setItem failed:', err)
|
||||
return false
|
||||
}
|
||||
pruneOldResults()
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
return true
|
||||
} catch {
|
||||
// Pruning hat nicht gereicht — aggressiver pruefen
|
||||
pruneOldResults(0)
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
return true
|
||||
} catch {
|
||||
console.warn('localStorage immer noch voll, wert wird verworfen')
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pruneOldResults(keep: number = MAX_KEEP): void {
|
||||
try {
|
||||
const keys: { key: string; ts: number }[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i)
|
||||
if (!k || !k.startsWith(RESULT_KEY_PREFIX)) continue
|
||||
const ts = Number(k.slice(RESULT_KEY_PREFIX.length)) || 0
|
||||
keys.push({ key: k, ts })
|
||||
}
|
||||
keys.sort((a, b) => a.ts - b.ts) // oldest first
|
||||
const toRemove = keys.slice(0, Math.max(0, keys.length - keep))
|
||||
for (const k of toRemove) {
|
||||
try { localStorage.removeItem(k.key) } catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function getStorageUsageMB(): number {
|
||||
let bytes = 0
|
||||
try {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i)
|
||||
if (!k) continue
|
||||
const v = localStorage.getItem(k) || ''
|
||||
bytes += k.length + v.length
|
||||
}
|
||||
} catch {}
|
||||
return bytes / (1024 * 1024)
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
type Phase = {
|
||||
cookies?: string[]
|
||||
scripts?: string[]
|
||||
tracking_services?: (string | { name?: string })[]
|
||||
new_tracking?: unknown[]
|
||||
violations?: Array<{ severity?: string; text?: string }>
|
||||
undocumented?: unknown[]
|
||||
}
|
||||
|
||||
type CategoryTest = {
|
||||
category: string
|
||||
category_label: string
|
||||
tracking_services?: (string | { name?: string })[]
|
||||
cookies_set?: string[]
|
||||
provider_details_visible?: boolean
|
||||
violations?: Array<{ severity?: string; text?: string; legal_ref?: string }>
|
||||
}
|
||||
|
||||
type BannerViolation = {
|
||||
severity?: string
|
||||
text?: string
|
||||
legal_ref?: string
|
||||
}
|
||||
|
||||
type StructuredCheck = {
|
||||
id: string
|
||||
label: string
|
||||
passed: boolean
|
||||
skipped?: boolean
|
||||
severity: string
|
||||
level?: number
|
||||
hint?: string
|
||||
}
|
||||
|
||||
type BannerResp = {
|
||||
found: boolean
|
||||
check_id: string
|
||||
banner?: {
|
||||
banner_provider?: string
|
||||
banner_detected?: boolean
|
||||
completeness_pct?: number
|
||||
correctness_pct?: number
|
||||
phases?: Record<string, Phase>
|
||||
banner_checks?: { violations?: BannerViolation[] }
|
||||
category_tests?: CategoryTest[]
|
||||
structured_checks?: StructuredCheck[]
|
||||
summary?: Record<string, number>
|
||||
}
|
||||
}
|
||||
|
||||
const PHASE_LABEL: Record<string, string> = {
|
||||
before_consent: 'Vor Consent',
|
||||
after_reject: 'Nach Ablehnung',
|
||||
after_accept: 'Nach Annahme',
|
||||
}
|
||||
|
||||
const SEV_BADGE: Record<string, string> = {
|
||||
CRITICAL: 'bg-red-600 text-white',
|
||||
HIGH: 'bg-red-100 text-red-800',
|
||||
MEDIUM: 'bg-amber-100 text-amber-800',
|
||||
LOW: 'bg-blue-100 text-blue-800',
|
||||
INFO: 'bg-gray-100 text-gray-600',
|
||||
}
|
||||
|
||||
function pctColor(pct?: number): string {
|
||||
if (pct === undefined || pct === null) return 'text-gray-400'
|
||||
return pct >= 80 ? 'text-green-700' : pct >= 50 ? 'text-amber-700' : 'text-red-700'
|
||||
}
|
||||
|
||||
export default function BannerTab({ checkId }: { checkId: string }) {
|
||||
const [data, setData] = useState<BannerResp | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [checkFilter, setCheckFilter] = useState<'all' | 'fail' | 'critical'>('fail')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
fetch(`/api/sdk/v1/agent/banner/${checkId}`)
|
||||
.then(r => r.json())
|
||||
.then(d => { if (!cancelled) setData(d) })
|
||||
.catch(e => { if (!cancelled) setError(String(e)) })
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [checkId])
|
||||
|
||||
if (loading) return <div className="p-6 text-sm text-gray-500">Lade Banner-Daten…</div>
|
||||
if (error) return <div className="p-6 text-sm text-red-600">Fehler: {error}</div>
|
||||
if (!data?.found || !data.banner) {
|
||||
return <div className="p-6 text-sm text-gray-500">Keine Banner-Daten zu diesem Check.</div>
|
||||
}
|
||||
|
||||
const b = data.banner
|
||||
const phases = b.phases || {}
|
||||
const cats = b.category_tests || []
|
||||
const violations = b.banner_checks?.violations || []
|
||||
const checks = b.structured_checks || []
|
||||
const summary = b.summary || {}
|
||||
|
||||
const filteredChecks = checks.filter(c => {
|
||||
if (checkFilter === 'all') return true
|
||||
if (checkFilter === 'fail') return !c.passed && !c.skipped
|
||||
return !c.passed && !c.skipped && ['CRITICAL', 'HIGH'].includes(c.severity)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Quality Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||
<div className="border rounded p-3">
|
||||
<div className="text-[10px] uppercase text-gray-500">Vollstaendigkeit</div>
|
||||
<div className={`text-2xl font-semibold ${pctColor(b.completeness_pct)}`}>
|
||||
{b.completeness_pct ?? '–'}{b.completeness_pct !== undefined && '%'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded p-3">
|
||||
<div className="text-[10px] uppercase text-gray-500">Korrektheit</div>
|
||||
<div className={`text-2xl font-semibold ${pctColor(b.correctness_pct)}`}>
|
||||
{b.correctness_pct ?? '–'}{b.correctness_pct !== undefined && '%'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded p-3">
|
||||
<div className="text-[10px] uppercase text-gray-500">Verstoesse</div>
|
||||
<div className="text-2xl font-semibold text-red-700">
|
||||
{summary.total_violations ?? violations.length}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500 mt-1">
|
||||
crit:{summary.critical ?? 0} · high:{summary.high ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded p-3">
|
||||
<div className="text-[10px] uppercase text-gray-500">CMP</div>
|
||||
<div className="text-sm font-medium text-gray-800 truncate">
|
||||
{b.banner_provider || 'unbekannt'}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500 mt-1">
|
||||
{b.banner_detected ? 'Banner erkannt' : 'kein Banner'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phases */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2 bg-gray-50 border-b text-sm font-medium text-gray-700">
|
||||
Cookie-Setzungen pro Phase (echter Browser-Test)
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Phase</th>
|
||||
<th className="px-3 py-2 text-center">Cookies</th>
|
||||
<th className="px-3 py-2 text-center">Tracker</th>
|
||||
<th className="px-3 py-2 text-left">Auffaelligkeiten</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(['before_consent', 'after_reject', 'after_accept'] as const).map(key => {
|
||||
const p = phases[key] || {}
|
||||
const nc = (p.cookies || []).length
|
||||
const nt = (p.tracking_services || []).length
|
||||
const issues: string[] = []
|
||||
if (p.violations?.length) issues.push(`${p.violations.length} Verstoss`)
|
||||
if (p.new_tracking?.length) issues.push(`${p.new_tracking.length} neue Tracker`)
|
||||
if (p.undocumented?.length) issues.push(`${p.undocumented.length} undokumentiert`)
|
||||
const color = key === 'before_consent'
|
||||
? (nc === 0 ? 'text-green-600' : 'text-red-600')
|
||||
: key === 'after_reject'
|
||||
? (nc <= 1 ? 'text-green-600' : 'text-amber-600')
|
||||
: 'text-gray-700'
|
||||
return (
|
||||
<tr key={key} className="border-t">
|
||||
<td className="px-3 py-2 font-medium">{PHASE_LABEL[key]}</td>
|
||||
<td className={`px-3 py-2 text-center font-semibold ${color}`}>{nc}</td>
|
||||
<td className="px-3 py-2 text-center">{nt}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{issues.join(', ') || '—'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Per-Category */}
|
||||
{cats.length > 0 && (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2 bg-gray-50 border-b text-sm font-medium text-gray-700">
|
||||
Provider-Listing pro Kategorie (P19 Click-Through-Test)
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Kategorie</th>
|
||||
<th className="px-3 py-2 text-center">Anbieter sichtbar</th>
|
||||
<th className="px-3 py-2 text-center">Tracker erkannt</th>
|
||||
<th className="px-3 py-2 text-left">Violations</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cats.map(c => {
|
||||
const pdv = c.provider_details_visible
|
||||
const pdv_label = pdv === true ? 'Ja' : pdv === false ? 'Nein' : '–'
|
||||
const pdv_color = pdv === false ? 'text-red-700' : pdv === true ? 'text-green-700' : 'text-gray-400'
|
||||
return (
|
||||
<tr key={c.category} className="border-t">
|
||||
<td className="px-3 py-2">{c.category_label}</td>
|
||||
<td className={`px-3 py-2 text-center font-semibold ${pdv_color}`}>{pdv_label}</td>
|
||||
<td className="px-3 py-2 text-center">{(c.tracking_services || []).length}</td>
|
||||
<td className="px-3 py-2 text-red-700 text-[10px]">
|
||||
{(c.violations || []).map(v => v.text?.slice(0, 80)).join('; ') || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Banner-Checks Violations */}
|
||||
{violations.length > 0 && (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2 bg-gray-50 border-b text-sm font-medium text-gray-700">
|
||||
Banner-Verstoesse ({violations.length})
|
||||
</div>
|
||||
<ul className="text-xs divide-y">
|
||||
{violations.map((v, i) => {
|
||||
const sev = (v.severity || 'MEDIUM').toUpperCase()
|
||||
return (
|
||||
<li key={i} className="px-3 py-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${SEV_BADGE[sev] || 'bg-gray-100'}`}>{sev}</span>
|
||||
<div>
|
||||
<div className="text-gray-900">{v.text}</div>
|
||||
{v.legal_ref && <div className="text-[10px] text-gray-400 italic mt-1">Quelle: {v.legal_ref}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 46 structured_checks Drilldown */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2 bg-gray-50 border-b text-sm font-medium text-gray-700 flex items-center gap-3">
|
||||
<span>Banner-Checks ({checks.length})</span>
|
||||
<div className="ml-auto flex gap-1">
|
||||
{(['all', 'fail', 'critical'] as const).map(f => (
|
||||
<button key={f}
|
||||
onClick={() => setCheckFilter(f)}
|
||||
className={`px-2 py-1 rounded text-[10px] border ${
|
||||
checkFilter === f ? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-white text-gray-600 border-gray-200'
|
||||
}`}>
|
||||
{f === 'all' ? 'Alle' : f === 'fail' ? 'Nur Fail' : 'Nur CRIT/HIGH'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Status</th>
|
||||
<th className="px-3 py-2 text-left">Sev</th>
|
||||
<th className="px-3 py-2 text-left">Check</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredChecks.map(c => (
|
||||
<tr key={c.id} className="border-t">
|
||||
<td className="px-3 py-2">
|
||||
{c.passed ? <span className="text-green-600">✓</span>
|
||||
: c.skipped ? <span className="text-gray-400">—</span>
|
||||
: <span className="text-red-600">✗</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${SEV_BADGE[c.severity] || 'bg-gray-100'}`}>
|
||||
{c.severity}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="text-gray-900">{c.label}</div>
|
||||
{c.hint && !c.passed && (
|
||||
<div className="text-[10px] text-gray-500 mt-1">{c.hint.slice(0, 200)}</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredChecks.length === 0 && (
|
||||
<tr><td colSpan={3} className="px-3 py-4 text-center text-gray-400">Keine Checks fuer den Filter.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
type Finding = {
|
||||
id: number
|
||||
source_type: string
|
||||
doc_type: string
|
||||
severity: string
|
||||
status: string
|
||||
regulation: string
|
||||
label: string
|
||||
hint: string
|
||||
action_recipe: Record<string, string>
|
||||
anchor_excerpt: string
|
||||
anchor_conf: number
|
||||
vendor_name: string
|
||||
category: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
type Summary = {
|
||||
total: number
|
||||
by_source: Record<string, number>
|
||||
by_severity: Record<string, number>
|
||||
by_status: Record<string, number>
|
||||
by_doc_type: Record<string, number>
|
||||
}
|
||||
|
||||
type Resp = {
|
||||
found: boolean
|
||||
summary: Summary
|
||||
count: number
|
||||
findings: Finding[]
|
||||
}
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
all: 'Alle Quellen',
|
||||
mc: 'Master-Controls',
|
||||
pflichtangabe: 'Pflichtangaben',
|
||||
vendor: 'Vendor-Findings',
|
||||
redundanz: 'Redundanzen',
|
||||
}
|
||||
|
||||
const SEVERITY_COLOR: Record<string, string> = {
|
||||
CRITICAL: 'bg-red-600 text-white',
|
||||
HIGH: 'bg-red-100 text-red-800',
|
||||
MEDIUM: 'bg-amber-100 text-amber-800',
|
||||
LOW: 'bg-blue-100 text-blue-800',
|
||||
INFO: 'bg-gray-100 text-gray-600',
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
failed: 'Fail',
|
||||
passed: 'Pass',
|
||||
skipped: 'Skip',
|
||||
na: 'N/A',
|
||||
info: 'Info',
|
||||
}
|
||||
|
||||
const SEVERITY_OPTS = ['all', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO']
|
||||
const STATUS_OPTS = ['all', 'failed', 'passed', 'skipped', 'na', 'info']
|
||||
|
||||
export default function FindingsTab({ checkId }: { checkId: string }) {
|
||||
const [data, setData] = useState<Resp | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [source, setSource] = useState('all')
|
||||
const [severity, setSeverity] = useState('all')
|
||||
const [docType, setDocType] = useState('all')
|
||||
const [status, setStatus] = useState('failed')
|
||||
const [q, setQ] = useState('')
|
||||
const [expanded, setExpanded] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
const qs = new URLSearchParams({
|
||||
source, severity, doc_type: docType, status, q, limit: '1500',
|
||||
}).toString()
|
||||
fetch(`/api/sdk/v1/agent/findings/${checkId}?${qs}`)
|
||||
.then(r => r.json())
|
||||
.then(d => { if (!cancelled) setData(d) })
|
||||
.catch(e => { if (!cancelled) setError(String(e)) })
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [checkId, source, severity, docType, status, q])
|
||||
|
||||
const docTypes = useMemo(
|
||||
() => Object.keys(data?.summary?.by_doc_type ?? {}).filter(d => d !== '-').sort(),
|
||||
[data],
|
||||
)
|
||||
|
||||
const csvExport = () => {
|
||||
const rows = data?.findings ?? []
|
||||
const head = ['Quelle', 'Doc', 'Severity', 'Status', 'Regulation', 'Label', 'Vendor', 'Hint']
|
||||
const lines = [head.join(',')]
|
||||
for (const r of rows) {
|
||||
const cells = [
|
||||
r.source_type, r.doc_type, r.severity, r.status,
|
||||
r.regulation, r.label, r.vendor_name, r.hint,
|
||||
].map(c => `"${String(c ?? '').replace(/"/g, '""').replace(/\n/g, ' ')}"`)
|
||||
lines.push(cells.join(','))
|
||||
}
|
||||
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `findings-${checkId}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
if (loading && !data) return <div className="p-6 text-sm text-gray-500">Lade Voll-Audit…</div>
|
||||
if (error) return <div className="p-6 text-sm text-red-600">Fehler: {error}</div>
|
||||
if (!data?.found) {
|
||||
return (
|
||||
<div className="p-6 text-sm text-gray-500">
|
||||
Keine unified findings für diesen Run gespeichert (alter Run vor P5?).
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sum = data.summary
|
||||
const findings = data.findings
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||
{Object.entries(SOURCE_LABEL).filter(([k]) => k !== 'all').map(([k, label]) => {
|
||||
const count = sum.by_source?.[k] ?? 0
|
||||
return (
|
||||
<button key={k}
|
||||
onClick={() => setSource(source === k ? 'all' : k)}
|
||||
className={`text-left rounded-lg border px-3 py-2 transition ${
|
||||
source === k
|
||||
? 'border-blue-500 bg-blue-50 text-blue-900'
|
||||
: 'border-gray-200 hover:border-gray-300 bg-white'
|
||||
}`}>
|
||||
<div className="text-[10px] uppercase tracking-wide text-gray-500">{label}</div>
|
||||
<div className="text-lg font-semibold">{count}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Filter row */}
|
||||
<div className="flex flex-wrap gap-2 items-center text-xs">
|
||||
<select value={severity} onChange={e => setSeverity(e.target.value)}
|
||||
className="border border-gray-200 rounded px-2 py-1">
|
||||
{SEVERITY_OPTS.map(s => (
|
||||
<option key={s} value={s}>
|
||||
{s === 'all' ? 'Alle Severities' : s}
|
||||
{s !== 'all' && sum.by_severity?.[s] != null ? ` (${sum.by_severity[s]})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={status} onChange={e => setStatus(e.target.value)}
|
||||
className="border border-gray-200 rounded px-2 py-1">
|
||||
{STATUS_OPTS.map(s => (
|
||||
<option key={s} value={s}>
|
||||
{s === 'all' ? 'Alle Status' : STATUS_LABEL[s] ?? s}
|
||||
{s !== 'all' && sum.by_status?.[s] != null ? ` (${sum.by_status[s]})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={docType} onChange={e => setDocType(e.target.value)}
|
||||
className="border border-gray-200 rounded px-2 py-1">
|
||||
<option value="all">Alle Doc-Types</option>
|
||||
{docTypes.map(d => (
|
||||
<option key={d} value={d}>{d} ({sum.by_doc_type?.[d] ?? 0})</option>
|
||||
))}
|
||||
</select>
|
||||
<input value={q} onChange={e => setQ(e.target.value)}
|
||||
placeholder="Suche Label / Anbieter…"
|
||||
className="border border-gray-200 rounded px-2 py-1 min-w-[180px]" />
|
||||
<button onClick={csvExport}
|
||||
className="ml-auto border border-gray-200 hover:border-gray-300 rounded px-2 py-1">
|
||||
CSV exportieren
|
||||
</button>
|
||||
<span className="text-gray-500">{data.count} Treffer</span>
|
||||
</div>
|
||||
|
||||
{/* Findings table */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Quelle</th>
|
||||
<th className="px-3 py-2 text-left">Doc</th>
|
||||
<th className="px-3 py-2 text-left">Sev</th>
|
||||
<th className="px-3 py-2 text-left">Status</th>
|
||||
<th className="px-3 py-2 text-left">Finding</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{findings.map(f => (
|
||||
<React.Fragment key={f.id}>
|
||||
<tr className="border-t cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => setExpanded(expanded === f.id ? null : f.id)}>
|
||||
<td className="px-3 py-2 text-gray-500 capitalize">{f.source_type}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{f.doc_type === '-' ? '—' : f.doc_type}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-medium ${
|
||||
SEVERITY_COLOR[f.severity] || 'bg-gray-100'
|
||||
}`}>{f.severity}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-600">{STATUS_LABEL[f.status] ?? f.status}</td>
|
||||
<td className="px-3 py-2 text-gray-900">
|
||||
{f.label}
|
||||
{f.vendor_name && (
|
||||
<span className="ml-2 text-[10px] text-gray-400">
|
||||
· {f.vendor_name}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
const rl = String(f.payload?.risk_label ?? '')
|
||||
if (!rl) return null
|
||||
const cls = rl === 'kritisch' ? 'bg-red-600 text-white' :
|
||||
rl === 'hoch' ? 'bg-red-100 text-red-800' :
|
||||
rl === 'mittel' ? 'bg-amber-100 text-amber-800' :
|
||||
rl === 'gering' ? 'bg-green-50 text-green-700' :
|
||||
'bg-gray-100 text-gray-500'
|
||||
return <span className={`ml-2 px-1.5 py-0.5 rounded text-[10px] font-medium ${cls}`}>Risk: {rl}</span>
|
||||
})()}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded === f.id && (
|
||||
<tr className="bg-gray-50/50">
|
||||
<td colSpan={5} className="px-3 py-3 text-xs space-y-2">
|
||||
{f.hint && (
|
||||
<div className="text-gray-700">{f.hint}</div>
|
||||
)}
|
||||
{f.action_recipe?.fix_text && (
|
||||
<div className="bg-amber-50 border-l-2 border-amber-300 pl-3 py-2">
|
||||
<div className="font-medium text-amber-800 mb-1">Empfehlung</div>
|
||||
<div className="whitespace-pre-line text-amber-900">
|
||||
{f.action_recipe.fix_text}
|
||||
</div>
|
||||
{f.action_recipe.where && (
|
||||
<div className="text-[10px] text-amber-700 mt-1">
|
||||
Einfuegen in: {f.action_recipe.where}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{f.anchor_excerpt && (
|
||||
<div className="bg-blue-50 border-l-2 border-blue-300 pl-3 py-2">
|
||||
<div className="font-medium text-blue-800 mb-1">
|
||||
Fundstelle im Dokument (Konfidenz {Math.round((f.anchor_conf || 0) * 100)}%)
|
||||
</div>
|
||||
<div className="italic text-blue-900">"{f.anchor_excerpt}"</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10px] text-gray-400">
|
||||
Source: {f.source_type} · Regulation: {f.regulation || '—'}
|
||||
{f.category && ` · Kategorie: ${f.category}`}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{findings.length === 0 && (
|
||||
<tr><td colSpan={5} className="px-3 py-6 text-center text-gray-400">
|
||||
Keine Findings fuer die aktuellen Filter.
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, useMemo } from 'react'
|
||||
import { use as useUnwrap } from 'react'
|
||||
import FindingsTab from './FindingsTab'
|
||||
import BannerTab from './BannerTab'
|
||||
|
||||
type MCRow = {
|
||||
id: number
|
||||
doc_type: string
|
||||
mc_id: string
|
||||
label: string
|
||||
passed: number
|
||||
skipped: number
|
||||
severity: string
|
||||
regulation: string
|
||||
matched_text: string
|
||||
hint: string
|
||||
}
|
||||
|
||||
type ScorecardRow = {
|
||||
regulation: string
|
||||
total: number
|
||||
passed: number
|
||||
failed: number
|
||||
skipped: number
|
||||
pct: number
|
||||
severity: Record<string, number>
|
||||
}
|
||||
|
||||
type AuditResponse = {
|
||||
found: boolean
|
||||
run?: {
|
||||
check_id: string
|
||||
ts: string
|
||||
site_name: string
|
||||
base_domain: string
|
||||
doc_count: number
|
||||
scorecard: { by_regulation: ScorecardRow[]; totals: any }
|
||||
vvt_summary: { total?: number; internal?: number; external?: number }
|
||||
}
|
||||
mc_count?: number
|
||||
results?: MCRow[]
|
||||
}
|
||||
|
||||
// P8: MC-Audit ist eine Checkliste, KEINE Severity-Drohung. Statt
|
||||
// rotem HIGH-Badge zeigen wir die Quellen-Prioritaet (Gesetz vs.
|
||||
// Behoerden-Leitlinie vs. Best-Practice) und einen 3-Tier-Status
|
||||
// (erfuellt / nicht erfuellt / selbst pruefen).
|
||||
|
||||
const PRIORITY_BADGE: Record<string, string> = {
|
||||
Gesetz: 'bg-slate-800 text-white',
|
||||
'Behoerden-Leitlinie': 'bg-blue-100 text-blue-800',
|
||||
'Best-Practice': 'bg-gray-100 text-gray-600',
|
||||
'—': 'bg-gray-50 text-gray-400',
|
||||
}
|
||||
|
||||
function regulationToPriority(reg: string): keyof typeof PRIORITY_BADGE {
|
||||
const r = (reg || '').toLowerCase()
|
||||
if (/dsgvo|gdpr|eprivacy|tdddg|tkg|bdsg|ttdsg/.test(r)) return 'Gesetz'
|
||||
if (/edpb|dsk|cnil|lfdi|eugh|orientierungshilfe|leitlinie|guideline/.test(r))
|
||||
return 'Behoerden-Leitlinie'
|
||||
if (/iso|nist|bsi|cobit|sox/.test(r)) return 'Best-Practice'
|
||||
return '—'
|
||||
}
|
||||
|
||||
const _CONDITIONAL_RE = /\b(falls|sofern|wenn|soweit|ggf\.|gegebenenfalls)\b/i
|
||||
|
||||
function rowReviewStatus(r: MCRow): 'pass' | 'fail' | 'review' | 'na' {
|
||||
if (r.passed) return 'pass'
|
||||
if (r.skipped) return 'na'
|
||||
// failed: harter Fail nur bei matched_text-Beleg ODER nicht-konditionalem Label
|
||||
if (!r.matched_text && _CONDITIONAL_RE.test(r.label || '')) return 'review'
|
||||
return 'fail'
|
||||
}
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ value: 'all', label: 'Alle' },
|
||||
{ value: 'fail', label: 'Nicht erfuellt' },
|
||||
{ value: 'review', label: 'Selbst pruefen' },
|
||||
{ value: 'pass', label: 'Erfuellt' },
|
||||
{ value: 'na', label: 'Nicht anwendbar' },
|
||||
] as const
|
||||
|
||||
export default function AuditPage(
|
||||
{ params }: { params: Promise<{ checkId: string }> },
|
||||
) {
|
||||
const { checkId } = useUnwrap(params)
|
||||
const [data, setData] = useState<AuditResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filterStatus, setFilterStatus] = useState<typeof STATUS_FILTERS[number]['value']>('fail')
|
||||
const [filterReg, setFilterReg] = useState<string>('')
|
||||
const [filterDoc, setFilterDoc] = useState<string>('')
|
||||
const [expanded, setExpanded] = useState<number | null>(null)
|
||||
const [tab, setTab] = useState<'mc' | 'all' | 'banner'>('all')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
fetch(`/api/sdk/v1/agent/audit/${checkId}`)
|
||||
.then(r => r.json())
|
||||
.then(d => { if (!cancelled) setData(d) })
|
||||
.catch(e => { if (!cancelled) setError(String(e)) })
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [checkId])
|
||||
|
||||
const allRows = data?.results ?? []
|
||||
const docTypes = useMemo(
|
||||
() => Array.from(new Set(allRows.map(r => r.doc_type))).sort(),
|
||||
[allRows],
|
||||
)
|
||||
const regulations = useMemo(
|
||||
() => Array.from(new Set(allRows.map(r => r.regulation).filter(Boolean))).sort(),
|
||||
[allRows],
|
||||
)
|
||||
|
||||
const filtered = allRows.filter(r => {
|
||||
if (filterStatus !== 'all' && rowReviewStatus(r) !== filterStatus) return false
|
||||
if (filterReg && r.regulation !== filterReg) return false
|
||||
if (filterDoc && r.doc_type !== filterDoc) return false
|
||||
return true
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-6 text-sm text-gray-500">Lade Audit…</div>
|
||||
}
|
||||
if (error || !data?.found) {
|
||||
return (
|
||||
<div className="p-6 text-sm text-red-600">
|
||||
Audit nicht gefunden{error ? `: ${error}` : ''}.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const run = data.run!
|
||||
const scorecard = run.scorecard?.by_regulation ?? []
|
||||
const totals = run.scorecard?.totals ?? { total: 0, passed: 0, failed: 0, pct: 0 }
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6 max-w-6xl">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">
|
||||
MC-Audit: {run.site_name}
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
check_id <code className="bg-gray-100 px-1 rounded">{checkId}</code> ·{' '}
|
||||
{new Date(run.ts).toLocaleString('de-DE')} · {run.doc_count} Dokumente ·{' '}
|
||||
{data.mc_count} MC-Eintraege
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div className="flex gap-2 border-b border-gray-200">
|
||||
{([
|
||||
{ key: 'all', label: 'Voll-Audit (alle Findings)' },
|
||||
{ key: 'banner', label: 'Cookie-Banner-Analyse' },
|
||||
{ key: 'mc', label: 'Nur MC-Scorecard' },
|
||||
] as const).map(t => (
|
||||
<button key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm border-b-2 -mb-px transition ${
|
||||
tab === t.key
|
||||
? 'border-blue-600 text-blue-700 font-medium'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'all' && <FindingsTab checkId={checkId} />}
|
||||
{tab === 'banner' && <BannerTab checkId={checkId} />}
|
||||
|
||||
{tab === 'mc' && <>
|
||||
{/* Scorecard */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-3 bg-blue-50 border-b border-blue-100">
|
||||
<h2 className="text-sm font-medium text-blue-900">
|
||||
Compliance-Scorecard nach Regulation
|
||||
<span className="ml-2 text-blue-700 font-semibold text-base">
|
||||
{totals.pct}%
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-blue-600">
|
||||
({totals.passed} bestanden, {totals.failed} Fail,{' '}
|
||||
{totals.skipped} skipped — {totals.total} gesamt)
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Regulation</th>
|
||||
<th className="px-3 py-2 text-center">Passed</th>
|
||||
<th className="px-3 py-2 text-center">Failed</th>
|
||||
<th className="px-3 py-2 text-center">HIGH</th>
|
||||
<th className="px-3 py-2 text-center">MEDIUM</th>
|
||||
<th className="px-3 py-2 text-right">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scorecard.map(row => (
|
||||
<tr key={row.regulation} className="border-t hover:bg-blue-50/30 cursor-pointer"
|
||||
onClick={() => setFilterReg(row.regulation === filterReg ? '' : row.regulation)}>
|
||||
<td className="px-3 py-2 font-medium">{row.regulation}</td>
|
||||
<td className="px-3 py-2 text-center text-green-700">{row.passed}</td>
|
||||
<td className="px-3 py-2 text-center text-red-700">{row.failed}</td>
|
||||
<td className="px-3 py-2 text-center text-red-700">
|
||||
{(row.severity.HIGH || 0) + (row.severity.CRITICAL || 0)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center text-amber-700">
|
||||
{row.severity.MEDIUM || 0}
|
||||
</td>
|
||||
<td className={`px-3 py-2 text-right font-semibold ${
|
||||
row.pct >= 80 ? 'text-green-700' :
|
||||
row.pct >= 50 ? 'text-amber-700' : 'text-red-700'
|
||||
}`}>{row.pct}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3 items-center text-xs">
|
||||
<div className="flex gap-1">
|
||||
{STATUS_FILTERS.map(f => (
|
||||
<button key={f.value}
|
||||
onClick={() => setFilterStatus(f.value)}
|
||||
className={`px-2.5 py-1 rounded-full border ${
|
||||
filterStatus === f.value
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||
}`}>{f.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<select value={filterDoc} onChange={e => setFilterDoc(e.target.value)}
|
||||
className="border border-gray-200 rounded px-2 py-1">
|
||||
<option value="">Alle Doc-Types</option>
|
||||
{docTypes.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
<select value={filterReg} onChange={e => setFilterReg(e.target.value)}
|
||||
className="border border-gray-200 rounded px-2 py-1">
|
||||
<option value="">Alle Regulations</option>
|
||||
{regulations.map(r => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
<span className="text-gray-500">
|
||||
{filtered.length} von {allRows.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Status</th>
|
||||
<th className="px-3 py-2 text-left">Doc</th>
|
||||
<th className="px-3 py-2 text-left">Regulation</th>
|
||||
<th className="px-3 py-2 text-left">MC</th>
|
||||
<th className="px-3 py-2 text-left">Prioritaet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(row => (
|
||||
<React.Fragment key={row.id}>
|
||||
<tr className="border-t cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => setExpanded(expanded === row.id ? null : row.id)}>
|
||||
<td className="px-3 py-2">
|
||||
{(() => {
|
||||
const st = rowReviewStatus(row)
|
||||
if (st === 'pass') return <span className="text-green-600" title="Erfuellt">✓</span>
|
||||
if (st === 'na') return <span className="text-gray-400" title="Nicht anwendbar">—</span>
|
||||
if (st === 'review') return <span className="text-amber-600" title="Selbst pruefen">?</span>
|
||||
return <span className="text-red-600" title="Nicht erfuellt">✗</span>
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-700">{row.doc_type}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{row.regulation || '—'}</td>
|
||||
<td className="px-3 py-2 text-gray-900">{row.label}</td>
|
||||
<td className="px-3 py-2">
|
||||
{(() => {
|
||||
const prio = regulationToPriority(row.regulation)
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-medium ${PRIORITY_BADGE[prio]}`}>
|
||||
{prio}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded === row.id && (
|
||||
<tr className="bg-gray-50/50">
|
||||
<td colSpan={5} className="px-3 py-3 text-xs">
|
||||
<div className="text-gray-500 mb-1">
|
||||
MC-ID: <code>{row.mc_id}</code>
|
||||
</div>
|
||||
{row.matched_text && (
|
||||
<div className="mb-2">
|
||||
<span className="text-green-700 font-medium">Treffer: </span>
|
||||
<span className="font-mono text-gray-700">
|
||||
"{row.matched_text}"
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{row.hint && (
|
||||
<div className="text-amber-700 bg-amber-50 border-l-2 border-amber-200 pl-2 py-1">
|
||||
{row.hint}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-6 text-center text-gray-400">
|
||||
Keine MCs entsprechen den aktuellen Filtern.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Props {
|
||||
/** Risk classification of the AI system. Tile is only rendered for high_risk / unacceptable. */
|
||||
riskLevel: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a tile pointing to the BSI QUAIDAL-based data-quality control tab.
|
||||
* AI Act Article 10 obligations (training-data quality) apply only to high-risk
|
||||
* systems, so the tile is skipped for limited / minimal / not-applicable classes.
|
||||
*/
|
||||
export function Art10Tile({ riskLevel }: Props) {
|
||||
if (riskLevel !== 'high_risk' && riskLevel !== 'unacceptable') return null
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/sdk/quality?category=data_quality"
|
||||
className="block mt-3 p-3 rounded-lg border border-purple-200 bg-purple-50 hover:bg-purple-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-purple-200 text-purple-700 flex items-center justify-center shrink-0">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V7M3 7l9 6 9-6M3 7l9-4 9 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-purple-900">
|
||||
Art. 10 Datenqualität (Hochrisiko-KI)
|
||||
</div>
|
||||
<div className="text-xs text-purple-700 mt-0.5">
|
||||
BSI QUAIDAL Controls: 10 Kriterien, 15 Bausteine, 30 Maßnahmen, 140 Metriken.
|
||||
Klicken zum Öffnen des Trainingsdaten-Qualität-Moduls.
|
||||
</div>
|
||||
</div>
|
||||
<svg className="w-4 h-4 text-purple-500 shrink-0 mt-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { RiskPyramid } from './_components/RiskPyramid'
|
||||
import { AddSystemForm } from './_components/AddSystemForm'
|
||||
import { AISystemCard } from './_components/AISystemCard'
|
||||
import DecisionTreeWizard from '@/components/sdk/ai-act/DecisionTreeWizard'
|
||||
import { Art10Tile } from './_components/Art10Tile'
|
||||
|
||||
type TabId = 'overview' | 'decision-tree' | 'results'
|
||||
|
||||
@@ -136,6 +137,7 @@ function SavedResultsTab() {
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
<Art10Tile riskLevel={r.high_risk_result} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -360,6 +362,16 @@ export default function AIActPage() {
|
||||
)}
|
||||
</StepHeader>
|
||||
|
||||
<div className="px-4 py-2 bg-emerald-50 border border-emerald-200 rounded-lg text-xs text-emerald-800 flex items-start gap-2">
|
||||
<span className="font-semibold">Quellen & Lizenz:</span>
|
||||
<span>
|
||||
Inhalte gemaess <strong>EU-Verordnung 2024/1689 (KI-Verordnung / AI Act)</strong> —
|
||||
Lizenzregel R1 (EU_LAW, woertlich uebernehmbar).
|
||||
Risiko-Klassifizierungslogik basiert auf Anhang III der Verordnung.{' '}
|
||||
<a href="/sdk/licenses" className="underline">Quellenverzeichnis</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg w-fit">
|
||||
{TABS.map(tab => (
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CATEGORY_OPTIONS,
|
||||
} from '../control-library/components/helpers'
|
||||
import { ControlDetail } from '../control-library/components/ControlDetail'
|
||||
import { SourceBadge } from '@/components/sdk/SourceBadge'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
@@ -310,6 +311,7 @@ export default function AtomicControlsPage() {
|
||||
<TargetAudienceBadge audience={ctrl.target_audience} />
|
||||
<GenerationStrategyBadge strategy={ctrl.generation_strategy} pipelineInfo={ctrl} />
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
<SourceBadge controlUuid={ctrl.id} compact />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-900 group-hover:text-violet-700">{ctrl.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{ctrl.objective}</p>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import { LicenseModuleBanner } from '@/components/sdk/LicenseModuleBanner'
|
||||
import { useAuditChecklist } from './_hooks/useAuditChecklist'
|
||||
import { ChecklistItemCard } from './_components/ChecklistItemCard'
|
||||
import { LoadingSkeleton } from './_components/LoadingSkeleton'
|
||||
@@ -89,6 +90,12 @@ export default function AuditChecklistPage() {
|
||||
</div>
|
||||
</StepHeader>
|
||||
|
||||
<LicenseModuleBanner
|
||||
rule={3}
|
||||
sourceLabel="BreakPilot-Audit-Methodik"
|
||||
detail="Eigene Audit-Checklisten und -Workflows. Zitierte Rechtsquellen (DSGVO/ISO 27001/...) jeweils mit eigener Lizenzregel."
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface HistoryEntry {
|
||||
cid: string
|
||||
version: string | null
|
||||
document_type: string | null
|
||||
document_id: string | null
|
||||
parent_cid: string | null
|
||||
created_at: string | null
|
||||
checksum: string | null
|
||||
}
|
||||
|
||||
interface DiffResponse {
|
||||
kind: 'text' | 'binary'
|
||||
cid_a: string
|
||||
cid_b: string
|
||||
metadata_diff: Record<string, { old: unknown; new: unknown }>
|
||||
diff?: string
|
||||
added_lines?: number
|
||||
removed_lines?: number
|
||||
note?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
cid: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function shorten(cid: string): string {
|
||||
if (cid.length <= 14) return cid
|
||||
return cid.slice(0, 8) + '…' + cid.slice(-6)
|
||||
}
|
||||
|
||||
export default function CIDHistoryModal({ cid, onClose }: Props) {
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [diffPair, setDiffPair] = useState<{ a: string; b: string } | null>(null)
|
||||
const [diff, setDiff] = useState<DiffResponse | null>(null)
|
||||
const [diffLoading, setDiffLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancel = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
fetch(`/api/sdk/v1/dsms/documents/${encodeURIComponent(cid)}/history`)
|
||||
.then(async (r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
const json = await r.json()
|
||||
if (!cancel) setHistory(json.history || [])
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancel) setError(e?.message || 'Fehler beim Laden')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancel) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancel = true
|
||||
}
|
||||
}, [cid])
|
||||
|
||||
async function loadDiff(a: string, b: string) {
|
||||
setDiffPair({ a, b })
|
||||
setDiff(null)
|
||||
setDiffLoading(true)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/sdk/v1/dsms/documents/${encodeURIComponent(a)}/diff/${encodeURIComponent(b)}`
|
||||
)
|
||||
if (res.ok) {
|
||||
const json = (await res.json()) as DiffResponse
|
||||
setDiff(json)
|
||||
} else {
|
||||
setDiff({ kind: 'binary', cid_a: a, cid_b: b, metadata_diff: {}, note: `HTTP ${res.status}` })
|
||||
}
|
||||
} finally {
|
||||
setDiffLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
|
||||
<div
|
||||
className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col bg-white dark:bg-gray-800 rounded-xl shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">DSMS-Versionsverlauf</h2>
|
||||
<code className="text-[10px] font-mono text-gray-500 dark:text-gray-400">{shorten(cid)}</code>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 dark:text-gray-400 text-sm">
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-4">
|
||||
{loading && <div className="text-sm text-gray-500">Verlauf wird geladen…</div>}
|
||||
{error && <div className="text-sm text-red-600 dark:text-red-400">{error}</div>}
|
||||
|
||||
{!loading && !error && history.length === 0 && (
|
||||
<div className="text-sm text-gray-500 italic">
|
||||
Kein Versionsverlauf gefunden. Diese CID hat keine parent_cid-Kette.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && history.length > 0 && (
|
||||
<>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{history.length} Version{history.length > 1 ? 'en' : ''} in der Kette (neueste oben).
|
||||
</div>
|
||||
<ol className="relative border-l-2 border-emerald-500/40 pl-4 space-y-3">
|
||||
{history.map((entry, idx) => {
|
||||
const next = history[idx + 1]
|
||||
return (
|
||||
<li key={entry.cid} className="relative">
|
||||
<div className="absolute -left-[1.4rem] top-1.5 w-3 h-3 rounded-full bg-emerald-500 ring-2 ring-white dark:ring-gray-800" />
|
||||
<div className="bg-gray-50 dark:bg-gray-900/40 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Version {entry.version || '?'} {idx === 0 && <span className="ml-2 text-[10px] text-emerald-600 font-semibold">AKTUELL</span>}
|
||||
</div>
|
||||
<code className="text-[10px] font-mono text-gray-500 dark:text-gray-400 break-all">{entry.cid}</code>
|
||||
</div>
|
||||
{next && (
|
||||
<button
|
||||
onClick={() => loadDiff(next.cid, entry.cid)}
|
||||
className="shrink-0 text-[11px] text-purple-600 hover:text-purple-800 dark:text-purple-400 hover:underline"
|
||||
title="Aenderungen zur Vorversion anzeigen"
|
||||
>
|
||||
Diff zu V{next.version || '?'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400 flex flex-wrap gap-x-3 gap-y-0.5">
|
||||
{entry.document_type && <span>Typ: {entry.document_type}</span>}
|
||||
{entry.document_id && <span>Dok-ID: {entry.document_id}</span>}
|
||||
{entry.created_at && <span>{new Date(entry.created_at).toLocaleString('de-DE')}</span>}
|
||||
</div>
|
||||
{entry.checksum && (
|
||||
<div className="mt-1 text-[10px] text-gray-400 font-mono">SHA-256: {entry.checksum.slice(0, 16)}…</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
|
||||
{diffPair && (
|
||||
<div className="mt-4 border-t border-gray-200 dark:border-gray-700 pt-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold text-gray-900 dark:text-white">
|
||||
Diff: {shorten(diffPair.a)} → {shorten(diffPair.b)}
|
||||
</h3>
|
||||
<button onClick={() => { setDiff(null); setDiffPair(null) }} className="text-[11px] text-gray-500 hover:text-gray-700">
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
{diffLoading && <div className="text-xs text-gray-500">Diff wird geladen…</div>}
|
||||
{!diffLoading && diff && (
|
||||
<>
|
||||
{Object.keys(diff.metadata_diff || {}).length > 0 && (
|
||||
<div className="text-xs">
|
||||
<div className="font-medium text-gray-700 dark:text-gray-300 mb-1">Metadaten-Aenderungen</div>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{Object.entries(diff.metadata_diff).map(([field, { old, new: nv }]) => (
|
||||
<tr key={field} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="py-0.5 pr-2 font-mono text-[10px] text-gray-500">{field}</td>
|
||||
<td className="py-0.5 pr-2 text-red-600 dark:text-red-400 line-through">{JSON.stringify(old)}</td>
|
||||
<td className="py-0.5 text-green-700 dark:text-green-400">{JSON.stringify(nv)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{diff.kind === 'text' && diff.diff && (
|
||||
<>
|
||||
<div className="text-[11px] text-gray-500">
|
||||
{diff.added_lines ?? 0} Zeilen hinzu, {diff.removed_lines ?? 0} entfernt
|
||||
</div>
|
||||
<pre className="text-[10px] font-mono whitespace-pre-wrap bg-gray-900 text-gray-100 p-3 rounded max-h-64 overflow-y-auto">
|
||||
{diff.diff}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
{diff.kind === 'binary' && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-400 italic">
|
||||
{diff.note || 'Binaere Datei — kein Text-Diff verfuegbar.'}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useAuditTimeline, type AuditEntry } from './_hooks/useAuditTimeline'
|
||||
import CIDHistoryModal from './_components/CIDHistoryModal'
|
||||
|
||||
const ENTITY_LABELS: Record<string, string> = {
|
||||
evidence: 'Nachweis', control: 'Control', document: 'Dokument',
|
||||
@@ -16,8 +18,24 @@ const ACTION_COLORS: Record<string, string> = {
|
||||
|
||||
const FILTER_OPTIONS = ['all', 'evidence', 'dsms_archive', 'control', 'document', 'dsfa', 'vvt', 'tom']
|
||||
|
||||
// new_value may be a plain CID (from Python evidence flow) or a JSON envelope
|
||||
// {"cid":"X","filename":"...","size":"..."} (from the Go IACE tech-file flow).
|
||||
function extractCID(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
if (typeof parsed.cid === 'string') return parsed.cid
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export default function AuditTimelinePage() {
|
||||
const { entries, loading, filter, setFilter } = useAuditTimeline()
|
||||
const [historyCID, setHistoryCID] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
@@ -58,16 +76,18 @@ export default function AuditTimelinePage() {
|
||||
|
||||
<div className="space-y-4">
|
||||
{entries.map((entry) => (
|
||||
<TimelineEntry key={entry.id} entry={entry} />
|
||||
<TimelineEntry key={entry.id} entry={entry} onShowHistory={setHistoryCID} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{historyCID && <CIDHistoryModal cid={historyCID} onClose={() => setHistoryCID(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineEntry({ entry }: { entry: AuditEntry }) {
|
||||
function TimelineEntry({ entry, onShowHistory }: { entry: AuditEntry; onShowHistory: (cid: string) => void }) {
|
||||
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)
|
||||
@@ -94,7 +114,7 @@ function TimelineEntry({ entry }: { entry: AuditEntry }) {
|
||||
<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">
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||
<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>
|
||||
@@ -102,6 +122,16 @@ function TimelineEntry({ entry }: { entry: AuditEntry }) {
|
||||
{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>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (entry.new_value) onShowHistory(extractCID(entry.new_value))
|
||||
}}
|
||||
className="text-[10px] text-purple-600 hover:text-purple-800 dark:text-purple-400 underline-offset-2 hover:underline"
|
||||
title="DSMS-Versionsverlauf und Diff zur Vorversion anzeigen"
|
||||
>
|
||||
Verlauf anzeigen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,23 @@ import type { CanonicalControl } from '../_types'
|
||||
import { EFFORT_LABELS } from '../_types'
|
||||
import { SeverityBadge, StateBadge, LicenseRuleBadge } from './Badges'
|
||||
|
||||
// Defensive coercers: backend has rows where evidence/requirements/test_procedure/open_anchors
|
||||
// are JSON-encoded strings instead of arrays. .map() on a string throws — coerce here.
|
||||
function asArray<T = unknown>(v: unknown): T[] {
|
||||
if (Array.isArray(v)) return v as T[]
|
||||
if (typeof v === 'string' && v.trim().startsWith('[')) {
|
||||
try { const p = JSON.parse(v); return Array.isArray(p) ? p : [] } catch { return [] }
|
||||
}
|
||||
return []
|
||||
}
|
||||
function asStringArray(v: unknown): string[] {
|
||||
return asArray(v).map(x => typeof x === 'string' ? x : JSON.stringify(x))
|
||||
}
|
||||
type EvidenceItem = string | { type?: string; description?: string }
|
||||
function asEvidenceArray(v: unknown): EvidenceItem[] {
|
||||
return asArray<EvidenceItem>(v)
|
||||
}
|
||||
|
||||
export function ControlDetailView({
|
||||
ctrl,
|
||||
onBack,
|
||||
@@ -72,31 +89,31 @@ export function ControlDetailView({
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Geltungsbereich</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{ctrl.scope.platforms && ctrl.scope.platforms.length > 0 && (
|
||||
{asStringArray(ctrl.scope?.platforms).length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">Plattformen</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ctrl.scope.platforms.map(p => (
|
||||
{asStringArray(ctrl.scope?.platforms).map(p => (
|
||||
<span key={p} className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs">{p}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ctrl.scope.components && ctrl.scope.components.length > 0 && (
|
||||
{asStringArray(ctrl.scope?.components).length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">Komponenten</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ctrl.scope.components.map(c => (
|
||||
{asStringArray(ctrl.scope?.components).map(c => (
|
||||
<span key={c} className="px-2 py-0.5 bg-purple-50 text-purple-700 rounded text-xs">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ctrl.scope.data_classes && ctrl.scope.data_classes.length > 0 && (
|
||||
{asStringArray(ctrl.scope?.data_classes).length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">Datenklassen</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ctrl.scope.data_classes.map(d => (
|
||||
{asStringArray(ctrl.scope?.data_classes).map(d => (
|
||||
<span key={d} className="px-2 py-0.5 bg-amber-50 text-amber-700 rounded text-xs">{d}</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -109,7 +126,7 @@ export function ControlDetailView({
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
|
||||
<ol className="space-y-2">
|
||||
{ctrl.requirements.map((req, i) => (
|
||||
{asStringArray(ctrl.requirements).map((req, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<span className="flex-shrink-0 w-5 h-5 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center text-xs font-medium mt-0.5">{i + 1}</span>
|
||||
{req}
|
||||
@@ -122,7 +139,7 @@ export function ControlDetailView({
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
|
||||
<ol className="space-y-2">
|
||||
{ctrl.test_procedure.map((step, i) => (
|
||||
{asStringArray(ctrl.test_procedure).map((step, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
{step}
|
||||
@@ -135,12 +152,18 @@ export function ControlDetailView({
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Nachweisanforderungen</h3>
|
||||
<div className="space-y-2">
|
||||
{ctrl.evidence.map((ev, i) => (
|
||||
{asEvidenceArray(ctrl.evidence).map((ev, i) => (
|
||||
<div key={i} className="flex items-start gap-2 p-3 bg-gray-50 rounded-lg">
|
||||
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-500 uppercase">{ev.type}</span>
|
||||
<p className="text-sm text-gray-700">{ev.description}</p>
|
||||
{typeof ev === 'string' ? (
|
||||
<p className="text-sm text-gray-700">{ev}</p>
|
||||
) : (
|
||||
<>
|
||||
{ev.type && <span className="text-xs font-medium text-gray-500 uppercase">{ev.type}</span>}
|
||||
<p className="text-sm text-gray-700">{ev.description ?? JSON.stringify(ev)}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -152,13 +175,13 @@ export function ControlDetailView({
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<BookOpen className="w-4 h-4 text-green-700" />
|
||||
<h3 className="text-sm font-semibold text-green-900">Open-Source-Referenzen</h3>
|
||||
<span className="text-xs text-green-600">({ctrl.open_anchors.length} Quellen)</span>
|
||||
<span className="text-xs text-green-600">({asArray(ctrl.open_anchors).length} Quellen)</span>
|
||||
</div>
|
||||
<p className="text-xs text-green-700 mb-3">
|
||||
Dieses Control basiert auf frei verfuegbarem Wissen. Alle Referenzen sind offen und oeffentlich zugaenglich.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{ctrl.open_anchors.map((anchor, i) => (
|
||||
{asArray<{ framework?: string; ref?: string; url?: string }>(ctrl.open_anchors).map((anchor, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-2 bg-white rounded border border-green-100">
|
||||
<Scale className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -180,11 +203,11 @@ export function ControlDetailView({
|
||||
</section>
|
||||
|
||||
{/* Tags */}
|
||||
{ctrl.tags.length > 0 && (
|
||||
{asStringArray(ctrl.tags).length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Tags</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ctrl.tags.map(tag => (
|
||||
{asStringArray(ctrl.tags).map(tag => (
|
||||
<span key={tag} className="px-2 py-1 bg-gray-100 text-gray-600 rounded text-xs">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,16 @@ import { ControlRegulatorySection } from './ControlRegulatorySection'
|
||||
import { ControlSimilarControls } from './ControlSimilarControls'
|
||||
import { ControlReviewActions } from './ControlReviewActions'
|
||||
|
||||
// Defensive coercer: some canonical_controls rows have evidence/tags/etc.
|
||||
// as JSON-encoded strings instead of arrays. .map() on a string throws.
|
||||
function toArray<T = unknown>(v: unknown): T[] {
|
||||
if (Array.isArray(v)) return v as T[]
|
||||
if (typeof v === 'string' && v.trim().startsWith('[')) {
|
||||
try { const p = JSON.parse(v); return Array.isArray(p) ? p : [] } catch { return [] }
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
interface SimilarControl {
|
||||
control_id: string; title: string; severity: string; release_state: string;
|
||||
tags: string[]; license_rule: number | null; verification_method: string | null;
|
||||
@@ -186,7 +196,7 @@ export function ControlDetail({
|
||||
<ControlTraceability ctrl={ctrl} traceability={traceability} loadingTrace={loadingTrace}
|
||||
onNavigateToControl={onNavigateToControl} />
|
||||
|
||||
{!ctrl.source_citation && ctrl.open_anchors.length > 0 && (
|
||||
{!ctrl.source_citation && toArray(ctrl.open_anchors).length > 0 && (
|
||||
<section className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Scale className="w-4 h-4 text-amber-600" />
|
||||
@@ -201,36 +211,36 @@ export function ControlDetail({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(ctrl.scope.platforms?.length || ctrl.scope.components?.length || ctrl.scope.data_classes?.length) ? (
|
||||
{(toArray(ctrl.scope?.platforms).length || toArray(ctrl.scope?.components).length || toArray(ctrl.scope?.data_classes).length) ? (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Geltungsbereich</h3>
|
||||
<div className="grid grid-cols-3 gap-4 text-xs">
|
||||
{ctrl.scope.platforms?.length ? <div><span className="text-gray-500">Plattformen:</span> <span className="text-gray-700">{ctrl.scope.platforms.join(', ')}</span></div> : null}
|
||||
{ctrl.scope.components?.length ? <div><span className="text-gray-500">Komponenten:</span> <span className="text-gray-700">{ctrl.scope.components.join(', ')}</span></div> : null}
|
||||
{ctrl.scope.data_classes?.length ? <div><span className="text-gray-500">Datenklassen:</span> <span className="text-gray-700">{ctrl.scope.data_classes.join(', ')}</span></div> : null}
|
||||
{toArray<string>(ctrl.scope?.platforms).length ? <div><span className="text-gray-500">Plattformen:</span> <span className="text-gray-700">{toArray<string>(ctrl.scope?.platforms).join(', ')}</span></div> : null}
|
||||
{toArray<string>(ctrl.scope?.components).length ? <div><span className="text-gray-500">Komponenten:</span> <span className="text-gray-700">{toArray<string>(ctrl.scope?.components).join(', ')}</span></div> : null}
|
||||
{toArray<string>(ctrl.scope?.data_classes).length ? <div><span className="text-gray-500">Datenklassen:</span> <span className="text-gray-700">{toArray<string>(ctrl.scope?.data_classes).join(', ')}</span></div> : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{Array.isArray(ctrl.requirements) && ctrl.requirements.length > 0 && (
|
||||
{toArray<string>(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>
|
||||
<ol className="list-decimal list-inside space-y-1">{toArray<string>(ctrl.requirements).map((r, i) => <li key={i} className="text-sm text-gray-700">{r}</li>)}</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{Array.isArray(ctrl.test_procedure) && ctrl.test_procedure.length > 0 && (
|
||||
{toArray<string>(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>
|
||||
<ol className="list-decimal list-inside space-y-1">{toArray<string>(ctrl.test_procedure).map((s, i) => <li key={i} className="text-sm text-gray-700">{s}</li>)}</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{ctrl.evidence.length > 0 && (
|
||||
{toArray(ctrl.evidence).length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Nachweise</h3>
|
||||
<div className="space-y-2">
|
||||
{ctrl.evidence.map((ev, i) => (
|
||||
{toArray<string | { type?: string; description?: string }>(ctrl.evidence).map((ev, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
{typeof ev === 'string' ? <div>{ev}</div> : <div><span className="font-medium">{ev.type}:</span> {ev.description}</div>}
|
||||
@@ -243,9 +253,9 @@ export function ControlDetail({
|
||||
<section className="grid grid-cols-3 gap-4 text-xs text-gray-500">
|
||||
{ctrl.risk_score !== null && <div>Risiko-Score: <span className="text-gray-700 font-medium">{ctrl.risk_score}</span></div>}
|
||||
{ctrl.implementation_effort && <div>Aufwand: <span className="text-gray-700 font-medium">{EFFORT_LABELS[ctrl.implementation_effort] || ctrl.implementation_effort}</span></div>}
|
||||
{ctrl.tags.length > 0 && (
|
||||
{toArray<string>(ctrl.tags).length > 0 && (
|
||||
<div className="col-span-3 flex items-center gap-1 flex-wrap">
|
||||
{ctrl.tags.map(t => <span key={t} className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">{t}</span>)}
|
||||
{toArray<string>(ctrl.tags).map(t => <span key={t} className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">{t}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -253,11 +263,11 @@ export function ControlDetail({
|
||||
<section className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<BookOpen className="w-4 h-4 text-green-700" />
|
||||
<h3 className="text-sm font-semibold text-green-900">Open-Source-Referenzen ({ctrl.open_anchors.length})</h3>
|
||||
<h3 className="text-sm font-semibold text-green-900">Open-Source-Referenzen ({toArray(ctrl.open_anchors).length})</h3>
|
||||
</div>
|
||||
{ctrl.open_anchors.length > 0 ? (
|
||||
{toArray(ctrl.open_anchors).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{ctrl.open_anchors.map((anchor, i) => (
|
||||
{toArray<{ framework?: string; ref?: string; url?: string }>(ctrl.open_anchors).map((anchor, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<ExternalLink className="w-3.5 h-3.5 text-green-600 flex-shrink-0" />
|
||||
<span className="font-medium text-green-800">{anchor.framework}</span>
|
||||
|
||||
@@ -232,14 +232,25 @@ export function StateBadge({ state }: { state: string }) {
|
||||
|
||||
export function LicenseRuleBadge({ rule }: { rule: number | null | undefined }) {
|
||||
if (!rule) return null
|
||||
const config: Record<number, { bg: string; label: string }> = {
|
||||
1: { bg: 'bg-green-100 text-green-700', label: 'Free Use' },
|
||||
2: { bg: 'bg-blue-100 text-blue-700', label: 'Zitation' },
|
||||
3: { bg: 'bg-amber-100 text-amber-700', label: 'Reformuliert' },
|
||||
// Corrected labels per Task #21 LICENSE_RULES.md mapping:
|
||||
// R1 = woertlich (Hoheitsrecht/Public Domain, no attribution required)
|
||||
// R2 = woertlich + Attribution-Pflicht (CC-BY, OWASP, OECD, ENISA)
|
||||
// R3 = nur Identifier zitieren (DIN/ANSI/IEC/DGUV/proprietary — pipeline drops full text)
|
||||
const config: Record<number, { bg: string; label: string; title: string }> = {
|
||||
1: { bg: 'bg-emerald-100 text-emerald-800', label: 'R1', title: 'Woertlich uebernehmbar (Hoheitsrecht/Public Domain)' },
|
||||
2: { bg: 'bg-amber-100 text-amber-800', label: 'R2', title: 'Woertlich mit Attribution (CC-BY/OWASP/OECD/ENISA)' },
|
||||
3: { bg: 'bg-slate-100 text-slate-700', label: 'R3', title: 'Nur Identifier-Verweis (DIN/ANSI/IEC/proprietaer)' },
|
||||
}
|
||||
const c = config[rule]
|
||||
if (!c) return null
|
||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${c.bg}`}>{c.label}</span>
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${c.bg}`}
|
||||
title={c.title}
|
||||
>
|
||||
{c.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function VerificationMethodBadge({ method }: { method: string | null }) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { EMPTY_CONTROL } from './components/helpers'
|
||||
import { ControlForm } from './components/ControlForm'
|
||||
import { ControlDetail } from './components/ControlDetail'
|
||||
@@ -12,6 +14,24 @@ import { BACKEND_URL } from './components/helpers'
|
||||
|
||||
export default function ControlLibraryPage() {
|
||||
const state = useControlLibraryState()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
// Deep-link via /sdk/control-library?control=<id>
|
||||
// — e.g. from /sdk/master-controls member list.
|
||||
useEffect(() => {
|
||||
const cid = searchParams?.get('control')
|
||||
if (!cid || state.selectedControl?.control_id === cid) return
|
||||
fetch(`${BACKEND_URL}?endpoint=control&id=${encodeURIComponent(cid)}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(ctrl => {
|
||||
if (ctrl?.control_id) {
|
||||
state.setSelectedControl(ctrl)
|
||||
state.setMode('detail')
|
||||
}
|
||||
})
|
||||
.catch(() => { /* user just sees the list */ })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams])
|
||||
|
||||
const {
|
||||
handleCreate, handleUpdate, handleDelete, handleReview, handleBulkReject,
|
||||
|
||||
@@ -102,6 +102,7 @@ export interface BannerSite {
|
||||
site_name: string
|
||||
site_url: string
|
||||
is_active: boolean
|
||||
tcf_enabled?: boolean
|
||||
}
|
||||
|
||||
export function useCookieBanner() {
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function CookieBannerPage() {
|
||||
|
||||
{/* Tab: TCF/IAB */}
|
||||
{activeTab === 'tcf' && (
|
||||
<TCFSettings siteId={activeSiteId || undefined} tcfEnabled={false}
|
||||
<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}`, {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, useCallback, use } from 'react'
|
||||
import { SeverityBadge } from '../../_components/SeverityBadge'
|
||||
|
||||
interface BacklogItem {
|
||||
rank: number
|
||||
req_id: string
|
||||
title: string
|
||||
category: string
|
||||
severity: string
|
||||
annex_anchor: string
|
||||
description: string
|
||||
effort_days: number
|
||||
mapped_measure_names: { id: string; name: string }[]
|
||||
status: string
|
||||
priority_score: number
|
||||
}
|
||||
|
||||
interface BacklogResponse {
|
||||
project_id: string
|
||||
classification: string | null
|
||||
days_to_ce_deadline: number
|
||||
deadlines: { date: string; label: string }[]
|
||||
total: number
|
||||
items: BacklogItem[]
|
||||
}
|
||||
|
||||
export default function BacklogPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>
|
||||
}) {
|
||||
const { projectId } = use(params)
|
||||
const [data, setData] = useState<BacklogResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/backlog`, {
|
||||
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000001' },
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
setData(await res.json())
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (loading) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-gray-500">Laedt...</p></div>
|
||||
if (error) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-red-600">{error}</p></div>
|
||||
if (!data) return null
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<div className="mb-6">
|
||||
<a href={`/sdk/cra/${projectId}`} className="text-sm text-blue-600 hover:underline">
|
||||
← Zurueck zum Projekt
|
||||
</a>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mt-2">Prioritaeten-Backlog</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Sortiert nach Severity × Deadline-Druck × Effort. Was du heute tust, was naechsten Sprint, was vor 11.12.2027.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Deadline-Banner */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-6">
|
||||
{data.deadlines.map(d => {
|
||||
const days = Math.max(0, Math.round((new Date(d.date).getTime() - Date.now()) / 86400000))
|
||||
const isPast = new Date(d.date).getTime() < Date.now()
|
||||
return (
|
||||
<div
|
||||
key={d.date}
|
||||
className={`rounded-xl border p-4 ${
|
||||
isPast ? 'bg-gray-100 border-gray-200' :
|
||||
days < 90 ? 'bg-red-50 border-red-200' :
|
||||
days < 365 ? 'bg-orange-50 border-orange-200' :
|
||||
'bg-blue-50 border-blue-200'
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs text-gray-500">{d.date}</div>
|
||||
<div className="font-semibold text-gray-900 text-sm mt-0.5">{d.label}</div>
|
||||
<div className={`text-xs mt-1 ${isPast ? 'text-gray-500' : 'text-gray-700'}`}>
|
||||
{isPast ? 'bereits abgelaufen' : `noch ${days} Tage`}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Backlog */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Rang</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Anforderung</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Severity</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Score</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aufwand</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Massnahme</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{data.items.map(item => (
|
||||
<tr key={item.req_id} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-3 text-sm font-bold text-gray-700">{item.rank}</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="text-sm font-medium text-gray-900">{item.title}</div>
|
||||
<div className="text-xs text-gray-500">{item.category} · {item.annex_anchor}</div>
|
||||
</td>
|
||||
<td className="px-3 py-3"><SeverityBadge value={item.severity} /></td>
|
||||
<td className="px-3 py-3 text-sm font-mono text-gray-700">{item.priority_score}</td>
|
||||
<td className="px-3 py-3 text-sm text-gray-600">{item.effort_days} PT</td>
|
||||
<td className="px-3 py-3 text-xs text-gray-600">
|
||||
{item.mapped_measure_names.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{item.mapped_measure_names.map(m => (
|
||||
<div key={m.id} title={m.name}>
|
||||
<span className="font-mono text-gray-400">{m.id}:</span> {m.name.length > 50 ? m.name.slice(0, 50) + '...' : m.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<button
|
||||
className="px-2 py-1 text-xs bg-purple-100 text-purple-800 rounded hover:bg-purple-200"
|
||||
onClick={() => alert(`Jira-Export fuer ${item.req_id} — Phase-4-Feature`)}
|
||||
>
|
||||
→ Jira
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-4 text-center">
|
||||
Tage bis CE-Marking-Pflicht (11.12.2027): <span className="font-semibold">{data.days_to_ce_deadline}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, useCallback, use } from 'react'
|
||||
|
||||
interface CheckItem {
|
||||
id: string
|
||||
check_code: string
|
||||
title: string
|
||||
description: string
|
||||
check_type: string
|
||||
target_url: string | null
|
||||
linked_req_ids: string[]
|
||||
last_run_at: string | null
|
||||
is_active: boolean
|
||||
latest_result: { status: string; message: string; ran_at: string } | null
|
||||
}
|
||||
|
||||
interface ChecksResponse {
|
||||
project_id: string
|
||||
total: number
|
||||
items: CheckItem[]
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
pass: 'bg-green-100 text-green-800',
|
||||
fail: 'bg-red-100 text-red-800',
|
||||
manual_review_required: 'bg-yellow-100 text-yellow-800',
|
||||
}
|
||||
|
||||
export default function ChecksPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>
|
||||
}) {
|
||||
const { projectId } = use(params)
|
||||
const [data, setData] = useState<ChecksResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [running, setRunning] = useState<string | null>(null)
|
||||
const [urlInputs, setUrlInputs] = useState<Record<string, string>>({})
|
||||
|
||||
const tenant = '00000000-0000-0000-0000-000000000001'
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/checks`, {
|
||||
headers: { 'X-Tenant-ID': tenant },
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const json: ChecksResponse = await res.json()
|
||||
setData(json)
|
||||
const u: Record<string, string> = {}
|
||||
for (const c of json.items) u[c.id] = c.target_url || ''
|
||||
setUrlInputs(u)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const initChecks = async () => {
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/checks`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Tenant-ID': tenant },
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Init fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
const runCheck = async (checkId: string) => {
|
||||
setRunning(checkId)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/checks/${checkId}/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Tenant-ID': tenant, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ target_url: urlInputs[checkId] || null }),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Run fehlgeschlagen')
|
||||
} finally {
|
||||
setRunning(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-gray-500">Laedt...</p></div>
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-5xl mx-auto px-4">
|
||||
<div className="mb-6">
|
||||
<a href={`/sdk/cra/${projectId}`} className="text-sm text-blue-600 hover:underline">
|
||||
← Zurueck zum Projekt
|
||||
</a>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mt-2">Automatisierte Checks</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
CRA-typische Online-Pruefungen: security.txt, Update-Policy, TLS-Konfiguration, Vuln-Disclosure.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">
|
||||
<pre className="whitespace-pre-wrap">{error}</pre>
|
||||
<button onClick={() => setError('')} className="text-xs underline mt-1">Schliessen</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.items.length === 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
|
||||
<p className="text-gray-600 mb-3">Noch keine Checks fuer dieses Projekt konfiguriert.</p>
|
||||
<button
|
||||
onClick={initChecks}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm font-medium"
|
||||
>
|
||||
Standard-CRA-Checks erstellen (6 Stueck)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.items.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{data.items.map(c => (
|
||||
<div key={c.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<div className="flex items-start justify-between gap-4 mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900">{c.title}</h3>
|
||||
<span className="text-xs text-gray-400">{c.check_code}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">{c.description}</p>
|
||||
{c.linked_req_ids.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{c.linked_req_ids.map(r => (
|
||||
<span key={r} className="px-2 py-0.5 text-xs rounded bg-blue-100 text-blue-700">{r}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{c.latest_result && (
|
||||
<span className={`px-2 py-1 text-xs rounded-full font-medium ${STATUS_STYLE[c.latest_result.status] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{c.latest_result.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(c.check_type === 'url_probe' || c.check_type === 'tls_probe' || c.check_type === 'manual_review') && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
type="url"
|
||||
placeholder={c.check_type === 'tls_probe' ? 'https://product.example.com' : 'https://your-product.com'}
|
||||
value={urlInputs[c.id] ?? ''}
|
||||
onChange={e => setUrlInputs({ ...urlInputs, [c.id]: e.target.value })}
|
||||
className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-red-500"
|
||||
/>
|
||||
<button
|
||||
onClick={() => runCheck(c.id)}
|
||||
disabled={running === c.id}
|
||||
className="px-3 py-1.5 bg-red-600 text-white text-sm rounded hover:bg-red-700 disabled:bg-gray-300"
|
||||
>
|
||||
{running === c.id ? 'Laeuft...' : 'Run'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{c.latest_result && (
|
||||
<div className="mt-2 text-xs text-gray-600 bg-gray-50 rounded p-2 font-mono">
|
||||
{c.latest_result.message}
|
||||
<div className="text-gray-400 mt-1 text-[10px]">
|
||||
Geprueft: {new Date(c.latest_result.ran_at).toLocaleString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-xl p-4 text-sm text-blue-900">
|
||||
<strong>Hinweis:</strong> Aktuell implementiert: <code>cra_security_txt</code> (HTTP) und <code>cra_tls_cert_check</code> (TLS-Handshake).
|
||||
Andere Check-Typen sind als <code>manual_review_required</code> markiert — der Pruefer beantwortet sie manuell.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, useCallback, use } from 'react'
|
||||
|
||||
interface DocItem {
|
||||
id: string | null
|
||||
doc_type: string
|
||||
doc_type_label: string
|
||||
title: string
|
||||
content_md: string | null
|
||||
version: number
|
||||
requirements_coverage: Record<string, unknown>
|
||||
status: string
|
||||
signed_by: string | null
|
||||
signed_at: string | null
|
||||
generated_at: string | null
|
||||
superseded_at: string | null
|
||||
}
|
||||
|
||||
interface DocListResponse {
|
||||
project_id: string
|
||||
total: number
|
||||
items: DocItem[]
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
draft: 'bg-yellow-100 text-yellow-800',
|
||||
reviewed: 'bg-blue-100 text-blue-800',
|
||||
approved: 'bg-green-100 text-green-800',
|
||||
superseded: 'bg-gray-200 text-gray-600',
|
||||
not_generated: 'bg-gray-100 text-gray-400',
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
reviewed: 'Geprueft',
|
||||
approved: 'Freigegeben',
|
||||
superseded: 'Veraltet',
|
||||
not_generated: 'Nicht erzeugt',
|
||||
}
|
||||
|
||||
export default function DocumentsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>
|
||||
}) {
|
||||
const { projectId } = use(params)
|
||||
const [data, setData] = useState<DocListResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [generating, setGenerating] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
const [docContent, setDocContent] = useState<Record<string, string>>({})
|
||||
|
||||
// Generation params per doc type
|
||||
const [manufacturer, setManufacturer] = useState('')
|
||||
const [notifiedBody, setNotifiedBody] = useState('')
|
||||
const [securityContact, setSecurityContact] = useState('')
|
||||
|
||||
// Approval form
|
||||
const [approving, setApproving] = useState<string | null>(null)
|
||||
const [signedBy, setSignedBy] = useState('')
|
||||
|
||||
const tenant = '00000000-0000-0000-0000-000000000001'
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/documents`, {
|
||||
headers: { 'X-Tenant-ID': tenant },
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
setData(await res.json())
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const generate = async (docType: string) => {
|
||||
setGenerating(docType)
|
||||
setError('')
|
||||
try {
|
||||
const body: Record<string, string> = { doc_type: docType }
|
||||
if (docType === 'doc_eu_conformity') {
|
||||
if (manufacturer) body.manufacturer = manufacturer
|
||||
if (notifiedBody) body.notified_body = notifiedBody
|
||||
}
|
||||
if (docType === 'doc_cvd_policy' && securityContact) {
|
||||
body.security_contact = securityContact
|
||||
}
|
||||
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/documents/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenant },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const doc = await res.json()
|
||||
setDocContent(prev => ({ ...prev, [doc.id]: doc.content_md }))
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Generierung fehlgeschlagen')
|
||||
} finally {
|
||||
setGenerating(null)
|
||||
}
|
||||
}
|
||||
|
||||
const loadContent = async (docId: string) => {
|
||||
if (docContent[docId]) {
|
||||
setExpanded(expanded === docId ? null : docId)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/documents/${docId}`, {
|
||||
headers: { 'X-Tenant-ID': tenant },
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const doc = await res.json()
|
||||
setDocContent(prev => ({ ...prev, [docId]: doc.content_md }))
|
||||
setExpanded(docId)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
const approve = async (docId: string, status: string) => {
|
||||
if (!signedBy.trim()) {
|
||||
setError('Bitte Namen zur Freigabe eintragen.')
|
||||
return
|
||||
}
|
||||
setApproving(docId)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/documents/${docId}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenant },
|
||||
body: JSON.stringify({ signed_by: signedBy, status }),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Freigabe fehlgeschlagen')
|
||||
} finally {
|
||||
setApproving(null)
|
||||
}
|
||||
}
|
||||
|
||||
const download = (doc: DocItem) => {
|
||||
const content = docContent[doc.id || ''] || doc.content_md || ''
|
||||
if (!content) return
|
||||
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${doc.doc_type}_v${doc.version}_${doc.id?.slice(0, 8)}.md`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
if (loading) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-gray-500">Laedt...</p></div>
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-5xl mx-auto px-4">
|
||||
<div className="mb-6">
|
||||
<a href={`/sdk/cra/${projectId}`} className="text-sm text-blue-600 hover:underline">
|
||||
← Zurueck zum Projekt
|
||||
</a>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mt-2">CRA-Dokumente</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
DoC (Annex VII), Technische Doku (Annex V), CVD-Policy, Update-Policy, SBOM-Bericht — generiert aus aktuellem Projektstand.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">
|
||||
<pre className="whitespace-pre-wrap">{error}</pre>
|
||||
<button onClick={() => setError('')} className="text-xs underline mt-1">Schliessen</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generation params */}
|
||||
<details className="bg-white rounded-xl border border-gray-200 p-4 mb-4">
|
||||
<summary className="cursor-pointer text-sm font-medium text-gray-700">
|
||||
Optionale Parameter fuer Generierung (Hersteller, NoBo, Security-Contact)
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Hersteller (fuer DoC)</label>
|
||||
<input value={manufacturer} onChange={e => setManufacturer(e.target.value)} placeholder="Acme GmbH, Musterstr. 1, 80331 Muenchen" className="w-full px-3 py-2 border rounded text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Notified Body (falls Modul C)</label>
|
||||
<input value={notifiedBody} onChange={e => setNotifiedBody(e.target.value)} placeholder="TUEV Nord (NB-0044)" className="w-full px-3 py-2 border rounded text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Security-Contact (fuer CVD-Policy)</label>
|
||||
<input type="email" value={securityContact} onChange={e => setSecurityContact(e.target.value)} placeholder="security@example.com" className="w-full px-3 py-2 border rounded text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div className="space-y-3">
|
||||
{data?.items.map(doc => (
|
||||
<div key={doc.doc_type} className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<div className="flex items-start justify-between gap-4 mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-gray-900">{doc.doc_type_label}</h3>
|
||||
{doc.version > 0 && (
|
||||
<span className="text-xs text-gray-500">v{doc.version}</span>
|
||||
)}
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${STATUS_STYLE[doc.status]}`}>
|
||||
{STATUS_LABEL[doc.status]}
|
||||
</span>
|
||||
</div>
|
||||
{doc.generated_at && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Generiert: {new Date(doc.generated_at).toLocaleString('de-DE')}
|
||||
{doc.signed_by && doc.signed_at && (
|
||||
<> · Freigegeben von <span className="font-medium">{doc.signed_by}</span> am {new Date(doc.signed_at).toLocaleString('de-DE')}</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{doc.requirements_coverage && Object.keys(doc.requirements_coverage).length > 0 && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Coverage: {String(doc.requirements_coverage.fields_filled || 0)} / {String(doc.requirements_coverage.fields_required || 0)} Pflichtfelder · {String(doc.requirements_coverage.annex_anchor || '')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => generate(doc.doc_type)}
|
||||
disabled={generating === doc.doc_type}
|
||||
className="px-3 py-1.5 bg-red-600 text-white text-sm rounded hover:bg-red-700 disabled:bg-gray-300"
|
||||
>
|
||||
{generating === doc.doc_type ? 'Generiere...' : (doc.version === 0 ? 'Generieren' : 'Neu generieren')}
|
||||
</button>
|
||||
{doc.id && (
|
||||
<button
|
||||
onClick={() => loadContent(doc.id!)}
|
||||
className="px-3 py-1.5 bg-gray-100 text-gray-700 text-sm rounded hover:bg-gray-200"
|
||||
>
|
||||
{expanded === doc.id ? 'Einklappen' : 'Inhalt'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded === doc.id && doc.id && docContent[doc.id] && (
|
||||
<div className="mt-3 border-t border-gray-200 pt-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs text-gray-500 font-mono">Markdown-Vorschau</p>
|
||||
<button
|
||||
onClick={() => download(doc)}
|
||||
className="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
|
||||
>
|
||||
⬇ Download (.md)
|
||||
</button>
|
||||
</div>
|
||||
<pre className="bg-gray-50 rounded p-3 text-xs overflow-x-auto max-h-96 whitespace-pre-wrap font-mono">
|
||||
{docContent[doc.id]}
|
||||
</pre>
|
||||
|
||||
{doc.status === 'draft' && (
|
||||
<div className="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded">
|
||||
<p className="text-xs text-yellow-800 mb-2">
|
||||
Vor Freigabe pruefen ob alle <code>[zu ergaenzen]</code>-Stellen gefuellt sind.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={signedBy}
|
||||
onChange={e => setSignedBy(e.target.value)}
|
||||
placeholder="Name + Rolle des Freigebenden"
|
||||
className="flex-1 px-2 py-1 border rounded text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => approve(doc.id!, 'reviewed')}
|
||||
disabled={approving === doc.id || !signedBy.trim()}
|
||||
className="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 disabled:bg-gray-300"
|
||||
>
|
||||
Als geprueft markieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => approve(doc.id!, 'approved')}
|
||||
disabled={approving === doc.id || !signedBy.trim()}
|
||||
className="px-3 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 disabled:bg-gray-300"
|
||||
>
|
||||
Freigeben
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-xl p-4 text-sm text-blue-900">
|
||||
<strong>Hinweis:</strong> Diese Dokumente sind <em>Skelette</em> aus dem aktuellen Projektstand. Markdown-Format, manuelles Editieren + Unterzeichnung erforderlich vor Inverkehrbringen. PDF-Export folgt in Phase 5.5.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, useCallback, use } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
const LANGUAGES = [
|
||||
{ value: '', label: '— bitte waehlen —' },
|
||||
{ value: 'js', label: 'JavaScript / TypeScript' },
|
||||
{ value: 'python', label: 'Python' },
|
||||
{ value: 'go', label: 'Go' },
|
||||
{ value: 'rust', label: 'Rust' },
|
||||
{ value: 'java', label: 'Java / Kotlin' },
|
||||
{ value: 'csharp', label: 'C# / .NET' },
|
||||
{ value: 'cpp', label: 'C / C++' },
|
||||
{ value: 'swift', label: 'Swift' },
|
||||
{ value: 'mixed', label: 'Mehrere Sprachen' },
|
||||
{ value: 'other', label: 'Andere' },
|
||||
]
|
||||
|
||||
interface CRAProject {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
repo_url: string | null
|
||||
primary_language: string | null
|
||||
has_firmware: boolean
|
||||
connected_to_internet: boolean
|
||||
has_software_updates: boolean
|
||||
processes_personal_data: boolean
|
||||
is_critical_infra_supplier: boolean
|
||||
intended_use: string
|
||||
}
|
||||
|
||||
export default function IntakePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>
|
||||
}) {
|
||||
const { projectId } = use(params)
|
||||
const router = useRouter()
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [repoUrl, setRepoUrl] = useState('')
|
||||
const [primaryLanguage, setPrimaryLanguage] = useState('')
|
||||
const [hasFirmware, setHasFirmware] = useState(false)
|
||||
const [connectedInternet, setConnectedInternet] = useState(false)
|
||||
const [hasUpdates, setHasUpdates] = useState(false)
|
||||
const [processesPersonal, setProcessesPersonal] = useState(false)
|
||||
const [isCriticalInfra, setIsCriticalInfra] = useState(false)
|
||||
const [intendedUse, setIntendedUse] = useState('')
|
||||
|
||||
const tenant = '00000000-0000-0000-0000-000000000001'
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}`, {
|
||||
headers: { 'X-Tenant-ID': tenant },
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const p: CRAProject = await res.json()
|
||||
setName(p.name)
|
||||
setDescription(p.description || '')
|
||||
setRepoUrl(p.repo_url || '')
|
||||
setPrimaryLanguage(p.primary_language || '')
|
||||
setHasFirmware(p.has_firmware)
|
||||
setConnectedInternet(p.connected_to_internet)
|
||||
setHasUpdates(p.has_software_updates)
|
||||
setProcessesPersonal(p.processes_personal_data)
|
||||
setIsCriticalInfra(p.is_critical_infra_supplier)
|
||||
setIntendedUse(p.intended_use || '')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenant },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description,
|
||||
repo_url: repoUrl || null,
|
||||
primary_language: primaryLanguage || null,
|
||||
has_firmware: hasFirmware,
|
||||
connected_to_internet: connectedInternet,
|
||||
has_software_updates: hasUpdates,
|
||||
processes_personal_data: processesPersonal,
|
||||
is_critical_infra_supplier: isCriticalInfra,
|
||||
intended_use: intendedUse,
|
||||
status: 'scoped',
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
router.push(`/sdk/cra/${projectId}/scope`)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Speichern fehlgeschlagen')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-gray-500">Laedt...</p></div>
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-3xl mx-auto px-4">
|
||||
<div className="mb-6">
|
||||
<a href={`/sdk/cra/${projectId}`} className="text-sm text-blue-600 hover:underline">
|
||||
← Zurueck zum Projekt
|
||||
</a>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mt-2">Intake — Software-Profil</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Schritt 1 von 3 — Beschreibe Software, Firmware und Connectivity. Daraus leiten wir die CRA-Klassifikation ab.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Produktname *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
|
||||
placeholder="z.B. SmartHome Gateway v3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kurzbeschreibung</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Intended Use — Zweck und Anwendungsbereich
|
||||
</label>
|
||||
<textarea
|
||||
value={intendedUse}
|
||||
onChange={e => setIntendedUse(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="z.B. Mobile App fuer Industrieanlagen-Monitoring, oder: Password Manager fuer KMU, oder: VPN-Software fuer Mitarbeiter-Geraete"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Wichtig fuer die Klassifikation. Erwaehne konkrete Funktionen (z.B. "Firewall", "Betriebssystem") wenn zutreffend.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Repo-URL (optional)</label>
|
||||
<input
|
||||
type="url"
|
||||
value={repoUrl}
|
||||
onChange={e => setRepoUrl(e.target.value)}
|
||||
placeholder="https://github.com/..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Primaere Programmiersprache</label>
|
||||
<select
|
||||
value={primaryLanguage}
|
||||
onChange={e => setPrimaryLanguage(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
|
||||
>
|
||||
{LANGUAGES.map(l => (
|
||||
<option key={l.value} value={l.value}>{l.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-5">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Eigenschaften des Produkts</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{[
|
||||
['hasFirmware', 'Enthaelt Firmware (Embedded/IoT)', hasFirmware, setHasFirmware],
|
||||
['connectedInternet', 'Mit dem Internet verbunden', connectedInternet, setConnectedInternet],
|
||||
['hasUpdates', 'Hat Software-/Firmware-Updates', hasUpdates, setHasUpdates],
|
||||
['processesPersonal', 'Verarbeitet personenbezogene Daten', processesPersonal, setProcessesPersonal],
|
||||
['isCriticalInfra', 'Zulieferer fuer kritische Infrastruktur', isCriticalInfra, setIsCriticalInfra],
|
||||
].map(([key, label, value, setter]) => (
|
||||
<label key={key as string} className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value as boolean}
|
||||
onChange={e => (setter as (b: boolean) => void)(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{label as string}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-3">
|
||||
<button
|
||||
onClick={() => router.push(`/sdk/cra/${projectId}`)}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={saving || !name.trim()}
|
||||
className="flex-1 py-2 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 disabled:bg-gray-300"
|
||||
>
|
||||
{saving ? 'Speichert...' : 'Weiter zum Scope-Check →'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, useCallback, use } from 'react'
|
||||
|
||||
interface MonitoringData {
|
||||
project_id: string
|
||||
deadlines: { date: string; label: string }[]
|
||||
summary: {
|
||||
active_vulns: number
|
||||
critical_vulns: number
|
||||
high_vulns: number
|
||||
breached_24h_reporting: number
|
||||
breached_72h_reporting: number
|
||||
sbom_versions: number
|
||||
configured_checks: number
|
||||
}
|
||||
post_market_checklist: { item: string; done: boolean; href_suffix: string }[]
|
||||
}
|
||||
|
||||
export default function MonitoringPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>
|
||||
}) {
|
||||
const { projectId } = use(params)
|
||||
const [data, setData] = useState<MonitoringData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/monitoring`, {
|
||||
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000001' },
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
setData(await res.json())
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (loading) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-gray-500">Laedt...</p></div>
|
||||
if (error) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-red-600">{error}</p></div>
|
||||
if (!data) return null
|
||||
|
||||
const completeness = data.post_market_checklist.filter(c => c.done).length
|
||||
const totalChecks = data.post_market_checklist.length
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-5xl mx-auto px-4">
|
||||
<div className="mb-6">
|
||||
<a href={`/sdk/cra/${projectId}`} className="text-sm text-blue-600 hover:underline">
|
||||
← Zurueck zum Projekt
|
||||
</a>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mt-2">Post-Market Monitoring</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
CRA-Stichtage + Vuln-Reporting-Compliance + Post-Market-Pflichten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* CRA-Stichtage */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">CRA-Stichtage</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{data.deadlines.map(d => {
|
||||
const target = new Date(d.date).getTime()
|
||||
const days = Math.round((target - Date.now()) / 86400000)
|
||||
const isPast = days < 0
|
||||
const isSoon = days >= 0 && days < 90
|
||||
const styles = isPast ? 'bg-gray-100 border-gray-200' :
|
||||
isSoon ? 'bg-red-50 border-red-200' :
|
||||
days < 365 ? 'bg-orange-50 border-orange-200' :
|
||||
'bg-blue-50 border-blue-200'
|
||||
return (
|
||||
<div key={d.date} className={`rounded-lg border p-4 ${styles}`}>
|
||||
<div className="text-xs text-gray-500">{d.date}</div>
|
||||
<div className="font-semibold text-gray-900 text-sm mt-0.5">{d.label}</div>
|
||||
<div className="text-xs mt-1 text-gray-700">
|
||||
{isPast ? `vor ${-days} Tagen` : `noch ${days} Tage`}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vuln-Reporting Compliance Banner */}
|
||||
{(data.summary.breached_24h_reporting > 0 || data.summary.breached_72h_reporting > 0) && (
|
||||
<div className="bg-red-50 border-2 border-red-300 rounded-xl p-5 mb-6">
|
||||
<h3 className="text-sm font-bold text-red-900 uppercase tracking-wide mb-2">⚠ CRA-Pflichten verletzt</h3>
|
||||
{data.summary.breached_24h_reporting > 0 && (
|
||||
<p className="text-sm text-red-800">
|
||||
<span className="font-semibold">{data.summary.breached_24h_reporting}</span> Schwachstelle(n) ohne 24h-Fruehwarnung an ENISA — Art. 14(2)(a) CRA.
|
||||
</p>
|
||||
)}
|
||||
{data.summary.breached_72h_reporting > 0 && (
|
||||
<p className="text-sm text-red-800 mt-1">
|
||||
<span className="font-semibold">{data.summary.breached_72h_reporting}</span> Schwachstelle(n) ohne 72h-Detailbericht — Art. 14(2)(b) CRA.
|
||||
</p>
|
||||
)}
|
||||
<a href={`/sdk/cra/${projectId}/vuln`} className="inline-block mt-2 text-sm text-red-700 underline font-medium">
|
||||
Zu den Schwachstellen →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
<SummaryCard label="Aktive Vulns" value={data.summary.active_vulns} subtitle={`${data.summary.critical_vulns} Critical · ${data.summary.high_vulns} High`} color="blue" />
|
||||
<SummaryCard label="SBOM-Versionen" value={data.summary.sbom_versions} subtitle={data.summary.sbom_versions === 0 ? 'noch keine' : 'hochgeladen'} color={data.summary.sbom_versions > 0 ? 'green' : 'gray'} />
|
||||
<SummaryCard label="Aktive Checks" value={data.summary.configured_checks} subtitle={data.summary.configured_checks === 0 ? 'init noetig' : 'konfiguriert'} color={data.summary.configured_checks > 0 ? 'green' : 'gray'} />
|
||||
<SummaryCard label="Post-Market" value={`${completeness}/${totalChecks}`} subtitle="erfuellt" color={completeness === totalChecks ? 'green' : 'orange'} />
|
||||
</div>
|
||||
|
||||
{/* Post-Market Checklist */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Post-Market-Pflichten</h3>
|
||||
<ul className="space-y-2">
|
||||
{data.post_market_checklist.map((c, i) => (
|
||||
<li key={i} className="flex items-center gap-3">
|
||||
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
|
||||
c.done ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-400'
|
||||
}`}>
|
||||
{c.done ? '✓' : '○'}
|
||||
</span>
|
||||
<span className={`text-sm ${c.done ? 'text-gray-700' : 'text-gray-900 font-medium'}`}>{c.item}</span>
|
||||
{!c.done && (
|
||||
<a
|
||||
href={`/sdk/cra/${projectId}/${c.href_suffix}`}
|
||||
className="ml-auto text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
Erledigen →
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-sm text-blue-900">
|
||||
<strong>Hinweis:</strong> Diese Seite aggregiert CRA-Pflichten aus SBOM, Checks und Vulnerability-Tracker. Die Reporting-Pflichten 24h/72h gelten ab CRA Art. 14(2) — verletzte Fristen erscheinen als rotes Banner.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, subtitle, color }: { label: string; value: number | string; subtitle: string; color: 'blue' | 'red' | 'green' | 'orange' | 'gray' }) {
|
||||
const bg = {
|
||||
blue: 'bg-blue-50 border-blue-200 text-blue-700',
|
||||
red: 'bg-red-50 border-red-200 text-red-700',
|
||||
green: 'bg-green-50 border-green-200 text-green-700',
|
||||
orange: 'bg-orange-50 border-orange-200 text-orange-700',
|
||||
gray: 'bg-gray-50 border-gray-200 text-gray-600',
|
||||
}[color]
|
||||
return (
|
||||
<div className={`rounded-xl border p-3 ${bg}`}>
|
||||
<p className="text-xs uppercase tracking-wide">{label}</p>
|
||||
<p className="text-2xl font-bold mt-1">{value}</p>
|
||||
<p className="text-xs mt-0.5 opacity-80">{subtitle}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, useCallback, use } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ClassificationBadge } from '../_components/ClassificationBadge'
|
||||
import { StatusStepper } from '../_components/StatusStepper'
|
||||
import { SeverityBadge } from '../_components/SeverityBadge'
|
||||
|
||||
interface CRAProject {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
cra_classification: string | null
|
||||
classification_rationale: string[]
|
||||
conformity_path: string | null
|
||||
status: string
|
||||
intended_use: string
|
||||
repo_url: string | null
|
||||
primary_language: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface BacklogItem {
|
||||
rank: number
|
||||
req_id: string
|
||||
title: string
|
||||
category: string
|
||||
severity: string
|
||||
effort_days: number
|
||||
priority_score: number
|
||||
}
|
||||
|
||||
interface BacklogData {
|
||||
days_to_ce_deadline: number
|
||||
deadlines: { date: string; label: string }[]
|
||||
total: number
|
||||
items: BacklogItem[]
|
||||
}
|
||||
|
||||
const PATH_LABEL: Record<string, string> = {
|
||||
self_assessment: 'Modul A — Self-Assessment',
|
||||
harmonized_standard: 'Modul B — Harmonized Standard',
|
||||
eucc: 'Modul H — EUCC',
|
||||
notified_body: 'Modul C — Notified Body',
|
||||
}
|
||||
|
||||
export default function CRAProjectDashboard({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>
|
||||
}) {
|
||||
const { projectId } = use(params)
|
||||
const router = useRouter()
|
||||
const [project, setProject] = useState<CRAProject | null>(null)
|
||||
const [backlog, setBacklog] = useState<BacklogData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const headers = { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000001' }
|
||||
const [projRes, backlogRes] = await Promise.all([
|
||||
fetch(`/api/sdk/v1/cra/projects/${projectId}`, { headers }),
|
||||
fetch(`/api/sdk/v1/cra/projects/${projectId}/backlog`, { headers }),
|
||||
])
|
||||
if (!projRes.ok) throw new Error(await projRes.text())
|
||||
setProject(await projRes.json())
|
||||
if (backlogRes.ok) {
|
||||
setBacklog(await backlogRes.json())
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (loading) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-gray-500">Laedt...</p></div>
|
||||
if (error) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-red-600">{error}</p></div>
|
||||
if (!project) return null
|
||||
|
||||
const nextStep =
|
||||
project.status === 'draft' ? { href: `/sdk/cra/${projectId}/intake`, label: 'Intake starten' } :
|
||||
project.status === 'scoped' ? { href: `/sdk/cra/${projectId}/scope`, label: 'Scope-Check ausfuehren' } :
|
||||
project.status === 'classified' ? { href: `/sdk/cra/${projectId}/path`, label: 'Konformitaetspfad waehlen' } :
|
||||
project.status === 'path_selected' ? { href: null, label: 'Phase 2 (Requirements) folgt' } :
|
||||
{ href: null, label: '' }
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-5xl mx-auto px-4">
|
||||
<div className="mb-4">
|
||||
<a href="/sdk/cra" className="text-sm text-blue-600 hover:underline">← Alle CRA-Projekte</a>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{project.name}</h1>
|
||||
{project.description && (
|
||||
<p className="text-gray-600 mt-1">{project.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<ClassificationBadge value={project.cra_classification} size="lg" />
|
||||
</div>
|
||||
|
||||
<StatusStepper current={project.status} />
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
{backlog && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
<KPICard
|
||||
label="Annex-I Requirements"
|
||||
value={backlog.total}
|
||||
hint="aus Migration 059"
|
||||
color="blue"
|
||||
/>
|
||||
<KPICard
|
||||
label="Critical-Anforderungen"
|
||||
value={backlog.items.filter(i => i.severity === 'CRITICAL').length}
|
||||
hint={`+ ${backlog.items.filter(i => i.severity === 'HIGH').length} High`}
|
||||
color="red"
|
||||
/>
|
||||
<KPICard
|
||||
label="Tage bis CE-Pflicht"
|
||||
value={backlog.days_to_ce_deadline}
|
||||
hint="11.12.2027"
|
||||
color={backlog.days_to_ce_deadline < 365 ? 'orange' : 'green'}
|
||||
/>
|
||||
<KPICard
|
||||
label="Compliance"
|
||||
value="0%"
|
||||
hint="Evidence in Phase 3"
|
||||
color="gray"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top-10 Backlog-Snippet */}
|
||||
{backlog && backlog.items.length > 0 && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Top-10 Prioritaeten</h3>
|
||||
<a href={`/sdk/cra/${projectId}/backlog`} className="text-xs text-blue-600 hover:underline">
|
||||
Vollstaendiges Backlog →
|
||||
</a>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-gray-500 uppercase">
|
||||
<tr>
|
||||
<th className="text-left py-1">#</th>
|
||||
<th className="text-left py-1">Anforderung</th>
|
||||
<th className="text-left py-1">Severity</th>
|
||||
<th className="text-left py-1">Aufwand</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{backlog.items.slice(0, 10).map(item => (
|
||||
<tr key={item.req_id} className="hover:bg-gray-50">
|
||||
<td className="py-2 text-gray-500">{item.rank}</td>
|
||||
<td className="py-2">
|
||||
<div className="font-medium text-gray-900">{item.title}</div>
|
||||
<div className="text-xs text-gray-500">{item.category}</div>
|
||||
</td>
|
||||
<td className="py-2"><SeverityBadge value={item.severity} /></td>
|
||||
<td className="py-2 text-gray-600">{item.effort_days} PT</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-7 gap-2 mb-6">
|
||||
<a href={`/sdk/cra/${projectId}/requirements`} className="text-center py-2 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 text-xs font-medium">Requirements</a>
|
||||
<a href={`/sdk/cra/${projectId}/backlog`} className="text-center py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 text-xs font-medium">Backlog</a>
|
||||
<a href={`/sdk/cra/${projectId}/sbom`} className="text-center py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 text-xs font-medium">SBOM</a>
|
||||
<a href={`/sdk/cra/${projectId}/checks`} className="text-center py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 text-xs font-medium">Checks</a>
|
||||
<a href={`/sdk/cra/${projectId}/vuln`} className="text-center py-2 bg-orange-100 text-orange-700 rounded-lg hover:bg-orange-200 text-xs font-medium">Vulns (CVD)</a>
|
||||
<a href={`/sdk/cra/${projectId}/monitoring`} className="text-center py-2 bg-yellow-100 text-yellow-700 rounded-lg hover:bg-yellow-200 text-xs font-medium">Monitoring</a>
|
||||
<a href={`/sdk/cra/${projectId}/documents`} className="text-center py-2 bg-teal-100 text-teal-700 rounded-lg hover:bg-teal-200 text-xs font-medium">Dokumente</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<InfoCard
|
||||
title="Intake"
|
||||
content={
|
||||
project.status === 'draft'
|
||||
? <span className="text-gray-400">Noch nicht erfasst</span>
|
||||
: (
|
||||
<div className="space-y-1 text-sm">
|
||||
{project.intended_use && <div><span className="text-gray-500">Use:</span> {project.intended_use}</div>}
|
||||
{project.primary_language && <div><span className="text-gray-500">Sprache:</span> {project.primary_language}</div>}
|
||||
{project.repo_url && <div><span className="text-gray-500">Repo:</span> {project.repo_url}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
actionHref={`/sdk/cra/${projectId}/intake`}
|
||||
actionLabel={project.status === 'draft' ? 'Erfassen' : 'Bearbeiten'}
|
||||
/>
|
||||
|
||||
<InfoCard
|
||||
title="Klassifikation"
|
||||
content={
|
||||
project.cra_classification ? (
|
||||
<div>
|
||||
<ClassificationBadge value={project.cra_classification} size="md" />
|
||||
{project.classification_rationale?.length > 0 && (
|
||||
<ul className="mt-2 text-xs text-gray-600 list-disc list-inside space-y-0.5">
|
||||
{project.classification_rationale.map((r, i) => <li key={i}>{r}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : <span className="text-gray-400">Scope-Check ausstehend</span>
|
||||
}
|
||||
actionHref={`/sdk/cra/${projectId}/scope`}
|
||||
actionLabel={project.cra_classification ? 'Neu pruefen' : 'Pruefen'}
|
||||
/>
|
||||
|
||||
<InfoCard
|
||||
title="Konformitaetspfad"
|
||||
content={
|
||||
project.conformity_path
|
||||
? <span className="font-medium text-purple-700">{PATH_LABEL[project.conformity_path] || project.conformity_path}</span>
|
||||
: <span className="text-gray-400">Noch nicht gewaehlt</span>
|
||||
}
|
||||
actionHref={project.cra_classification ? `/sdk/cra/${projectId}/path` : null}
|
||||
actionLabel={project.conformity_path ? 'Aendern' : 'Waehlen'}
|
||||
/>
|
||||
|
||||
<InfoCard
|
||||
title="Status"
|
||||
content={
|
||||
<div className="space-y-1 text-sm">
|
||||
<div><span className="text-gray-500">Aktuell:</span> {project.status}</div>
|
||||
<div className="text-xs text-gray-400">Aktualisiert: {new Date(project.updated_at).toLocaleString('de-DE')}</div>
|
||||
</div>
|
||||
}
|
||||
actionHref={null}
|
||||
actionLabel=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
{nextStep.href && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-blue-900">Naechster Schritt</h3>
|
||||
<p className="text-sm text-blue-700 mt-1">{nextStep.label}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push(nextStep.href!)}
|
||||
className="px-5 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!nextStep.href && nextStep.label && (
|
||||
<div className="bg-gray-100 border border-gray-200 rounded-xl p-5 text-center text-gray-600">
|
||||
{nextStep.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoCard({
|
||||
title, content, actionHref, actionLabel,
|
||||
}: {
|
||||
title: string
|
||||
content: React.ReactNode
|
||||
actionHref: string | null
|
||||
actionLabel: string
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">{title}</h3>
|
||||
{actionHref && actionLabel && (
|
||||
<a href={actionHref} className="text-xs text-blue-600 hover:underline">{actionLabel}</a>
|
||||
)}
|
||||
</div>
|
||||
<div>{content}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KPICard({
|
||||
label, value, hint, color,
|
||||
}: {
|
||||
label: string
|
||||
value: string | number
|
||||
hint: string
|
||||
color: 'blue' | 'red' | 'orange' | 'green' | 'gray'
|
||||
}) {
|
||||
const colors = {
|
||||
blue: 'bg-blue-50 border-blue-200 text-blue-700',
|
||||
red: 'bg-red-50 border-red-200 text-red-700',
|
||||
orange: 'bg-orange-50 border-orange-200 text-orange-700',
|
||||
green: 'bg-green-50 border-green-200 text-green-700',
|
||||
gray: 'bg-gray-50 border-gray-200 text-gray-700',
|
||||
}
|
||||
return (
|
||||
<div className={`rounded-xl border p-4 ${colors[color]}`}>
|
||||
<p className="text-xs text-gray-600 uppercase tracking-wide">{label}</p>
|
||||
<p className="text-2xl font-bold mt-1">{value}</p>
|
||||
<p className="text-xs mt-1 opacity-80">{hint}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, useCallback, use } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ClassificationBadge } from '../../_components/ClassificationBadge'
|
||||
|
||||
interface CRAProject {
|
||||
id: string
|
||||
name: string
|
||||
cra_classification: string | null
|
||||
conformity_path: string | null
|
||||
status: string
|
||||
}
|
||||
|
||||
type PathId = 'self_assessment' | 'harmonized_standard' | 'eucc' | 'notified_body'
|
||||
|
||||
interface PathOption {
|
||||
id: PathId
|
||||
modul: string
|
||||
title: string
|
||||
short: string
|
||||
details: string[]
|
||||
}
|
||||
|
||||
const PATHS: PathOption[] = [
|
||||
{
|
||||
id: 'self_assessment',
|
||||
modul: 'Modul A',
|
||||
title: 'Self-Assessment',
|
||||
short: 'Konformitaetsbewertung durch interne Pruefung',
|
||||
details: [
|
||||
'Hersteller fuehrt Konformitaetsbewertung selbst durch',
|
||||
'Geringster externer Aufwand, schnelle Umsetzung',
|
||||
'Default fuer Standard-Produkte',
|
||||
'Technische Dokumentation + DoC bleibt Pflicht',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'harmonized_standard',
|
||||
modul: 'Modul B',
|
||||
title: 'Harmonized Standard',
|
||||
short: 'Konformitaetsvermutung durch harmonisierte Norm',
|
||||
details: [
|
||||
'Anwendung einer harmonisierten EU-Norm (z.B. DIN EN 40000-1-2 Entwurf)',
|
||||
'Konformitaetsvermutung gemaess EU-Recht',
|
||||
'Geringeres Audit-Risiko',
|
||||
'Empfohlen bei verfuegbarer harmonisierter Norm',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'eucc',
|
||||
modul: 'Modul H',
|
||||
title: 'EUCC Zertifizierung',
|
||||
short: 'European Cybersecurity Certification Scheme',
|
||||
details: [
|
||||
'ENISA-EUCC-Zertifizierung (Common Criteria-basiert)',
|
||||
'Hoechste Anerkennung in EU + Drittstaaten',
|
||||
'Hoher Aufwand, ITSEF-Pruefung erforderlich',
|
||||
'Pflicht bei einigen Important Class II-Produkten',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'notified_body',
|
||||
modul: 'Modul C',
|
||||
title: 'Notified Body Assessment',
|
||||
short: 'Drittprueforganisation pruefn die Konformitaet',
|
||||
details: [
|
||||
'Externe Bewertung durch akkreditierte Stelle',
|
||||
'PFLICHT fuer Critical-Produkte (Annex IV)',
|
||||
'Hoechste Auditierbarkeit + Vertrauen',
|
||||
'Laufzeit + Kosten am hoechsten',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const ALLOWED: Record<string, PathId[]> = {
|
||||
STANDARD: ['self_assessment', 'harmonized_standard', 'eucc', 'notified_body'],
|
||||
IMPORTANT_I: ['self_assessment', 'harmonized_standard', 'eucc', 'notified_body'],
|
||||
IMPORTANT_II: ['harmonized_standard', 'eucc', 'notified_body'],
|
||||
CRITICAL: ['notified_body'],
|
||||
}
|
||||
|
||||
const DEFAULT_FOR: Record<string, PathId> = {
|
||||
STANDARD: 'self_assessment',
|
||||
IMPORTANT_I: 'self_assessment',
|
||||
IMPORTANT_II: 'harmonized_standard',
|
||||
CRITICAL: 'notified_body',
|
||||
}
|
||||
|
||||
export default function PathSelectPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>
|
||||
}) {
|
||||
const { projectId } = use(params)
|
||||
const router = useRouter()
|
||||
const [project, setProject] = useState<CRAProject | null>(null)
|
||||
const [selected, setSelected] = useState<PathId | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const tenant = '00000000-0000-0000-0000-000000000001'
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}`, {
|
||||
headers: { 'X-Tenant-ID': tenant },
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const p: CRAProject = await res.json()
|
||||
setProject(p)
|
||||
if (p.conformity_path) {
|
||||
setSelected(p.conformity_path as PathId)
|
||||
} else if (p.cra_classification && DEFAULT_FOR[p.cra_classification]) {
|
||||
setSelected(DEFAULT_FOR[p.cra_classification])
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const submit = async () => {
|
||||
if (!selected) return
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/path-select`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenant },
|
||||
body: JSON.stringify({ conformity_path: selected }),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
router.push(`/sdk/cra/${projectId}`)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Speichern fehlgeschlagen')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-gray-500">Laedt...</p></div>
|
||||
if (!project) return null
|
||||
|
||||
if (!project.cra_classification) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-8">
|
||||
<div className="max-w-2xl mx-auto bg-yellow-50 border border-yellow-200 rounded-lg p-6">
|
||||
<p className="text-yellow-800">
|
||||
Bitte erst den Scope-Check ausfuehren.
|
||||
<a href={`/sdk/cra/${projectId}/scope`} className="ml-2 underline">→ Zum Scope-Check</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const allowedPaths = ALLOWED[project.cra_classification] || []
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div className="mb-6">
|
||||
<a href={`/sdk/cra/${projectId}`} className="text-sm text-blue-600 hover:underline">
|
||||
← Zurueck zum Projekt
|
||||
</a>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mt-2">Konformitaetspfad waehlen</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Schritt 3 von 3 — basierend auf der Klassifikation siehst du die zulaessigen Pfade.
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Klassifikation:</span>
|
||||
<ClassificationBadge value={project.cra_classification} size="md" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
{PATHS.map(path => {
|
||||
const allowed = allowedPaths.includes(path.id)
|
||||
const isSelected = selected === path.id
|
||||
return (
|
||||
<button
|
||||
key={path.id}
|
||||
onClick={() => allowed && setSelected(path.id)}
|
||||
disabled={!allowed}
|
||||
className={`text-left p-5 rounded-xl border-2 transition-all ${
|
||||
isSelected ? 'border-red-500 bg-red-50' :
|
||||
allowed ? 'border-gray-200 bg-white hover:border-red-300 hover:shadow-md' :
|
||||
'border-gray-200 bg-gray-50 opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">{path.modul}</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{path.title}</h3>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<span className="px-2 py-0.5 text-xs bg-red-600 text-white rounded">Gewaehlt</span>
|
||||
)}
|
||||
{!allowed && (
|
||||
<span className="px-2 py-0.5 text-xs bg-gray-200 text-gray-600 rounded">Nicht zulaessig</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">{path.short}</p>
|
||||
<ul className="text-xs text-gray-600 space-y-1">
|
||||
{path.details.map((d, i) => (
|
||||
<li key={i} className="flex items-start gap-1.5">
|
||||
<span className="text-gray-400 mt-0.5">•</span>
|
||||
<span>{d}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
{selected ? (
|
||||
<>Ausgewaehlt: <span className="font-medium text-gray-900">
|
||||
{PATHS.find(p => p.id === selected)?.title}
|
||||
</span></>
|
||||
) : (
|
||||
'Keine Auswahl getroffen'
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => router.push(`/sdk/cra/${projectId}/scope`)}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
← Zurueck
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={saving || !selected}
|
||||
className="px-6 py-2 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 disabled:bg-gray-300"
|
||||
>
|
||||
{saving ? 'Speichert...' : 'Pfad festlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, useCallback, use } from 'react'
|
||||
import { SeverityBadge } from '../../_components/SeverityBadge'
|
||||
|
||||
interface Requirement {
|
||||
req_id: string
|
||||
n: number
|
||||
category: string
|
||||
title: string
|
||||
annex_anchor: string
|
||||
iso27001_ref: string[]
|
||||
description: string
|
||||
severity: string
|
||||
mapped_measures: string[]
|
||||
mapped_measure_names: { id: string; name: string }[]
|
||||
evidence_type: string
|
||||
effort_days: number
|
||||
status: string
|
||||
}
|
||||
|
||||
interface RequirementsResponse {
|
||||
project_id: string
|
||||
classification: string | null
|
||||
total: number
|
||||
items: Requirement[]
|
||||
}
|
||||
|
||||
export default function RequirementsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>
|
||||
}) {
|
||||
const { projectId } = use(params)
|
||||
const [data, setData] = useState<RequirementsResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [filterCategory, setFilterCategory] = useState<string>('all')
|
||||
const [filterSeverity, setFilterSeverity] = useState<string>('all')
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/requirements`, {
|
||||
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000001' },
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
setData(await res.json())
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (loading) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-gray-500">Laedt...</p></div>
|
||||
if (error) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-red-600">{error}</p></div>
|
||||
if (!data) return null
|
||||
|
||||
const categories = Array.from(new Set(data.items.map(i => i.category)))
|
||||
const filtered = data.items.filter(r =>
|
||||
(filterCategory === 'all' || r.category === filterCategory) &&
|
||||
(filterSeverity === 'all' || r.severity === filterSeverity)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="mb-6">
|
||||
<a href={`/sdk/cra/${projectId}`} className="text-sm text-blue-600 hover:underline">
|
||||
← Zurueck zum Projekt
|
||||
</a>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mt-2">CRA Annex I Requirements</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Alle {data.total} Essential Cybersecurity Requirements aus Annex I. Status bleibt "unbewertet" bis Evidence-Checks in Phase 3 verknuepft sind.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mb-4 flex-wrap">
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={e => setFilterCategory(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="all">Alle Kategorien</option>
|
||||
{categories.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={filterSeverity}
|
||||
onChange={e => setFilterSeverity(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="all">Alle Severities</option>
|
||||
<option value="CRITICAL">Kritisch</option>
|
||||
<option value="HIGH">Hoch</option>
|
||||
<option value="MEDIUM">Mittel</option>
|
||||
<option value="LOW">Niedrig</option>
|
||||
</select>
|
||||
<span className="text-sm text-gray-500 self-center">
|
||||
{filtered.length} von {data.total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">#</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Anforderung</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Kategorie</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Severity</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aufwand</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{filtered.map(req => (
|
||||
<React.Fragment key={req.req_id}>
|
||||
<tr
|
||||
className="hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => setExpanded(expanded === req.req_id ? null : req.req_id)}
|
||||
>
|
||||
<td className="px-3 py-2 text-sm text-gray-500">{req.n}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="text-sm font-medium text-gray-900">{req.title}</div>
|
||||
<div className="text-xs text-gray-500">{req.annex_anchor} · {req.req_id}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm text-gray-600">{req.category}</td>
|
||||
<td className="px-3 py-2"><SeverityBadge value={req.severity} /></td>
|
||||
<td className="px-3 py-2 text-sm text-gray-600">{req.effort_days} PT</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600">{req.status}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded === req.req_id && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-4 bg-blue-50">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-600 uppercase">Beschreibung</p>
|
||||
<p className="text-sm text-gray-700 mt-1">{req.description}</p>
|
||||
</div>
|
||||
{req.iso27001_ref.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-600 uppercase">ISO 27001:2022 Mapping</p>
|
||||
<p className="text-sm text-gray-700 mt-1">
|
||||
{req.iso27001_ref.map(r => (
|
||||
<span key={r} className="inline-block mr-2 mb-1 px-2 py-0.5 bg-white rounded text-xs">{r}</span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{req.mapped_measure_names.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-600 uppercase">Empfohlene Massnahmen</p>
|
||||
<ul className="text-sm text-gray-700 mt-1 space-y-0.5">
|
||||
{req.mapped_measure_names.map(m => (
|
||||
<li key={m.id}>
|
||||
<span className="font-mono text-xs text-gray-500">{m.id}</span> — {m.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 pt-1">
|
||||
Evidence-Typ: <span className="font-medium">{req.evidence_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, useCallback, use, useRef } from 'react'
|
||||
|
||||
interface SBOMItem {
|
||||
id: string
|
||||
filename: string
|
||||
format: string
|
||||
spec_version: string | null
|
||||
component_count: number
|
||||
summary: Record<string, unknown>
|
||||
scan_status: string
|
||||
scan_summary: Record<string, unknown>
|
||||
uploaded_at: string
|
||||
scanned_at: string | null
|
||||
}
|
||||
|
||||
interface SBOMListResponse {
|
||||
project_id: string
|
||||
total: number
|
||||
items: SBOMItem[]
|
||||
}
|
||||
|
||||
export default function SBOMPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>
|
||||
}) {
|
||||
const { projectId } = use(params)
|
||||
const [data, setData] = useState<SBOMListResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const tenant = '00000000-0000-0000-0000-000000000001'
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/sbom`, {
|
||||
headers: { 'X-Tenant-ID': tenant },
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
setData(await res.json())
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const onUpload = async () => {
|
||||
const f = fileRef.current?.files?.[0]
|
||||
if (!f) return
|
||||
setUploading(true)
|
||||
setError('')
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', f)
|
||||
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/sbom`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Tenant-ID': tenant },
|
||||
body: fd,
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
if (fileRef.current) fileRef.current.value = ''
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Upload fehlgeschlagen')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-gray-500">Laedt...</p></div>
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-5xl mx-auto px-4">
|
||||
<div className="mb-6">
|
||||
<a href={`/sdk/cra/${projectId}`} className="text-sm text-blue-600 hover:underline">
|
||||
← Zurueck zum Projekt
|
||||
</a>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mt-2">SBOM — Software Bill of Materials</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
CycloneDX oder SPDX hochladen. Verknuepft mit Annex-I Requirement 23 (SBOM-Pflicht).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">
|
||||
<pre className="whitespace-pre-wrap">{error}</pre>
|
||||
<button onClick={() => setError('')} className="text-red-500 mt-1 underline text-xs">Schliessen</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Neue Version hochladen</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
className="flex-1 text-sm file:mr-3 file:py-1.5 file:px-3 file:rounded file:border-0 file:text-sm file:bg-blue-100 file:text-blue-700 hover:file:bg-blue-200"
|
||||
/>
|
||||
<button
|
||||
onClick={onUpload}
|
||||
disabled={uploading}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:bg-gray-300 text-sm font-medium"
|
||||
>
|
||||
{uploading ? 'Laedt hoch...' : 'Upload'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Format: CycloneDX-JSON (mit <code>bomFormat: "CycloneDX"</code>) oder SPDX-JSON (mit <code>spdxVersion</code>).
|
||||
Generieren z.B. via <code>npx @cyclonedx/cyclonedx-npm</code> oder <code>cyclonedx-py</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{data && data.items.length === 0 && (
|
||||
<div className="bg-gray-100 rounded-xl p-8 text-center text-gray-500">
|
||||
Noch kein SBOM hochgeladen.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.items.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Versionen ({data.total})</h3>
|
||||
{data.items.map(s => (
|
||||
<div key={s.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-gray-900">{s.filename}</span>
|
||||
<span className="px-2 py-0.5 text-xs rounded bg-blue-100 text-blue-700 uppercase">{s.format}</span>
|
||||
{s.spec_version && (
|
||||
<span className="text-xs text-gray-500">v{s.spec_version}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{s.component_count} Komponenten · hochgeladen {new Date(s.uploaded_at).toLocaleString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
s.scan_status === 'scanned' ? 'bg-green-100 text-green-700' :
|
||||
s.scan_status === 'failed' ? 'bg-red-100 text-red-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
Scan: {s.scan_status}
|
||||
</span>
|
||||
</div>
|
||||
{s.summary && Object.keys(s.summary).length > 0 && (
|
||||
<details className="mt-3 text-xs">
|
||||
<summary className="cursor-pointer text-gray-600 hover:text-gray-900">Summary-Details</summary>
|
||||
<pre className="mt-2 p-2 bg-gray-50 rounded overflow-x-auto text-xs">{JSON.stringify(s.summary, null, 2)}</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-xl p-4 text-sm text-blue-900">
|
||||
<strong>Hinweis:</strong> Der osv.dev-Vulnerability-Scan wird durch ein separates Tool im Team durchgefuehrt.
|
||||
Diese Seite akzeptiert SBOM-Uploads und persistiert sie versioniert.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, useCallback, use } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ClassificationBadge } from '../../_components/ClassificationBadge'
|
||||
|
||||
interface CRAProject {
|
||||
id: string
|
||||
name: string
|
||||
intended_use: string
|
||||
primary_language: string | null
|
||||
connected_to_internet: boolean
|
||||
has_software_updates: boolean
|
||||
processes_personal_data: boolean
|
||||
is_critical_infra_supplier: boolean
|
||||
cra_classification: string | null
|
||||
classification_rationale: string[]
|
||||
status: string
|
||||
}
|
||||
|
||||
const CLASSIFICATION_DESC: Record<string, string> = {
|
||||
NOT_IN_SCOPE: 'Dein Produkt enthaelt keine digitalen Elemente nach CRA-Definition. Es ist nicht vom CRA betroffen.',
|
||||
STANDARD: 'Default-Kategorie fuer Produkte mit digitalen Elementen. Self-Assessment (Modul A) ist der typische Pfad.',
|
||||
IMPORTANT_I: 'Annex III Klasse I — Wichtige Produkte mit erhoehten Anforderungen. Self-Assessment OR Harmonized Standard moeglich.',
|
||||
IMPORTANT_II: 'Annex III Klasse II — Wichtige Produkte mit hohem Sicherheitsbedarf. Harmonized Standard ODER EUCC ODER Notified Body.',
|
||||
CRITICAL: 'Annex IV — Kritische Produkte (z.B. HSM, Smart-Meter-Gateways). Notified-Body-Assessment Pflicht.',
|
||||
}
|
||||
|
||||
export default function ScopeCheckPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>
|
||||
}) {
|
||||
const { projectId } = use(params)
|
||||
const router = useRouter()
|
||||
const [project, setProject] = useState<CRAProject | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const tenant = '00000000-0000-0000-0000-000000000001'
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}`, {
|
||||
headers: { 'X-Tenant-ID': tenant },
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
setProject(await res.json())
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const runScopeCheck = async () => {
|
||||
setChecking(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/scope-check`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Tenant-ID': tenant },
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
setProject(await res.json())
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Klassifikation fehlgeschlagen')
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-gray-500">Laedt...</p></div>
|
||||
if (!project) return null
|
||||
|
||||
const hasResult = !!project.cra_classification
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-3xl mx-auto px-4">
|
||||
<div className="mb-6">
|
||||
<a href={`/sdk/cra/${projectId}`} className="text-sm text-blue-600 hover:underline">
|
||||
← Zurueck zum Projekt
|
||||
</a>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mt-2">Scope-Check & Klassifikation</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Schritt 2 von 3 — Wir matchen dein Intake gegen Annex III/IV des CRA.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Aktuelle Intake-Daten</h3>
|
||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm">
|
||||
<Field label="Produkt" value={project.name} />
|
||||
<Field label="Sprache" value={project.primary_language || '—'} />
|
||||
<Field label="Intended Use" value={project.intended_use || '—'} fullWidth />
|
||||
<Field label="Internet" value={project.connected_to_internet ? 'Ja' : 'Nein'} />
|
||||
<Field label="Software-Updates" value={project.has_software_updates ? 'Ja' : 'Nein'} />
|
||||
<Field label="Personenbezogene Daten" value={project.processes_personal_data ? 'Ja' : 'Nein'} />
|
||||
<Field label="Kritische Infra" value={project.is_critical_infra_supplier ? 'Ja' : 'Nein'} />
|
||||
</dl>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<button
|
||||
onClick={runScopeCheck}
|
||||
disabled={checking}
|
||||
className="w-full py-3 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 disabled:bg-gray-300"
|
||||
>
|
||||
{checking ? 'Pruefe...' : hasResult ? 'Klassifikation neu berechnen' : 'Klassifikation berechnen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasResult && (
|
||||
<div className="mt-6 bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Ergebnis</h3>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<ClassificationBadge value={project.cra_classification} size="lg" />
|
||||
<p className="text-sm text-gray-700">
|
||||
{CLASSIFICATION_DESC[project.cra_classification!]}
|
||||
</p>
|
||||
</div>
|
||||
{project.classification_rationale?.length > 0 && (
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-4">
|
||||
<p className="text-xs font-semibold text-gray-600 uppercase tracking-wide mb-2">Begruendung</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm text-gray-700">
|
||||
{project.classification_rationale.map((r, i) => <li key={i}>{r}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => router.push(`/sdk/cra/${projectId}/intake`)}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
← Intake anpassen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push(`/sdk/cra/${projectId}/path`)}
|
||||
disabled={project.cra_classification === 'NOT_IN_SCOPE'}
|
||||
className="flex-1 py-2 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 disabled:bg-gray-300"
|
||||
>
|
||||
Weiter zum Konformitaetspfad →
|
||||
</button>
|
||||
</div>
|
||||
{project.cra_classification === 'NOT_IN_SCOPE' && (
|
||||
<p className="text-xs text-gray-500 mt-2 text-center">
|
||||
Produkt ist nicht im CRA-Scope. Keine weiteren Schritte noetig.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, value, fullWidth }: { label: string; value: string; fullWidth?: boolean }) {
|
||||
return (
|
||||
<div className={fullWidth ? 'md:col-span-2' : ''}>
|
||||
<dt className="text-xs text-gray-500">{label}</dt>
|
||||
<dd className="text-gray-900 mt-0.5">{value}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, useCallback, use } from 'react'
|
||||
import { SeverityBadge } from '../../_components/SeverityBadge'
|
||||
|
||||
interface Vuln {
|
||||
id: string
|
||||
cve_id: string | null
|
||||
title: string
|
||||
description: string
|
||||
severity: string | null
|
||||
cvss_score: number | null
|
||||
affected_components: string[]
|
||||
reporter_source: string
|
||||
reporter_contact: string | null
|
||||
discovered_at: string
|
||||
triaged_at: string | null
|
||||
patched_at: string | null
|
||||
disclosed_at: string | null
|
||||
embargo_until: string | null
|
||||
reported_to_enisa_at: string | null
|
||||
detailed_report_at: string | null
|
||||
status: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
interface VulnListResponse {
|
||||
project_id: string
|
||||
total: number
|
||||
summary: {
|
||||
critical_open: number
|
||||
breached_24h_reporting: number
|
||||
breached_72h_reporting: number
|
||||
by_status: Record<string, number>
|
||||
}
|
||||
items: Vuln[]
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
reported: 'Gemeldet',
|
||||
triaged: 'Triagiert',
|
||||
patched: 'Gepatcht',
|
||||
disclosed: 'Offengelegt',
|
||||
withdrawn: 'Zurueckgezogen',
|
||||
}
|
||||
|
||||
const STATUS_NEXT: Record<string, { status: string; label: string } | null> = {
|
||||
reported: { status: 'triaged', label: 'Triagieren' },
|
||||
triaged: { status: 'patched', label: 'Patch verfuegbar' },
|
||||
patched: { status: 'disclosed', label: 'Offenlegen' },
|
||||
disclosed: null,
|
||||
withdrawn: null,
|
||||
}
|
||||
|
||||
function ageHours(iso: string | null): number {
|
||||
if (!iso) return 0
|
||||
return (Date.now() - new Date(iso).getTime()) / 3600000
|
||||
}
|
||||
|
||||
function fmtRemaining(iso: string | null, hours: number): { label: string; color: string } {
|
||||
if (!iso) return { label: '—', color: 'text-gray-400' }
|
||||
const age = ageHours(iso)
|
||||
const remaining = hours - age
|
||||
if (remaining < 0) return { label: `+${Math.round(-remaining)}h ueber Frist`, color: 'text-red-600 font-semibold' }
|
||||
if (remaining < 4) return { label: `noch ${remaining.toFixed(1)}h`, color: 'text-orange-600 font-semibold' }
|
||||
return { label: `noch ${Math.round(remaining)}h`, color: 'text-gray-600' }
|
||||
}
|
||||
|
||||
export default function VulnPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>
|
||||
}) {
|
||||
const { projectId } = use(params)
|
||||
const [data, setData] = useState<VulnListResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [transitioning, setTransitioning] = useState<string | null>(null)
|
||||
|
||||
// New vuln form state
|
||||
const [title, setTitle] = useState('')
|
||||
const [cveId, setCveId] = useState('')
|
||||
const [severity, setSeverity] = useState('')
|
||||
const [cvssScore, setCvssScore] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [components, setComponents] = useState('')
|
||||
const [reporterSource, setReporterSource] = useState('internal')
|
||||
const [reporterContact, setReporterContact] = useState('')
|
||||
|
||||
const tenant = '00000000-0000-0000-0000-000000000001'
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/vulnerabilities`, {
|
||||
headers: { 'X-Tenant-ID': tenant },
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
setData(await res.json())
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const create = async () => {
|
||||
if (!title.trim()) return
|
||||
setCreating(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/vulnerabilities`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenant },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
cve_id: cveId || null,
|
||||
description,
|
||||
severity: severity || null,
|
||||
cvss_score: cvssScore ? parseFloat(cvssScore) : null,
|
||||
affected_components: components.split(',').map(s => s.trim()).filter(Boolean),
|
||||
reporter_source: reporterSource,
|
||||
reporter_contact: reporterContact || null,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
setShowForm(false)
|
||||
setTitle(''); setCveId(''); setSeverity(''); setCvssScore('')
|
||||
setDescription(''); setComponents(''); setReporterContact('')
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Anlegen fehlgeschlagen')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const transition = async (vulnId: string, nextStatus: string) => {
|
||||
setTransitioning(vulnId)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/vulnerabilities/${vulnId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenant },
|
||||
body: JSON.stringify({ status: nextStatus }),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Statuswechsel fehlgeschlagen')
|
||||
} finally {
|
||||
setTransitioning(null)
|
||||
}
|
||||
}
|
||||
|
||||
const markReported = async (vulnId: string, field: 'reported_to_enisa_at' | 'detailed_report_at') => {
|
||||
setTransitioning(vulnId)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/vulnerabilities/${vulnId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenant },
|
||||
body: JSON.stringify({ [field]: new Date().toISOString() }),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Reporting fehlgeschlagen')
|
||||
} finally {
|
||||
setTransitioning(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-gray-500">Laedt...</p></div>
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<div className="mb-6">
|
||||
<a href={`/sdk/cra/${projectId}`} className="text-sm text-blue-600 hover:underline">
|
||||
← Zurueck zum Projekt
|
||||
</a>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mt-2">Vulnerability Disclosure (CVD)</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Schwachstellen tracken. CRA-Pflichten: 24h Fruehwarnung an ENISA, 72h Detailbericht.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">
|
||||
<pre className="whitespace-pre-wrap">{error}</pre>
|
||||
<button onClick={() => setError('')} className="text-red-500 underline text-xs">Schliessen</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary KPIs */}
|
||||
{data && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
<SummaryCard label="Aktive Vulns" value={data.total - (data.summary.by_status.withdrawn || 0)} color="blue" />
|
||||
<SummaryCard label="Critical offen" value={data.summary.critical_open} color={data.summary.critical_open > 0 ? 'red' : 'green'} />
|
||||
<SummaryCard label="24h-Reporting versaeumt" value={data.summary.breached_24h_reporting} color={data.summary.breached_24h_reporting > 0 ? 'red' : 'green'} />
|
||||
<SummaryCard label="72h-Reporting versaeumt" value={data.summary.breached_72h_reporting} color={data.summary.breached_72h_reporting > 0 ? 'red' : 'green'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="mb-4 w-full py-3 border-2 border-dashed border-red-300 rounded-xl text-red-600 hover:bg-red-50 font-medium"
|
||||
>
|
||||
{showForm ? 'Abbrechen' : '+ Neue Schwachstelle melden'}
|
||||
</button>
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-6">
|
||||
<h3 className="text-sm font-semibold mb-3">Neue Schwachstelle</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs text-gray-600 mb-1">Titel *</label>
|
||||
<input value={title} onChange={e => setTitle(e.target.value)} className="w-full px-3 py-2 border rounded text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">CVE-ID (optional)</label>
|
||||
<input value={cveId} onChange={e => setCveId(e.target.value)} placeholder="CVE-2026-12345" className="w-full px-3 py-2 border rounded text-sm font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Severity</label>
|
||||
<select value={severity} onChange={e => setSeverity(e.target.value)} className="w-full px-3 py-2 border rounded text-sm">
|
||||
<option value="">— waehlen —</option>
|
||||
<option value="LOW">LOW</option>
|
||||
<option value="MEDIUM">MEDIUM</option>
|
||||
<option value="HIGH">HIGH</option>
|
||||
<option value="CRITICAL">CRITICAL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">CVSS Score (0-10)</label>
|
||||
<input type="number" min="0" max="10" step="0.1" value={cvssScore} onChange={e => setCvssScore(e.target.value)} className="w-full px-3 py-2 border rounded text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Reporter</label>
|
||||
<select value={reporterSource} onChange={e => setReporterSource(e.target.value)} className="w-full px-3 py-2 border rounded text-sm">
|
||||
<option value="internal">Intern</option>
|
||||
<option value="external">Extern (Kunde/Partner)</option>
|
||||
<option value="researcher">Security Researcher</option>
|
||||
<option value="scanner">Automatisierter Scanner</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs text-gray-600 mb-1">Reporter-Kontakt</label>
|
||||
<input value={reporterContact} onChange={e => setReporterContact(e.target.value)} placeholder="email@..." className="w-full px-3 py-2 border rounded text-sm" />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs text-gray-600 mb-1">Betroffene Komponenten (Komma-getrennt)</label>
|
||||
<input value={components} onChange={e => setComponents(e.target.value)} placeholder="lodash@4.17.20, axios@0.21.0" className="w-full px-3 py-2 border rounded text-sm font-mono" />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs text-gray-600 mb-1">Beschreibung</label>
|
||||
<textarea value={description} onChange={e => setDescription(e.target.value)} rows={3} className="w-full px-3 py-2 border rounded text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={create}
|
||||
disabled={creating || !title.trim()}
|
||||
className="mt-4 w-full py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:bg-gray-300 font-medium"
|
||||
>
|
||||
{creating ? 'Erstelle...' : 'Schwachstelle erfassen'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.items.length === 0 && !showForm && (
|
||||
<div className="bg-gray-100 rounded-xl p-8 text-center text-gray-500">
|
||||
Noch keine Schwachstellen erfasst.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.items.map(v => {
|
||||
const tx = STATUS_NEXT[v.status]
|
||||
const rep24 = fmtRemaining(v.discovered_at, 24)
|
||||
const rep72 = fmtRemaining(v.discovered_at, 72)
|
||||
return (
|
||||
<div key={v.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-3">
|
||||
<div className="flex items-start justify-between gap-4 mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-gray-900">{v.title}</h3>
|
||||
{v.cve_id && <span className="font-mono text-xs px-1.5 py-0.5 bg-gray-100 rounded">{v.cve_id}</span>}
|
||||
{v.severity && <SeverityBadge value={v.severity} />}
|
||||
{v.cvss_score !== null && <span className="text-xs text-gray-500">CVSS {v.cvss_score}</span>}
|
||||
</div>
|
||||
{v.description && <p className="text-sm text-gray-600 mt-1">{v.description}</p>}
|
||||
{v.affected_components.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{v.affected_components.map((c, i) => (
|
||||
<span key={i} className="font-mono text-xs px-1.5 py-0.5 bg-yellow-50 text-yellow-800 rounded">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-700 flex-shrink-0">
|
||||
{STATUS_LABEL[v.status] || v.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* CRA Reporting Compliance */}
|
||||
{v.status !== 'withdrawn' && (
|
||||
<div className="grid grid-cols-2 gap-3 mb-3 text-xs">
|
||||
<div className={`p-2 rounded ${v.reported_to_enisa_at ? 'bg-green-50' : 'bg-orange-50'}`}>
|
||||
<div className="font-semibold text-gray-700">24h: ENISA-Fruehwarnung</div>
|
||||
{v.reported_to_enisa_at ? (
|
||||
<div className="text-green-700">✓ {new Date(v.reported_to_enisa_at).toLocaleString('de-DE')}</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className={rep24.color}>{rep24.label}</span>
|
||||
<button
|
||||
onClick={() => markReported(v.id, 'reported_to_enisa_at')}
|
||||
disabled={transitioning === v.id}
|
||||
className="px-2 py-0.5 bg-orange-600 text-white rounded text-xs"
|
||||
>
|
||||
Jetzt melden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-2 rounded ${v.detailed_report_at ? 'bg-green-50' : 'bg-orange-50'}`}>
|
||||
<div className="font-semibold text-gray-700">72h: Detailbericht</div>
|
||||
{v.detailed_report_at ? (
|
||||
<div className="text-green-700">✓ {new Date(v.detailed_report_at).toLocaleString('de-DE')}</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className={rep72.color}>{rep72.label}</span>
|
||||
<button
|
||||
onClick={() => markReported(v.id, 'detailed_report_at')}
|
||||
disabled={transitioning === v.id}
|
||||
className="px-2 py-0.5 bg-orange-600 text-white rounded text-xs"
|
||||
>
|
||||
Jetzt melden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<div>
|
||||
Entdeckt: {new Date(v.discovered_at).toLocaleString('de-DE')}
|
||||
{v.patched_at && <> · Gepatcht: {new Date(v.patched_at).toLocaleString('de-DE')}</>}
|
||||
{v.disclosed_at && <> · Offengelegt: {new Date(v.disclosed_at).toLocaleString('de-DE')}</>}
|
||||
</div>
|
||||
{tx && (
|
||||
<button
|
||||
onClick={() => transition(v.id, tx.status)}
|
||||
disabled={transitioning === v.id}
|
||||
className="px-3 py-1 bg-blue-600 text-white rounded text-xs hover:bg-blue-700 disabled:bg-gray-300"
|
||||
>
|
||||
→ {tx.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, color }: { label: string; value: number; color: 'blue' | 'red' | 'green' | 'orange' }) {
|
||||
const bg = {
|
||||
blue: 'bg-blue-50 border-blue-200 text-blue-700',
|
||||
red: 'bg-red-50 border-red-200 text-red-700',
|
||||
green: 'bg-green-50 border-green-200 text-green-700',
|
||||
orange: 'bg-orange-50 border-orange-200 text-orange-700',
|
||||
}[color]
|
||||
return (
|
||||
<div className={`rounded-xl border p-3 ${bg}`}>
|
||||
<p className="text-xs uppercase tracking-wide">{label}</p>
|
||||
<p className="text-2xl font-bold mt-1">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
type Classification = 'NOT_IN_SCOPE' | 'STANDARD' | 'IMPORTANT_I' | 'IMPORTANT_II' | 'CRITICAL'
|
||||
|
||||
const STYLES: Record<string, { bg: string; label: string }> = {
|
||||
NOT_IN_SCOPE: { bg: 'bg-gray-200 text-gray-700', label: 'Nicht im Scope' },
|
||||
STANDARD: { bg: 'bg-blue-100 text-blue-800', label: 'Standard' },
|
||||
IMPORTANT_I: { bg: 'bg-yellow-100 text-yellow-800', label: 'Important Class I' },
|
||||
IMPORTANT_II: { bg: 'bg-orange-100 text-orange-800', label: 'Important Class II' },
|
||||
CRITICAL: { bg: 'bg-red-100 text-red-800', label: 'Critical' },
|
||||
}
|
||||
|
||||
export function ClassificationBadge({ value, size = 'md' }: { value: string | null; size?: 'sm' | 'md' | 'lg' }) {
|
||||
if (!value) {
|
||||
return <span className="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-500">Unbewertet</span>
|
||||
}
|
||||
const style = STYLES[value] || { bg: 'bg-gray-100 text-gray-700', label: value }
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-0.5 text-xs',
|
||||
md: 'px-3 py-1 text-sm font-medium',
|
||||
lg: 'px-4 py-2 text-base font-semibold',
|
||||
}[size]
|
||||
return <span className={`rounded-full ${sizeClasses} ${style.bg}`}>{style.label}</span>
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
|
||||
const STYLES: Record<string, { bg: string; label: string }> = {
|
||||
CRITICAL: { bg: 'bg-red-600 text-white', label: 'Kritisch' },
|
||||
HIGH: { bg: 'bg-orange-500 text-white', label: 'Hoch' },
|
||||
MEDIUM: { bg: 'bg-yellow-400 text-gray-900', label: 'Mittel' },
|
||||
LOW: { bg: 'bg-blue-100 text-blue-800', label: 'Niedrig' },
|
||||
}
|
||||
|
||||
export function SeverityBadge({ value }: { value: string }) {
|
||||
const s = STYLES[value] || { bg: 'bg-gray-200 text-gray-700', label: value }
|
||||
return <span className={`px-2 py-0.5 text-xs font-bold rounded ${s.bg}`}>{s.label}</span>
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
const STEPS = [
|
||||
{ id: 'draft', label: 'Entwurf' },
|
||||
{ id: 'scoped', label: 'Intake' },
|
||||
{ id: 'classified', label: 'Klassifiziert' },
|
||||
{ id: 'path_selected', label: 'Pfad' },
|
||||
{ id: 'requirements_mapped', label: 'Requirements' },
|
||||
{ id: 'evidence_pending', label: 'Evidence' },
|
||||
{ id: 'ready_for_review', label: 'Review' },
|
||||
{ id: 'declaration_ready', label: 'DoC' },
|
||||
{ id: 'post_market', label: 'Post-Market' },
|
||||
]
|
||||
|
||||
export function StatusStepper({ current }: { current: string }) {
|
||||
const currentIdx = STEPS.findIndex(s => s.id === current)
|
||||
return (
|
||||
<div className="flex items-center gap-1 overflow-x-auto py-2">
|
||||
{STEPS.map((step, idx) => {
|
||||
const isPast = idx < currentIdx
|
||||
const isCurrent = idx === currentIdx
|
||||
return (
|
||||
<div key={step.id} className="flex items-center gap-1 flex-shrink-0">
|
||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-medium ${
|
||||
isCurrent ? 'bg-blue-600 text-white' :
|
||||
isPast ? 'bg-green-500 text-white' :
|
||||
'bg-gray-200 text-gray-500'
|
||||
}`}>{idx + 1}</div>
|
||||
<span className={`text-xs ${isCurrent ? 'font-semibold text-blue-700' : isPast ? 'text-gray-700' : 'text-gray-400'}`}>
|
||||
{step.label}
|
||||
</span>
|
||||
{idx < STEPS.length - 1 && (
|
||||
<span className={`mx-1 ${isPast ? 'text-green-500' : 'text-gray-300'}`}>→</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ClassificationBadge } from './_components/ClassificationBadge'
|
||||
|
||||
interface CRAProject {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
cra_classification: string | null
|
||||
conformity_path: string | null
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const PATH_LABEL: Record<string, string> = {
|
||||
self_assessment: 'Modul A (Self-Assessment)',
|
||||
harmonized_standard: 'Modul B (Harmonized)',
|
||||
eucc: 'Modul H (EUCC)',
|
||||
notified_body: 'Modul C (Notified Body)',
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
scoped: 'Intake erfasst',
|
||||
classified: 'Klassifiziert',
|
||||
path_selected: 'Pfad gewaehlt',
|
||||
requirements_mapped: 'Requirements',
|
||||
evidence_pending: 'Evidence',
|
||||
gaps_open: 'Gaps offen',
|
||||
remediation: 'Remediation',
|
||||
ready_for_review: 'In Pruefung',
|
||||
declaration_ready: 'DoC bereit',
|
||||
post_market: 'Post-Market',
|
||||
archived: 'Archiviert',
|
||||
}
|
||||
|
||||
export default function CRAProjectsPage() {
|
||||
const router = useRouter()
|
||||
const [projects, setProjects] = useState<CRAProject[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newDescription, setNewDescription] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const tenantHeader = '00000000-0000-0000-0000-000000000001'
|
||||
|
||||
const loadProjects = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/cra/projects', {
|
||||
headers: { 'X-Tenant-ID': tenantHeader },
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const data = await res.json()
|
||||
setProjects(data.projects || [])
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadProjects() }, [loadProjects])
|
||||
|
||||
const createProject = async () => {
|
||||
if (!newName.trim()) return
|
||||
setCreating(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/cra/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantHeader },
|
||||
body: JSON.stringify({ name: newName, description: newDescription }),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const project = await res.json()
|
||||
router.push(`/sdk/cra/${project.id}/intake`)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Anlegen fehlgeschlagen')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900">CRA Compliance</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Cyber Resilience Act — Konformitaets-Workflow fuer Produkte mit digitalen Elementen.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Fuer Entwickler / Tech-Experten. Hardware-CE-Risikobeurteilung siehe{' '}
|
||||
<a href="/sdk/iace" className="text-blue-600 hover:underline">iACE</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 px-4 py-2 bg-emerald-50 border border-emerald-200 rounded-lg text-xs text-emerald-800 flex items-start gap-2">
|
||||
<span className="font-semibold">Quellen & Lizenz:</span>
|
||||
<span>
|
||||
Inhalte gemaess <strong>EU-Verordnung 2024/2847 (Cyber Resilience Act)</strong> —
|
||||
Lizenzregel R1 (EU_LAW, woertlich uebernehmbar). ENISA-Implementation-Guidance
|
||||
ergaenzend (R1 EU_PUBLIC).{' '}
|
||||
<a href="/sdk/licenses" className="underline">Quellenverzeichnis</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-3 underline">Schliessen</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="mb-6 w-full py-4 border-2 border-dashed border-red-300 rounded-xl text-red-600 hover:bg-red-50 hover:border-red-400 transition-colors font-medium"
|
||||
>
|
||||
+ Neues CRA-Projekt
|
||||
</button>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-500 py-12">Laedt...</div>
|
||||
) : projects.length === 0 ? (
|
||||
<p className="text-center text-gray-500 mt-8">
|
||||
Noch keine Projekte. Starten Sie Ihre erste CRA-Konformitaetsanalyse.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-gray-800">Projekte</h2>
|
||||
{projects.map(p => (
|
||||
<a
|
||||
key={p.id}
|
||||
href={`/sdk/cra/${p.id}`}
|
||||
className="block bg-white rounded-xl shadow-sm border border-gray-200 p-5 hover:shadow-md hover:border-red-300 transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{p.name}</h3>
|
||||
{p.description && (
|
||||
<p className="text-sm text-gray-500 mt-1 truncate">{p.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<ClassificationBadge value={p.cra_classification} size="sm" />
|
||||
{p.conformity_path && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-100 text-purple-800">
|
||||
{PATH_LABEL[p.conformity_path] || p.conformity_path}
|
||||
</span>
|
||||
)}
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-700">
|
||||
{STATUS_LABEL[p.status] || p.status}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(p.created_at).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4 p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Neues CRA-Projekt anlegen</h3>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Projektname (z.B. SmartHome Gateway v3)"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Kurzbeschreibung (optional)"
|
||||
value={newDescription}
|
||||
onChange={e => setNewDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-5">
|
||||
<button
|
||||
onClick={() => { setShowModal(false); setNewName(''); setNewDescription('') }}
|
||||
disabled={creating}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={createProject}
|
||||
disabled={creating || !newName.trim()}
|
||||
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:bg-gray-300"
|
||||
>
|
||||
{creating ? 'Erstelle...' : 'Anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Lifecycle-Phasen-Filter für den Document-Generator.
|
||||
*
|
||||
* Zeigt 5 Phasen-Tabs (Pre-Founding, Founding, Startup, KMU, Konzern) und
|
||||
* filtert die angezeigten Templates entsprechend ihres `lifecycle_stage`-Arrays.
|
||||
*
|
||||
* Phasen-Definitionen synchron zu lib/sdk/founding/template-categories.ts
|
||||
*/
|
||||
|
||||
import {
|
||||
LIFECYCLE_STAGE_LABELS,
|
||||
type LifecycleStage,
|
||||
TEMPLATE_CATEGORIES,
|
||||
} from '@/lib/sdk/founding/template-categories'
|
||||
|
||||
interface Props {
|
||||
activeStage: LifecycleStage | 'all'
|
||||
onChange: (stage: LifecycleStage | 'all') => void
|
||||
/** Template-Counts pro Stage (optional, sonst aus Code-Registry berechnet) */
|
||||
countsByStage?: Record<string, number>
|
||||
}
|
||||
|
||||
const STAGE_ORDER: (LifecycleStage | 'all')[] = [
|
||||
'all',
|
||||
'pre_founding',
|
||||
'founding',
|
||||
'startup',
|
||||
'kmu',
|
||||
'konzern',
|
||||
]
|
||||
|
||||
const STAGE_ICONS: Record<LifecycleStage | 'all', string> = {
|
||||
all: '📚',
|
||||
pre_founding: '🌱',
|
||||
founding: '⚖️',
|
||||
startup: '🚀',
|
||||
kmu: '🏢',
|
||||
konzern: '🏛️',
|
||||
}
|
||||
|
||||
const STAGE_HINTS: Record<LifecycleStage, string> = {
|
||||
pre_founding: 'Vor dem Notartermin — Term Sheet, IP-Sicherung, Wandeldarlehen',
|
||||
founding: 'Für den Notartermin — Satzung, Gesellschafterliste, HRB-Anmeldung',
|
||||
startup: '0–3 Jahre, <25 Mitarbeiter — Arbeitsverträge, AVV, Datenschutz',
|
||||
kmu: '3+ Jahre, 25–250 MA — ISMS, Whistleblower, vollständige TOM',
|
||||
konzern: '250+ MA — Konzern-Compliance, ISO 27001',
|
||||
}
|
||||
|
||||
export function LifecycleFilter({ activeStage, onChange, countsByStage }: Props) {
|
||||
const counts = countsByStage || computeCountsFromRegistry()
|
||||
|
||||
return (
|
||||
<div className="mb-6" data-testid="lifecycle-filter">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Phase Deines Unternehmens</h3>
|
||||
<span className="text-xs text-gray-500">— filtert Dokumente nach Lifecycle</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{STAGE_ORDER.map(stage => {
|
||||
const isAll = stage === 'all'
|
||||
const count = isAll
|
||||
? Object.values(counts).reduce((s, c) => s + c, 0)
|
||||
: (counts[stage] || 0)
|
||||
const label = isAll ? 'Alle' : LIFECYCLE_STAGE_LABELS[stage as LifecycleStage].split(' (')[0]
|
||||
const isActive = activeStage === stage
|
||||
return (
|
||||
<button
|
||||
key={stage}
|
||||
type="button"
|
||||
data-testid={`stage-tab-${stage}`}
|
||||
onClick={() => onChange(stage)}
|
||||
className={`px-3 py-2 rounded-lg border text-sm font-medium transition ${
|
||||
isActive
|
||||
? 'bg-purple-600 text-white border-purple-600 shadow-sm'
|
||||
: 'bg-white text-gray-700 border-gray-200 hover:border-purple-300 hover:bg-purple-50'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1.5">{STAGE_ICONS[stage]}</span>
|
||||
{label}
|
||||
<span className={`ml-2 px-1.5 py-0.5 text-xs rounded-full ${
|
||||
isActive ? 'bg-white/20' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{activeStage !== 'all' && (
|
||||
<p className="mt-2 text-sm text-gray-500" data-testid="stage-hint">
|
||||
{STAGE_HINTS[activeStage as LifecycleStage]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function computeCountsFromRegistry(): Record<string, number> {
|
||||
const counts: Record<string, number> = {
|
||||
pre_founding: 0, founding: 0, startup: 0, kmu: 0, konzern: 0,
|
||||
}
|
||||
for (const cat of Object.values(TEMPLATE_CATEGORIES)) {
|
||||
for (const stage of cat.lifecycle_stage) {
|
||||
counts[stage] = (counts[stage] || 0) + 1
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
export function filterTemplatesByStage<T extends { document_type?: string; type?: string }>(
|
||||
templates: T[],
|
||||
stage: LifecycleStage | 'all'
|
||||
): T[] {
|
||||
if (stage === 'all') return templates
|
||||
return templates.filter(t => {
|
||||
const docType = t.document_type || t.type
|
||||
if (!docType) return false
|
||||
const cat = TEMPLATE_CATEGORIES[docType]
|
||||
if (!cat) return stage === 'startup' // Fallback: unkategorisierte zeigen wir in Startup
|
||||
return cat.lifecycle_stage.includes(stage)
|
||||
})
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export const CATEGORIES: { key: string; label: string; types: string[] | null }[
|
||||
]},
|
||||
|
||||
// Datenschutz-Informationen (alle DSI-Typen):
|
||||
{ key: 'dsi', label: 'Datenschutzinfos', types: ['privacy_policy', 'applicant_dsi', 'employee_dsi', 'social_media_dsi', 'video_conference_dsi', 'informationspflichten'] },
|
||||
{ key: 'dsi', label: 'Datenschutzinfos', types: ['privacy_policy', 'data_protection_policy', 'applicant_dsi', 'employee_dsi', 'social_media_dsi', 'video_conference_dsi', 'informationspflichten'] },
|
||||
|
||||
// Einwilligungen:
|
||||
{ key: 'consent', label: 'Einwilligungen', types: ['consent_texts', 'cookie_banner', 'verpflichtungserklaerung'] },
|
||||
|
||||
@@ -15,6 +15,8 @@ import { getGeneratorDefaults, getProfileLabel } from './scopeDefaults'
|
||||
import TemplateLibrary from './_components/TemplateLibrary'
|
||||
import GeneratorSection from './_components/GeneratorSection'
|
||||
import RecommendedDocuments from './_components/RecommendedDocuments'
|
||||
import { LifecycleFilter, filterTemplatesByStage } from './_components/LifecycleFilter'
|
||||
import type { LifecycleStage } from '@/lib/sdk/founding/template-categories'
|
||||
|
||||
function DocumentGeneratorPageInner() {
|
||||
const { state } = useSDK()
|
||||
@@ -24,6 +26,7 @@ function DocumentGeneratorPageInner() {
|
||||
const [allTemplates, setAllTemplates] = useState<LegalTemplateResult[]>([])
|
||||
const [isLoadingLibrary, setIsLoadingLibrary] = useState(true)
|
||||
const [activeCategory, setActiveCategory] = useState<string>('all')
|
||||
const [activeStage, setActiveStage] = useState<LifecycleStage | 'all'>('all')
|
||||
const [activeLanguage, setActiveLanguage] = useState<'all' | 'de' | 'en'>('all')
|
||||
const [librarySearch, setLibrarySearch] = useState('')
|
||||
const [expandedPreviewId, setExpandedPreviewId] = useState<string | null>(null)
|
||||
@@ -101,7 +104,35 @@ function DocumentGeneratorPageInner() {
|
||||
}
|
||||
}, [state?.complianceScope?.determinedLevel, state?.companyProfile])
|
||||
|
||||
// ── MODULE WIRING: CookieBanner → CONSENT + FEATURES ─────────────────────
|
||||
// ── 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
|
||||
@@ -181,10 +212,15 @@ function DocumentGeneratorPageInner() {
|
||||
}
|
||||
}, [selectedDataPointsData])
|
||||
|
||||
// Filtered templates (computed)
|
||||
// Filtered templates (computed) — Lifecycle + Category + Language + Search
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const category = CATEGORIES.find((c: { key: string }) => c.key === activeCategory)
|
||||
return allTemplates.filter((t) => {
|
||||
// 1. Lifecycle-Phase Filter via Code-Registry (mapped auf templateType)
|
||||
const stageFiltered = filterTemplatesByStage(
|
||||
allTemplates.map(t => ({ ...t, document_type: t.templateType || '' })),
|
||||
activeStage
|
||||
)
|
||||
return stageFiltered.filter((t) => {
|
||||
if (category && category.types !== null) {
|
||||
if (!category.types.includes(t.templateType || '')) return false
|
||||
}
|
||||
@@ -197,7 +233,22 @@ function DocumentGeneratorPageInner() {
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [allTemplates, activeCategory, activeLanguage, librarySearch])
|
||||
}, [allTemplates, activeCategory, activeStage, activeLanguage, librarySearch])
|
||||
|
||||
// Counts by stage for filter UI
|
||||
const countsByStage = useMemo(() => {
|
||||
const counts: Record<string, number> = { pre_founding: 0, founding: 0, startup: 0, kmu: 0, konzern: 0 }
|
||||
const stages: LifecycleStage[] = ['pre_founding', 'founding', 'startup', 'kmu', 'konzern']
|
||||
for (const t of allTemplates) {
|
||||
const docType = t.templateType || ''
|
||||
for (const s of stages) {
|
||||
if (filterTemplatesByStage([{ document_type: docType }], s).length) {
|
||||
counts[s]++
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}, [allTemplates])
|
||||
|
||||
const handleUseTemplate = useCallback((t: LegalTemplateResult) => {
|
||||
setActiveTemplate(t)
|
||||
@@ -246,6 +297,16 @@ function DocumentGeneratorPageInner() {
|
||||
tips={stepInfo.tips}
|
||||
/>
|
||||
|
||||
<div className="px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg text-xs text-slate-700 flex items-start gap-2">
|
||||
<span className="font-semibold">Quellen & Lizenz:</span>
|
||||
<span>
|
||||
Die 91 Standard-Vorlagen sind <strong>BreakPilot-Eigenwerke</strong> (Lizenzregel R3 — Identifier-Verweis,
|
||||
eigene Lizenz). Vorlagen mit gesetzlicher Grundlage (z.B. VVT nach Art. 30 DSGVO,
|
||||
Loeschkonzept nach Art. 17 DSGVO) zitieren die jeweilige Rechtsquelle als R1.{' '}
|
||||
<a href="/sdk/licenses" className="underline">Quellenverzeichnis</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
@@ -264,6 +325,13 @@ function DocumentGeneratorPageInner() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lifecycle-Phase Filter */}
|
||||
<LifecycleFilter
|
||||
activeStage={activeStage}
|
||||
onChange={setActiveStage}
|
||||
countsByStage={countsByStage}
|
||||
/>
|
||||
|
||||
{/* Recommended documents based on scope profile */}
|
||||
<RecommendedDocuments
|
||||
allTemplates={allTemplates}
|
||||
|
||||
@@ -225,6 +225,51 @@ const TEMPLATE_RULES: TemplateRule[] = [
|
||||
condition: () => 'required', // Immer Pflicht bei Websites
|
||||
},
|
||||
|
||||
// ── DSE & Datenschutz-Kerndokumente (P38) ──────────────────────────────
|
||||
{
|
||||
templateType: 'privacy_policy',
|
||||
label: 'Datenschutzerklaerung (Website)',
|
||||
condition: () => 'required', // Art. 13 DSGVO — bei jeder Website Pflicht
|
||||
},
|
||||
{
|
||||
templateType: 'data_protection_policy',
|
||||
label: 'Datenschutzrichtlinie (intern)',
|
||||
condition: (_answers, level) => level >= 'L2' ? 'required' : 'recommended',
|
||||
},
|
||||
{
|
||||
templateType: 'dsfa',
|
||||
label: 'DSFA-Vorlage',
|
||||
condition: (answers) => {
|
||||
const dsfa = answers.get('proc_dsfa_required') || answers.get('comp_dsfa_processes')
|
||||
if (dsfa === 'yes' || dsfa === 'required') return 'required'
|
||||
return 'optional'
|
||||
},
|
||||
},
|
||||
{
|
||||
templateType: 'dpa',
|
||||
label: 'Auftragsverarbeitungsvertrag (AVV)',
|
||||
condition: (answers) => {
|
||||
const vendors = answers.get('comp_has_processors') || answers.get('comp_vendor_management')
|
||||
if (vendors && vendors !== 'no') return 'required'
|
||||
return 'recommended'
|
||||
},
|
||||
},
|
||||
{
|
||||
templateType: 'vvt_register',
|
||||
label: 'Verzeichnis von Verarbeitungstaetigkeiten (VVT)',
|
||||
condition: (_answers, level) => level >= 'L2' ? 'required' : 'recommended',
|
||||
},
|
||||
{
|
||||
templateType: 'tom_documentation',
|
||||
label: 'TOM-Dokumentation',
|
||||
condition: (_answers, level) => level >= 'L2' ? 'required' : 'recommended',
|
||||
},
|
||||
{
|
||||
templateType: 'loeschkonzept',
|
||||
label: 'Loeschkonzept',
|
||||
condition: (_answers, level) => level >= 'L2' ? 'required' : 'recommended',
|
||||
},
|
||||
|
||||
// ── Drittlandtransfer (SCC + TIA) ───────────────────────────────────────
|
||||
// SCC+TIA nur erforderlich wenn Drittlandtransfer OHNE Angemessenheitsbeschluss/DPF
|
||||
{
|
||||
|
||||
@@ -132,6 +132,16 @@ export default function DSFAPage() {
|
||||
)}
|
||||
</StepHeader>
|
||||
|
||||
<div className="px-4 py-2 bg-emerald-50 border border-emerald-200 rounded-lg text-xs text-emerald-800 flex items-start gap-2">
|
||||
<span className="font-semibold">Quellen & Lizenz:</span>
|
||||
<span>
|
||||
Inhalte gemaess <strong>DSGVO Art. 35</strong> (EU 2016/679) — Lizenzregel R1
|
||||
(Hoheitsrecht/EU_LAW, woertlich uebernehmbar). Vorlagen-Texte aus
|
||||
Aufsichtsbehoerden ebenfalls R1.{' '}
|
||||
<a href="/sdk/licenses" className="underline">Quellenverzeichnis</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* DSFA Requirement Check */}
|
||||
{dsfaCheck.required && dsfas.length === 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-5">
|
||||
|
||||
@@ -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>
|
||||
@@ -264,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
|
||||
@@ -144,4 +145,5 @@ export interface BannerSite {
|
||||
site_id: string
|
||||
site_name: string
|
||||
site_url: string
|
||||
tcf_enabled?: boolean
|
||||
}
|
||||
|
||||
@@ -57,12 +57,7 @@ export default function EinwilligungenPage() {
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<button className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Export
|
||||
</button>
|
||||
<ConsentExportButton />
|
||||
</StepHeader>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
@@ -150,3 +145,32 @@ export default function EinwilligungenPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Export-Dropdown im Step-Header. Streamt CSV/JSON direkt aus dem
|
||||
// Backend via /api/sdk/v1/einwilligungen/export-Proxy.
|
||||
function ConsentExportButton() {
|
||||
return (
|
||||
<div className="relative group">
|
||||
<button className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Export
|
||||
</button>
|
||||
<div className="absolute right-0 top-full mt-1 w-60 bg-white border border-gray-200 rounded-lg shadow-lg invisible group-hover:visible opacity-0 group-hover:opacity-100 transition-all z-10">
|
||||
<a href="/api/sdk/v1/einwilligungen/export?format=csv&kind=consents" download
|
||||
className="block px-4 py-2 text-sm text-gray-700 hover:bg-purple-50 first:rounded-t-lg">
|
||||
Einwilligungen als CSV
|
||||
</a>
|
||||
<a href="/api/sdk/v1/einwilligungen/export?format=json&kind=consents" download
|
||||
className="block px-4 py-2 text-sm text-gray-700 hover:bg-purple-50">
|
||||
Einwilligungen als JSON
|
||||
</a>
|
||||
<a href="/api/sdk/v1/einwilligungen/export?format=csv&kind=history" download
|
||||
className="block px-4 py-2 text-sm text-gray-700 hover:bg-purple-50 last:rounded-b-lg border-t border-gray-100">
|
||||
Aenderungs-Historie als CSV
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { FoundingWizardState } from '@/lib/sdk/founding/types'
|
||||
|
||||
interface Props {
|
||||
state: FoundingWizardState
|
||||
update: <K extends keyof FoundingWizardState>(k: K, v: FoundingWizardState[K]) => void
|
||||
}
|
||||
|
||||
export function StepBasics({ state, update }: Props) {
|
||||
const b = state.basics
|
||||
const [prefillStatus, setPrefillStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
|
||||
|
||||
async function prefillFromCompanyProfile() {
|
||||
setPrefillStatus('loading')
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/company-profile', { cache: 'no-store' })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const payload = await res.json()
|
||||
const p = payload?.profile ?? payload
|
||||
if (!p || typeof p !== 'object') throw new Error('leeres Profil')
|
||||
const industries = Array.isArray(p.industry) ? p.industry.filter(Boolean) : []
|
||||
const industry = industries.length > 0
|
||||
? industries.join(', ')
|
||||
: (p.industryOther || b.industry)
|
||||
const address = [p.headquartersStreet, [p.headquartersZip, p.headquartersCity].filter(Boolean).join(' ')]
|
||||
.filter(Boolean).join(', ') || b.company_address
|
||||
const seat = p.headquartersCity || b.company_seat
|
||||
// Purpose ableiten aus offerings/businessModel — Fallback wenn nichts da
|
||||
const purposeBits: string[] = []
|
||||
if (p.businessModel) purposeBits.push(`Geschäftsmodell: ${p.businessModel}`)
|
||||
if (Array.isArray(p.offerings) && p.offerings.length > 0)
|
||||
purposeBits.push(`Leistungen: ${p.offerings.join(', ')}`)
|
||||
const purpose = purposeBits.length > 0
|
||||
? purposeBits.join('; ')
|
||||
: b.company_purpose_description
|
||||
update('basics', {
|
||||
...b,
|
||||
company_name: p.companyName || b.company_name,
|
||||
legal_form: (p.legalForm === 'UG' ? 'UG' : (p.legalForm === 'GmbH' ? 'GmbH' : b.legal_form)),
|
||||
company_seat: seat,
|
||||
company_address: address,
|
||||
industry,
|
||||
company_purpose_description: b.company_purpose_description.trim() === '' ? purpose : b.company_purpose_description,
|
||||
})
|
||||
setPrefillStatus('success')
|
||||
} catch (err) {
|
||||
console.error('[founding-wizard] prefill failed', err)
|
||||
setPrefillStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600">
|
||||
Stammdaten der Gesellschaft. Pflicht für Satzung, HRB-Anmeldung und SHA.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={prefillFromCompanyProfile}
|
||||
disabled={prefillStatus === 'loading'}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-blue-300 bg-blue-50 hover:bg-blue-100 disabled:opacity-50"
|
||||
>
|
||||
{prefillStatus === 'loading' ? 'Lade…' : 'Aus Unternehmensprofil vorbefüllen'}
|
||||
</button>
|
||||
</div>
|
||||
{prefillStatus === 'success' && (
|
||||
<div className="text-xs text-green-700 bg-green-50 border border-green-200 rounded px-2 py-1">
|
||||
Daten aus Unternehmensprofil übernommen. Bitte prüfen und ergänzen.
|
||||
</div>
|
||||
)}
|
||||
{prefillStatus === 'error' && (
|
||||
<div className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1">
|
||||
Konnte Unternehmensprofil nicht laden — bitte Felder manuell ausfüllen.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Firmenname</label>
|
||||
<input
|
||||
data-testid="company-name"
|
||||
type="text"
|
||||
value={b.company_name}
|
||||
onChange={e => update('basics', { ...b, company_name: e.target.value })}
|
||||
placeholder="Breakpilot GmbH"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsform</label>
|
||||
<select
|
||||
data-testid="legal-form"
|
||||
value={b.legal_form}
|
||||
onChange={e => update('basics', { ...b, legal_form: e.target.value as 'GmbH' | 'UG' })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
>
|
||||
<option value="GmbH">GmbH</option>
|
||||
<option value="UG">UG (haftungsbeschränkt)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sitz (Stadt)</label>
|
||||
<input
|
||||
data-testid="company-seat"
|
||||
type="text"
|
||||
value={b.company_seat}
|
||||
onChange={e => update('basics', { ...b, company_seat: e.target.value })}
|
||||
placeholder="z.B. Stuttgart"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
|
||||
<input
|
||||
data-testid="company-address"
|
||||
type="text"
|
||||
value={b.company_address}
|
||||
onChange={e => update('basics', { ...b, company_address: e.target.value })}
|
||||
placeholder="Straße, PLZ Ort"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Branche</label>
|
||||
<input
|
||||
data-testid="industry"
|
||||
type="text"
|
||||
value={b.industry}
|
||||
onChange={e => update('basics', { ...b, industry: e.target.value })}
|
||||
placeholder="z.B. SaaS, Beratung, Handwerk"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Geschäftsjahr</label>
|
||||
<input
|
||||
data-testid="business-year"
|
||||
type="text"
|
||||
value={b.business_year}
|
||||
onChange={e => update('basics', { ...b, business_year: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Registergericht
|
||||
</label>
|
||||
<input
|
||||
data-testid="register-court"
|
||||
type="text"
|
||||
value={b.register_court || ''}
|
||||
onChange={e => update('basics', { ...b, register_court: e.target.value })}
|
||||
placeholder="z.B. Amtsgericht Stuttgart"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Zuständiges Amtsgericht für HRB-Eintragung
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
HRB-Nummer <span className="text-gray-400">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
data-testid="hrb-number"
|
||||
type="text"
|
||||
value={b.hrb_number || ''}
|
||||
onChange={e => update('basics', { ...b, hrb_number: e.target.value })}
|
||||
placeholder="z.B. HRB 12345 (leer falls noch nicht eingetragen)"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Unternehmensgegenstand (Volltext für § 2 Satzung)
|
||||
</label>
|
||||
<textarea
|
||||
data-testid="company-purpose"
|
||||
value={b.company_purpose_description}
|
||||
onChange={e => update('basics', { ...b, company_purpose_description: e.target.value })}
|
||||
rows={4}
|
||||
placeholder="z.B. die Entwicklung, Bereitstellung, der Betrieb und der Vertrieb von Softwarelösungen, Plattformen und IT-Dienstleistungen im Bereich der Künstlichen Intelligenz"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Detaillierte Tätigkeitsbereiche (eine Zeile pro Bullet)
|
||||
</label>
|
||||
<textarea
|
||||
data-testid="company-purpose-bullets"
|
||||
value={b.company_purpose_bullets.join('\n')}
|
||||
onChange={e => update('basics', { ...b, company_purpose_bullets: e.target.value.split('\n').filter(Boolean) })}
|
||||
rows={5}
|
||||
placeholder={'a) Entwicklung von Software\nb) Beratung im Bereich...\nc) ...'}
|
||||
className="w-full px-3 py-2 border rounded-lg font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="research_focus"
|
||||
data-testid="research-focus"
|
||||
checked={b.has_research_focus}
|
||||
onChange={e => update('basics', { ...b, has_research_focus: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="research_focus" className="text-sm text-gray-700">
|
||||
Forschungsfokus (aktiviert F&E-Klauseln in SHA und GO-GF)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import type { FoundingWizardState, GeneratedDocument } from '@/lib/sdk/founding/types'
|
||||
import { NOTARY_BUNDLE_DOCUMENTS } from '@/lib/sdk/founding/template-categories'
|
||||
|
||||
interface Props {
|
||||
state: FoundingWizardState
|
||||
update: <K extends keyof FoundingWizardState>(k: K, v: FoundingWizardState[K]) => void
|
||||
generating: boolean
|
||||
error: string | null
|
||||
onGenerate: () => Promise<GeneratedDocument[]>
|
||||
}
|
||||
|
||||
const DOC_LABELS: Record<string, string> = {
|
||||
articles_of_association: 'Satzung',
|
||||
gesellschafterliste: 'Gesellschafterliste (§ 40 GmbHG)',
|
||||
gf_bestellungsbeschluss: 'Gesellschafterbeschluss zur GF-Bestellung',
|
||||
hrb_anmeldung: 'Handelsregister-Anmeldung',
|
||||
sha: 'Shareholders\' Agreement (SHA)',
|
||||
geschaeftsordnung_gf: 'Geschäftsordnung Geschäftsführung (GO-GF)',
|
||||
managing_director_employment_contract: 'GF-Dienstvertrag (pro GF)',
|
||||
ip_assignment_agreement: 'IP-Assignment (pro Gründer)',
|
||||
term_sheet: 'Term Sheet',
|
||||
convertible_loan_agreement: 'Wandeldarlehensvertrag',
|
||||
subscription_agreement: 'Beteiligungsvertrag',
|
||||
esop_plan: 'ESOP/VSOP-Plan',
|
||||
cap_table: 'Cap Table',
|
||||
}
|
||||
|
||||
export function StepGenerate({ state, update, generating, error, onGenerate }: Props) {
|
||||
const toggleDoc = (docType: string) => {
|
||||
const next = state.selected_documents.includes(docType)
|
||||
? state.selected_documents.filter(d => d !== docType)
|
||||
: [...state.selected_documents, docType]
|
||||
update('selected_documents', next)
|
||||
}
|
||||
|
||||
const selectNotaryBundle = () => {
|
||||
update('selected_documents', [...NOTARY_BUNDLE_DOCUMENTS])
|
||||
}
|
||||
|
||||
const summary = useMemo(() => ({
|
||||
name: state.basics.company_name,
|
||||
seat: state.basics.company_seat,
|
||||
stammkapital: state.capital.stammkapital_eur,
|
||||
num_gesellschafter: state.gesellschafter.length,
|
||||
num_gf: state.gesellschafter.filter(g => g.is_geschaeftsfuehrer).length,
|
||||
}), [state])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-purple-900 mb-2">Zusammenfassung</h3>
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm" data-testid="generate-summary">
|
||||
<dt className="text-gray-600">Firma:</dt><dd>{summary.name} ({state.basics.legal_form})</dd>
|
||||
<dt className="text-gray-600">Sitz:</dt><dd>{summary.seat}</dd>
|
||||
<dt className="text-gray-600">Stammkapital:</dt><dd>{summary.stammkapital.toLocaleString('de-DE')} €</dd>
|
||||
<dt className="text-gray-600">Gesellschafter:</dt><dd>{summary.num_gesellschafter}</dd>
|
||||
<dt className="text-gray-600">Geschäftsführer:</dt><dd>{summary.num_gf}</dd>
|
||||
<dt className="text-gray-600">Notar:</dt><dd>{state.notar.notary_name} ({state.notar.notary_place})</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-semibold">Zu generierende Dokumente</h3>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="select-notary-bundle"
|
||||
onClick={selectNotaryBundle}
|
||||
className="text-sm text-purple-600 hover:underline"
|
||||
>
|
||||
➜ Notartermin-Bundle auswählen
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{Object.entries(DOC_LABELS).map(([docType, label]) => (
|
||||
<label key={docType} className="flex items-start gap-3 p-2 hover:bg-gray-50 rounded">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid={`doc-${docType}`}
|
||||
checked={state.selected_documents.includes(docType)}
|
||||
onChange={() => toggleDoc(docType)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
<div className="text-xs text-gray-500">{docType}</div>
|
||||
</div>
|
||||
{NOTARY_BUNDLE_DOCUMENTS.includes(docType) && (
|
||||
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded">Notartermin</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-4 border-t">
|
||||
<p className="text-sm text-gray-500">
|
||||
{state.selected_documents.length} Dokument(e) ausgewählt
|
||||
</p>
|
||||
<button
|
||||
data-testid="generate-docs"
|
||||
onClick={onGenerate}
|
||||
disabled={generating || state.selected_documents.length === 0}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 font-medium"
|
||||
>
|
||||
{generating ? 'Generiere...' : 'Dokumente als Word generieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-900" data-testid="generate-error">
|
||||
Fehler: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.generated_documents && state.generated_documents.length > 0 && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4" data-testid="generated-docs">
|
||||
<h3 className="font-semibold text-green-900 mb-3">
|
||||
✓ {state.generated_documents.length} Dokument(e) generiert
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{state.generated_documents.map((doc, idx) => (
|
||||
<li key={idx} className="flex justify-between items-center bg-white rounded px-3 py-2 border border-green-200">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{doc.title}</div>
|
||||
<div className="text-xs text-gray-500">{(doc.size_bytes / 1024).toFixed(1)} KB</div>
|
||||
</div>
|
||||
<a
|
||||
href={doc.download_url}
|
||||
download
|
||||
data-testid={`download-${doc.document_type}`}
|
||||
className="px-3 py-1.5 bg-green-600 text-white rounded text-sm hover:bg-green-700"
|
||||
>
|
||||
Word herunterladen
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { FoundingWizardState, Gesellschafter } from '@/lib/sdk/founding/types'
|
||||
|
||||
interface Props {
|
||||
state: FoundingWizardState
|
||||
addGesellschafter: (g: Omit<Gesellschafter, 'id' | 'anteil_nr'>) => void
|
||||
updateGesellschafter: (id: string, p: Partial<Gesellschafter>) => void
|
||||
removeGesellschafter: (id: string) => void
|
||||
}
|
||||
|
||||
export function StepGesellschafter({ state, addGesellschafter, updateGesellschafter, removeGesellschafter }: Props) {
|
||||
const [form, setForm] = useState({
|
||||
name: '', geburtsdatum: '', adresse: '', email: '',
|
||||
nennbetrag_eur: 12500, is_geschaeftsfuehrer: true, internal_role: '',
|
||||
has_academic_background: false, ip_areas: '',
|
||||
})
|
||||
|
||||
const totalNennbetrag = state.gesellschafter.reduce((s, g) => s + g.nennbetrag_eur, 0)
|
||||
const target = state.capital.stammkapital_eur
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!form.name.trim()) return
|
||||
const ip_areas = form.ip_areas
|
||||
.split('\n').map(s => s.trim()).filter(Boolean)
|
||||
addGesellschafter({
|
||||
rolle: 'founder',
|
||||
name: form.name,
|
||||
geburtsdatum: form.geburtsdatum || undefined,
|
||||
adresse: form.adresse,
|
||||
email: form.email || undefined,
|
||||
nennbetrag_eur: form.nennbetrag_eur,
|
||||
is_geschaeftsfuehrer: form.is_geschaeftsfuehrer,
|
||||
internal_role: form.internal_role || undefined,
|
||||
has_academic_background: form.has_academic_background,
|
||||
ip_areas: ip_areas.length > 0 ? ip_areas : undefined,
|
||||
})
|
||||
setForm({ name: '', geburtsdatum: '', adresse: '', email: '', nennbetrag_eur: 12500,
|
||||
is_geschaeftsfuehrer: true, internal_role: '', has_academic_background: false, ip_areas: '' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold mb-3">Neuen Gesellschafter hinzufügen</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input
|
||||
data-testid="gs-name"
|
||||
placeholder="Name"
|
||||
value={form.name}
|
||||
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
className="px-3 py-2 border rounded"
|
||||
/>
|
||||
<input
|
||||
data-testid="gs-birthdate"
|
||||
type="date"
|
||||
placeholder="Geburtsdatum"
|
||||
value={form.geburtsdatum}
|
||||
onChange={e => setForm({ ...form, geburtsdatum: e.target.value })}
|
||||
className="px-3 py-2 border rounded"
|
||||
/>
|
||||
<input
|
||||
data-testid="gs-address"
|
||||
placeholder="Adresse (Straße, PLZ Ort)"
|
||||
value={form.adresse}
|
||||
onChange={e => setForm({ ...form, adresse: e.target.value })}
|
||||
className="px-3 py-2 border rounded col-span-2"
|
||||
/>
|
||||
<input
|
||||
data-testid="gs-email"
|
||||
type="email"
|
||||
placeholder="E-Mail (optional)"
|
||||
value={form.email}
|
||||
onChange={e => setForm({ ...form, email: e.target.value })}
|
||||
className="px-3 py-2 border rounded"
|
||||
/>
|
||||
<input
|
||||
data-testid="gs-nennbetrag"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
placeholder="Nennbetrag in EUR"
|
||||
value={form.nennbetrag_eur}
|
||||
onChange={e => setForm({ ...form, nennbetrag_eur: parseInt(e.target.value) || 0 })}
|
||||
className="px-3 py-2 border rounded"
|
||||
/>
|
||||
<select
|
||||
data-testid="gs-role"
|
||||
value={form.internal_role}
|
||||
onChange={e => setForm({ ...form, internal_role: e.target.value })}
|
||||
className="px-3 py-2 border rounded bg-white"
|
||||
>
|
||||
<option value="">Rolle wählen…</option>
|
||||
<option value="CEO">CEO (Chief Executive Officer)</option>
|
||||
<option value="CTO">CTO (Chief Technical Officer)</option>
|
||||
<option value="CFO">CFO (Chief Financial Officer)</option>
|
||||
<option value="COO">COO (Chief Operating Officer)</option>
|
||||
<option value="CPO">CPO (Chief Product Officer)</option>
|
||||
<option value="Geschäftsführer">Geschäftsführer (ohne Spezialisierung)</option>
|
||||
<option value="Gesellschafter">Gesellschafter (kein GF)</option>
|
||||
<option value="Sonstige">Sonstige</option>
|
||||
</select>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="gs-is-gf"
|
||||
checked={form.is_geschaeftsfuehrer}
|
||||
onChange={e => setForm({ ...form, is_geschaeftsfuehrer: e.target.checked })}
|
||||
/>
|
||||
<label className="text-sm">Geschäftsführer/in</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="gs-academic"
|
||||
checked={form.has_academic_background}
|
||||
onChange={e => setForm({ ...form, has_academic_background: e.target.checked })}
|
||||
/>
|
||||
<label className="text-sm">Akademischer Hintergrund</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
IP-Bereiche, die diese Person in die Gesellschaft einbringt
|
||||
<span className="text-gray-400"> (optional, eine Zeile pro Bereich)</span>
|
||||
</label>
|
||||
<textarea
|
||||
data-testid="gs-ip-areas"
|
||||
value={form.ip_areas}
|
||||
onChange={e => setForm({ ...form, ip_areas: e.target.value })}
|
||||
rows={3}
|
||||
placeholder={'z.B.\nCompliance-Engine (Quellcode + Architektur)\nRAG-Pipeline\nKonfigurationsdaten'}
|
||||
className="w-full px-3 py-2 border rounded font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Bei mehreren Gründern wird pro Person ein eigener IP-Assignment-Vertrag generiert.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
data-testid="add-gesellschafter"
|
||||
onClick={handleAdd}
|
||||
disabled={!form.name.trim() || form.nennbetrag_eur < 1}
|
||||
className="mt-3 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
Gesellschafter hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Gesellschafter ({state.gesellschafter.length})</h3>
|
||||
{state.gesellschafter.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">Noch keine Gesellschafter angelegt.</p>
|
||||
) : (
|
||||
<table className="w-full text-sm" data-testid="gs-table">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Nr.</th>
|
||||
<th className="px-3 py-2 text-left">Name</th>
|
||||
<th className="px-3 py-2 text-left">Geburtsdatum</th>
|
||||
<th className="px-3 py-2 text-right">Nennbetrag</th>
|
||||
<th className="px-3 py-2 text-right">Anteil %</th>
|
||||
<th className="px-3 py-2">GF?</th>
|
||||
<th className="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{state.gesellschafter.map(g => (
|
||||
<tr key={g.id} className="border-t" data-testid={`gs-row-${g.anteil_nr}`}>
|
||||
<td className="px-3 py-2">{g.anteil_nr}</td>
|
||||
<td className="px-3 py-2 font-medium">
|
||||
{g.name}{g.internal_role ? ` (${g.internal_role})` : ''}
|
||||
{g.ip_areas && g.ip_areas.length > 0 && (
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
IP: {g.ip_areas.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">{g.geburtsdatum || '—'}</td>
|
||||
<td className="px-3 py-2 text-right">{g.nennbetrag_eur.toLocaleString('de-DE')} €</td>
|
||||
<td className="px-3 py-2 text-right">{((g.nennbetrag_eur / Math.max(target, 1)) * 100).toFixed(2)}%</td>
|
||||
<td className="px-3 py-2 text-center">{g.is_geschaeftsfuehrer ? '✓' : '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<button
|
||||
onClick={() => removeGesellschafter(g.id)}
|
||||
className="text-red-600 hover:underline text-xs"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="border-t-2 font-semibold bg-gray-50">
|
||||
<td colSpan={3} className="px-3 py-2">Summe</td>
|
||||
<td className="px-3 py-2 text-right" data-testid="gs-total">
|
||||
{totalNennbetrag.toLocaleString('de-DE')} €
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{totalNennbetrag === target ? '100%' : `≠ ${target.toLocaleString('de-DE')} €`}
|
||||
</td>
|
||||
<td colSpan={2}></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{totalNennbetrag !== target && state.gesellschafter.length > 0 && (
|
||||
<p className="mt-2 text-sm text-orange-600">
|
||||
⚠ Die Summe der Nennbeträge ({totalNennbetrag.toLocaleString('de-DE')} €)
|
||||
entspricht nicht dem Stammkapital ({target.toLocaleString('de-DE')} €).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Kombinierte einfache Steps: Geschäftsführer (3), Kapital (4), Notar (5), SHA (6).
|
||||
* Jeder Sub-Step ist eine simple Form.
|
||||
*/
|
||||
|
||||
import type { FoundingWizardState, GFContract } from '@/lib/sdk/founding/types'
|
||||
|
||||
interface PropsBase {
|
||||
state: FoundingWizardState
|
||||
update: <K extends keyof FoundingWizardState>(k: K, v: FoundingWizardState[K]) => void
|
||||
}
|
||||
|
||||
export function StepGFAssignment({ state, update }: PropsBase) {
|
||||
const founders = state.gesellschafter
|
||||
const toggleGF = (id: string, val: boolean) => {
|
||||
update('gesellschafter', state.gesellschafter.map(g => g.id === id ? { ...g, is_geschaeftsfuehrer: val } : g))
|
||||
}
|
||||
const setRole = (id: string, role: string) => {
|
||||
update('gesellschafter', state.gesellschafter.map(g => g.id === id ? { ...g, internal_role: role } : g))
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Wähle, welche Gesellschafter zu Geschäftsführern bestellt werden sollen. Standardmäßig sind alle Gründer auch GF.
|
||||
</p>
|
||||
{founders.length === 0 ? (
|
||||
<p className="text-orange-600">Bitte zuerst Gesellschafter in Step 2 anlegen.</p>
|
||||
) : (
|
||||
<table className="w-full text-sm" data-testid="gf-assignment-table">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Gesellschafter</th>
|
||||
<th className="px-3 py-2 text-left">Interne Rolle (CEO, CTO, ...)</th>
|
||||
<th className="px-3 py-2">GF?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{founders.map(g => (
|
||||
<tr key={g.id} className="border-t">
|
||||
<td className="px-3 py-2 font-medium">{g.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
value={g.internal_role || ''}
|
||||
onChange={e => setRole(g.id, e.target.value)}
|
||||
className="px-2 py-1 border rounded w-48"
|
||||
placeholder="CEO, CTO, COO..."
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid={`gf-toggle-${g.anteil_nr}`}
|
||||
checked={g.is_geschaeftsfuehrer}
|
||||
onChange={e => toggleGF(g.id, e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StepCapital({ state, update }: PropsBase) {
|
||||
const c = state.capital
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Stammkapital (EUR)</label>
|
||||
<input
|
||||
data-testid="stammkapital"
|
||||
type="number" min={1} step={1}
|
||||
value={c.stammkapital_eur}
|
||||
onChange={e => update('capital', { ...c, stammkapital_eur: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">GmbH: mind. 25.000 €, UG: ab 1 €</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Einlage-Art</label>
|
||||
<select
|
||||
data-testid="einlage-method"
|
||||
value={c.einlage_method}
|
||||
onChange={e => update('capital', { ...c, einlage_method: e.target.value as typeof c.einlage_method })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
>
|
||||
<option value="Geld">Bargründung</option>
|
||||
<option value="Sacheinlage">Sachgründung</option>
|
||||
<option value="Geld und Sacheinlage">Misch-Gründung</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Sofortige Einzahlung (%)
|
||||
</label>
|
||||
<input
|
||||
data-testid="einlage-quote"
|
||||
type="number" min={25} max={100}
|
||||
value={c.einlage_quote_initial_pct}
|
||||
onChange={e => update('capital', { ...c, einlage_quote_initial_pct: parseInt(e.target.value) || 50 })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Mind. 25% gem. § 7 Abs. 2 GmbHG, Standard 50%</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-7">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="has_sach"
|
||||
data-testid="has-sacheinlage"
|
||||
checked={c.has_sacheinlage}
|
||||
onChange={e => update('capital', { ...c, has_sacheinlage: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="has_sach" className="text-sm">Sacheinlage-Klausel aktivieren</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StepNotar({ state, update }: PropsBase) {
|
||||
const n = state.notar
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name des Notars</label>
|
||||
<input
|
||||
data-testid="notary-name"
|
||||
value={n.notary_name}
|
||||
onChange={e => update('notar', { ...n, notary_name: e.target.value })}
|
||||
placeholder="z.B. Dr. Müller"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notarsitz</label>
|
||||
<input
|
||||
data-testid="notary-place"
|
||||
value={n.notary_place}
|
||||
onChange={e => update('notar', { ...n, notary_place: e.target.value })}
|
||||
placeholder="z.B. Stuttgart"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
|
||||
<input
|
||||
data-testid="notary-address"
|
||||
value={n.notary_address || ''}
|
||||
onChange={e => update('notar', { ...n, notary_address: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Geplanter Notartermin</label>
|
||||
<input
|
||||
data-testid="notarial-date"
|
||||
type="date"
|
||||
value={n.notarial_date || ''}
|
||||
onChange={e => update('notar', { ...n, notarial_date: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-sm text-blue-900">
|
||||
<strong>Hinweis:</strong> Die URNr. wird vom Notar beim Beurkundungstermin vergeben. Du kannst die generierte
|
||||
HRB-Anmeldung als Vorbereitungsdokument zum Termin mitnehmen.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StepSHAConfig({ state, update }: PropsBase) {
|
||||
const s = state.sha
|
||||
const updateField = <K extends keyof typeof s>(k: K, v: typeof s[K]) => update('sha', { ...s, [k]: v })
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="has-sha"
|
||||
checked={s.has_sha}
|
||||
onChange={e => updateField('has_sha', e.target.checked)}
|
||||
/>
|
||||
<label className="text-sm font-medium">SHA (Shareholders' Agreement) ist Teil des Notartermin-Pakets</label>
|
||||
</div>
|
||||
|
||||
{s.has_sha && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Vesting-Dauer (Monate)</label>
|
||||
<input data-testid="vesting-months" type="number" value={s.vesting_months}
|
||||
onChange={e => updateField('vesting_months', parseInt(e.target.value) || 48)}
|
||||
className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Cliff (Monate)</label>
|
||||
<input data-testid="cliff-months" type="number" value={s.cliff_months}
|
||||
onChange={e => updateField('cliff_months', parseInt(e.target.value) || 12)}
|
||||
className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Drag-Along Schwelle (%)</label>
|
||||
<input data-testid="drag-along-pct" type="number" value={s.drag_along_threshold_pct}
|
||||
onChange={e => updateField('drag_along_threshold_pct', parseInt(e.target.value) || 75)}
|
||||
className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Reserved-Matters Mehrheit (%)</label>
|
||||
<input data-testid="reserved-matters-pct" type="number" value={s.reserved_matters_majority_pct}
|
||||
onChange={e => updateField('reserved_matters_majority_pct', parseInt(e.target.value) || 75)}
|
||||
className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div className="col-span-2 grid grid-cols-3 gap-3 mt-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" data-testid="has-beirat" checked={s.has_beirat}
|
||||
onChange={e => updateField('has_beirat', e.target.checked)} />
|
||||
Beirat einrichten
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" data-testid="has-texas" checked={s.has_texas_shootout}
|
||||
onChange={e => updateField('has_texas_shootout', e.target.checked)} />
|
||||
Texas Shoot-Out (Deadlock)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" data-testid="has-ceo" checked={s.has_ceo_designation}
|
||||
onChange={e => updateField('has_ceo_designation', e.target.checked)} />
|
||||
CEO mit Stichentscheid
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface GFContractStepProps extends PropsBase {
|
||||
gf_list: Array<{ id: string; name: string; internal_role?: string }>
|
||||
upsertGFContract: (c: GFContract) => void
|
||||
}
|
||||
|
||||
export function StepGFContracts({ state, gf_list, upsertGFContract }: GFContractStepProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Für jeden Geschäftsführer wird ein Dienstvertrag generiert. Bitte Eckdaten ausfüllen.
|
||||
</p>
|
||||
{gf_list.length === 0 ? (
|
||||
<p className="text-orange-600">Bitte zuerst in Step 2 mindestens einen GF anlegen.</p>
|
||||
) : (
|
||||
gf_list.map(gf => {
|
||||
const c = state.gf_contracts.find(x => x.gesellschafter_id === gf.id) || {
|
||||
gesellschafter_id: gf.id,
|
||||
gross_annual_salary_eur: 84000,
|
||||
has_bonus: false,
|
||||
has_company_car: false,
|
||||
has_bav: false,
|
||||
vacation_days: 30,
|
||||
kuendigungsfrist_gesellschaft_monate: 6,
|
||||
kuendigungsfrist_gf_monate: 3,
|
||||
para_181_release: true,
|
||||
sv_status: 'sozialversicherungsfrei' as const,
|
||||
}
|
||||
const u = (patch: Partial<GFContract>) => upsertGFContract({ ...c, ...patch })
|
||||
return (
|
||||
<div key={gf.id} className="border rounded-lg p-4" data-testid={`contract-${gf.id}`}>
|
||||
<h4 className="font-semibold mb-3">{gf.name} {gf.internal_role && `(${gf.internal_role})`}</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-700 mb-1">Jahresgehalt (EUR brutto)</label>
|
||||
<input
|
||||
data-testid={`salary-${gf.id}`}
|
||||
type="number"
|
||||
value={c.gross_annual_salary_eur}
|
||||
onChange={e => u({ gross_annual_salary_eur: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-2 py-1 border rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-700 mb-1">Urlaubstage</label>
|
||||
<input type="number" value={c.vacation_days}
|
||||
onChange={e => u({ vacation_days: parseInt(e.target.value) || 30 })}
|
||||
className="w-full px-2 py-1 border rounded" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-700 mb-1">SV-Status</label>
|
||||
<select value={c.sv_status} onChange={e => u({ sv_status: e.target.value as GFContract['sv_status'] })}
|
||||
className="w-full px-2 py-1 border rounded">
|
||||
<option value="sozialversicherungsfrei">sv-frei (Standard für GF/Gesellschafter)</option>
|
||||
<option value="sozialversicherungspflichtig">sv-pflichtig</option>
|
||||
<option value="noch zu klären">noch zu klären</option>
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={c.para_181_release}
|
||||
onChange={e => u({ para_181_release: e.target.checked })} />
|
||||
§ 181 BGB-Befreiung
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={c.has_bonus}
|
||||
onChange={e => u({ has_bonus: e.target.checked })} />
|
||||
Bonus-Vereinbarung
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={c.has_company_car}
|
||||
onChange={e => u({ has_company_car: e.target.checked })} />
|
||||
Firmenfahrzeug
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
defaultFoundingWizardState,
|
||||
type FoundingWizardState,
|
||||
type Gesellschafter,
|
||||
type GFContract,
|
||||
type GeneratedDocument,
|
||||
} from '@/lib/sdk/founding/types'
|
||||
|
||||
const STORAGE_KEY = 'breakpilot:founding-wizard:state:v1'
|
||||
|
||||
export const FOUNDING_WIZARD_STEPS = [
|
||||
{ id: 1, name: 'Stage & Basics', description: 'Unternehmensname, Sitz, Gegenstand' },
|
||||
{ id: 2, name: 'Gesellschafter', description: 'Gründer und ihre Anteile' },
|
||||
{ id: 3, name: 'Geschäftsführer', description: 'GF-Bestellung und Rollen' },
|
||||
{ id: 4, name: 'Kapital', description: 'Stammkapital und Einzahlung' },
|
||||
{ id: 5, name: 'Notar', description: 'Notartermin und Beurkundung' },
|
||||
{ id: 6, name: 'SHA-Optionen', description: 'Vesting, Drag-Along, Reserved Matters' },
|
||||
{ id: 7, name: 'GF-Verträge', description: 'Vergütung, D&O, Kündigungsfristen' },
|
||||
{ id: 8, name: 'Dokumente generieren', description: 'Auswahl und Word-Export' },
|
||||
]
|
||||
|
||||
export function useFoundingWizardForm() {
|
||||
const [state, setState] = useState<FoundingWizardState>(defaultFoundingWizardState())
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Hydrate from localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw)
|
||||
setState({ ...defaultFoundingWizardState(), ...parsed })
|
||||
}
|
||||
} catch {
|
||||
// ignore corrupted storage
|
||||
}
|
||||
setHydrated(true)
|
||||
}, [])
|
||||
|
||||
// Persist on every change after hydration
|
||||
useEffect(() => {
|
||||
if (!hydrated) return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||
} catch {
|
||||
// quota exceeded - ignore
|
||||
}
|
||||
}, [state, hydrated])
|
||||
|
||||
const update = useCallback(<K extends keyof FoundingWizardState>(
|
||||
key: K,
|
||||
value: FoundingWizardState[K] | ((prev: FoundingWizardState[K]) => FoundingWizardState[K])
|
||||
) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
[key]: typeof value === 'function' ? (value as Function)(prev[key]) : value,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const setStep = useCallback((step: number) => {
|
||||
setState(prev => ({ ...prev, current_step: step }))
|
||||
}, [])
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
setState(prev => ({ ...prev, current_step: Math.min(prev.current_step + 1, FOUNDING_WIZARD_STEPS.length) }))
|
||||
}, [])
|
||||
|
||||
const prevStep = useCallback(() => {
|
||||
setState(prev => ({ ...prev, current_step: Math.max(prev.current_step - 1, 1) }))
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState(defaultFoundingWizardState())
|
||||
try { localStorage.removeItem(STORAGE_KEY) } catch {}
|
||||
}, [])
|
||||
|
||||
// Gesellschafter helpers
|
||||
const addGesellschafter = useCallback((gs: Omit<Gesellschafter, 'id' | 'anteil_nr'>) => {
|
||||
setState(prev => {
|
||||
const nextNr = (prev.gesellschafter.reduce((m, g) => Math.max(m, g.anteil_nr), 0)) + 1
|
||||
const id = `gs_${Date.now()}_${nextNr}`
|
||||
return { ...prev, gesellschafter: [...prev.gesellschafter, { ...gs, id, anteil_nr: nextNr }] }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updateGesellschafter = useCallback((id: string, patch: Partial<Gesellschafter>) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
gesellschafter: prev.gesellschafter.map(g => g.id === id ? { ...g, ...patch } : g),
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const removeGesellschafter = useCallback((id: string) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
gesellschafter: prev.gesellschafter.filter(g => g.id !== id),
|
||||
gf_contracts: prev.gf_contracts.filter(c => c.gesellschafter_id !== id),
|
||||
}))
|
||||
}, [])
|
||||
|
||||
// GF Contract helpers
|
||||
const upsertGFContract = useCallback((contract: GFContract) => {
|
||||
setState(prev => {
|
||||
const idx = prev.gf_contracts.findIndex(c => c.gesellschafter_id === contract.gesellschafter_id)
|
||||
const next = [...prev.gf_contracts]
|
||||
if (idx >= 0) next[idx] = contract
|
||||
else next.push(contract)
|
||||
return { ...prev, gf_contracts: next }
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Validation (canProceed for current step)
|
||||
const canProceed = useMemo(() => {
|
||||
switch (state.current_step) {
|
||||
case 1:
|
||||
return state.basics.company_name.trim().length > 1 &&
|
||||
state.basics.company_seat.trim().length > 1 &&
|
||||
state.basics.company_purpose_description.trim().length > 10
|
||||
case 2: {
|
||||
if (state.gesellschafter.length < 1) return false
|
||||
const sum = state.gesellschafter.reduce((s, g) => s + (g.nennbetrag_eur || 0), 0)
|
||||
return sum === state.capital.stammkapital_eur
|
||||
}
|
||||
case 3:
|
||||
return state.gesellschafter.some(g => g.is_geschaeftsfuehrer)
|
||||
case 4:
|
||||
return state.capital.stammkapital_eur >= 25000
|
||||
case 5:
|
||||
return state.notar.notary_name.trim().length > 1 && state.notar.notary_place.trim().length > 1
|
||||
case 6:
|
||||
return true
|
||||
case 7:
|
||||
return state.gesellschafter.filter(g => g.is_geschaeftsfuehrer)
|
||||
.every(g => state.gf_contracts.some(c => c.gesellschafter_id === g.id))
|
||||
case 8:
|
||||
return state.selected_documents.length > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, [state])
|
||||
|
||||
const generateDocuments = useCallback(async (): Promise<GeneratedDocument[]> => {
|
||||
setGenerating(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await fetch('/api/v1/founding-wizard/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(state),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Generierung fehlgeschlagen: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
const docs: GeneratedDocument[] = data.documents || []
|
||||
setState(prev => ({ ...prev, generated_documents: docs }))
|
||||
return docs
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Unbekannter Fehler'
|
||||
setError(msg)
|
||||
throw e
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [state])
|
||||
|
||||
// Derived: hat zugehöriger GF einen Vertrag?
|
||||
const gf_list = useMemo(
|
||||
() => state.gesellschafter.filter(g => g.is_geschaeftsfuehrer),
|
||||
[state.gesellschafter]
|
||||
)
|
||||
|
||||
return {
|
||||
state, hydrated, generating, error,
|
||||
update, setStep, nextStep, prevStep, reset,
|
||||
addGesellschafter, updateGesellschafter, removeGesellschafter,
|
||||
upsertGFContract,
|
||||
canProceed, generateDocuments,
|
||||
gf_list,
|
||||
steps: FOUNDING_WIZARD_STEPS,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { useFoundingWizardForm } from './_hooks/useFoundingWizardForm'
|
||||
import { StepBasics } from './_components/StepBasics'
|
||||
import { StepGesellschafter } from './_components/StepGesellschafter'
|
||||
import { StepCapital, StepGFAssignment, StepGFContracts, StepNotar, StepSHAConfig } from './_components/StepsSimpleConfig'
|
||||
import { StepGenerate } from './_components/StepGenerate'
|
||||
|
||||
export default function FoundingWizardPage() {
|
||||
const {
|
||||
state, hydrated, generating, error,
|
||||
update, nextStep, prevStep, reset,
|
||||
addGesellschafter, updateGesellschafter, removeGesellschafter,
|
||||
upsertGFContract,
|
||||
canProceed, generateDocuments,
|
||||
gf_list, steps,
|
||||
} = useFoundingWizardForm()
|
||||
|
||||
if (!hydrated) return null
|
||||
|
||||
const isLastStep = state.current_step === steps.length
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8" data-testid="founding-wizard">
|
||||
<div className="max-w-5xl mx-auto px-4">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Gründungs-Wizard</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Erstellt alle Notartermin-Dokumente für Deine GmbH/UG-Gründung in 8 Schritten.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
data-testid="reset-wizard"
|
||||
onClick={() => { if (confirm('Wizard-Daten zurücksetzen?')) reset() }}
|
||||
className="text-sm text-gray-500 hover:text-red-600"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="mb-8" data-testid="wizard-progress">
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((step, idx) => (
|
||||
<React.Fragment key={step.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.current_step > step.id && update('current_step', step.id)}
|
||||
className="flex items-center"
|
||||
data-testid={`step-indicator-${step.id}`}
|
||||
>
|
||||
<div className={`w-9 h-9 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
step.id < state.current_step ? 'bg-purple-600 text-white' :
|
||||
step.id === state.current_step ? 'bg-purple-100 text-purple-600 border-2 border-purple-600' :
|
||||
'bg-gray-100 text-gray-400'
|
||||
}`}>
|
||||
{step.id < state.current_step ? '✓' : step.id}
|
||||
</div>
|
||||
<div className="ml-2 hidden md:block text-left">
|
||||
<div className={`text-xs font-medium ${step.id <= state.current_step ? 'text-gray-900' : 'text-gray-400'}`}>
|
||||
{step.name}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{idx < steps.length - 1 && (
|
||||
<div className={`flex-1 h-0.5 mx-2 ${step.id < state.current_step ? 'bg-purple-600' : 'bg-gray-200'}`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{steps[state.current_step - 1]?.name}
|
||||
</h2>
|
||||
<p className="text-gray-500 text-sm">{steps[state.current_step - 1]?.description}</p>
|
||||
</div>
|
||||
|
||||
<div data-testid={`step-content-${state.current_step}`}>
|
||||
{state.current_step === 1 && <StepBasics state={state} update={update} />}
|
||||
{state.current_step === 2 && (
|
||||
<StepGesellschafter
|
||||
state={state}
|
||||
addGesellschafter={addGesellschafter}
|
||||
updateGesellschafter={updateGesellschafter}
|
||||
removeGesellschafter={removeGesellschafter}
|
||||
/>
|
||||
)}
|
||||
{state.current_step === 3 && <StepGFAssignment state={state} update={update} />}
|
||||
{state.current_step === 4 && <StepCapital state={state} update={update} />}
|
||||
{state.current_step === 5 && <StepNotar state={state} update={update} />}
|
||||
{state.current_step === 6 && <StepSHAConfig state={state} update={update} />}
|
||||
{state.current_step === 7 && (
|
||||
<StepGFContracts state={state} update={update} gf_list={gf_list} upsertGFContract={upsertGFContract} />
|
||||
)}
|
||||
{state.current_step === 8 && (
|
||||
<StepGenerate
|
||||
state={state}
|
||||
update={update}
|
||||
generating={generating}
|
||||
error={error}
|
||||
onGenerate={generateDocuments}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
{!isLastStep && (
|
||||
<div className="flex justify-between items-center mt-8 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
data-testid="prev-step"
|
||||
onClick={prevStep}
|
||||
disabled={state.current_step === 1}
|
||||
className="px-6 py-3 text-gray-600 hover:text-gray-900 disabled:opacity-50"
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
<span className="text-xs text-gray-400">
|
||||
Schritt {state.current_step} von {steps.length}
|
||||
</span>
|
||||
<button
|
||||
data-testid="next-step"
|
||||
onClick={nextStep}
|
||||
disabled={!canProceed}
|
||||
className="px-8 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+265
-30
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import type { HazardMatchPair, GroundTruthEntry, HazardSummary } from '../_hooks/useBenchmark'
|
||||
|
||||
interface Props {
|
||||
@@ -11,13 +12,57 @@ interface Props {
|
||||
|
||||
type TabType = 'matched' | 'missing' | 'extra'
|
||||
|
||||
// Per-hazard clarification status fetched once and shared with all detail rows.
|
||||
type HazardClarStatus = { open: number; answered: number; total: number }
|
||||
|
||||
function useClarificationsByHazard(projectId: string | undefined): Record<string, HazardClarStatus> {
|
||||
const [byHz, setByHz] = useState<Record<string, HazardClarStatus>>({})
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
let cancelled = false
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/clarifications`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => {
|
||||
if (cancelled || !d?.clarifications) return
|
||||
const out: Record<string, HazardClarStatus> = {}
|
||||
for (const c of d.clarifications as Array<{ affected_hazard_ids: string[]; status: string }>) {
|
||||
const isOpen = c.status !== 'answered' && c.status !== 'not_relevant'
|
||||
for (const hid of c.affected_hazard_ids) {
|
||||
if (!out[hid]) out[hid] = { open: 0, answered: 0, total: 0 }
|
||||
out[hid].total += 1
|
||||
if (isOpen) out[hid].open += 1
|
||||
else out[hid].answered += 1
|
||||
}
|
||||
}
|
||||
setByHz(out)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => { cancelled = true }
|
||||
}, [projectId])
|
||||
return byHz
|
||||
}
|
||||
|
||||
export function HazardComparisonTable({ matched, missing, extra }: Props) {
|
||||
const [tab, setTab] = useState<TabType>('matched')
|
||||
const params = useParams()
|
||||
const projectId = params?.projectId as string | undefined
|
||||
const clarStatusByHazard = useClarificationsByHazard(projectId)
|
||||
|
||||
// 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', count: matched.length, color: 'text-green-600' },
|
||||
{ id: 'missing', label: 'Fehlend', count: missing.length, color: 'text-red-600' },
|
||||
{ id: 'extra', label: 'Zusaetzlich', count: extra.length, color: 'text-gray-500' },
|
||||
{ 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 (
|
||||
@@ -40,15 +85,16 @@ export function HazardComparisonTable({ matched, missing, extra }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{tab === 'matched' && <MatchedTable pairs={matched} />}
|
||||
{tab === 'missing' && <MissingTable entries={missing} />}
|
||||
{tab === 'extra' && <ExtraTable entries={extra} />}
|
||||
{tab === 'matched' && <MatchedTable pairs={realMatched} clarStatusByHazard={clarStatusByHazard} projectId={projectId} />}
|
||||
{tab === 'missing' && <MissingTable entries={allMissing} />}
|
||||
{tab === 'extra' && <ExtraTable entries={allExtra} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) {
|
||||
function MatchedTable({ pairs, clarStatusByHazard, projectId }: { pairs: HazardMatchPair[]; clarStatusByHazard: Record<string, HazardClarStatus>; projectId?: string }) {
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
|
||||
if (pairs.length === 0) return <EmptyState text="Keine Zuordnungen gefunden" />
|
||||
return (
|
||||
<table className="w-full text-xs">
|
||||
@@ -56,38 +102,213 @@ function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) {
|
||||
<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(GT)</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">Match</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) => (
|
||||
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||
<td className="px-3 py-2 text-gray-400">{p.gt_entry.nr}</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 text-gray-400">{p.match_reason}</td>
|
||||
</tr>
|
||||
))}
|
||||
{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}
|
||||
clarStatus={clarStatusByHazard[p.engine_hazard.id]}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</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, clarStatus, projectId }: {
|
||||
gt: GroundTruthEntry
|
||||
engine: HazardSummary
|
||||
clarStatus?: HazardClarStatus
|
||||
projectId?: string
|
||||
}) {
|
||||
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 || extractScenario(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)" />
|
||||
)}
|
||||
{clarStatus && clarStatus.total > 0 && (
|
||||
<ClarificationBanner status={clarStatus} projectId={projectId} />
|
||||
)}
|
||||
{(() => {
|
||||
const norms = extractEngineNorms(engine.description)
|
||||
if (norms.length === 0) return null
|
||||
return <DetailRow label="Referenzierte Normen" gt={norms.join(' | ')} />
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Go init handler appends two annotated blocks to Hazard.Description:
|
||||
* "<scenario>\n\nMit Anlagenbauer zu klaeren:\n- frage 1\n- frage 2\n\n
|
||||
* Referenzierte Normen: EN 60204-1 Ziff. 6.2 | EN 61140"
|
||||
* These helpers split that string back into structured pieces so the UI
|
||||
* can render scenario, clarifications and norms as separate sections.
|
||||
*/
|
||||
function extractScenario(desc?: string): string {
|
||||
if (!desc) return ''
|
||||
const idx = desc.indexOf('\n\nMit Anlagenbauer zu klaeren')
|
||||
const cut = idx >= 0 ? desc.slice(0, idx) : desc
|
||||
// Also cut off a trailing norm line if it's the only suffix
|
||||
const normIdx = cut.indexOf('\n\nReferenzierte Normen')
|
||||
return (normIdx >= 0 ? cut.slice(0, normIdx) : cut).trim()
|
||||
}
|
||||
|
||||
// (extractClarifications removed in Phase 2 — clarifications are loaded
|
||||
// from the dedicated /clarifications API and rendered as a status banner
|
||||
// instead of being parsed out of the hazard description.)
|
||||
|
||||
function ClarificationBanner({ status, projectId }: { status: HazardClarStatus; projectId?: string }) {
|
||||
const allDone = status.open === 0
|
||||
const href = projectId ? `/sdk/iace/${projectId}/clarifications` : '#'
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-gray-500 uppercase">Klärungen</div>
|
||||
<a
|
||||
href={href}
|
||||
className={`mt-0.5 inline-flex items-center gap-2 px-3 py-1.5 rounded border text-xs ${
|
||||
allDone
|
||||
? 'bg-green-50 border-green-200 text-green-800 hover:bg-green-100'
|
||||
: 'bg-orange-50 border-orange-200 text-orange-800 hover:bg-orange-100'
|
||||
}`}
|
||||
>
|
||||
{allDone ? (
|
||||
<>
|
||||
<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>
|
||||
Alle {status.total} Klärungen beantwortet
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
{status.open} offene Klärung{status.open === 1 ? '' : 'en'} {status.answered > 0 && `(${status.answered} beantwortet)`} — Klärungen-Seite öffnen
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function extractEngineNorms(desc?: string): string[] {
|
||||
if (!desc) return []
|
||||
const m = desc.match(/Referenzierte Normen:\s*([^\n]+)/)
|
||||
if (!m) return []
|
||||
return m[1].split('|').map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
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 (
|
||||
@@ -153,6 +374,20 @@ function ScoreBadge({ score }: { score: number }) {
|
||||
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>
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ export interface GroundTruthEntry {
|
||||
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 {
|
||||
|
||||
@@ -12,7 +12,9 @@ export default function BenchmarkPage() {
|
||||
const { result, gtLoaded, gtEntryCount, loading, error, importGT, runBenchmark } = useBenchmark(projectId)
|
||||
const [gtProjectId, setGtProjectId] = useState('')
|
||||
|
||||
const coveragePct = result ? Math.round(result.coverage_score * 100) : 0
|
||||
// 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 (
|
||||
@@ -74,7 +76,7 @@ export default function BenchmarkPage() {
|
||||
<ScoreCard
|
||||
label="Hazard Coverage"
|
||||
value={`${coveragePct}%`}
|
||||
sub={`${result.matched_pairs?.length || 0} / ${result.total_gt} erkannt`}
|
||||
sub={`${realMatchCount} / ${result.total_gt} erkannt (>= 50% Match)`}
|
||||
color={coveragePct >= 80 ? 'green' : coveragePct >= 50 ? 'yellow' : 'red'}
|
||||
/>
|
||||
<ScoreCard
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
type Clarification = {
|
||||
id: string
|
||||
question: string
|
||||
source: string
|
||||
category: 'manufacturer' | 'pattern_norm' | string
|
||||
norm_references?: string[]
|
||||
affected_hazard_ids: string[]
|
||||
affected_hazard_names: string[]
|
||||
status: 'open' | 'in_progress' | 'answered' | 'not_relevant'
|
||||
answer?: 'ja' | 'nein' | 'teilweise' | ''
|
||||
reasoning?: string
|
||||
answered_by?: string
|
||||
answered_at?: string
|
||||
assigned_to?: string
|
||||
}
|
||||
|
||||
type ListResponse = {
|
||||
clarifications: Clarification[]
|
||||
open_count: number
|
||||
answered_count: number
|
||||
total: number
|
||||
}
|
||||
|
||||
const CATEGORY_LABEL: Record<string, string> = {
|
||||
manufacturer: 'Hersteller',
|
||||
pattern_norm: 'Norm / Pattern',
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
open: 'Offen',
|
||||
in_progress: 'In Klärung',
|
||||
answered: 'Beantwortet',
|
||||
not_relevant: 'Nicht relevant',
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
open: 'bg-orange-100 text-orange-800',
|
||||
in_progress: 'bg-yellow-100 text-yellow-800',
|
||||
answered: 'bg-green-100 text-green-800',
|
||||
not_relevant: 'bg-gray-100 text-gray-700',
|
||||
}
|
||||
|
||||
export default function ClarificationsPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
|
||||
const [data, setData] = useState<ListResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState<Clarification | null>(null)
|
||||
const [filter, setFilter] = useState<'all' | 'open' | 'answered'>('open')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const r = await fetch(`/api/sdk/v1/iace/projects/${projectId}/clarifications`)
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
const json: ListResponse = await r.json()
|
||||
setData(json)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const filtered = (data?.clarifications ?? []).filter(c => {
|
||||
if (filter === 'open' && (c.status === 'answered' || c.status === 'not_relevant')) return false
|
||||
if (filter === 'answered' && c.status !== 'answered' && c.status !== 'not_relevant') return false
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase()
|
||||
if (!c.question.toLowerCase().includes(q) && !c.source.toLowerCase().includes(q)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const groupedBySource: Record<string, Clarification[]> = {}
|
||||
for (const c of filtered) {
|
||||
const key = c.source
|
||||
if (!groupedBySource[key]) groupedBySource[key] = []
|
||||
groupedBySource[key].push(c)
|
||||
}
|
||||
|
||||
// CRA-Spur: zeige Banner, wenn mindestens eine Klaerung einen CRA-Bezug
|
||||
// hat (Norm-Referenz "2024/2847" oder "DIN EN 40000-1-2"). Die Banner
|
||||
// erinnert den Anwender daran, dass die CRA-Pflichten zwar bereits jetzt
|
||||
// dokumentiert werden, aber erst zum 11.12.2027 verpflichtend gelten.
|
||||
const hasCRAClarifications = (data?.clarifications ?? []).some(c =>
|
||||
(c.norm_references ?? []).some(n => n.includes('2024/2847') || n.includes('40000-1-2'))
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Klärungen mit dem Anlagenbauer</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Standardisierte Prüffragen aus Norm- und Herstellerwissen. Eine Antwort gilt für alle referenzierten Gefährdungen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{data && (
|
||||
<div className="flex gap-2 text-sm">
|
||||
<Badge color="bg-orange-100 text-orange-800" label={`${data.open_count} offen`} />
|
||||
<Badge color="bg-green-100 text-green-800" label={`${data.answered_count} beantwortet`} />
|
||||
<Badge color="bg-gray-100 text-gray-700" label={`${data.total} gesamt`} />
|
||||
</div>
|
||||
)}
|
||||
<a
|
||||
href={`/api/sdk/v1/iace/projects/${projectId}/clarifications.csv`}
|
||||
download
|
||||
className="text-xs px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 inline-flex items-center gap-1.5"
|
||||
title="CSV-Export für die Übergabe an den Anlagenbauer"
|
||||
>
|
||||
<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 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5 5-5M12 15V3" />
|
||||
</svg>
|
||||
CSV
|
||||
</a>
|
||||
<a
|
||||
href={`/api/sdk/v1/iace/projects/${projectId}/clarifications.html`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 inline-flex items-center gap-1.5"
|
||||
title="Druckansicht öffnen — mit Strg/Cmd-P als PDF speichern"
|
||||
>
|
||||
<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="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 / Druck
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mb-4 items-center">
|
||||
<div className="flex gap-1 text-sm">
|
||||
{(['open', 'answered', 'all'] as const).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1.5 rounded ${filter === f ? 'bg-blue-600 text-white' : 'bg-gray-100 hover:bg-gray-200'}`}
|
||||
>
|
||||
{f === 'open' ? 'Offen' : f === 'answered' ? 'Beantwortet' : 'Alle'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen in Frage oder Quelle..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="flex-1 max-w-sm border rounded px-3 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!loading && hasCRAClarifications && (
|
||||
<div className="mb-4 rounded-md border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-900">
|
||||
<div className="font-semibold mb-1">Cyber Resilience Act (CRA) — Hinweis zur Geltung</div>
|
||||
<div className="text-blue-800">
|
||||
Diese Klärungsliste enthält Fragen zur Verordnung (EU) 2024/2847 (CRA). Die CRA gilt für „Produkte mit digitalen Elementen", die ab dem <strong>11.12.2027</strong> auf dem EU-Markt bereitgestellt werden. Die hier dokumentierten Pflichten (SBOM, signierte Updates, CVD-Policy, Patch-SLA, Incident-Notification an ENISA) sollten bereits jetzt im Entwurf des Anlagenbauers berücksichtigt sein. Harmonisierter Standard: <strong>DIN EN 40000-1-2</strong> (Entwurf 11/2025).
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <div className="text-gray-500">Lade Klärungen…</div>}
|
||||
{error && <div className="text-red-600">Fehler: {error}</div>}
|
||||
|
||||
{!loading && data && Object.keys(groupedBySource).length === 0 && (
|
||||
<div className="text-gray-500 italic">Keine Klärungen für die aktuelle Auswahl.</div>
|
||||
)}
|
||||
|
||||
{!loading && data && Object.entries(groupedBySource).map(([source, items]) => (
|
||||
<div key={source} className="mb-6">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-2">
|
||||
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded">
|
||||
{CATEGORY_LABEL[items[0].category] || items[0].category}
|
||||
</span>
|
||||
{source}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{items.map(c => (
|
||||
<div key={c.id} className="border rounded-lg p-3 bg-white shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">{c.question}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
Betrifft <strong>{c.affected_hazard_ids.length}</strong> Gefährdung
|
||||
{c.affected_hazard_ids.length !== 1 ? 'en' : ''}
|
||||
{c.affected_hazard_names.length > 0 && (
|
||||
<span className="ml-1">— {c.affected_hazard_names.slice(0, 2).join('; ')}{c.affected_hazard_names.length > 2 ? `, +${c.affected_hazard_names.length - 2} weitere` : ''}</span>
|
||||
)}
|
||||
</div>
|
||||
{c.norm_references && c.norm_references.length > 0 && (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
Norm: {c.norm_references.join(' | ')}
|
||||
</div>
|
||||
)}
|
||||
{c.status === 'answered' && c.reasoning && (
|
||||
<div className="mt-2 text-xs text-gray-700 bg-green-50 border border-green-200 rounded p-2">
|
||||
<strong>Antwort ({c.answer}):</strong> {c.reasoning}
|
||||
{c.answered_by && (
|
||||
<span className="text-gray-500 ml-2">— {c.answered_by}, {c.answered_at?.slice(0, 10)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 text-xs">
|
||||
<span className={`px-2 py-0.5 rounded ${STATUS_COLOR[c.status]}`}>{STATUS_LABEL[c.status]}</span>
|
||||
<button
|
||||
onClick={() => setEditing(c)}
|
||||
className="px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
{c.status === 'answered' ? 'Bearbeiten' : 'Beantworten'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{editing && (
|
||||
<AnswerModal
|
||||
clarification={editing}
|
||||
projectId={projectId}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null)
|
||||
load()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Badge({ color, label }: { color: string; label: string }) {
|
||||
return <span className={`px-2 py-0.5 rounded text-xs ${color}`}>{label}</span>
|
||||
}
|
||||
|
||||
type Comment = { id: string; author: string; body: string; created_at: string }
|
||||
type HistoryEntry = {
|
||||
actor: string
|
||||
from_status?: string
|
||||
to_status?: string
|
||||
from_answer?: string
|
||||
to_answer?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
function AnswerModal({
|
||||
clarification,
|
||||
projectId,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
clarification: Clarification & { assigned_to?: string }
|
||||
projectId: string
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [status, setStatus] = useState(clarification.status)
|
||||
const [answer, setAnswer] = useState<'ja' | 'nein' | 'teilweise' | ''>(
|
||||
(clarification.answer as 'ja' | 'nein' | 'teilweise' | '') || ''
|
||||
)
|
||||
const [reasoning, setReasoning] = useState(clarification.reasoning || '')
|
||||
const [answeredBy, setAnsweredBy] = useState(clarification.answered_by || '')
|
||||
const [assignedTo, setAssignedTo] = useState(clarification.assigned_to || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [comments, setComments] = useState<Comment[]>([])
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([])
|
||||
const [newComment, setNewComment] = useState('')
|
||||
const [postingComment, setPostingComment] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/clarifications/${encodeURIComponent(clarification.id)}/detail`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => {
|
||||
if (!d) return
|
||||
setComments(d.comments || [])
|
||||
setHistory(d.history || [])
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [projectId, clarification.id])
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const r = await fetch(
|
||||
`/api/sdk/v1/iace/projects/${projectId}/clarifications/${encodeURIComponent(clarification.id)}/answer`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
status, answer, reasoning,
|
||||
answered_by: answeredBy,
|
||||
assigned_to: assignedTo,
|
||||
question: clarification.question,
|
||||
source: clarification.source,
|
||||
category: clarification.category,
|
||||
norm_references: clarification.norm_references,
|
||||
}),
|
||||
}
|
||||
)
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const postComment = async () => {
|
||||
if (!newComment.trim()) return
|
||||
setPostingComment(true)
|
||||
try {
|
||||
const r = await fetch(
|
||||
`/api/sdk/v1/iace/projects/${projectId}/clarifications/${encodeURIComponent(clarification.id)}/comment`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ author: answeredBy || assignedTo || 'unbekannt', body: newComment }),
|
||||
}
|
||||
)
|
||||
if (r.ok) {
|
||||
const d = await r.json()
|
||||
if (d.comment) setComments(prev => [...prev, d.comment])
|
||||
setNewComment('')
|
||||
} else {
|
||||
setError(`Kommentar HTTP ${r.status} — bitte zuerst Status setzen, damit der Klärungs-Datensatz angelegt wird.`)
|
||||
}
|
||||
} finally {
|
||||
setPostingComment(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4 overflow-y-auto" onClick={onClose}>
|
||||
<div className="bg-white rounded-lg max-w-2xl w-full p-5 shadow-xl my-8" onClick={e => e.stopPropagation()}>
|
||||
<div className="text-sm text-gray-500 mb-1">{clarification.source}</div>
|
||||
<div className="text-base font-medium mb-4">{clarification.question}</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Zugewiesen an</label>
|
||||
<input
|
||||
type="text"
|
||||
value={assignedTo}
|
||||
onChange={e => setAssignedTo(e.target.value)}
|
||||
className="w-full border rounded p-2 text-sm"
|
||||
placeholder="z.B. anlagenbauer@fanuc.de"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Bearbeiter</label>
|
||||
<input
|
||||
type="text"
|
||||
value={answeredBy}
|
||||
onChange={e => setAnsweredBy(e.target.value)}
|
||||
className="w-full border rounded p-2 text-sm"
|
||||
placeholder="Name oder Kürzel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Status</label>
|
||||
<div className="flex gap-1 mb-3 text-sm">
|
||||
{(['open', 'in_progress', 'answered', 'not_relevant'] as const).map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setStatus(s)}
|
||||
className={`px-3 py-1 rounded border ${status === s ? 'bg-blue-600 text-white border-blue-600' : 'bg-white hover:bg-gray-50'}`}
|
||||
>
|
||||
{STATUS_LABEL[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(status === 'answered' || status === 'in_progress') && (
|
||||
<>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Antwort</label>
|
||||
<div className="flex gap-1 mb-3 text-sm">
|
||||
{(['ja', 'teilweise', 'nein'] as const).map(a => (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => setAnswer(a)}
|
||||
className={`px-3 py-1 rounded border ${answer === a ? 'bg-blue-600 text-white border-blue-600' : 'bg-white hover:bg-gray-50'}`}
|
||||
>
|
||||
{a}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Begründung / Notiz</label>
|
||||
<textarea
|
||||
value={reasoning}
|
||||
onChange={e => setReasoning(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full border rounded p-2 text-sm mb-4"
|
||||
placeholder="z.B. Pruefprotokoll vom 12.03.2024 vom Anlagenbauer FANUC vorgelegt; DCS-Konfig liegt bei."
|
||||
/>
|
||||
|
||||
{/* Comment Thread */}
|
||||
<div className="border-t pt-3 mt-3 mb-3">
|
||||
<div className="text-xs font-medium text-gray-700 mb-2">Diskussion ({comments.length})</div>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto mb-2">
|
||||
{comments.map(c => (
|
||||
<div key={c.id} className="text-xs bg-gray-50 rounded p-2">
|
||||
<div className="font-medium text-gray-700">{c.author || 'anonym'} <span className="text-gray-400 font-normal">· {c.created_at.slice(0, 16).replace('T', ' ')}</span></div>
|
||||
<div className="text-gray-700 whitespace-pre-wrap">{c.body}</div>
|
||||
</div>
|
||||
))}
|
||||
{comments.length === 0 && <div className="text-xs text-gray-400 italic">Noch keine Kommentare.</div>}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={newComment}
|
||||
onChange={e => setNewComment(e.target.value)}
|
||||
placeholder="Kommentar hinzufügen..."
|
||||
className="flex-1 border rounded px-2 py-1.5 text-xs"
|
||||
onKeyDown={e => { if (e.key === 'Enter') postComment() }}
|
||||
/>
|
||||
<button
|
||||
onClick={postComment}
|
||||
disabled={postingComment || !newComment.trim()}
|
||||
className="px-3 py-1 rounded bg-gray-700 text-white text-xs hover:bg-gray-800 disabled:opacity-50"
|
||||
>Senden</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{history.length > 0 && (
|
||||
<details className="mb-3 text-xs">
|
||||
<summary className="cursor-pointer text-gray-600 hover:text-gray-800">Verlauf ({history.length})</summary>
|
||||
<div className="mt-1 space-y-1 text-gray-600">
|
||||
{history.map((h, i) => (
|
||||
<div key={i} className="border-l-2 border-gray-200 pl-2">
|
||||
<span className="text-gray-400">{h.created_at.slice(0, 16).replace('T', ' ')}</span> ·
|
||||
<strong> {h.actor || 'unbekannt'}</strong>: {h.from_status} → {h.to_status}
|
||||
{h.from_answer !== h.to_answer && ` (Antwort ${h.from_answer || '—'} → ${h.to_answer || '—'})`}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{error && <div className="text-red-600 text-sm mb-2">Fehler: {error}</div>}
|
||||
|
||||
<div className="flex justify-end gap-2 text-sm">
|
||||
<button onClick={onClose} className="px-3 py-1.5 rounded border bg-white hover:bg-gray-50">Abbrechen</button>
|
||||
<button onClick={save} disabled={saving} className="px-3 py-1.5 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50">
|
||||
{saving ? 'Speichere…' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
type Suggestion = {
|
||||
name: string
|
||||
reduction_type: 'design' | 'protection' | 'information' | string
|
||||
description: string
|
||||
source_project_count: number
|
||||
source_project_names: string[]
|
||||
is_customer_standard: boolean
|
||||
has_verified_instances: boolean
|
||||
}
|
||||
|
||||
type ProjectInfo = { customer_name?: string; machine_name?: string }
|
||||
|
||||
// /sdk/iace/[projectId]/customer-standards
|
||||
//
|
||||
// Surfaces mitigations that the expert flagged as "Kundenstandard" (or
|
||||
// successfully verified) in earlier projects of the SAME customer. Picking
|
||||
// one and clicking "Übernehmen" applies it to all matching hazards in the
|
||||
// current project — every match is set to is_relevant=true,
|
||||
// is_customer_standard=true, status='verified'. Saves the round-trip
|
||||
// through Massnahmen + Verifikation for the cases where the safety expert
|
||||
// already knows the answer from a prior plant at the same site.
|
||||
//
|
||||
// Filter "Auch verifizierte einbeziehen" widens the pool beyond strictly
|
||||
// is_customer_standard=true to also include status='verified' rows — useful
|
||||
// when the customer-standard habit is not yet established in the corpus.
|
||||
export default function CustomerStandardsPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
|
||||
const [project, setProject] = useState<ProjectInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [includeVerified, setIncludeVerified] = useState(false)
|
||||
const [importing, setImporting] = useState<string | null>(null)
|
||||
const [importedNames, setImportedNames] = useState<Set<string>>(new Set())
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [sgRes, prRes] = await Promise.all([
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/customer-standards?include_verified=${includeVerified}`),
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}`),
|
||||
])
|
||||
if (sgRes.ok) {
|
||||
const j = await sgRes.json()
|
||||
setSuggestions(j.suggestions || [])
|
||||
}
|
||||
if (prRes.ok) {
|
||||
const j = await prRes.json()
|
||||
const p = j.project || j
|
||||
setProject({ customer_name: p.customer_name, machine_name: p.machine_name })
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId, includeVerified])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
function toggleSelect(name: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(name)) next.delete(name); else next.add(name)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function importOne(name: string) {
|
||||
setImporting(name)
|
||||
try {
|
||||
const r = await fetch(`/api/sdk/v1/iace/projects/${projectId}/customer-standards/import`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
if (r.ok) {
|
||||
setImportedNames((prev) => new Set(prev).add(name))
|
||||
setSelected((prev) => { const n = new Set(prev); n.delete(name); return n })
|
||||
} else {
|
||||
const j = await r.json().catch(() => null)
|
||||
setError(j?.error || `HTTP ${r.status}`)
|
||||
}
|
||||
} finally {
|
||||
setImporting(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function importSelected() {
|
||||
const names = Array.from(selected)
|
||||
for (const n of names) {
|
||||
await importOne(n)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
|
||||
// No customer set → guide the user to set it first
|
||||
const hasCustomer = !!(project?.customer_name && project.customer_name.trim() !== '')
|
||||
if (!hasCustomer) {
|
||||
return (
|
||||
<div className="space-y-4 max-w-3xl">
|
||||
<h1 className="text-2xl font-bold">Kundenstandards</h1>
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||
Dieses Projekt hat noch keinen <em>Kundennamen</em>. Damit Massnahmen aus früheren
|
||||
Anlagen desselben Kunden wiederverwendet werden können, trage den Kundennamen
|
||||
unter <a className="text-purple-700 underline" href={`/sdk/iace/${projectId}/order`}>Auftrag → Kunde</a> ein.
|
||||
Sobald der Kundenname gesetzt ist, erscheint hier die Liste der wiederverwendbaren
|
||||
Maßnahmen aus seinen Vorprojekten.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Kundenstandards</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Übernimm Maßnahmen, die der Kunde <strong>{project?.customer_name}</strong> in
|
||||
anderen Anlagen bereits als Standard etabliert hat. Übernehmen setzt sie für alle
|
||||
passenden Gefährdungen <em>relevant</em> und <em>verifiziert</em> ohne Nachweis.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-600">
|
||||
<input type="checkbox" checked={includeVerified}
|
||||
onChange={(e) => setIncludeVerified(e.target.checked)}
|
||||
className="accent-purple-600" />
|
||||
Auch <em>verifizierte</em> einbeziehen
|
||||
</label>
|
||||
{selected.size > 0 && (
|
||||
<button onClick={importSelected} disabled={!!importing}
|
||||
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
{importing ? 'Übernehme…' : `${selected.size} übernehmen`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-red-600 text-sm">Fehler: {error}</div>}
|
||||
|
||||
{suggestions.length === 0 && (
|
||||
<div className="rounded-md border border-gray-200 bg-gray-50 px-4 py-6 text-sm text-gray-600">
|
||||
Keine wiederverwendbaren Maßnahmen für <strong>{project?.customer_name}</strong> gefunden.
|
||||
{!includeVerified && ' Aktiviere „Auch verifizierte einbeziehen" oben rechts, um den Pool zu erweitern.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{suggestions.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="grid grid-cols-[28px_2fr_120px_100px_120px] gap-3 px-4 py-2 bg-gray-50 dark:bg-gray-750 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<div />
|
||||
<div>Massnahme</div>
|
||||
<div className="text-center">Vorprojekte</div>
|
||||
<div>Status</div>
|
||||
<div className="text-right">Aktion</div>
|
||||
</div>
|
||||
{suggestions.map((s) => {
|
||||
const imported = importedNames.has(s.name)
|
||||
return (
|
||||
<div key={s.name} className={`grid grid-cols-[28px_2fr_120px_100px_120px] gap-3 px-4 py-2.5 border-t border-gray-100 dark:border-gray-700 ${imported ? 'bg-green-50/40' : ''} ${selected.has(s.name) ? 'bg-purple-50' : ''}`}>
|
||||
<div className="pt-0.5">
|
||||
<input type="checkbox" checked={selected.has(s.name)} onChange={() => toggleSelect(s.name)} disabled={imported}
|
||||
className="accent-purple-600" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm text-gray-900 dark:text-white">{s.name}</div>
|
||||
{s.description && <div className="text-[11px] text-gray-500 mt-0.5 line-clamp-2">{s.description}</div>}
|
||||
{s.source_project_names.length > 0 && (
|
||||
<div className="text-[10px] text-gray-400 mt-1">aus: {s.source_project_names.slice(0,3).join(', ')}{s.source_project_names.length > 3 ? ` (+${s.source_project_names.length - 3})` : ''}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center self-center">
|
||||
<span className="text-sm font-semibold text-purple-700">{s.source_project_count}×</span>
|
||||
</div>
|
||||
<div className="self-center flex flex-wrap gap-1">
|
||||
{s.is_customer_standard && <span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-700">Kundenstandard</span>}
|
||||
{s.has_verified_instances && !s.is_customer_standard && <span className="text-[10px] px-1.5 py-0.5 rounded bg-green-100 text-green-700">Verifiziert</span>}
|
||||
</div>
|
||||
<div className="text-right self-center">
|
||||
{imported ? (
|
||||
<span className="text-[11px] text-green-700">✓ Übernommen</span>
|
||||
) : (
|
||||
<button onClick={() => importOne(s.name)} disabled={!!importing}
|
||||
className="px-2.5 py-1 text-[11px] bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50">
|
||||
{importing === s.name ? 'Übernehme…' : 'Übernehmen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -39,11 +39,19 @@ export function HazardTable({ hazards, lifecyclePhases, onDelete }: {
|
||||
.map((hazard) => (
|
||||
<tr key={hazard.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{hazard.name}</div>
|
||||
{hazard.name.startsWith('Auto:') && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">Auto</span>
|
||||
)}
|
||||
{(hazard as { pattern_id?: string }).pattern_id && (
|
||||
<span
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-mono font-medium bg-slate-100 text-slate-700 border border-slate-200 cursor-help"
|
||||
title={`Quelle: BreakPilot IACE Pattern-Engine (${(hazard as { pattern_id?: string }).pattern_id}). Lizenzregel R3 — Eigenwerk, kein externer Lizenz-Footer noetig. Pattern-Definition mit Norm-Referenzen siehe Library.`}
|
||||
>
|
||||
{(hazard as { pattern_id?: string }).pattern_id} · R3
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hazard.description && (
|
||||
<div className="text-xs text-gray-500 truncate max-w-[250px]">{hazard.description}</div>
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
'use client'
|
||||
|
||||
// LLM Gap-Review Modal — Task #8.
|
||||
//
|
||||
// Triggers POST /projects/:id/llm-gap-review on mount and lists the
|
||||
// LLM's gap suggestions with an Adopt / Reject UX. Adoption goes through
|
||||
// the regular CreateHazard / CreateMitigation endpoints — the modal
|
||||
// itself never mutates project state on its own.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
type Suggestion = {
|
||||
kind: 'hazard' | 'mitigation'
|
||||
title: string
|
||||
description: string
|
||||
category?: string
|
||||
hazard_ref?: string
|
||||
pattern_ref?: string
|
||||
norm_refs?: string[]
|
||||
confidence?: 'high' | 'medium' | 'low'
|
||||
rationale?: string
|
||||
}
|
||||
|
||||
type Response = {
|
||||
project_id: string
|
||||
source: 'llm_gap_review' | 'fallback_static'
|
||||
model?: string
|
||||
suggestions: Suggestion[]
|
||||
input_summary: {
|
||||
hazard_count: number
|
||||
mitigation_count: number
|
||||
limits_form_fields: number
|
||||
}
|
||||
}
|
||||
|
||||
const CONF_COLOR: Record<string, string> = {
|
||||
high: 'bg-emerald-100 text-emerald-800 border-emerald-200',
|
||||
medium: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||
low: 'bg-slate-100 text-slate-600 border-slate-200',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projectId: string
|
||||
onClose: () => void
|
||||
onAdoptHazard?: (s: Suggestion) => Promise<void>
|
||||
onAdoptMitigation?: (s: Suggestion) => Promise<void>
|
||||
}
|
||||
|
||||
export function LLMGapReviewModal({ projectId, onClose, onAdoptHazard, onAdoptMitigation }: Props) {
|
||||
const [data, setData] = useState<Response | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [adopted, setAdopted] = useState<Set<number>>(new Set())
|
||||
const [rejected, setRejected] = useState<Set<number>>(new Set())
|
||||
const [adopting, setAdopting] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/llm-gap-review`, { method: 'POST' })
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(`HTTP ${r.status}`)))
|
||||
.then(setData)
|
||||
.catch((e) => setError(String(e)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [projectId])
|
||||
|
||||
async function adopt(idx: number) {
|
||||
if (!data) return
|
||||
const s = data.suggestions[idx]
|
||||
setAdopting(idx)
|
||||
try {
|
||||
if (s.kind === 'hazard' && onAdoptHazard) await onAdoptHazard(s)
|
||||
else if (s.kind === 'mitigation' && onAdoptMitigation) await onAdoptMitigation(s)
|
||||
setAdopted((prev) => new Set(prev).add(idx))
|
||||
} catch (e) {
|
||||
setError(`Adopt fehlgeschlagen: ${e}`)
|
||||
} finally {
|
||||
setAdopting(null)
|
||||
}
|
||||
}
|
||||
|
||||
function reject(idx: number) {
|
||||
setRejected((prev) => new Set(prev).add(idx))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">KI-Gap-Review</h2>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
LLM-gestuetzte Suche nach fehlenden Gefaehrdungen und Schutzmassnahmen — Vorschlaege sind unverbindlich bis explizit uebernommen.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-3">
|
||||
{loading && (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-purple-600 mx-auto" />
|
||||
<p className="text-sm text-gray-500 mt-3">LLM laeuft (Qwen/Claude). Das kann bis zu 30 Sekunden dauern.</p>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">
|
||||
Fehler: {error}
|
||||
</div>
|
||||
)}
|
||||
{data && (
|
||||
<>
|
||||
<div className="text-xs text-gray-500 flex items-center gap-3 border-b border-gray-100 pb-2">
|
||||
<span>
|
||||
Eingabe: {data.input_summary.hazard_count} Gefaehrdungen,{' '}
|
||||
{data.input_summary.mitigation_count} Massnahmen, {data.input_summary.limits_form_fields} Grenzen-Felder
|
||||
</span>
|
||||
<span className="text-gray-300">·</span>
|
||||
<span>
|
||||
Quelle: {data.source === 'llm_gap_review'
|
||||
? `LLM (${data.model ?? 'unbekannt'})`
|
||||
: 'Statische Fallback-Liste'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{data.suggestions.length === 0 && (
|
||||
<div className="text-center text-gray-500 py-12 text-sm">
|
||||
Keine Lueckenvorschlaege. Die deterministische Pattern-Engine hat vermutlich bereits alle Standard-Gefaehrdungen abgedeckt.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.suggestions.map((s, i) => {
|
||||
const isAdopted = adopted.has(i)
|
||||
const isRejected = rejected.has(i)
|
||||
const isWorking = adopting === i
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`border rounded-lg p-3 ${
|
||||
isAdopted ? 'border-emerald-200 bg-emerald-50' :
|
||||
isRejected ? 'border-slate-200 bg-slate-50 opacity-50' :
|
||||
'border-gray-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<span className={`px-1.5 py-0.5 text-[10px] rounded font-medium ${
|
||||
s.kind === 'hazard' ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{s.kind === 'hazard' ? 'Gefaehrdung' : 'Massnahme'}
|
||||
</span>
|
||||
{s.category && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded bg-gray-100 text-gray-700">{s.category}</span>
|
||||
)}
|
||||
{s.confidence && (
|
||||
<span className={`px-1.5 py-0.5 text-[10px] rounded border ${CONF_COLOR[s.confidence]}`}>
|
||||
{s.confidence}
|
||||
</span>
|
||||
)}
|
||||
{(s.norm_refs ?? []).map((n) => (
|
||||
<span key={n} className="px-1.5 py-0.5 text-[10px] rounded bg-indigo-50 text-indigo-700 font-mono">{n}</span>
|
||||
))}
|
||||
{s.pattern_ref && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded bg-purple-50 text-purple-700 font-mono">{s.pattern_ref}</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">{s.title}</h3>
|
||||
<p className="text-xs text-gray-600 mt-1">{s.description}</p>
|
||||
{s.hazard_ref && (
|
||||
<p className="text-[11px] text-gray-500 mt-1">Bezogen auf: <em>{s.hazard_ref}</em></p>
|
||||
)}
|
||||
{s.rationale && (
|
||||
<p className="text-[11px] text-gray-400 mt-1 italic">{s.rationale}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 flex-shrink-0">
|
||||
{!isAdopted && !isRejected && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => adopt(i)}
|
||||
disabled={isWorking}
|
||||
className="px-3 py-1 text-xs bg-emerald-600 text-white rounded hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{isWorking ? '…' : 'Uebernehmen'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => reject(i)}
|
||||
className="px-3 py-1 text-xs text-gray-600 border border-gray-300 rounded hover:bg-gray-50"
|
||||
>
|
||||
Verwerfen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isAdopted && <span className="text-xs text-emerald-700 font-medium">✓ Uebernommen</span>}
|
||||
{isRejected && <span className="text-xs text-gray-500">Verworfen</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-3 border-t border-gray-200 bg-gray-50 flex items-center justify-between flex-shrink-0">
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Hinweis: LLM-Vorschlaege sind NICHT die deterministische Engine-Output. Jede Uebernahme wird als <code>source=llm_gap_review</code> markiert.
|
||||
</p>
|
||||
<button onClick={onClose} className="px-3 py-1.5 text-sm border border-gray-300 rounded hover:bg-white">
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LLMGapReviewModal
|
||||
@@ -4,15 +4,18 @@ 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'
|
||||
import { LibraryModal } from './_components/LibraryModal'
|
||||
import { AutoSuggestPanel } from './_components/AutoSuggestPanel'
|
||||
import { CustomHazardModal } from './_components/CustomHazardModal'
|
||||
import { LLMGapReviewModal } from './_components/LLMGapReviewModal'
|
||||
import { useHazards } from './_hooks/useHazards'
|
||||
|
||||
type ViewMode = 'list' | 'risk'
|
||||
type ViewMode = 'list' | 'risk' | 'blocks'
|
||||
|
||||
export default function HazardsPage() {
|
||||
const params = useParams()
|
||||
@@ -20,6 +23,7 @@ export default function HazardsPage() {
|
||||
const h = useHazards(projectId)
|
||||
const [view, setView] = useState<ViewMode>('risk')
|
||||
const [showCustomModal, setShowCustomModal] = useState(false)
|
||||
const [showGapReview, setShowGapReview] = useState(false)
|
||||
const [residualFilter, setResidualFilter] = useState<ResidualFilter>('all')
|
||||
const [decisions, setDecisions] = useState<Record<string, boolean | null>>({})
|
||||
|
||||
@@ -69,6 +73,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">
|
||||
@@ -98,6 +106,15 @@ export default function HazardsPage() {
|
||||
</svg>
|
||||
Eigene Gefaehrdung
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowGapReview(true)}
|
||||
title="LLM (Qwen/Claude) prueft auf fehlende Gefaehrdungen und Massnahmen — Vorschlaege sind unverbindlich."
|
||||
className="flex items-center gap-2 px-3 py-2 border border-indigo-300 text-indigo-700 rounded-lg hover:bg-indigo-50 transition-colors text-sm">
|
||||
<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-Gap-Review
|
||||
</button>
|
||||
<button onClick={() => h.setShowForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -164,14 +181,23 @@ export default function HazardsPage() {
|
||||
onClose={() => setShowCustomModal(false)} />
|
||||
)}
|
||||
|
||||
{showGapReview && (
|
||||
<LLMGapReviewModal
|
||||
projectId={projectId}
|
||||
onClose={() => setShowGapReview(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{h.hazards.length > 0 ? (
|
||||
view === 'risk' ? (
|
||||
<>
|
||||
<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} />
|
||||
)
|
||||
|
||||
@@ -13,6 +13,9 @@ export interface Mitigation {
|
||||
verified_by: string | null
|
||||
source?: string
|
||||
operational_states?: string[]
|
||||
// Expert flags (migration 029).
|
||||
is_relevant?: boolean
|
||||
is_customer_standard?: boolean
|
||||
}
|
||||
|
||||
export interface Hazard {
|
||||
|
||||
@@ -45,6 +45,8 @@ 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,
|
||||
is_relevant: Boolean(m.is_relevant),
|
||||
is_customer_standard: Boolean(m.is_customer_standard),
|
||||
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>()
|
||||
@@ -151,6 +153,48 @@ export function useMitigations(projectId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk delete without per-row confirm; caller owns the confirm-step.
|
||||
async function handleDeleteSilent(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}`, { method: 'DELETE' })
|
||||
if (!res.ok) console.error('delete failed for', id, res.status)
|
||||
} catch (err) {
|
||||
console.error('Failed to delete mitigation:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Flag a mitigation as relevant for this project (or unflag). Optimistic:
|
||||
// updates local state immediately, refetches afterwards.
|
||||
async function handleSetRelevant(id: string, value: boolean) {
|
||||
setMitigations((prev) => prev.map((m) => m.id === id ? { ...m, status: m.status } : m))
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/iace/mitigations/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_relevant: value }),
|
||||
})
|
||||
await fetchData()
|
||||
} catch (err) {
|
||||
console.error('Failed to set relevant flag:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark a mitigation as "customer standard" — already implemented at the
|
||||
// customer's site, no evidence required. Implies is_relevant=true (server
|
||||
// enforces this via the CHECK constraint).
|
||||
async function handleSetCustomerStandard(id: string, value: boolean) {
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/iace/mitigations/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_customer_standard: value }),
|
||||
})
|
||||
await fetchData()
|
||||
} catch (err) {
|
||||
console.error('Failed to set customer-standard flag:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const byType = {
|
||||
design: mitigations.filter((m) => m.reduction_type === 'design'),
|
||||
protection: mitigations.filter((m) => m.reduction_type === 'protection'),
|
||||
@@ -159,7 +203,8 @@ export function useMitigations(projectId: string) {
|
||||
|
||||
return {
|
||||
mitigations, hazards, loading, hierarchyWarning, setHierarchyWarning,
|
||||
measures, byType,
|
||||
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify, handleDelete,
|
||||
measures, byType, fetchData,
|
||||
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify,
|
||||
handleDelete, handleDeleteSilent, handleSetRelevant, handleSetCustomerStandard,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,9 @@ export default function MitigationsPage() {
|
||||
|
||||
const {
|
||||
hazards, loading, hierarchyWarning, setHierarchyWarning,
|
||||
measures, byType,
|
||||
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify, handleDelete,
|
||||
measures, byType, fetchData,
|
||||
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure,
|
||||
handleDelete, handleDeleteSilent, handleSetRelevant,
|
||||
} = useMitigations(projectId)
|
||||
|
||||
const [measureNorms, setMeasureNorms] = useState<Record<string, string[]>>({})
|
||||
@@ -47,48 +48,66 @@ export default function MitigationsPage() {
|
||||
const [showSuggest, setShowSuggest] = useState(false)
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({ design: true, protection: true, information: true })
|
||||
const [mitPages, setMitPages] = useState<Record<string, number>>({ design: 1, protection: 1, information: 1 })
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [batchAction, setBatchAction] = useState<'verify' | 'delete' | null>(null)
|
||||
const [expandedMeasure, setExpandedMeasure] = useState<string | null>(null)
|
||||
// Group-Expand: key = `${type}:${title}` so the same title in different
|
||||
// reduction stages stays independently togglable.
|
||||
const [expandedGroup, setExpandedGroup] = useState<Set<string>>(new Set())
|
||||
|
||||
function toggleGroup(key: string) {
|
||||
setExpandedGroup((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) next.delete(key); else next.add(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Mitigations sharing the same title (e.g. "Sicherheitszeichen nach ISO 7010"
|
||||
// applied to 21 hazards) collapse into a single group row. Each instance
|
||||
// keeps its own DB id, status and notes — the grouping is presentation-only.
|
||||
//
|
||||
// Within a group we additionally deduplicate by hazard_id: the engine
|
||||
// sometimes emits the same (name, hazard_id) pair twice when "Neu
|
||||
// initialisieren" is clicked repeatedly. We pick the row that already
|
||||
// carries user state (is_relevant=true preferred, then newest created_at)
|
||||
// so the expert's decisions are not lost. The DB still holds both rows;
|
||||
// a separate migration adds a UNIQUE(hazard_id, name) constraint to
|
||||
// prevent the duplicates upstream.
|
||||
function groupByTitle(items: Mitigation[]): Array<{ title: string; instances: Mitigation[] }> {
|
||||
const map = new Map<string, Mitigation[]>()
|
||||
for (const m of items) {
|
||||
const key = (m.title || '').trim() || '(ohne Titel)'
|
||||
const arr = map.get(key)
|
||||
if (arr) arr.push(m); else map.set(key, [m])
|
||||
}
|
||||
return Array.from(map.entries()).map(([title, instances]) => {
|
||||
const byHazard = new Map<string, Mitigation>()
|
||||
for (const m of instances) {
|
||||
const hid = (m.linked_hazard_ids || []).join('|') || m.id
|
||||
const prev = byHazard.get(hid)
|
||||
if (!prev) { byHazard.set(hid, m); continue }
|
||||
// Tie-break: prefer is_relevant=true, then newest created_at
|
||||
const score = (x: Mitigation) => (x.is_relevant ? 2 : 0) + (x.created_at > (prev.created_at || '') ? 1 : 0)
|
||||
if (score(m) > score(prev)) byHazard.set(hid, m)
|
||||
}
|
||||
return { title, instances: Array.from(byHazard.values()) }
|
||||
})
|
||||
}
|
||||
|
||||
// Compact status distribution: returns counts for the three known states.
|
||||
function statusCounts(instances: Mitigation[]) {
|
||||
const c = { planned: 0, implemented: 0, verified: 0 }
|
||||
for (const m of instances) {
|
||||
if (m.status === 'planned') c.planned++
|
||||
else if (m.status === 'implemented') c.implemented++
|
||||
else if (m.status === 'verified') c.verified++
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
function toggleSection(type: string) {
|
||||
setExpanded((prev) => ({ ...prev, [type]: !prev[type] }))
|
||||
}
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function selectAllInType(type: string) {
|
||||
const items = byType[type as keyof typeof byType]
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
const allSelected = items.every((m) => next.has(m.id))
|
||||
if (allSelected) { items.forEach((m) => next.delete(m.id)) }
|
||||
else { items.forEach((m) => next.add(m.id)) }
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function handleBatchVerify() {
|
||||
setBatchAction('verify')
|
||||
for (const id of selected) { await handleVerify(id) }
|
||||
setSelected(new Set())
|
||||
setBatchAction(null)
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (!confirm(`${selected.size} Massnahmen wirklich loeschen?`)) return
|
||||
setBatchAction('delete')
|
||||
for (const id of selected) { await handleDelete(id) }
|
||||
setSelected(new Set())
|
||||
setBatchAction(null)
|
||||
}
|
||||
|
||||
function handleOpenLibrary(type?: string) {
|
||||
setLibraryFilter(type)
|
||||
fetchMeasuresLibrary(type)
|
||||
@@ -122,43 +141,31 @@ export default function MitigationsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selected.size > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-gray-500">{selected.size} ausgewaehlt</span>
|
||||
<button onClick={handleBatchVerify} disabled={batchAction !== null}
|
||||
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||
{batchAction === 'verify' ? 'Verifiziere...' : 'Verifizieren'}
|
||||
</button>
|
||||
<button onClick={handleBatchDelete} disabled={batchAction !== null}
|
||||
className="px-3 py-1.5 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50">
|
||||
Loeschen
|
||||
</button>
|
||||
<button onClick={() => setSelected(new Set())} className="px-2 py-1.5 text-xs text-gray-500 hover:text-gray-700">
|
||||
Abbrechen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{selected.size === 0 && (
|
||||
<>
|
||||
<button onClick={() => setShowSuggest(true)}
|
||||
className="px-3 py-1.5 text-xs border border-green-300 text-green-700 rounded-lg hover:bg-green-50">
|
||||
Vorschlaege
|
||||
</button>
|
||||
<button onClick={() => handleOpenLibrary()}
|
||||
className="px-3 py-1.5 text-xs border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50">
|
||||
Bibliothek
|
||||
</button>
|
||||
<button onClick={() => { setPreselectedType(undefined); setShowForm(true) }}
|
||||
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||
+ Hinzufuegen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => setShowSuggest(true)}
|
||||
className="px-3 py-1.5 text-xs border border-green-300 text-green-700 rounded-lg hover:bg-green-50">
|
||||
Vorschlaege
|
||||
</button>
|
||||
<button onClick={() => handleOpenLibrary()}
|
||||
className="px-3 py-1.5 text-xs border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50">
|
||||
Bibliothek
|
||||
</button>
|
||||
<button onClick={() => { setPreselectedType(undefined); setShowForm(true) }}
|
||||
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||
+ Hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hierarchyWarning && <HierarchyWarning onDismiss={() => setHierarchyWarning(false)} />}
|
||||
|
||||
{/* Reinitialisieren-Warnung: nach manuellem Loeschen wuerde ein Reinit
|
||||
die geloeschten Engine-Vorschlaege wiederherstellen. */}
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-2.5 text-xs text-amber-900">
|
||||
<strong>Hinweis:</strong> Markiere jede Maßnahme als <em>Relevant</em> (☑) oder lösche sie aus dem Projekt (🗑).
|
||||
Nur als <em>relevant</em> markierte Maßnahmen erscheinen in der Verifikation.
|
||||
<strong> Achtung:</strong> nach dem Löschen kein <em>Neu initialisieren</em> mehr drücken — sonst werden die gelöschten Vorschläge aus den Engine-Daten wiederhergestellt.
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<MitigationForm
|
||||
onSubmit={async (data) => { const ok = await handleSubmit(data); if (ok) setShowForm(false) }}
|
||||
@@ -173,7 +180,6 @@ export default function MitigationsPage() {
|
||||
const config = REDUCTION_TYPES[type]
|
||||
const items = byType[type]
|
||||
const isExpanded = expanded[type]
|
||||
const allSelected = items.length > 0 && items.every((m) => selected.has(m.id))
|
||||
|
||||
return (
|
||||
<div key={type} className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
@@ -191,68 +197,133 @@ export default function MitigationsPage() {
|
||||
<span className="text-sm font-bold">{items.length}</span>
|
||||
</button>
|
||||
|
||||
{/* Accordion Content — Table rows */}
|
||||
{isExpanded && items.length > 0 && (
|
||||
{/* Accordion Content — grouped by measure title */}
|
||||
{isExpanded && items.length > 0 && (() => {
|
||||
const groups = groupByTitle(items)
|
||||
const visibleGroups = groups.slice(0, (mitPages[type] || 1) * 50)
|
||||
return (
|
||||
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||
{/* Table header */}
|
||||
<div className="grid grid-cols-[24px_2fr_1fr_80px] gap-2 px-4 py-2 bg-gray-50 dark:bg-gray-750 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<div>
|
||||
<input type="checkbox" checked={allSelected} onChange={() => selectAllInType(type)}
|
||||
className="accent-purple-600" title="Alle auswaehlen" />
|
||||
</div>
|
||||
<div className="grid grid-cols-[36px_36px_2fr_120px_110px] gap-2 px-4 py-2 bg-gray-50 dark:bg-gray-750 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<div title="Relevant fuer dieses Projekt">Relev.</div>
|
||||
<div />
|
||||
<div>Massnahme</div>
|
||||
<div>Gefaehrdung</div>
|
||||
<div>Status</div>
|
||||
<div className="text-right pr-2">Gefährdungen</div>
|
||||
<div>Status (P · I · V)</div>
|
||||
</div>
|
||||
{/* Rows — paginated */}
|
||||
{items.slice(0, (mitPages[type] || 1) * 50).map((m) => {
|
||||
const isDetailOpen = expandedMeasure === m.id
|
||||
const catMatch = (m.description || '').match(/Kategorie\s+(\S+)/)
|
||||
{visibleGroups.map(({ title, instances }) => {
|
||||
const groupKey = `${type}:${title}`
|
||||
const isGroupOpen = expandedGroup.has(groupKey)
|
||||
// (legacy bulk-select removed — Relevant-checkbox is now the primary mass-action)
|
||||
const counts = statusCounts(instances)
|
||||
const refs = measureNorms[title.toLowerCase()]
|
||||
const first = instances[0]
|
||||
const description = first?.description || ''
|
||||
const catMatch = description.match(/Kategorie\s+(\S+)/)
|
||||
const category = catMatch?.[1]
|
||||
const refs = measureNorms[(m.title || '').toLowerCase()]
|
||||
const relevantInGroup = instances.filter((m) => m.is_relevant).length
|
||||
const allRelevant = relevantInGroup === instances.length
|
||||
return (
|
||||
<div key={m.id}>
|
||||
<div onClick={() => setExpandedMeasure(isDetailOpen ? null : m.id)}
|
||||
className={`grid grid-cols-[24px_2fr_1fr_80px] gap-2 px-4 py-2 border-t border-gray-50 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors cursor-pointer ${selected.has(m.id) ? 'bg-purple-50 dark:bg-purple-900/10' : ''}`}>
|
||||
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selected.has(m.id)} onChange={() => toggleSelect(m.id)}
|
||||
className="accent-purple-600" />
|
||||
</div>
|
||||
<div className="min-w-0 flex items-start gap-1">
|
||||
<svg className={`w-3 h-3 mt-1 shrink-0 text-gray-400 transition-transform ${isDetailOpen ? '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>
|
||||
<div className="text-sm text-gray-900 dark:text-white">{m.title || ''}</div>
|
||||
{!isDetailOpen && category && <div className="text-[10px] text-gray-400 mt-0.5">Kategorie: {category}</div>}
|
||||
<div key={groupKey}>
|
||||
{/* Group header row */}
|
||||
<div onClick={() => toggleGroup(groupKey)}
|
||||
className={`grid grid-cols-[36px_36px_2fr_120px_110px] gap-2 px-4 py-2 border-t border-gray-50 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors cursor-pointer`}>
|
||||
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={allRelevant} ref={(el) => { if (el) el.indeterminate = !allRelevant && relevantInGroup > 0 }}
|
||||
onChange={async (e) => {
|
||||
const target = e.target.checked
|
||||
for (const m of instances) {
|
||||
if (m.is_relevant !== target) await handleSetRelevant(m.id, target)
|
||||
}
|
||||
}}
|
||||
className="accent-purple-600" title={`${relevantInGroup}/${instances.length} als relevant markiert. Klick: alle als ${allRelevant ? 'nicht relevant' : 'relevant'} markieren.`} />
|
||||
</div>
|
||||
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={async () => {
|
||||
if (!confirm(`Alle ${instances.length} Instanzen von "${title}" loeschen?`)) return
|
||||
for (const m of instances) await handleDeleteSilent(m.id)
|
||||
await fetchData()
|
||||
}} className="text-gray-400 hover:text-red-600" title="Ganze Gruppe loeschen">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M16 7V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-w-0 flex items-start gap-1">
|
||||
<svg className={`w-3 h-3 mt-1 shrink-0 text-gray-400 transition-transform ${isGroupOpen ? '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>
|
||||
<div className="text-sm text-gray-900 dark:text-white">{title}</div>
|
||||
{category && <div className="text-[10px] text-gray-400 mt-0.5">Kategorie: {category}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 text-right pr-2">{instances.length}</div>
|
||||
<div className="text-xs flex items-center gap-1.5 font-mono">
|
||||
<span className="text-gray-500" title={`${counts.planned} geplant`}>{counts.planned}</span>
|
||||
<span className="text-gray-300">·</span>
|
||||
<span className="text-blue-600" title={`${counts.implemented} umgesetzt`}>{counts.implemented}</span>
|
||||
<span className="text-gray-300">·</span>
|
||||
<span className="text-green-600" title={`${counts.verified} verifiziert`}>{counts.verified}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{(m.linked_hazard_names || []).join(', ') || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<StatusBadge status={m.status} />
|
||||
</div>
|
||||
{/* Group children — one row per instance (hazard) */}
|
||||
{isGroupOpen && (
|
||||
<div className="bg-gray-50/40 dark:bg-gray-900/20 border-t border-gray-100 dark:border-gray-700">
|
||||
{description && (
|
||||
<p className="px-12 pt-2 pb-1 text-[11px] text-gray-500 dark:text-gray-400 italic">{description}</p>
|
||||
)}
|
||||
{refs?.length > 0 && (
|
||||
<p className="px-12 pb-2 text-[11px] text-blue-500">Normen: {refs.join(', ')}</p>
|
||||
)}
|
||||
{instances.map((m) => {
|
||||
const isDetailOpen = expandedMeasure === m.id
|
||||
return (
|
||||
<div key={m.id}>
|
||||
<div onClick={() => setExpandedMeasure(isDetailOpen ? null : m.id)}
|
||||
className={`grid grid-cols-[36px_36px_2fr_120px_110px] gap-2 px-4 py-1.5 border-t border-gray-100 dark:border-gray-700 hover:bg-white dark:hover:bg-gray-800 transition-colors cursor-pointer ${m.is_relevant ? 'bg-emerald-50/40 dark:bg-emerald-900/10' : ''}`}>
|
||||
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={Boolean(m.is_relevant)} onChange={() => handleSetRelevant(m.id, !m.is_relevant)}
|
||||
className="accent-purple-600" title="Als relevant markieren" />
|
||||
</div>
|
||||
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => handleDelete(m.id)}
|
||||
className="text-gray-400 hover:text-red-600" title="Loeschen">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M16 7V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-300 min-w-0">
|
||||
{(m.linked_hazard_names || []).join(', ') || '— (keine Gefaehrdung verknuepft)'}
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-400 self-center text-right pr-2">
|
||||
{m.is_customer_standard ? 'Kundenstandard' : ''}
|
||||
</div>
|
||||
<div><StatusBadge status={m.status} /></div>
|
||||
</div>
|
||||
{isDetailOpen && (
|
||||
<div className="px-12 py-2 bg-white dark:bg-gray-800 border-t border-gray-100 dark:border-gray-700 text-xs space-y-1">
|
||||
<MitigationHints projectId={projectId} mitigationId={m.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isDetailOpen && (
|
||||
<div className="px-12 py-3 bg-gray-50 dark:bg-gray-750 border-t border-gray-100 dark:border-gray-700 text-xs space-y-1">
|
||||
{m.description && <p className="text-gray-600 dark:text-gray-300">{m.description}</p>}
|
||||
{category && <p className="text-purple-600">Diese Massnahme gilt fuer alle Gefaehrdungen der Kategorie <strong>{category}</strong>.</p>}
|
||||
{refs?.length > 0 && <p className="text-blue-500">Normen: {refs.join(', ')}</p>}
|
||||
<MitigationHints projectId={projectId} mitigationId={m.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{items.length > (mitPages[type] || 1) * 50 && (
|
||||
{groups.length > visibleGroups.length && (
|
||||
<button onClick={() => setMitPages(prev => ({ ...prev, [type]: (prev[type] || 1) + 1 }))}
|
||||
className="w-full py-2 text-xs text-purple-600 hover:bg-purple-50 border-t border-gray-100 transition-colors">
|
||||
Weitere {Math.min(50, items.length - (mitPages[type] || 1) * 50)} von {items.length} laden...
|
||||
Weitere {Math.min(50, groups.length - visibleGroups.length)} von {groups.length} Maßnahmen laden...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
})()}
|
||||
|
||||
{isExpanded && items.length === 0 && (
|
||||
<div className="px-4 py-6 text-center text-sm text-gray-400 border-t border-gray-100">
|
||||
|
||||
@@ -68,10 +68,14 @@ export default function OrderPage() {
|
||||
setSaveState('saving')
|
||||
try {
|
||||
const merged = { ...existingMetaRef.current, order_data: next }
|
||||
// Mirror Auftraggeber.Firmenname into the top-level customer_name
|
||||
// column so the Customer-Standards-Reuse feature can index by it.
|
||||
// Empty string → null on the backend, no broken reuse for fresh projects.
|
||||
const customerName = (next.client.company || '').trim()
|
||||
await fetch(`/api/sdk/v1/iace/projects/${projectId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ metadata: merged }),
|
||||
body: JSON.stringify({ metadata: merged, customer_name: customerName }),
|
||||
})
|
||||
existingMetaRef.current = merged
|
||||
setSaveState('saved')
|
||||
|
||||
@@ -1,86 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import type { VerificationItem, VerificationFormData } from './_components/verification-types'
|
||||
import { VerificationForm } from './_components/VerificationForm'
|
||||
import { CompleteModal } from './_components/CompleteModal'
|
||||
import { SuggestEvidenceModal } from './_components/SuggestEvidenceModal'
|
||||
import { VerificationTable } from './_components/VerificationTable'
|
||||
import { useMitigations } from '../mitigations/_hooks/useMitigations'
|
||||
import type { Mitigation } from '../mitigations/_components/types'
|
||||
|
||||
// Verifikations-Page (Phase-1 Workflow):
|
||||
//
|
||||
// Diese Seite ist eine abgeleitete View auf die Maßnahmen-Liste. Sie zeigt
|
||||
// nur diejenigen Maßnahmen, die der Fachmann auf der Maßnahmen-Seite als
|
||||
// `is_relevant = true` markiert hat. Pro Maßnahme stehen zwei Aktionen
|
||||
// zur Verfügung:
|
||||
//
|
||||
// 1. "Beim Kunden Standard" — Die Maßnahme ist beim Kunden bereits
|
||||
// umgesetzt (z.B. firmenweite Vorgabe, identische Vor-Anlage).
|
||||
// Setzt is_customer_standard = true und status = verified.
|
||||
// Es ist kein Nachweis-Dokument erforderlich.
|
||||
//
|
||||
// 2. "Verifizieren (mit Nachweis)" — Öffnet ein Modal, in dem der
|
||||
// Verifizierer einen Text-Nachweis hinterlegt (Prüfprotokoll-Nummer,
|
||||
// Abnahme-Referenz, etc.). Setzt status = verified. Die File-Upload-
|
||||
// Variante folgt in Phase 2, sobald ein Object-Storage-Backend
|
||||
// verfügbar ist.
|
||||
//
|
||||
// Wenn die Maßnahme bereits verifiziert ist, wird ein "Zurücksetzen"-Link
|
||||
// angeboten — er stellt status auf 'implemented' zurück, damit der
|
||||
// Fachmann eine versehentliche Bestätigung rückgängig machen kann.
|
||||
|
||||
export default function VerificationPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
const [items, setItems] = useState<VerificationItem[]>([])
|
||||
const [hazards, setHazards] = useState<{ id: string; name: string }[]>([])
|
||||
const [mitigations, setMitigations] = useState<{ id: string; title: string }[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [completingItem, setCompletingItem] = useState<VerificationItem | null>(null)
|
||||
const [showSuggest, setShowSuggest] = useState(false)
|
||||
|
||||
useEffect(() => { fetchData() }, [projectId])
|
||||
const { byType, loading, handleSetCustomerStandard } = useMitigations(projectId)
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
// Only load verifications initially — hazards/mitigations loaded on demand
|
||||
const verRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`)
|
||||
if (verRes.ok) { const j = await verRes.json(); setItems(j.verifications || j || []) }
|
||||
} catch (err) { console.error('Failed to fetch data:', err) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
async function loadMitigationsIfNeeded() {
|
||||
if (mitigations.length > 0) return
|
||||
try {
|
||||
const mitRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`)
|
||||
if (mitRes.ok) {
|
||||
const j = await mitRes.json()
|
||||
const mits = (j.mitigations || j || []).map((m: Record<string, string>) => ({ id: m.id, title: m.title || m.name || '' }))
|
||||
setMitigations(mits)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function handleSubmit(data: VerificationFormData) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
|
||||
})
|
||||
if (res.ok) { setShowForm(false); await fetchData() }
|
||||
} catch (err) { console.error('Failed to add verification:', err) }
|
||||
}
|
||||
|
||||
async function handleAddSuggestedEvidence(title: string, description: string, method: string, mitigationId: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, description, method, linked_mitigation_id: mitigationId }),
|
||||
})
|
||||
if (res.ok) await fetchData()
|
||||
} catch (err) { console.error('Failed to add suggested evidence:', err) }
|
||||
}
|
||||
|
||||
async function handleComplete(id: string, result: string, passed: boolean) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}/complete`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ result, passed }),
|
||||
})
|
||||
if (res.ok) { setCompletingItem(null); await fetchData() }
|
||||
} catch (err) { console.error('Failed to complete verification:', err) }
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Verifikation wirklich loeschen?')) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) await fetchData()
|
||||
} catch (err) { console.error('Failed to delete verification:', err) }
|
||||
}
|
||||
|
||||
const completed = items.filter(i => i.status === 'completed').length
|
||||
const failed = items.filter(i => i.status === 'failed').length
|
||||
const pending = items.filter(i => i.status === 'pending' || i.status === 'in_progress').length
|
||||
const [verifyTarget, setVerifyTarget] = useState<Mitigation | null>(null)
|
||||
const [verifyResult, setVerifyResult] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -88,82 +43,191 @@ export default function VerificationPage() {
|
||||
</div>
|
||||
)
|
||||
|
||||
const allRelevant = [...byType.design, ...byType.protection, ...byType.information].filter((m) => m.is_relevant)
|
||||
const groups = groupByTitle(allRelevant)
|
||||
const totals = {
|
||||
total: allRelevant.length,
|
||||
verified: allRelevant.filter((m) => m.status === 'verified').length,
|
||||
customerStd: allRelevant.filter((m) => m.is_customer_standard).length,
|
||||
pending: allRelevant.filter((m) => m.status !== 'verified').length,
|
||||
}
|
||||
|
||||
async function setStatus(id: string, value: 'implemented' | 'verified') {
|
||||
await fetch(`/api/sdk/v1/iace/mitigations/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: value }),
|
||||
})
|
||||
}
|
||||
|
||||
async function submitVerify() {
|
||||
if (!verifyTarget) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${verifyTarget.id}/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ verification_result: verifyResult }),
|
||||
})
|
||||
// Refetch via window-reload of just the data — useMitigations refreshes on mount.
|
||||
window.location.reload()
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
setVerifyTarget(null)
|
||||
setVerifyResult('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Verifikationsplan</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">Nachweisfuehrung fuer alle Schutzmassnahmen und Sicherheitsanforderungen.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{true && (
|
||||
<button onClick={async () => { await loadMitigationsIfNeeded(); setShowSuggest(true) }} className="flex items-center gap-2 px-3 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors text-sm">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
Nachweise vorschlagen
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setShowForm(true)} className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Verifikation hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Verifikation</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Bestätige die Umsetzung jeder als relevant markierten Maßnahme — entweder als
|
||||
<em> Kundenstandard</em> (keine Nachweis-Datei nötig) oder mit hinterlegtem Nachweis.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{items.length}</div>
|
||||
<div className="text-xs text-gray-500">Gesamt</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-green-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{completed}</div>
|
||||
<div className="text-xs text-green-600">Abgeschlossen</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-red-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-red-600">{failed}</div>
|
||||
<div className="text-xs text-red-600">Fehlgeschlagen</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-yellow-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-yellow-600">{pending}</div>
|
||||
<div className="text-xs text-yellow-600">Ausstehend</div>
|
||||
</div>
|
||||
{totals.total === 0 ? (
|
||||
<div className="rounded-md border border-gray-200 bg-gray-50 px-4 py-6 text-sm text-gray-600">
|
||||
Keine als <em>relevant</em> markierten Maßnahmen vorhanden. Gehe zurück zur
|
||||
{' '}<a className="text-purple-600 underline" href={`/sdk/iace/${projectId}/mitigations`}>Maßnahmen-Seite</a>{' '}
|
||||
und kreuze die anwendbaren Maßnahmen an.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<Stat n={totals.total} label="relevant" tone="gray" />
|
||||
<Stat n={totals.pending} label="offen" tone="amber" />
|
||||
<Stat n={totals.verified} label="verifiziert" tone="green" />
|
||||
<Stat n={totals.customerStd} label="Kundenstandard" tone="blue" />
|
||||
</div>
|
||||
|
||||
{groups.map(({ title, instances }) => {
|
||||
const verifiedCount = instances.filter((m) => m.status === 'verified').length
|
||||
const allDone = verifiedCount === instances.length
|
||||
return (
|
||||
<div key={title} className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className={`flex items-center gap-3 px-4 py-3 ${allDone ? 'bg-green-50 dark:bg-green-900/20' : 'bg-gray-50 dark:bg-gray-750'}`}>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">{title}</div>
|
||||
<div className="text-xs text-gray-500">{verifiedCount}/{instances.length} verifiziert</div>
|
||||
</div>
|
||||
{allDone && (
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{instances.map((m) => {
|
||||
const isVerified = m.status === 'verified'
|
||||
return (
|
||||
<div key={m.id} className={`grid grid-cols-[1fr_240px] gap-3 px-4 py-2.5 items-center ${isVerified ? 'bg-green-50/30 dark:bg-green-900/10' : ''}`}>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-200">
|
||||
{(m.linked_hazard_names || []).join(', ') || '— (keine Gefährdung verknüpft)'}
|
||||
</div>
|
||||
{m.is_customer_standard && (
|
||||
<div className="text-[11px] text-blue-600 mt-0.5">Beim Kunden Standard</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{!isVerified ? (
|
||||
<>
|
||||
<button onClick={async () => {
|
||||
await handleSetCustomerStandard(m.id, true)
|
||||
await setStatus(m.id, 'verified')
|
||||
window.location.reload()
|
||||
}} className="px-2.5 py-1 text-[11px] border border-blue-300 text-blue-700 rounded hover:bg-blue-50">
|
||||
Kundenstandard
|
||||
</button>
|
||||
<button onClick={() => { setVerifyTarget(m); setVerifyResult('') }}
|
||||
className="px-2.5 py-1 text-[11px] bg-green-600 text-white rounded hover:bg-green-700">
|
||||
Verifizieren…
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[11px] text-green-700">✓ Verifiziert</span>
|
||||
<button onClick={async () => {
|
||||
if (!confirm('Verifizierung zurücksetzen?')) return
|
||||
await setStatus(m.id, 'implemented')
|
||||
window.location.reload()
|
||||
}} className="text-[11px] text-gray-400 hover:text-red-600 underline">
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showForm && <VerificationForm onSubmit={handleSubmit} onCancel={() => setShowForm(false)} hazards={hazards} mitigations={mitigations} />}
|
||||
{completingItem && <CompleteModal item={completingItem} onSubmit={handleComplete} onClose={() => setCompletingItem(null)} />}
|
||||
{showSuggest && <SuggestEvidenceModal mitigations={mitigations} projectId={projectId} onAddEvidence={handleAddSuggestedEvidence} onClose={() => setShowSuggest(false)} />}
|
||||
|
||||
{items.length > 0 ? (
|
||||
<VerificationTable items={items} onComplete={setCompletingItem} onDelete={handleDelete} />
|
||||
) : !showForm && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Kein Verifikationsplan vorhanden</h3>
|
||||
<p className="mt-2 text-gray-500 max-w-md mx-auto">
|
||||
Definieren Sie Verifikationsschritte fuer Ihre Schutzmassnahmen.
|
||||
Jede Massnahme sollte durch mindestens eine Verifikation abgedeckt sein.
|
||||
</p>
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
{mitigations.length > 0 && (
|
||||
<button onClick={async () => { await loadMitigationsIfNeeded(); setShowSuggest(true) }} className="px-6 py-3 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors">
|
||||
Nachweise vorschlagen
|
||||
{verifyTarget && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl max-w-lg w-full p-6 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Verifizieren</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">{verifyTarget.title}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{(verifyTarget.linked_hazard_names || []).join(', ')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Nachweis / Prüfprotokoll-Referenz</label>
|
||||
<textarea value={verifyResult} onChange={(e) => setVerifyResult(e.target.value)}
|
||||
placeholder="z.B. Prüfprotokoll PM-2026-014 vom 14.05.2026, durchgeführt durch Hr. Schmidt (TÜV Süd)"
|
||||
className="w-full border rounded px-3 py-2 text-sm h-24" />
|
||||
<p className="text-[10px] text-gray-400 mt-1">Datei-Upload folgt in Phase 2 — vorerst genügt eine eindeutige Referenz.</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button onClick={() => setVerifyTarget(null)} disabled={submitting} className="text-xs px-3 py-1.5 text-gray-500 hover:text-gray-700">Abbrechen</button>
|
||||
<button onClick={submitVerify} disabled={submitting || !verifyResult.trim()}
|
||||
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50">
|
||||
{submitting ? 'Speichere…' : 'Verifizieren'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setShowForm(true)} className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||
Erste Verifikation anlegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ n, label, tone }: { n: number; label: string; tone: 'gray' | 'amber' | 'green' | 'blue' }) {
|
||||
const color =
|
||||
tone === 'amber' ? 'text-amber-600 border-amber-200' :
|
||||
tone === 'green' ? 'text-green-600 border-green-200' :
|
||||
tone === 'blue' ? 'text-blue-600 border-blue-200' :
|
||||
'text-gray-700 border-gray-200'
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg border p-4 text-center ${color}`}>
|
||||
<div className="text-2xl font-bold">{n}</div>
|
||||
<div className="text-xs">{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function groupByTitle(items: Mitigation[]): Array<{ title: string; instances: Mitigation[] }> {
|
||||
const map = new Map<string, Mitigation[]>()
|
||||
for (const m of items) {
|
||||
const key = (m.title || '').trim() || '(ohne Titel)'
|
||||
const arr = map.get(key)
|
||||
if (arr) arr.push(m); else map.set(key, [m])
|
||||
}
|
||||
// Frontend dedupe per hazard_id (mirrors mitigations/page.tsx)
|
||||
return Array.from(map.entries()).map(([title, list]) => {
|
||||
const byHazard = new Map<string, Mitigation>()
|
||||
for (const m of list) {
|
||||
const hid = (m.linked_hazard_ids || []).join('|') || m.id
|
||||
const prev = byHazard.get(hid)
|
||||
if (!prev || (m.status === 'verified' && prev.status !== 'verified')) byHazard.set(hid, m)
|
||||
}
|
||||
return { title, instances: Array.from(byHazard.values()) }
|
||||
})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user