Compare commits
200 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4087bb5f18 | |||
| 85e758b250 | |||
| 916dec87ee | |||
| 5fc16dd61d | |||
| 46278cda5b | |||
| 75174273f4 | |||
| 6baf44ac84 | |||
| 299375e486 | |||
| 2b1fe3713a | |||
| 872145d883 | |||
| 9bdaa28038 | |||
| 0a84c747f2 | |||
| cf6005a47c | |||
| 64d8b0f1f9 | |||
| d9278f256e | |||
| 0dbd7b4e45 | |||
| b663e2508f | |||
| ff100c1cb8 | |||
| 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 |
@@ -127,4 +127,58 @@ consent-tester/services/dsi_discovery.py
|
|||||||
backend-compliance/compliance/api/agent_compliance_check_routes.py
|
backend-compliance/compliance/api/agent_compliance_check_routes.py
|
||||||
|
|
||||||
# --- docs-src: binary office files (not source code) ---
|
# --- 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
|
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
|
# Build + push compliance service images to registry.meghsakha.com
|
||||||
# and trigger orca redeploy on every push to main that touches a service.
|
# and trigger orca redeploy after CI passes on main.
|
||||||
|
#
|
||||||
|
# This workflow is gated on the CI workflow completing successfully.
|
||||||
|
# It does not run independently — if CI fails, builds + deploy are skipped.
|
||||||
|
# Per-service builds are gated on detect-changes so only services with
|
||||||
|
# modified files are rebuilt; trigger-orca runs only if at least one build
|
||||||
|
# succeeded and none failed.
|
||||||
#
|
#
|
||||||
# Requires Gitea Actions secrets:
|
# Requires Gitea Actions secrets:
|
||||||
# REGISTRY_USERNAME / REGISTRY_PASSWORD — registry.meghsakha.com credentials
|
# REGISTRY_USERNAME / REGISTRY_PASSWORD — registry.meghsakha.com credentials
|
||||||
@@ -8,24 +14,68 @@
|
|||||||
name: Build + Deploy
|
name: Build + Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_run:
|
||||||
|
workflows: ["CI"]
|
||||||
|
types: [completed]
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
|
||||||
- 'admin-compliance/**'
|
|
||||||
- 'backend-compliance/**'
|
|
||||||
- 'ai-compliance-sdk/**'
|
|
||||||
- 'developer-portal/**'
|
|
||||||
- 'compliance-tts-service/**'
|
|
||||||
- 'document-crawler/**'
|
|
||||||
- 'dsms-gateway/**'
|
|
||||||
- 'dsms-node/**'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── per-service builds run in parallel ────────────────────────────────────
|
# ── gate: only proceed if CI succeeded ────────────────────────────────────
|
||||||
|
ci-passed:
|
||||||
|
runs-on: docker
|
||||||
|
container: alpine:3.20
|
||||||
|
if: github.event.workflow_run.conclusion == 'success'
|
||||||
|
steps:
|
||||||
|
- name: CI passed, proceeding with build + deploy
|
||||||
|
run: echo "CI run ${{ github.event.workflow_run.id }} succeeded on ${{ github.event.workflow_run.head_branch }} @ ${{ github.event.workflow_run.head_sha }}"
|
||||||
|
|
||||||
|
# ── detect which services changed since the last successful build ────────
|
||||||
|
# Diff base = the last-build/main git tag, set by mark-last-build at the
|
||||||
|
# end of every successful run. Works across squash merges, multi-commit
|
||||||
|
# raw pushes, and force pushes (force pushes leave a stale tag → diff
|
||||||
|
# shows symmetric differences → safe over-rebuild). If the tag doesn't
|
||||||
|
# exist yet, scripts/detect-changes.sh falls back to rebuilding all.
|
||||||
|
detect-changes:
|
||||||
|
runs-on: docker
|
||||||
|
container: alpine:3.20
|
||||||
|
needs: ci-passed
|
||||||
|
outputs:
|
||||||
|
admin: ${{ steps.diff.outputs.admin }}
|
||||||
|
backend: ${{ steps.diff.outputs.backend }}
|
||||||
|
sdk: ${{ steps.diff.outputs.sdk }}
|
||||||
|
portal: ${{ steps.diff.outputs.portal }}
|
||||||
|
tts: ${{ steps.diff.outputs.tts }}
|
||||||
|
crawler: ${{ steps.diff.outputs.crawler }}
|
||||||
|
dsms_gateway: ${{ steps.diff.outputs.dsms_gateway }}
|
||||||
|
dsms_node: ${{ steps.diff.outputs.dsms_node }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git bash
|
||||||
|
git clone --depth 200 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
git fetch --tags origin || true
|
||||||
|
- name: Resolve base SHA from last-build/main tag
|
||||||
|
run: |
|
||||||
|
BASE=$(git rev-parse --verify refs/tags/last-build/main 2>/dev/null || true)
|
||||||
|
echo "Base SHA: ${BASE:-<none, will rebuild all>}"
|
||||||
|
# Deepen if base isn't yet in the shallow clone.
|
||||||
|
if [ -n "$BASE" ] && ! git rev-parse --verify "${BASE}^{commit}" >/dev/null 2>&1; then
|
||||||
|
git fetch --unshallow origin 2>/dev/null \
|
||||||
|
|| git fetch --depth=10000 origin 2>/dev/null \
|
||||||
|
|| true
|
||||||
|
fi
|
||||||
|
echo "BASE_SHA=${BASE}" >> "$GITHUB_ENV"
|
||||||
|
- name: Detect changes
|
||||||
|
id: diff
|
||||||
|
run: bash scripts/detect-changes.sh
|
||||||
|
|
||||||
|
# ── per-service builds run in parallel (only changed services) ────────────
|
||||||
|
|
||||||
build-admin-compliance:
|
build-admin-compliance:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.admin == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -49,6 +99,8 @@ jobs:
|
|||||||
build-backend-compliance:
|
build-backend-compliance:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.backend == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -72,6 +124,8 @@ jobs:
|
|||||||
build-ai-sdk:
|
build-ai-sdk:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.sdk == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -95,6 +149,8 @@ jobs:
|
|||||||
build-developer-portal:
|
build-developer-portal:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.portal == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -118,6 +174,8 @@ jobs:
|
|||||||
build-tts:
|
build-tts:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.tts == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -141,6 +199,8 @@ jobs:
|
|||||||
build-document-crawler:
|
build-document-crawler:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.crawler == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -164,6 +224,8 @@ jobs:
|
|||||||
build-dsms-gateway:
|
build-dsms-gateway:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.dsms_gateway == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -187,6 +249,8 @@ jobs:
|
|||||||
build-dsms-node:
|
build-dsms-node:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.dsms_node == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -207,7 +271,55 @@ jobs:
|
|||||||
docker push registry.meghsakha.com/breakpilot/compliance-dsms-node:latest
|
docker push registry.meghsakha.com/breakpilot/compliance-dsms-node:latest
|
||||||
docker push registry.meghsakha.com/breakpilot/compliance-dsms-node:${SHORT_SHA}
|
docker push registry.meghsakha.com/breakpilot/compliance-dsms-node:${SHORT_SHA}
|
||||||
|
|
||||||
# ── orca redeploy (only after all builds succeed) ─────────────────────────
|
# ── advance the last-build/main tag — the diff base for future runs ──────
|
||||||
|
# Runs when no build failed. Covers two cases:
|
||||||
|
# - at least one service was rebuilt → mark this SHA as the new baseline
|
||||||
|
# - all services were skipped (nothing changed) → still advance the tag
|
||||||
|
# so we don't keep re-evaluating the same skipped commits forever
|
||||||
|
# Skips if any build failed → tag stays put → next push retries those
|
||||||
|
# services from the previous known-good base.
|
||||||
|
mark-last-build:
|
||||||
|
runs-on: docker
|
||||||
|
container: alpine:3.20
|
||||||
|
needs:
|
||||||
|
- build-admin-compliance
|
||||||
|
- build-backend-compliance
|
||||||
|
- build-ai-sdk
|
||||||
|
- build-developer-portal
|
||||||
|
- build-tts
|
||||||
|
- build-document-crawler
|
||||||
|
- build-dsms-gateway
|
||||||
|
- build-dsms-node
|
||||||
|
if: |
|
||||||
|
always() &&
|
||||||
|
!contains(needs.*.result, 'failure') &&
|
||||||
|
!contains(needs.*.result, 'cancelled')
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git
|
||||||
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
- name: Force-push last-build/main tag
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
SHA="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
||||||
|
echo "Advancing last-build/main → ${SHA}"
|
||||||
|
git tag -f last-build/main "$SHA"
|
||||||
|
# Encode token into the push URL (no on-disk credential persistence).
|
||||||
|
PUSH_URL="${GITHUB_SERVER_URL/https:\/\//https:\/\/x-access-token:${GITEA_TOKEN}@}/${GITHUB_REPOSITORY}.git"
|
||||||
|
git push --force "$PUSH_URL" "refs/tags/last-build/main"
|
||||||
|
echo "Tag last-build/main now at ${SHA}"
|
||||||
|
|
||||||
|
# ── orca redeploy — runs 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:
|
trigger-orca:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
@@ -221,6 +333,18 @@ jobs:
|
|||||||
- build-document-crawler
|
- build-document-crawler
|
||||||
- build-dsms-gateway
|
- build-dsms-gateway
|
||||||
- build-dsms-node
|
- 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:
|
steps:
|
||||||
- name: Checkout (for SHA)
|
- name: Checkout (for SHA)
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+101
-9
@@ -19,6 +19,49 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
|
# ── Change detection (always runs first) ─────────────────────────────────
|
||||||
|
# Diff base:
|
||||||
|
# PR → merge-base with the PR base branch
|
||||||
|
# push → last-build/main tag (set by build-push-deploy after a green build)
|
||||||
|
# Falls back to "rebuild all" when the base is missing or unreachable.
|
||||||
|
detect-changes:
|
||||||
|
runs-on: docker
|
||||||
|
container: alpine:3.20
|
||||||
|
outputs:
|
||||||
|
admin: ${{ steps.diff.outputs.admin }}
|
||||||
|
backend: ${{ steps.diff.outputs.backend }}
|
||||||
|
sdk: ${{ steps.diff.outputs.sdk }}
|
||||||
|
portal: ${{ steps.diff.outputs.portal }}
|
||||||
|
tts: ${{ steps.diff.outputs.tts }}
|
||||||
|
crawler: ${{ steps.diff.outputs.crawler }}
|
||||||
|
dsms_gateway: ${{ steps.diff.outputs.dsms_gateway }}
|
||||||
|
dsms_node: ${{ steps.diff.outputs.dsms_node }}
|
||||||
|
any_python: ${{ steps.diff.outputs.any_python }}
|
||||||
|
any_node: ${{ steps.diff.outputs.any_node }}
|
||||||
|
any: ${{ steps.diff.outputs.any }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git bash
|
||||||
|
git clone --depth 200 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
|
||||||
|
git fetch --depth 200 origin "${GITHUB_BASE_REF}" || true
|
||||||
|
else
|
||||||
|
git fetch --tags origin || true
|
||||||
|
fi
|
||||||
|
- name: Resolve base SHA
|
||||||
|
run: |
|
||||||
|
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
|
||||||
|
BASE=$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD 2>/dev/null || true)
|
||||||
|
else
|
||||||
|
BASE=$(git rev-parse --verify refs/tags/last-build/main 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
echo "Base SHA: ${BASE:-<none>}"
|
||||||
|
echo "BASE_SHA=${BASE}" >> "$GITHUB_ENV"
|
||||||
|
- name: Detect changes
|
||||||
|
id: diff
|
||||||
|
run: bash scripts/detect-changes.sh
|
||||||
|
|
||||||
# ── Branch naming convention (PR only) ──────────────────────────────────
|
# ── Branch naming convention (PR only) ──────────────────────────────────
|
||||||
branch-name:
|
branch-name:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
@@ -55,10 +98,12 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── LOC budget (always) ──────────────────────────────────────────────────
|
# ── LOC budget (only if files changed) ───────────────────────────────────
|
||||||
loc-budget:
|
loc-budget:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: alpine:3.20
|
container: alpine:3.20
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.any == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -86,10 +131,11 @@ jobs:
|
|||||||
--redact \
|
--redact \
|
||||||
|| { echo "::error::Secrets detected — remove them before merging."; exit 1; }
|
|| { echo "::error::Secrets detected — remove them before merging."; exit 1; }
|
||||||
|
|
||||||
# ── Go lint + build (PR only) ────────────────────────────────────────────
|
# ── Go lint + build (PR only, gated on ai-compliance-sdk changes) ────────
|
||||||
go-lint:
|
go-lint:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
if: github.event_name == 'pull_request'
|
needs: detect-changes
|
||||||
|
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.sdk == 'true'
|
||||||
container: golangci/golangci-lint:v1.62-alpine
|
container: golangci/golangci-lint:v1.62-alpine
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -107,10 +153,11 @@ jobs:
|
|||||||
cd ai-compliance-sdk
|
cd ai-compliance-sdk
|
||||||
go build ./...
|
go build ./...
|
||||||
|
|
||||||
# ── Python lint + import check (PR only) ────────────────────────────────
|
# ── Python lint + import check (PR only, gated on python service changes) ─
|
||||||
python-lint:
|
python-lint:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
if: github.event_name == 'pull_request'
|
needs: detect-changes
|
||||||
|
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any_python == 'true'
|
||||||
container: python:3.12-slim
|
container: python:3.12-slim
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -137,10 +184,11 @@ jobs:
|
|||||||
python -c "import compliance; print('Import OK')" \
|
python -c "import compliance; print('Import OK')" \
|
||||||
|| { echo "::error::compliance package fails to import — missing import or syntax error."; exit 1; }
|
|| { echo "::error::compliance package fails to import — missing import or syntax error."; exit 1; }
|
||||||
|
|
||||||
# ── Node.js lint + type-check (PR only) ─────────────────────────────────
|
# ── Node.js lint + type-check (PR only, gated on Next.js service changes) ─
|
||||||
nodejs-lint:
|
nodejs-lint:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
if: github.event_name == 'pull_request'
|
needs: detect-changes
|
||||||
|
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any_node == 'true'
|
||||||
container: node:20-alpine
|
container: node:20-alpine
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -158,10 +206,12 @@ jobs:
|
|||||||
done
|
done
|
||||||
exit $fail
|
exit $fail
|
||||||
|
|
||||||
# ── Node.js build — next build (PR + push to main) ───────────────────────
|
# ── Node.js build — next build (gated on Next.js service changes) ───────
|
||||||
nodejs-build:
|
nodejs-build:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: node:20-alpine
|
container: node:20-alpine
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.any_node == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -244,10 +294,12 @@ jobs:
|
|||||||
- name: Vulnerability scan (fail on high+)
|
- name: Vulnerability scan (fail on high+)
|
||||||
run: grype sbom:sbom-out/sbom.cdx.json --fail-on high -q
|
run: grype sbom:sbom-out/sbom.cdx.json --fail-on high -q
|
||||||
|
|
||||||
# ── Tests (PR + push to main) ─────────────────────────────────────────────
|
# ── Tests (gated per service) ────────────────────────────────────────────
|
||||||
test-go:
|
test-go:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: golang:1.24-alpine
|
container: golang:1.24-alpine
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.sdk == 'true'
|
||||||
env:
|
env:
|
||||||
CGO_ENABLED: "0"
|
CGO_ENABLED: "0"
|
||||||
steps:
|
steps:
|
||||||
@@ -262,9 +314,45 @@ jobs:
|
|||||||
go test -v -coverprofile=coverage.out ./...
|
go test -v -coverprofile=coverage.out ./...
|
||||||
go tool cover -func=coverage.out | tail -1
|
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:
|
test-python-backend:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: python:3.12-slim
|
container: python:3.12-slim
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.backend == 'true'
|
||||||
env:
|
env:
|
||||||
CI: "true"
|
CI: "true"
|
||||||
steps:
|
steps:
|
||||||
@@ -284,6 +372,8 @@ jobs:
|
|||||||
test-python-document-crawler:
|
test-python-document-crawler:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: python:3.12-slim
|
container: python:3.12-slim
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.crawler == 'true'
|
||||||
env:
|
env:
|
||||||
CI: "true"
|
CI: "true"
|
||||||
steps:
|
steps:
|
||||||
@@ -303,6 +393,8 @@ jobs:
|
|||||||
test-python-dsms-gateway:
|
test-python-dsms-gateway:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: python:3.12-slim
|
container: python:3.12-slim
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.dsms_gateway == 'true'
|
||||||
env:
|
env:
|
||||||
CI: "true"
|
CI: "true"
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -55,5 +55,9 @@ EXPOSE 3000
|
|||||||
# Set hostname
|
# Set hostname
|
||||||
ENV HOSTNAME="0.0.0.0"
|
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
|
# Start the application
|
||||||
CMD ["node", "server.js"]
|
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.
|
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.
|
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
|
## RAG-Nutzung
|
||||||
Nutze das gesamte RAG-Corpus fuer Kontext und Quellenangaben — ausgenommen sind
|
Nutze das gesamte RAG-Corpus fuer Kontext und Quellenangaben — ausgenommen sind
|
||||||
NIBIS-Inhalte (Erwartungshorizonte, Bildungsstandards, curriculare Vorgaben).
|
NIBIS-Inhalte (Erwartungshorizonte, Bildungsstandards, curriculare Vorgaben).
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Proxy: Admin → Backend /api/compliance/agent/admin/benchmark
|
||||||
|
* (P107 — Branchen-Benchmark-Cockpit)
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const qs = request.nextUrl.searchParams.toString()
|
||||||
|
try {
|
||||||
|
const r = await fetch(
|
||||||
|
`${BACKEND_URL}/api/compliance/agent/admin/benchmark?${qs}`,
|
||||||
|
{ signal: AbortSignal.timeout(20000) },
|
||||||
|
)
|
||||||
|
const body = await r.text()
|
||||||
|
return new NextResponse(body, {
|
||||||
|
status: r.status,
|
||||||
|
headers: { 'Content-Type': r.headers.get('content-type') || 'application/json' },
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Benchmark-API nicht erreichbar', detail: String(e) },
|
||||||
|
{ status: 503 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' },
|
regenerate: { label: 'Neugenerierung', color: 'text-red-700', bg: 'bg-red-100' },
|
||||||
fix: { label: 'Korrekturen', color: 'text-amber-700', bg: 'bg-amber-100' },
|
fix: { label: 'Korrekturen', color: 'text-amber-700', bg: 'bg-amber-100' },
|
||||||
import: { label: 'Konform', color: 'text-green-700', bg: 'bg-green-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> = {
|
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,
|
regenerate: results.filter(r => r.scenario === 'regenerate').length,
|
||||||
fix: results.filter(r => r.scenario === 'fix').length,
|
fix: results.filter(r => r.scenario === 'fix').length,
|
||||||
import: results.filter(r => r.scenario === 'import').length,
|
import: results.filter(r => r.scenario === 'import').length,
|
||||||
|
missing: results.filter(r => r.scenario === 'missing').length,
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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.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.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.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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,7 +167,15 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 shrink-0 ml-3">
|
<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>
|
<span className="text-xs text-red-600 font-medium">Fehler</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useState, useCallback } from 'react'
|
import React, { useState, useCallback } from 'react'
|
||||||
import { ChecklistView } from './ChecklistView'
|
import { ChecklistView } from './ChecklistView'
|
||||||
import { DocumentRow } from './DocumentRow'
|
import { DocumentRow } from './DocumentRow'
|
||||||
|
import { MigrationPanel } from './MigrationPanel'
|
||||||
|
|
||||||
const DOCUMENT_TYPES = [
|
const DOCUMENT_TYPES = [
|
||||||
{ id: 'dse', label: 'DSI (Datenschutzinformation)', required: true },
|
{ id: 'dse', label: 'DSI (Datenschutzinformation)', required: true },
|
||||||
@@ -66,13 +67,17 @@ interface HistoryEntry {
|
|||||||
docCount: number
|
docCount: number
|
||||||
findings: number
|
findings: number
|
||||||
resultKey: string
|
resultKey: string
|
||||||
|
checkId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ComplianceCheckTab() {
|
export function ComplianceCheckTab() {
|
||||||
const [docs, setDocs] = useState<DocsState>(initState)
|
const [docs, setDocs] = useState<DocsState>(initState)
|
||||||
const [useAgent, setUseAgent] = useState(false)
|
const [useAgent, setUseAgent] = useState(false)
|
||||||
|
const [tdmOverride, setTdmOverride] = useState(false)
|
||||||
|
const [tdmOverrideReason, setTdmOverrideReason] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [progress, setProgress] = useState('')
|
const [progress, setProgress] = useState('')
|
||||||
|
const [progressPct, setProgressPct] = useState(0)
|
||||||
const [results, setResults] = useState<any>(() => {
|
const [results, setResults] = useState<any>(() => {
|
||||||
if (typeof window === 'undefined') return null
|
if (typeof window === 'undefined') return null
|
||||||
try { const s = localStorage.getItem(STORAGE_KEY_RESULTS); return s ? JSON.parse(s) : null } catch { 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
|
if (!res.ok) continue
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.progress) setProgress(data.progress)
|
if (data.progress) setProgress(data.progress)
|
||||||
|
if (typeof data.progress_pct === 'number') setProgressPct(data.progress_pct)
|
||||||
if (data.status === 'completed' && data.result) {
|
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.setItem(STORAGE_KEY_RESULTS, JSON.stringify(data.result))
|
||||||
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (data.status === 'failed' || data.status === 'not_found') {
|
if (['failed', 'not_found', 'skipped_tdm'].includes(data.status)) {
|
||||||
if (data.status === 'failed') setError(data.error || 'Pruefung fehlgeschlagen')
|
if (data.status !== 'not_found') setError(data.error || (data.status === 'skipped_tdm' ? 'TDM-Vorbehalt erkannt — Crawl uebersprungen' : 'Pruefung fehlgeschlagen'))
|
||||||
setProgress(''); setLoading(false)
|
setProgress(''); setProgressPct(0); setLoading(false); localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId(''); return
|
||||||
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
} catch { /* retry */ }
|
} catch { /* retry */ }
|
||||||
}
|
}
|
||||||
@@ -177,6 +181,7 @@ export function ComplianceCheckTab() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
setResults(null)
|
setResults(null)
|
||||||
setProgress('Compliance-Check wird gestartet...')
|
setProgress('Compliance-Check wird gestartet...')
|
||||||
|
setProgressPct(0)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = DOCUMENT_TYPES
|
const entries = DOCUMENT_TYPES
|
||||||
@@ -194,6 +199,8 @@ export function ComplianceCheckTab() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
documents: entries,
|
documents: entries,
|
||||||
use_agent: useAgent,
|
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}`)
|
if (!startRes.ok) throw new Error(`Pruefung konnte nicht gestartet werden: ${startRes.status}`)
|
||||||
@@ -202,17 +209,19 @@ export function ComplianceCheckTab() {
|
|||||||
setActiveCheckId(check_id)
|
setActiveCheckId(check_id)
|
||||||
localStorage.setItem(STORAGE_KEY_CHECK_ID, 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
|
let attempts = 0
|
||||||
while (attempts < 300) {
|
while (attempts < 500) {
|
||||||
await new Promise(r => setTimeout(r, 3000))
|
await new Promise(r => setTimeout(r, 3000))
|
||||||
const pollRes = await fetch(`/api/sdk/v1/agent/compliance-check?check_id=${check_id}`)
|
const pollRes = await fetch(`/api/sdk/v1/agent/compliance-check?check_id=${check_id}`)
|
||||||
if (!pollRes.ok) { attempts++; continue }
|
if (!pollRes.ok) { attempts++; continue }
|
||||||
const pollData = await pollRes.json()
|
const pollData = await pollRes.json()
|
||||||
if (pollData.progress) setProgress(pollData.progress)
|
if (pollData.progress) setProgress(pollData.progress)
|
||||||
|
if (typeof pollData.progress_pct === 'number') setProgressPct(pollData.progress_pct)
|
||||||
if (pollData.status === 'completed' && pollData.result) {
|
if (pollData.status === 'completed' && pollData.result) {
|
||||||
setResults(pollData.result)
|
setResults(pollData.result)
|
||||||
setProgress('')
|
setProgress('')
|
||||||
|
setProgressPct(0)
|
||||||
localStorage.setItem(STORAGE_KEY_RESULTS, JSON.stringify(pollData.result))
|
localStorage.setItem(STORAGE_KEY_RESULTS, JSON.stringify(pollData.result))
|
||||||
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||||
|
|
||||||
@@ -229,19 +238,20 @@ export function ComplianceCheckTab() {
|
|||||||
localStorage.setItem(STORAGE_KEY_HISTORY, JSON.stringify(updated))
|
localStorage.setItem(STORAGE_KEY_HISTORY, JSON.stringify(updated))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (pollData.status === 'failed') {
|
if (['failed', 'skipped_tdm'].includes(pollData.status)) {
|
||||||
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
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++
|
attempts++
|
||||||
}
|
}
|
||||||
if (attempts >= 300) {
|
if (attempts >= 500) {
|
||||||
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||||
throw new Error('Zeitlimit ueberschritten (15 Min)')
|
throw new Error('Zeitlimit ueberschritten (15 Min)')
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||||
setProgress('')
|
setProgress('')
|
||||||
|
setProgressPct(0)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -313,10 +323,15 @@ export function ComplianceCheckTab() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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 */}
|
{/* Submit button */}
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
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"
|
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 ? (
|
{loading ? (
|
||||||
@@ -334,12 +349,21 @@ export function ComplianceCheckTab() {
|
|||||||
|
|
||||||
{/* Progress */}
|
{/* Progress */}
|
||||||
{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">
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm text-purple-700 space-y-2">
|
||||||
<svg className="animate-spin w-4 h-4 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24">
|
<div className="flex items-center gap-3">
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
<svg className="animate-spin w-4 h-4 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24">
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
</svg>
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
{progress}
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -439,13 +463,14 @@ export function ComplianceCheckTab() {
|
|||||||
|
|
||||||
<ChecklistView results={results.results} />
|
<ChecklistView results={results.results} />
|
||||||
|
|
||||||
{/* Email status */}
|
{/* Email + Migration + Full-audit */}
|
||||||
{results.email_status && (
|
{results.email_status && (
|
||||||
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
|
<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'}`} />
|
<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}
|
E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{results.check_id && <MigrationPanel checkId={results.check_id} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -2,30 +2,41 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { ChecklistView } from './ChecklistView'
|
import { ChecklistView } from './ChecklistView'
|
||||||
|
import { ResultsTabsView } from './ResultsTabsView'
|
||||||
|
import { PreScanWizard, useScanContext, isContextComplete } from './PreScanWizard'
|
||||||
|
import { safeSetItem } from './storageHelpers'
|
||||||
|
|
||||||
interface DocEntry {
|
interface DocEntry {
|
||||||
id: string
|
id: string
|
||||||
type: string
|
type: string
|
||||||
label: string
|
label: string
|
||||||
url: string
|
url: string
|
||||||
|
text: string // P-Paste: User kopiert Doc-Text direkt rein
|
||||||
|
mode: 'url' | 'text' // welcher Input wird aktiv genutzt
|
||||||
}
|
}
|
||||||
|
|
||||||
const DOC_TYPES = [
|
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: 'social_media', label: 'DSE Social Media (Art. 26)' },
|
||||||
{ id: 'dsfa', label: 'DSFA (Art. 35)' },
|
{ id: 'dsfa', label: 'DSFA (Art. 35)' },
|
||||||
{ id: 'agb', label: 'AGB / Nutzungsbedingungen' },
|
{ id: 'dsa', label: 'DSA / Digital Services Act' },
|
||||||
{ id: 'impressum', label: 'Impressum' },
|
{ id: 'legal_notice', label: 'Rechtliche Hinweise (IP, Forward-Looking)' },
|
||||||
{ id: 'cookie', label: 'Cookie-Richtlinie' },
|
{ id: 'lizenzhinweise', label: 'Lizenzhinweise Dritter (OSS)' },
|
||||||
{ id: 'widerruf', label: 'Widerrufsbelehrung' },
|
|
||||||
{ id: 'other', label: 'Sonstiges' },
|
{ id: 'other', label: 'Sonstiges' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function newEntry(): DocEntry {
|
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() {
|
export function DocCheckTab() {
|
||||||
|
const [scanContext, setScanContext] = useScanContext()
|
||||||
const [entries, setEntries] = useState<DocEntry[]>(() => {
|
const [entries, setEntries] = useState<DocEntry[]>(() => {
|
||||||
if (typeof window === 'undefined') return [newEntry()]
|
if (typeof window === 'undefined') return [newEntry()]
|
||||||
try { const s = localStorage.getItem('doc-check-entries'); return s ? JSON.parse(s) : [newEntry()] } catch { return [newEntry()] }
|
try { const s = localStorage.getItem('doc-check-entries'); return s ? JSON.parse(s) : [newEntry()] } catch { return [newEntry()] }
|
||||||
@@ -74,7 +85,7 @@ export function DocCheckTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
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
|
if (validEntries.length === 0) return
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -89,11 +100,17 @@ export function DocCheckTab() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
entries: validEntries.map(e => ({
|
entries: validEntries.map(e => ({
|
||||||
doc_type: e.type,
|
doc_type: e.type,
|
||||||
label: e.label || e.url.split('/').pop() || 'Dokument',
|
label: e.label
|
||||||
url: e.url.trim(),
|
|| (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,
|
check_cookie_banner: checkCookieBanner,
|
||||||
use_agent: useAgent,
|
use_agent: useAgent,
|
||||||
|
scan_context: scanContext,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!startRes.ok) throw new Error(`Pruefung konnte nicht gestartet werden: ${startRes.status}`)
|
if (!startRes.ok) throw new Error(`Pruefung konnte nicht gestartet werden: ${startRes.status}`)
|
||||||
@@ -111,13 +128,13 @@ export function DocCheckTab() {
|
|||||||
if (pollData.status === 'completed' && pollData.result) {
|
if (pollData.status === 'completed' && pollData.result) {
|
||||||
setResults(pollData.result)
|
setResults(pollData.result)
|
||||||
setProgress('')
|
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()}`
|
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 entry = { date: new Date().toISOString(), urls: validEntries.length, findings: pollData.result.total_findings || 0, resultKey }
|
||||||
const updated = [entry, ...history].slice(0, 30)
|
const updated = [entry, ...history].slice(0, 30)
|
||||||
setHistory(updated)
|
setHistory(updated)
|
||||||
localStorage.setItem('doc-check-history', JSON.stringify(updated))
|
safeSetItem('doc-check-history', JSON.stringify(updated))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (pollData.status === 'failed') {
|
if (pollData.status === 'failed') {
|
||||||
@@ -133,43 +150,90 @@ export function DocCheckTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contextReady = isContextComplete(scanContext)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* URL Entries */}
|
{/* P79 Pre-Scan-Wizard — 8 Pflichtfelder */}
|
||||||
<div className="space-y-2">
|
<PreScanWizard value={scanContext} onChange={setScanContext} />
|
||||||
|
|
||||||
|
{/* URL / Text Entries */}
|
||||||
|
<div className="space-y-3">
|
||||||
{entries.map((entry, i) => (
|
{entries.map((entry, i) => (
|
||||||
<div key={entry.id} className="flex items-center gap-2">
|
<div key={entry.id} className="space-y-1.5">
|
||||||
<select
|
<div className="flex items-center gap-2">
|
||||||
value={entry.type}
|
<select
|
||||||
onChange={e => updateEntry(entry.id, 'type', e.target.value)}
|
value={entry.type}
|
||||||
className="w-48 px-3 py-2.5 border border-gray-300 rounded-lg text-sm bg-white shrink-0"
|
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>
|
{DOC_TYPES.map(t => (
|
||||||
))}
|
<option key={t.id} value={t.id}>{t.label}</option>
|
||||||
</select>
|
))}
|
||||||
<input
|
</select>
|
||||||
type="text"
|
<input
|
||||||
value={entry.label}
|
type="text"
|
||||||
onChange={e => updateEntry(entry.id, 'label', e.target.value)}
|
value={entry.label}
|
||||||
placeholder={entry.type === 'other' ? 'Dokumentname' : 'Version / Stand (optional)'}
|
onChange={e => updateEntry(entry.id, 'label', e.target.value)}
|
||||||
className="w-40 px-3 py-2.5 border border-gray-300 rounded-lg text-sm shrink-0"
|
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}
|
{/* Mode-Toggle URL / Text */}
|
||||||
onChange={e => updateEntry(entry.id, 'url', e.target.value)}
|
<div className="inline-flex border border-gray-300 rounded-lg overflow-hidden text-xs shrink-0">
|
||||||
onBlur={() => autoLabel(entry)}
|
<button type="button"
|
||||||
placeholder="https://example.com/datenschutz"
|
onClick={() => updateEntry(entry.id, 'mode', 'url')}
|
||||||
className="flex-1 px-3 py-2.5 border border-gray-300 rounded-lg text-sm"
|
className={`px-3 py-2 ${entry.mode === 'url'
|
||||||
/>
|
? 'bg-purple-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'}`}>
|
||||||
{entries.length > 1 && (
|
URL
|
||||||
<button onClick={() => removeEntry(entry.id)}
|
</button>
|
||||||
className="p-2 text-gray-400 hover:text-red-500 shrink-0">
|
<button type="button"
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
onClick={() => updateEntry(entry.id, 'mode', 'text')}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
className={`px-3 py-2 ${entry.mode === 'text'
|
||||||
</svg>
|
? 'bg-purple-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'}`}>
|
||||||
</button>
|
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>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -212,8 +276,11 @@ export function DocCheckTab() {
|
|||||||
{/* Submit */}
|
{/* Submit */}
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
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"
|
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 ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
@@ -223,6 +290,8 @@ export function DocCheckTab() {
|
|||||||
</svg>
|
</svg>
|
||||||
Pruefe...
|
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`
|
`${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>
|
<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 && (
|
{results && results.results && (
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
<ResultsTabsView results={results} />
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* History */}
|
{/* 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 { AddSystemForm } from './_components/AddSystemForm'
|
||||||
import { AISystemCard } from './_components/AISystemCard'
|
import { AISystemCard } from './_components/AISystemCard'
|
||||||
import DecisionTreeWizard from '@/components/sdk/ai-act/DecisionTreeWizard'
|
import DecisionTreeWizard from '@/components/sdk/ai-act/DecisionTreeWizard'
|
||||||
|
import { Art10Tile } from './_components/Art10Tile'
|
||||||
|
|
||||||
type TabId = 'overview' | 'decision-tree' | 'results'
|
type TabId = 'overview' | 'decision-tree' | 'results'
|
||||||
|
|
||||||
@@ -136,6 +137,7 @@ function SavedResultsTab() {
|
|||||||
Löschen
|
Löschen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<Art10Tile riskLevel={r.high_risk_result} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -360,6 +362,16 @@ export default function AIActPage() {
|
|||||||
)}
|
)}
|
||||||
</StepHeader>
|
</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 */}
|
{/* Tabs */}
|
||||||
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg w-fit">
|
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg w-fit">
|
||||||
{TABS.map(tab => (
|
{TABS.map(tab => (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
CATEGORY_OPTIONS,
|
CATEGORY_OPTIONS,
|
||||||
} from '../control-library/components/helpers'
|
} from '../control-library/components/helpers'
|
||||||
import { ControlDetail } from '../control-library/components/ControlDetail'
|
import { ControlDetail } from '../control-library/components/ControlDetail'
|
||||||
|
import { SourceBadge } from '@/components/sdk/SourceBadge'
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@@ -310,6 +311,7 @@ export default function AtomicControlsPage() {
|
|||||||
<TargetAudienceBadge audience={ctrl.target_audience} />
|
<TargetAudienceBadge audience={ctrl.target_audience} />
|
||||||
<GenerationStrategyBadge strategy={ctrl.generation_strategy} pipelineInfo={ctrl} />
|
<GenerationStrategyBadge strategy={ctrl.generation_strategy} pipelineInfo={ctrl} />
|
||||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||||
|
<SourceBadge controlUuid={ctrl.id} compact />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-sm font-medium text-gray-900 group-hover:text-violet-700">{ctrl.title}</h3>
|
<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>
|
<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 React, { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||||
|
import { LicenseModuleBanner } from '@/components/sdk/LicenseModuleBanner'
|
||||||
import { useAuditChecklist } from './_hooks/useAuditChecklist'
|
import { useAuditChecklist } from './_hooks/useAuditChecklist'
|
||||||
import { ChecklistItemCard } from './_components/ChecklistItemCard'
|
import { ChecklistItemCard } from './_components/ChecklistItemCard'
|
||||||
import { LoadingSkeleton } from './_components/LoadingSkeleton'
|
import { LoadingSkeleton } from './_components/LoadingSkeleton'
|
||||||
@@ -89,6 +90,12 @@ export default function AuditChecklistPage() {
|
|||||||
</div>
|
</div>
|
||||||
</StepHeader>
|
</StepHeader>
|
||||||
|
|
||||||
|
<LicenseModuleBanner
|
||||||
|
rule={3}
|
||||||
|
sourceLabel="BreakPilot-Audit-Methodik"
|
||||||
|
detail="Eigene Audit-Checklisten und -Workflows. Zitierte Rechtsquellen (DSGVO/ISO 27001/...) jeweils mit eigener Lizenzregel."
|
||||||
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||||
<span>{error}</span>
|
<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'
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import { useAuditTimeline, type AuditEntry } from './_hooks/useAuditTimeline'
|
import { useAuditTimeline, type AuditEntry } from './_hooks/useAuditTimeline'
|
||||||
|
import CIDHistoryModal from './_components/CIDHistoryModal'
|
||||||
|
|
||||||
const ENTITY_LABELS: Record<string, string> = {
|
const ENTITY_LABELS: Record<string, string> = {
|
||||||
evidence: 'Nachweis', control: 'Control', document: 'Dokument',
|
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']
|
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() {
|
export default function AuditTimelinePage() {
|
||||||
const { entries, loading, filter, setFilter } = useAuditTimeline()
|
const { entries, loading, filter, setFilter } = useAuditTimeline()
|
||||||
|
const [historyCID, setHistoryCID] = useState<string | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
@@ -58,16 +76,18 @@ export default function AuditTimelinePage() {
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
<TimelineEntry key={entry.id} entry={entry} />
|
<TimelineEntry key={entry.id} entry={entry} onShowHistory={setHistoryCID} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{historyCID && <CIDHistoryModal cid={historyCID} onClose={() => setHistoryCID(null)} />}
|
||||||
</div>
|
</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 dotColor = ACTION_COLORS[entry.action] || 'bg-gray-400'
|
||||||
const isCID = entry.field_changed === 'dsms_cid' || entry.action === 'archive'
|
const isCID = entry.field_changed === 'dsms_cid' || entry.action === 'archive'
|
||||||
const date = new Date(entry.performed_at)
|
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>
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">{entry.change_summary}</p>
|
||||||
)}
|
)}
|
||||||
{isCID && entry.new_value && (
|
{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">
|
<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" />
|
<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>
|
</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}
|
{entry.new_value.length > 20 ? entry.new_value.slice(0, 8) + '...' + entry.new_value.slice(-6) : entry.new_value}
|
||||||
</code>
|
</code>
|
||||||
<span className="text-[10px] text-emerald-500">DSMS/IPFS</span>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P107 — Branchen-Benchmark-Cockpit.
|
||||||
|
*
|
||||||
|
* Multi-Site-Vergleich auf einen Blick. Anonymize-Toggle für Big-4-
|
||||||
|
* Wirtschaftspruefer-Demos.
|
||||||
|
*
|
||||||
|
* URL: /sdk/benchmark
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface Kpi {
|
||||||
|
check_id: string
|
||||||
|
site_label: string
|
||||||
|
site_domain: string
|
||||||
|
captured_at: string
|
||||||
|
industry: string
|
||||||
|
vendors_total: number
|
||||||
|
vendors_us: number
|
||||||
|
vendors_non_eu: number
|
||||||
|
us_pct: number
|
||||||
|
non_eu_pct: number
|
||||||
|
source_breakdown: Record<string, number>
|
||||||
|
max_cookies_per_vendor: number
|
||||||
|
avg_cookies_per_vendor: number
|
||||||
|
cookies_in_browser: number
|
||||||
|
cookies_detailed_count: number
|
||||||
|
cookie_doc_chars: number
|
||||||
|
banner_detected: boolean
|
||||||
|
banner_provider: string
|
||||||
|
banner_violations: number
|
||||||
|
compliance_score: number | null
|
||||||
|
saving_low_eur: number
|
||||||
|
saving_high_eur: number
|
||||||
|
data_quality_pct: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Summary {
|
||||||
|
n_sites: number
|
||||||
|
avg_vendors: number
|
||||||
|
avg_us_pct: number
|
||||||
|
avg_non_eu_pct: number
|
||||||
|
avg_cookies_browser: number
|
||||||
|
avg_score: number
|
||||||
|
max_vendors: number
|
||||||
|
max_saving_high: number
|
||||||
|
total_saving_low: number
|
||||||
|
total_saving_high: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const INDUSTRIES = [
|
||||||
|
{ id: '', label: 'Alle Branchen' },
|
||||||
|
{ id: 'automotive', label: 'Automotive (OEM)' },
|
||||||
|
{ id: 'banking', label: 'Banking / Finance' },
|
||||||
|
{ id: 'chemistry', label: 'Chemie / Pharma' },
|
||||||
|
{ id: 'luftfahrt', label: 'Luftfahrt' },
|
||||||
|
{ id: 'ecommerce', label: 'E-Commerce' },
|
||||||
|
{ id: 'saas', label: 'SaaS / Software' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PRESET_GROUPS = [
|
||||||
|
{ id: 'automotive_oem', label: 'Automotive OEMs', sites: 'Volkswagen,BMW,Mercedes-Benz,SEAT,AUDI' },
|
||||||
|
{ id: 'automotive_supl', label: 'Automotive Zulieferer', sites: 'ZF Friedrichshafen,Robert Bosch,Continental' },
|
||||||
|
{ id: 'chemie', label: 'Chemie (DAX)', sites: 'BASF,Bayer,Henkel,Linde' },
|
||||||
|
{ id: 'luftfahrt', label: 'Luftfahrt', sites: 'Lufthansa,Eurowings,Condor' },
|
||||||
|
{ id: 'banking', label: 'Banking (DAX)', sites: 'Deutsche Bank,Commerzbank,DZ Bank,KfW' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function BenchmarkPage() {
|
||||||
|
const [industry, setIndustry] = useState('')
|
||||||
|
const [sites, setSites] = useState('')
|
||||||
|
const [anonymized, setAnonymized] = useState(false)
|
||||||
|
const [data, setData] = useState<{kpis: Kpi[]; summary: Summary} | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true); setError(null)
|
||||||
|
try {
|
||||||
|
const url = new URL('/api/compliance/admin/benchmark', window.location.origin)
|
||||||
|
if (industry) url.searchParams.set('industry', industry)
|
||||||
|
if (sites) url.searchParams.set('sites', sites)
|
||||||
|
if (anonymized) url.searchParams.set('anonymized', 'true')
|
||||||
|
const r = await fetch(url.toString())
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||||
|
setData(await r.json())
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || String(e))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { fetchData() }, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-7xl mx-auto">
|
||||||
|
<header className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
Branchen-Benchmark-Cockpit
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
DAX-Konzern-Vergleich auf Basis aller bisher gepruefter Sites.
|
||||||
|
Mit Anonymize-Toggle fuer Wirtschaftspruefer-Demos.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Filter-Leiste */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4 mb-4 flex flex-wrap gap-3 items-end">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Branche</label>
|
||||||
|
<select value={industry} onChange={e => setIndustry(e.target.value)}
|
||||||
|
className="px-3 py-2 border rounded text-sm">
|
||||||
|
{INDUSTRIES.map(i => <option key={i.id} value={i.id}>{i.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-[300px]">
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
|
Sites (komma-getrennt) oder Preset wählen
|
||||||
|
</label>
|
||||||
|
<input value={sites} onChange={e => setSites(e.target.value)}
|
||||||
|
placeholder="Volkswagen,BMW,Mercedes-Benz"
|
||||||
|
className="w-full px-3 py-2 border rounded text-sm font-mono" />
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{PRESET_GROUPS.map(p => (
|
||||||
|
<button key={p.id} onClick={() => setSites(p.sites)}
|
||||||
|
className="px-2 py-0.5 text-[10px] bg-gray-100 hover:bg-gray-200 rounded">
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input type="checkbox" checked={anonymized}
|
||||||
|
onChange={e => setAnonymized(e.target.checked)}
|
||||||
|
className="rounded" />
|
||||||
|
<span><strong>Anonymisieren</strong> (OEM 1/2/3 statt Hersteller-Namen)</span>
|
||||||
|
</label>
|
||||||
|
<button onClick={fetchData} disabled={loading}
|
||||||
|
className="px-4 py-2 bg-purple-600 text-white rounded font-medium hover:bg-purple-700 disabled:opacity-50">
|
||||||
|
{loading ? 'Lade…' : 'Aktualisieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 rounded p-3 text-sm mb-4">
|
||||||
|
Fehler: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary-KPIs */}
|
||||||
|
{data?.summary && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 mb-4">
|
||||||
|
<Kpi label="Sites im Vergleich" value={data.summary.n_sites} />
|
||||||
|
<Kpi label="⌀ Vendors" value={data.summary.avg_vendors} />
|
||||||
|
<Kpi label="⌀ US-Anteil" value={`${data.summary.avg_us_pct}%`}
|
||||||
|
tone={data.summary.avg_us_pct > 60 ? 'warn' : 'ok'} />
|
||||||
|
<Kpi label="⌀ Score" value={data.summary.avg_score || '—'} />
|
||||||
|
<Kpi label="Saving-Potenzial (Σ)" value={`${Math.round(data.summary.total_saving_high/1000)}k €`}
|
||||||
|
tone="ok" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vergleichstabelle */}
|
||||||
|
{data?.kpis && data.kpis.length > 0 ? (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-gray-50 text-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-3 py-2 sticky left-0 bg-gray-50">Site</th>
|
||||||
|
<th className="text-right px-2 py-2">Score</th>
|
||||||
|
<th className="text-right px-2 py-2">Vendors</th>
|
||||||
|
<th className="text-right px-2 py-2">US%</th>
|
||||||
|
<th className="text-right px-2 py-2">Drittland%</th>
|
||||||
|
<th className="text-right px-2 py-2">Cookies Browser</th>
|
||||||
|
<th className="text-right px-2 py-2">Cookie-Doc kB</th>
|
||||||
|
<th className="text-center px-2 py-2">Banner</th>
|
||||||
|
<th className="text-left px-2 py-2">Provider</th>
|
||||||
|
<th className="text-right px-2 py-2">Banner-Verstöße</th>
|
||||||
|
<th className="text-right px-2 py-2">Saving € Jahr</th>
|
||||||
|
<th className="text-right px-2 py-2">Daten-Qualität</th>
|
||||||
|
<th className="text-left px-2 py-2">Captured</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.kpis.map((k, i) => (
|
||||||
|
<tr key={i} className={`border-t hover:bg-gray-50 ${i%2 ? 'bg-gray-50/30' : ''}`}>
|
||||||
|
<td className="px-3 py-2 font-semibold sticky left-0 bg-inherit">
|
||||||
|
{k.site_label}
|
||||||
|
<div className="text-[9px] text-gray-400 font-mono">{k.check_id}</div>
|
||||||
|
</td>
|
||||||
|
<td className={`px-2 py-2 text-right ${
|
||||||
|
!k.compliance_score ? 'text-gray-400' :
|
||||||
|
k.compliance_score >= 80 ? 'text-green-700' :
|
||||||
|
k.compliance_score >= 60 ? 'text-amber-700' : 'text-red-700'
|
||||||
|
}`}>
|
||||||
|
{k.compliance_score ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right font-mono">{k.vendors_total}</td>
|
||||||
|
<td className={`px-2 py-2 text-right ${k.us_pct > 60 ? 'text-red-700 font-semibold' : ''}`}>
|
||||||
|
{k.us_pct}%
|
||||||
|
</td>
|
||||||
|
<td className={`px-2 py-2 text-right ${k.non_eu_pct > 70 ? 'text-red-700' : ''}`}>
|
||||||
|
{k.non_eu_pct}%
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right font-mono">{k.cookies_in_browser}</td>
|
||||||
|
<td className="px-2 py-2 text-right text-gray-500">
|
||||||
|
{Math.round(k.cookie_doc_chars / 1000)}k
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-center">{k.banner_detected ? '✓' : '✗'}</td>
|
||||||
|
<td className="px-2 py-2 text-gray-600">{k.banner_provider || '—'}</td>
|
||||||
|
<td className={`px-2 py-2 text-right ${k.banner_violations ? 'text-red-700' : 'text-gray-400'}`}>
|
||||||
|
{k.banner_violations || 0}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right text-green-700 font-mono">
|
||||||
|
{k.saving_high_eur ? `${(k.saving_high_eur/1000).toFixed(0)}k` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className={`px-2 py-2 text-right ${
|
||||||
|
k.data_quality_pct >= 70 ? 'text-green-700' :
|
||||||
|
k.data_quality_pct >= 40 ? 'text-amber-700' : 'text-red-700'
|
||||||
|
}`}>
|
||||||
|
{k.data_quality_pct}%
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-[10px] text-gray-500">
|
||||||
|
{k.captured_at?.substring(0, 16).replace('T', ' ')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : !loading && (
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center text-gray-500">
|
||||||
|
Keine Snapshots gefunden — Filter anpassen oder einen Audit-Lauf starten.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 text-xs text-gray-500">
|
||||||
|
<strong>Big-4-Hinweis:</strong> Mit Anonymize-Toggle koennen wir den
|
||||||
|
kompletten Branchen-Cut zeigen ohne Hersteller-Namen zu nennen
|
||||||
|
(z.B. "OEM 3 hat 78% US-Vendor-Anteil"). Damit ist die Daten-
|
||||||
|
Hoheit bei BreakPilot und Big 4 sieht den Mehrwert ohne dass
|
||||||
|
Wettbewerber-Vergleiche extern werden.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Kpi({ label, value, tone = 'neutral' }: {
|
||||||
|
label: string; value: any; tone?: 'ok' | 'warn' | 'bad' | 'neutral'
|
||||||
|
}) {
|
||||||
|
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-white 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-xl font-bold mt-1">{value}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,6 +8,23 @@ import type { CanonicalControl } from '../_types'
|
|||||||
import { EFFORT_LABELS } from '../_types'
|
import { EFFORT_LABELS } from '../_types'
|
||||||
import { SeverityBadge, StateBadge, LicenseRuleBadge } from './Badges'
|
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({
|
export function ControlDetailView({
|
||||||
ctrl,
|
ctrl,
|
||||||
onBack,
|
onBack,
|
||||||
@@ -72,31 +89,31 @@ export function ControlDetailView({
|
|||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Geltungsbereich</h3>
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">Geltungsbereich</h3>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
{ctrl.scope.platforms && ctrl.scope.platforms.length > 0 && (
|
{asStringArray(ctrl.scope?.platforms).length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-gray-500 mb-1">Plattformen</p>
|
<p className="text-xs font-medium text-gray-500 mb-1">Plattformen</p>
|
||||||
<div className="flex flex-wrap gap-1">
|
<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>
|
<span key={p} className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs">{p}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{ctrl.scope.components && ctrl.scope.components.length > 0 && (
|
{asStringArray(ctrl.scope?.components).length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-gray-500 mb-1">Komponenten</p>
|
<p className="text-xs font-medium text-gray-500 mb-1">Komponenten</p>
|
||||||
<div className="flex flex-wrap gap-1">
|
<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>
|
<span key={c} className="px-2 py-0.5 bg-purple-50 text-purple-700 rounded text-xs">{c}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{ctrl.scope.data_classes && ctrl.scope.data_classes.length > 0 && (
|
{asStringArray(ctrl.scope?.data_classes).length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-gray-500 mb-1">Datenklassen</p>
|
<p className="text-xs font-medium text-gray-500 mb-1">Datenklassen</p>
|
||||||
<div className="flex flex-wrap gap-1">
|
<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>
|
<span key={d} className="px-2 py-0.5 bg-amber-50 text-amber-700 rounded text-xs">{d}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -109,7 +126,7 @@ export function ControlDetailView({
|
|||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
|
||||||
<ol className="space-y-2">
|
<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">
|
<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>
|
<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}
|
{req}
|
||||||
@@ -122,7 +139,7 @@ export function ControlDetailView({
|
|||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
|
||||||
<ol className="space-y-2">
|
<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">
|
<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" />
|
<CheckCircle2 className="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" />
|
||||||
{step}
|
{step}
|
||||||
@@ -135,12 +152,18 @@ export function ControlDetailView({
|
|||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Nachweisanforderungen</h3>
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">Nachweisanforderungen</h3>
|
||||||
<div className="space-y-2">
|
<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">
|
<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" />
|
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-medium text-gray-500 uppercase">{ev.type}</span>
|
{typeof ev === 'string' ? (
|
||||||
<p className="text-sm text-gray-700">{ev.description}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -152,13 +175,13 @@ export function ControlDetailView({
|
|||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<BookOpen className="w-4 h-4 text-green-700" />
|
<BookOpen className="w-4 h-4 text-green-700" />
|
||||||
<h3 className="text-sm font-semibold text-green-900">Open-Source-Referenzen</h3>
|
<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>
|
</div>
|
||||||
<p className="text-xs text-green-700 mb-3">
|
<p className="text-xs text-green-700 mb-3">
|
||||||
Dieses Control basiert auf frei verfuegbarem Wissen. Alle Referenzen sind offen und oeffentlich zugaenglich.
|
Dieses Control basiert auf frei verfuegbarem Wissen. Alle Referenzen sind offen und oeffentlich zugaenglich.
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
<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">
|
<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" />
|
<Scale className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -180,11 +203,11 @@ export function ControlDetailView({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
{ctrl.tags.length > 0 && (
|
{asStringArray(ctrl.tags).length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Tags</h3>
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">Tags</h3>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<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>
|
<span key={tag} className="px-2 py-1 bg-gray-100 text-gray-600 rounded text-xs">{tag}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ import { ControlRegulatorySection } from './ControlRegulatorySection'
|
|||||||
import { ControlSimilarControls } from './ControlSimilarControls'
|
import { ControlSimilarControls } from './ControlSimilarControls'
|
||||||
import { ControlReviewActions } from './ControlReviewActions'
|
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 {
|
interface SimilarControl {
|
||||||
control_id: string; title: string; severity: string; release_state: string;
|
control_id: string; title: string; severity: string; release_state: string;
|
||||||
tags: string[]; license_rule: number | null; verification_method: string | null;
|
tags: string[]; license_rule: number | null; verification_method: string | null;
|
||||||
@@ -186,7 +196,7 @@ export function ControlDetail({
|
|||||||
<ControlTraceability ctrl={ctrl} traceability={traceability} loadingTrace={loadingTrace}
|
<ControlTraceability ctrl={ctrl} traceability={traceability} loadingTrace={loadingTrace}
|
||||||
onNavigateToControl={onNavigateToControl} />
|
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">
|
<section className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Scale className="w-4 h-4 text-amber-600" />
|
<Scale className="w-4 h-4 text-amber-600" />
|
||||||
@@ -201,36 +211,36 @@ export function ControlDetail({
|
|||||||
</section>
|
</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>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Geltungsbereich</h3>
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">Geltungsbereich</h3>
|
||||||
<div className="grid grid-cols-3 gap-4 text-xs">
|
<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}
|
{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}
|
||||||
{ctrl.scope.components?.length ? <div><span className="text-gray-500">Komponenten:</span> <span className="text-gray-700">{ctrl.scope.components.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}
|
||||||
{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?.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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{Array.isArray(ctrl.requirements) && ctrl.requirements.length > 0 && (
|
{toArray<string>(ctrl.requirements).length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
|
||||||
<ol className="list-decimal list-inside space-y-1">{ctrl.requirements.map((r, i) => <li key={i} className="text-sm text-gray-700">{r}</li>)}</ol>
|
<ol className="list-decimal list-inside space-y-1">{toArray<string>(ctrl.requirements).map((r, i) => <li key={i} className="text-sm text-gray-700">{r}</li>)}</ol>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{Array.isArray(ctrl.test_procedure) && ctrl.test_procedure.length > 0 && (
|
{toArray<string>(ctrl.test_procedure).length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
|
||||||
<ol className="list-decimal list-inside space-y-1">{ctrl.test_procedure.map((s, i) => <li key={i} className="text-sm text-gray-700">{s}</li>)}</ol>
|
<ol className="list-decimal list-inside space-y-1">{toArray<string>(ctrl.test_procedure).map((s, i) => <li key={i} className="text-sm text-gray-700">{s}</li>)}</ol>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ctrl.evidence.length > 0 && (
|
{toArray(ctrl.evidence).length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Nachweise</h3>
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">Nachweise</h3>
|
||||||
<div className="space-y-2">
|
<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">
|
<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" />
|
<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>}
|
{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">
|
<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.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.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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -253,11 +263,11 @@ export function ControlDetail({
|
|||||||
<section className="bg-green-50 border border-green-200 rounded-lg p-4">
|
<section className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<BookOpen className="w-4 h-4 text-green-700" />
|
<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>
|
</div>
|
||||||
{ctrl.open_anchors.length > 0 ? (
|
{toArray(ctrl.open_anchors).length > 0 ? (
|
||||||
<div className="space-y-2">
|
<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">
|
<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" />
|
<ExternalLink className="w-3.5 h-3.5 text-green-600 flex-shrink-0" />
|
||||||
<span className="font-medium text-green-800">{anchor.framework}</span>
|
<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 }) {
|
export function LicenseRuleBadge({ rule }: { rule: number | null | undefined }) {
|
||||||
if (!rule) return null
|
if (!rule) return null
|
||||||
const config: Record<number, { bg: string; label: string }> = {
|
// Corrected labels per Task #21 LICENSE_RULES.md mapping:
|
||||||
1: { bg: 'bg-green-100 text-green-700', label: 'Free Use' },
|
// R1 = woertlich (Hoheitsrecht/Public Domain, no attribution required)
|
||||||
2: { bg: 'bg-blue-100 text-blue-700', label: 'Zitation' },
|
// R2 = woertlich + Attribution-Pflicht (CC-BY, OWASP, OECD, ENISA)
|
||||||
3: { bg: 'bg-amber-100 text-amber-700', label: 'Reformuliert' },
|
// 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]
|
const c = config[rule]
|
||||||
if (!c) return null
|
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 }) {
|
export function VerificationMethodBadge({ method }: { method: string | null }) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { EMPTY_CONTROL } from './components/helpers'
|
import { EMPTY_CONTROL } from './components/helpers'
|
||||||
import { ControlForm } from './components/ControlForm'
|
import { ControlForm } from './components/ControlForm'
|
||||||
import { ControlDetail } from './components/ControlDetail'
|
import { ControlDetail } from './components/ControlDetail'
|
||||||
@@ -12,6 +14,24 @@ import { BACKEND_URL } from './components/helpers'
|
|||||||
|
|
||||||
export default function ControlLibraryPage() {
|
export default function ControlLibraryPage() {
|
||||||
const state = useControlLibraryState()
|
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 {
|
const {
|
||||||
handleCreate, handleUpdate, handleDelete, handleReview, handleBulkReject,
|
handleCreate, handleUpdate, handleDelete, handleReview, handleBulkReject,
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export interface BannerSite {
|
|||||||
site_name: string
|
site_name: string
|
||||||
site_url: string
|
site_url: string
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
|
tcf_enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCookieBanner() {
|
export function useCookieBanner() {
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export default function CookieBannerPage() {
|
|||||||
|
|
||||||
{/* Tab: TCF/IAB */}
|
{/* Tab: TCF/IAB */}
|
||||||
{activeTab === 'tcf' && (
|
{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) => {
|
onToggle={(enabled) => {
|
||||||
if (activeSiteId) {
|
if (activeSiteId) {
|
||||||
fetch(`/api/sdk/v1/banner/admin/sites/${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):
|
// 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:
|
// Einwilligungen:
|
||||||
{ key: 'consent', label: 'Einwilligungen', types: ['consent_texts', 'cookie_banner', 'verpflichtungserklaerung'] },
|
{ 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 TemplateLibrary from './_components/TemplateLibrary'
|
||||||
import GeneratorSection from './_components/GeneratorSection'
|
import GeneratorSection from './_components/GeneratorSection'
|
||||||
import RecommendedDocuments from './_components/RecommendedDocuments'
|
import RecommendedDocuments from './_components/RecommendedDocuments'
|
||||||
|
import { LifecycleFilter, filterTemplatesByStage } from './_components/LifecycleFilter'
|
||||||
|
import type { LifecycleStage } from '@/lib/sdk/founding/template-categories'
|
||||||
|
|
||||||
function DocumentGeneratorPageInner() {
|
function DocumentGeneratorPageInner() {
|
||||||
const { state } = useSDK()
|
const { state } = useSDK()
|
||||||
@@ -24,6 +26,7 @@ function DocumentGeneratorPageInner() {
|
|||||||
const [allTemplates, setAllTemplates] = useState<LegalTemplateResult[]>([])
|
const [allTemplates, setAllTemplates] = useState<LegalTemplateResult[]>([])
|
||||||
const [isLoadingLibrary, setIsLoadingLibrary] = useState(true)
|
const [isLoadingLibrary, setIsLoadingLibrary] = useState(true)
|
||||||
const [activeCategory, setActiveCategory] = useState<string>('all')
|
const [activeCategory, setActiveCategory] = useState<string>('all')
|
||||||
|
const [activeStage, setActiveStage] = useState<LifecycleStage | 'all'>('all')
|
||||||
const [activeLanguage, setActiveLanguage] = useState<'all' | 'de' | 'en'>('all')
|
const [activeLanguage, setActiveLanguage] = useState<'all' | 'de' | 'en'>('all')
|
||||||
const [librarySearch, setLibrarySearch] = useState('')
|
const [librarySearch, setLibrarySearch] = useState('')
|
||||||
const [expandedPreviewId, setExpandedPreviewId] = useState<string | null>(null)
|
const [expandedPreviewId, setExpandedPreviewId] = useState<string | null>(null)
|
||||||
@@ -101,7 +104,35 @@ function DocumentGeneratorPageInner() {
|
|||||||
}
|
}
|
||||||
}, [state?.complianceScope?.determinedLevel, state?.companyProfile])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const banner = state?.cookieBanner
|
const banner = state?.cookieBanner
|
||||||
if (!banner) return
|
if (!banner) return
|
||||||
@@ -181,10 +212,15 @@ function DocumentGeneratorPageInner() {
|
|||||||
}
|
}
|
||||||
}, [selectedDataPointsData])
|
}, [selectedDataPointsData])
|
||||||
|
|
||||||
// Filtered templates (computed)
|
// Filtered templates (computed) — Lifecycle + Category + Language + Search
|
||||||
const filteredTemplates = useMemo(() => {
|
const filteredTemplates = useMemo(() => {
|
||||||
const category = CATEGORIES.find((c: { key: string }) => c.key === activeCategory)
|
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 && category.types !== null) {
|
||||||
if (!category.types.includes(t.templateType || '')) return false
|
if (!category.types.includes(t.templateType || '')) return false
|
||||||
}
|
}
|
||||||
@@ -197,7 +233,22 @@ function DocumentGeneratorPageInner() {
|
|||||||
}
|
}
|
||||||
return true
|
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) => {
|
const handleUseTemplate = useCallback((t: LegalTemplateResult) => {
|
||||||
setActiveTemplate(t)
|
setActiveTemplate(t)
|
||||||
@@ -246,6 +297,16 @@ function DocumentGeneratorPageInner() {
|
|||||||
tips={stepInfo.tips}
|
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 */}
|
{/* Status bar */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||||
@@ -264,6 +325,13 @@ function DocumentGeneratorPageInner() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Lifecycle-Phase Filter */}
|
||||||
|
<LifecycleFilter
|
||||||
|
activeStage={activeStage}
|
||||||
|
onChange={setActiveStage}
|
||||||
|
countsByStage={countsByStage}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Recommended documents based on scope profile */}
|
{/* Recommended documents based on scope profile */}
|
||||||
<RecommendedDocuments
|
<RecommendedDocuments
|
||||||
allTemplates={allTemplates}
|
allTemplates={allTemplates}
|
||||||
|
|||||||
@@ -225,6 +225,51 @@ const TEMPLATE_RULES: TemplateRule[] = [
|
|||||||
condition: () => 'required', // Immer Pflicht bei Websites
|
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) ───────────────────────────────────────
|
// ── Drittlandtransfer (SCC + TIA) ───────────────────────────────────────
|
||||||
// SCC+TIA nur erforderlich wenn Drittlandtransfer OHNE Angemessenheitsbeschluss/DPF
|
// SCC+TIA nur erforderlich wenn Drittlandtransfer OHNE Angemessenheitsbeschluss/DPF
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -132,6 +132,16 @@ export default function DSFAPage() {
|
|||||||
)}
|
)}
|
||||||
</StepHeader>
|
</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 */}
|
{/* DSFA Requirement Check */}
|
||||||
{dsfaCheck.required && dsfas.length === 0 && (
|
{dsfaCheck.required && dsfas.length === 0 && (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-xl p-5">
|
<div className="bg-red-50 border border-red-200 rounded-xl p-5">
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { useBannerConsents } from '../_hooks/useBannerConsents'
|
import { useBannerConsents } from '../_hooks/useBannerConsents'
|
||||||
import { BannerConsentRecord, PAGE_SIZE } from '../_types'
|
import { BannerConsentRecord, PAGE_SIZE } from '../_types'
|
||||||
|
|
||||||
|
const BANNER_API = '/api/sdk/v1/banner'
|
||||||
|
const TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
|
||||||
function formatDate(iso: string | null): string {
|
function formatDate(iso: string | null): string {
|
||||||
if (!iso) return '—'
|
if (!iso) return '—'
|
||||||
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
@@ -42,12 +45,35 @@ const methodColors: Record<string, string> = {
|
|||||||
export default function BannerConsentsTab() {
|
export default function BannerConsentsTab() {
|
||||||
const {
|
const {
|
||||||
records, sites, selectedSite, changeSite,
|
records, sites, selectedSite, changeSite,
|
||||||
stats, currentPage, setCurrentPage, totalRecords, loading,
|
stats, currentPage, setCurrentPage, totalRecords, loading, reload,
|
||||||
} = useBannerConsents()
|
} = useBannerConsents()
|
||||||
|
|
||||||
const [detail, setDetail] = useState<BannerConsentRecord | null>(null)
|
const [detail, setDetail] = useState<BannerConsentRecord | null>(null)
|
||||||
|
const [linkEmailInput, setLinkEmailInput] = useState('')
|
||||||
|
const [linkingEmail, setLinkingEmail] = useState(false)
|
||||||
const totalPages = Math.ceil(totalRecords / PAGE_SIZE)
|
const totalPages = Math.ceil(totalRecords / PAGE_SIZE)
|
||||||
|
|
||||||
|
const withdrawConsent = useCallback(async (id: string) => {
|
||||||
|
if (!confirm('Consent wirklich widerrufen? Diese Aktion kann nicht rueckgaengig gemacht werden.')) return
|
||||||
|
await fetch(`${BANNER_API}/consent/${id}`, { method: 'DELETE', headers: { 'x-tenant-id': TENANT_ID } })
|
||||||
|
setDetail(null)
|
||||||
|
reload()
|
||||||
|
}, [reload])
|
||||||
|
|
||||||
|
const linkEmail = useCallback(async (record: BannerConsentRecord) => {
|
||||||
|
if (!linkEmailInput.includes('@')) return
|
||||||
|
setLinkingEmail(true)
|
||||||
|
await fetch(`${BANNER_API}/consent/link-email`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-tenant-id': TENANT_ID },
|
||||||
|
body: JSON.stringify({ site_id: record.site_id, device_fingerprint: record.device_fingerprint, email: linkEmailInput }),
|
||||||
|
})
|
||||||
|
setLinkingEmail(false)
|
||||||
|
setLinkEmailInput('')
|
||||||
|
setDetail({ ...record, linked_email: linkEmailInput })
|
||||||
|
reload()
|
||||||
|
}, [linkEmailInput, reload])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Stats + Site Selector */}
|
{/* Stats + Site Selector */}
|
||||||
@@ -184,6 +210,18 @@ export default function BannerConsentsTab() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{detail.vendor_consents && Object.keys(detail.vendor_consents).length > 0 && (
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<span className="text-gray-500">Vendors</span>
|
||||||
|
<div className="flex flex-wrap gap-1 justify-end">
|
||||||
|
{Object.entries(detail.vendor_consents).map(([name, accepted]) => (
|
||||||
|
<span key={name} className={`text-xs px-2 py-0.5 rounded-full ${accepted ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">Methode</span>
|
<span className="text-gray-500">Methode</span>
|
||||||
<span>{detail.consent_method ? (
|
<span>{detail.consent_method ? (
|
||||||
@@ -192,9 +230,28 @@ export default function BannerConsentsTab() {
|
|||||||
</span>
|
</span>
|
||||||
) : '—'}</span>
|
) : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-gray-500">Verknüpft mit</span>
|
<span className="text-gray-500">Verknüpft mit</span>
|
||||||
<span>{detail.linked_email || '— (anonym)'}</span>
|
{detail.linked_email ? (
|
||||||
|
<span className="text-purple-600 text-xs">{detail.linked_email}</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="E-Mail verknüpfen..."
|
||||||
|
value={linkEmailInput}
|
||||||
|
onChange={e => setLinkEmailInput(e.target.value)}
|
||||||
|
className="text-xs border border-gray-200 rounded px-2 py-1 w-40"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => linkEmail(detail)}
|
||||||
|
disabled={linkingEmail || !linkEmailInput.includes('@')}
|
||||||
|
className="text-xs px-2 py-1 bg-purple-600 text-white rounded disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{linkingEmail ? '...' : 'Link'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between"><span className="text-gray-500">Erteilt</span><span>{formatDate(detail.created_at)}</span></div>
|
<div className="flex justify-between"><span className="text-gray-500">Erteilt</span><span>{formatDate(detail.created_at)}</span></div>
|
||||||
<div className="flex justify-between"><span className="text-gray-500">Ablauf</span><span>{formatDate(detail.expires_at)}</span></div>
|
<div className="flex justify-between"><span className="text-gray-500">Ablauf</span><span>{formatDate(detail.expires_at)}</span></div>
|
||||||
@@ -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>}
|
{detail.banner_config_hash && <div><span className="text-gray-500 text-xs">Config-Hash</span><p className="text-xs text-gray-600 font-mono">{detail.banner_config_hash}</p></div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Widerruf-Button */}
|
||||||
|
<div className="border-t border-gray-100 pt-4 mt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => withdrawConsent(detail.id)}
|
||||||
|
className="w-full px-4 py-2 text-xs font-semibold text-red-600 border border-red-200 rounded-lg hover:bg-red-50 transition-colors"
|
||||||
|
>
|
||||||
|
Consent widerrufen (Art. 17 DSGVO)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export interface BannerConsentRecord {
|
|||||||
device_fingerprint: string
|
device_fingerprint: string
|
||||||
categories: string[]
|
categories: string[]
|
||||||
vendors: string[]
|
vendors: string[]
|
||||||
|
vendor_consents: Record<string, boolean>
|
||||||
ip_hash: string | null
|
ip_hash: string | null
|
||||||
user_agent: string | null
|
user_agent: string | null
|
||||||
linked_email: string | null
|
linked_email: string | null
|
||||||
@@ -144,4 +145,5 @@ export interface BannerSite {
|
|||||||
site_id: string
|
site_id: string
|
||||||
site_name: string
|
site_name: string
|
||||||
site_url: string
|
site_url: string
|
||||||
|
tcf_enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,12 +57,7 @@ export default function EinwilligungenPage() {
|
|||||||
explanation={stepInfo.explanation}
|
explanation={stepInfo.explanation}
|
||||||
tips={stepInfo.tips}
|
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">
|
<ConsentExportButton />
|
||||||
<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>
|
|
||||||
</StepHeader>
|
</StepHeader>
|
||||||
|
|
||||||
{/* Navigation Tabs */}
|
{/* Navigation Tabs */}
|
||||||
@@ -150,3 +145,32 @@ export default function EinwilligungenPage() {
|
|||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+155
-14
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'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'
|
import type { HazardMatchPair, GroundTruthEntry, HazardSummary } from '../_hooks/useBenchmark'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -11,17 +12,57 @@ interface Props {
|
|||||||
|
|
||||||
type TabType = 'matched' | 'missing' | 'extra'
|
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) {
|
export function HazardComparisonTable({ matched, missing, extra }: Props) {
|
||||||
const [tab, setTab] = useState<TabType>('matched')
|
const [tab, setTab] = useState<TabType>('matched')
|
||||||
|
const params = useParams()
|
||||||
|
const projectId = params?.projectId as string | undefined
|
||||||
|
const clarStatusByHazard = useClarificationsByHazard(projectId)
|
||||||
|
|
||||||
// Compute quality levels for matched pairs
|
// Split matches: >= 50% are real matches, < 50% are weak (shown separately)
|
||||||
const greenCount = matched.filter(p => p.match_score >= 0.7).length
|
const realMatched = matched.filter(p => p.match_score >= 0.5)
|
||||||
const yellowCount = matched.filter(p => p.match_score >= 0.4 && p.match_score < 0.7).length
|
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 }[] = [
|
const tabs: { id: TabType; label: string; count: number; color: string }[] = [
|
||||||
{ id: 'matched', label: `Zugeordnet (${greenCount} exakt, ${yellowCount} aehnlich)`, count: matched.length, color: 'text-green-600' },
|
{ id: 'matched', label: `Zugeordnet (${greenCount} exakt, ${yellowCount} aehnlich)`, count: realMatched.length, color: 'text-green-600' },
|
||||||
{ id: 'missing', label: 'Fehlend', count: missing.length, color: 'text-red-600' },
|
{ id: 'missing', label: 'Fehlend', count: allMissing.length, color: 'text-red-600' },
|
||||||
{ id: 'extra', label: 'Zusaetzlich', count: extra.length, color: 'text-gray-500' },
|
{ id: 'extra', label: 'Engine Findings', count: allExtra.length, color: 'text-blue-500' },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -44,15 +85,15 @@ export function HazardComparisonTable({ matched, missing, extra }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
{tab === 'matched' && <MatchedTable pairs={matched} />}
|
{tab === 'matched' && <MatchedTable pairs={realMatched} clarStatusByHazard={clarStatusByHazard} projectId={projectId} />}
|
||||||
{tab === 'missing' && <MissingTable entries={missing} />}
|
{tab === 'missing' && <MissingTable entries={allMissing} />}
|
||||||
{tab === 'extra' && <ExtraTable entries={extra} />}
|
{tab === 'extra' && <ExtraTable entries={allExtra} />}
|
||||||
</div>
|
</div>
|
||||||
</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>>({})
|
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
|
||||||
if (pairs.length === 0) return <EmptyState text="Keine Zuordnungen gefunden" />
|
if (pairs.length === 0) return <EmptyState text="Keine Zuordnungen gefunden" />
|
||||||
return (
|
return (
|
||||||
@@ -102,7 +143,12 @@ function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) {
|
|||||||
{isOpen && (
|
{isOpen && (
|
||||||
<tr className="bg-gray-50/70 dark:bg-gray-850">
|
<tr className="bg-gray-50/70 dark:bg-gray-850">
|
||||||
<td colSpan={6} className="px-4 py-3">
|
<td colSpan={6} className="px-4 py-3">
|
||||||
<DetailComparison gt={p.gt_entry} engine={p.engine_hazard} />
|
<DetailComparison
|
||||||
|
gt={p.gt_entry}
|
||||||
|
engine={p.engine_hazard}
|
||||||
|
clarStatus={clarStatusByHazard[p.engine_hazard.id]}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
@@ -114,8 +160,28 @@ function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 */
|
/** Side-by-side detail comparison of GT entry vs. Engine hazard */
|
||||||
function DetailComparison({ gt, engine }: { gt: GroundTruthEntry; engine: HazardSummary }) {
|
function DetailComparison({ gt, engine, clarStatus, projectId }: {
|
||||||
|
gt: GroundTruthEntry
|
||||||
|
engine: HazardSummary
|
||||||
|
clarStatus?: HazardClarStatus
|
||||||
|
projectId?: string
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-4 text-xs">
|
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||||
{/* Left: Ground Truth */}
|
{/* Left: Ground Truth */}
|
||||||
@@ -141,20 +207,95 @@ function DetailComparison({ gt, engine }: { gt: GroundTruthEntry; engine: Hazard
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="font-semibold text-purple-700 dark:text-purple-400 uppercase text-[10px]">Engine (automatisch)</div>
|
<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="Gefaehrdung" gt={engine.name} />
|
||||||
<DetailRow label="Szenario" gt={engine.scenario || engine.description || '-'} />
|
<DetailRow label="Szenario" gt={engine.scenario || extractScenario(engine.description) || '-'} />
|
||||||
<DetailRow label="Gefahrenstelle" gt={engine.zone || '-'} />
|
<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="Moeglicher Schaden" gt={engine.possible_harm || '-'} />
|
||||||
<DetailRow label="Trigger" gt={engine.trigger_event || '-'} />
|
<DetailRow label="Trigger" gt={engine.trigger_event || '-'} />
|
||||||
|
{engine.affected_person && (
|
||||||
|
<DetailRow label="Betroffene Personen" gt={engine.affected_person} />
|
||||||
|
)}
|
||||||
{engine.mitigations && engine.mitigations.length > 0 ? (
|
{engine.mitigations && engine.mitigations.length > 0 ? (
|
||||||
<DetailRow label="Massnahmen" gt={engine.mitigations.join('\n')} multiline />
|
<DetailRow label="Massnahmen" gt={engine.mitigations.join('\n')} multiline />
|
||||||
) : (
|
) : (
|
||||||
<DetailRow label="Massnahmen" gt="(keine zugeordnet)" />
|
<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>
|
||||||
</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 }) {
|
function DetailRow({ label, gt, multiline }: { label: string; gt: string; multiline?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface HazardSummary {
|
|||||||
component?: string; zone?: string; risk_level?: string
|
component?: string; zone?: string; risk_level?: string
|
||||||
description?: string; scenario?: string
|
description?: string; scenario?: string
|
||||||
possible_harm?: string; trigger_event?: string
|
possible_harm?: string; trigger_event?: string
|
||||||
|
affected_person?: string; lifecycle_phase?: string
|
||||||
mitigations?: string[]
|
mitigations?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ export default function BenchmarkPage() {
|
|||||||
const { result, gtLoaded, gtEntryCount, loading, error, importGT, runBenchmark } = useBenchmark(projectId)
|
const { result, gtLoaded, gtEntryCount, loading, error, importGT, runBenchmark } = useBenchmark(projectId)
|
||||||
const [gtProjectId, setGtProjectId] = useState('')
|
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
|
const measurePct = result ? Math.round(result.measure_coverage * 100) : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -74,7 +76,7 @@ export default function BenchmarkPage() {
|
|||||||
<ScoreCard
|
<ScoreCard
|
||||||
label="Hazard Coverage"
|
label="Hazard Coverage"
|
||||||
value={`${coveragePct}%`}
|
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'}
|
color={coveragePct >= 80 ? 'green' : coveragePct >= 50 ? 'yellow' : 'red'}
|
||||||
/>
|
/>
|
||||||
<ScoreCard
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { calculateAP } from './useFMEA'
|
||||||
|
|
||||||
|
describe('calculateAP — AIAG-VDA 2019 Handbook Action Priority', () => {
|
||||||
|
it('returns H for severity 10 with mid occurrence', () => {
|
||||||
|
expect(calculateAP(10, 5, 5)).toBe('H')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns H for severity 9 with low detection', () => {
|
||||||
|
expect(calculateAP(9, 4, 7)).toBe('H')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns M for severity 9 with low occurrence and good detection', () => {
|
||||||
|
expect(calculateAP(9, 2, 5)).toBe('M')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns L for severity 9 with very low occurrence and detection', () => {
|
||||||
|
expect(calculateAP(9, 1, 4)).toBe('L')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns H for severity 7 with high occurrence', () => {
|
||||||
|
expect(calculateAP(7, 5, 1)).toBe('H')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns M for severity 7 with mid occurrence', () => {
|
||||||
|
expect(calculateAP(7, 3, 5)).toBe('M')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns L for low-severity well-controlled mode', () => {
|
||||||
|
expect(calculateAP(3, 1, 1)).toBe('L')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns L for severity 5 with very low occurrence and detection', () => {
|
||||||
|
expect(calculateAP(5, 1, 1)).toBe('L')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -156,5 +156,52 @@ export function useFMEA(projectId: string) {
|
|||||||
// Get unique components for the suggest button
|
// Get unique components for the suggest button
|
||||||
const components = [...new Map(rows.map((r) => [r.component.id, r.component])).values()]
|
const components = [...new Map(rows.map((r) => [r.component.id, r.component])).values()]
|
||||||
|
|
||||||
return { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions }
|
/**
|
||||||
|
* Accept a suggested FM: build an FMEA row from the FM defaults, prepend it
|
||||||
|
* to the table state, and remove the FM from the suggestion list.
|
||||||
|
* Returns false if the (component, fm.id) combo already exists in rows.
|
||||||
|
*/
|
||||||
|
function acceptSuggestion(fm: FailureMode, componentId: string): boolean {
|
||||||
|
const comp = components.find((c) => c.id === componentId)
|
||||||
|
if (!comp) return false
|
||||||
|
const dup = rows.find((r) => r.component.id === componentId && r.failureMode.id === fm.id)
|
||||||
|
if (dup) {
|
||||||
|
// Still drop the suggestion so the UI does not keep offering it.
|
||||||
|
setSuggestions((prev) => prev.filter((s) => s.id !== fm.id))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const s = fm.default_severity || 5
|
||||||
|
const o = fm.default_occurrence || 5
|
||||||
|
const d = fm.default_detection || 5
|
||||||
|
const newRow: FMEARow = {
|
||||||
|
component: comp,
|
||||||
|
failureMode: fm,
|
||||||
|
severity: s,
|
||||||
|
occurrence: o,
|
||||||
|
detection: d,
|
||||||
|
rpz: s * o * d,
|
||||||
|
ap: calculateAP(s, o, d),
|
||||||
|
}
|
||||||
|
setRows((prev) => [newRow, ...prev].sort((a, b) => b.rpz - a.rpz))
|
||||||
|
setSuggestions((prev) => prev.filter((sg) => sg.id !== fm.id))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectSuggestion(fmId: string) {
|
||||||
|
setSuggestions((prev) => prev.filter((sg) => sg.id !== fmId))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
loading,
|
||||||
|
stats,
|
||||||
|
components,
|
||||||
|
suggestFMs,
|
||||||
|
suggesting,
|
||||||
|
suggestions,
|
||||||
|
suggestSource,
|
||||||
|
setSuggestions,
|
||||||
|
acceptSuggestion,
|
||||||
|
rejectSuggestion,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { useFMEA, type FMEARow } from './_hooks/useFMEA'
|
import { useFMEA, type FMEARow } from './_hooks/useFMEA'
|
||||||
|
|
||||||
@@ -27,8 +27,17 @@ function rpzLabel(rpz: number): string {
|
|||||||
|
|
||||||
export default function FMEAPage() {
|
export default function FMEAPage() {
|
||||||
const { projectId } = useParams<{ projectId: string }>()
|
const { projectId } = useParams<{ projectId: string }>()
|
||||||
const { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions } = useFMEA(projectId)
|
const { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions, acceptSuggestion, rejectSuggestion } = useFMEA(projectId)
|
||||||
const [suggestComp, setSuggestComp] = useState<string | null>(null)
|
const [suggestComp, setSuggestComp] = useState<string | null>(null)
|
||||||
|
const [acceptedCount, setAcceptedCount] = useState(0)
|
||||||
|
|
||||||
|
// Reset accepted-count when a fresh suggestion run is loaded or the panel closes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (suggesting) setAcceptedCount(0)
|
||||||
|
}, [suggesting])
|
||||||
|
useEffect(() => {
|
||||||
|
if (suggestions.length === 0) setAcceptedCount(0)
|
||||||
|
}, [suggestions.length])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -97,26 +106,60 @@ export default function FMEAPage() {
|
|||||||
{suggestions.length > 0 && (
|
{suggestions.length > 0 && (
|
||||||
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-xl p-4">
|
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-xl p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-sm font-semibold text-purple-800 dark:text-purple-300">
|
<div>
|
||||||
KI-Vorschlaege ({suggestions.length}) — {suggestSource === 'llm' ? 'LLM-generiert' : 'Bibliothek'}
|
<h3 className="text-sm font-semibold text-purple-800 dark:text-purple-300">
|
||||||
</h3>
|
KI-Vorschlaege ({suggestions.length}) — {suggestSource === 'llm' ? 'LLM-generiert' : 'Bibliothek-Fallback'}
|
||||||
|
</h3>
|
||||||
|
{acceptedCount > 0 && (
|
||||||
|
<div className="text-xs text-green-700 dark:text-green-400 mt-0.5">
|
||||||
|
{acceptedCount} Vorschlag{acceptedCount > 1 ? 'e' : ''} uebernommen
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button onClick={() => setSuggestions([])} className="text-xs text-purple-600 hover:text-purple-800">Schliessen</button>
|
<button onClick={() => setSuggestions([])} className="text-xs text-purple-600 hover:text-purple-800">Schliessen</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{suggestions.map((fm, i) => (
|
{suggestions.map((fm) => {
|
||||||
<div key={i} className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg p-3 border border-purple-100 dark:border-purple-800">
|
const rpz = fm.default_severity * fm.default_occurrence * fm.default_detection
|
||||||
<div className="flex-1 min-w-0">
|
return (
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{fm.name_de}</div>
|
<div key={fm.id} className="flex items-start justify-between gap-3 bg-white dark:bg-gray-800 rounded-lg p-3 border border-purple-100 dark:border-purple-800">
|
||||||
<div className="text-xs text-gray-500 mt-0.5">{fm.effect}</div>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex gap-3 mt-1 text-xs text-gray-400">
|
<div className="text-sm font-medium text-gray-900 dark:text-white">{fm.name_de}</div>
|
||||||
<span>S={fm.default_severity}</span>
|
<div className="text-xs text-gray-500 mt-0.5">{fm.effect}</div>
|
||||||
<span>O={fm.default_occurrence}</span>
|
<div className="flex gap-3 mt-1 text-xs text-gray-400">
|
||||||
<span>D={fm.default_detection}</span>
|
<span>S={fm.default_severity}</span>
|
||||||
<span className="font-bold">RPZ={fm.default_severity * fm.default_occurrence * fm.default_detection}</span>
|
<span>O={fm.default_occurrence}</span>
|
||||||
|
<span>D={fm.default_detection}</span>
|
||||||
|
<span className={`font-bold ${rpz > 200 ? 'text-red-600' : rpz > 100 ? 'text-orange-600' : 'text-gray-500'}`}>RPZ={rpz}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!suggestComp) return
|
||||||
|
const ok = acceptSuggestion(fm, suggestComp)
|
||||||
|
if (ok) setAcceptedCount((c) => c + 1)
|
||||||
|
}}
|
||||||
|
disabled={!suggestComp}
|
||||||
|
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-medium rounded transition-colors"
|
||||||
|
title="Diesen Fehlermodus der FMEA-Tabelle hinzufuegen"
|
||||||
|
>
|
||||||
|
Uebernehmen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => rejectSuggestion(fm.id)}
|
||||||
|
className="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-xs font-medium rounded transition-colors"
|
||||||
|
title="Diesen Vorschlag verwerfen"
|
||||||
|
>
|
||||||
|
Ablehnen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
))}
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-purple-700 dark:text-purple-400 mt-3">
|
||||||
|
Hinweis: Uebernommene Fehlermodi erscheinen sofort in der Tabelle unten. Bewertung (S/O/D) ist anpassbar — Standardwerte aus der Bibliothek.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -39,11 +39,19 @@ export function HazardTable({ hazards, lifecyclePhases, onDelete }: {
|
|||||||
.map((hazard) => (
|
.map((hazard) => (
|
||||||
<tr key={hazard.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
<tr key={hazard.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||||
<td className="px-4 py-3">
|
<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>
|
<div className="text-sm font-medium text-gray-900 dark:text-white">{hazard.name}</div>
|
||||||
{hazard.name.startsWith('Auto:') && (
|
{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>
|
<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>
|
</div>
|
||||||
{hazard.description && (
|
{hazard.description && (
|
||||||
<div className="text-xs text-gray-500 truncate max-w-[250px]">{hazard.description}</div>
|
<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
|
||||||
@@ -12,6 +12,7 @@ import type { ResidualFilter } from './_components/ResidualRiskPanel'
|
|||||||
import { LibraryModal } from './_components/LibraryModal'
|
import { LibraryModal } from './_components/LibraryModal'
|
||||||
import { AutoSuggestPanel } from './_components/AutoSuggestPanel'
|
import { AutoSuggestPanel } from './_components/AutoSuggestPanel'
|
||||||
import { CustomHazardModal } from './_components/CustomHazardModal'
|
import { CustomHazardModal } from './_components/CustomHazardModal'
|
||||||
|
import { LLMGapReviewModal } from './_components/LLMGapReviewModal'
|
||||||
import { useHazards } from './_hooks/useHazards'
|
import { useHazards } from './_hooks/useHazards'
|
||||||
|
|
||||||
type ViewMode = 'list' | 'risk' | 'blocks'
|
type ViewMode = 'list' | 'risk' | 'blocks'
|
||||||
@@ -22,6 +23,7 @@ export default function HazardsPage() {
|
|||||||
const h = useHazards(projectId)
|
const h = useHazards(projectId)
|
||||||
const [view, setView] = useState<ViewMode>('risk')
|
const [view, setView] = useState<ViewMode>('risk')
|
||||||
const [showCustomModal, setShowCustomModal] = useState(false)
|
const [showCustomModal, setShowCustomModal] = useState(false)
|
||||||
|
const [showGapReview, setShowGapReview] = useState(false)
|
||||||
const [residualFilter, setResidualFilter] = useState<ResidualFilter>('all')
|
const [residualFilter, setResidualFilter] = useState<ResidualFilter>('all')
|
||||||
const [decisions, setDecisions] = useState<Record<string, boolean | null>>({})
|
const [decisions, setDecisions] = useState<Record<string, boolean | null>>({})
|
||||||
|
|
||||||
@@ -104,6 +106,15 @@ export default function HazardsPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
Eigene Gefaehrdung
|
Eigene Gefaehrdung
|
||||||
</button>
|
</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)}
|
<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">
|
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">
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@@ -170,6 +181,13 @@ export default function HazardsPage() {
|
|||||||
onClose={() => setShowCustomModal(false)} />
|
onClose={() => setShowCustomModal(false)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showGapReview && (
|
||||||
|
<LLMGapReviewModal
|
||||||
|
projectId={projectId}
|
||||||
|
onClose={() => setShowGapReview(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{h.hazards.length > 0 ? (
|
{h.hazards.length > 0 ? (
|
||||||
view === 'risk' ? (
|
view === 'risk' ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export interface Mitigation {
|
|||||||
verified_by: string | null
|
verified_by: string | null
|
||||||
source?: string
|
source?: string
|
||||||
operational_states?: string[]
|
operational_states?: string[]
|
||||||
|
// Expert flags (migration 029).
|
||||||
|
is_relevant?: boolean
|
||||||
|
is_customer_standard?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Hazard {
|
export interface Hazard {
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export function useMitigations(projectId: string) {
|
|||||||
created_at: (m.created_at || '') as string,
|
created_at: (m.created_at || '') as string,
|
||||||
verified_at: (m.verified_at || null) as string | null,
|
verified_at: (m.verified_at || null) as string | null,
|
||||||
verified_by: (m.verified_by || null) as string | null,
|
verified_by: (m.verified_by || null) as string | null,
|
||||||
|
is_relevant: Boolean(m.is_relevant),
|
||||||
|
is_customer_standard: Boolean(m.is_customer_standard),
|
||||||
operational_states: (() => {
|
operational_states: (() => {
|
||||||
const ids = m.linked_hazard_ids ? (m.linked_hazard_ids as string[]) : m.hazard_id ? [m.hazard_id as string] : []
|
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>()
|
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 = {
|
const byType = {
|
||||||
design: mitigations.filter((m) => m.reduction_type === 'design'),
|
design: mitigations.filter((m) => m.reduction_type === 'design'),
|
||||||
protection: mitigations.filter((m) => m.reduction_type === 'protection'),
|
protection: mitigations.filter((m) => m.reduction_type === 'protection'),
|
||||||
@@ -159,7 +203,8 @@ export function useMitigations(projectId: string) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
mitigations, hazards, loading, hierarchyWarning, setHierarchyWarning,
|
mitigations, hazards, loading, hierarchyWarning, setHierarchyWarning,
|
||||||
measures, byType,
|
measures, byType, fetchData,
|
||||||
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify, handleDelete,
|
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify,
|
||||||
|
handleDelete, handleDeleteSilent, handleSetRelevant, handleSetCustomerStandard,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user