Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 3784988d00 |
@@ -150,3 +150,35 @@ 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
|
||||
|
||||
@@ -313,10 +313,13 @@ jobs:
|
||||
git push --force "$PUSH_URL" "refs/tags/last-build/main"
|
||||
echo "Tag last-build/main now at ${SHA}"
|
||||
|
||||
# ── orca redeploy — runs only if at least one build succeeded ─────────────
|
||||
# `always()` lets this run when some builds are skipped (unchanged services).
|
||||
# The contains() checks ensure we only redeploy when something actually built
|
||||
# and no build failed.
|
||||
# ── orca redeploy — runs if at least one build was triggered AND green ────
|
||||
# Per-job `result == 'success'` is true only when the job actually ran and
|
||||
# passed; skipped/failed/cancelled jobs return their own status string and
|
||||
# fail the OR. This avoids Gitea's quirky evaluation of `contains(needs.*
|
||||
# .result, 'success')` when most upstreams are skipped (root cause of
|
||||
# trigger-orca being skipped on single-service changes).
|
||||
# `always()` is required so the job is evaluated when upstreams skip.
|
||||
|
||||
trigger-orca:
|
||||
runs-on: docker
|
||||
@@ -332,9 +335,16 @@ jobs:
|
||||
- build-dsms-node
|
||||
if: |
|
||||
always() &&
|
||||
contains(needs.*.result, 'success') &&
|
||||
!contains(needs.*.result, 'failure') &&
|
||||
!contains(needs.*.result, 'cancelled')
|
||||
(
|
||||
needs.build-admin-compliance.result == 'success' ||
|
||||
needs.build-backend-compliance.result == 'success' ||
|
||||
needs.build-ai-sdk.result == 'success' ||
|
||||
needs.build-developer-portal.result == 'success' ||
|
||||
needs.build-tts.result == 'success' ||
|
||||
needs.build-document-crawler.result == 'success' ||
|
||||
needs.build-dsms-gateway.result == 'success' ||
|
||||
needs.build-dsms-node.result == 'success'
|
||||
)
|
||||
steps:
|
||||
- name: Checkout (for SHA)
|
||||
run: |
|
||||
|
||||
@@ -314,6 +314,40 @@ jobs:
|
||||
go test -v -coverprofile=coverage.out ./...
|
||||
go tool cover -func=coverage.out | tail -1
|
||||
|
||||
iace-gt-coverage:
|
||||
runs-on: docker
|
||||
container: python:3.12-slim
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.sdk == 'true'
|
||||
env:
|
||||
# Lower bound on Strong+Weak GT-Bremse coverage. Raise this number when
|
||||
# coverage improves; never lower it without an explicit decision.
|
||||
MIN_COVERAGE_PCT: "70"
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq git > /dev/null 2>&1
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: GT-Bremse measure-coverage report
|
||||
run: |
|
||||
python3 scripts/gt_measure_gap_analysis.py --json /tmp/gt_gap_report.json > /tmp/gt_gap_report.md
|
||||
echo "--- summary ---"
|
||||
head -8 /tmp/gt_gap_report.md
|
||||
- name: Enforce coverage threshold
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json, os, sys
|
||||
d = json.load(open('/tmp/gt_gap_report.json'))
|
||||
total = d['total']
|
||||
covered = d['ok_count'] + d['weak_count']
|
||||
pct = covered * 100 / total if total else 0.0
|
||||
threshold = float(os.environ['MIN_COVERAGE_PCT'])
|
||||
print(f"GT coverage (strong+weak): {covered}/{total} = {pct:.1f}% (threshold {threshold}%)")
|
||||
if pct < threshold:
|
||||
print(f"::error::GT-Bremse coverage regression — {pct:.1f}% < {threshold}%")
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
test-python-backend:
|
||||
runs-on: docker
|
||||
container: python:3.12-slim
|
||||
|
||||
@@ -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: { checkId: string } },
|
||||
) {
|
||||
const checkId = params.checkId
|
||||
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/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: { checkId: string } },
|
||||
) {
|
||||
const checkId = params.checkId
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ const SCENARIO_LABELS: Record<string, { label: string; color: string; bg: string
|
||||
regenerate: { label: 'Neugenerierung', color: 'text-red-700', bg: 'bg-red-100' },
|
||||
fix: { label: 'Korrekturen', color: 'text-amber-700', bg: 'bg-amber-100' },
|
||||
import: { label: 'Konform', color: 'text-green-700', bg: 'bg-green-100' },
|
||||
missing: { label: 'Fehlt', color: 'text-gray-600', bg: 'bg-gray-100' },
|
||||
}
|
||||
|
||||
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||
@@ -102,6 +103,7 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
regenerate: results.filter(r => r.scenario === 'regenerate').length,
|
||||
fix: results.filter(r => r.scenario === 'fix').length,
|
||||
import: results.filter(r => r.scenario === 'import').length,
|
||||
missing: results.filter(r => r.scenario === 'missing').length,
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -114,6 +116,7 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
{scenarioCounts.import > 0 && <span className="bg-green-100 text-green-700 px-2 py-0.5 rounded-full">{scenarioCounts.import} konform</span>}
|
||||
{scenarioCounts.fix > 0 && <span className="bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full">{scenarioCounts.fix} Korrekturen</span>}
|
||||
{scenarioCounts.regenerate > 0 && <span className="bg-red-100 text-red-700 px-2 py-0.5 rounded-full">{scenarioCounts.regenerate} Neugenerierung</span>}
|
||||
{scenarioCounts.missing > 0 && <span className="bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">{scenarioCounts.missing} fehlt</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -164,7 +167,15 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0 ml-3">
|
||||
{r.error ? (
|
||||
{r.error && r.error.startsWith("Auf der Website nicht gefunden") ? (
|
||||
<span className="text-xs text-amber-700 font-medium px-2 py-0.5 bg-amber-100 rounded-full whitespace-nowrap">
|
||||
Nicht gefunden
|
||||
</span>
|
||||
) : r.error && r.error.startsWith("Nicht eingereicht") ? (
|
||||
<span className="text-xs text-gray-500 font-medium px-2 py-0.5 bg-gray-100 rounded-full whitespace-nowrap">
|
||||
Nicht eingereicht
|
||||
</span>
|
||||
) : r.error ? (
|
||||
<span className="text-xs text-red-600 font-medium">Fehler</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { ChecklistView } from './ChecklistView'
|
||||
import { DocumentRow } from './DocumentRow'
|
||||
import { MigrationPanel } from './MigrationPanel'
|
||||
|
||||
const DOCUMENT_TYPES = [
|
||||
{ id: 'dse', label: 'DSI (Datenschutzinformation)', required: true },
|
||||
@@ -66,13 +67,17 @@ interface HistoryEntry {
|
||||
docCount: number
|
||||
findings: number
|
||||
resultKey: string
|
||||
checkId?: string
|
||||
}
|
||||
|
||||
export function ComplianceCheckTab() {
|
||||
const [docs, setDocs] = useState<DocsState>(initState)
|
||||
const [useAgent, setUseAgent] = useState(false)
|
||||
const [tdmOverride, setTdmOverride] = useState(false)
|
||||
const [tdmOverrideReason, setTdmOverrideReason] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [progress, setProgress] = useState('')
|
||||
const [progressPct, setProgressPct] = useState(0)
|
||||
const [results, setResults] = useState<any>(() => {
|
||||
if (typeof window === 'undefined') return null
|
||||
try { const s = localStorage.getItem(STORAGE_KEY_RESULTS); return s ? JSON.parse(s) : null } catch { return null }
|
||||
@@ -109,17 +114,16 @@ export function ComplianceCheckTab() {
|
||||
if (!res.ok) continue
|
||||
const data = await res.json()
|
||||
if (data.progress) setProgress(data.progress)
|
||||
if (typeof data.progress_pct === 'number') setProgressPct(data.progress_pct)
|
||||
if (data.status === 'completed' && data.result) {
|
||||
setResults(data.result); setProgress(''); setLoading(false)
|
||||
setResults(data.result); setProgress(''); setProgressPct(0); setLoading(false)
|
||||
localStorage.setItem(STORAGE_KEY_RESULTS, JSON.stringify(data.result))
|
||||
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||
return
|
||||
}
|
||||
if (data.status === 'failed' || data.status === 'not_found') {
|
||||
if (data.status === 'failed') setError(data.error || 'Pruefung fehlgeschlagen')
|
||||
setProgress(''); setLoading(false)
|
||||
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||
return
|
||||
if (['failed', 'not_found', 'skipped_tdm'].includes(data.status)) {
|
||||
if (data.status !== 'not_found') setError(data.error || (data.status === 'skipped_tdm' ? 'TDM-Vorbehalt erkannt — Crawl uebersprungen' : 'Pruefung fehlgeschlagen'))
|
||||
setProgress(''); setProgressPct(0); setLoading(false); localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId(''); return
|
||||
}
|
||||
} catch { /* retry */ }
|
||||
}
|
||||
@@ -177,6 +181,7 @@ export function ComplianceCheckTab() {
|
||||
setError(null)
|
||||
setResults(null)
|
||||
setProgress('Compliance-Check wird gestartet...')
|
||||
setProgressPct(0)
|
||||
|
||||
try {
|
||||
const entries = DOCUMENT_TYPES
|
||||
@@ -194,6 +199,8 @@ export function ComplianceCheckTab() {
|
||||
body: JSON.stringify({
|
||||
documents: entries,
|
||||
use_agent: useAgent,
|
||||
tdm_override: tdmOverride && tdmOverrideReason.trim().length >= 10,
|
||||
tdm_override_reason: tdmOverrideReason.trim(),
|
||||
}),
|
||||
})
|
||||
if (!startRes.ok) throw new Error(`Pruefung konnte nicht gestartet werden: ${startRes.status}`)
|
||||
@@ -210,9 +217,11 @@ export function ComplianceCheckTab() {
|
||||
if (!pollRes.ok) { attempts++; continue }
|
||||
const pollData = await pollRes.json()
|
||||
if (pollData.progress) setProgress(pollData.progress)
|
||||
if (typeof pollData.progress_pct === 'number') setProgressPct(pollData.progress_pct)
|
||||
if (pollData.status === 'completed' && pollData.result) {
|
||||
setResults(pollData.result)
|
||||
setProgress('')
|
||||
setProgressPct(0)
|
||||
localStorage.setItem(STORAGE_KEY_RESULTS, JSON.stringify(pollData.result))
|
||||
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||
|
||||
@@ -229,9 +238,9 @@ export function ComplianceCheckTab() {
|
||||
localStorage.setItem(STORAGE_KEY_HISTORY, JSON.stringify(updated))
|
||||
break
|
||||
}
|
||||
if (pollData.status === 'failed') {
|
||||
if (['failed', 'skipped_tdm'].includes(pollData.status)) {
|
||||
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||
throw new Error(pollData.error || 'Pruefung fehlgeschlagen')
|
||||
throw new Error(pollData.error || (pollData.status === 'skipped_tdm' ? 'TDM-Vorbehalt' : 'Pruefung fehlgeschlagen'))
|
||||
}
|
||||
attempts++
|
||||
}
|
||||
@@ -242,6 +251,7 @@ export function ComplianceCheckTab() {
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||
setProgress('')
|
||||
setProgressPct(0)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -313,10 +323,15 @@ export function ComplianceCheckTab() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50/60 border border-amber-200 rounded-lg p-3 space-y-2">
|
||||
<label className="flex items-start gap-2 cursor-pointer"><input type="checkbox" checked={tdmOverride} onChange={e => setTdmOverride(e.target.checked)} className="mt-0.5 accent-amber-600" /><span className="text-xs text-amber-900"><strong>Schriftliche Crawl-Erlaubnis vorhanden</strong> — uebergeht TDM-Vorbehalte (robots.txt / ai.txt)</span></label>
|
||||
{tdmOverride && <input type="text" value={tdmOverrideReason} onChange={e => setTdmOverrideReason(e.target.value)} placeholder="z.B. Auftragsbeziehung Safetykon GmbH, Email Hr. X vom 18.05.2026" className="w-full px-3 py-2 text-xs border border-amber-300 rounded bg-white" />}
|
||||
{tdmOverride && tdmOverrideReason.trim().length < 10 && <p className="text-[10px] text-amber-700">Pflicht: Reason mit min. 10 Zeichen (Audit-Spur).</p>}
|
||||
</div>
|
||||
{/* Submit button */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || filledCount === 0}
|
||||
disabled={loading || filledCount === 0 || (tdmOverride && tdmOverrideReason.trim().length < 10)}
|
||||
className="w-full px-4 py-3 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50 transition-colors text-sm flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
@@ -334,12 +349,21 @@ export function ComplianceCheckTab() {
|
||||
|
||||
{/* Progress */}
|
||||
{progress && (
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm text-purple-700 flex items-center gap-3">
|
||||
<svg className="animate-spin w-4 h-4 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{progress}
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm text-purple-700 space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="animate-spin w-4 h-4 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="flex-1">{progress}</span>
|
||||
<span className="text-xs font-mono text-purple-600 tabular-nums">{progressPct}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-purple-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-500 rounded-full transition-all duration-500 ease-out"
|
||||
style={{ width: `${Math.max(2, progressPct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -439,13 +463,14 @@ export function ComplianceCheckTab() {
|
||||
|
||||
<ChecklistView results={results.results} />
|
||||
|
||||
{/* Email status */}
|
||||
{/* Email + Migration + Full-audit */}
|
||||
{results.email_status && (
|
||||
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${results.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
|
||||
E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status}
|
||||
</div>
|
||||
)}
|
||||
{results.check_id && <MigrationPanel checkId={results.check_id} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,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,326 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, useMemo } from 'react'
|
||||
import { use as useUnwrap } from 'react'
|
||||
import FindingsTab from './FindingsTab'
|
||||
|
||||
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'>('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: '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 === 'mc' && <>
|
||||
{/* Scorecard */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-3 bg-blue-50 border-b border-blue-100">
|
||||
<h2 className="text-sm font-medium text-blue-900">
|
||||
Compliance-Scorecard nach Regulation
|
||||
<span className="ml-2 text-blue-700 font-semibold text-base">
|
||||
{totals.pct}%
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-blue-600">
|
||||
({totals.passed} bestanden, {totals.failed} Fail,{' '}
|
||||
{totals.skipped} skipped — {totals.total} gesamt)
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Regulation</th>
|
||||
<th className="px-3 py-2 text-center">Passed</th>
|
||||
<th className="px-3 py-2 text-center">Failed</th>
|
||||
<th className="px-3 py-2 text-center">HIGH</th>
|
||||
<th className="px-3 py-2 text-center">MEDIUM</th>
|
||||
<th className="px-3 py-2 text-right">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scorecard.map(row => (
|
||||
<tr key={row.regulation} className="border-t hover:bg-blue-50/30 cursor-pointer"
|
||||
onClick={() => setFilterReg(row.regulation === filterReg ? '' : row.regulation)}>
|
||||
<td className="px-3 py-2 font-medium">{row.regulation}</td>
|
||||
<td className="px-3 py-2 text-center text-green-700">{row.passed}</td>
|
||||
<td className="px-3 py-2 text-center text-red-700">{row.failed}</td>
|
||||
<td className="px-3 py-2 text-center text-red-700">
|
||||
{(row.severity.HIGH || 0) + (row.severity.CRITICAL || 0)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center text-amber-700">
|
||||
{row.severity.MEDIUM || 0}
|
||||
</td>
|
||||
<td className={`px-3 py-2 text-right font-semibold ${
|
||||
row.pct >= 80 ? 'text-green-700' :
|
||||
row.pct >= 50 ? 'text-amber-700' : 'text-red-700'
|
||||
}`}>{row.pct}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3 items-center text-xs">
|
||||
<div className="flex gap-1">
|
||||
{STATUS_FILTERS.map(f => (
|
||||
<button key={f.value}
|
||||
onClick={() => setFilterStatus(f.value)}
|
||||
className={`px-2.5 py-1 rounded-full border ${
|
||||
filterStatus === f.value
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||
}`}>{f.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<select value={filterDoc} onChange={e => setFilterDoc(e.target.value)}
|
||||
className="border border-gray-200 rounded px-2 py-1">
|
||||
<option value="">Alle Doc-Types</option>
|
||||
{docTypes.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
<select value={filterReg} onChange={e => setFilterReg(e.target.value)}
|
||||
className="border border-gray-200 rounded px-2 py-1">
|
||||
<option value="">Alle Regulations</option>
|
||||
{regulations.map(r => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
<span className="text-gray-500">
|
||||
{filtered.length} von {allRows.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Status</th>
|
||||
<th className="px-3 py-2 text-left">Doc</th>
|
||||
<th className="px-3 py-2 text-left">Regulation</th>
|
||||
<th className="px-3 py-2 text-left">MC</th>
|
||||
<th className="px-3 py-2 text-left">Prioritaet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(row => (
|
||||
<React.Fragment key={row.id}>
|
||||
<tr className="border-t cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => setExpanded(expanded === row.id ? null : row.id)}>
|
||||
<td className="px-3 py-2">
|
||||
{(() => {
|
||||
const st = rowReviewStatus(row)
|
||||
if (st === 'pass') return <span className="text-green-600" title="Erfuellt">✓</span>
|
||||
if (st === 'na') return <span className="text-gray-400" title="Nicht anwendbar">—</span>
|
||||
if (st === 'review') return <span className="text-amber-600" title="Selbst pruefen">?</span>
|
||||
return <span className="text-red-600" title="Nicht erfuellt">✗</span>
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-700">{row.doc_type}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{row.regulation || '—'}</td>
|
||||
<td className="px-3 py-2 text-gray-900">{row.label}</td>
|
||||
<td className="px-3 py-2">
|
||||
{(() => {
|
||||
const prio = regulationToPriority(row.regulation)
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-medium ${PRIORITY_BADGE[prio]}`}>
|
||||
{prio}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded === row.id && (
|
||||
<tr className="bg-gray-50/50">
|
||||
<td colSpan={5} className="px-3 py-3 text-xs">
|
||||
<div className="text-gray-500 mb-1">
|
||||
MC-ID: <code>{row.mc_id}</code>
|
||||
</div>
|
||||
{row.matched_text && (
|
||||
<div className="mb-2">
|
||||
<span className="text-green-700 font-medium">Treffer: </span>
|
||||
<span className="font-mono text-gray-700">
|
||||
"{row.matched_text}"
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{row.hint && (
|
||||
<div className="text-amber-700 bg-amber-50 border-l-2 border-amber-200 pl-2 py-1">
|
||||
{row.hint}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-6 text-center text-gray-400">
|
||||
Keine MCs entsprechen den aktuellen Filtern.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Props {
|
||||
/** Risk classification of the AI system. Tile is only rendered for high_risk / unacceptable. */
|
||||
riskLevel: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a tile pointing to the BSI QUAIDAL-based data-quality control tab.
|
||||
* AI Act Article 10 obligations (training-data quality) apply only to high-risk
|
||||
* systems, so the tile is skipped for limited / minimal / not-applicable classes.
|
||||
*/
|
||||
export function Art10Tile({ riskLevel }: Props) {
|
||||
if (riskLevel !== 'high_risk' && riskLevel !== 'unacceptable') return null
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/sdk/quality?category=data_quality"
|
||||
className="block mt-3 p-3 rounded-lg border border-purple-200 bg-purple-50 hover:bg-purple-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-purple-200 text-purple-700 flex items-center justify-center shrink-0">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V7M3 7l9 6 9-6M3 7l9-4 9 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-purple-900">
|
||||
Art. 10 Datenqualität (Hochrisiko-KI)
|
||||
</div>
|
||||
<div className="text-xs text-purple-700 mt-0.5">
|
||||
BSI QUAIDAL Controls: 10 Kriterien, 15 Bausteine, 30 Maßnahmen, 140 Metriken.
|
||||
Klicken zum Öffnen des Trainingsdaten-Qualität-Moduls.
|
||||
</div>
|
||||
</div>
|
||||
<svg className="w-4 h-4 text-purple-500 shrink-0 mt-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { RiskPyramid } from './_components/RiskPyramid'
|
||||
import { AddSystemForm } from './_components/AddSystemForm'
|
||||
import { AISystemCard } from './_components/AISystemCard'
|
||||
import DecisionTreeWizard from '@/components/sdk/ai-act/DecisionTreeWizard'
|
||||
import { Art10Tile } from './_components/Art10Tile'
|
||||
|
||||
type TabId = 'overview' | 'decision-tree' | 'results'
|
||||
|
||||
@@ -136,6 +137,7 @@ function SavedResultsTab() {
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
<Art10Tile riskLevel={r.high_risk_result} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,23 @@ import type { CanonicalControl } from '../_types'
|
||||
import { EFFORT_LABELS } from '../_types'
|
||||
import { SeverityBadge, StateBadge, LicenseRuleBadge } from './Badges'
|
||||
|
||||
// Defensive coercers: backend has rows where evidence/requirements/test_procedure/open_anchors
|
||||
// are JSON-encoded strings instead of arrays. .map() on a string throws — coerce here.
|
||||
function asArray<T = unknown>(v: unknown): T[] {
|
||||
if (Array.isArray(v)) return v as T[]
|
||||
if (typeof v === 'string' && v.trim().startsWith('[')) {
|
||||
try { const p = JSON.parse(v); return Array.isArray(p) ? p : [] } catch { return [] }
|
||||
}
|
||||
return []
|
||||
}
|
||||
function asStringArray(v: unknown): string[] {
|
||||
return asArray(v).map(x => typeof x === 'string' ? x : JSON.stringify(x))
|
||||
}
|
||||
type EvidenceItem = string | { type?: string; description?: string }
|
||||
function asEvidenceArray(v: unknown): EvidenceItem[] {
|
||||
return asArray<EvidenceItem>(v)
|
||||
}
|
||||
|
||||
export function ControlDetailView({
|
||||
ctrl,
|
||||
onBack,
|
||||
@@ -72,31 +89,31 @@ export function ControlDetailView({
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Geltungsbereich</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{ctrl.scope.platforms && ctrl.scope.platforms.length > 0 && (
|
||||
{asStringArray(ctrl.scope?.platforms).length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">Plattformen</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ctrl.scope.platforms.map(p => (
|
||||
{asStringArray(ctrl.scope?.platforms).map(p => (
|
||||
<span key={p} className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs">{p}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ctrl.scope.components && ctrl.scope.components.length > 0 && (
|
||||
{asStringArray(ctrl.scope?.components).length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">Komponenten</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ctrl.scope.components.map(c => (
|
||||
{asStringArray(ctrl.scope?.components).map(c => (
|
||||
<span key={c} className="px-2 py-0.5 bg-purple-50 text-purple-700 rounded text-xs">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ctrl.scope.data_classes && ctrl.scope.data_classes.length > 0 && (
|
||||
{asStringArray(ctrl.scope?.data_classes).length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">Datenklassen</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ctrl.scope.data_classes.map(d => (
|
||||
{asStringArray(ctrl.scope?.data_classes).map(d => (
|
||||
<span key={d} className="px-2 py-0.5 bg-amber-50 text-amber-700 rounded text-xs">{d}</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -109,7 +126,7 @@ export function ControlDetailView({
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
|
||||
<ol className="space-y-2">
|
||||
{ctrl.requirements.map((req, i) => (
|
||||
{asStringArray(ctrl.requirements).map((req, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<span className="flex-shrink-0 w-5 h-5 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center text-xs font-medium mt-0.5">{i + 1}</span>
|
||||
{req}
|
||||
@@ -122,7 +139,7 @@ export function ControlDetailView({
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
|
||||
<ol className="space-y-2">
|
||||
{ctrl.test_procedure.map((step, i) => (
|
||||
{asStringArray(ctrl.test_procedure).map((step, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
{step}
|
||||
@@ -135,12 +152,18 @@ export function ControlDetailView({
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Nachweisanforderungen</h3>
|
||||
<div className="space-y-2">
|
||||
{ctrl.evidence.map((ev, i) => (
|
||||
{asEvidenceArray(ctrl.evidence).map((ev, i) => (
|
||||
<div key={i} className="flex items-start gap-2 p-3 bg-gray-50 rounded-lg">
|
||||
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-500 uppercase">{ev.type}</span>
|
||||
<p className="text-sm text-gray-700">{ev.description}</p>
|
||||
{typeof ev === 'string' ? (
|
||||
<p className="text-sm text-gray-700">{ev}</p>
|
||||
) : (
|
||||
<>
|
||||
{ev.type && <span className="text-xs font-medium text-gray-500 uppercase">{ev.type}</span>}
|
||||
<p className="text-sm text-gray-700">{ev.description ?? JSON.stringify(ev)}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -152,13 +175,13 @@ export function ControlDetailView({
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<BookOpen className="w-4 h-4 text-green-700" />
|
||||
<h3 className="text-sm font-semibold text-green-900">Open-Source-Referenzen</h3>
|
||||
<span className="text-xs text-green-600">({ctrl.open_anchors.length} Quellen)</span>
|
||||
<span className="text-xs text-green-600">({asArray(ctrl.open_anchors).length} Quellen)</span>
|
||||
</div>
|
||||
<p className="text-xs text-green-700 mb-3">
|
||||
Dieses Control basiert auf frei verfuegbarem Wissen. Alle Referenzen sind offen und oeffentlich zugaenglich.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{ctrl.open_anchors.map((anchor, i) => (
|
||||
{asArray<{ framework?: string; ref?: string; url?: string }>(ctrl.open_anchors).map((anchor, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-2 bg-white rounded border border-green-100">
|
||||
<Scale className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -180,11 +203,11 @@ export function ControlDetailView({
|
||||
</section>
|
||||
|
||||
{/* Tags */}
|
||||
{ctrl.tags.length > 0 && (
|
||||
{asStringArray(ctrl.tags).length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Tags</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ctrl.tags.map(tag => (
|
||||
{asStringArray(ctrl.tags).map(tag => (
|
||||
<span key={tag} className="px-2 py-1 bg-gray-100 text-gray-600 rounded text-xs">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,16 @@ import { ControlRegulatorySection } from './ControlRegulatorySection'
|
||||
import { ControlSimilarControls } from './ControlSimilarControls'
|
||||
import { ControlReviewActions } from './ControlReviewActions'
|
||||
|
||||
// Defensive coercer: some canonical_controls rows have evidence/tags/etc.
|
||||
// as JSON-encoded strings instead of arrays. .map() on a string throws.
|
||||
function toArray<T = unknown>(v: unknown): T[] {
|
||||
if (Array.isArray(v)) return v as T[]
|
||||
if (typeof v === 'string' && v.trim().startsWith('[')) {
|
||||
try { const p = JSON.parse(v); return Array.isArray(p) ? p : [] } catch { return [] }
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
interface SimilarControl {
|
||||
control_id: string; title: string; severity: string; release_state: string;
|
||||
tags: string[]; license_rule: number | null; verification_method: string | null;
|
||||
@@ -186,7 +196,7 @@ export function ControlDetail({
|
||||
<ControlTraceability ctrl={ctrl} traceability={traceability} loadingTrace={loadingTrace}
|
||||
onNavigateToControl={onNavigateToControl} />
|
||||
|
||||
{!ctrl.source_citation && ctrl.open_anchors.length > 0 && (
|
||||
{!ctrl.source_citation && toArray(ctrl.open_anchors).length > 0 && (
|
||||
<section className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Scale className="w-4 h-4 text-amber-600" />
|
||||
@@ -201,36 +211,36 @@ export function ControlDetail({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(ctrl.scope.platforms?.length || ctrl.scope.components?.length || ctrl.scope.data_classes?.length) ? (
|
||||
{(toArray(ctrl.scope?.platforms).length || toArray(ctrl.scope?.components).length || toArray(ctrl.scope?.data_classes).length) ? (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Geltungsbereich</h3>
|
||||
<div className="grid grid-cols-3 gap-4 text-xs">
|
||||
{ctrl.scope.platforms?.length ? <div><span className="text-gray-500">Plattformen:</span> <span className="text-gray-700">{ctrl.scope.platforms.join(', ')}</span></div> : null}
|
||||
{ctrl.scope.components?.length ? <div><span className="text-gray-500">Komponenten:</span> <span className="text-gray-700">{ctrl.scope.components.join(', ')}</span></div> : null}
|
||||
{ctrl.scope.data_classes?.length ? <div><span className="text-gray-500">Datenklassen:</span> <span className="text-gray-700">{ctrl.scope.data_classes.join(', ')}</span></div> : null}
|
||||
{toArray<string>(ctrl.scope?.platforms).length ? <div><span className="text-gray-500">Plattformen:</span> <span className="text-gray-700">{toArray<string>(ctrl.scope?.platforms).join(', ')}</span></div> : null}
|
||||
{toArray<string>(ctrl.scope?.components).length ? <div><span className="text-gray-500">Komponenten:</span> <span className="text-gray-700">{toArray<string>(ctrl.scope?.components).join(', ')}</span></div> : null}
|
||||
{toArray<string>(ctrl.scope?.data_classes).length ? <div><span className="text-gray-500">Datenklassen:</span> <span className="text-gray-700">{toArray<string>(ctrl.scope?.data_classes).join(', ')}</span></div> : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{Array.isArray(ctrl.requirements) && ctrl.requirements.length > 0 && (
|
||||
{toArray<string>(ctrl.requirements).length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
|
||||
<ol className="list-decimal list-inside space-y-1">{ctrl.requirements.map((r, i) => <li key={i} className="text-sm text-gray-700">{r}</li>)}</ol>
|
||||
<ol className="list-decimal list-inside space-y-1">{toArray<string>(ctrl.requirements).map((r, i) => <li key={i} className="text-sm text-gray-700">{r}</li>)}</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{Array.isArray(ctrl.test_procedure) && ctrl.test_procedure.length > 0 && (
|
||||
{toArray<string>(ctrl.test_procedure).length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
|
||||
<ol className="list-decimal list-inside space-y-1">{ctrl.test_procedure.map((s, i) => <li key={i} className="text-sm text-gray-700">{s}</li>)}</ol>
|
||||
<ol className="list-decimal list-inside space-y-1">{toArray<string>(ctrl.test_procedure).map((s, i) => <li key={i} className="text-sm text-gray-700">{s}</li>)}</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{ctrl.evidence.length > 0 && (
|
||||
{toArray(ctrl.evidence).length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Nachweise</h3>
|
||||
<div className="space-y-2">
|
||||
{ctrl.evidence.map((ev, i) => (
|
||||
{toArray<string | { type?: string; description?: string }>(ctrl.evidence).map((ev, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
{typeof ev === 'string' ? <div>{ev}</div> : <div><span className="font-medium">{ev.type}:</span> {ev.description}</div>}
|
||||
@@ -243,9 +253,9 @@ export function ControlDetail({
|
||||
<section className="grid grid-cols-3 gap-4 text-xs text-gray-500">
|
||||
{ctrl.risk_score !== null && <div>Risiko-Score: <span className="text-gray-700 font-medium">{ctrl.risk_score}</span></div>}
|
||||
{ctrl.implementation_effort && <div>Aufwand: <span className="text-gray-700 font-medium">{EFFORT_LABELS[ctrl.implementation_effort] || ctrl.implementation_effort}</span></div>}
|
||||
{ctrl.tags.length > 0 && (
|
||||
{toArray<string>(ctrl.tags).length > 0 && (
|
||||
<div className="col-span-3 flex items-center gap-1 flex-wrap">
|
||||
{ctrl.tags.map(t => <span key={t} className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">{t}</span>)}
|
||||
{toArray<string>(ctrl.tags).map(t => <span key={t} className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">{t}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -253,11 +263,11 @@ export function ControlDetail({
|
||||
<section className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<BookOpen className="w-4 h-4 text-green-700" />
|
||||
<h3 className="text-sm font-semibold text-green-900">Open-Source-Referenzen ({ctrl.open_anchors.length})</h3>
|
||||
<h3 className="text-sm font-semibold text-green-900">Open-Source-Referenzen ({toArray(ctrl.open_anchors).length})</h3>
|
||||
</div>
|
||||
{ctrl.open_anchors.length > 0 ? (
|
||||
{toArray(ctrl.open_anchors).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{ctrl.open_anchors.map((anchor, i) => (
|
||||
{toArray<{ framework?: string; ref?: string; url?: string }>(ctrl.open_anchors).map((anchor, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<ExternalLink className="w-3.5 h-3.5 text-green-600 flex-shrink-0" />
|
||||
<span className="font-medium text-green-800">{anchor.framework}</span>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { EMPTY_CONTROL } from './components/helpers'
|
||||
import { ControlForm } from './components/ControlForm'
|
||||
import { ControlDetail } from './components/ControlDetail'
|
||||
@@ -12,6 +14,24 @@ import { BACKEND_URL } from './components/helpers'
|
||||
|
||||
export default function ControlLibraryPage() {
|
||||
const state = useControlLibraryState()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
// Deep-link via /sdk/control-library?control=<id>
|
||||
// — e.g. from /sdk/master-controls member list.
|
||||
useEffect(() => {
|
||||
const cid = searchParams?.get('control')
|
||||
if (!cid || state.selectedControl?.control_id === cid) return
|
||||
fetch(`${BACKEND_URL}?endpoint=control&id=${encodeURIComponent(cid)}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(ctrl => {
|
||||
if (ctrl?.control_id) {
|
||||
state.setSelectedControl(ctrl)
|
||||
state.setMode('detail')
|
||||
}
|
||||
})
|
||||
.catch(() => { /* user just sees the list */ })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams])
|
||||
|
||||
const {
|
||||
handleCreate, handleUpdate, handleDelete, handleReview, handleBulkReject,
|
||||
|
||||
@@ -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,200 @@
|
||||
'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>
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -57,12 +57,7 @@ export default function EinwilligungenPage() {
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<button className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Export
|
||||
</button>
|
||||
<ConsentExportButton />
|
||||
</StepHeader>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
@@ -150,3 +145,32 @@ export default function EinwilligungenPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Export-Dropdown im Step-Header. Streamt CSV/JSON direkt aus dem
|
||||
// Backend via /api/sdk/v1/einwilligungen/export-Proxy.
|
||||
function ConsentExportButton() {
|
||||
return (
|
||||
<div className="relative group">
|
||||
<button className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Export
|
||||
</button>
|
||||
<div className="absolute right-0 top-full mt-1 w-60 bg-white border border-gray-200 rounded-lg shadow-lg invisible group-hover:visible opacity-0 group-hover:opacity-100 transition-all z-10">
|
||||
<a href="/api/sdk/v1/einwilligungen/export?format=csv&kind=consents" download
|
||||
className="block px-4 py-2 text-sm text-gray-700 hover:bg-purple-50 first:rounded-t-lg">
|
||||
Einwilligungen als CSV
|
||||
</a>
|
||||
<a href="/api/sdk/v1/einwilligungen/export?format=json&kind=consents" download
|
||||
className="block px-4 py-2 text-sm text-gray-700 hover:bg-purple-50">
|
||||
Einwilligungen als JSON
|
||||
</a>
|
||||
<a href="/api/sdk/v1/einwilligungen/export?format=csv&kind=history" download
|
||||
className="block px-4 py-2 text-sm text-gray-700 hover:bg-purple-50 last:rounded-b-lg border-t border-gray-100">
|
||||
Aenderungs-Historie als CSV
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+119
-6
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import type { HazardMatchPair, GroundTruthEntry, HazardSummary } from '../_hooks/useBenchmark'
|
||||
|
||||
interface Props {
|
||||
@@ -11,8 +12,41 @@ interface Props {
|
||||
|
||||
type TabType = 'matched' | 'missing' | 'extra'
|
||||
|
||||
// Per-hazard clarification status fetched once and shared with all detail rows.
|
||||
type HazardClarStatus = { open: number; answered: number; total: number }
|
||||
|
||||
function useClarificationsByHazard(projectId: string | undefined): Record<string, HazardClarStatus> {
|
||||
const [byHz, setByHz] = useState<Record<string, HazardClarStatus>>({})
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
let cancelled = false
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/clarifications`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => {
|
||||
if (cancelled || !d?.clarifications) return
|
||||
const out: Record<string, HazardClarStatus> = {}
|
||||
for (const c of d.clarifications as Array<{ affected_hazard_ids: string[]; status: string }>) {
|
||||
const isOpen = c.status !== 'answered' && c.status !== 'not_relevant'
|
||||
for (const hid of c.affected_hazard_ids) {
|
||||
if (!out[hid]) out[hid] = { open: 0, answered: 0, total: 0 }
|
||||
out[hid].total += 1
|
||||
if (isOpen) out[hid].open += 1
|
||||
else out[hid].answered += 1
|
||||
}
|
||||
}
|
||||
setByHz(out)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => { cancelled = true }
|
||||
}, [projectId])
|
||||
return byHz
|
||||
}
|
||||
|
||||
export function HazardComparisonTable({ matched, missing, extra }: Props) {
|
||||
const [tab, setTab] = useState<TabType>('matched')
|
||||
const params = useParams()
|
||||
const projectId = params?.projectId as string | undefined
|
||||
const clarStatusByHazard = useClarificationsByHazard(projectId)
|
||||
|
||||
// Split matches: >= 50% are real matches, < 50% are weak (shown separately)
|
||||
const realMatched = matched.filter(p => p.match_score >= 0.5)
|
||||
@@ -51,7 +85,7 @@ export function HazardComparisonTable({ matched, missing, extra }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{tab === 'matched' && <MatchedTable pairs={realMatched} />}
|
||||
{tab === 'matched' && <MatchedTable pairs={realMatched} clarStatusByHazard={clarStatusByHazard} projectId={projectId} />}
|
||||
{tab === 'missing' && <MissingTable entries={allMissing} />}
|
||||
{tab === 'extra' && <ExtraTable entries={allExtra} />}
|
||||
</div>
|
||||
@@ -59,7 +93,7 @@ export function HazardComparisonTable({ matched, missing, extra }: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) {
|
||||
function MatchedTable({ pairs, clarStatusByHazard, projectId }: { pairs: HazardMatchPair[]; clarStatusByHazard: Record<string, HazardClarStatus>; projectId?: string }) {
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
|
||||
if (pairs.length === 0) return <EmptyState text="Keine Zuordnungen gefunden" />
|
||||
return (
|
||||
@@ -109,7 +143,12 @@ function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) {
|
||||
{isOpen && (
|
||||
<tr className="bg-gray-50/70 dark:bg-gray-850">
|
||||
<td colSpan={6} className="px-4 py-3">
|
||||
<DetailComparison gt={p.gt_entry} engine={p.engine_hazard} />
|
||||
<DetailComparison
|
||||
gt={p.gt_entry}
|
||||
engine={p.engine_hazard}
|
||||
clarStatus={clarStatusByHazard[p.engine_hazard.id]}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -137,7 +176,12 @@ function formatLifecycles(raw: string): string {
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||
{/* Left: Ground Truth */}
|
||||
@@ -163,7 +207,7 @@ function DetailComparison({ gt, engine }: { gt: GroundTruthEntry; engine: Hazard
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold text-purple-700 dark:text-purple-400 uppercase text-[10px]">Engine (automatisch)</div>
|
||||
<DetailRow label="Gefaehrdung" gt={engine.name} />
|
||||
<DetailRow label="Szenario" gt={engine.scenario || engine.description || '-'} />
|
||||
<DetailRow label="Szenario" gt={engine.scenario || extractScenario(engine.description) || '-'} />
|
||||
<DetailRow label="Gefahrenstelle" gt={engine.zone || '-'} />
|
||||
{engine.lifecycle_phase && (
|
||||
<DetailRow label="Lebensphasen" gt={formatLifecycles(engine.lifecycle_phase)} />
|
||||
@@ -178,11 +222,80 @@ function DetailComparison({ gt, engine }: { gt: GroundTruthEntry; engine: Hazard
|
||||
) : (
|
||||
<DetailRow label="Massnahmen" gt="(keine zugeordnet)" />
|
||||
)}
|
||||
{clarStatus && clarStatus.total > 0 && (
|
||||
<ClarificationBanner status={clarStatus} projectId={projectId} />
|
||||
)}
|
||||
{(() => {
|
||||
const norms = extractEngineNorms(engine.description)
|
||||
if (norms.length === 0) return null
|
||||
return <DetailRow label="Referenzierte Normen" gt={norms.join(' | ')} />
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Go init handler appends two annotated blocks to Hazard.Description:
|
||||
* "<scenario>\n\nMit Anlagenbauer zu klaeren:\n- frage 1\n- frage 2\n\n
|
||||
* Referenzierte Normen: EN 60204-1 Ziff. 6.2 | EN 61140"
|
||||
* These helpers split that string back into structured pieces so the UI
|
||||
* can render scenario, clarifications and norms as separate sections.
|
||||
*/
|
||||
function extractScenario(desc?: string): string {
|
||||
if (!desc) return ''
|
||||
const idx = desc.indexOf('\n\nMit Anlagenbauer zu klaeren')
|
||||
const cut = idx >= 0 ? desc.slice(0, idx) : desc
|
||||
// Also cut off a trailing norm line if it's the only suffix
|
||||
const normIdx = cut.indexOf('\n\nReferenzierte Normen')
|
||||
return (normIdx >= 0 ? cut.slice(0, normIdx) : cut).trim()
|
||||
}
|
||||
|
||||
// (extractClarifications removed in Phase 2 — clarifications are loaded
|
||||
// from the dedicated /clarifications API and rendered as a status banner
|
||||
// instead of being parsed out of the hazard description.)
|
||||
|
||||
function ClarificationBanner({ status, projectId }: { status: HazardClarStatus; projectId?: string }) {
|
||||
const allDone = status.open === 0
|
||||
const href = projectId ? `/sdk/iace/${projectId}/clarifications` : '#'
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-gray-500 uppercase">Klärungen</div>
|
||||
<a
|
||||
href={href}
|
||||
className={`mt-0.5 inline-flex items-center gap-2 px-3 py-1.5 rounded border text-xs ${
|
||||
allDone
|
||||
? 'bg-green-50 border-green-200 text-green-800 hover:bg-green-100'
|
||||
: 'bg-orange-50 border-orange-200 text-orange-800 hover:bg-orange-100'
|
||||
}`}
|
||||
>
|
||||
{allDone ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Alle {status.total} Klärungen beantwortet
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
{status.open} offene Klärung{status.open === 1 ? '' : 'en'} {status.answered > 0 && `(${status.answered} beantwortet)`} — Klärungen-Seite öffnen
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function extractEngineNorms(desc?: string): string[] {
|
||||
if (!desc) return []
|
||||
const m = desc.match(/Referenzierte Normen:\s*([^\n]+)/)
|
||||
if (!m) return []
|
||||
return m[1].split('|').map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
function DetailRow({ label, gt, multiline }: { label: string; gt: string; multiline?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,9 @@ export interface Mitigation {
|
||||
verified_by: string | null
|
||||
source?: string
|
||||
operational_states?: string[]
|
||||
// Expert flags (migration 029).
|
||||
is_relevant?: boolean
|
||||
is_customer_standard?: boolean
|
||||
}
|
||||
|
||||
export interface Hazard {
|
||||
|
||||
@@ -45,6 +45,8 @@ export function useMitigations(projectId: string) {
|
||||
created_at: (m.created_at || '') as string,
|
||||
verified_at: (m.verified_at || null) as string | null,
|
||||
verified_by: (m.verified_by || null) as string | null,
|
||||
is_relevant: Boolean(m.is_relevant),
|
||||
is_customer_standard: Boolean(m.is_customer_standard),
|
||||
operational_states: (() => {
|
||||
const ids = m.linked_hazard_ids ? (m.linked_hazard_ids as string[]) : m.hazard_id ? [m.hazard_id as string] : []
|
||||
const states = new Set<string>()
|
||||
@@ -151,6 +153,48 @@ export function useMitigations(projectId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk delete without per-row confirm; caller owns the confirm-step.
|
||||
async function handleDeleteSilent(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}`, { method: 'DELETE' })
|
||||
if (!res.ok) console.error('delete failed for', id, res.status)
|
||||
} catch (err) {
|
||||
console.error('Failed to delete mitigation:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Flag a mitigation as relevant for this project (or unflag). Optimistic:
|
||||
// updates local state immediately, refetches afterwards.
|
||||
async function handleSetRelevant(id: string, value: boolean) {
|
||||
setMitigations((prev) => prev.map((m) => m.id === id ? { ...m, status: m.status } : m))
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/iace/mitigations/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_relevant: value }),
|
||||
})
|
||||
await fetchData()
|
||||
} catch (err) {
|
||||
console.error('Failed to set relevant flag:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark a mitigation as "customer standard" — already implemented at the
|
||||
// customer's site, no evidence required. Implies is_relevant=true (server
|
||||
// enforces this via the CHECK constraint).
|
||||
async function handleSetCustomerStandard(id: string, value: boolean) {
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/iace/mitigations/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_customer_standard: value }),
|
||||
})
|
||||
await fetchData()
|
||||
} catch (err) {
|
||||
console.error('Failed to set customer-standard flag:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const byType = {
|
||||
design: mitigations.filter((m) => m.reduction_type === 'design'),
|
||||
protection: mitigations.filter((m) => m.reduction_type === 'protection'),
|
||||
@@ -159,7 +203,8 @@ export function useMitigations(projectId: string) {
|
||||
|
||||
return {
|
||||
mitigations, hazards, loading, hierarchyWarning, setHierarchyWarning,
|
||||
measures, byType,
|
||||
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify, handleDelete,
|
||||
measures, byType, fetchData,
|
||||
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify,
|
||||
handleDelete, handleDeleteSilent, handleSetRelevant, handleSetCustomerStandard,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,9 @@ export default function MitigationsPage() {
|
||||
|
||||
const {
|
||||
hazards, loading, hierarchyWarning, setHierarchyWarning,
|
||||
measures, byType,
|
||||
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify, handleDelete,
|
||||
measures, byType, fetchData,
|
||||
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure,
|
||||
handleDelete, handleDeleteSilent, handleSetRelevant,
|
||||
} = useMitigations(projectId)
|
||||
|
||||
const [measureNorms, setMeasureNorms] = useState<Record<string, string[]>>({})
|
||||
@@ -47,48 +48,66 @@ export default function MitigationsPage() {
|
||||
const [showSuggest, setShowSuggest] = useState(false)
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({ design: true, protection: true, information: true })
|
||||
const [mitPages, setMitPages] = useState<Record<string, number>>({ design: 1, protection: 1, information: 1 })
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [batchAction, setBatchAction] = useState<'verify' | 'delete' | null>(null)
|
||||
const [expandedMeasure, setExpandedMeasure] = useState<string | null>(null)
|
||||
// Group-Expand: key = `${type}:${title}` so the same title in different
|
||||
// reduction stages stays independently togglable.
|
||||
const [expandedGroup, setExpandedGroup] = useState<Set<string>>(new Set())
|
||||
|
||||
function toggleGroup(key: string) {
|
||||
setExpandedGroup((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) next.delete(key); else next.add(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Mitigations sharing the same title (e.g. "Sicherheitszeichen nach ISO 7010"
|
||||
// applied to 21 hazards) collapse into a single group row. Each instance
|
||||
// keeps its own DB id, status and notes — the grouping is presentation-only.
|
||||
//
|
||||
// Within a group we additionally deduplicate by hazard_id: the engine
|
||||
// sometimes emits the same (name, hazard_id) pair twice when "Neu
|
||||
// initialisieren" is clicked repeatedly. We pick the row that already
|
||||
// carries user state (is_relevant=true preferred, then newest created_at)
|
||||
// so the expert's decisions are not lost. The DB still holds both rows;
|
||||
// a separate migration adds a UNIQUE(hazard_id, name) constraint to
|
||||
// prevent the duplicates upstream.
|
||||
function groupByTitle(items: Mitigation[]): Array<{ title: string; instances: Mitigation[] }> {
|
||||
const map = new Map<string, Mitigation[]>()
|
||||
for (const m of items) {
|
||||
const key = (m.title || '').trim() || '(ohne Titel)'
|
||||
const arr = map.get(key)
|
||||
if (arr) arr.push(m); else map.set(key, [m])
|
||||
}
|
||||
return Array.from(map.entries()).map(([title, instances]) => {
|
||||
const byHazard = new Map<string, Mitigation>()
|
||||
for (const m of instances) {
|
||||
const hid = (m.linked_hazard_ids || []).join('|') || m.id
|
||||
const prev = byHazard.get(hid)
|
||||
if (!prev) { byHazard.set(hid, m); continue }
|
||||
// Tie-break: prefer is_relevant=true, then newest created_at
|
||||
const score = (x: Mitigation) => (x.is_relevant ? 2 : 0) + (x.created_at > (prev.created_at || '') ? 1 : 0)
|
||||
if (score(m) > score(prev)) byHazard.set(hid, m)
|
||||
}
|
||||
return { title, instances: Array.from(byHazard.values()) }
|
||||
})
|
||||
}
|
||||
|
||||
// Compact status distribution: returns counts for the three known states.
|
||||
function statusCounts(instances: Mitigation[]) {
|
||||
const c = { planned: 0, implemented: 0, verified: 0 }
|
||||
for (const m of instances) {
|
||||
if (m.status === 'planned') c.planned++
|
||||
else if (m.status === 'implemented') c.implemented++
|
||||
else if (m.status === 'verified') c.verified++
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
function toggleSection(type: string) {
|
||||
setExpanded((prev) => ({ ...prev, [type]: !prev[type] }))
|
||||
}
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function selectAllInType(type: string) {
|
||||
const items = byType[type as keyof typeof byType]
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
const allSelected = items.every((m) => next.has(m.id))
|
||||
if (allSelected) { items.forEach((m) => next.delete(m.id)) }
|
||||
else { items.forEach((m) => next.add(m.id)) }
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function handleBatchVerify() {
|
||||
setBatchAction('verify')
|
||||
for (const id of selected) { await handleVerify(id) }
|
||||
setSelected(new Set())
|
||||
setBatchAction(null)
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (!confirm(`${selected.size} Massnahmen wirklich loeschen?`)) return
|
||||
setBatchAction('delete')
|
||||
for (const id of selected) { await handleDelete(id) }
|
||||
setSelected(new Set())
|
||||
setBatchAction(null)
|
||||
}
|
||||
|
||||
function handleOpenLibrary(type?: string) {
|
||||
setLibraryFilter(type)
|
||||
fetchMeasuresLibrary(type)
|
||||
@@ -122,43 +141,31 @@ export default function MitigationsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selected.size > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-gray-500">{selected.size} ausgewaehlt</span>
|
||||
<button onClick={handleBatchVerify} disabled={batchAction !== null}
|
||||
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||
{batchAction === 'verify' ? 'Verifiziere...' : 'Verifizieren'}
|
||||
</button>
|
||||
<button onClick={handleBatchDelete} disabled={batchAction !== null}
|
||||
className="px-3 py-1.5 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50">
|
||||
Loeschen
|
||||
</button>
|
||||
<button onClick={() => setSelected(new Set())} className="px-2 py-1.5 text-xs text-gray-500 hover:text-gray-700">
|
||||
Abbrechen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{selected.size === 0 && (
|
||||
<>
|
||||
<button onClick={() => setShowSuggest(true)}
|
||||
className="px-3 py-1.5 text-xs border border-green-300 text-green-700 rounded-lg hover:bg-green-50">
|
||||
Vorschlaege
|
||||
</button>
|
||||
<button onClick={() => handleOpenLibrary()}
|
||||
className="px-3 py-1.5 text-xs border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50">
|
||||
Bibliothek
|
||||
</button>
|
||||
<button onClick={() => { setPreselectedType(undefined); setShowForm(true) }}
|
||||
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||
+ Hinzufuegen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => setShowSuggest(true)}
|
||||
className="px-3 py-1.5 text-xs border border-green-300 text-green-700 rounded-lg hover:bg-green-50">
|
||||
Vorschlaege
|
||||
</button>
|
||||
<button onClick={() => handleOpenLibrary()}
|
||||
className="px-3 py-1.5 text-xs border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50">
|
||||
Bibliothek
|
||||
</button>
|
||||
<button onClick={() => { setPreselectedType(undefined); setShowForm(true) }}
|
||||
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||
+ Hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hierarchyWarning && <HierarchyWarning onDismiss={() => setHierarchyWarning(false)} />}
|
||||
|
||||
{/* Reinitialisieren-Warnung: nach manuellem Loeschen wuerde ein Reinit
|
||||
die geloeschten Engine-Vorschlaege wiederherstellen. */}
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-2.5 text-xs text-amber-900">
|
||||
<strong>Hinweis:</strong> Markiere jede Maßnahme als <em>Relevant</em> (☑) oder lösche sie aus dem Projekt (🗑).
|
||||
Nur als <em>relevant</em> markierte Maßnahmen erscheinen in der Verifikation.
|
||||
<strong> Achtung:</strong> nach dem Löschen kein <em>Neu initialisieren</em> mehr drücken — sonst werden die gelöschten Vorschläge aus den Engine-Daten wiederhergestellt.
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<MitigationForm
|
||||
onSubmit={async (data) => { const ok = await handleSubmit(data); if (ok) setShowForm(false) }}
|
||||
@@ -173,7 +180,6 @@ export default function MitigationsPage() {
|
||||
const config = REDUCTION_TYPES[type]
|
||||
const items = byType[type]
|
||||
const isExpanded = expanded[type]
|
||||
const allSelected = items.length > 0 && items.every((m) => selected.has(m.id))
|
||||
|
||||
return (
|
||||
<div key={type} className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
@@ -191,68 +197,133 @@ export default function MitigationsPage() {
|
||||
<span className="text-sm font-bold">{items.length}</span>
|
||||
</button>
|
||||
|
||||
{/* Accordion Content — Table rows */}
|
||||
{isExpanded && items.length > 0 && (
|
||||
{/* Accordion Content — grouped by measure title */}
|
||||
{isExpanded && items.length > 0 && (() => {
|
||||
const groups = groupByTitle(items)
|
||||
const visibleGroups = groups.slice(0, (mitPages[type] || 1) * 50)
|
||||
return (
|
||||
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||
{/* Table header */}
|
||||
<div className="grid grid-cols-[24px_2fr_1fr_80px] gap-2 px-4 py-2 bg-gray-50 dark:bg-gray-750 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<div>
|
||||
<input type="checkbox" checked={allSelected} onChange={() => selectAllInType(type)}
|
||||
className="accent-purple-600" title="Alle auswaehlen" />
|
||||
</div>
|
||||
<div className="grid grid-cols-[36px_36px_2fr_120px_110px] gap-2 px-4 py-2 bg-gray-50 dark:bg-gray-750 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<div title="Relevant fuer dieses Projekt">Relev.</div>
|
||||
<div />
|
||||
<div>Massnahme</div>
|
||||
<div>Gefaehrdung</div>
|
||||
<div>Status</div>
|
||||
<div className="text-right pr-2">Gefährdungen</div>
|
||||
<div>Status (P · I · V)</div>
|
||||
</div>
|
||||
{/* Rows — paginated */}
|
||||
{items.slice(0, (mitPages[type] || 1) * 50).map((m) => {
|
||||
const isDetailOpen = expandedMeasure === m.id
|
||||
const catMatch = (m.description || '').match(/Kategorie\s+(\S+)/)
|
||||
{visibleGroups.map(({ title, instances }) => {
|
||||
const groupKey = `${type}:${title}`
|
||||
const isGroupOpen = expandedGroup.has(groupKey)
|
||||
// (legacy bulk-select removed — Relevant-checkbox is now the primary mass-action)
|
||||
const counts = statusCounts(instances)
|
||||
const refs = measureNorms[title.toLowerCase()]
|
||||
const first = instances[0]
|
||||
const description = first?.description || ''
|
||||
const catMatch = description.match(/Kategorie\s+(\S+)/)
|
||||
const category = catMatch?.[1]
|
||||
const refs = measureNorms[(m.title || '').toLowerCase()]
|
||||
const relevantInGroup = instances.filter((m) => m.is_relevant).length
|
||||
const allRelevant = relevantInGroup === instances.length
|
||||
return (
|
||||
<div key={m.id}>
|
||||
<div onClick={() => setExpandedMeasure(isDetailOpen ? null : m.id)}
|
||||
className={`grid grid-cols-[24px_2fr_1fr_80px] gap-2 px-4 py-2 border-t border-gray-50 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors cursor-pointer ${selected.has(m.id) ? 'bg-purple-50 dark:bg-purple-900/10' : ''}`}>
|
||||
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selected.has(m.id)} onChange={() => toggleSelect(m.id)}
|
||||
className="accent-purple-600" />
|
||||
</div>
|
||||
<div className="min-w-0 flex items-start gap-1">
|
||||
<svg className={`w-3 h-3 mt-1 shrink-0 text-gray-400 transition-transform ${isDetailOpen ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="text-sm text-gray-900 dark:text-white">{m.title || ''}</div>
|
||||
{!isDetailOpen && category && <div className="text-[10px] text-gray-400 mt-0.5">Kategorie: {category}</div>}
|
||||
<div key={groupKey}>
|
||||
{/* Group header row */}
|
||||
<div onClick={() => toggleGroup(groupKey)}
|
||||
className={`grid grid-cols-[36px_36px_2fr_120px_110px] gap-2 px-4 py-2 border-t border-gray-50 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors cursor-pointer`}>
|
||||
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={allRelevant} ref={(el) => { if (el) el.indeterminate = !allRelevant && relevantInGroup > 0 }}
|
||||
onChange={async (e) => {
|
||||
const target = e.target.checked
|
||||
for (const m of instances) {
|
||||
if (m.is_relevant !== target) await handleSetRelevant(m.id, target)
|
||||
}
|
||||
}}
|
||||
className="accent-purple-600" title={`${relevantInGroup}/${instances.length} als relevant markiert. Klick: alle als ${allRelevant ? 'nicht relevant' : 'relevant'} markieren.`} />
|
||||
</div>
|
||||
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={async () => {
|
||||
if (!confirm(`Alle ${instances.length} Instanzen von "${title}" loeschen?`)) return
|
||||
for (const m of instances) await handleDeleteSilent(m.id)
|
||||
await fetchData()
|
||||
}} className="text-gray-400 hover:text-red-600" title="Ganze Gruppe loeschen">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M16 7V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-w-0 flex items-start gap-1">
|
||||
<svg className={`w-3 h-3 mt-1 shrink-0 text-gray-400 transition-transform ${isGroupOpen ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="text-sm text-gray-900 dark:text-white">{title}</div>
|
||||
{category && <div className="text-[10px] text-gray-400 mt-0.5">Kategorie: {category}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 text-right pr-2">{instances.length}</div>
|
||||
<div className="text-xs flex items-center gap-1.5 font-mono">
|
||||
<span className="text-gray-500" title={`${counts.planned} geplant`}>{counts.planned}</span>
|
||||
<span className="text-gray-300">·</span>
|
||||
<span className="text-blue-600" title={`${counts.implemented} umgesetzt`}>{counts.implemented}</span>
|
||||
<span className="text-gray-300">·</span>
|
||||
<span className="text-green-600" title={`${counts.verified} verifiziert`}>{counts.verified}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{(m.linked_hazard_names || []).join(', ') || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<StatusBadge status={m.status} />
|
||||
</div>
|
||||
{/* Group children — one row per instance (hazard) */}
|
||||
{isGroupOpen && (
|
||||
<div className="bg-gray-50/40 dark:bg-gray-900/20 border-t border-gray-100 dark:border-gray-700">
|
||||
{description && (
|
||||
<p className="px-12 pt-2 pb-1 text-[11px] text-gray-500 dark:text-gray-400 italic">{description}</p>
|
||||
)}
|
||||
{refs?.length > 0 && (
|
||||
<p className="px-12 pb-2 text-[11px] text-blue-500">Normen: {refs.join(', ')}</p>
|
||||
)}
|
||||
{instances.map((m) => {
|
||||
const isDetailOpen = expandedMeasure === m.id
|
||||
return (
|
||||
<div key={m.id}>
|
||||
<div onClick={() => setExpandedMeasure(isDetailOpen ? null : m.id)}
|
||||
className={`grid grid-cols-[36px_36px_2fr_120px_110px] gap-2 px-4 py-1.5 border-t border-gray-100 dark:border-gray-700 hover:bg-white dark:hover:bg-gray-800 transition-colors cursor-pointer ${m.is_relevant ? 'bg-emerald-50/40 dark:bg-emerald-900/10' : ''}`}>
|
||||
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={Boolean(m.is_relevant)} onChange={() => handleSetRelevant(m.id, !m.is_relevant)}
|
||||
className="accent-purple-600" title="Als relevant markieren" />
|
||||
</div>
|
||||
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => handleDelete(m.id)}
|
||||
className="text-gray-400 hover:text-red-600" title="Loeschen">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M16 7V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-300 min-w-0">
|
||||
{(m.linked_hazard_names || []).join(', ') || '— (keine Gefaehrdung verknuepft)'}
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-400 self-center text-right pr-2">
|
||||
{m.is_customer_standard ? 'Kundenstandard' : ''}
|
||||
</div>
|
||||
<div><StatusBadge status={m.status} /></div>
|
||||
</div>
|
||||
{isDetailOpen && (
|
||||
<div className="px-12 py-2 bg-white dark:bg-gray-800 border-t border-gray-100 dark:border-gray-700 text-xs space-y-1">
|
||||
<MitigationHints projectId={projectId} mitigationId={m.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isDetailOpen && (
|
||||
<div className="px-12 py-3 bg-gray-50 dark:bg-gray-750 border-t border-gray-100 dark:border-gray-700 text-xs space-y-1">
|
||||
{m.description && <p className="text-gray-600 dark:text-gray-300">{m.description}</p>}
|
||||
{category && <p className="text-purple-600">Diese Massnahme gilt fuer alle Gefaehrdungen der Kategorie <strong>{category}</strong>.</p>}
|
||||
{refs?.length > 0 && <p className="text-blue-500">Normen: {refs.join(', ')}</p>}
|
||||
<MitigationHints projectId={projectId} mitigationId={m.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{items.length > (mitPages[type] || 1) * 50 && (
|
||||
{groups.length > visibleGroups.length && (
|
||||
<button onClick={() => setMitPages(prev => ({ ...prev, [type]: (prev[type] || 1) + 1 }))}
|
||||
className="w-full py-2 text-xs text-purple-600 hover:bg-purple-50 border-t border-gray-100 transition-colors">
|
||||
Weitere {Math.min(50, items.length - (mitPages[type] || 1) * 50)} von {items.length} laden...
|
||||
Weitere {Math.min(50, groups.length - visibleGroups.length)} von {groups.length} Maßnahmen laden...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
})()}
|
||||
|
||||
{isExpanded && items.length === 0 && (
|
||||
<div className="px-4 py-6 text-center text-sm text-gray-400 border-t border-gray-100">
|
||||
|
||||
@@ -68,10 +68,14 @@ export default function OrderPage() {
|
||||
setSaveState('saving')
|
||||
try {
|
||||
const merged = { ...existingMetaRef.current, order_data: next }
|
||||
// Mirror Auftraggeber.Firmenname into the top-level customer_name
|
||||
// column so the Customer-Standards-Reuse feature can index by it.
|
||||
// Empty string → null on the backend, no broken reuse for fresh projects.
|
||||
const customerName = (next.client.company || '').trim()
|
||||
await fetch(`/api/sdk/v1/iace/projects/${projectId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ metadata: merged }),
|
||||
body: JSON.stringify({ metadata: merged, customer_name: customerName }),
|
||||
})
|
||||
existingMetaRef.current = merged
|
||||
setSaveState('saved')
|
||||
|
||||
@@ -1,86 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import type { VerificationItem, VerificationFormData } from './_components/verification-types'
|
||||
import { VerificationForm } from './_components/VerificationForm'
|
||||
import { CompleteModal } from './_components/CompleteModal'
|
||||
import { SuggestEvidenceModal } from './_components/SuggestEvidenceModal'
|
||||
import { VerificationTable } from './_components/VerificationTable'
|
||||
import { useMitigations } from '../mitigations/_hooks/useMitigations'
|
||||
import type { Mitigation } from '../mitigations/_components/types'
|
||||
|
||||
// Verifikations-Page (Phase-1 Workflow):
|
||||
//
|
||||
// Diese Seite ist eine abgeleitete View auf die Maßnahmen-Liste. Sie zeigt
|
||||
// nur diejenigen Maßnahmen, die der Fachmann auf der Maßnahmen-Seite als
|
||||
// `is_relevant = true` markiert hat. Pro Maßnahme stehen zwei Aktionen
|
||||
// zur Verfügung:
|
||||
//
|
||||
// 1. "Beim Kunden Standard" — Die Maßnahme ist beim Kunden bereits
|
||||
// umgesetzt (z.B. firmenweite Vorgabe, identische Vor-Anlage).
|
||||
// Setzt is_customer_standard = true und status = verified.
|
||||
// Es ist kein Nachweis-Dokument erforderlich.
|
||||
//
|
||||
// 2. "Verifizieren (mit Nachweis)" — Öffnet ein Modal, in dem der
|
||||
// Verifizierer einen Text-Nachweis hinterlegt (Prüfprotokoll-Nummer,
|
||||
// Abnahme-Referenz, etc.). Setzt status = verified. Die File-Upload-
|
||||
// Variante folgt in Phase 2, sobald ein Object-Storage-Backend
|
||||
// verfügbar ist.
|
||||
//
|
||||
// Wenn die Maßnahme bereits verifiziert ist, wird ein "Zurücksetzen"-Link
|
||||
// angeboten — er stellt status auf 'implemented' zurück, damit der
|
||||
// Fachmann eine versehentliche Bestätigung rückgängig machen kann.
|
||||
|
||||
export default function VerificationPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
const [items, setItems] = useState<VerificationItem[]>([])
|
||||
const [hazards, setHazards] = useState<{ id: string; name: string }[]>([])
|
||||
const [mitigations, setMitigations] = useState<{ id: string; title: string }[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [completingItem, setCompletingItem] = useState<VerificationItem | null>(null)
|
||||
const [showSuggest, setShowSuggest] = useState(false)
|
||||
|
||||
useEffect(() => { fetchData() }, [projectId])
|
||||
const { byType, loading, handleSetCustomerStandard } = useMitigations(projectId)
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
// Only load verifications initially — hazards/mitigations loaded on demand
|
||||
const verRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`)
|
||||
if (verRes.ok) { const j = await verRes.json(); setItems(j.verifications || j || []) }
|
||||
} catch (err) { console.error('Failed to fetch data:', err) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
async function loadMitigationsIfNeeded() {
|
||||
if (mitigations.length > 0) return
|
||||
try {
|
||||
const mitRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`)
|
||||
if (mitRes.ok) {
|
||||
const j = await mitRes.json()
|
||||
const mits = (j.mitigations || j || []).map((m: Record<string, string>) => ({ id: m.id, title: m.title || m.name || '' }))
|
||||
setMitigations(mits)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function handleSubmit(data: VerificationFormData) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
|
||||
})
|
||||
if (res.ok) { setShowForm(false); await fetchData() }
|
||||
} catch (err) { console.error('Failed to add verification:', err) }
|
||||
}
|
||||
|
||||
async function handleAddSuggestedEvidence(title: string, description: string, method: string, mitigationId: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, description, method, linked_mitigation_id: mitigationId }),
|
||||
})
|
||||
if (res.ok) await fetchData()
|
||||
} catch (err) { console.error('Failed to add suggested evidence:', err) }
|
||||
}
|
||||
|
||||
async function handleComplete(id: string, result: string, passed: boolean) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}/complete`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ result, passed }),
|
||||
})
|
||||
if (res.ok) { setCompletingItem(null); await fetchData() }
|
||||
} catch (err) { console.error('Failed to complete verification:', err) }
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Verifikation wirklich loeschen?')) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) await fetchData()
|
||||
} catch (err) { console.error('Failed to delete verification:', err) }
|
||||
}
|
||||
|
||||
const completed = items.filter(i => i.status === 'completed').length
|
||||
const failed = items.filter(i => i.status === 'failed').length
|
||||
const pending = items.filter(i => i.status === 'pending' || i.status === 'in_progress').length
|
||||
const [verifyTarget, setVerifyTarget] = useState<Mitigation | null>(null)
|
||||
const [verifyResult, setVerifyResult] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -88,82 +43,191 @@ export default function VerificationPage() {
|
||||
</div>
|
||||
)
|
||||
|
||||
const allRelevant = [...byType.design, ...byType.protection, ...byType.information].filter((m) => m.is_relevant)
|
||||
const groups = groupByTitle(allRelevant)
|
||||
const totals = {
|
||||
total: allRelevant.length,
|
||||
verified: allRelevant.filter((m) => m.status === 'verified').length,
|
||||
customerStd: allRelevant.filter((m) => m.is_customer_standard).length,
|
||||
pending: allRelevant.filter((m) => m.status !== 'verified').length,
|
||||
}
|
||||
|
||||
async function setStatus(id: string, value: 'implemented' | 'verified') {
|
||||
await fetch(`/api/sdk/v1/iace/mitigations/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: value }),
|
||||
})
|
||||
}
|
||||
|
||||
async function submitVerify() {
|
||||
if (!verifyTarget) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${verifyTarget.id}/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ verification_result: verifyResult }),
|
||||
})
|
||||
// Refetch via window-reload of just the data — useMitigations refreshes on mount.
|
||||
window.location.reload()
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
setVerifyTarget(null)
|
||||
setVerifyResult('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Verifikationsplan</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">Nachweisfuehrung fuer alle Schutzmassnahmen und Sicherheitsanforderungen.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{true && (
|
||||
<button onClick={async () => { await loadMitigationsIfNeeded(); setShowSuggest(true) }} className="flex items-center gap-2 px-3 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors text-sm">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
Nachweise vorschlagen
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setShowForm(true)} className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Verifikation hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Verifikation</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Bestätige die Umsetzung jeder als relevant markierten Maßnahme — entweder als
|
||||
<em> Kundenstandard</em> (keine Nachweis-Datei nötig) oder mit hinterlegtem Nachweis.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{items.length}</div>
|
||||
<div className="text-xs text-gray-500">Gesamt</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-green-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{completed}</div>
|
||||
<div className="text-xs text-green-600">Abgeschlossen</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-red-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-red-600">{failed}</div>
|
||||
<div className="text-xs text-red-600">Fehlgeschlagen</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-yellow-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-yellow-600">{pending}</div>
|
||||
<div className="text-xs text-yellow-600">Ausstehend</div>
|
||||
</div>
|
||||
{totals.total === 0 ? (
|
||||
<div className="rounded-md border border-gray-200 bg-gray-50 px-4 py-6 text-sm text-gray-600">
|
||||
Keine als <em>relevant</em> markierten Maßnahmen vorhanden. Gehe zurück zur
|
||||
{' '}<a className="text-purple-600 underline" href={`/sdk/iace/${projectId}/mitigations`}>Maßnahmen-Seite</a>{' '}
|
||||
und kreuze die anwendbaren Maßnahmen an.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<Stat n={totals.total} label="relevant" tone="gray" />
|
||||
<Stat n={totals.pending} label="offen" tone="amber" />
|
||||
<Stat n={totals.verified} label="verifiziert" tone="green" />
|
||||
<Stat n={totals.customerStd} label="Kundenstandard" tone="blue" />
|
||||
</div>
|
||||
|
||||
{groups.map(({ title, instances }) => {
|
||||
const verifiedCount = instances.filter((m) => m.status === 'verified').length
|
||||
const allDone = verifiedCount === instances.length
|
||||
return (
|
||||
<div key={title} className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className={`flex items-center gap-3 px-4 py-3 ${allDone ? 'bg-green-50 dark:bg-green-900/20' : 'bg-gray-50 dark:bg-gray-750'}`}>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">{title}</div>
|
||||
<div className="text-xs text-gray-500">{verifiedCount}/{instances.length} verifiziert</div>
|
||||
</div>
|
||||
{allDone && (
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{instances.map((m) => {
|
||||
const isVerified = m.status === 'verified'
|
||||
return (
|
||||
<div key={m.id} className={`grid grid-cols-[1fr_240px] gap-3 px-4 py-2.5 items-center ${isVerified ? 'bg-green-50/30 dark:bg-green-900/10' : ''}`}>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-200">
|
||||
{(m.linked_hazard_names || []).join(', ') || '— (keine Gefährdung verknüpft)'}
|
||||
</div>
|
||||
{m.is_customer_standard && (
|
||||
<div className="text-[11px] text-blue-600 mt-0.5">Beim Kunden Standard</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{!isVerified ? (
|
||||
<>
|
||||
<button onClick={async () => {
|
||||
await handleSetCustomerStandard(m.id, true)
|
||||
await setStatus(m.id, 'verified')
|
||||
window.location.reload()
|
||||
}} className="px-2.5 py-1 text-[11px] border border-blue-300 text-blue-700 rounded hover:bg-blue-50">
|
||||
Kundenstandard
|
||||
</button>
|
||||
<button onClick={() => { setVerifyTarget(m); setVerifyResult('') }}
|
||||
className="px-2.5 py-1 text-[11px] bg-green-600 text-white rounded hover:bg-green-700">
|
||||
Verifizieren…
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[11px] text-green-700">✓ Verifiziert</span>
|
||||
<button onClick={async () => {
|
||||
if (!confirm('Verifizierung zurücksetzen?')) return
|
||||
await setStatus(m.id, 'implemented')
|
||||
window.location.reload()
|
||||
}} className="text-[11px] text-gray-400 hover:text-red-600 underline">
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showForm && <VerificationForm onSubmit={handleSubmit} onCancel={() => setShowForm(false)} hazards={hazards} mitigations={mitigations} />}
|
||||
{completingItem && <CompleteModal item={completingItem} onSubmit={handleComplete} onClose={() => setCompletingItem(null)} />}
|
||||
{showSuggest && <SuggestEvidenceModal mitigations={mitigations} projectId={projectId} onAddEvidence={handleAddSuggestedEvidence} onClose={() => setShowSuggest(false)} />}
|
||||
|
||||
{items.length > 0 ? (
|
||||
<VerificationTable items={items} onComplete={setCompletingItem} onDelete={handleDelete} />
|
||||
) : !showForm && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Kein Verifikationsplan vorhanden</h3>
|
||||
<p className="mt-2 text-gray-500 max-w-md mx-auto">
|
||||
Definieren Sie Verifikationsschritte fuer Ihre Schutzmassnahmen.
|
||||
Jede Massnahme sollte durch mindestens eine Verifikation abgedeckt sein.
|
||||
</p>
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
{mitigations.length > 0 && (
|
||||
<button onClick={async () => { await loadMitigationsIfNeeded(); setShowSuggest(true) }} className="px-6 py-3 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors">
|
||||
Nachweise vorschlagen
|
||||
{verifyTarget && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl max-w-lg w-full p-6 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Verifizieren</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">{verifyTarget.title}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{(verifyTarget.linked_hazard_names || []).join(', ')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Nachweis / Prüfprotokoll-Referenz</label>
|
||||
<textarea value={verifyResult} onChange={(e) => setVerifyResult(e.target.value)}
|
||||
placeholder="z.B. Prüfprotokoll PM-2026-014 vom 14.05.2026, durchgeführt durch Hr. Schmidt (TÜV Süd)"
|
||||
className="w-full border rounded px-3 py-2 text-sm h-24" />
|
||||
<p className="text-[10px] text-gray-400 mt-1">Datei-Upload folgt in Phase 2 — vorerst genügt eine eindeutige Referenz.</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button onClick={() => setVerifyTarget(null)} disabled={submitting} className="text-xs px-3 py-1.5 text-gray-500 hover:text-gray-700">Abbrechen</button>
|
||||
<button onClick={submitVerify} disabled={submitting || !verifyResult.trim()}
|
||||
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50">
|
||||
{submitting ? 'Speichere…' : 'Verifizieren'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setShowForm(true)} className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||
Erste Verifikation anlegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ n, label, tone }: { n: number; label: string; tone: 'gray' | 'amber' | 'green' | 'blue' }) {
|
||||
const color =
|
||||
tone === 'amber' ? 'text-amber-600 border-amber-200' :
|
||||
tone === 'green' ? 'text-green-600 border-green-200' :
|
||||
tone === 'blue' ? 'text-blue-600 border-blue-200' :
|
||||
'text-gray-700 border-gray-200'
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg border p-4 text-center ${color}`}>
|
||||
<div className="text-2xl font-bold">{n}</div>
|
||||
<div className="text-xs">{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function groupByTitle(items: Mitigation[]): Array<{ title: string; instances: Mitigation[] }> {
|
||||
const map = new Map<string, Mitigation[]>()
|
||||
for (const m of items) {
|
||||
const key = (m.title || '').trim() || '(ohne Titel)'
|
||||
const arr = map.get(key)
|
||||
if (arr) arr.push(m); else map.set(key, [m])
|
||||
}
|
||||
// Frontend dedupe per hazard_id (mirrors mitigations/page.tsx)
|
||||
return Array.from(map.entries()).map(([title, list]) => {
|
||||
const byHazard = new Map<string, Mitigation>()
|
||||
for (const m of list) {
|
||||
const hid = (m.linked_hazard_ids || []).join('|') || m.id
|
||||
const prev = byHazard.get(hid)
|
||||
if (!prev || (m.status === 'verified' && prev.status !== 'verified')) byHazard.set(hid, m)
|
||||
}
|
||||
return { title, instances: Array.from(byHazard.values()) }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ const IACE_NAV_ITEMS = [
|
||||
{ id: 'components', label: 'Komponenten', href: '/components', icon: 'cube' },
|
||||
{ id: 'hazards', label: 'Hazard Log', href: '/hazards', icon: 'warning' },
|
||||
{ id: 'mitigations', label: 'Massnahmen', href: '/mitigations', icon: 'shield' },
|
||||
{ id: 'clarifications', label: 'Klärungen', href: '/clarifications', icon: 'chat' },
|
||||
{ id: 'verification', label: 'Verifikation', href: '/verification', icon: 'check' },
|
||||
{ id: 'customer-standards', label: 'Kundenstandards', href: '/customer-standards', icon: 'building' },
|
||||
{ id: 'evidence', label: 'Nachweise', href: '/evidence', icon: 'document' },
|
||||
{ id: 'tech-file', label: 'CE-Akte', href: '/tech-file', icon: 'folder' },
|
||||
]
|
||||
@@ -66,6 +68,12 @@ function NavIcon({ icon, className }: { icon: string; className?: string }) {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)
|
||||
case 'building':
|
||||
return (
|
||||
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0H5m14 0h2m-16 0H3m4-4h2m-2-4h2m-2-4h2m4 8h2m-2-4h2m-2-4h2" />
|
||||
</svg>
|
||||
)
|
||||
case 'document':
|
||||
return (
|
||||
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -115,6 +123,23 @@ export default function IACELayout({ children }: { children: React.ReactNode })
|
||||
const [variantInfo, setVariantInfo] = React.useState<{
|
||||
parentProjectId?: string; parentName?: string; variantCount?: number
|
||||
}>({})
|
||||
const [openClarifications, setOpenClarifications] = React.useState<number | null>(null)
|
||||
|
||||
// Poll the clarifications endpoint so the sidebar always shows the
|
||||
// current "offene Klaerungen" counter. Refresh whenever the user
|
||||
// navigates back to this layout (i.e. when pathname changes).
|
||||
React.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 || typeof d.open_count !== 'number') return
|
||||
setOpenClarifications(d.open_count)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => { cancelled = true }
|
||||
}, [projectId, pathname])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!projectId) return
|
||||
@@ -218,7 +243,15 @@ export default function IACELayout({ children }: { children: React.ReactNode })
|
||||
}`}
|
||||
>
|
||||
<NavIcon icon={item.icon} className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
<span className="truncate flex-1">{item.label}</span>
|
||||
{item.id === 'clarifications' && openClarifications !== null && openClarifications > 0 && (
|
||||
<span
|
||||
className="ml-auto inline-flex items-center justify-center min-w-[20px] px-1.5 py-0.5 text-[10px] font-semibold rounded-full bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300"
|
||||
title={`${openClarifications} offene Klärung${openClarifications === 1 ? '' : 'en'}`}
|
||||
>
|
||||
{openClarifications}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -199,32 +199,43 @@ function MCDetail({ mc, onBack }: { mc: Record<string, unknown>; onBack: () => v
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-50">
|
||||
{filtered.map((m, i) => (
|
||||
<div key={i} className="px-4 py-3 hover:bg-gray-50">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-gray-400">{m.control_id}</span>
|
||||
{m.severity && (
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold ${SEV[m.severity] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{m.severity}
|
||||
</span>
|
||||
{filtered.map((m, i) => {
|
||||
const inner = (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-gray-400">{m.control_id}</span>
|
||||
{m.severity && (
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold ${SEV[m.severity] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{m.severity}
|
||||
</span>
|
||||
)}
|
||||
{m.phase && (
|
||||
<span className="text-[10px] text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">
|
||||
{m.phase}
|
||||
</span>
|
||||
)}
|
||||
{m.action && (
|
||||
<span className="text-[10px] text-gray-400">{m.action}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-900">{m.title}</p>
|
||||
{m.regulation_source && (
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
{m.regulation_source} {m.regulation_article}
|
||||
</p>
|
||||
)}
|
||||
{m.phase && (
|
||||
<span className="text-[10px] text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">
|
||||
{m.phase}
|
||||
</span>
|
||||
)}
|
||||
{m.action && (
|
||||
<span className="text-[10px] text-gray-400">{m.action}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-900">{m.title}</p>
|
||||
{m.regulation_source && (
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
{m.regulation_source} {m.regulation_article}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
return m.control_id ? (
|
||||
<a key={i}
|
||||
href={`/sdk/control-library?control=${encodeURIComponent(m.control_id)}`}
|
||||
className="block px-4 py-3 hover:bg-purple-50/40 transition-colors">
|
||||
{inner}
|
||||
</a>
|
||||
) : (
|
||||
<div key={i} className="px-4 py-3 hover:bg-gray-50">{inner}</div>
|
||||
)
|
||||
})}
|
||||
{filtered.length === 0 && !loading && (
|
||||
<div className="p-8 text-center text-gray-400">Keine Controls gefunden</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchCriterionTree, type QuaidalControl, type QuaidalCriterionTree } from '../_hooks/useQuaidalData'
|
||||
|
||||
interface Props {
|
||||
sectionId: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function ControlBlock({ ctrl, badgeColor }: { ctrl: QuaidalControl; badgeColor: string }) {
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-4 bg-white">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<h4 className="font-semibold text-gray-900">{ctrl.canonical_name}</h4>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${badgeColor} shrink-0`}>{ctrl.source.section}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3 whitespace-pre-line">{ctrl.description}</p>
|
||||
{ctrl.source.url && (
|
||||
<a
|
||||
href={ctrl.source.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-xs text-purple-600 hover:text-purple-800 underline"
|
||||
>
|
||||
BSI-Quelle ansehen ({ctrl.source.framework})
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function QuaidalCriterionDetail({ sectionId, onClose }: Props) {
|
||||
const [tree, setTree] = useState<QuaidalCriterionTree | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoading(true)
|
||||
fetchCriterionTree(sectionId).then(t => {
|
||||
if (active) {
|
||||
setTree(t)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [sectionId])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide">QUAIDAL Kriterium</div>
|
||||
<h2 className="text-xl font-bold text-gray-900">
|
||||
{tree?.criterion.canonical_name || sectionId}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-full hover:bg-gray-100 flex items-center justify-center text-gray-500"
|
||||
aria-label="Schliessen"
|
||||
>×</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto p-6 space-y-6">
|
||||
{loading && <div className="text-center text-gray-400 py-12">Lade...</div>}
|
||||
|
||||
{tree && (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-2">
|
||||
Anforderung (eigene Formulierung)
|
||||
</h3>
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<p className="text-gray-800 whitespace-pre-line">{tree.criterion.description}</p>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-gray-500">
|
||||
<span>Regulierung: <span className="font-medium text-gray-700">{tree.criterion.regulation_anchor || '—'}</span></span>
|
||||
<span>Quelle: <span className="font-medium text-gray-700">{tree.criterion.source.framework} {tree.criterion.source.section}</span></span>
|
||||
{tree.criterion.source.url && (
|
||||
<a href={tree.criterion.source.url} target="_blank" rel="noreferrer noopener" className="text-purple-600 hover:text-purple-800 underline">
|
||||
Originalquelle
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tree.criterion.external_refs.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-2">
|
||||
Externe Referenzen (nicht ingestiert, nur Verweis)
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tree.criterion.external_refs.map((ref, i) => (
|
||||
<span key={i} className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded">
|
||||
{ref.framework}{ref.citation ? ` — ${ref.citation}` : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tree.building_blocks.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
Bausteine ({tree.building_blocks.length})
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{tree.building_blocks.map(qb => (
|
||||
<ControlBlock key={qb.derived_id} ctrl={qb} badgeColor="bg-blue-100 text-blue-700" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tree.measures.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
Maßnahmen ({tree.measures.length})
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{tree.measures.map(m => (
|
||||
<ControlBlock key={m.derived_id} ctrl={m} badgeColor="bg-green-100 text-green-700" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tree.metrics.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
Metriken & Methoden ({tree.metrics.length})
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{tree.metrics.map(qm => (
|
||||
<ControlBlock key={qm.derived_id} ctrl={qm} badgeColor="bg-amber-100 text-amber-700" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-3 border-t border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||
Eigene Clean-Room-Ableitung von BSI QUAIDAL. Quellverweis und Lizenz-Note pro Eintrag.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuaidalData, type QuaidalControl } from '../_hooks/useQuaidalData'
|
||||
import { QuaidalCriterionDetail } from './QuaidalCriterionDetail'
|
||||
|
||||
function CriterionCard({ ctrl, onOpen }: { ctrl: QuaidalControl; onOpen: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onOpen}
|
||||
className="text-left bg-white rounded-xl border border-gray-200 p-5 hover:border-purple-400 hover:shadow-sm transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className="font-semibold text-gray-900">{ctrl.canonical_name}</h3>
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-100 text-purple-700">
|
||||
{ctrl.source.section}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 line-clamp-3">{ctrl.description}</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="text-gray-500">Bausteine: <span className="font-medium text-gray-700">{ctrl.related_quaidal_ids.length}</span></span>
|
||||
{ctrl.external_refs.slice(0, 2).map((r, i) => (
|
||||
<span key={i} className="px-1.5 py-0.5 bg-gray-100 text-gray-600 rounded">
|
||||
{r.framework}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function TrainingDataQualityTab() {
|
||||
const { criteria, stats, loading, error } = useQuaidalData()
|
||||
const [openSection, setOpenSection] = useState<string | null>(null)
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center text-gray-400 py-12">Lade QUAIDAL-Katalog...</div>
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
||||
QUAIDAL-Daten konnten nicht geladen werden: {error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-xl p-5">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Trainingsdaten-Qualität nach BSI QUAIDAL</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Operative Umsetzung von EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI) auf Basis des
|
||||
BSI-Katalogs QUAIDAL. Alle Controls sind eigenständig formuliert (Clean-Room) und verweisen
|
||||
auf die jeweilige QUAIDAL-Sektion.
|
||||
</p>
|
||||
{stats && (
|
||||
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Qualitätskriterien</div>
|
||||
<div className="text-xl font-semibold text-gray-900">{stats.counts_by_kind.criterion ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Bausteine</div>
|
||||
<div className="text-xl font-semibold text-gray-900">{stats.counts_by_kind.building_block ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Maßnahmen</div>
|
||||
<div className="text-xl font-semibold text-gray-900">{stats.counts_by_kind.measure ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Metriken & Methoden</div>
|
||||
<div className="text-xl font-semibold text-gray-900">{stats.counts_by_kind.metric ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">10 Qualitätskriterien</h3>
|
||||
{criteria.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center text-gray-400">
|
||||
Keine Kriterien gefunden. Bitte Backend-Ingest prüfen.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{criteria.map(c => (
|
||||
<CriterionCard
|
||||
key={c.derived_id}
|
||||
ctrl={c}
|
||||
onOpen={() => setOpenSection(c.source.section)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stats?.license_note && (
|
||||
<div className="text-xs text-gray-500 italic">{stats.license_note}</div>
|
||||
)}
|
||||
|
||||
{openSection && (
|
||||
<QuaidalCriterionDetail
|
||||
sectionId={openSection}
|
||||
onClose={() => setOpenSection(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
export interface QuaidalExternalRef {
|
||||
framework: string
|
||||
citation: string | null
|
||||
}
|
||||
|
||||
export interface QuaidalSource {
|
||||
framework: string
|
||||
section: string
|
||||
url: string | null
|
||||
commit_sha: string | null
|
||||
title_original: string | null
|
||||
license_note: string | null
|
||||
}
|
||||
|
||||
export interface QuaidalControl {
|
||||
derived_id: string
|
||||
kind: 'criterion' | 'building_block' | 'measure' | 'metric'
|
||||
canonical_name: string
|
||||
description: string
|
||||
regulation_anchor: string | null
|
||||
related_quaidal_ids: string[]
|
||||
external_refs: QuaidalExternalRef[]
|
||||
source: QuaidalSource
|
||||
plagiarism_score: number | null
|
||||
}
|
||||
|
||||
export interface QuaidalStats {
|
||||
counts_by_kind: Record<string, number>
|
||||
source_framework: string
|
||||
source_commit_sha: string | null
|
||||
license_note: string | null
|
||||
}
|
||||
|
||||
export interface QuaidalCriterionTree {
|
||||
criterion: QuaidalControl
|
||||
building_blocks: QuaidalControl[]
|
||||
measures: QuaidalControl[]
|
||||
metrics: QuaidalControl[]
|
||||
}
|
||||
|
||||
const API_BASE = '/api/sdk/v1/quaidal'
|
||||
|
||||
export function useQuaidalData() {
|
||||
const [criteria, setCriteria] = useState<QuaidalControl[]>([])
|
||||
const [stats, setStats] = useState<QuaidalStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [criteriaRes, statsRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/criteria`, { cache: 'no-store' }),
|
||||
fetch(`${API_BASE}/stats`, { cache: 'no-store' }),
|
||||
])
|
||||
if (criteriaRes.ok) {
|
||||
const data = (await criteriaRes.json()) as QuaidalControl[]
|
||||
setCriteria(Array.isArray(data) ? data : [])
|
||||
} else {
|
||||
setError(`Criteria endpoint returned ${criteriaRes.status}`)
|
||||
}
|
||||
if (statsRes.ok) {
|
||||
setStats(await statsRes.json())
|
||||
}
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadAll() }, [loadAll])
|
||||
|
||||
return { criteria, stats, loading, error, reload: loadAll }
|
||||
}
|
||||
|
||||
export async function fetchCriterionTree(sectionId: string): Promise<QuaidalCriterionTree | null> {
|
||||
const res = await fetch(`${API_BASE}/criteria/${encodeURIComponent(sectionId)}`, { cache: 'no-store' })
|
||||
if (!res.ok) return null
|
||||
return (await res.json()) as QuaidalCriterionTree
|
||||
}
|
||||
@@ -1,15 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { useQualityData } from './_hooks/useQualityData'
|
||||
import { MetricCard, type QualityMetric } from './_components/MetricCard'
|
||||
import { TestRow } from './_components/TestRow'
|
||||
import { MetricModal } from './_components/MetricModal'
|
||||
import { TestModal } from './_components/TestModal'
|
||||
import { TrainingDataQualityTab } from './_components/TrainingDataQualityTab'
|
||||
|
||||
type TabId = 'model_quality' | 'data_quality'
|
||||
|
||||
export default function QualityPage() {
|
||||
const { state } = useSDK()
|
||||
const searchParams = useSearchParams()
|
||||
const initialTab: TabId = searchParams?.get('category') === 'data_quality' ? 'data_quality' : 'model_quality'
|
||||
const [tab, setTab] = useState<TabId>(initialTab)
|
||||
|
||||
const {
|
||||
metrics,
|
||||
tests,
|
||||
@@ -41,24 +49,54 @@ export default function QualityPage() {
|
||||
<h1 className="text-2xl font-bold text-gray-900">AI Quality Dashboard</h1>
|
||||
<p className="mt-1 text-gray-500">Ueberwachen Sie die Qualitaet und Fairness Ihrer KI-Systeme</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowTestModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
|
||||
Test hinzufuegen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditMetric(undefined); setShowMetricModal(true) }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /></svg>
|
||||
Messung hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
{tab === 'model_quality' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowTestModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
|
||||
Test hinzufuegen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditMetric(undefined); setShowMetricModal(true) }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /></svg>
|
||||
Messung hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex gap-6">
|
||||
<button
|
||||
onClick={() => setTab('model_quality')}
|
||||
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === 'model_quality'
|
||||
? 'border-purple-500 text-purple-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Modell-Qualität
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('data_quality')}
|
||||
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === 'data_quality'
|
||||
? 'border-purple-500 text-purple-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Trainingsdaten-Qualität (BSI QUAIDAL)
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{tab === 'data_quality' && <TrainingDataQualityTab />}
|
||||
{tab === 'model_quality' && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Durchschnittlicher Score</div>
|
||||
@@ -141,6 +179,8 @@ export default function QualityPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showMetricModal && (
|
||||
<MetricModal
|
||||
|
||||
@@ -75,6 +75,28 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
|
||||
<AdditionalModuleItem href="/sdk/agent" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>} label="Compliance Agent" isActive={pathname?.startsWith('/sdk/agent') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
</div>
|
||||
|
||||
{/* CRA Compliance */}
|
||||
<div className="border-t-2 border-red-200 py-2 bg-red-50/30">
|
||||
{!collapsed && (
|
||||
<div className="px-4 py-2 text-xs font-semibold text-red-600 uppercase tracking-wider">
|
||||
CRA Compliance
|
||||
</div>
|
||||
)}
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/cra"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
}
|
||||
label="CRA Compliance"
|
||||
isActive={pathname?.startsWith('/sdk/cra') ?? false}
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Regulatory Gap-Analyse */}
|
||||
<div className="border-t-2 border-orange-200 py-2 bg-orange-50/30">
|
||||
{!collapsed && (
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Content-Blocker Generator (Borlabs-Parity).
|
||||
*
|
||||
* Returns a small JS snippet that scans the page for blockable third-party
|
||||
* embeds (YouTube, Vimeo, Google Maps, Spotify, Twitter, Facebook) and
|
||||
* replaces them with a click-to-consent placeholder until the user agrees
|
||||
* to the relevant cookie category.
|
||||
*
|
||||
* The customer drops a SECOND script tag next to the banner:
|
||||
* <script src="/cookie-banner.js"></script>
|
||||
* <script src="/cookie-content-blocker.js"></script>
|
||||
*
|
||||
* Author writes content as either:
|
||||
* <bp-consent-block category="EXTERNAL_MEDIA"
|
||||
* provider="YouTube"
|
||||
* src="https://www.youtube.com/embed/...">
|
||||
* <!-- the original iframe / embed code -->
|
||||
* </bp-consent-block>
|
||||
*
|
||||
* OR auto-detect: any <iframe src="https://www.youtube.com/...">
|
||||
* gets wrapped on page load.
|
||||
*/
|
||||
|
||||
const KNOWN_EMBEDS: Array<{ host: string; provider: string; category: string }> = [
|
||||
{ host: 'youtube.com', provider: 'YouTube', category: 'EXTERNAL_MEDIA' },
|
||||
{ host: 'youtu.be', provider: 'YouTube', category: 'EXTERNAL_MEDIA' },
|
||||
{ host: 'vimeo.com', provider: 'Vimeo', category: 'EXTERNAL_MEDIA' },
|
||||
{ host: 'google.com/maps', provider: 'Google Maps', category: 'EXTERNAL_MEDIA' },
|
||||
{ host: 'maps.googleapis.com', provider: 'Google Maps', category: 'EXTERNAL_MEDIA' },
|
||||
{ host: 'spotify.com', provider: 'Spotify', category: 'EXTERNAL_MEDIA' },
|
||||
{ host: 'soundcloud.com', provider: 'SoundCloud', category: 'EXTERNAL_MEDIA' },
|
||||
{ host: 'twitter.com', provider: 'Twitter / X', category: 'PERSONALIZATION' },
|
||||
{ host: 'facebook.com', provider: 'Facebook', category: 'PERSONALIZATION' },
|
||||
{ host: 'instagram.com', provider: 'Instagram', category: 'PERSONALIZATION' },
|
||||
]
|
||||
|
||||
export function generateContentBlockerJS(cookieName: string = 'cookie_consent'): string {
|
||||
return `(function () {
|
||||
'use strict';
|
||||
var COOKIE_NAME = ${JSON.stringify(cookieName)};
|
||||
var KNOWN_EMBEDS = ${JSON.stringify(KNOWN_EMBEDS)};
|
||||
|
||||
function getConsent() {
|
||||
var c = document.cookie.split('; ').find(function (r) {
|
||||
return r.indexOf(COOKIE_NAME + '=') === 0;
|
||||
});
|
||||
if (!c) return null;
|
||||
try { return JSON.parse(decodeURIComponent(c.split('=')[1])); } catch (e) { return null; }
|
||||
}
|
||||
|
||||
function categoryGranted(cat) {
|
||||
var c = getConsent();
|
||||
if (!c) return false;
|
||||
var k = String(cat).toLowerCase();
|
||||
return c[cat] === true || c[k] === true;
|
||||
}
|
||||
|
||||
function classifyByHost(src) {
|
||||
if (!src) return null;
|
||||
for (var i = 0; i < KNOWN_EMBEDS.length; i++) {
|
||||
if (src.indexOf(KNOWN_EMBEDS[i].host) > -1) return KNOWN_EMBEDS[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function makePlaceholder(provider, category, originalHTML, parent) {
|
||||
var ph = document.createElement('div');
|
||||
ph.className = 'bp-consent-placeholder';
|
||||
ph.style.cssText = 'border:2px dashed #cbd5e1;background:#f8fafc;padding:24px;' +
|
||||
'border-radius:8px;text-align:center;font-family:-apple-system,sans-serif;color:#475569';
|
||||
ph.innerHTML =
|
||||
'<div style="font-size:14px;font-weight:600;color:#1e293b;margin-bottom:8px">' +
|
||||
'Inhalt von ' + provider + ' blockiert</div>' +
|
||||
'<div style="font-size:12px;margin-bottom:12px">' +
|
||||
'Zum Anzeigen dieses Inhalts wird Ihre Einwilligung fuer die Kategorie ' +
|
||||
'<strong>' + category + '</strong> benoetigt. ' +
|
||||
'Beim Akzeptieren werden Cookies von ' + provider + ' gesetzt.</div>' +
|
||||
'<button class="bp-consent-load-btn" ' +
|
||||
'style="background:#7c3aed;color:white;border:none;padding:8px 16px;' +
|
||||
'border-radius:6px;font-size:13px;cursor:pointer;margin-right:6px">' +
|
||||
'Inhalt einmalig laden</button>' +
|
||||
'<button class="bp-consent-accept-btn" ' +
|
||||
'style="background:#16a34a;color:white;border:none;padding:8px 16px;' +
|
||||
'border-radius:6px;font-size:13px;cursor:pointer">' +
|
||||
category + ' akzeptieren</button>';
|
||||
ph.querySelector('.bp-consent-load-btn').addEventListener('click', function () {
|
||||
var div = document.createElement('div');
|
||||
div.innerHTML = originalHTML;
|
||||
while (div.firstChild) parent.insertBefore(div.firstChild, ph);
|
||||
ph.remove();
|
||||
});
|
||||
ph.querySelector('.bp-consent-accept-btn').addEventListener('click', function () {
|
||||
var c = getConsent() || {};
|
||||
c[category] = true;
|
||||
var date = new Date();
|
||||
date.setTime(date.getTime() + 180 * 86400000);
|
||||
document.cookie = COOKIE_NAME + '=' + encodeURIComponent(JSON.stringify(c)) +
|
||||
';expires=' + date.toUTCString() + ';path=/;SameSite=Lax';
|
||||
window.dispatchEvent(new CustomEvent('cookieConsentUpdated', { detail: c }));
|
||||
// Re-scan: placeholders for THIS category get replaced now
|
||||
processAll();
|
||||
});
|
||||
return ph;
|
||||
}
|
||||
|
||||
function processWrapped() {
|
||||
var wrapped = document.querySelectorAll('bp-consent-block, [data-bp-consent-block]');
|
||||
wrapped.forEach(function (el) {
|
||||
var cat = el.getAttribute('category') || el.getAttribute('data-category') || 'EXTERNAL_MEDIA';
|
||||
var prov = el.getAttribute('provider') || el.getAttribute('data-provider') || 'Drittanbieter';
|
||||
if (categoryGranted(cat)) {
|
||||
// Already consented: unwrap the inner content
|
||||
var html = el.innerHTML;
|
||||
var tmp = document.createElement('div');
|
||||
tmp.innerHTML = html;
|
||||
var parent = el.parentNode;
|
||||
while (tmp.firstChild) parent.insertBefore(tmp.firstChild, el);
|
||||
el.remove();
|
||||
} else {
|
||||
var parent = el.parentNode;
|
||||
var inner = el.innerHTML;
|
||||
var ph = makePlaceholder(prov, cat, inner, parent);
|
||||
parent.insertBefore(ph, el);
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function processBareIframes() {
|
||||
var iframes = document.querySelectorAll('iframe[src]:not([data-bp-processed])');
|
||||
iframes.forEach(function (f) {
|
||||
var match = classifyByHost(f.getAttribute('src') || '');
|
||||
if (!match) return;
|
||||
f.setAttribute('data-bp-processed', '1');
|
||||
if (categoryGranted(match.category)) return;
|
||||
var html = f.outerHTML;
|
||||
var parent = f.parentNode;
|
||||
var ph = makePlaceholder(match.provider, match.category, html, parent);
|
||||
parent.replaceChild(ph, f);
|
||||
});
|
||||
}
|
||||
|
||||
function processAll() {
|
||||
processWrapped();
|
||||
processBareIframes();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', processAll);
|
||||
} else {
|
||||
processAll();
|
||||
}
|
||||
// Re-process when consent updates
|
||||
window.addEventListener('cookieConsentUpdated', processAll);
|
||||
})();`
|
||||
}
|
||||
@@ -325,18 +325,25 @@ function generateJS(config: CookieBannerConfig): string {
|
||||
const CATEGORIES = ${JSON.stringify(categoryIds)};
|
||||
const REQUIRED_CATEGORIES = ${JSON.stringify(requiredCategories)};
|
||||
|
||||
// Google Consent Mode v2 — PFLICHT seit Maerz 2024 fuer Google Services in EEA
|
||||
// Sets default consent state to "denied" BEFORE any Google tags fire
|
||||
if (typeof gtag === 'function') {
|
||||
gtag('consent', 'default', {
|
||||
analytics_storage: 'denied',
|
||||
ad_storage: 'denied',
|
||||
ad_user_data: 'denied',
|
||||
ad_personalization: 'denied',
|
||||
functionality_storage: 'granted',
|
||||
security_storage: 'granted',
|
||||
});
|
||||
// Google Consent Mode v2 — PFLICHT seit Maerz 2024 fuer Google Services
|
||||
// in EEA. Shim gtag/dataLayer falls Google Tag noch nicht initialisiert
|
||||
// wurde, dann sofort den default consent state setzen (DENIED).
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
if (typeof gtag !== 'function') {
|
||||
window.gtag = function () { window.dataLayer.push(arguments); };
|
||||
}
|
||||
// wait_for_update gibt dem Banner 500ms Zeit, damit der Nutzer
|
||||
// entscheiden kann bevor Tags feuern. Empfehlung von Google fuer GCM v2.
|
||||
gtag('consent', 'default', {
|
||||
analytics_storage: 'denied',
|
||||
ad_storage: 'denied',
|
||||
ad_user_data: 'denied',
|
||||
ad_personalization: 'denied',
|
||||
functionality_storage: 'granted',
|
||||
security_storage: 'granted',
|
||||
wait_for_update: 500,
|
||||
region: ['EEA', 'CH', 'GB'],
|
||||
});
|
||||
|
||||
function updateGoogleConsentMode(consent) {
|
||||
if (typeof gtag !== 'function') return;
|
||||
@@ -364,10 +371,61 @@ function generateJS(config: CookieBannerConfig): string {
|
||||
document.cookie = COOKIE_NAME + '=' + encodeURIComponent(JSON.stringify(consent)) +
|
||||
';expires=' + date.toUTCString() +
|
||||
';path=/;SameSite=Lax';
|
||||
// Append to local history (Art. 7(3) DSGVO Best-Practice + Borlabs-Parity).
|
||||
// Server-seitiges Logging laeuft separat via consent-service.
|
||||
try {
|
||||
const HKEY = COOKIE_NAME + '_history';
|
||||
const hist = JSON.parse(localStorage.getItem(HKEY) || '[]');
|
||||
hist.push({
|
||||
ts: new Date().toISOString(),
|
||||
choices: consent,
|
||||
});
|
||||
if (hist.length > 50) hist.splice(0, hist.length - 50);
|
||||
localStorage.setItem(HKEY, JSON.stringify(hist));
|
||||
} catch (e) { /* localStorage blocked */ }
|
||||
window.dispatchEvent(new CustomEvent('cookieConsentUpdated', { detail: consent }));
|
||||
updateGoogleConsentMode(consent);
|
||||
}
|
||||
|
||||
// Borlabs-Parity: zeigt dem Nutzer alle seine bisherigen Einwilligungen.
|
||||
// Aufruf via window.bpShowConsentHistory() oder Klick auf den Link im Banner-Footer.
|
||||
window.bpShowConsentHistory = function () {
|
||||
var existing = document.getElementById('bpConsentHistoryModal');
|
||||
if (existing) { existing.remove(); return; }
|
||||
var hist = [];
|
||||
try { hist = JSON.parse(localStorage.getItem(COOKIE_NAME + '_history') || '[]'); } catch (e) {}
|
||||
var rows = hist.length === 0
|
||||
? '<p style="color:#94a3b8;font-style:italic">Noch keine Einwilligungen gespeichert.</p>'
|
||||
: hist.slice().reverse().map(function (h) {
|
||||
var d = new Date(h.ts);
|
||||
var parts = Object.keys(h.choices).map(function (k) {
|
||||
return '<span style="margin-right:8px;font-size:11px;color:' +
|
||||
(h.choices[k] ? '#16a34a' : '#dc2626') + '">' +
|
||||
(h.choices[k] ? '✓ ' : '✗ ') + k + '</span>';
|
||||
}).join('');
|
||||
return '<div style="border-bottom:1px solid #e5e7eb;padding:8px 0">' +
|
||||
'<div style="font-size:12px;color:#64748b;margin-bottom:4px">' +
|
||||
d.toLocaleString('de-DE') + '</div>' +
|
||||
'<div>' + parts + '</div></div>';
|
||||
}).join('');
|
||||
var modal = document.createElement('div');
|
||||
modal.id = 'bpConsentHistoryModal';
|
||||
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);' +
|
||||
'z-index:999999;display:flex;align-items:center;justify-content:center;padding:20px';
|
||||
modal.innerHTML = '<div style="background:white;border-radius:8px;max-width:500px;' +
|
||||
'width:100%;max-height:80vh;overflow:auto;padding:20px;font-family:-apple-system,sans-serif">' +
|
||||
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">' +
|
||||
'<h3 style="margin:0;font-size:16px">Ihre Einwilligungs-Historie</h3>' +
|
||||
'<button onclick="document.getElementById(\\'bpConsentHistoryModal\\').remove()" ' +
|
||||
'style="background:none;border:none;font-size:24px;cursor:pointer;color:#94a3b8">×</button>' +
|
||||
'</div>' +
|
||||
'<p style="font-size:12px;color:#64748b;margin:0 0 12px">' +
|
||||
'Lokal in Ihrem Browser gespeichert. Server-seitig laufen Audit-Logs gemaess Art. 7(1) DSGVO.</p>' +
|
||||
rows + '</div>';
|
||||
modal.addEventListener('click', function (e) { if (e.target === modal) modal.remove(); });
|
||||
document.body.appendChild(modal);
|
||||
};
|
||||
|
||||
function hasConsent(category) {
|
||||
const consent = getConsent();
|
||||
if (!consent) return REQUIRED_CATEGORIES.includes(category);
|
||||
|
||||
Generated
+41
-40
@@ -21,7 +21,7 @@
|
||||
"jspdf": "^4.1.0",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "^15.1.0",
|
||||
"next": "^15.5.16",
|
||||
"pg": "^8.13.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -1597,15 +1597,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "15.5.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.12.tgz",
|
||||
"integrity": "sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==",
|
||||
"version": "15.5.16",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.16.tgz",
|
||||
"integrity": "sha512-9QMKolCl+JnJtaRAQSXy4RQrhgfe8W7/G1+Hl3QSB/HZY7zQMzTwPDdTRwwio8BS96ps1MHpHhbS8qxoNV3JIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "15.5.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.12.tgz",
|
||||
"integrity": "sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg==",
|
||||
"version": "15.5.16",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.16.tgz",
|
||||
"integrity": "sha512-wzdER4JZj+31vNkhaZ1Ght3IsNI8DMwj7VqadfIOqJB5sh8FiOqNSopYADQn6mgEPomzDd/DHqBcfo2fmVMYtg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1619,9 +1619,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "15.5.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.12.tgz",
|
||||
"integrity": "sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA==",
|
||||
"version": "15.5.16",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.16.tgz",
|
||||
"integrity": "sha512-PPTo+cvcanxkuDEuDyZGk28ntmu0WjfkxqlG7hw9Mhsiribs4x1C6h2Culn0cJKqsne1gFjjZRK3ax7WYlSxgg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1635,9 +1635,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "15.5.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.12.tgz",
|
||||
"integrity": "sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw==",
|
||||
"version": "15.5.16",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.16.tgz",
|
||||
"integrity": "sha512-Jl0IL9P7S8uNl5oI1TqrQmfmLp7OqjWM58000pVnUVIsHrvPP6m9QDW/uNWYUbmd+8IYvc6MTeZKICstBMBpew==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1651,9 +1651,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "15.5.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.12.tgz",
|
||||
"integrity": "sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==",
|
||||
"version": "15.5.16",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.16.tgz",
|
||||
"integrity": "sha512-Zf0BIqv/o5uOWfyRkzgGhyV2Tky7HLt0bG+w7XWdaU1JpyX0tltM3TrSfa/Y9c597SJG4CzN47+u2InhgZZ4vg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1667,9 +1667,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "15.5.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.12.tgz",
|
||||
"integrity": "sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==",
|
||||
"version": "15.5.16",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.16.tgz",
|
||||
"integrity": "sha512-HCDDU1TRLeUDV180QQTWrs5Oa4lIcI7XH9nF0UVUVmYLN/boZ6LqyFtm3814gc1fv+lOVyKaw5B6bVC9BpXTSQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1683,9 +1683,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "15.5.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.12.tgz",
|
||||
"integrity": "sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==",
|
||||
"version": "15.5.16",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.16.tgz",
|
||||
"integrity": "sha512-kvXUY1dn5wxKuMkXxQRUbPjEnKxW1PR9uKOm0zpIpj3574+cFfaePhYFmBVtrOuwt+w34OdDzNaJr5Iixf+HBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1699,9 +1699,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "15.5.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.12.tgz",
|
||||
"integrity": "sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==",
|
||||
"version": "15.5.16",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.16.tgz",
|
||||
"integrity": "sha512-zpOQuF+eyENMXRjglp2hZCIrUjTdO37suEBnDn1mX4PXSuetXZDMLpjKOh4dYSw3SiDTnOoOUwBl5i5Elr6nnQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1715,9 +1715,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "15.5.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.12.tgz",
|
||||
"integrity": "sha512-Z1Dh6lhFkxvBDH1FoW6OU/L6prYwPSlwjLiZkExIAh8fbP6iI/M7iGTQAJPYJ9YFlWobCZ1PHbchFhFYb2ADkw==",
|
||||
"version": "15.5.16",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.16.tgz",
|
||||
"integrity": "sha512-LnwKYpiSmIzXlTq76hMeeIzZoDcFwu848p6H+QBkGFJIbZphgzNUPdHruJcHM/bFnaFeco0l1Frie5I27VKglA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5118,12 +5118,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "15.5.12",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz",
|
||||
"integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==",
|
||||
"version": "15.5.16",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.16.tgz",
|
||||
"integrity": "sha512-aZExBk/V6JCu3NCFc90twdj9L/M3y0+ukeQwUAZbOiqRhAX+h2oMEa0NZFhcpj6HYRYjVS3V2/3xvyOpNnmw7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "15.5.12",
|
||||
"@next/env": "15.5.16",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
@@ -5136,14 +5136,14 @@
|
||||
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "15.5.12",
|
||||
"@next/swc-darwin-x64": "15.5.12",
|
||||
"@next/swc-linux-arm64-gnu": "15.5.12",
|
||||
"@next/swc-linux-arm64-musl": "15.5.12",
|
||||
"@next/swc-linux-x64-gnu": "15.5.12",
|
||||
"@next/swc-linux-x64-musl": "15.5.12",
|
||||
"@next/swc-win32-arm64-msvc": "15.5.12",
|
||||
"@next/swc-win32-x64-msvc": "15.5.12",
|
||||
"@next/swc-darwin-arm64": "15.5.16",
|
||||
"@next/swc-darwin-x64": "15.5.16",
|
||||
"@next/swc-linux-arm64-gnu": "15.5.16",
|
||||
"@next/swc-linux-arm64-musl": "15.5.16",
|
||||
"@next/swc-linux-x64-gnu": "15.5.16",
|
||||
"@next/swc-linux-x64-musl": "15.5.16",
|
||||
"@next/swc-win32-arm64-msvc": "15.5.16",
|
||||
"@next/swc-win32-x64-msvc": "15.5.16",
|
||||
"sharp": "^0.34.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -5472,6 +5472,7 @@
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"jspdf": "^4.1.0",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "^15.1.0",
|
||||
"next": "^15.5.16",
|
||||
"pg": "^8.13.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// projectMetadataRoot is the shape we store inside iace_projects.metadata.
|
||||
// We only own the "clarification_answers" key; everything else is preserved
|
||||
// as opaque JSON so we don't trample on existing fields (limits_form, etc).
|
||||
type projectMetadataRoot map[string]json.RawMessage
|
||||
|
||||
const clarificationAnswersKey = "clarification_answers"
|
||||
|
||||
// readClarificationAnswers parses project.metadata and returns the
|
||||
// clarification_answers map. Missing/empty metadata yields an empty map.
|
||||
func readClarificationAnswers(meta json.RawMessage) (map[string]iace.ClarificationAnswer, projectMetadataRoot) {
|
||||
root := projectMetadataRoot{}
|
||||
if len(meta) > 0 {
|
||||
_ = json.Unmarshal(meta, &root)
|
||||
}
|
||||
answers := map[string]iace.ClarificationAnswer{}
|
||||
if raw, ok := root[clarificationAnswersKey]; ok && len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &answers)
|
||||
}
|
||||
return answers, root
|
||||
}
|
||||
|
||||
// reconstructHazardPatterns re-runs the pattern engine for the project's
|
||||
// narrative so we can map each hazard back to the patterns that fired for
|
||||
// it. The Hazard table itself doesn't persist the source-pattern list, so
|
||||
// this is the only way to know "which clarifications apply to which hazard".
|
||||
func (h *IACEHandler) reconstructHazardPatterns(narrative string, machineType string, hazards []iace.Hazard) map[uuid.UUID][]string {
|
||||
parsed := iace.ParseNarrative(narrative, machineType)
|
||||
compIDs := make([]string, 0, len(parsed.Components))
|
||||
for _, c := range parsed.Components {
|
||||
compIDs = append(compIDs, c.LibraryID)
|
||||
}
|
||||
energyIDs := make([]string, 0, len(parsed.EnergySources))
|
||||
for _, e := range parsed.EnergySources {
|
||||
energyIDs = append(energyIDs, e.SourceID)
|
||||
}
|
||||
engine := iace.NewPatternEngine()
|
||||
out := engine.Match(iace.MatchInput{
|
||||
ComponentLibraryIDs: compIDs,
|
||||
EnergySourceIDs: energyIDs,
|
||||
LifecyclePhases: parsed.LifecyclePhases,
|
||||
CustomTags: parsed.CustomTags,
|
||||
OperationalStates: parsed.OperationalStates,
|
||||
StateTransitions: parsed.StateTransitions,
|
||||
HumanRoles: parsed.Roles,
|
||||
MachineTypes: []string{machineType},
|
||||
})
|
||||
|
||||
// Map hazard.HazardousZone → set of HP-IDs by substring-matching the
|
||||
// pattern's ZoneDE. The hazard table doesn't keep a back-pointer to
|
||||
// the source pattern, so this approximation re-runs pattern matching
|
||||
// against the narrative and matches by normalised zone.
|
||||
hazardToPatterns := map[uuid.UUID][]string{}
|
||||
for _, hz := range hazards {
|
||||
hzZone := normalizeKey(hz.HazardousZone)
|
||||
if hzZone == "" {
|
||||
continue
|
||||
}
|
||||
for _, m := range out.MatchedPatterns {
|
||||
pz := normalizeKey(m.ZoneDE)
|
||||
if pz == "" {
|
||||
continue
|
||||
}
|
||||
if pz == hzZone || containsSubstring(hzZone, pz) || containsSubstring(pz, hzZone) {
|
||||
hazardToPatterns[hz.ID] = appendUnique(hazardToPatterns[hz.ID], m.PatternID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return hazardToPatterns
|
||||
}
|
||||
|
||||
func normalizeKey(s string) string {
|
||||
s = iace.NormalizeDEPublic(s)
|
||||
out := []rune{}
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case ',', '/', '(', ')', '-', '.', ':', ';':
|
||||
out = append(out, ' ')
|
||||
default:
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func appendUnique(slice []string, s string) []string {
|
||||
for _, x := range slice {
|
||||
if x == s {
|
||||
return slice
|
||||
}
|
||||
}
|
||||
return append(slice, s)
|
||||
}
|
||||
|
||||
// ListClarifications handles GET /projects/:id/clarifications.
|
||||
// Returns the aggregated clarification list with affected-hazard cross-refs
|
||||
// and the persisted answer state.
|
||||
//
|
||||
// Phase 3 storage model: answers live in the iace_clarifications table
|
||||
// when migration 028 has been applied. The JSONB fallback in
|
||||
// project.metadata.clarification_answers is still read so projects that
|
||||
// were answered before the migration keep their state until the one-shot
|
||||
// upcopy runs.
|
||||
func (h *IACEHandler) ListClarifications(c *gin.Context) {
|
||||
projectID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
project, err := h.store.GetProject(ctx, projectID)
|
||||
if err != nil || project == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||
return
|
||||
}
|
||||
hazards, _ := h.store.ListHazards(ctx, projectID)
|
||||
|
||||
// Primary: relational answers
|
||||
answers := map[string]iace.ClarificationAnswer{}
|
||||
if rows, rerr := h.store.ListClarificationsForProject(ctx, projectID); rerr == nil {
|
||||
for _, r := range rows {
|
||||
answeredAt := ""
|
||||
if r.AnsweredAt != nil {
|
||||
answeredAt = r.AnsweredAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
answers[r.ClarificationKey] = iace.ClarificationAnswer{
|
||||
Status: r.Status,
|
||||
Answer: r.Answer,
|
||||
Reasoning: r.Reasoning,
|
||||
AnsweredBy: r.AnsweredBy,
|
||||
AnsweredAt: answeredAt,
|
||||
AssignedTo: r.AssignedTo,
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: JSONB legacy answers (keep until one-shot upcopy is done)
|
||||
if legacy, _ := readClarificationAnswers(project.Metadata); len(legacy) > 0 {
|
||||
for k, v := range legacy {
|
||||
if _, ok := answers[k]; !ok {
|
||||
answers[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
narrative := extractNarrativeFromMetadata(project.Metadata)
|
||||
hazardToPatterns := h.reconstructHazardPatterns(narrative, project.MachineType, hazards)
|
||||
manufHits := iace.LookupManufacturerFeaturesInText(narrative)
|
||||
|
||||
clarifications := iace.BuildProjectClarifications(hazards, hazardToPatterns, manufHits, answers)
|
||||
sort.Slice(clarifications, func(i, j int) bool {
|
||||
// Open first, then answered. Within a status, group by category, then by source.
|
||||
if clarifications[i].Status != clarifications[j].Status {
|
||||
return clarifications[i].Status == "open"
|
||||
}
|
||||
if clarifications[i].Category != clarifications[j].Category {
|
||||
return clarifications[i].Category < clarifications[j].Category
|
||||
}
|
||||
return clarifications[i].Source < clarifications[j].Source
|
||||
})
|
||||
|
||||
openCount, answeredCount := 0, 0
|
||||
for _, cl := range clarifications {
|
||||
switch cl.Status {
|
||||
case "answered", "not_relevant":
|
||||
answeredCount++
|
||||
default:
|
||||
openCount++
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"clarifications": clarifications,
|
||||
"open_count": openCount,
|
||||
"answered_count": answeredCount,
|
||||
"total": len(clarifications),
|
||||
})
|
||||
}
|
||||
|
||||
// AnswerClarificationRequest is the request body for POST .../answer.
|
||||
type AnswerClarificationRequest struct {
|
||||
Status string `json:"status"` // open | in_progress | answered | not_relevant
|
||||
Answer string `json:"answer"` // ja | nein | teilweise
|
||||
Reasoning string `json:"reasoning"`
|
||||
AnsweredBy string `json:"answered_by"`
|
||||
AssignedTo string `json:"assigned_to"`
|
||||
// Snapshot fields written into the new table on first contact so the
|
||||
// audit trail does not break if the pattern library changes later.
|
||||
Question string `json:"question,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
NormReferences []string `json:"norm_references,omitempty"`
|
||||
}
|
||||
|
||||
// AnswerClarification handles POST /projects/:id/clarifications/:cid/answer.
|
||||
// Upserts the answer in iace_clarifications (Phase 3). Old JSONB answers
|
||||
// remain readable but are no longer written.
|
||||
func (h *IACEHandler) AnswerClarification(c *gin.Context) {
|
||||
projectID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||
return
|
||||
}
|
||||
cid := c.Param("cid")
|
||||
if cid == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing clarification id"})
|
||||
return
|
||||
}
|
||||
var req AnswerClarificationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Status == "" {
|
||||
if req.Answer != "" {
|
||||
req.Status = "answered"
|
||||
} else {
|
||||
req.Status = "open"
|
||||
}
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
project, err := h.store.GetProject(ctx, projectID)
|
||||
if err != nil || project == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||
return
|
||||
}
|
||||
tenantID, terr := getTenantID(c)
|
||||
if terr != nil {
|
||||
tenantID = project.TenantID
|
||||
}
|
||||
|
||||
// If the client didn't supply snapshot fields, fall back to whatever
|
||||
// the engine currently produces for this clarification id.
|
||||
if req.Question == "" || req.Source == "" {
|
||||
if prev, _ := h.store.GetClarificationByKey(ctx, projectID, cid); prev != nil {
|
||||
if req.Question == "" {
|
||||
req.Question = prev.Question
|
||||
}
|
||||
if req.Source == "" {
|
||||
req.Source = prev.Source
|
||||
}
|
||||
if req.Category == "" {
|
||||
req.Category = prev.Category
|
||||
}
|
||||
if len(req.NormReferences) == 0 {
|
||||
req.NormReferences = prev.NormReferences
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
answeredAt := &now
|
||||
if req.Status != "answered" && req.Status != "not_relevant" {
|
||||
answeredAt = nil
|
||||
}
|
||||
in := iace.ClarificationRow{
|
||||
TenantID: tenantID,
|
||||
ProjectID: projectID,
|
||||
ClarificationKey: cid,
|
||||
Question: req.Question,
|
||||
Source: req.Source,
|
||||
Category: req.Category,
|
||||
NormReferences: req.NormReferences,
|
||||
Status: req.Status,
|
||||
Answer: req.Answer,
|
||||
Reasoning: req.Reasoning,
|
||||
AssignedTo: req.AssignedTo,
|
||||
AnsweredBy: req.AnsweredBy,
|
||||
AnsweredAt: answeredAt,
|
||||
}
|
||||
row, err := h.store.UpsertClarification(ctx, in)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"clarification_id": cid,
|
||||
"row": row,
|
||||
})
|
||||
}
|
||||
|
||||
// CommentRequest is the body for POST .../comment.
|
||||
type CommentRequest struct {
|
||||
Author string `json:"author"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// PostClarificationComment handles POST /projects/:id/clarifications/:cid/comment.
|
||||
func (h *IACEHandler) PostClarificationComment(c *gin.Context) {
|
||||
projectID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||
return
|
||||
}
|
||||
cid := c.Param("cid")
|
||||
var req CommentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Body == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "body required"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
row, err := h.store.GetClarificationByKey(ctx, projectID, cid)
|
||||
if err != nil || row == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "clarification not found — answer/assign it first to create the row"})
|
||||
return
|
||||
}
|
||||
comment, err := h.store.AddClarificationComment(ctx, row.ID, req.Author, req.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"comment": comment})
|
||||
}
|
||||
|
||||
// ListClarificationDetail handles GET /projects/:id/clarifications/:cid/detail
|
||||
// and returns comments + history for one clarification.
|
||||
func (h *IACEHandler) ListClarificationDetail(c *gin.Context) {
|
||||
projectID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||
return
|
||||
}
|
||||
cid := c.Param("cid")
|
||||
ctx := c.Request.Context()
|
||||
row, _ := h.store.GetClarificationByKey(ctx, projectID, cid)
|
||||
if row == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"row": nil, "comments": []any{}, "history": []any{}})
|
||||
return
|
||||
}
|
||||
comments, _ := h.store.ListClarificationComments(ctx, row.ID)
|
||||
history, _ := h.store.ListClarificationHistory(ctx, row.ID)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"row": row,
|
||||
"comments": comments,
|
||||
"history": history,
|
||||
})
|
||||
_ = json.RawMessage{} // keep encoding/json import in case of future fields
|
||||
}
|
||||
|
||||
// ExportClarificationsCSV handles GET /projects/:id/clarifications.csv.
|
||||
// Returns the aggregated clarifications as a CSV for handover to the
|
||||
// Anlagenbauer — one row per question with all referenced hazards and
|
||||
// the current answer state.
|
||||
func (h *IACEHandler) ExportClarificationsCSV(c *gin.Context) {
|
||||
projectID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
project, err := h.store.GetProject(ctx, projectID)
|
||||
if err != nil || project == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||
return
|
||||
}
|
||||
hazards, _ := h.store.ListHazards(ctx, projectID)
|
||||
answers, _ := readClarificationAnswers(project.Metadata)
|
||||
narrative := extractNarrativeFromMetadata(project.Metadata)
|
||||
hazardToPatterns := h.reconstructHazardPatterns(narrative, project.MachineType, hazards)
|
||||
manufHits := iace.LookupManufacturerFeaturesInText(narrative)
|
||||
clarifications := iace.BuildProjectClarifications(hazards, hazardToPatterns, manufHits, answers)
|
||||
|
||||
sort.Slice(clarifications, func(i, j int) bool {
|
||||
if clarifications[i].Status != clarifications[j].Status {
|
||||
return clarifications[i].Status == "open"
|
||||
}
|
||||
return clarifications[i].Source < clarifications[j].Source
|
||||
})
|
||||
|
||||
filename := fmt.Sprintf("klaerungen_%s_%s.csv", project.MachineName, time.Now().Format("2006-01-02"))
|
||||
filename = strings.ReplaceAll(filename, " ", "_")
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", `attachment; filename="`+filename+`"`)
|
||||
// Excel-Erkennung: UTF-8 BOM voranstellen
|
||||
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||
w := csv.NewWriter(c.Writer)
|
||||
w.Comma = ';'
|
||||
_ = w.Write([]string{
|
||||
"ID", "Quelle", "Kategorie", "Frage", "Status", "Antwort", "Begruendung",
|
||||
"Bearbeiter", "Beantwortet_am", "Anzahl_Gefaehrdungen", "Gefaehrdungen", "Norm_Referenzen",
|
||||
})
|
||||
for _, cl := range clarifications {
|
||||
_ = w.Write([]string{
|
||||
cl.ID,
|
||||
cl.Source,
|
||||
cl.Category,
|
||||
cl.Question,
|
||||
cl.Status,
|
||||
cl.Answer,
|
||||
cl.Reasoning,
|
||||
cl.AnsweredBy,
|
||||
cl.AnsweredAt,
|
||||
fmt.Sprintf("%d", len(cl.AffectedHazardIDs)),
|
||||
strings.Join(cl.AffectedHazardNames, " | "),
|
||||
strings.Join(cl.NormReferences, " | "),
|
||||
})
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
// methodologyBlock returns the standardised methodology paragraph that
|
||||
// must be printed at the start of every IACE risk-assessment report.
|
||||
// Pure references to norm identifiers (no norm text) — kept here so
|
||||
// the same wording appears in every export.
|
||||
const methodologyBlock = `<section style="background:#f9fafb;border:1px solid #d1d5db;border-radius:6px;padding:12px;margin-bottom:18px;">
|
||||
<h3 style="font-size:11pt;margin:0 0 6px 0;">Methodik der Risikobeurteilung</h3>
|
||||
<ul style="margin:0;padding-left:18px;font-size:9.5pt;line-height:1.45;">
|
||||
<li>Gefaehrdungsidentifikation nach <strong>EN ISO 12100</strong>, Anhang B (Tabelle B.1) — Mechanik, Elektrik, Thermik, Laerm, Vibration, Strahlung, Materialien/Substanzen, Ergonomie.</li>
|
||||
<li>Bestimmung des erforderlichen Performance Levels (PLr) nach <strong>EN ISO 13849-1</strong>, Anhang A (Risikograph) aus S (Schwere), F (Haeufigkeit/Dauer) und P (Vermeidungsmoeglichkeit).</li>
|
||||
<li>Massnahmen-Hierarchie nach ISO 12100, Abschnitt 6: <strong>6.2 Inhaerent sichere Konstruktion</strong> (Design) → <strong>6.3 Technische Schutzmassnahmen</strong> (Protection) → <strong>6.4 Benutzerinformation</strong> (Information).</li>
|
||||
<li>Klaerungspunkte mit dem Anlagenbauer werden separat in der Klaerungs-Liste verwaltet (Audit-Trail mit Bearbeiter und Zeitstempel).</li>
|
||||
</ul>
|
||||
</section>`
|
||||
|
||||
// ExportClarificationsHTML handles GET /projects/:id/clarifications.html
|
||||
// and returns a print-friendly standalone HTML document that the browser
|
||||
// can render to PDF (no server-side PDF dependency needed). The Bediener
|
||||
// opens the link, hits Cmd-P / Strg-P and saves as PDF.
|
||||
func (h *IACEHandler) ExportClarificationsHTML(c *gin.Context) {
|
||||
projectID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
project, err := h.store.GetProject(ctx, projectID)
|
||||
if err != nil || project == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||
return
|
||||
}
|
||||
hazards, _ := h.store.ListHazards(ctx, projectID)
|
||||
answers := map[string]iace.ClarificationAnswer{}
|
||||
if rows, _ := h.store.ListClarificationsForProject(ctx, projectID); rows != nil {
|
||||
for _, r := range rows {
|
||||
at := ""
|
||||
if r.AnsweredAt != nil {
|
||||
at = r.AnsweredAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
answers[r.ClarificationKey] = iace.ClarificationAnswer{
|
||||
Status: r.Status, Answer: r.Answer, Reasoning: r.Reasoning,
|
||||
AnsweredBy: r.AnsweredBy, AnsweredAt: at,
|
||||
}
|
||||
}
|
||||
}
|
||||
if legacy, _ := readClarificationAnswers(project.Metadata); len(legacy) > 0 {
|
||||
for k, v := range legacy {
|
||||
if _, ok := answers[k]; !ok {
|
||||
answers[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
narrative := extractNarrativeFromMetadata(project.Metadata)
|
||||
hazardToPatterns := h.reconstructHazardPatterns(narrative, project.MachineType, hazards)
|
||||
manufHits := iace.LookupManufacturerFeaturesInText(narrative)
|
||||
cls := iace.BuildProjectClarifications(hazards, hazardToPatterns, manufHits, answers)
|
||||
sort.Slice(cls, func(i, j int) bool {
|
||||
if cls[i].Status != cls[j].Status {
|
||||
return cls[i].Status == "open"
|
||||
}
|
||||
return cls[i].Source < cls[j].Source
|
||||
})
|
||||
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
w := c.Writer
|
||||
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
||||
<title>Klaerungen — %s</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 18mm 15mm; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; font-size: 10pt; color: #111; line-height: 1.4; }
|
||||
h1 { font-size: 16pt; margin: 0 0 4px 0; }
|
||||
.sub { font-size: 9pt; color: #555; margin-bottom: 16px; }
|
||||
.meta { font-size: 9pt; color: #444; margin-bottom: 12px; }
|
||||
.bar { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 8pt; margin-right: 4px; }
|
||||
.open { background: #fed7aa; color: #7c2d12; }
|
||||
.done { background: #bbf7d0; color: #14532d; }
|
||||
.gray { background: #e5e7eb; color: #374151; }
|
||||
section { page-break-inside: avoid; margin-bottom: 14px; border: 1px solid #d1d5db; border-radius: 6px; padding: 10px 12px; }
|
||||
section h2 { font-size: 10pt; margin: 0 0 2px 0; }
|
||||
section .src { font-size: 8pt; color: #6b7280; margin-bottom: 6px; }
|
||||
.q { font-weight: 600; font-size: 10.5pt; margin: 4px 0; }
|
||||
.norm { font-size: 8pt; color: #555; }
|
||||
.affected { font-size: 8pt; color: #555; margin: 4px 0; }
|
||||
.answer { background: #ecfdf5; border: 1px solid #a7f3d0; padding: 6px 8px; border-radius: 4px; font-size: 9pt; margin-top: 6px; }
|
||||
.signrow { margin-top: 30px; display: flex; gap: 40px; }
|
||||
.signrow div { flex: 1; border-top: 1px solid #6b7280; padding-top: 4px; font-size: 8pt; color: #6b7280; }
|
||||
@media print { .noprint { display: none; } }
|
||||
.noprint { background: #fef9c3; border: 1px solid #fde047; padding: 6px 10px; border-radius: 4px; margin-bottom: 12px; font-size: 9pt; }
|
||||
</style></head><body>
|
||||
<div class="noprint">Tipp: Mit <kbd>Strg+P</kbd> / <kbd>Cmd+P</kbd> als PDF speichern.</div>
|
||||
<h1>Klaerungsliste — %s</h1>
|
||||
<div class="sub">Projekt-ID %s · Stand %s</div>
|
||||
` + methodologyBlock + `
|
||||
<div class="meta">
|
||||
<span class="bar open">%d offen</span>
|
||||
<span class="bar done">%d beantwortet</span>
|
||||
<span class="bar gray">%d gesamt</span>
|
||||
</div>
|
||||
`,
|
||||
htmlEscape(project.MachineName),
|
||||
htmlEscape(project.MachineName),
|
||||
project.ID.String(),
|
||||
time.Now().Format("2006-01-02 15:04"),
|
||||
countByStatus(cls, false), countByStatus(cls, true), len(cls),
|
||||
)
|
||||
for _, cl := range cls {
|
||||
statusCls := "open"
|
||||
statusLabel := "Offen"
|
||||
if cl.Status == "answered" {
|
||||
statusCls, statusLabel = "done", "Beantwortet"
|
||||
} else if cl.Status == "not_relevant" {
|
||||
statusCls, statusLabel = "gray", "Nicht relevant"
|
||||
} else if cl.Status == "in_progress" {
|
||||
statusCls, statusLabel = "open", "In Klaerung"
|
||||
}
|
||||
fmt.Fprintf(w, `<section><div class="src">%s · <span class="bar %s">%s</span></div>
|
||||
<h2>%s</h2>
|
||||
`,
|
||||
htmlEscape(cl.Source), statusCls, statusLabel,
|
||||
htmlEscape(cl.Question),
|
||||
)
|
||||
if len(cl.NormReferences) > 0 {
|
||||
fmt.Fprintf(w, `<div class="norm">Normen: %s</div>`, htmlEscape(strings.Join(cl.NormReferences, " | ")))
|
||||
}
|
||||
if len(cl.AffectedHazardNames) > 0 {
|
||||
fmt.Fprintf(w, `<div class="affected">Betrifft %d Gefaehrdung(en): %s</div>`,
|
||||
len(cl.AffectedHazardIDs),
|
||||
htmlEscape(strings.Join(cl.AffectedHazardNames, "; ")),
|
||||
)
|
||||
}
|
||||
if cl.Status == "answered" || cl.Status == "not_relevant" {
|
||||
fmt.Fprintf(w, `<div class="answer"><strong>Antwort (%s):</strong> %s`,
|
||||
htmlEscape(cl.Answer),
|
||||
htmlEscape(cl.Reasoning),
|
||||
)
|
||||
if cl.AnsweredBy != "" {
|
||||
ts := cl.AnsweredAt
|
||||
if len(ts) > 10 {
|
||||
ts = ts[:10]
|
||||
}
|
||||
fmt.Fprintf(w, ` <em>— %s, %s</em>`, htmlEscape(cl.AnsweredBy), htmlEscape(ts))
|
||||
}
|
||||
fmt.Fprintf(w, `</div>`)
|
||||
}
|
||||
fmt.Fprintf(w, `</section>`)
|
||||
}
|
||||
fmt.Fprintf(w, `<div class="signrow"><div>Anlagenbauer · Datum · Unterschrift</div><div>Bediener · Datum · Unterschrift</div></div>`)
|
||||
fmt.Fprintf(w, `</body></html>`)
|
||||
}
|
||||
|
||||
func htmlEscape(s string) string {
|
||||
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """, `'`, "'")
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
func countByStatus(cls []iace.Clarification, answered bool) int {
|
||||
n := 0
|
||||
for _, c := range cls {
|
||||
isDone := c.Status == "answered" || c.Status == "not_relevant"
|
||||
if isDone == answered {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ListCustomerStandardSuggestions handles
|
||||
// GET /api/v1/iace/projects/:id/customer-standards?include_verified=true|false
|
||||
//
|
||||
// Returns the set of reusable mitigations from prior projects of the same
|
||||
// customer. Empty array when the project has no customer_name or no
|
||||
// matching priors. The include_verified query flag controls whether
|
||||
// status='verified' mitigations are included alongside the explicit
|
||||
// is_customer_standard=true ones.
|
||||
func (h *IACEHandler) ListCustomerStandardSuggestions(c *gin.Context) {
|
||||
pid, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||
return
|
||||
}
|
||||
includeVerified := c.Query("include_verified") == "true"
|
||||
suggestions, err := h.store.ListCustomerStandardSuggestions(c.Request.Context(), pid, includeVerified)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if suggestions == nil {
|
||||
suggestions = []iace.CustomerStandardSuggestion{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"suggestions": suggestions,
|
||||
"count": len(suggestions),
|
||||
})
|
||||
}
|
||||
|
||||
// ImportCustomerStandardSuggestion handles
|
||||
// POST /api/v1/iace/projects/:id/customer-standards/import
|
||||
// Body: { "name": "Sicherheitszeichen nach ISO 7010" }
|
||||
//
|
||||
// Applies one suggestion to all matching hazards in the current project.
|
||||
// New mitigations are created idempotently; existing ones are flipped to
|
||||
// is_relevant=true + is_customer_standard=true + status='verified'.
|
||||
func (h *IACEHandler) ImportCustomerStandardSuggestion(c *gin.Context) {
|
||||
pid, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
n, err := h.store.ImportCustomerStandardSuggestion(c.Request.Context(), pid, body.Name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"imported": n,
|
||||
"name": body.Name,
|
||||
})
|
||||
}
|
||||
@@ -212,11 +212,40 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
|
||||
// Join all applicable lifecycles as comma-separated string
|
||||
lifecycleStr := strings.Join(mp.ApplicableLifecycles, ",")
|
||||
|
||||
// Phase 2: clarification questions are no longer embedded
|
||||
// in the hazard description — they live in the dedicated
|
||||
// /clarifications API and the UI loads them on demand.
|
||||
// The hazard description stays clean and focused on the
|
||||
// scenario itself. Only the aggregated norm-references
|
||||
// block is appended below for an at-a-glance audit trail.
|
||||
desc := mp.ScenarioDE
|
||||
// Phase 17: PLr per EN ISO 13849-1 Anhang A. The graph
|
||||
// inputs come from the pattern's DefaultSeverity/Exposure
|
||||
// (mapped to S1/S2 and F1/F2 at threshold 3) plus
|
||||
// DefaultAvoidability (P1/P2). If avoidability is unset
|
||||
// we default to P1 — the conservative direction is
|
||||
// downward (lower PLr), the operator can raise it
|
||||
// manually after expert review.
|
||||
avoid := 1
|
||||
if mp.DefaultAvoidability == 2 {
|
||||
avoid = 2
|
||||
}
|
||||
if mp.DefaultSeverity > 0 && mp.DefaultExposure > 0 {
|
||||
sBin := iace.SeverityToS(mp.DefaultSeverity)
|
||||
fBin := iace.ExposureToF(mp.DefaultExposure)
|
||||
plr := iace.ComputePLr(sBin, fBin, avoid)
|
||||
desc += fmt.Sprintf("\n\nRisikograph EN ISO 13849-1 (Anhang A): S%d · F%d · P%d → PLr %s",
|
||||
sBin, fBin, avoid, plr)
|
||||
}
|
||||
if mp.ISO12100Section != "" {
|
||||
desc += "\n\nKlassifikation: EN ISO 12100 Anhang B, Abschnitt " + mp.ISO12100Section
|
||||
}
|
||||
|
||||
hz, cerr := h.store.CreateHazard(ctx, iace.CreateHazardRequest{
|
||||
ProjectID: projectID,
|
||||
ComponentID: compID,
|
||||
Name: name,
|
||||
Description: mp.ScenarioDE,
|
||||
Description: desc,
|
||||
Category: cat,
|
||||
Scenario: mp.ScenarioDE,
|
||||
Function: iace.EncodeOpStates(mp.OperationalStates),
|
||||
@@ -273,16 +302,29 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// For each hazard: assign up to maxMitigationsPerHazard measures
|
||||
// Priority 1: Pattern-specific SuggestedMeasureIDs (from the pattern that created this hazard)
|
||||
// Priority 2: Category fallback (generic measures for the hazard category)
|
||||
// For each hazard: only pattern-specific SuggestedMeasureIDs are
|
||||
// used, FILTERED by category. Measures whose HazardCategory is
|
||||
// incompatible with the pattern's accepted set are skipped with a
|
||||
// MEASURE-SKIP log entry. There is NO category fallback any more —
|
||||
// if the pattern author left a hazard without applicable measures,
|
||||
// the hazard is created with zero mitigations and the operator must
|
||||
// consult an expert. This is the only honest answer: silently
|
||||
// inventing generic defaults (the previous behavior) produced
|
||||
// nonsense like "Rotationsbewegung vermeiden" for a sharp-edge
|
||||
// hazard. See feat/iace-measure-category-filter for context.
|
||||
_ = measuresByCat // retained for backwards-compat read by other code paths
|
||||
_ = patternCatToMeasureCat
|
||||
zeroMitigationHazards := 0
|
||||
for _, hazID := range allHazardIDs {
|
||||
hazCat := hazardCatByID[hazID]
|
||||
measCat := patternCatToMeasureCat(hazCat)
|
||||
accepted := acceptableMeasureCategories(hazCat)
|
||||
added := 0
|
||||
usedIDs := make(map[string]bool)
|
||||
// Aggregate norm references across all kept mitigations for this
|
||||
// hazard so we can attach a single "Referenzierte Normen" line
|
||||
// to the hazard description below.
|
||||
var hazardNorms []string
|
||||
seenNorm := map[string]bool{}
|
||||
|
||||
// Priority 1: Pattern-specific measures
|
||||
if patternMIDs, ok := hazardPatternMeasures[hazID]; ok {
|
||||
for _, mid := range patternMIDs {
|
||||
if added >= maxMitigationsPerHazard {
|
||||
@@ -292,44 +334,61 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !isCategoryCompatible(entry.HazardCategory, accepted) {
|
||||
fmt.Printf("MEASURE-SKIP: pattern-cat=%s acceptable=%v but mid=%s has cat=%s (%q) — skipping mismatch\n",
|
||||
hazCat, keysOf(accepted), mid, entry.HazardCategory, entry.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
rt := iace.ReductionType(entry.ReductionType)
|
||||
if rt == "" {
|
||||
rt = iace.ReductionTypeInformation
|
||||
}
|
||||
mitDesc := entry.Description
|
||||
if len(entry.NormReferences) > 0 {
|
||||
mitDesc += "\n\nNormen: " + strings.Join(entry.NormReferences, " | ")
|
||||
for _, n := range entry.NormReferences {
|
||||
if !seenNorm[n] {
|
||||
seenNorm[n] = true
|
||||
hazardNorms = append(hazardNorms, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
_, cerr := h.store.CreateMitigation(ctx, iace.CreateMitigationRequest{
|
||||
HazardID: hazID, ReductionType: rt,
|
||||
Name: entry.Name, Description: entry.Description,
|
||||
Name: entry.Name, Description: mitDesc,
|
||||
})
|
||||
if cerr != nil {
|
||||
fmt.Printf("MEASURE-ERROR: mid=%s name=%s err=%v\n", mid, entry.Name, cerr)
|
||||
} else {
|
||||
created++
|
||||
added++
|
||||
usedIDs[mid] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Append the aggregated norm list to the hazard so the UI shows
|
||||
// a single "Referenzierte Normen" panel per hazard.
|
||||
if len(hazardNorms) > 0 {
|
||||
if existing, getErr := h.store.GetHazard(ctx, hazID); getErr == nil && existing != nil {
|
||||
if !strings.Contains(existing.Description, "Referenzierte Normen:") {
|
||||
newDesc := existing.Description + "\n\nReferenzierte Normen: " + strings.Join(hazardNorms, " | ")
|
||||
_, _ = h.store.UpdateHazard(ctx, hazID, map[string]interface{}{
|
||||
"description": newDesc,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Category fallback (skip already-used IDs)
|
||||
for _, m := range measuresByCat[measCat] {
|
||||
if added >= maxMitigationsPerHazard || usedIDs[m.ID] {
|
||||
continue
|
||||
}
|
||||
rt := iace.ReductionType(m.ReductionType)
|
||||
if rt == "" {
|
||||
rt = iace.ReductionTypeInformation
|
||||
}
|
||||
_, cerr := h.store.CreateMitigation(ctx, iace.CreateMitigationRequest{
|
||||
HazardID: hazID, ReductionType: rt,
|
||||
Name: m.Name, Description: m.Description,
|
||||
})
|
||||
if cerr == nil {
|
||||
created++
|
||||
added++
|
||||
}
|
||||
if added == 0 {
|
||||
zeroMitigationHazards++
|
||||
fmt.Printf("COVERAGE-GAP: hazard %s (cat=%s) has no pattern-specific measures — operator must consult expert\n",
|
||||
hazID, hazCat)
|
||||
}
|
||||
}
|
||||
if zeroMitigationHazards > 0 {
|
||||
fmt.Printf("COVERAGE-GAP-SUMMARY: %d/%d hazards in this project have no mitigations and need expert review\n",
|
||||
zeroMitigationHazards, len(allHazardIDs))
|
||||
}
|
||||
patternMeasureCount := 0
|
||||
for _, mids := range hazardPatternMeasures {
|
||||
patternMeasureCount += len(mids)
|
||||
|
||||
@@ -45,6 +45,108 @@ func extractNarrativeFromMetadata(metadata json.RawMessage) string {
|
||||
return result
|
||||
}
|
||||
|
||||
// acceptableMeasureCategories returns the set of measure HazardCategory values
|
||||
// that are semantically applicable to a hazard with the given pattern category.
|
||||
// The mapping is a *set*, not a single value — many pattern categories accept
|
||||
// measures from several measure-library categories that are conceptually
|
||||
// related. E.g. a safety_function_failure hazard is sensibly mitigated by
|
||||
// software_control measures like watchdogs, plausibility checks or self-tests,
|
||||
// not just by the (almost empty) safety_function category.
|
||||
//
|
||||
// "general" is implicit — handled in isCategoryCompatible and not duplicated
|
||||
// in every set below.
|
||||
func acceptableMeasureCategories(patternCat string) map[string]bool {
|
||||
sets := map[string][]string{
|
||||
"mechanical_hazard": {"mechanical"},
|
||||
"electrical_hazard": {"electrical"},
|
||||
"thermal_hazard": {"thermal", "material_environmental"},
|
||||
// ISO 12100 Anhang B splits Nr. 4 Laerm and Nr. 5 Vibration into
|
||||
// two top-level groups. The legacy combined alias noise_vibration
|
||||
// is kept for backwards compat — all three resolve to the same
|
||||
// measure pool today (the library doesn't separate noise vs
|
||||
// vibration measures), but the pattern category now matches the
|
||||
// norm structure.
|
||||
"noise_hazard": {"noise_vibration", "ergonomic"},
|
||||
"vibration_hazard": {"noise_vibration", "ergonomic"},
|
||||
"noise_vibration": {"noise_vibration", "ergonomic"},
|
||||
"pneumatic_hydraulic": {"pneumatic_hydraulic", "mechanical"},
|
||||
"material_environmental": {"material_environmental"},
|
||||
"chemical_risk": {"material_environmental", "thermal"},
|
||||
"ergonomic": {"ergonomic"},
|
||||
"ergonomic_hazard": {"ergonomic"},
|
||||
"fire_explosion": {"thermal", "material_environmental"},
|
||||
"radiation_hazard": {"material_environmental"},
|
||||
"emc_hazard": {"electrical", "software_control"},
|
||||
"maintenance_hazard": {"mechanical"},
|
||||
"safety_function_failure": {"safety_function", "software_control"},
|
||||
"software_fault": {"software_control"},
|
||||
"sensor_fault": {"software_control"},
|
||||
"configuration_error": {"software_control"},
|
||||
"update_failure": {"software_control"},
|
||||
"hmi_error": {"software_control"},
|
||||
"mode_confusion": {"software_control"},
|
||||
"unauthorized_access": {"cyber_network", "software_control"},
|
||||
"communication_failure": {"cyber_network", "software_control"},
|
||||
"firmware_corruption": {"cyber_network", "software_control"},
|
||||
"logging_audit_failure": {"cyber_network", "software_control"},
|
||||
"ai_misclassification": {"ai_specific", "software_control"},
|
||||
"false_classification": {"ai_specific", "software_control"},
|
||||
"model_drift": {"ai_specific", "software_control"},
|
||||
"data_poisoning": {"ai_specific", "software_control"},
|
||||
"sensor_spoofing": {"ai_specific", "software_control"},
|
||||
"unintended_bias": {"ai_specific", "software_control"},
|
||||
// CRA / DIN EN 40000-1-2 cyber-resilience patterns (HP1910+).
|
||||
// cyber_resilience is the umbrella category used by patterns that
|
||||
// fire on the manufacturer-side obligations: SBOM, signed updates,
|
||||
// CVD policy, patch-SLA, hardening docs, incident notification.
|
||||
// Accept measures from the dedicated cyber_resilience pool plus the
|
||||
// broader cyber_network and software_control pools (existing
|
||||
// measures like "intrusion detection" or "audit logging" are
|
||||
// applicable here too).
|
||||
"cyber_resilience": {"cyber_resilience", "cyber_network", "software_control"},
|
||||
// Edge-case pattern categories from legacy authors. Treated as
|
||||
// synonyms of their primary hazard category so existing patterns
|
||||
// keep matching the right measure pool.
|
||||
"noise_source": {"noise_vibration", "ergonomic"},
|
||||
"vibration_source": {"noise_vibration", "ergonomic"},
|
||||
"high_temperature": {"thermal", "material_environmental"},
|
||||
"material_environmental_hazard": {"material_environmental"},
|
||||
}
|
||||
out := map[string]bool{"general": true}
|
||||
if list, ok := sets[patternCat]; ok {
|
||||
for _, c := range list {
|
||||
out[c] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isCategoryCompatible reports whether a measure with HazardCategory measureCat
|
||||
// is semantically applicable to a hazard whose acceptable measure categories
|
||||
// are listed in accepted. Empty measureCat is always allowed (legacy entries),
|
||||
// "general" measures are pre-seeded into accepted by acceptableMeasureCategories.
|
||||
//
|
||||
// Without this guard, patterns silently inherit nonsense mitigations (e.g.
|
||||
// HP1651 "robot restart while person in cell" inheriting M054 "Sichere
|
||||
// thermische Auslegung" — a thermal-design measure used as generic default in
|
||||
// ~100 mechanical patterns). The Fachmann benchmark rejects such mismatches.
|
||||
func isCategoryCompatible(measureCat string, accepted map[string]bool) bool {
|
||||
if measureCat == "" {
|
||||
return true
|
||||
}
|
||||
return accepted[measureCat]
|
||||
}
|
||||
|
||||
// keysOf returns the sorted keys of a string-bool set, used for diagnostic
|
||||
// log messages that report which measure categories were accepted for a hazard.
|
||||
func keysOf(s map[string]bool) []string {
|
||||
out := make([]string, 0, len(s))
|
||||
for k := range s {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// patternCatToMeasureCat maps pattern hazard categories to measure categories.
|
||||
func patternCatToMeasureCat(patternCat string) string {
|
||||
m := map[string]string{
|
||||
@@ -63,6 +165,7 @@ func patternCatToMeasureCat(patternCat string) string {
|
||||
"update_failure": "software_control", "hmi_error": "software_control",
|
||||
"emc_hazard": "electrical", "maintenance_hazard": "mechanical",
|
||||
"mode_confusion": "software_control", "chemical_risk": "material_environmental",
|
||||
"cyber_resilience": "cyber_resilience",
|
||||
}
|
||||
if cat, ok := m[patternCat]; ok {
|
||||
return cat
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package handlers
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestAcceptableMeasureCategories pins the set-based category acceptance map.
|
||||
// Each pattern category accepts not just its own measure category but a
|
||||
// curated set of semantically related ones — a safety_function_failure
|
||||
// pattern is sensibly mitigated by software_control measures (watchdogs,
|
||||
// plausibility checks), not just by the near-empty safety_function category.
|
||||
func TestAcceptableMeasureCategories(t *testing.T) {
|
||||
cases := []struct {
|
||||
patternCat string
|
||||
mustAccept []string // measure categories that MUST be accepted
|
||||
mustReject []string // measure categories that MUST be rejected
|
||||
}{
|
||||
// mechanical hazards: own + general only
|
||||
{"mechanical_hazard", []string{"mechanical", "general"}, []string{"thermal", "electrical"}},
|
||||
// electrical hazards: own + general only
|
||||
{"electrical_hazard", []string{"electrical", "general"}, []string{"thermal", "mechanical"}},
|
||||
// safety-function failures accept watchdogs (software_control)
|
||||
{"safety_function_failure", []string{"safety_function", "software_control", "general"}, []string{"mechanical", "thermal"}},
|
||||
// EMC accepts electrical + software (shielding + filter logic both apply)
|
||||
{"emc_hazard", []string{"electrical", "software_control", "general"}, []string{"mechanical"}},
|
||||
// AI failures accept ai_specific + software_control
|
||||
{"false_classification", []string{"ai_specific", "software_control", "general"}, []string{"mechanical", "electrical"}},
|
||||
// Fire/explosion accepts thermal + material_environmental
|
||||
{"fire_explosion", []string{"thermal", "material_environmental", "general"}, []string{"mechanical", "electrical"}},
|
||||
// Unknown pattern category: only general
|
||||
{"unknown_made_up_cat", []string{"general"}, []string{"mechanical", "electrical"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
accepted := acceptableMeasureCategories(c.patternCat)
|
||||
for _, mc := range c.mustAccept {
|
||||
if !isCategoryCompatible(mc, accepted) {
|
||||
t.Errorf("patternCat=%q must accept measureCat=%q but rejected (set=%v)",
|
||||
c.patternCat, mc, accepted)
|
||||
}
|
||||
}
|
||||
for _, mc := range c.mustReject {
|
||||
if isCategoryCompatible(mc, accepted) {
|
||||
t.Errorf("patternCat=%q must reject measureCat=%q but accepted (set=%v)",
|
||||
c.patternCat, mc, accepted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsCategoryCompatible_EmptyMeasureCat pins that legacy measures with
|
||||
// no HazardCategory set are always allowed — they would otherwise silently
|
||||
// disappear during the re-init, since the audit found ~80 such entries in
|
||||
// older library files.
|
||||
func TestIsCategoryCompatible_EmptyMeasureCat(t *testing.T) {
|
||||
accepted := acceptableMeasureCategories("mechanical_hazard")
|
||||
if !isCategoryCompatible("", accepted) {
|
||||
t.Error("empty measure category must be accepted (legacy entries)")
|
||||
}
|
||||
}
|
||||
@@ -451,6 +451,19 @@ func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) {
|
||||
// CE x Compliance Crossover
|
||||
iaceRoutes.GET("/projects/:id/compliance-triggers", h.GetComplianceTriggers)
|
||||
iaceRoutes.GET("/compliance-faq", h.GetComplianceFAQ)
|
||||
|
||||
// Clarifications — aggregated open questions per project
|
||||
iaceRoutes.GET("/projects/:id/clarifications", h.ListClarifications)
|
||||
iaceRoutes.GET("/projects/:id/clarifications.csv", h.ExportClarificationsCSV)
|
||||
iaceRoutes.GET("/projects/:id/clarifications.html", h.ExportClarificationsHTML)
|
||||
iaceRoutes.GET("/projects/:id/clarifications/:cid/detail", h.ListClarificationDetail)
|
||||
iaceRoutes.POST("/projects/:id/clarifications/:cid/answer", h.AnswerClarification)
|
||||
iaceRoutes.POST("/projects/:id/clarifications/:cid/comment", h.PostClarificationComment)
|
||||
|
||||
// Customer-Standard Reuse (migration 031): pull reusable mitigations
|
||||
// across prior projects of the same customer.
|
||||
iaceRoutes.GET("/projects/:id/customer-standards", h.ListCustomerStandardSuggestions)
|
||||
iaceRoutes.POST("/projects/:id/customer-standards/import", h.ImportCustomerStandardSuggestion)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Clarification represents an aggregated open question that the operator
|
||||
// must verify with the Anlagenbauer. The engine NEVER generates commentary
|
||||
// — it only surfaces norm-/manufacturer-derived check items that can be
|
||||
// objectively answered.
|
||||
//
|
||||
// IDs are deterministic so existing answers survive every project re-init:
|
||||
// - pattern:<HP-ID>:<index> — question is hard-coded on a HazardPattern
|
||||
// - manuf:<Manufacturer>:<index> — question comes from the manufacturer library
|
||||
//
|
||||
// "AffectedHazardIDs" / "AffectedMitigationIDs" are filled at request time
|
||||
// from the project's current hazards. They tell the UI which entries in the
|
||||
// hazard list will be marked "geklaert" once this clarification is answered.
|
||||
type Clarification struct {
|
||||
ID string `json:"id"`
|
||||
Question string `json:"question"`
|
||||
Source string `json:"source"` // "FANUC (Dual Check Safety)", "Pattern HP1640", ...
|
||||
Category string `json:"category"` // "manufacturer" | "pattern_norm"
|
||||
NormReferences []string `json:"norm_references,omitempty"`
|
||||
AffectedHazardIDs []uuid.UUID `json:"affected_hazard_ids"`
|
||||
AffectedHazardNames []string `json:"affected_hazard_names"` // shown directly in the table
|
||||
AffectedMitigationIDs []uuid.UUID `json:"affected_mitigation_ids,omitempty"`
|
||||
// State (merged from project.metadata.clarification_answers)
|
||||
Status string `json:"status"` // "open" | "in_progress" | "answered" | "not_relevant"
|
||||
Answer string `json:"answer,omitempty"` // "ja" | "nein" | "teilweise"
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
AnsweredBy string `json:"answered_by,omitempty"`
|
||||
AnsweredAt string `json:"answered_at,omitempty"`
|
||||
AssignedTo string `json:"assigned_to,omitempty"`
|
||||
}
|
||||
|
||||
// ClarificationAnswer is the persisted shape (one entry in
|
||||
// project.metadata.clarification_answers[<clarification.id>]).
|
||||
type ClarificationAnswer struct {
|
||||
Status string `json:"status"`
|
||||
Answer string `json:"answer,omitempty"`
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
AnsweredBy string `json:"answered_by,omitempty"`
|
||||
AnsweredAt string `json:"answered_at,omitempty"`
|
||||
AssignedTo string `json:"assigned_to,omitempty"`
|
||||
}
|
||||
|
||||
// BuildProjectClarifications walks the project's current hazards and returns
|
||||
// the deduplicated list of clarification questions that apply, with each
|
||||
// hazard correctly cross-referenced.
|
||||
//
|
||||
// Inputs are resolved upstream so this function stays free of DB access and
|
||||
// is unit-testable:
|
||||
// - hazards: the project's persisted hazards (Name, ID, Category)
|
||||
// - hazardSourcePatterns: per hazard, the HP-IDs that fired for it (today
|
||||
// we don't have a clean back-reference, so the handler does a name+zone
|
||||
// re-match against patterns)
|
||||
// - manufacturerHits: ManufacturerSafetyFeature entries whose aliases were
|
||||
// found in the project narrative
|
||||
// - answers: map[clarificationID]ClarificationAnswer from project.metadata
|
||||
func BuildProjectClarifications(
|
||||
hazards []Hazard,
|
||||
hazardSourcePatterns map[uuid.UUID][]string,
|
||||
manufacturerHits []ManufacturerSafetyFeature,
|
||||
answers map[string]ClarificationAnswer,
|
||||
) []Clarification {
|
||||
// Lookup helpers
|
||||
patternByID := make(map[string]HazardPattern)
|
||||
for _, p := range collectAllPatterns() {
|
||||
patternByID[p.ID] = p
|
||||
}
|
||||
|
||||
// Bucket by clarification ID so we accumulate affected hazards
|
||||
buckets := make(map[string]*Clarification)
|
||||
|
||||
// 1) Pattern-level clarifications
|
||||
for hzID, hpIDs := range hazardSourcePatterns {
|
||||
hz := findHazard(hazards, hzID)
|
||||
if hz == nil {
|
||||
continue
|
||||
}
|
||||
for _, hpID := range hpIDs {
|
||||
p, ok := patternByID[hpID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for i, q := range p.ClarificationQuestionsDE {
|
||||
cid := "pattern:" + hpID + ":" + intStr(i)
|
||||
b, exists := buckets[cid]
|
||||
if !exists {
|
||||
b = &Clarification{
|
||||
ID: cid,
|
||||
Question: q,
|
||||
Source: "Pattern " + hpID + " — " + p.NameDE,
|
||||
Category: "pattern_norm",
|
||||
Status: "open",
|
||||
}
|
||||
buckets[cid] = b
|
||||
}
|
||||
b.AffectedHazardIDs = append(b.AffectedHazardIDs, hz.ID)
|
||||
b.AffectedHazardNames = appendUniqueString(b.AffectedHazardNames, hz.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Manufacturer-level clarifications — apply to every hazard whose
|
||||
// category matches the manufacturer entry's AppliesToHazardCats
|
||||
for _, mf := range manufacturerHits {
|
||||
applicable := func(cat string) bool {
|
||||
if len(mf.AppliesToHazardCats) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, c := range mf.AppliesToHazardCats {
|
||||
if c == cat {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
for i, q := range mf.Clarifications {
|
||||
cid := "manuf:" + slug(mf.Manufacturer) + ":" + slug(mf.FeatureName) + ":" + intStr(i)
|
||||
b, exists := buckets[cid]
|
||||
if !exists {
|
||||
b = &Clarification{
|
||||
ID: cid,
|
||||
Question: q,
|
||||
Source: mf.Manufacturer + " — " + mf.FeatureName,
|
||||
Category: "manufacturer",
|
||||
NormReferences: mf.NormReferences,
|
||||
Status: "open",
|
||||
}
|
||||
buckets[cid] = b
|
||||
}
|
||||
for _, hz := range hazards {
|
||||
if !applicable(hz.Category) {
|
||||
continue
|
||||
}
|
||||
b.AffectedHazardIDs = append(b.AffectedHazardIDs, hz.ID)
|
||||
b.AffectedHazardNames = appendUniqueString(b.AffectedHazardNames, hz.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge persisted answers
|
||||
out := make([]Clarification, 0, len(buckets))
|
||||
for cid, b := range buckets {
|
||||
if ans, ok := answers[cid]; ok {
|
||||
if ans.Status != "" {
|
||||
b.Status = ans.Status
|
||||
} else if ans.Answer != "" {
|
||||
b.Status = "answered"
|
||||
}
|
||||
b.Answer = ans.Answer
|
||||
b.Reasoning = ans.Reasoning
|
||||
b.AnsweredBy = ans.AnsweredBy
|
||||
b.AnsweredAt = ans.AnsweredAt
|
||||
b.AssignedTo = ans.AssignedTo
|
||||
}
|
||||
// dedup hazard IDs (multiple patterns can target the same hazard)
|
||||
b.AffectedHazardIDs = dedupUUIDs(b.AffectedHazardIDs)
|
||||
out = append(out, *b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findHazard(hazards []Hazard, id uuid.UUID) *Hazard {
|
||||
for i := range hazards {
|
||||
if hazards[i].ID == id {
|
||||
return &hazards[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendUniqueString(slice []string, s string) []string {
|
||||
for _, x := range slice {
|
||||
if x == s {
|
||||
return slice
|
||||
}
|
||||
}
|
||||
return append(slice, s)
|
||||
}
|
||||
|
||||
func dedupUUIDs(ids []uuid.UUID) []uuid.UUID {
|
||||
seen := make(map[uuid.UUID]bool, len(ids))
|
||||
out := make([]uuid.UUID, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if !seen[id] {
|
||||
seen[id] = true
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func intStr(i int) string {
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := false
|
||||
if i < 0 {
|
||||
neg = true
|
||||
i = -i
|
||||
}
|
||||
var buf [20]byte
|
||||
pos := len(buf)
|
||||
for i > 0 {
|
||||
pos--
|
||||
buf[pos] = byte('0' + i%10)
|
||||
i /= 10
|
||||
}
|
||||
if neg {
|
||||
pos--
|
||||
buf[pos] = '-'
|
||||
}
|
||||
return string(buf[pos:])
|
||||
}
|
||||
|
||||
// slug lowercases and replaces non-[a-z0-9] with "-" so the manufacturer name
|
||||
// and feature name can be embedded in a stable clarification ID.
|
||||
func slug(s string) string {
|
||||
s = normalizeForMatch(s) // already lower + umlaut-folded
|
||||
var b strings.Builder
|
||||
prevDash := false
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
prevDash = false
|
||||
} else {
|
||||
if !prevDash && b.Len() > 0 {
|
||||
b.WriteRune('-')
|
||||
prevDash = true
|
||||
}
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if strings.HasSuffix(out, "-") {
|
||||
out = out[:len(out)-1]
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGTBremse_PinnedHazardToMeasureMappings is a regression net for the IACE
|
||||
// benchmark fix. Each pinned (GT-Nr, hazard pattern, measure) triple was
|
||||
// validated by an expert review on 2026-05 against testdata/ground_truth_bremse.json.
|
||||
// If any pattern stops referencing the listed measures, this test fails — so the
|
||||
// underlying GT scenario is no longer answered with the Fachmann-grade mitigation.
|
||||
//
|
||||
// Adding new entries here pins the Engine's answer for a specific GT scenario.
|
||||
// Removing entries means the GT scenario is no longer covered with the same
|
||||
// concrete measure (e.g. because the library was reorganized) — that needs an
|
||||
// active decision, not a silent drift.
|
||||
func TestGTBremse_PinnedHazardToMeasureMappings(t *testing.T) {
|
||||
cases := []struct {
|
||||
gtNr string
|
||||
patternID string
|
||||
requiredMeasures []string
|
||||
}{
|
||||
// GT 2.1/2.2: Elektrischer Schlag durch direktes Beruehren
|
||||
// Expert demand: konkrete Isolation MOhm + IP2X Einhausung
|
||||
{"2.1/2.2", "HP1640", []string{"M481", "M482"}},
|
||||
// GT 2.4: Schutzleiterfehler (>10 mA Ableitstroeme)
|
||||
// Expert demand: mech. Schutz + 10mm²-Cu + Ueberwachung + durchgehende Verbindung
|
||||
{"2.4", "HP1641", []string{"M511", "M512", "M514", "M515"}},
|
||||
// GT 2.5: Indirektes Beruehren — Schutzleiter durchgaengig + SK II / Kleinspannung
|
||||
{"2.5", "HP1685", []string{"M511", "M512", "M515", "M516"}},
|
||||
// GT 2.7: RCD an Steckdosenkreisen
|
||||
{"2.7", "HP1689", []string{"M518"}},
|
||||
// GT 2.12: Potentialausgleich zwischen Anlagenteilen
|
||||
{"2.12", "HP1688", []string{"M475", "M477"}},
|
||||
// GT 1.3: Pneumatik-Komponenten + Schlauchsicherung
|
||||
{"1.3", "HP1630", []string{"M483", "M484", "M485"}},
|
||||
// GT 1.5: Pneumatik-Restenergie nach Abschaltung
|
||||
{"1.5", "HP1717", []string{"M485", "M534"}},
|
||||
// GT 1.7: Teach-Modus mit Schluesselschalter + 250 mm/s + Zustimmtaster
|
||||
{"1.7", "HP1605", []string{"M491", "M492", "M493"}},
|
||||
// GT 1.8: Sicher begrenzter Bewegungsbereich + Zaun-Lastbemessung
|
||||
{"1.8", "HP1604", []string{"M494", "M501"}},
|
||||
// GT 1.10/1.18: Reach-over Sicherheitsabstand
|
||||
{"1.10/1.18", "HP1602", []string{"M495", "M486"}},
|
||||
// GT 1.11: Foerderband-Geometrie (Abstand + Oeffnungsgroesse)
|
||||
{"1.11", "HP1621", []string{"M496", "M497", "M498"}},
|
||||
// GT 1.22: Greifer-Versagen + Werkstueck weggeschleudert
|
||||
{"1.22", "HP1711", []string{"M501", "M502", "M536"}},
|
||||
// GT 1.24: Eingeschlossen in Zelle — Innenoeffnung + bewusster Wiederanlauf
|
||||
{"1.24", "HP1603", []string{"M489", "M488"}},
|
||||
// GT 1.12/1.24 (HP1651 Wiederanlauf-Variante): Wiederanlauf-Schutz-Measures —
|
||||
// NOT thermal (M054 was wrongly placed here and surfaced as
|
||||
// "Sichere thermische Auslegung" for a restart hazard)
|
||||
{"1.12/1.24", "HP1651", []string{"M488", "M487", "M489", "M490"}},
|
||||
// GT 1.1 (HP1625 sharp edges): edge-specific only, no rotational/distance fillers
|
||||
{"1.1", "HP1625", []string{"M003", "M004", "M027"}},
|
||||
// GT 1.26: Foerderband-Geschwindigkeit < 100 mm/s
|
||||
{"1.26", "HP1620", []string{"M498", "M499"}},
|
||||
// GT 1.27: Mechanischer Anschlag am Bandende
|
||||
{"1.27", "HP1622", []string{"M500"}},
|
||||
// GT 1.30: Druckluft-Reinigungsduese
|
||||
{"1.30", "HP1712", []string{"M504", "M505"}},
|
||||
// GT 1.32: WZM-Beladetuer + zweikanaliger Tuerschalter
|
||||
{"1.32", "HP1634", []string{}}, // skipped: HP1634 already had M061; verify exists
|
||||
// GT 1.34/2.10: KSS-Druckschlauch
|
||||
{"1.34/2.10", "HP1675", []string{"M484", "M483"}},
|
||||
// GT 1.38/1.39: KSS-Auslauf unten + Druck begrenzt
|
||||
{"1.38/1.39", "HP1703", []string{"M505", "M506", "M526"}},
|
||||
// GT 2.9: Wasser/Reinigung Schaltschrank
|
||||
{"2.9", "HP1716", []string{"M521", "M522", "M539"}},
|
||||
// GT 7.1: KSS-Hautkontakt
|
||||
{"7.1", "HP1715", []string{"M408", "M533"}},
|
||||
// GT 8.1: Manuelle Werkstueck-Handhabung + Hebehilfe >25kg
|
||||
{"8.1", "HP1713", []string{"M530", "M532"}},
|
||||
// GT 8.2: Bedienelement-Position ergonomisch
|
||||
{"8.2", "HP1714", []string{"M531"}},
|
||||
}
|
||||
|
||||
patterns := collectAllPatterns()
|
||||
measureByID := make(map[string]ProtectiveMeasureEntry)
|
||||
for _, m := range GetProtectiveMeasureLibrary() {
|
||||
measureByID[m.ID] = m
|
||||
}
|
||||
patternByID := make(map[string]HazardPattern)
|
||||
for _, p := range patterns {
|
||||
patternByID[p.ID] = p
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.gtNr+"_"+c.patternID, func(t *testing.T) {
|
||||
p, ok := patternByID[c.patternID]
|
||||
if !ok {
|
||||
t.Fatalf("pattern %s missing — GT %s no longer covered", c.patternID, c.gtNr)
|
||||
}
|
||||
suggested := make(map[string]bool)
|
||||
for _, m := range p.SuggestedMeasureIDs {
|
||||
suggested[m] = true
|
||||
}
|
||||
for _, req := range c.requiredMeasures {
|
||||
if _, exists := measureByID[req]; !exists {
|
||||
t.Errorf("required measure %s referenced by GT %s does not exist in library", req, c.gtNr)
|
||||
continue
|
||||
}
|
||||
if !suggested[req] {
|
||||
t.Errorf("pattern %s no longer suggests %s — GT %s expert mitigation lost (current: %v)",
|
||||
c.patternID, req, c.gtNr, p.SuggestedMeasureIDs)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGTBremse_ExpertMeasuresAllResolvable pins the static-text expectation
|
||||
// that every Fachmann measure newly added during the 2026-05 GT coverage work
|
||||
// (M481-M482, M483-M539) carries the concrete EN/IEC/ISO/DGUV norm reference
|
||||
// that the expert cited in the GT file. A measure without a concrete norm
|
||||
// reference is a regression — generic "Sichere X" entries were exactly the
|
||||
// problem this work was meant to fix.
|
||||
func TestGTBremse_ExpertMeasuresAllResolvable(t *testing.T) {
|
||||
expertIDs := []string{
|
||||
"M481", "M482", "M483", "M484", "M485", "M486", "M487", "M488", "M489", "M490",
|
||||
"M491", "M492", "M493", "M494", "M495", "M496", "M497", "M498", "M499", "M500",
|
||||
"M501", "M502", "M503", "M504", "M505", "M506", "M507", "M508", "M509", "M510",
|
||||
"M511", "M512", "M513", "M514", "M515", "M516", "M517", "M518", "M519", "M520",
|
||||
"M521", "M522", "M523", "M524", "M525", "M526", "M527", "M528", "M529", "M530",
|
||||
"M531", "M532", "M533", "M534", "M535", "M536", "M537", "M538", "M539",
|
||||
}
|
||||
measureByID := make(map[string]ProtectiveMeasureEntry)
|
||||
for _, m := range GetProtectiveMeasureLibrary() {
|
||||
measureByID[m.ID] = m
|
||||
}
|
||||
knownPrefixes := []string{"EN ", "IEC ", "ISO ", "DIN ", "TRBS", "TRGS", "ASR ", "DGUV", "OSHA", "VDE", "EN ISO", "DIN EN"}
|
||||
for _, id := range expertIDs {
|
||||
m, ok := measureByID[id]
|
||||
if !ok {
|
||||
t.Errorf("expert measure %s missing from library", id)
|
||||
continue
|
||||
}
|
||||
if len(m.NormReferences) == 0 {
|
||||
t.Errorf("measure %s (%q) has no NormReferences — concrete norm anchor missing", id, m.Name)
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, nr := range m.NormReferences {
|
||||
for _, p := range knownPrefixes {
|
||||
if strings.HasPrefix(nr, p) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("measure %s (%q) NormReferences %v contain no recognized norm prefix",
|
||||
id, m.Name, m.NormReferences)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,18 @@ type HazardPattern struct {
|
||||
RequiresExpertCalculation bool `json:"requires_expert_calculation,omitempty"`
|
||||
ExpertHintDE string `json:"expert_hint_de,omitempty"`
|
||||
ExpertHintEN string `json:"expert_hint_en,omitempty"`
|
||||
// ClarificationQuestionsDE: konkrete Fragen die der Bediener mit dem
|
||||
// Anlagenbauer abklaeren sollte, bevor er das Hazard als abgedeckt
|
||||
// betrachtet. Die Engine erfindet keine Begruendungen, sondern hinter-
|
||||
// legt nur prueffaehige Fragen aus Norm-Wissen. Beispiele:
|
||||
// - "Liegt ein Pruefprotokoll nach EN 60204-1 vor?"
|
||||
// - "Wird die Sicherheitsfunktion durch CE-Konformitaet der
|
||||
// Subkomponente abgedeckt? (Herstellererklaerung anfordern)"
|
||||
// - "Ist DCS-Achsbegrenzung am Roboter konfiguriert und validiert?"
|
||||
// Im Init-Handler werden diese Fragen an die Hazard.Description
|
||||
// angehaengt mit einem klaren Trenner — kein DB-Schema-Aenderungs-
|
||||
// bedarf.
|
||||
ClarificationQuestionsDE []string `json:"clarification_questions_de,omitempty"`
|
||||
// Detail fields — populated into Hazard DB when pattern fires
|
||||
ScenarioDE string `json:"scenario_de,omitempty"` // Gefahrensituation
|
||||
TriggerDE string `json:"trigger_de,omitempty"` // Gefaehrdendes Ereignis
|
||||
@@ -58,6 +70,58 @@ type HazardPattern struct {
|
||||
// is relevant. Written into the Hazard's LifecyclePhase field on creation.
|
||||
// Empty = not set (pattern does not specify lifecycle applicability).
|
||||
ApplicableLifecycles []string `json:"applicable_lifecycles,omitempty"`
|
||||
// ISO12100Section is a free-text identifier of the ISO 12100 Annex B
|
||||
// section that owns this hazard type (e.g. "6.2.2.1" or "6.3.5.5").
|
||||
// Stored as an identifier only — the norm text is NOT included to
|
||||
// keep the library urheberrechtlich neutral (DIN/Beuth license).
|
||||
// The frontend renders it as "EN ISO 12100 Abschnitt 6.3.5.5".
|
||||
ISO12100Section string `json:"iso_12100_section,omitempty"`
|
||||
// DefaultAvoidability is the P parameter of the EN ISO 13849-1
|
||||
// risk graph (Annex A): 1 = avoidable under certain conditions, 2 =
|
||||
// hardly avoidable. Combined with DefaultSeverity (S1/S2 derived
|
||||
// at threshold 3) and DefaultExposure (F1/F2 at threshold 3) it
|
||||
// feeds into the PLr (required Performance Level) computation,
|
||||
// see ComputePLr.
|
||||
DefaultAvoidability int `json:"default_avoidability,omitempty"` // 1 or 2
|
||||
}
|
||||
|
||||
// ComputePLr returns the required Performance Level (PLr) per EN ISO
|
||||
// 13849-1 Anhang A (Risikograph). Inputs are the three parameters of
|
||||
// the graph in their 1/2 form:
|
||||
// - s (Schwere): 1 = leicht/reversibel, 2 = schwer/irreversibel inkl. Tod
|
||||
// - f (Haeufigkeit/Dauer): 1 = selten/kurz, 2 = haeufig/dauernd
|
||||
// - p (Moeglichkeit Vermeidung): 1 = unter Bedingungen moeglich, 2 = kaum
|
||||
// Return value is one of "a", "b", "c", "d", "e" (PLa..PLe).
|
||||
//
|
||||
// The mapping follows the canonical 8-leaf binary tree of the standard:
|
||||
// S1 F1 P1 -> a
|
||||
// S1 F1 P2 -> b
|
||||
// S1 F2 P1 -> b
|
||||
// S1 F2 P2 -> c
|
||||
// S2 F1 P1 -> c
|
||||
// S2 F1 P2 -> d
|
||||
// S2 F2 P1 -> d
|
||||
// S2 F2 P2 -> e
|
||||
func ComputePLr(s, f, p int) string {
|
||||
idx := 0
|
||||
if s == 2 { idx += 4 }
|
||||
if f == 2 { idx += 2 }
|
||||
if p == 2 { idx += 1 }
|
||||
return []string{"a", "b", "b", "c", "c", "d", "d", "e"}[idx]
|
||||
}
|
||||
|
||||
// SeverityToS maps a 1-5 DefaultSeverity to the binary S parameter of
|
||||
// EN ISO 13849-1: 1-2 -> S1 (leicht/reversibel), 3-5 -> S2 (schwer/Tod).
|
||||
func SeverityToS(severity int) int {
|
||||
if severity >= 3 { return 2 }
|
||||
return 1
|
||||
}
|
||||
|
||||
// ExposureToF maps a 1-5 DefaultExposure to the binary F parameter of
|
||||
// EN ISO 13849-1: 1-2 -> F1 (selten/kurz), 3-5 -> F2 (haeufig/dauernd).
|
||||
func ExposureToF(exposure int) int {
|
||||
if exposure >= 3 { return 2 }
|
||||
return 1
|
||||
}
|
||||
|
||||
// Standard human roles for machinery interaction (ISO 12100 + BetrSichV).
|
||||
|
||||
@@ -180,7 +180,7 @@ func GetAGVAgriPatterns() []HazardPattern {
|
||||
RequiredEnergyTags: []string{},
|
||||
ExcludedComponentTags: []string{"single_agv_system"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M054", "M106"},
|
||||
SuggestedMeasureIDs: []string{"M002", "M008", "M061", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01"},
|
||||
Priority: 78,
|
||||
ScenarioDE: "Zwei AGVs kollidieren an einer Kreuzung oder im engen Gang. Ladung wird verschoben, umstehende Personen gefaehrdet.",
|
||||
@@ -195,7 +195,7 @@ func GetAGVAgriPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"agv", "sensor_part", "electrical_part"},
|
||||
RequiredEnergyTags: []string{"electrical"},
|
||||
GeneratedHazardCats: []string{"electrical_hazard", "safety_function_failure"},
|
||||
SuggestedMeasureIDs: []string{"M106"},
|
||||
SuggestedMeasureIDs: []string{"M478", "M479", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01"},
|
||||
Priority: 72,
|
||||
ScenarioDE: "Elektromagnetische Stoerungen (Schweissgeraete, Frequenzumrichter) beeinflussen AGV-Sensorik oder Steuerung.",
|
||||
|
||||
@@ -8,7 +8,7 @@ func builtinAIPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_ai"},
|
||||
RequiredEnergyTags: []string{"ai_model"},
|
||||
GeneratedHazardCats: []string{"false_classification"},
|
||||
SuggestedMeasureIDs: []string{"M101", "M102"},
|
||||
SuggestedMeasureIDs: []string{"M133", "M214", "M213", "M044", "M119"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E15"},
|
||||
Priority: 90,
|
||||
ScenarioDE: "KI-Modell klassifiziert Objekt oder Zustand falsch und loest darauf basierend eine gefaehrliche Aktion aus.",
|
||||
@@ -23,7 +23,7 @@ func builtinAIPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_ai"},
|
||||
RequiredEnergyTags: []string{"ai_model"},
|
||||
GeneratedHazardCats: []string{"model_drift"},
|
||||
SuggestedMeasureIDs: []string{"M103"},
|
||||
SuggestedMeasureIDs: []string{"M133", "M227", "M214", "M112"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E15"},
|
||||
Priority: 85,
|
||||
ScenarioDE: "KI-Modell verliert ueber Zeit an Genauigkeit, weil sich Eingangsdaten schleichend veraendern.",
|
||||
@@ -38,7 +38,7 @@ func builtinAIPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_ai"},
|
||||
RequiredEnergyTags: []string{"cyber", "ai_model"},
|
||||
GeneratedHazardCats: []string{"data_poisoning"},
|
||||
SuggestedMeasureIDs: []string{"M101", "M116"},
|
||||
SuggestedMeasureIDs: []string{"M188", "M133", "M113", "M214", "M187"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E15", "E16"},
|
||||
Priority: 85,
|
||||
ScenarioDE: "Angreifer manipuliert Trainingsdaten oder Eingangssignale, um das KI-Modell gezielt zu taeuschen.",
|
||||
@@ -53,7 +53,7 @@ func builtinAIPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_ai"},
|
||||
RequiredEnergyTags: []string{"ai_model"},
|
||||
GeneratedHazardCats: []string{"unintended_bias"},
|
||||
SuggestedMeasureIDs: []string{"M101"},
|
||||
SuggestedMeasureIDs: []string{"M133", "M227", "M204"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E15"},
|
||||
Priority: 75,
|
||||
ScenarioDE: "KI-Modell trifft systematisch ungleiche Entscheidungen fuer bestimmte Personengruppen oder Bedingungen.",
|
||||
@@ -68,7 +68,7 @@ func builtinAIPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_ai", "sensor_part"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"sensor_spoofing"},
|
||||
SuggestedMeasureIDs: []string{"M101", "M102"},
|
||||
SuggestedMeasureIDs: []string{"M213", "M214", "M119", "M133"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E15"},
|
||||
Priority: 80,
|
||||
ScenarioDE: "Sensor, der KI-Eingangsdaten liefert, wird manipuliert oder liefert durch Verschmutzung/Defekt falsche Werte.",
|
||||
|
||||
@@ -129,7 +129,7 @@ func GetCNCHazardPatterns() []HazardPattern {
|
||||
ID: "HP1408", NameDE: "Falscher Werkzeug-Offset", NameEN: "Wrong tool offset after setup",
|
||||
RequiredComponentTags: []string{"cutting_tool", "programmable"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M041", "M050"},
|
||||
SuggestedMeasureIDs: []string{"M008", "M001", "M061", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E14"},
|
||||
Priority: 78, MachineTypes: cncTypes,
|
||||
OperationalStates: []string{"teach_mode"},
|
||||
@@ -220,7 +220,7 @@ func GetCNCHazardPatterns() []HazardPattern {
|
||||
ID: "HP1414", NameDE: "Rutschgefahr durch KSS-Leckage am Boden", NameEN: "Slip hazard from MWF leakage on floor",
|
||||
RequiredComponentTags: []string{"cutting_tool"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M420", "M101"},
|
||||
SuggestedMeasureIDs: []string{"M538", "M484", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01"},
|
||||
Priority: 65, MachineTypes: cncTypes,
|
||||
ScenarioDE: "KSS-Leckage erzeugt rutschigen Bodenbelag um die Werkzeugmaschine",
|
||||
|
||||
@@ -49,7 +49,7 @@ func GetCobotHazardPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"afterrun_risk", "moving_part"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M054"},
|
||||
SuggestedMeasureIDs: []string{"M002", "M494", "M487", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E08"},
|
||||
Priority: 90,
|
||||
RequiresExpertCalculation: true,
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package iace
|
||||
|
||||
// GetCRAPatterns returns hazard patterns for the EU Cyber Resilience Act
|
||||
// compliance track. They fire when a project's components carry digital
|
||||
// elements (software, network, AI) and therefore fall under the CRA.
|
||||
//
|
||||
// The CRA itself (Verordnung (EU) 2024/2847) became force-of-law on
|
||||
// 11 December 2024 and applies to products with digital elements placed
|
||||
// on the EU market from 11 December 2027. The harmonised standard for
|
||||
// machinery is DIN EN 40000-1-2 (Entwurf November 2025).
|
||||
//
|
||||
// Identifiers only — no normative text is reproduced.
|
||||
//
|
||||
// HP range: HP1910-HP1918.
|
||||
func GetCRAPatterns() []HazardPattern {
|
||||
return []HazardPattern{
|
||||
{
|
||||
ID: "HP1910", NameDE: "Fehlende Software-Stueckliste (SBOM) fuer die Maschine", NameEN: "Missing Software Bill of Materials (SBOM)",
|
||||
RequiredComponentTags: []string{"has_software"},
|
||||
GeneratedHazardCats: []string{"cyber_resilience"},
|
||||
SuggestedMeasureIDs: []string{"M540"},
|
||||
Priority: 70,
|
||||
ApplicableLifecycles: []string{"normal_operation", "maintenance"},
|
||||
ScenarioDE: "Die Maschine enthaelt Software-Komponenten (PLC-Firmware, HMI, Roboter-Controller). Wird keine maschinenlesbare Komponentenliste mitgeliefert, kann der Betreiber im Falle einer veroeffentlichten Schwachstelle (CVE) nicht feststellen, ob seine Maschine betroffen ist.",
|
||||
TriggerDE: "Veroeffentlichung einer Sicherheitsluecke in einer mitverwendeten Bibliothek (z.B. log4j, OpenSSL); Audit-Anfrage des Betreibers; Schwachstellenmeldung durch Forscher.",
|
||||
HarmDE: "Cybersecurity-Folgeschaden: nicht behebbare Schwachstelle, Maschinen-Stillstand bei Behoerden-Audit, Reputations- und Haftungsrisiko.",
|
||||
AffectedDE: "Betreiber, IT-/OT-Verantwortliche, Anlagenbauer",
|
||||
ZoneDE: "Maschinendokumentation, Software-Auslieferung",
|
||||
ISO12100Section: "6.3.5.7",
|
||||
ClarificationQuestionsDE: []string{
|
||||
"Wird eine SBOM (SPDX oder CycloneDX) mit jeder Maschine bzw. jedem Software-Release ausgeliefert?",
|
||||
"Sind alle Open-Source-Bibliotheken in der SBOM mit Version und Lizenz inventarisiert?",
|
||||
"Wie wird die SBOM bei einem Software-Update aktualisiert?",
|
||||
},
|
||||
DefaultSeverity: 3, DefaultExposure: 4, DefaultAvoidability: 1,
|
||||
},
|
||||
{
|
||||
ID: "HP1911", NameDE: "Updates ohne kryptographische Signatur einspielbar", NameEN: "Unsigned firmware/software updates accepted",
|
||||
RequiredComponentTags: []string{"has_software", "networked"},
|
||||
GeneratedHazardCats: []string{"cyber_resilience"},
|
||||
SuggestedMeasureIDs: []string{"M541", "M547"},
|
||||
Priority: 90,
|
||||
ApplicableLifecycles: []string{"maintenance", "fault_clearing"},
|
||||
ScenarioDE: "Software- oder Firmware-Updates der Maschine koennen ohne Pruefung einer Hersteller-Signatur eingespielt werden. Ein Angreifer mit physischem oder Netzwerk-Zugriff kann manipulierte Updates aufspielen und die Maschinensicherheit kompromittieren.",
|
||||
TriggerDE: "Update-USB an HMI angesteckt; ungesicherter Update-Endpoint im Netzwerk erreichbar; Service-Techniker bringt nicht authentifiziertes Update mit.",
|
||||
HarmDE: "Kompromittierung der Steuerung mit Folgewirkung auf Maschinensicherheit (z.B. Deaktivieren von Sicherheitsfunktionen, Manipulation von Achs-Limits, Daten-Diebstahl).",
|
||||
AffectedDE: "Bedienpersonal, Wartungspersonal, Betreiber",
|
||||
ZoneDE: "Steuerung, HMI, Update-Schnittstellen",
|
||||
ISO12100Section: "6.3.5.7",
|
||||
ClarificationQuestionsDE: []string{
|
||||
"Werden alle Software-/Firmware-Updates kryptographisch signiert ausgeliefert?",
|
||||
"Prueft die Maschine die Signatur vor dem Anwenden eines Updates und verweigert unsignierte Pakete?",
|
||||
"Ist ein Rollback auf nachweislich verwundbare Versionen durch einen Versions-Counter blockiert?",
|
||||
},
|
||||
DefaultSeverity: 4, DefaultExposure: 2, DefaultAvoidability: 1,
|
||||
},
|
||||
{
|
||||
ID: "HP1912", NameDE: "Werkseitige Default-Passwoerter bleiben im Betrieb aktiv", NameEN: "Factory-default credentials remain active in production",
|
||||
RequiredComponentTags: []string{"has_software", "user_interface"},
|
||||
GeneratedHazardCats: []string{"cyber_resilience"},
|
||||
SuggestedMeasureIDs: []string{"M542"},
|
||||
Priority: 85,
|
||||
ApplicableLifecycles: []string{"setup", "normal_operation"},
|
||||
ScenarioDE: "Die Maschine wird mit werkseitigen Standard-Passwoertern (z.B. admin/admin, 1234, Werks-Default-PIN) ausgeliefert. Werden diese nicht beim ersten Hochfahren zwangsweise geaendert, sind sie ueblicherweise oeffentlich bekannt.",
|
||||
TriggerDE: "Inbetriebnahme ohne Initialisierungs-Wizard; Werks-Reset durch unbefugte Person; Werks-Default in Online-Listen veroeffentlicht.",
|
||||
HarmDE: "Unautorisierter Zugriff auf Bediener-/Wartungsebene, Manipulation der Maschinen-Konfiguration, Abgriff von Produktions-/Personendaten.",
|
||||
AffectedDE: "Betreiber, IT-/OT-Verantwortliche",
|
||||
ZoneDE: "HMI, Wartungs-Login, Remote-Service-Zugang",
|
||||
ISO12100Section: "6.3.5.7",
|
||||
ClarificationQuestionsDE: []string{
|
||||
"Erzwingt die Maschine beim ersten Hochfahren die Aenderung aller werkseitigen Default-Passwoerter?",
|
||||
"Sind die werkseitigen Credentials in der oeffentlichen Dokumentation NICHT enthalten?",
|
||||
"Wie wird ein Werks-Reset gegen unbefugten Zugriff geschuetzt?",
|
||||
},
|
||||
DefaultSeverity: 3, DefaultExposure: 3, DefaultAvoidability: 1,
|
||||
},
|
||||
{
|
||||
ID: "HP1913", NameDE: "Keine veroeffentlichte CVD-Policy (Vulnerability Disclosure)", NameEN: "No coordinated vulnerability disclosure policy",
|
||||
RequiredComponentTags: []string{"has_software"},
|
||||
GeneratedHazardCats: []string{"cyber_resilience"},
|
||||
SuggestedMeasureIDs: []string{"M543"},
|
||||
Priority: 70,
|
||||
ApplicableLifecycles: []string{"normal_operation"},
|
||||
ScenarioDE: "Der Anlagenbauer hat keine veroeffentlichte Kontaktstelle fuer die Meldung von Schwachstellen. Externe Sicherheitsforscher oder Kunden finden keinen Weg, Schwachstellen vertraulich zu melden — diese werden im Zweifel ungefiltert oeffentlich (Full-Disclosure).",
|
||||
TriggerDE: "Sicherheitsforscher findet Schwachstelle, kein PSIRT-Kontakt; Kunde will Schwachstelle melden, scheitert an Erreichbarkeit.",
|
||||
HarmDE: "Veroeffentlichung von Schwachstellen ohne Patch verfuegbar (0-day in der Wildbahn), Reputationsverlust, Behoerden-Sanktion nach CRA.",
|
||||
AffectedDE: "Hersteller, Betreiber, Sicherheitsforscher",
|
||||
ZoneDE: "Hersteller-Website, Dokumentation",
|
||||
ISO12100Section: "6.3.5.7",
|
||||
ClarificationQuestionsDE: []string{
|
||||
"Existiert eine veroeffentlichte CVD-Policy mit eindeutiger Meldekontaktstelle?",
|
||||
"Ist eine security.txt nach RFC 9116 auf der Hersteller-Website hinterlegt?",
|
||||
"Sind Antwort- und Behebungsfristen in der Policy dokumentiert?",
|
||||
},
|
||||
DefaultSeverity: 2, DefaultExposure: 3, DefaultAvoidability: 1,
|
||||
},
|
||||
{
|
||||
ID: "HP1914", NameDE: "Keine dokumentierte Patch-SLA fuer Sicherheits-Updates", NameEN: "No documented security patch SLA",
|
||||
RequiredComponentTags: []string{"has_software"},
|
||||
GeneratedHazardCats: []string{"cyber_resilience"},
|
||||
SuggestedMeasureIDs: []string{"M544"},
|
||||
Priority: 70,
|
||||
ApplicableLifecycles: []string{"normal_operation"},
|
||||
ScenarioDE: "Der Hersteller hat keine verbindlichen Reaktionszeiten fuer Sicherheits-Patches definiert. Im Schwachstellenfall ist offen, wie schnell ein Patch geliefert wird — Betreiber kann sein Risiko nicht steuern.",
|
||||
TriggerDE: "CVE wird veroeffentlicht; Betreiber fragt Patch-Termin an; Hersteller hat keine SLA und kann keinen Termin nennen.",
|
||||
HarmDE: "Lange Exposition gegenueber bekannter Schwachstelle; Verstoss gegen CRA-Pflichten zur Schwachstellenbehandlung.",
|
||||
AffectedDE: "Hersteller, Betreiber, IT-/OT-Verantwortliche",
|
||||
ZoneDE: "Vertrags-/Dokumentationsebene",
|
||||
ISO12100Section: "6.3.5.7",
|
||||
ClarificationQuestionsDE: []string{
|
||||
"Existiert eine dokumentierte Patch-SLA mit CVSS-abhaengigen Reaktionszeiten?",
|
||||
"Welcher Sicherheits-Supportzeitraum wird zugesagt (CRA-Mindest: erwartete Nutzungsdauer, typ. 5 Jahre)?",
|
||||
"Wird die SLA mit der Maschine ausgeliefert oder vertraglich vereinbart?",
|
||||
},
|
||||
DefaultSeverity: 2, DefaultExposure: 3, DefaultAvoidability: 1,
|
||||
},
|
||||
{
|
||||
ID: "HP1915", NameDE: "Fehlende Cybersecurity-Anwender-Dokumentation (Hardening-Guide)", NameEN: "Missing user-facing cybersecurity hardening guide",
|
||||
RequiredComponentTags: []string{"has_software", "networked"},
|
||||
GeneratedHazardCats: []string{"cyber_resilience"},
|
||||
SuggestedMeasureIDs: []string{"M545"},
|
||||
Priority: 65,
|
||||
ApplicableLifecycles: []string{"setup", "normal_operation"},
|
||||
ScenarioDE: "Der Betreiber erhaelt keine Anleitung zur sicheren Inbetriebnahme und Konfiguration der Maschine im Werks-Netzwerk. Konfigurations-Fehler (offene Ports, ungehaerteter Default-Zustand) bleiben unentdeckt.",
|
||||
TriggerDE: "Inbetriebnahme der Maschine im IT/OT-Netzwerk; Sicherheits-Audit beim Betreiber.",
|
||||
HarmDE: "Angreifbares Konfigurations-Profil, das vom Standardzustand der Maschine vererbt wurde; Verstoss gegen CRA Anhang II (Anwender-Information).",
|
||||
AffectedDE: "Betreiber, IT-/OT-Verantwortliche",
|
||||
ZoneDE: "Maschinen-Konfiguration, Netzwerk-Anbindung",
|
||||
ISO12100Section: "6.3.5.7",
|
||||
ClarificationQuestionsDE: []string{
|
||||
"Liegt der Maschine ein Cybersecurity-Hardening-Guide bei (Netzwerk-Segmentierung, deaktivierbare Dienste, sichere Konfiguration)?",
|
||||
"Wird der Guide bei Updates gepflegt?",
|
||||
"Enthaelt die Betriebsanleitung ein Kapitel 'Sichere Inbetriebnahme'?",
|
||||
},
|
||||
DefaultSeverity: 2, DefaultExposure: 3, DefaultAvoidability: 1,
|
||||
},
|
||||
{
|
||||
ID: "HP1916", NameDE: "Kein definierter Meldeprozess fuer Sicherheitsvorfaelle an ENISA/CSIRT", NameEN: "No incident notification process to ENISA / national CSIRT",
|
||||
RequiredComponentTags: []string{"has_software"},
|
||||
GeneratedHazardCats: []string{"cyber_resilience"},
|
||||
SuggestedMeasureIDs: []string{"M546"},
|
||||
Priority: 75,
|
||||
ApplicableLifecycles: []string{"normal_operation"},
|
||||
ScenarioDE: "Der Hersteller hat keinen internen Prozess, um aktiv ausgenutzte Schwachstellen oder schwere Sicherheitsvorfaelle innerhalb der CRA-Meldefristen (24 h / 72 h / 14 Tage) an ENISA bzw. die zustaendige nationale Stelle zu melden.",
|
||||
TriggerDE: "Schwachstelle wird in der Wildbahn ausgenutzt; Sicherheitsvorfall bei einem Kunden gemeldet.",
|
||||
HarmDE: "Versaeumte Meldefrist nach CRA Art. 14 — Bussgeldrisiko, Verlust der CE-Konformitaetsvermutung.",
|
||||
AffectedDE: "Hersteller, Compliance/PSIRT-Team",
|
||||
ZoneDE: "Hersteller-Prozesse",
|
||||
ISO12100Section: "6.3.5.7",
|
||||
ClarificationQuestionsDE: []string{
|
||||
"Existiert ein dokumentierter Incident-Notification-Prozess mit Eskalationsmatrix?",
|
||||
"Sind die CRA-Meldefristen (Erstmeldung 24 h, Update 72 h, Abschluss 14 Tage) als interne SLA umgesetzt?",
|
||||
"Ist die zustaendige nationale CSIRT-Stelle ermittelt und der Meldekanal getestet?",
|
||||
},
|
||||
DefaultSeverity: 3, DefaultExposure: 2, DefaultAvoidability: 1,
|
||||
},
|
||||
{
|
||||
ID: "HP1917", NameDE: "Keine Sicherheitsbewertung vor Inverkehrbringen (Pen-Test / Static Analysis)", NameEN: "No security assessment prior to placing on market",
|
||||
RequiredComponentTags: []string{"has_software"},
|
||||
GeneratedHazardCats: []string{"cyber_resilience"},
|
||||
SuggestedMeasureIDs: []string{"M548"},
|
||||
Priority: 70,
|
||||
ApplicableLifecycles: []string{"setup"},
|
||||
ScenarioDE: "Vor dem erstmaligen Inverkehrbringen wurde keine dokumentierte Sicherheitsbewertung (mind. statische Code-Analyse + Schwachstellen-Scan) durchgefuehrt. Bekannte Schwachstellen-Klassen gelangen unbemerkt in die Auslieferung.",
|
||||
TriggerDE: "Inverkehrbringen einer neuen Maschinen-Variante oder eines Major-Updates; behoerdlicher Konformitaetsnachweis.",
|
||||
HarmDE: "Auslieferung von Maschinen mit bekannten Schwachstellen; Verstoss gegen CRA Anhang I (Cybersecurity-Anforderungen).",
|
||||
AffectedDE: "Hersteller, Betreiber",
|
||||
ZoneDE: "Entwicklungs-/Auslieferungsprozess",
|
||||
ISO12100Section: "6.3.5.7",
|
||||
ClarificationQuestionsDE: []string{
|
||||
"Wird vor jedem Major-Release eine dokumentierte Sicherheitsbewertung durchgefuehrt?",
|
||||
"Sind statische Analyse und Dependency-Scan in den CI-Pipeline integriert?",
|
||||
"Werden bei erhoehtem Risiko Penetrationstests durch unabhaengige Stellen durchgefuehrt?",
|
||||
},
|
||||
DefaultSeverity: 3, DefaultExposure: 2, DefaultAvoidability: 1,
|
||||
},
|
||||
{
|
||||
ID: "HP1918", NameDE: "AI-Komponente ohne Cybersecurity-Risikobetrachtung", NameEN: "AI component without cybersecurity risk assessment",
|
||||
RequiredComponentTags: []string{"has_ai"},
|
||||
GeneratedHazardCats: []string{"cyber_resilience"},
|
||||
SuggestedMeasureIDs: []string{"M548", "M545"},
|
||||
Priority: 75,
|
||||
ApplicableLifecycles: []string{"normal_operation"},
|
||||
ScenarioDE: "Die Maschine enthaelt KI-Komponenten (z.B. Bildverarbeitung, anomalie-basierte Wartung). Cybersecurity-spezifische KI-Risiken (Model Inversion, Adversarial Examples, Poisoning des Trainingsdatensatzes) wurden nicht geprueft.",
|
||||
TriggerDE: "Adversarial Input wird ueber Sensor-/HMI-Schnittstelle eingespielt; manipuliertes Update-Modell wird ausgespielt.",
|
||||
HarmDE: "Fehlsteuerung der Maschine durch manipuliertes KI-Modell; mittelbare Sicherheitsfolgen, Datenleckage aus Modell.",
|
||||
AffectedDE: "Bedienpersonal, Betreiber",
|
||||
ZoneDE: "KI-Modul, Modell-Auslieferungspfad",
|
||||
ISO12100Section: "6.3.5.7",
|
||||
ClarificationQuestionsDE: []string{
|
||||
"Sind KI-spezifische Cybersecurity-Risiken (Adversarial Inputs, Poisoning, Model Inversion) im Risikobeurteilungsprozess betrachtet?",
|
||||
"Wie wird die Integritaet ausgelieferter KI-Modelle sichergestellt (Signatur, Hash, sichere Auslieferungs-Kanal)?",
|
||||
"Existiert ein Monitoring auf ungewoehnliche Eingangsmuster im Betrieb?",
|
||||
},
|
||||
DefaultSeverity: 3, DefaultExposure: 2, DefaultAvoidability: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func builtinCyberPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"networked", "it_component"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"communication_failure"},
|
||||
SuggestedMeasureIDs: []string{"M114", "M115"},
|
||||
SuggestedMeasureIDs: []string{"M113", "M106", "M119", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E17"},
|
||||
Priority: 80,
|
||||
ScenarioDE: "Netzwerkverbindung zwischen Steuerungskomponenten faellt aus; Maschine verliert Synchronisation.",
|
||||
@@ -68,7 +68,7 @@ func builtinCyberPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"it_component", "has_software"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"unauthorized_access", "firmware_corruption"},
|
||||
SuggestedMeasureIDs: []string{"M116", "M118"},
|
||||
SuggestedMeasureIDs: []string{"M186", "M188", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E18", "E19"},
|
||||
Priority: 75,
|
||||
ScenarioDE: "Kompromittierte Komponente oder Bibliothek wird in der Lieferkette eingeschleust.",
|
||||
|
||||
@@ -13,7 +13,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_software", "programmable"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"software_fault"},
|
||||
SuggestedMeasureIDs: []string{"M101", "M102", "M103"},
|
||||
SuggestedMeasureIDs: []string{"M111", "M107", "M106", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E14"},
|
||||
Priority: 85,
|
||||
ScenarioDE: "Dynamischer Speicher der Steuerung laeuft voll; Steuerungsprogramm verhaelt sich undefiniert oder stuerzt ab.",
|
||||
@@ -28,7 +28,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_software", "programmable"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"software_fault"},
|
||||
SuggestedMeasureIDs: []string{"M101", "M102"},
|
||||
SuggestedMeasureIDs: []string{"M040", "M044", "M106", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E14"},
|
||||
Priority: 85,
|
||||
ScenarioDE: "Zwei Tasks greifen gleichzeitig auf gemeinsame Ressource zu; Zustandsinformation wird inkonsistent.",
|
||||
@@ -43,7 +43,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_software", "programmable"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"software_fault"},
|
||||
SuggestedMeasureIDs: []string{"M101", "M103"},
|
||||
SuggestedMeasureIDs: []string{"M107", "M214", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E14"},
|
||||
Priority: 80,
|
||||
ScenarioDE: "Regelkreis teilt durch einen Sensorwert, der unerwartet Null wird; Stellgroesse laeuft ins Unendliche.",
|
||||
@@ -58,7 +58,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_software"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"software_fault"},
|
||||
SuggestedMeasureIDs: []string{"M101", "M102"},
|
||||
SuggestedMeasureIDs: []string{"M111", "M214", "M107", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E14"},
|
||||
Priority: 75,
|
||||
ScenarioDE: "Integer-Ueberlauf in der Steuerung wandelt grossen Positivwert in negativen Wert und kehrt Bewegungsrichtung um.",
|
||||
@@ -88,7 +88,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_software", "programmable"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"software_fault"},
|
||||
SuggestedMeasureIDs: []string{"M101", "M102", "M103"},
|
||||
SuggestedMeasureIDs: []string{"M106", "M107", "M044", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E14"},
|
||||
Priority: 80,
|
||||
ScenarioDE: "Zwei oder mehr Tasks der Steuerung blockieren sich gegenseitig; Steuerung reagiert nicht mehr.",
|
||||
@@ -148,7 +148,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"sensor_part", "has_software"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"software_fault"},
|
||||
SuggestedMeasureIDs: []string{"M101", "M102"},
|
||||
SuggestedMeasureIDs: []string{"M214", "M213", "M119", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E14"},
|
||||
Priority: 75,
|
||||
ScenarioDE: "Sensor liefert Wert ausserhalb seines Messbereichs; Steuerungssoftware interpretiert ihn falsch.",
|
||||
@@ -178,7 +178,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_software", "networked"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"software_fault"},
|
||||
SuggestedMeasureIDs: []string{"M101", "M114"},
|
||||
SuggestedMeasureIDs: []string{"M040", "M106", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E14"},
|
||||
Priority: 70,
|
||||
ScenarioDE: "Fehlerhafte Zeitstempel fuehren dazu, dass Prozessschritte in falscher Reihenfolge ausgefuehrt werden.",
|
||||
@@ -193,7 +193,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_software"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"software_fault"},
|
||||
SuggestedMeasureIDs: []string{"M101", "M102"},
|
||||
SuggestedMeasureIDs: []string{"M111", "M107", "M188", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E14", "E16"},
|
||||
Priority: 85,
|
||||
ScenarioDE: "Pufferueberlauf in der Steuerungssoftware ueberschreibt angrenzende Speicherbereiche und fuehrt zu undefiniertem Verhalten.",
|
||||
@@ -208,7 +208,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_software", "programmable"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"software_fault"},
|
||||
SuggestedMeasureIDs: []string{"M101", "M103"},
|
||||
SuggestedMeasureIDs: []string{"M107", "M105", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E14"},
|
||||
Priority: 80,
|
||||
ScenarioDE: "Nicht abgefangene Programmausnahme fuehrt zum Absturz der Steuerung ohne geordnete Abschaltung.",
|
||||
@@ -379,7 +379,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"it_component", "has_software"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"unauthorized_access", "firmware_corruption"},
|
||||
SuggestedMeasureIDs: []string{"M116", "M118"},
|
||||
SuggestedMeasureIDs: []string{"M186", "M188", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E18", "E19"},
|
||||
Priority: 75,
|
||||
ScenarioDE: "Kompromittierte Hardware oder Software wird ueber die Lieferkette in die Anlage eingebracht.",
|
||||
@@ -424,7 +424,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_software"},
|
||||
RequiredEnergyTags: []string{"cyber"},
|
||||
GeneratedHazardCats: []string{"unauthorized_access"},
|
||||
SuggestedMeasureIDs: []string{"M116", "M138"},
|
||||
SuggestedMeasureIDs: []string{"M188", "M186", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E18", "E19"},
|
||||
Priority: 80,
|
||||
ScenarioDE: "Steuerungssoftware oder Betriebssystem wird nicht gepatcht; bekannte CVEs sind ausnutzbar.",
|
||||
@@ -439,7 +439,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_software", "it_component"},
|
||||
RequiredEnergyTags: []string{"cyber"},
|
||||
GeneratedHazardCats: []string{"firmware_corruption"},
|
||||
SuggestedMeasureIDs: []string{"M116", "M142"},
|
||||
SuggestedMeasureIDs: []string{"M186", "M188", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E16"},
|
||||
Priority: 70,
|
||||
ScenarioDE: "Angreifer manipuliert Backup-Daten; bei Wiederherstellung wird kompromittierte Konfiguration eingespielt.",
|
||||
|
||||
@@ -27,7 +27,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_ai"},
|
||||
RequiredEnergyTags: []string{"ai_model"},
|
||||
GeneratedHazardCats: []string{"unintended_bias", "false_classification"},
|
||||
SuggestedMeasureIDs: []string{"M101"},
|
||||
SuggestedMeasureIDs: []string{"M133", "M227", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E15"},
|
||||
Priority: 80,
|
||||
ScenarioDE: "Einseitige Trainingsdaten fuehren dazu, dass das KI-Modell bestimmte Varianten systematisch falsch klassifiziert.",
|
||||
@@ -42,7 +42,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_ai", "sensor_part"},
|
||||
RequiredEnergyTags: []string{"ai_model", "cyber"},
|
||||
GeneratedHazardCats: []string{"data_poisoning", "sensor_spoofing"},
|
||||
SuggestedMeasureIDs: []string{"M101", "M116"},
|
||||
SuggestedMeasureIDs: []string{"M133", "M214", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E15", "E16"},
|
||||
Priority: 85,
|
||||
ScenarioDE: "Gezielt veraenderte Eingabedaten (Adversarial Patches) taeuschen das Bilderkennungssystem und erzwingen Fehlklassifikation.",
|
||||
@@ -57,7 +57,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_ai"},
|
||||
RequiredEnergyTags: []string{"ai_model"},
|
||||
GeneratedHazardCats: []string{"model_drift"},
|
||||
SuggestedMeasureIDs: []string{"M103"},
|
||||
SuggestedMeasureIDs: []string{"M133", "M227", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E15"},
|
||||
Priority: 75,
|
||||
ScenarioDE: "KI-Modell verliert durch veraenderte Prozessbedingungen ueber Wochen schleichend an Genauigkeit.",
|
||||
@@ -72,7 +72,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_ai", "programmable"},
|
||||
RequiredEnergyTags: []string{"ai_model"},
|
||||
GeneratedHazardCats: []string{"software_fault", "safety_function_failure"},
|
||||
SuggestedMeasureIDs: []string{"M101", "M104"},
|
||||
SuggestedMeasureIDs: []string{"M044", "M227", "M105", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E07", "E15"},
|
||||
Priority: 95,
|
||||
RequiresExpertCalculation: true,
|
||||
@@ -89,7 +89,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_ai", "user_interface"},
|
||||
RequiredEnergyTags: []string{"ai_model"},
|
||||
GeneratedHazardCats: []string{"false_classification"},
|
||||
SuggestedMeasureIDs: []string{"M101"},
|
||||
SuggestedMeasureIDs: []string{"M133", "M204", "M227", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E15"},
|
||||
Priority: 70,
|
||||
ScenarioDE: "KI-System gibt Empfehlung ohne Begruendung; Bediener folgt blindlings einer fehlerhaften Empfehlung.",
|
||||
@@ -104,7 +104,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_ai"},
|
||||
RequiredEnergyTags: []string{"ai_model"},
|
||||
GeneratedHazardCats: []string{"false_classification"},
|
||||
SuggestedMeasureIDs: []string{"M101", "M102"},
|
||||
SuggestedMeasureIDs: []string{"M133", "M227", "M214", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E15"},
|
||||
Priority: 80,
|
||||
ScenarioDE: "KI trifft bei Eingabedaten nahe der Entscheidungsgrenze unzuverlaessige Entscheidungen mit schwankender Konfidenz.",
|
||||
@@ -119,7 +119,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_ai", "programmable"},
|
||||
RequiredEnergyTags: []string{"ai_model"},
|
||||
GeneratedHazardCats: []string{"software_fault"},
|
||||
SuggestedMeasureIDs: []string{"M101", "M102", "M103"},
|
||||
SuggestedMeasureIDs: []string{"M133", "M227", "M105", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E15"},
|
||||
Priority: 85,
|
||||
ScenarioDE: "RL-Agent entdeckt unbeabsichtigte Strategie zur Reward-Maximierung, die gefaehrliches Verhalten einschliesst.",
|
||||
@@ -134,7 +134,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_ai"},
|
||||
RequiredEnergyTags: []string{"ai_model"},
|
||||
GeneratedHazardCats: []string{"unintended_bias"},
|
||||
SuggestedMeasureIDs: []string{"M101"},
|
||||
SuggestedMeasureIDs: []string{"M186", "M187", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E15"},
|
||||
Priority: 70,
|
||||
ScenarioDE: "KI-System verarbeitet Kamerabilder mit erkennbaren Personen ohne Einwilligung oder Rechtsgrundlage.",
|
||||
@@ -149,7 +149,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_ai", "safety_device"},
|
||||
RequiredEnergyTags: []string{"ai_model"},
|
||||
GeneratedHazardCats: []string{"safety_function_failure"},
|
||||
SuggestedMeasureIDs: []string{"M101", "M104"},
|
||||
SuggestedMeasureIDs: []string{"M105", "M227", "M044", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E07", "E15"},
|
||||
Priority: 95,
|
||||
RequiresExpertCalculation: true,
|
||||
@@ -166,7 +166,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_ai"},
|
||||
RequiredEnergyTags: []string{"ai_model"},
|
||||
GeneratedHazardCats: []string{"model_drift"},
|
||||
SuggestedMeasureIDs: []string{"M103"},
|
||||
SuggestedMeasureIDs: []string{"M133", "M227", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E15"},
|
||||
Priority: 75,
|
||||
ScenarioDE: "Grundlegende Aenderung des Produktionsprozesses macht das KI-Modell ungueltig, da es auf alten Zusammenhaengen basiert.",
|
||||
@@ -181,7 +181,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_ai", "user_interface"},
|
||||
RequiredEnergyTags: []string{"ai_model"},
|
||||
GeneratedHazardCats: []string{"false_classification"},
|
||||
SuggestedMeasureIDs: []string{"M101", "M102"},
|
||||
SuggestedMeasureIDs: []string{"M133", "M214", "M227", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E15"},
|
||||
Priority: 80,
|
||||
ScenarioDE: "KI-System meldet einen Zustand mit hoher Konfidenz, der in Wirklichkeit nicht vorliegt (Halluzination).",
|
||||
@@ -196,7 +196,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_ai", "networked"},
|
||||
RequiredEnergyTags: []string{"ai_model"},
|
||||
GeneratedHazardCats: []string{"communication_failure", "safety_function_failure"},
|
||||
SuggestedMeasureIDs: []string{"M101", "M104", "M115"},
|
||||
SuggestedMeasureIDs: []string{"M109", "M113", "M106", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E15", "E17"},
|
||||
Priority: 90,
|
||||
ScenarioDE: "Sicherheitsrelevante KI-Funktion benoetigt Cloud-Verbindung; bei Netzwerkausfall ist die Sicherheit nicht gewaehrleistet.",
|
||||
@@ -211,7 +211,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_ai"},
|
||||
RequiredEnergyTags: []string{"ai_model"},
|
||||
GeneratedHazardCats: []string{"false_classification"},
|
||||
SuggestedMeasureIDs: []string{"M101", "M102"},
|
||||
SuggestedMeasureIDs: []string{"M044", "M119", "M133", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E15"},
|
||||
Priority: 75,
|
||||
ScenarioDE: "KI-System ist alleiniger Qualitaetsgate ohne Backup-Pruefung; bei KI-Ausfall passieren alle Teile unkontrolliert.",
|
||||
@@ -226,7 +226,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_ai"},
|
||||
RequiredEnergyTags: []string{"ai_model"},
|
||||
GeneratedHazardCats: []string{"model_drift"},
|
||||
SuggestedMeasureIDs: []string{"M103"},
|
||||
SuggestedMeasureIDs: []string{"M133", "M227", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E15", "E21"},
|
||||
Priority: 80,
|
||||
ScenarioDE: "KI-basierte Wartungsvorhersage unterschaetzt Verschleiss und empfiehlt Wartung zu spaet.",
|
||||
|
||||
@@ -13,7 +13,7 @@ func GetCyberExtendedPatterns3() []HazardPattern {
|
||||
RequiredComponentTags: []string{"networked", "it_component"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"communication_failure"},
|
||||
SuggestedMeasureIDs: []string{"M114", "M115"},
|
||||
SuggestedMeasureIDs: []string{"M113", "M106", "M119", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E17"},
|
||||
Priority: 85,
|
||||
ScenarioDE: "Kompletter Feldbusausfall trennt SPS von allen Antrieben und Sensoren; Maschine verliert Kontrolle.",
|
||||
@@ -28,7 +28,7 @@ func GetCyberExtendedPatterns3() []HazardPattern {
|
||||
RequiredComponentTags: []string{"networked"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"communication_failure"},
|
||||
SuggestedMeasureIDs: []string{"M114", "M115"},
|
||||
SuggestedMeasureIDs: []string{"M113", "M106", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E17"},
|
||||
Priority: 80,
|
||||
ScenarioDE: "Einzelne Telegramme im Echtzeit-Bussystem gehen verloren; Antrieb erhaelt keinen neuen Sollwert und behlt den alten.",
|
||||
@@ -58,7 +58,7 @@ func GetCyberExtendedPatterns3() []HazardPattern {
|
||||
RequiredComponentTags: []string{"networked", "it_component"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"communication_failure"},
|
||||
SuggestedMeasureIDs: []string{"M115"},
|
||||
SuggestedMeasureIDs: []string{"M113", "M106", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E17"},
|
||||
Priority: 70,
|
||||
ScenarioDE: "Gateway zwischen Feldbus und Leitebene faellt aus; SCADA verliert Sicht auf Prozess, Alarme kommen nicht durch.",
|
||||
@@ -88,7 +88,7 @@ func GetCyberExtendedPatterns3() []HazardPattern {
|
||||
RequiredComponentTags: []string{"networked"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"communication_failure"},
|
||||
SuggestedMeasureIDs: []string{"M114"},
|
||||
SuggestedMeasureIDs: []string{"M113", "M186", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E17"},
|
||||
Priority: 65,
|
||||
ScenarioDE: "Zwei Geraete im Maschinennetzwerk haben dieselbe IP-Adresse; Kommunikation ist unzuverlaessig.",
|
||||
@@ -103,7 +103,7 @@ func GetCyberExtendedPatterns3() []HazardPattern {
|
||||
RequiredComponentTags: []string{"networked", "has_software"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"communication_failure", "software_fault"},
|
||||
SuggestedMeasureIDs: []string{"M114"},
|
||||
SuggestedMeasureIDs: []string{"M040", "M106", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E14", "E17"},
|
||||
Priority: 75,
|
||||
ScenarioDE: "PTP/NTP-Synchronisation im Netzwerk geht verloren; zeitgesteuerte Aktionen werden asynchron ausgefuehrt.",
|
||||
@@ -118,7 +118,7 @@ func GetCyberExtendedPatterns3() []HazardPattern {
|
||||
RequiredComponentTags: []string{"networked", "safety_device"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"communication_failure", "safety_function_failure"},
|
||||
SuggestedMeasureIDs: []string{"M114", "M115"},
|
||||
SuggestedMeasureIDs: []string{"M113", "M106", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E07", "E17"},
|
||||
Priority: 85,
|
||||
ScenarioDE: "Netzwerk-Ueberlastung verzoegert sicherheitsrelevante Telegramme ueber die maximale Reaktionszeit hinaus.",
|
||||
@@ -133,7 +133,7 @@ func GetCyberExtendedPatterns3() []HazardPattern {
|
||||
RequiredComponentTags: []string{"networked", "it_component"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"communication_failure"},
|
||||
SuggestedMeasureIDs: []string{"M114"},
|
||||
SuggestedMeasureIDs: []string{"M113", "M186", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E17"},
|
||||
Priority: 60,
|
||||
ScenarioDE: "Falsche Routing-Konfiguration leitet Steuerbefehle an falsches Teilnetz oder laesst sie ins Leere laufen.",
|
||||
@@ -148,7 +148,7 @@ func GetCyberExtendedPatterns3() []HazardPattern {
|
||||
RequiredComponentTags: []string{"networked", "it_component"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"communication_failure"},
|
||||
SuggestedMeasureIDs: []string{"M115"},
|
||||
SuggestedMeasureIDs: []string{"M113", "M119", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E17"},
|
||||
Priority: 75,
|
||||
ScenarioDE: "Managed Switch in Ring-Topologie faellt aus; Ring-Redundanz uebernimmt, aber Umschaltzeit stoert Echtzeit-Kommunikation.",
|
||||
@@ -212,7 +212,7 @@ func GetCyberExtendedPatterns3() []HazardPattern {
|
||||
RequiredComponentTags: []string{"user_interface", "has_software"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"software_fault"},
|
||||
SuggestedMeasureIDs: []string{"M101", "M103"},
|
||||
SuggestedMeasureIDs: []string{"M205", "M204", "M214", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E14"},
|
||||
Priority: 75,
|
||||
ScenarioDE: "HMI zeigt falschen Messwert (z. B. falsche Zuordnung von Sensor zu Anzeige) und Bediener reagiert falsch.",
|
||||
|
||||
@@ -8,7 +8,7 @@ func builtinElectricalPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"high_voltage"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M061", "M062", "M063", "M121"},
|
||||
SuggestedMeasureIDs: []string{"M481", "M482", "M088", "M265", "M522"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E04", "E10"},
|
||||
Priority: 95,
|
||||
ScenarioDE: "Person beruehrt spannungsfuehrende Teile durch defekte Isolation oder ungesicherten Zugang.",
|
||||
@@ -16,6 +16,7 @@ func builtinElectricalPatterns() []HazardPattern {
|
||||
HarmDE: "Stromschlag, Herzkammerflimmern, Verbrennungen, Todesfolge bei Hochspannung.",
|
||||
AffectedDE: "Wartungspersonal, Elektrofachkraefte, Bedienpersonal",
|
||||
ZoneDE: "Schaltschrank, Klemmenkasten, Motoranschluss, Frequenzumrichter",
|
||||
ISO12100Section: "6.2.9",
|
||||
DefaultSeverity: 5, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
@@ -23,7 +24,7 @@ func builtinElectricalPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"electrical_part"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M061", "M064", "M121"},
|
||||
SuggestedMeasureIDs: []string{"M482", "M481", "M089", "M508", "M522"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E04", "E10"},
|
||||
Priority: 85,
|
||||
ScenarioDE: "Elektrische Bauteile (Motoren, Netzteile, Schaltgeraete) stellen bei Defekt oder offenem Gehaeuse eine Beruehrungsgefahr dar.",
|
||||
@@ -31,6 +32,7 @@ func builtinElectricalPatterns() []HazardPattern {
|
||||
HarmDE: "Stromschlag, lokale Verbrennungen, Muskelverkrampfung.",
|
||||
AffectedDE: "Bedienpersonal, Wartungspersonal",
|
||||
ZoneDE: "Motorgehaeuse, Anschlussklemmen, Netzteile, Kabelkanaele",
|
||||
ISO12100Section: "6.2.9",
|
||||
DefaultSeverity: 4, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
@@ -38,7 +40,7 @@ func builtinElectricalPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"stored_energy"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M062", "M063", "M121", "M123"},
|
||||
SuggestedMeasureIDs: []string{"M046", "M047", "M138", "M522", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E10"},
|
||||
Priority: 85,
|
||||
ScenarioDE: "Kondensatoren, Batterien oder Frequenzumrichter halten nach Abschalten gefaehrliche Restspannung.",
|
||||
@@ -46,6 +48,7 @@ func builtinElectricalPatterns() []HazardPattern {
|
||||
HarmDE: "Stromschlag, Verbrennungen durch Lichtbogen, Explosion bei Lithium-Akkus.",
|
||||
AffectedDE: "Elektrofachkraefte, Wartungspersonal",
|
||||
ZoneDE: "Zwischenkreiskondensatoren, Batteriefaecher, USV-Anlagen",
|
||||
ISO12100Section: "6.2.10",
|
||||
DefaultSeverity: 4, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
@@ -68,7 +71,7 @@ func builtinElectricalPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"electrical_part"},
|
||||
RequiredEnergyTags: []string{"electromagnetic"},
|
||||
GeneratedHazardCats: []string{"emc_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M066", "M121"},
|
||||
SuggestedMeasureIDs: []string{"M478", "M479", "M044", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E10"},
|
||||
Priority: 70,
|
||||
ScenarioDE: "Elektromagnetische Stoerungen beeinflussen Steuerungssignale und loesen unerwartete Maschinenbewegungen aus.",
|
||||
@@ -76,6 +79,7 @@ func builtinElectricalPatterns() []HazardPattern {
|
||||
HarmDE: "Fehlausloesung von Aktoren, unerwartete Bewegung, Ausfall von Sicherheitsfunktionen.",
|
||||
AffectedDE: "Bedienpersonal, Personen im Maschinenbereich",
|
||||
ZoneDE: "Gesamte Maschinenumgebung, insbesondere Signalleitungen und Sensorik",
|
||||
ISO12100Section: "6.3.3",
|
||||
DefaultSeverity: 3, DefaultExposure: 2,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ func GetElevatorPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"elevator_car", "moving_part"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M054", "M106"},
|
||||
SuggestedMeasureIDs: []string{"M008", "M001", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01"},
|
||||
Priority: 75,
|
||||
ScenarioDE: "Fahrkorb steht nicht buendig mit dem Stockwerksboden. Stufenbildung von mehr als 20mm.",
|
||||
@@ -363,7 +363,7 @@ func GetElevatorPatterns() []HazardPattern {
|
||||
MachineTypes: []string{"elevator", "lift", "escalator"},
|
||||
RequiredComponentTags: []string{"elevator_traction", "noise_source"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"noise_vibration"},
|
||||
GeneratedHazardCats: []string{"noise_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M141"},
|
||||
SuggestedEvidenceIDs: []string{"E20"},
|
||||
Priority: 55,
|
||||
@@ -412,7 +412,7 @@ func GetElevatorPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"elevator_door", "moving_part"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M054", "M106"},
|
||||
SuggestedMeasureIDs: []string{"M061", "M002", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E08"},
|
||||
Priority: 78,
|
||||
ScenarioDE: "Tuerschliessmechanismus uebt zu grosse Kraft aus. Passagiere werden beim Schliessen der Tueren getroffen.",
|
||||
|
||||
@@ -8,7 +8,7 @@ func builtinEnvironmentPatterns() []HazardPattern {
|
||||
ID: "HP023", NameDE: "Laermgefahr", NameEN: "Noise hazard",
|
||||
RequiredComponentTags: []string{"noise_source"},
|
||||
RequiredEnergyTags: []string{"acoustic"},
|
||||
GeneratedHazardCats: []string{"noise_vibration"},
|
||||
GeneratedHazardCats: []string{"noise_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M091", "M131"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E12"},
|
||||
Priority: 70,
|
||||
@@ -17,13 +17,14 @@ func builtinEnvironmentPatterns() []HazardPattern {
|
||||
HarmDE: "Laermschwerhoerigkeit (BK 2301), Tinnitus, Konzentrationsstoerung.",
|
||||
AffectedDE: "Bedienpersonal, Personen im Umfeld",
|
||||
ZoneDE: "Gesamter Arbeitsbereich um die Maschine, insbesondere Auslassseite",
|
||||
ISO12100Section: "6.3.3.2.1",
|
||||
DefaultSeverity: 3, DefaultExposure: 4,
|
||||
},
|
||||
{
|
||||
ID: "HP024", NameDE: "Vibrationsgefahr", NameEN: "Vibration hazard",
|
||||
RequiredComponentTags: []string{"vibration_source"},
|
||||
RequiredEnergyTags: []string{"vibration"},
|
||||
GeneratedHazardCats: []string{"noise_vibration"},
|
||||
GeneratedHazardCats: []string{"vibration_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M092", "M131"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E13"},
|
||||
Priority: 65,
|
||||
@@ -32,13 +33,14 @@ func builtinEnvironmentPatterns() []HazardPattern {
|
||||
HarmDE: "Hand-Arm-Vibrationssyndrom (BK 2104), Durchblutungsstoerung, Gelenkschaeden.",
|
||||
AffectedDE: "Bedienpersonal, Maschinenfuehrer",
|
||||
ZoneDE: "Griffe, Bedienelemente, Standfussbereich, Fahrersitz",
|
||||
ISO12100Section: "6.3.3.2.1",
|
||||
DefaultSeverity: 3, DefaultExposure: 4,
|
||||
},
|
||||
{
|
||||
ID: "HP025", NameDE: "Laerm durch rotierende Hochgeschwindigkeitsteile", NameEN: "Noise from high-speed rotating parts",
|
||||
RequiredComponentTags: []string{"rotating_part", "high_speed"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"noise_vibration"},
|
||||
GeneratedHazardCats: []string{"noise_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M091", "M092", "M131"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E12", "E13"},
|
||||
Priority: 70,
|
||||
@@ -47,6 +49,7 @@ func builtinEnvironmentPatterns() []HazardPattern {
|
||||
HarmDE: "Gehoerschaedigung, Tinnitus, erhoehtes Unfallrisiko durch Konzentrationsverlust.",
|
||||
AffectedDE: "Bedienpersonal, Personen im Hallenbereich",
|
||||
ZoneDE: "Umgebung der Spindel/Schleifscheibe, Maschinengehaeuse, offene Bearbeitungszone",
|
||||
ISO12100Section: "6.3.3.2.1",
|
||||
DefaultSeverity: 3, DefaultExposure: 4,
|
||||
},
|
||||
// Ergonomic
|
||||
@@ -55,7 +58,7 @@ func builtinEnvironmentPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"user_interface"},
|
||||
RequiredEnergyTags: []string{"ergonomic"},
|
||||
GeneratedHazardCats: []string{"ergonomic"},
|
||||
SuggestedMeasureIDs: []string{"M126", "M121"},
|
||||
SuggestedMeasureIDs: []string{"M029", "M030", "M032", "M033", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E24"},
|
||||
Priority: 50,
|
||||
ScenarioDE: "Bediener arbeitet in unguenstiger Koerperhaltung (stehend, gebeugt, verdreht) ueber laengere Schichtdauer.",
|
||||
@@ -85,7 +88,7 @@ func builtinEnvironmentPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"user_interface", "programmable"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"hmi_error", "mode_confusion"},
|
||||
SuggestedMeasureIDs: []string{"M126", "M127", "M121"},
|
||||
SuggestedMeasureIDs: []string{"M204", "M205", "M206", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E14", "E24"},
|
||||
Priority: 70,
|
||||
ScenarioDE: "Bediener verwechselt Betriebsarten oder aktiviert falsche Funktion durch unklare HMI-Gestaltung.",
|
||||
|
||||
@@ -12,73 +12,73 @@ func GetExtendedHazardPatterns() []HazardPattern {
|
||||
func getExtendedHazardPatternsA() []HazardPattern {
|
||||
return []HazardPattern{
|
||||
{
|
||||
ID: "HP045", NameDE: "Aktor — elektrisch", NameEN: "Actuator — electrical",
|
||||
ID: "HP1800", NameDE: "Aktor — elektrisch", NameEN: "Actuator — electrical",
|
||||
RequiredComponentTags: []string{"actuator_part"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
RequiredLifecycles: []string{"maintenance"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M121"},
|
||||
SuggestedMeasureIDs: []string{"M141"},
|
||||
SuggestedEvidenceIDs: []string{"E21"},
|
||||
Priority: 80,
|
||||
// Source: R341
|
||||
},
|
||||
{
|
||||
ID: "HP046", NameDE: "Aktor — mechanisch", NameEN: "Actuator — mechanical",
|
||||
ID: "HP1801", NameDE: "Aktor — mechanisch", NameEN: "Actuator — mechanical",
|
||||
RequiredComponentTags: []string{"actuator_part"},
|
||||
RequiredEnergyTags: []string{"kinetic"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M106"},
|
||||
SuggestedMeasureIDs: []string{"M141"},
|
||||
SuggestedEvidenceIDs: []string{"E08"},
|
||||
Priority: 80,
|
||||
// Source: R340
|
||||
},
|
||||
{
|
||||
ID: "HP047", NameDE: "KI-Steuerung — Software", NameEN: "Ai Controller — software",
|
||||
ID: "HP1802", NameDE: "KI-Steuerung — Software", NameEN: "Ai Controller — software",
|
||||
RequiredComponentTags: []string{"has_ai", "has_software", "programmable"},
|
||||
RequiredEnergyTags: []string{},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
GeneratedHazardCats: []string{"ai_misclassification"},
|
||||
SuggestedMeasureIDs: []string{"M103"},
|
||||
SuggestedMeasureIDs: []string{"M133", "M227", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E15"},
|
||||
Priority: 75,
|
||||
// Source: R590, R1090
|
||||
},
|
||||
{
|
||||
ID: "HP048", NameDE: "Kabelbaum — elektrisch", NameEN: "Cable Harness — electrical",
|
||||
ID: "HP1803", NameDE: "Kabelbaum — elektrisch", NameEN: "Cable Harness — electrical",
|
||||
RequiredComponentTags: []string{"electrical_part"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
RequiredLifecycles: []string{"installation", "operation"},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M062"},
|
||||
SuggestedMeasureIDs: []string{"M481", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E20"},
|
||||
Priority: 80,
|
||||
// Source: R065, R570, R1070
|
||||
},
|
||||
{
|
||||
ID: "HP049", NameDE: "Kabelsystem — elektrisch", NameEN: "Cable System — electrical",
|
||||
ID: "HP1804", NameDE: "Kabelsystem — elektrisch", NameEN: "Cable System — electrical",
|
||||
RequiredComponentTags: []string{"electrical_part"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
RequiredLifecycles: []string{"installation", "operation"},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M062"},
|
||||
SuggestedMeasureIDs: []string{"M481", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E20"},
|
||||
Priority: 80,
|
||||
// Source: R317, R318
|
||||
},
|
||||
{
|
||||
ID: "HP050", NameDE: "Kamerasystem — elektrisch", NameEN: "Camera System — electrical",
|
||||
ID: "HP1805", NameDE: "Kamerasystem — elektrisch", NameEN: "Camera System — electrical",
|
||||
RequiredComponentTags: []string{"sensor_part"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
GeneratedHazardCats: []string{"ai_misclassification"},
|
||||
SuggestedMeasureIDs: []string{"M082"},
|
||||
SuggestedMeasureIDs: []string{"M133", "M227", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E20"},
|
||||
Priority: 75,
|
||||
// Source: R074, R328
|
||||
},
|
||||
{
|
||||
ID: "HP051", NameDE: "Druckluftleitung — pneumatisch", NameEN: "Compressed Air Line — pneumatic",
|
||||
ID: "HP1806", NameDE: "Druckluftleitung — pneumatisch", NameEN: "Compressed Air Line — pneumatic",
|
||||
RequiredComponentTags: []string{"pneumatic_part"},
|
||||
RequiredEnergyTags: []string{"pneumatic_pressure"},
|
||||
RequiredLifecycles: []string{"maintenance"},
|
||||
@@ -89,7 +89,7 @@ func getExtendedHazardPatternsA() []HazardPattern {
|
||||
// Source: R070
|
||||
},
|
||||
{
|
||||
ID: "HP052", NameDE: "Kompressor — pneumatisch", NameEN: "Compressor — pneumatic",
|
||||
ID: "HP1807", NameDE: "Kompressor — pneumatisch", NameEN: "Compressor — pneumatic",
|
||||
RequiredComponentTags: []string{"high_pressure", "noise_source", "pneumatic_part"},
|
||||
RequiredEnergyTags: []string{"pneumatic_pressure"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
@@ -100,18 +100,18 @@ func getExtendedHazardPatternsA() []HazardPattern {
|
||||
// Source: R578, R1078
|
||||
},
|
||||
{
|
||||
ID: "HP053", NameDE: "Schaltschrank — elektrisch", NameEN: "Control Cabinet — electrical",
|
||||
ID: "HP1808", NameDE: "Schaltschrank — elektrisch", NameEN: "Control Cabinet — electrical",
|
||||
RequiredComponentTags: []string{"electrical_part", "high_voltage"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
RequiredLifecycles: []string{"maintenance", "operation"},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M061", "M063", "M121"},
|
||||
SuggestedMeasureIDs: []string{"M481", "M482", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E10", "E20"},
|
||||
Priority: 80,
|
||||
// Source: R061, R062, R315, R316, R566, R567, R1066, R1067
|
||||
},
|
||||
{
|
||||
ID: "HP054", NameDE: "Steuerungsschnittstelle — Software", NameEN: "Control Interface — software",
|
||||
ID: "HP1809", NameDE: "Steuerungsschnittstelle — Software", NameEN: "Control Interface — software",
|
||||
RequiredComponentTags: []string{"has_software", "user_interface"},
|
||||
RequiredEnergyTags: []string{},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
@@ -122,51 +122,51 @@ func getExtendedHazardPatternsA() []HazardPattern {
|
||||
// Source: R080, R334
|
||||
},
|
||||
{
|
||||
ID: "HP055", NameDE: "Steuerung — elektrisch", NameEN: "Controller — electrical",
|
||||
ID: "HP1810", NameDE: "Steuerung — elektrisch", NameEN: "Controller — electrical",
|
||||
RequiredComponentTags: []string{"has_software", "programmable"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
RequiredLifecycles: []string{"restart"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M106"},
|
||||
SuggestedMeasureIDs: []string{"M061", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E08"},
|
||||
Priority: 80,
|
||||
// Source: R339, R598, R1098
|
||||
},
|
||||
{
|
||||
ID: "HP056", NameDE: "Foerderband — mechanisch", NameEN: "Conveyor Belt — mechanical",
|
||||
ID: "HP1811", NameDE: "Foerderband — mechanisch", NameEN: "Conveyor Belt — mechanical",
|
||||
RequiredComponentTags: []string{"entanglement_risk", "moving_part", "rotating_part"},
|
||||
RequiredEnergyTags: []string{"kinetic"},
|
||||
RequiredLifecycles: []string{"automatic_operation", "cleaning", "operation"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M051", "M054", "M121"},
|
||||
SuggestedMeasureIDs: []string{"M061", "M002", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E20", "E21"},
|
||||
Priority: 80,
|
||||
// Source: R053, R054, R556, R557, R1056, R1057
|
||||
},
|
||||
{
|
||||
ID: "HP057", NameDE: "Foerdersystem — mechanisch", NameEN: "Conveyor System — mechanical",
|
||||
ID: "HP1812", NameDE: "Foerdersystem — mechanisch", NameEN: "Conveyor System — mechanical",
|
||||
RequiredComponentTags: []string{"entanglement_risk", "moving_part", "rotating_part"},
|
||||
RequiredEnergyTags: []string{"kinetic"},
|
||||
RequiredLifecycles: []string{"cleaning", "operation"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M051", "M054", "M121"},
|
||||
SuggestedMeasureIDs: []string{"M061", "M002", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E20", "E21"},
|
||||
Priority: 80,
|
||||
// Source: R305, R306
|
||||
},
|
||||
{
|
||||
ID: "HP058", NameDE: "Kuehlgeraet — thermisch", NameEN: "Cooling Unit — thermal",
|
||||
ID: "HP1813", NameDE: "Kuehlgeraet — thermisch", NameEN: "Cooling Unit — thermal",
|
||||
RequiredComponentTags: []string{"high_temperature"},
|
||||
RequiredEnergyTags: []string{"thermal"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
GeneratedHazardCats: []string{"thermal_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M022"},
|
||||
SuggestedMeasureIDs: []string{"M141"},
|
||||
SuggestedEvidenceIDs: []string{"E14"},
|
||||
Priority: 70,
|
||||
// Source: R581, R1081
|
||||
},
|
||||
{
|
||||
ID: "HP059", NameDE: "Kupplung — mechanisch", NameEN: "Coupling — mechanical",
|
||||
ID: "HP1814", NameDE: "Kupplung — mechanisch", NameEN: "Coupling — mechanical",
|
||||
RequiredComponentTags: []string{"rotating_part"},
|
||||
RequiredEnergyTags: []string{"kinetic"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
@@ -177,62 +177,62 @@ func getExtendedHazardPatternsA() []HazardPattern {
|
||||
// Source: R056, R564, R1064
|
||||
},
|
||||
{
|
||||
ID: "HP060", NameDE: "Diagnosemodul — Software", NameEN: "Diagnostic Module — software",
|
||||
ID: "HP1815", NameDE: "Diagnosemodul — Software", NameEN: "Diagnostic Module — software",
|
||||
RequiredComponentTags: []string{"has_software", "safety_device"},
|
||||
RequiredEnergyTags: []string{},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
GeneratedHazardCats: []string{"software_fault"},
|
||||
SuggestedMeasureIDs: []string{"M103"},
|
||||
SuggestedMeasureIDs: []string{"M105", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E14"},
|
||||
Priority: 70,
|
||||
// Source: R596, R1096
|
||||
},
|
||||
{
|
||||
ID: "HP061", NameDE: "Firewall — Software", NameEN: "Firewall — software",
|
||||
ID: "HP1816", NameDE: "Firewall — Software", NameEN: "Firewall — software",
|
||||
RequiredComponentTags: []string{"networked", "security_device"},
|
||||
RequiredEnergyTags: []string{},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
GeneratedHazardCats: []string{"unauthorized_access"},
|
||||
SuggestedMeasureIDs: []string{"M116"},
|
||||
SuggestedMeasureIDs: []string{"M188", "M186", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E16"},
|
||||
Priority: 85,
|
||||
// Source: R587, R1087
|
||||
},
|
||||
{
|
||||
ID: "HP062", NameDE: "Firmware — Software", NameEN: "Firmware — software",
|
||||
ID: "HP1817", NameDE: "Firmware — Software", NameEN: "Firmware — software",
|
||||
RequiredComponentTags: []string{"has_software", "programmable"},
|
||||
RequiredEnergyTags: []string{},
|
||||
RequiredLifecycles: []string{"software_update"},
|
||||
GeneratedHazardCats: []string{"update_failure"},
|
||||
SuggestedMeasureIDs: []string{"M104"},
|
||||
SuggestedMeasureIDs: []string{"M188", "M186", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E18"},
|
||||
Priority: 70,
|
||||
// Source: R338, R597, R1097
|
||||
},
|
||||
{
|
||||
ID: "HP063", NameDE: "Ofen — thermisch", NameEN: "Furnace — thermal",
|
||||
ID: "HP1818", NameDE: "Ofen — thermisch", NameEN: "Furnace — thermal",
|
||||
RequiredComponentTags: []string{"high_temperature"},
|
||||
RequiredEnergyTags: []string{"thermal"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
GeneratedHazardCats: []string{"thermal_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M015"},
|
||||
SuggestedMeasureIDs: []string{"M141"},
|
||||
SuggestedEvidenceIDs: []string{"E20"},
|
||||
Priority: 70,
|
||||
// Source: R326, R580, R1080
|
||||
},
|
||||
{
|
||||
ID: "HP064", NameDE: "Ofenkammer — thermisch", NameEN: "Furnace Chamber — thermal",
|
||||
ID: "HP1819", NameDE: "Ofenkammer — thermisch", NameEN: "Furnace Chamber — thermal",
|
||||
RequiredComponentTags: []string{"high_temperature"},
|
||||
RequiredEnergyTags: []string{"thermal"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
GeneratedHazardCats: []string{"thermal_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M015"},
|
||||
SuggestedMeasureIDs: []string{"M141"},
|
||||
SuggestedEvidenceIDs: []string{"E20"},
|
||||
Priority: 70,
|
||||
// Source: R072
|
||||
},
|
||||
{
|
||||
ID: "HP065", NameDE: "Getriebe — mechanisch", NameEN: "Gearbox — mechanical",
|
||||
ID: "HP1820", NameDE: "Getriebe — mechanisch", NameEN: "Gearbox — mechanical",
|
||||
RequiredComponentTags: []string{"pinch_point", "rotating_part"},
|
||||
RequiredEnergyTags: []string{"kinetic"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
@@ -243,18 +243,18 @@ func getExtendedHazardPatternsA() []HazardPattern {
|
||||
// Source: R055, R563, R1063
|
||||
},
|
||||
{
|
||||
ID: "HP066", NameDE: "Heizelement — thermisch", NameEN: "Heating Element — thermal",
|
||||
ID: "HP1821", NameDE: "Heizelement — thermisch", NameEN: "Heating Element — thermal",
|
||||
RequiredComponentTags: []string{"high_temperature"},
|
||||
RequiredEnergyTags: []string{"thermal"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
GeneratedHazardCats: []string{"thermal_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M015"},
|
||||
SuggestedMeasureIDs: []string{"M141"},
|
||||
SuggestedEvidenceIDs: []string{"E10"},
|
||||
Priority: 70,
|
||||
// Source: R071, R325, R579, R1079
|
||||
},
|
||||
{
|
||||
ID: "HP067", NameDE: "HMI-Bedienterminal — elektrisch", NameEN: "Hmi — electrical",
|
||||
ID: "HP1822", NameDE: "HMI-Bedienterminal — elektrisch", NameEN: "Hmi — electrical",
|
||||
RequiredComponentTags: []string{"has_software", "user_interface"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
@@ -265,7 +265,7 @@ func getExtendedHazardPatternsA() []HazardPattern {
|
||||
// Source: R333
|
||||
},
|
||||
{
|
||||
ID: "HP068", NameDE: "HMI-Panel — elektrisch", NameEN: "Hmi Panel — electrical",
|
||||
ID: "HP1823", NameDE: "HMI-Panel — elektrisch", NameEN: "Hmi Panel — electrical",
|
||||
RequiredComponentTags: []string{"has_software", "user_interface"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
@@ -276,7 +276,7 @@ func getExtendedHazardPatternsA() []HazardPattern {
|
||||
// Source: R079, R591, R1091
|
||||
},
|
||||
{
|
||||
ID: "HP069", NameDE: "Hydraulikzylinder — hydraulisch", NameEN: "Hydraulic Cylinder — hydraulic",
|
||||
ID: "HP1824", NameDE: "Hydraulikzylinder — hydraulisch", NameEN: "Hydraulic Cylinder — hydraulic",
|
||||
RequiredComponentTags: []string{"high_force", "high_pressure", "hydraulic_part", "moving_part"},
|
||||
RequiredEnergyTags: []string{"hydraulic_pressure"},
|
||||
RequiredLifecycles: []string{"maintenance", "operation"},
|
||||
@@ -287,7 +287,7 @@ func getExtendedHazardPatternsA() []HazardPattern {
|
||||
// Source: R066, R319, R320, R572, R1072
|
||||
},
|
||||
{
|
||||
ID: "HP070", NameDE: "Hydraulikschlauch — hydraulisch", NameEN: "Hydraulic Hose — hydraulic",
|
||||
ID: "HP1825", NameDE: "Hydraulikschlauch — hydraulisch", NameEN: "Hydraulic Hose — hydraulic",
|
||||
RequiredComponentTags: []string{"high_pressure", "hydraulic_part"},
|
||||
RequiredEnergyTags: []string{"hydraulic_pressure"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
@@ -298,7 +298,7 @@ func getExtendedHazardPatternsA() []HazardPattern {
|
||||
// Source: R067, R321, R573, R1073
|
||||
},
|
||||
{
|
||||
ID: "HP071", NameDE: "Hydraulikpumpe — hydraulisch", NameEN: "Hydraulic Pump — hydraulic",
|
||||
ID: "HP1826", NameDE: "Hydraulikpumpe — hydraulisch", NameEN: "Hydraulic Pump — hydraulic",
|
||||
RequiredComponentTags: []string{"high_pressure", "hydraulic_part"},
|
||||
RequiredEnergyTags: []string{"hydraulic_pressure"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
@@ -309,7 +309,7 @@ func getExtendedHazardPatternsA() []HazardPattern {
|
||||
// Source: R068, R322, R571, R1071
|
||||
},
|
||||
{
|
||||
ID: "HP072", NameDE: "Hydrauliksystem — hydraulisch", NameEN: "Hydraulic System — hydraulic",
|
||||
ID: "HP1827", NameDE: "Hydrauliksystem — hydraulisch", NameEN: "Hydraulic System — hydraulic",
|
||||
RequiredComponentTags: []string{"high_pressure", "hydraulic_part"},
|
||||
RequiredEnergyTags: []string{"hydraulic_pressure"},
|
||||
RequiredLifecycles: []string{"maintenance"},
|
||||
@@ -320,12 +320,12 @@ func getExtendedHazardPatternsA() []HazardPattern {
|
||||
// Source: R575, R1075
|
||||
},
|
||||
{
|
||||
ID: "HP073", NameDE: "Hydraulikventil — hydraulisch", NameEN: "Hydraulic Valve — hydraulic",
|
||||
ID: "HP1828", NameDE: "Hydraulikventil — hydraulisch", NameEN: "Hydraulic Valve — hydraulic",
|
||||
RequiredComponentTags: []string{"high_pressure", "hydraulic_part"},
|
||||
RequiredEnergyTags: []string{"hydraulic_pressure"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
GeneratedHazardCats: []string{"pneumatic_hydraulic"},
|
||||
SuggestedMeasureIDs: []string{"M022"},
|
||||
SuggestedMeasureIDs: []string{"M522", "M539", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E14"},
|
||||
Priority: 70,
|
||||
// Source: R574, R1074
|
||||
|
||||
@@ -5,18 +5,18 @@ package iace
|
||||
func getExtendedHazardPatternsB() []HazardPattern {
|
||||
return []HazardPattern{
|
||||
{
|
||||
ID: "HP074", NameDE: "Industrie-Switch — elektrisch", NameEN: "Industrial Switch — electrical",
|
||||
ID: "HP1830", NameDE: "Industrie-Switch — elektrisch", NameEN: "Industrial Switch — electrical",
|
||||
RequiredComponentTags: []string{"networked", "security_device"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
GeneratedHazardCats: []string{"communication_failure"},
|
||||
SuggestedMeasureIDs: []string{"M116"},
|
||||
SuggestedMeasureIDs: []string{"M113", "M106", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E08"},
|
||||
Priority: 70,
|
||||
// Source: R075, R329, R585, R1085
|
||||
},
|
||||
{
|
||||
ID: "HP075", NameDE: "Laserscanner — elektrisch", NameEN: "Laser Scanner — electrical",
|
||||
ID: "HP1831", NameDE: "Laserscanner — elektrisch", NameEN: "Laser Scanner — electrical",
|
||||
RequiredComponentTags: []string{"sensor_part"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
@@ -27,7 +27,7 @@ func getExtendedHazardPatternsB() []HazardPattern {
|
||||
// Source: R583, R1083
|
||||
},
|
||||
{
|
||||
ID: "HP076", NameDE: "Hubwerk — mechanisch", NameEN: "Lifting Device — mechanical",
|
||||
ID: "HP1832", NameDE: "Hubwerk — mechanisch", NameEN: "Lifting Device — mechanical",
|
||||
RequiredComponentTags: []string{"gravity_risk", "high_force", "moving_part"},
|
||||
RequiredEnergyTags: []string{"kinetic"},
|
||||
RequiredLifecycles: []string{"operation", "transport"},
|
||||
@@ -38,18 +38,18 @@ func getExtendedHazardPatternsB() []HazardPattern {
|
||||
// Source: R307, R308
|
||||
},
|
||||
{
|
||||
ID: "HP077", NameDE: "Hubtisch — hydraulisch", NameEN: "Lifting Table — hydraulic",
|
||||
ID: "HP1833", NameDE: "Hubtisch — hydraulisch", NameEN: "Lifting Table — hydraulic",
|
||||
RequiredComponentTags: []string{"gravity_risk", "moving_part"},
|
||||
RequiredEnergyTags: []string{"hydraulic_pressure"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M021"},
|
||||
SuggestedMeasureIDs: []string{"M482", "M481", "M522", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E11"},
|
||||
Priority: 80,
|
||||
// Source: R560, R1060
|
||||
},
|
||||
{
|
||||
ID: "HP078", NameDE: "Linearachse — mechanisch", NameEN: "Linear Axis — mechanical",
|
||||
ID: "HP1834", NameDE: "Linearachse — mechanisch", NameEN: "Linear Axis — mechanical",
|
||||
RequiredComponentTags: []string{"crush_point", "moving_part"},
|
||||
RequiredEnergyTags: []string{"kinetic"},
|
||||
RequiredLifecycles: []string{"automatic_operation", "maintenance", "setup"},
|
||||
@@ -60,7 +60,7 @@ func getExtendedHazardPatternsB() []HazardPattern {
|
||||
// Source: R051, R052, R301, R302
|
||||
},
|
||||
{
|
||||
ID: "HP079", NameDE: "Maschinenrahmen — mechanisch", NameEN: "Machine Frame — mechanical",
|
||||
ID: "HP1835", NameDE: "Maschinenrahmen — mechanisch", NameEN: "Machine Frame — mechanical",
|
||||
RequiredComponentTags: []string{"structural_part"},
|
||||
RequiredEnergyTags: []string{"kinetic"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
@@ -71,18 +71,18 @@ func getExtendedHazardPatternsB() []HazardPattern {
|
||||
// Source: R335, R593, R1093
|
||||
},
|
||||
{
|
||||
ID: "HP080", NameDE: "ML-Modell — Software", NameEN: "Ml Model — software",
|
||||
ID: "HP1836", NameDE: "ML-Modell — Software", NameEN: "Ml Model — software",
|
||||
RequiredComponentTags: []string{"has_ai", "has_software"},
|
||||
RequiredEnergyTags: []string{},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
GeneratedHazardCats: []string{"model_drift"},
|
||||
SuggestedMeasureIDs: []string{"M103"},
|
||||
SuggestedMeasureIDs: []string{"M133", "M227", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E15"},
|
||||
Priority: 75,
|
||||
// Source: R078, R332, R589, R1089
|
||||
},
|
||||
{
|
||||
ID: "HP081", NameDE: "Ueberwachungssystem — elektrisch", NameEN: "Monitoring System — electrical",
|
||||
ID: "HP1837", NameDE: "Ueberwachungssystem — elektrisch", NameEN: "Monitoring System — electrical",
|
||||
RequiredComponentTags: []string{"has_software", "safety_device"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
@@ -93,7 +93,7 @@ func getExtendedHazardPatternsB() []HazardPattern {
|
||||
// Source: R337, R595, R1095
|
||||
},
|
||||
{
|
||||
ID: "HP082", NameDE: "Palettierer — mechanisch", NameEN: "Palletizer — mechanical",
|
||||
ID: "HP1838", NameDE: "Palettierer — mechanisch", NameEN: "Palletizer — mechanical",
|
||||
RequiredComponentTags: []string{"high_force", "moving_part"},
|
||||
RequiredEnergyTags: []string{"kinetic"},
|
||||
RequiredLifecycles: []string{"automatic_operation"},
|
||||
@@ -104,18 +104,18 @@ func getExtendedHazardPatternsB() []HazardPattern {
|
||||
// Source: R559, R1059
|
||||
},
|
||||
{
|
||||
ID: "HP083", NameDE: "Plattform — mechanisch", NameEN: "Platform — mechanical",
|
||||
ID: "HP1839", NameDE: "Plattform — mechanisch", NameEN: "Platform — mechanical",
|
||||
RequiredComponentTags: []string{"gravity_risk", "structural_part"},
|
||||
RequiredEnergyTags: []string{"kinetic"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M052"},
|
||||
SuggestedMeasureIDs: []string{"M061", "M002", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E20"},
|
||||
Priority: 80,
|
||||
// Source: R336, R594, R1094
|
||||
},
|
||||
{
|
||||
ID: "HP084", NameDE: "Pneumatikzylinder — pneumatisch", NameEN: "Pneumatic Cylinder — pneumatic",
|
||||
ID: "HP1840", NameDE: "Pneumatikzylinder — pneumatisch", NameEN: "Pneumatic Cylinder — pneumatic",
|
||||
RequiredComponentTags: []string{"moving_part", "pneumatic_part", "stored_energy"},
|
||||
RequiredEnergyTags: []string{"pneumatic_pressure"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
@@ -126,7 +126,7 @@ func getExtendedHazardPatternsB() []HazardPattern {
|
||||
// Source: R069, R323, R576, R1076
|
||||
},
|
||||
{
|
||||
ID: "HP085", NameDE: "Pneumatikleitung — pneumatisch", NameEN: "Pneumatic Line — pneumatic",
|
||||
ID: "HP1841", NameDE: "Pneumatikleitung — pneumatisch", NameEN: "Pneumatic Line — pneumatic",
|
||||
RequiredComponentTags: []string{"pneumatic_part"},
|
||||
RequiredEnergyTags: []string{"pneumatic_pressure"},
|
||||
RequiredLifecycles: []string{"maintenance"},
|
||||
@@ -137,29 +137,29 @@ func getExtendedHazardPatternsB() []HazardPattern {
|
||||
// Source: R324, R577, R1077
|
||||
},
|
||||
{
|
||||
ID: "HP086", NameDE: "Stromversorgung — elektrisch", NameEN: "Power Supply — electrical",
|
||||
ID: "HP1842", NameDE: "Stromversorgung — elektrisch", NameEN: "Power Supply — electrical",
|
||||
RequiredComponentTags: []string{"electrical_part", "high_voltage"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
RequiredLifecycles: []string{"maintenance", "operation"},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M061", "M121"},
|
||||
SuggestedMeasureIDs: []string{"M481", "M482", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E14", "E20"},
|
||||
Priority: 80,
|
||||
// Source: R063, R311, R312, R568, R1068
|
||||
},
|
||||
{
|
||||
ID: "HP087", NameDE: "Naeherungssensor — elektrisch", NameEN: "Proximity Sensor — electrical",
|
||||
ID: "HP1843", NameDE: "Naeherungssensor — elektrisch", NameEN: "Proximity Sensor — electrical",
|
||||
RequiredComponentTags: []string{"sensor_part"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
GeneratedHazardCats: []string{"sensor_fault"},
|
||||
SuggestedMeasureIDs: []string{"M082"},
|
||||
SuggestedMeasureIDs: []string{"M214", "M119", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E08"},
|
||||
Priority: 70,
|
||||
// Source: R073, R327, R582, R1082
|
||||
},
|
||||
{
|
||||
ID: "HP088", NameDE: "Roboterarm — mechanisch", NameEN: "Robot Arm — mechanical",
|
||||
ID: "HP1844", NameDE: "Roboterarm — mechanisch", NameEN: "Robot Arm — mechanical",
|
||||
RequiredComponentTags: []string{"high_force", "moving_part", "rotating_part"},
|
||||
RequiredEnergyTags: []string{"kinetic"},
|
||||
RequiredLifecycles: []string{"automatic_operation", "maintenance", "teach"},
|
||||
@@ -170,18 +170,18 @@ func getExtendedHazardPatternsB() []HazardPattern {
|
||||
// Source: R303, R304, R551, R552, R1051, R1052
|
||||
},
|
||||
{
|
||||
ID: "HP089", NameDE: "Robotersteuerung — elektrisch", NameEN: "Robot Controller — electrical",
|
||||
ID: "HP1845", NameDE: "Robotersteuerung — elektrisch", NameEN: "Robot Controller — electrical",
|
||||
RequiredComponentTags: []string{"has_software", "programmable"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
GeneratedHazardCats: []string{"software_fault"},
|
||||
SuggestedMeasureIDs: []string{"M103"},
|
||||
SuggestedMeasureIDs: []string{"M533", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E14"},
|
||||
Priority: 70,
|
||||
// Source: R553, R1053
|
||||
},
|
||||
{
|
||||
ID: "HP090", NameDE: "Greifer — mechanisch", NameEN: "Robot Gripper — mechanical",
|
||||
ID: "HP1846", NameDE: "Greifer — mechanisch", NameEN: "Robot Gripper — mechanical",
|
||||
RequiredComponentTags: []string{"clamping_part", "moving_part", "pinch_point"},
|
||||
RequiredEnergyTags: []string{"kinetic"},
|
||||
RequiredLifecycles: []string{"automatic_operation", "operation", "setup"},
|
||||
@@ -192,7 +192,7 @@ func getExtendedHazardPatternsB() []HazardPattern {
|
||||
// Source: R057, R058, R554
|
||||
},
|
||||
{
|
||||
ID: "HP091", NameDE: "Greifer — pneumatisch", NameEN: "Robot Gripper — pneumatic",
|
||||
ID: "HP1847", NameDE: "Greifer — pneumatisch", NameEN: "Robot Gripper — pneumatic",
|
||||
RequiredComponentTags: []string{"clamping_part", "moving_part", "pinch_point"},
|
||||
RequiredEnergyTags: []string{"pneumatic_pressure"},
|
||||
RequiredLifecycles: []string{"maintenance", "operation"},
|
||||
@@ -203,18 +203,18 @@ func getExtendedHazardPatternsB() []HazardPattern {
|
||||
// Source: R555, R1054, R1055
|
||||
},
|
||||
{
|
||||
ID: "HP092", NameDE: "Rollenfoerderer — mechanisch", NameEN: "Roller Conveyor — mechanical",
|
||||
ID: "HP1848", NameDE: "Rollenfoerderer — mechanisch", NameEN: "Roller Conveyor — mechanical",
|
||||
RequiredComponentTags: []string{"entanglement_risk", "moving_part", "rotating_part"},
|
||||
RequiredEnergyTags: []string{"kinetic"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M051"},
|
||||
SuggestedMeasureIDs: []string{"M061", "M002", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E20"},
|
||||
Priority: 80,
|
||||
// Source: R558, R1058
|
||||
},
|
||||
{
|
||||
ID: "HP093", NameDE: "Drehtisch — mechanisch", NameEN: "Rotary Table — mechanical",
|
||||
ID: "HP1849", NameDE: "Drehtisch — mechanisch", NameEN: "Rotary Table — mechanical",
|
||||
RequiredComponentTags: []string{"high_force", "rotating_part"},
|
||||
RequiredEnergyTags: []string{"kinetic"},
|
||||
RequiredLifecycles: []string{"automatic_operation", "maintenance"},
|
||||
@@ -225,18 +225,18 @@ func getExtendedHazardPatternsB() []HazardPattern {
|
||||
// Source: R309, R310
|
||||
},
|
||||
{
|
||||
ID: "HP094", NameDE: "Drehscheibe — mechanisch", NameEN: "Rotating Disc — mechanical",
|
||||
ID: "HP1850", NameDE: "Drehscheibe — mechanisch", NameEN: "Rotating Disc — mechanical",
|
||||
RequiredComponentTags: []string{"high_speed", "rotating_part"},
|
||||
RequiredEnergyTags: []string{"kinetic"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M051"},
|
||||
SuggestedMeasureIDs: []string{"M061", "M002", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E20"},
|
||||
Priority: 80,
|
||||
// Source: R565, R1065
|
||||
},
|
||||
{
|
||||
ID: "HP095", NameDE: "Spindel — mechanisch", NameEN: "Rotating Spindle — mechanical",
|
||||
ID: "HP1851", NameDE: "Spindel — mechanisch", NameEN: "Rotating Spindle — mechanical",
|
||||
RequiredComponentTags: []string{"cutting_part", "high_speed", "rotating_part"},
|
||||
RequiredEnergyTags: []string{"kinetic"},
|
||||
RequiredLifecycles: []string{"maintenance", "operation"},
|
||||
@@ -247,7 +247,7 @@ func getExtendedHazardPatternsB() []HazardPattern {
|
||||
// Source: R561, R562, R1061, R1062
|
||||
},
|
||||
{
|
||||
ID: "HP096", NameDE: "Router — elektrisch", NameEN: "Router — electrical",
|
||||
ID: "HP1852", NameDE: "Router — elektrisch", NameEN: "Router — electrical",
|
||||
RequiredComponentTags: []string{"networked", "security_device"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
@@ -258,7 +258,7 @@ func getExtendedHazardPatternsB() []HazardPattern {
|
||||
// Source: R076, R330, R586, R1086
|
||||
},
|
||||
{
|
||||
ID: "HP097", NameDE: "Gesamtsystem — gemischt", NameEN: "System — mixed",
|
||||
ID: "HP1853", NameDE: "Gesamtsystem — gemischt", NameEN: "System — mixed",
|
||||
RequiredComponentTags: []string{"has_software"},
|
||||
RequiredEnergyTags: []string{},
|
||||
RequiredLifecycles: []string{"operation", "safety_validation"},
|
||||
@@ -269,18 +269,18 @@ func getExtendedHazardPatternsB() []HazardPattern {
|
||||
// Source: R599, R600, R1099, R1100
|
||||
},
|
||||
{
|
||||
ID: "HP098", NameDE: "Werkzeugwechsler — mechanisch", NameEN: "Tool Changer — mechanical",
|
||||
ID: "HP1854", NameDE: "Werkzeugwechsler — mechanisch", NameEN: "Tool Changer — mechanical",
|
||||
RequiredComponentTags: []string{"moving_part", "pinch_point"},
|
||||
RequiredEnergyTags: []string{"kinetic"},
|
||||
RequiredLifecycles: []string{"maintenance", "operation"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M051"},
|
||||
SuggestedMeasureIDs: []string{"M061", "M002", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E14", "E20"},
|
||||
Priority: 80,
|
||||
// Source: R059, R060
|
||||
},
|
||||
{
|
||||
ID: "HP099", NameDE: "Touch-Bedienfeld — Software", NameEN: "Touch Interface — software",
|
||||
ID: "HP1855", NameDE: "Touch-Bedienfeld — Software", NameEN: "Touch Interface — software",
|
||||
RequiredComponentTags: []string{"has_software", "user_interface"},
|
||||
RequiredEnergyTags: []string{},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
@@ -291,34 +291,34 @@ func getExtendedHazardPatternsB() []HazardPattern {
|
||||
// Source: R592, R1092
|
||||
},
|
||||
{
|
||||
ID: "HP100", NameDE: "Transformator — elektrisch", NameEN: "Transformer — electrical",
|
||||
ID: "HP1856", NameDE: "Transformator — elektrisch", NameEN: "Transformer — electrical",
|
||||
RequiredComponentTags: []string{"electrical_part", "high_voltage"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
RequiredLifecycles: []string{"inspection", "operation"},
|
||||
GeneratedHazardCats: []string{"electrical_hazard", "thermal_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M014", "M062"},
|
||||
SuggestedMeasureIDs: []string{"M481", "M477", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E10"},
|
||||
Priority: 80,
|
||||
// Source: R064, R313, R314, R569, R1069
|
||||
},
|
||||
{
|
||||
ID: "HP101", NameDE: "KI-Bilderkennung — Software", NameEN: "Vision Ai — software",
|
||||
ID: "HP1857", NameDE: "KI-Bilderkennung — Software", NameEN: "Vision Ai — software",
|
||||
RequiredComponentTags: []string{"has_ai", "sensor_part"},
|
||||
RequiredEnergyTags: []string{},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
GeneratedHazardCats: []string{"sensor_fault"},
|
||||
SuggestedMeasureIDs: []string{"M103"},
|
||||
SuggestedMeasureIDs: []string{"M214", "M119", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E15"},
|
||||
Priority: 70,
|
||||
// Source: R077, R331, R588, R1088
|
||||
},
|
||||
{
|
||||
ID: "HP102", NameDE: "Vision-Kamera — elektrisch", NameEN: "Vision Camera — electrical",
|
||||
ID: "HP1858", NameDE: "Vision-Kamera — elektrisch", NameEN: "Vision Camera — electrical",
|
||||
RequiredComponentTags: []string{"sensor_part"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
RequiredLifecycles: []string{"operation"},
|
||||
GeneratedHazardCats: []string{"ai_misclassification"},
|
||||
SuggestedMeasureIDs: []string{"M082"},
|
||||
SuggestedMeasureIDs: []string{"M133", "M227", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E20"},
|
||||
Priority: 75,
|
||||
// Source: R584, R1084
|
||||
|
||||
@@ -95,7 +95,7 @@ func GetExtendedHazardPatterns2() []HazardPattern {
|
||||
RequiredEnergyTags: []string{},
|
||||
RequiredLifecycles: []string{"maintenance"},
|
||||
GeneratedHazardCats: []string{"maintenance_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M121"},
|
||||
SuggestedMeasureIDs: []string{"M186", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E14", "E20"},
|
||||
Priority: 70,
|
||||
ScenarioDE: "Nicht-originale Ersatzteile oder improvisierte Reparaturen beeintraechtigen die Sicherheit.",
|
||||
@@ -146,7 +146,7 @@ func GetExtendedHazardPatterns2() []HazardPattern {
|
||||
RequiredComponentTags: []string{"electrical_part"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M014"},
|
||||
SuggestedMeasureIDs: []string{"M088", "M329", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E10"},
|
||||
Priority: 55,
|
||||
ScenarioDE: "Statische Aufladung bei Folientransport oder Granulat fuehrt zu Funkenbildung.",
|
||||
@@ -161,7 +161,7 @@ func GetExtendedHazardPatterns2() []HazardPattern {
|
||||
RequiredComponentTags: []string{"electrical_part", "high_voltage"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M061", "M062"},
|
||||
SuggestedMeasureIDs: []string{"M515", "M511", "M514", "M512"},
|
||||
SuggestedEvidenceIDs: []string{"E10", "E14"},
|
||||
Priority: 90,
|
||||
ScenarioDE: "Korrodierte oder unterbrochene Schutzleiter fuehren bei Isolationsfehler zu Koerperdurchstroemung.",
|
||||
@@ -176,7 +176,7 @@ func GetExtendedHazardPatterns2() []HazardPattern {
|
||||
RequiredComponentTags: []string{"electrical_part", "stored_energy"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M061", "M121"},
|
||||
SuggestedMeasureIDs: []string{"M047", "M046", "M522", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E10", "E14"},
|
||||
Priority: 90,
|
||||
ScenarioDE: "Kondensatoren oder Zwischenkreise halten Spannung nach Abschaltung.",
|
||||
@@ -423,7 +423,7 @@ func GetExtendedHazardPatterns2() []HazardPattern {
|
||||
RequiredComponentTags: []string{"has_software", "programmable"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"safety_function_failure"},
|
||||
SuggestedMeasureIDs: []string{"M103"},
|
||||
SuggestedMeasureIDs: []string{"M186", "M187", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E14", "E15"},
|
||||
Priority: 75,
|
||||
ScenarioDE: "Steuerungsparameter gehen bei Spannungsausfall verloren, Maschine startet mit Werkseinstellungen.",
|
||||
|
||||
@@ -101,7 +101,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"gravity_risk", "moving_part"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M051"},
|
||||
SuggestedMeasureIDs: []string{"M008", "M002", "M061", "M141"},
|
||||
Priority: 80, MachineTypes: []string{"crane", "construction"},
|
||||
ScenarioDE: "Unkontrolliertes Schwingen einer angehobenen Last", HarmDE: "Quetschung, Erschlagen durch pendelnde Last",
|
||||
TriggerDE: "Schraeger Zug oder ploetzliches Abstoppen", AffectedDE: "Kranfuehrer, Anschlaeger", ZoneDE: "Schwenkbereich des Krans", DefaultSeverity: 4, DefaultExposure: 3,
|
||||
@@ -125,7 +125,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"high_voltage", "electrical_part"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
GeneratedHazardCats: []string{"electrical_hazard", "thermal_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M054"},
|
||||
SuggestedMeasureIDs: []string{"M520", "M477", "M138", "M141"},
|
||||
Priority: 95,
|
||||
ExpertHintDE: "Lichtbogenschutz (Arc Flash) — PSA Kategorie und Schutzabstand berechnen.",
|
||||
ScenarioDE: "Lichtbogenbildung bei Kurzschluss in Schaltanlage", HarmDE: "Schwere Verbrennungen, Augenverletzungen, Gehoerschaden",
|
||||
@@ -136,7 +136,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"electrical_part", "stored_energy"},
|
||||
RequiredEnergyTags: []string{"stored_energy"},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M054"},
|
||||
SuggestedMeasureIDs: []string{"M047", "M522", "M141"},
|
||||
Priority: 88,
|
||||
ExpertHintDE: "Entladezeit abwarten oder Entladewiderstand vorsehen. Spannungsfreiheit messen.",
|
||||
ScenarioDE: "Elektrischer Schlag durch geladenen Kondensator nach Abschaltung", HarmDE: "Elektrischer Schlag, Herzrhythmusstoerungen",
|
||||
@@ -147,7 +147,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"electrical_part"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M054"},
|
||||
SuggestedMeasureIDs: []string{"M088", "M329", "M141"},
|
||||
Priority: 72,
|
||||
ScenarioDE: "Statische Entladung zuendet brennbare Atmosphaere", HarmDE: "Verbrennung, Explosion",
|
||||
TriggerDE: "Funkenentladung bei ungeerdetem Material", AffectedDE: "Bedienpersonal", ZoneDE: "Ex-Bereich, Lackierzone", DefaultSeverity: 4, DefaultExposure: 2,
|
||||
@@ -157,7 +157,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"high_voltage"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M054"},
|
||||
SuggestedMeasureIDs: []string{"M088", "M515", "M514", "M518"},
|
||||
Priority: 92,
|
||||
ScenarioDE: "Koerperdurchstroemung bei defekter Schutzerdung", HarmDE: "Elektrischer Schlag, Herzkammerflimmern, Tod",
|
||||
TriggerDE: "Beruehrung eines fehlerhaft geerdeten Gehaeuses", AffectedDE: "Bedienpersonal", ZoneDE: "Maschinengehaeuse, Schaltschrank", DefaultSeverity: 5, DefaultExposure: 3,
|
||||
@@ -167,7 +167,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"electrical_part"},
|
||||
RequiredEnergyTags: []string{"electromagnetic"},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M054"},
|
||||
SuggestedMeasureIDs: []string{"M046", "M047", "M141"},
|
||||
Priority: 70,
|
||||
ScenarioDE: "Gefaehrliche Induktionsspannung in abgeschalteter Leitung", HarmDE: "Elektrischer Schlag",
|
||||
TriggerDE: "Parallelfuehrung zu aktiven Hochspannungsleitungen", AffectedDE: "Elektrofachkraft", ZoneDE: "Kabeltrasse, Freileitungsbereich", DefaultSeverity: 3, DefaultExposure: 2,
|
||||
@@ -177,7 +177,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"electrical_part"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
GeneratedHazardCats: []string{"electrical_hazard", "thermal_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M054"},
|
||||
SuggestedMeasureIDs: []string{"M520", "M519", "M141"},
|
||||
Priority: 85,
|
||||
ScenarioDE: "Kabelbrand durch Ueberstrom bei fehlender Absicherung", HarmDE: "Verbrennungen, Rauchvergiftung, Anlagenbrand",
|
||||
TriggerDE: "Ueberlast oder defekte Sicherung", AffectedDE: "Alle Personen im Gebaeude", ZoneDE: "Kabelkanal, Verteiler", DefaultSeverity: 4, DefaultExposure: 2,
|
||||
@@ -191,7 +191,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"chemical_risk"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"material_environmental"},
|
||||
SuggestedMeasureIDs: []string{"M054", "M124"},
|
||||
SuggestedMeasureIDs: []string{"M388", "M386", "M385", "M384", "M141"},
|
||||
Priority: 95,
|
||||
RequiresExpertCalculation: true,
|
||||
ExpertHintDE: "Explosionsschutz-Dokument erforderlich. Zoneneinteilung und Zuendquellenanalyse.",
|
||||
@@ -310,7 +310,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"electrical_part"},
|
||||
RequiredEnergyTags: []string{"electromagnetic"},
|
||||
GeneratedHazardCats: []string{"material_environmental"},
|
||||
SuggestedMeasureIDs: []string{"M054"},
|
||||
SuggestedMeasureIDs: []string{"M141", "M533"},
|
||||
Priority: 70,
|
||||
ScenarioDE: "Exposition gegenueber elektromagnetischen Feldern bei Induktionsanlage", HarmDE: "Erwaermung von Implantaten, Herzschrittmacher-Stoerung",
|
||||
TriggerDE: "Aufenthalt im Nahfeld ohne Abschirmung", AffectedDE: "Bedienpersonal, Implantattraeger", ZoneDE: "Induktionsanlage", DefaultSeverity: 3, DefaultExposure: 3,
|
||||
@@ -320,7 +320,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"sensor_part"},
|
||||
RequiredEnergyTags: []string{"radiation"},
|
||||
GeneratedHazardCats: []string{"material_environmental"},
|
||||
SuggestedMeasureIDs: []string{"M001", "M054"},
|
||||
SuggestedMeasureIDs: []string{"M392", "M141", "M533"},
|
||||
Priority: 95,
|
||||
RequiresExpertCalculation: true,
|
||||
ExpertHintDE: "Strahlenschutzbeauftragter und Genehmigung erforderlich.",
|
||||
@@ -356,7 +356,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"chemical_risk"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"material_environmental"},
|
||||
SuggestedMeasureIDs: []string{"M054", "M124"},
|
||||
SuggestedMeasureIDs: []string{"M388", "M385", "M386", "M141"},
|
||||
Priority: 96,
|
||||
RequiresExpertCalculation: true,
|
||||
ExpertHintDE: "Explosionsschutz-Dokument. ATEX-Zoneneinteilung erforderlich.",
|
||||
@@ -378,7 +378,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"chemical_risk", "cutting_part"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"material_environmental"},
|
||||
SuggestedMeasureIDs: []string{"M054", "M124"},
|
||||
SuggestedMeasureIDs: []string{"M386", "M387", "M141"},
|
||||
Priority: 92,
|
||||
ExpertHintDE: "Metallbraende nur mit Spezialloeschmittel (Klasse D). Kein Wasser!",
|
||||
ScenarioDE: "Metallbrand bei Schleifen/Fraesen von Leichtmetallstaub", HarmDE: "Unkontrollierbarer Brand, Explosion bei Wasserloeschversuch",
|
||||
@@ -401,7 +401,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"chemical_risk"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"material_environmental"},
|
||||
SuggestedMeasureIDs: []string{"M054"},
|
||||
SuggestedMeasureIDs: []string{"M385", "M386", "M141"},
|
||||
Priority: 80,
|
||||
ScenarioDE: "Erhoehte Entzuendbarkeit aller Materialien bei Sauerstoffanreicherung", HarmDE: "Schnelle Brandausbreitung, schwere Verbrennungen",
|
||||
TriggerDE: "Sauerstoffleckage in geschlossenem Raum", AffectedDE: "Alle Personen im Bereich", ZoneDE: "Sauerstoff-Versorgungsanlage", DefaultSeverity: 4, DefaultExposure: 2,
|
||||
@@ -434,8 +434,8 @@ func GetDGUVExtendedPatterns() []HazardPattern {
|
||||
ID: "HP132", NameDE: "Ganzkoepervibrration bei Fahrzeugen/Maschinen", NameEN: "Whole-body vibration from vehicles/machines",
|
||||
RequiredComponentTags: []string{"vibration_source"},
|
||||
RequiredEnergyTags: []string{"vibration"},
|
||||
GeneratedHazardCats: []string{"noise_vibration"},
|
||||
SuggestedMeasureIDs: []string{"M054"},
|
||||
GeneratedHazardCats: []string{"vibration_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M141"},
|
||||
Priority: 65,
|
||||
ScenarioDE: "Ganzkoerpervibration bei Fahren von Flurfoerderfahrzeugen", HarmDE: "Wirbelsaeulenschaeden, Bandscheibenvorfall",
|
||||
TriggerDE: "Langzeitexposition auf ungefedertem Sitz", AffectedDE: "Fahrpersonal", ZoneDE: "Fahrzeugfuehrerstand", DefaultSeverity: 3, DefaultExposure: 4,
|
||||
@@ -444,7 +444,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
|
||||
ID: "HP133", NameDE: "Hand-Arm-Vibration bei handgefuehrten Werkzeugen", NameEN: "Hand-arm vibration from handheld tools",
|
||||
RequiredComponentTags: []string{"vibration_source"},
|
||||
RequiredEnergyTags: []string{"vibration"},
|
||||
GeneratedHazardCats: []string{"noise_vibration"},
|
||||
GeneratedHazardCats: []string{"vibration_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M054", "M141"},
|
||||
Priority: 70,
|
||||
ScenarioDE: "Hand-Arm-Vibration durch handgefuehrtes Schlagwerkzeug", HarmDE: "Durchblutungsstoerungen, Weissfingerkrankheit",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user