Compare commits

..

1 Commits

Author SHA1 Message Date
Benjamin Admin dd420ff85b fix(mc): defensive mapping queries + MinIO env overridable + iace migration 151
CI / detect-changes (pull_request) Failing after 6s
CI / branch-name (pull_request) Successful in 1s
CI / guardrail-integrity (pull_request) Failing after 4s
CI / secret-scan (pull_request) Failing after 6s
CI / dep-audit (pull_request) Failing after 12s
CI / sbom-scan (pull_request) Failing after 2s
CI / build-sha-integrity (pull_request) Failing after 4s
CI / validate-canonical-controls (pull_request) Failing after 9s
CI / loc-budget (pull_request) Has been skipped
CI / go-lint (pull_request) Has been skipped
CI / python-lint (pull_request) Has been skipped
CI / nodejs-lint (pull_request) Has been skipped
CI / nodejs-build (pull_request) Has been skipped
CI / test-go (pull_request) Has been skipped
CI / iace-gt-coverage (pull_request) Has been skipped
CI / test-python-backend (pull_request) Has been skipped
CI / test-python-document-crawler (pull_request) Has been skipped
CI / test-python-dsms-gateway (pull_request) Has been skipped
- master-controls route: guard all mapping queries with hasMappingTables() so
  an unseeded DB degrades to empty filters instead of a 500.
- docker-compose: MinIO endpoint/keys/secure overridable via env (prod defaults
  preserved) — enables per-environment local config.
- migration 151: reproducible iace_projects.parent_project_id (was ad-hoc).

[migration-approved]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:06:22 +02:00
260 changed files with 862 additions and 10721 deletions
+1 -1
View File
@@ -422,7 +422,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
run: | run: |
apk add --no-cache git python3 py3-yaml apk add --no-cache git python3
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git . git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
- name: Validate every Dockerfile + compose block declares BUILD_SHA - name: Validate every Dockerfile + compose block declares BUILD_SHA
run: | run: |
@@ -211,7 +211,7 @@ export async function handleV2Draft(body: Record<string, unknown>): Promise<Next
}, { status: 403 }) }, { status: 403 })
} }
const scores = extractScoresFromDraftContext(draftContext as unknown as Parameters<typeof extractScoresFromDraftContext>[0]) const scores = extractScoresFromDraftContext(draftContext)
const narrativeTags: NarrativeTags = deriveNarrativeTags(scores) const narrativeTags: NarrativeTags = deriveNarrativeTags(scores)
const allowedFacts = buildAllowedFactsFromDraftContext(draftContext, narrativeTags) const allowedFacts = buildAllowedFactsFromDraftContext(draftContext, narrativeTags)
@@ -1,41 +0,0 @@
/**
* Compliance-Check SSE-Proxy
* GET /api/sdk/v1/agent/compliance-check/{check_id}/stream
* → backend /api/compliance/agent/compliance-check/{check_id}/stream
*
* Reicht den text/event-stream-Body unmodifiziert durch (progressive
* topic-/progress-Events fürs Frontend). Additiv zum Polling.
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL =
process.env.BACKEND_API_URL || process.env.BACKEND_URL ||
'http://backend-compliance:8002'
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ check_id: string }> },
) {
const { check_id } = await params
try {
const response = await fetch(
`${BACKEND_URL}/api/compliance/agent/compliance-check/${check_id}/stream`,
{ signal: AbortSignal.timeout(1_800_000) }, // 30 min
)
return new NextResponse(response.body, {
status: response.status,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
},
})
} catch {
return NextResponse.json(
{ error: 'SSE-Stream zum Backend fehlgeschlagen' },
{ status: 503 },
)
}
}
@@ -8,10 +8,10 @@ const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:80
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ checkId: string }> }, { params }: { params: { checkId: string } },
) { ) {
const qs = request.nextUrl.searchParams.toString() const qs = request.nextUrl.searchParams.toString()
const url = `${BACKEND_URL}/api/compliance/agent/migration/${(await params).checkId}/banner-preview${qs ? `?${qs}` : ''}` const url = `${BACKEND_URL}/api/compliance/agent/migration/${params.checkId}/banner-preview${qs ? `?${qs}` : ''}`
try { try {
const resp = await fetch(url, { signal: AbortSignal.timeout(15000) }) const resp = await fetch(url, { signal: AbortSignal.timeout(15000) })
const data = await resp.json() const data = await resp.json()
@@ -8,9 +8,9 @@ const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:80
export async function GET( export async function GET(
_request: NextRequest, _request: NextRequest,
{ params }: { params: Promise<{ checkId: string }> }, { params }: { params: { checkId: string } },
) { ) {
const url = `${BACKEND_URL}/api/compliance/agent/migration/${(await params).checkId}/document-preview` const url = `${BACKEND_URL}/api/compliance/agent/migration/${params.checkId}/document-preview`
try { try {
const resp = await fetch(url, { signal: AbortSignal.timeout(15000) }) const resp = await fetch(url, { signal: AbortSignal.timeout(15000) })
const data = await resp.json() const data = await resp.json()
@@ -8,9 +8,9 @@ const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:80
export async function GET( export async function GET(
_request: NextRequest, _request: NextRequest,
{ params }: { params: Promise<{ checkId: string }> }, { params }: { params: { checkId: string } },
) { ) {
const url = `${BACKEND_URL}/api/compliance/agent/migration/${(await params).checkId}/summary` const url = `${BACKEND_URL}/api/compliance/agent/migration/${params.checkId}/summary`
try { try {
const resp = await fetch(url, { signal: AbortSignal.timeout(15000) }) const resp = await fetch(url, { signal: AbortSignal.timeout(15000) })
const data = await resp.json() const data = await resp.json()
@@ -1,34 +0,0 @@
/**
* AGB-Analyse-Proxy
* GET /api/sdk/v1/agent/snapshots/{snapshotId}/agb-check
* → backend /api/compliance/agent/snapshots/{snapshotId}/agb-check
*
* Laeuft den kuratierten AGBAgent (§§ 305 ff. BGB) auf dem gespeicherten
* AGB-Text (kein Re-Crawl).
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL =
process.env.BACKEND_API_URL || process.env.BACKEND_URL ||
'http://backend-compliance:8002'
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ snapshotId: string }> },
) {
const { snapshotId } = await params
try {
const response = await fetch(
`${BACKEND_URL}/api/compliance/agent/snapshots/${snapshotId}/agb-check`,
{ signal: AbortSignal.timeout(120_000) },
)
const data = await response.json()
return NextResponse.json(data, { status: response.status })
} catch {
return NextResponse.json(
{ error: 'AGB-Analyse fehlgeschlagen', findings: [] },
{ status: 503 },
)
}
}
@@ -1,33 +0,0 @@
/**
* Cookie-Library-Abgleich-Proxy
* GET /api/sdk/v1/agent/snapshots/{snapshotId}/cookie-check
* → backend /api/compliance/agent/snapshots/{snapshotId}/cookie-check
*
* Pro-Cookie-Abgleich gegen die cookie_knowledge_db (deklariert vs. echt).
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL =
process.env.BACKEND_API_URL || process.env.BACKEND_URL ||
'http://backend-compliance:8002'
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ snapshotId: string }> },
) {
const { snapshotId } = await params
try {
const response = await fetch(
`${BACKEND_URL}/api/compliance/agent/snapshots/${snapshotId}/cookie-check`,
{ signal: AbortSignal.timeout(60_000) },
)
const data = await response.json()
return NextResponse.json(data, { status: response.status })
} catch {
return NextResponse.json(
{ error: 'Cookie-Library-Abgleich fehlgeschlagen', findings: [] },
{ status: 503 },
)
}
}
@@ -1,34 +0,0 @@
/**
* DSE-Analyse-Proxy
* GET /api/sdk/v1/agent/snapshots/{snapshotId}/dse-check
* → backend /api/compliance/agent/snapshots/{snapshotId}/dse-check
*
* Laeuft den kuratierten DSEAgent (Art. 13/14, ART13_CHECKLIST — kein
* Library-Firehose) auf dem gespeicherten DSE-Text (kein Re-Crawl).
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL =
process.env.BACKEND_API_URL || process.env.BACKEND_URL ||
'http://backend-compliance:8002'
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ snapshotId: string }> },
) {
const { snapshotId } = await params
try {
const response = await fetch(
`${BACKEND_URL}/api/compliance/agent/snapshots/${snapshotId}/dse-check`,
{ signal: AbortSignal.timeout(120_000) },
)
const data = await response.json()
return NextResponse.json(data, { status: response.status })
} catch {
return NextResponse.json(
{ error: 'DSE-Analyse fehlgeschlagen', findings: [] },
{ status: 503 },
)
}
}
@@ -1,34 +0,0 @@
/**
* Impressum-Analyse-Proxy
* GET /api/sdk/v1/agent/snapshots/{snapshotId}/impressum-check
* → backend /api/compliance/agent/snapshots/{snapshotId}/impressum-check
*
* Laeuft den v3 ImpressumAgent auf dem gespeicherten Impressum-Text
* (kein Re-Crawl) und liefert den AgentOutput (Findings/Massnahmen/Coverage).
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL =
process.env.BACKEND_API_URL || process.env.BACKEND_URL ||
'http://backend-compliance:8002'
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ snapshotId: string }> },
) {
const { snapshotId } = await params
try {
const response = await fetch(
`${BACKEND_URL}/api/compliance/agent/snapshots/${snapshotId}/impressum-check`,
{ signal: AbortSignal.timeout(120_000) },
)
const data = await response.json()
return NextResponse.json(data, { status: response.status })
} catch {
return NextResponse.json(
{ error: 'Impressum-Analyse fehlgeschlagen', findings: [] },
{ status: 503 },
)
}
}
@@ -1,34 +0,0 @@
/**
* Snapshot-Proxy
* GET /api/sdk/v1/agent/snapshots/{snapshotId}
* → backend /api/compliance/agent/snapshots/{snapshotId}
*
* Liefert die persistierten Roh-Daten eines Checks (cmp_vendors + Cookies +
* banner_result) — Basis für den Cookie-Result-View OHNE Re-Crawl.
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL =
process.env.BACKEND_API_URL || process.env.BACKEND_URL ||
'http://backend-compliance:8002'
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ snapshotId: string }> },
) {
const { snapshotId } = await params
try {
const response = await fetch(
`${BACKEND_URL}/api/compliance/agent/snapshots/${snapshotId}`,
{ signal: AbortSignal.timeout(60_000) },
)
const data = await response.json()
return NextResponse.json(data, { status: response.status })
} catch {
return NextResponse.json(
{ error: 'Snapshot-Laden zum Backend fehlgeschlagen' },
{ status: 503 },
)
}
}
@@ -1,33 +0,0 @@
/**
* Snapshot-Liste (Historie)
* GET /api/sdk/v1/agent/snapshots?domain=&limit=
* → backend /api/compliance/agent/snapshots
*
* Ohne domain: alle letzten Snapshots (Historie zum Durchklicken).
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL =
process.env.BACKEND_API_URL || process.env.BACKEND_URL ||
'http://backend-compliance:8002'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const domain = searchParams.get('domain') || ''
const limit = searchParams.get('limit') || '50'
try {
const response = await fetch(
`${BACKEND_URL}/api/compliance/agent/snapshots`
+ `?domain=${encodeURIComponent(domain)}&limit=${encodeURIComponent(limit)}`,
{ signal: AbortSignal.timeout(30_000) },
)
const data = await response.json()
return NextResponse.json(data, { status: response.status })
} catch {
return NextResponse.json(
{ error: 'Snapshot-Liste zum Backend fehlgeschlagen', snapshots: [] },
{ status: 503 },
)
}
}
@@ -5,9 +5,9 @@ import { NextRequest, NextResponse } from 'next/server'
const DSMS_URL = process.env.DSMS_GATEWAY_URL || 'http://dsms-gateway:8082' const DSMS_URL = process.env.DSMS_GATEWAY_URL || 'http://dsms-gateway:8082'
export async function GET(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) { export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params const { path } = await params
const target = `${DSMS_URL}/api/v1/${(path || []).join('/')}` const target = `${DSMS_URL}/api/v1/${path.join('/')}`
try { try {
const resp = await fetch(target, { const resp = await fetch(target, {
@@ -15,13 +15,10 @@ const pool = new Pool({ connectionString: dbUrl })
let metaCache: { at: number; data: unknown } | null = null let metaCache: { at: number; data: unknown } | null = null
const META_TTL_MS = 120_000 const META_TTL_MS = 120_000
// The use-case mapping tables (mc_use_case_mappings, mc_verification, // The use-case mapping tables (mc_use_case_mappings/mc_verification/mc_regulations)
// mc_regulations, mc_use_case_sync_state) are seeded together per-environment // are seeded per-environment and may not exist yet on a fresh/unseeded DB. Guard
// and may not exist yet on a fresh/unseeded DB. We probe mc_use_case_mappings as // every mapping query so the route degrades to empty filters instead of a 500.
// the existence sentinel and guard every mapping query so the route degrades to // Cached with a short TTL so it picks up the tables once that DB gets seeded.
// empty filters instead of a 500. Short TTL so it picks up the tables once seeded.
// NB: the sentinel assumes the siblings are seeded together — a half-seeded DB
// (mappings present but e.g. mc_regulations missing) would still 500 on those.
let mappingTablesCache: { at: number; present: boolean } | null = null let mappingTablesCache: { at: number; present: boolean } | null = null
async function hasMappingTables(): Promise<boolean> { async function hasMappingTables(): Promise<boolean> {
if (mappingTablesCache && Date.now() - mappingTablesCache.at < 300_000) { if (mappingTablesCache && Date.now() - mappingTablesCache.at < 300_000) {
@@ -299,8 +296,8 @@ async function handleMeta(_params: URLSearchParams) {
no_source_count: 0, no_source_count: 0,
release_state_counts: { active: total }, release_state_counts: { active: total },
verification_method_counts: Object.fromEntries( verification_method_counts: Object.fromEntries(
(vRes.rows as { verification_method: string; c: string }[]).map((x) => vRes.rows.map((x: { verification_method: string; c: string }) =>
[x.verification_method, parseInt(x.c)] as [string, number])), [x.verification_method, parseInt(x.c)])),
category_counts: facet(catRes.rows), category_counts: facet(catRes.rows),
evidence_type_counts: {}, evidence_type_counts: {},
use_case_counts: Object.fromEntries( use_case_counts: Object.fromEntries(
@@ -7,6 +7,7 @@ import { useSDK } from '@/lib/sdk'
import { import {
CourseCategory, CourseCategory,
COURSE_CATEGORY_INFO, COURSE_CATEGORY_INFO,
CreateCourseRequest,
GenerateCourseRequest GenerateCourseRequest
} from '@/lib/sdk/academy/types' } from '@/lib/sdk/academy/types'
import { createCourse, generateCourse } from '@/lib/sdk/academy/api' import { createCourse, generateCourse } from '@/lib/sdk/academy/api'
@@ -167,7 +167,7 @@ function AdvisoryBoardPageInner() {
retention_purpose: intake.retention?.purpose || intake.retention_purpose || '', retention_purpose: intake.retention?.purpose || intake.retention_purpose || '',
contracts: intake.contracts_list || [], contracts: intake.contracts_list || [],
subprocessors: intake.contracts?.subprocessors || intake.subprocessors || '', subprocessors: intake.contracts?.subprocessors || intake.subprocessors || '',
} as AdvisoryForm) })
}) })
.catch(() => {}) .catch(() => {})
.finally(() => setEditLoading(false)) .finally(() => setEditLoading(false))
@@ -25,8 +25,6 @@ import {
METHODIK_SHORT, METHODIK_SHORT,
SEVERITY_BG, SEVERITY_BG,
SEVERITY_COLOR, SEVERITY_COLOR,
STATUS_LABEL,
STATUS_STYLE,
} from './_agentTypes' } from './_agentTypes'
export function AgentFindingCard({ f }: { f: Finding }) { export function AgentFindingCard({ f }: { f: Finding }) {
@@ -34,10 +32,6 @@ export function AgentFindingCard({ f }: { f: Finding }) {
const color = SEVERITY_COLOR[sev] const color = SEVERITY_COLOR[sev]
const bg = SEVERITY_BG[sev] const bg = SEVERITY_BG[sev]
const sources = f.sources || [] const sources = f.sources || []
// Verdikt-Pill nur für Nicht-FAIL-Status (Applicability/Unknown) —
// macht klar: kein Verstoß, sondern Hinweis/unbestimmt.
const statusLabel = f.status ? STATUS_LABEL[f.status] : undefined
const statusStyle = f.status ? STATUS_STYLE[f.status] : undefined
return ( return (
<div <div
className="rounded border-l-4 p-3 space-y-2" className="rounded border-l-4 p-3 space-y-2"
@@ -50,16 +44,9 @@ export function AgentFindingCard({ f }: { f: Finding }) {
> >
{sev} {sev}
</span> </span>
{statusLabel && statusStyle && ( <code className="text-[11px] text-gray-500">{f.check_id}</code>
<span
className="text-[10px] font-semibold px-1.5 py-0.5 rounded"
style={{ background: statusStyle.bg, color: statusStyle.fg }}
>
{statusLabel}
</span>
)}
{sources.map((s, i) => ( {sources.map((s, i) => (
<MethodikBadge key={i} src={s.source_type} /> <MethodikBadge key={i} src={s.source_type} sourceId={s.source_id} />
))} ))}
{f.confidence !== undefined && ( {f.confidence !== undefined && (
<span className="text-[10px] text-gray-500 ml-auto"> <span className="text-[10px] text-gray-500 ml-auto">
@@ -91,12 +78,9 @@ export function AgentFindingCard({ f }: { f: Finding }) {
s.source_type === 'llm_cloud' s.source_type === 'llm_cloud'
) )
? 'Empfehlung (LLM-Vorschlag)' ? 'Empfehlung (LLM-Vorschlag)'
: f.status === 'insufficient_evidence' || : sev === 'HIGH'
f.status === 'possibly_applicable' ? 'Pflicht-Maßnahme'
? 'Prüf-Hinweis' : 'Best-Practice-Empfehlung'
: sev === 'HIGH'
? 'Pflicht-Maßnahme'
: 'Best-Practice-Empfehlung'
} }
tone="green" tone="green"
> >
@@ -0,0 +1,63 @@
'use client'
/**
* "Was wurde geprüft" — listet alle MCs eines Agents mit ihrem Status.
* Standardmäßig collapsed; zeigt sofort, was Methodik des Agents war.
*/
import React, { useState } from 'react'
import type { McCoverage } from './_agentTypes'
const STATUS_COLOR: Record<string, string> = {
ok: '#10b981',
na: '#94a3b8',
skipped: '#cbd5e1',
high: '#dc2626',
medium: '#f59e0b',
low: '#3b82f6',
}
const STATUS_LABEL: Record<string, string> = {
ok: 'OK',
na: 'n/a',
skipped: 'übersprungen',
high: 'HIGH',
medium: 'MEDIUM',
low: 'LOW',
}
export function AgentMcCoverage({ coverage }: { coverage: McCoverage[] }) {
const [open, setOpen] = useState(false)
if (!coverage?.length) return null
return (
<div className="border rounded bg-slate-50">
<button
onClick={() => setOpen(o => !o)}
className="w-full text-left px-3 py-2 text-xs font-semibold uppercase text-gray-700 flex justify-between items-center"
>
<span>Was wurde geprüft? ({coverage.length} MCs)</span>
<span className="text-gray-400">{open ? '▾' : '▸'}</span>
</button>
{open && (
<div className="border-t bg-white p-2 space-y-0.5 max-h-60 overflow-y-auto">
{coverage.map(c => (
<div key={c.mc_id} className="flex items-center gap-2 text-xs">
<span
className="w-2 h-2 rounded-full inline-block"
style={{ background: STATUS_COLOR[c.status] || '#cbd5e1' }}
/>
<code className="text-gray-500">{c.mc_id}</code>
<span className="text-gray-700">
{STATUS_LABEL[c.status] || c.status}
</span>
{c.reason && (
<span className="text-gray-400 italic"> {c.reason}</span>
)}
</div>
))}
</div>
)}
</div>
)
}
@@ -1,44 +0,0 @@
'use client'
/**
* AgentModuleTab — generischer Snapshot-Modul-Tab für einen Doc-Type-Agenten
* (Impressum, DSE, …). Lädt `/snapshots/{id}/{docType}-check` beim Mounten
* (kein Re-Crawl) und rendert den AgentOutput im geteilten AgentResultTab.
* Wird nur gemountet, wenn der Tab aktiv ist → Analyse läuft on-demand.
*/
import React, { useEffect, useState } from 'react'
import { AgentResultTab } from './AgentResultTab'
export function AgentModuleTab(
{ snapshotId, docType, label }:
{ snapshotId: string; docType: string; label: string },
) {
const [data, setData] = useState<any>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
setLoading(true)
fetch(`/api/sdk/v1/agent/snapshots/${snapshotId}/${docType}-check`)
.then(r => r.json())
.then(d => { if (!cancelled) setData(d) })
.catch(() => {
if (!cancelled) setData({ error: `${label}-Analyse fehlgeschlagen`, findings: [] })
})
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [snapshotId, docType, label])
if (loading) return <div className="text-sm text-gray-500">{label}-Analyse läuft</div>
if (data?.error) return <div className="text-sm text-red-600">{data.error}</div>
if (data && ((data.findings?.length ?? 0) > 0 || (data.mc_coverage?.length ?? 0) > 0)) {
return <AgentResultTab topicLabel={label} output={data} />
}
return (
<div className="text-sm text-gray-500">
{data?.notes || `Keine ${label}-Auswertung verfügbar.`}
</div>
)
}
@@ -1,82 +0,0 @@
'use client'
/**
* AgentPflichtTable — die geprüften Pflichtangaben als menschliche Tabelle:
* Status-Icon + Feldname + tatsächlich gefundener Text. Ersetzt die alte
* MC-ID-Liste.
*
* WICHTIG: zeigt NIE die mc_id (Reverse-Engineering-Schutz der MC-Bibliothek)
* — nur das menschliche `label`. Generisch für jeden Agenten verwendbar.
*/
import React from 'react'
import type { McCoverage } from './_agentTypes'
const DISP: Record<string, { icon: string; text: string; color: string }> = {
ok: { icon: '✓', text: 'vorhanden', color: '#16a34a' },
high: { icon: '✗', text: 'fehlt', color: '#dc2626' },
medium: { icon: '✗', text: 'fehlt', color: '#d97706' },
low: { icon: '✗', text: 'fehlt', color: '#2563eb' },
possibly_applicable: { icon: '?', text: 'zu prüfen', color: '#ca8a04' },
insufficient_evidence: { icon: '?', text: 'unklar', color: '#64748b' },
na: { icon: '', text: 'nicht anwendbar', color: '#94a3b8' },
skipped: { icon: '', text: 'nicht geprüft', color: '#cbd5e1' },
}
// Reihenfolge: Probleme zuerst, dann erfüllt, dann n/a.
const RANK: Record<string, number> = {
high: 0, medium: 1, low: 2, possibly_applicable: 3,
insufficient_evidence: 4, ok: 5, na: 6, skipped: 7,
}
export function AgentPflichtTable({ coverage }: { coverage: McCoverage[] }) {
if (!coverage?.length) return null
const rows = [...coverage].sort(
(a, b) => (RANK[a.status] ?? 9) - (RANK[b.status] ?? 9),
)
const count = (s: string) => coverage.filter(c => c.status === s).length
const ok = count('ok')
const fehlt = count('high') + count('medium') + count('low')
const pruefen = count('possibly_applicable') + count('insufficient_evidence')
const na = count('na') + count('skipped')
return (
<div className="border rounded overflow-hidden">
<div className="px-3 py-2 text-xs font-semibold uppercase text-gray-700 border-b bg-slate-50">
Pflichtangaben <span className="text-green-700">{ok} vorhanden</span>
{fehlt > 0 && <> · <span className="text-red-600">{fehlt} fehlt</span></>}
{pruefen > 0 && (
<> · <span className="text-yellow-700">{pruefen} zu prüfen</span></>
)}
{na > 0 && <> · <span className="text-gray-400">{na} n/a</span></>}
</div>
<div className="divide-y divide-gray-100">
{rows.map((c, i) => {
const d = DISP[c.status] || DISP.skipped
return (
<div key={i} className="flex items-start gap-2 px-3 py-1.5 text-xs">
<span
className="font-bold w-4 text-center shrink-0"
style={{ color: d.color }}
aria-label={d.text}
>
{d.icon}
</span>
<span className="font-medium text-gray-800 w-52 shrink-0">
{c.label || 'Angabe'}
</span>
<span className="text-gray-500 flex-1 min-w-0 break-words">
{c.status === 'ok' ? (
<span className="italic">{c.found || 'vorhanden'}</span>
) : (
<span style={{ color: d.color }}>{d.text}</span>
)}
</span>
</div>
)
})}
</div>
</div>
)
}
@@ -1,65 +0,0 @@
'use client'
/**
* AgentResultTab — Inhalt eines Themen-Ergebnis-Tabs im Compliance-Check.
* Themen-Header (Label + Konfidenz + Severity-Ampel) + der geteilte
* AgentResultView. Standardisierter Rahmen, den jeder Themen-Agent
* (Impressum, später Cookie/Vendor/Savings) füllt.
*/
import React from 'react'
import type { SlotOutput } from './_agentTypes'
import { isOutputSkipped } from './_agentTypes'
import { AgentResultView } from './AgentResultView'
export function AgentResultTab({
topicLabel, output,
}: {
topicLabel: string
output: SlotOutput
}) {
const wasSkipped = isOutputSkipped(output)
const allGreen = !wasSkipped && output.findings.length === 0
const high = output.findings.filter(f => f.severity === 'HIGH').length
const medium = output.findings.filter(f => f.severity === 'MEDIUM').length
const low = output.findings.filter(f => f.severity === 'LOW').length
return (
<div className="rounded-lg border bg-white p-4 space-y-3 shadow-sm">
<div className="flex items-baseline gap-3 flex-wrap">
<h3 className="font-semibold text-gray-900">{topicLabel}</h3>
<span className="text-xs text-gray-500">
Konfidenz {(output.confidence * 100).toFixed(0)}%
</span>
{high > 0 && (
<span className="text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded font-semibold">
{high} HIGH
</span>
)}
{medium > 0 && (
<span className="text-xs bg-amber-100 text-amber-800 px-2 py-0.5 rounded">
{medium} MEDIUM
</span>
)}
{low > 0 && (
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded">
{low} LOW
</span>
)}
{allGreen && (
<span className="text-xs bg-emerald-100 text-emerald-800 px-2 py-0.5 rounded">
Alle anwendbaren MCs erfüllt
</span>
)}
{wasSkipped && (
<span className="text-xs bg-amber-100 text-amber-800 px-2 py-0.5 rounded">
Dokument nicht geladen
</span>
)}
</div>
<AgentResultView output={output} />
</div>
)
}
@@ -1,128 +0,0 @@
'use client'
/**
* AgentResultView — der geteilte Render-Body eines AgentOutput:
* MC-Coverage + Speedometer + Eskalationslog + Findings (HIGH→LOW) +
* konsolidierte Maßnahmen. KEIN Header — den setzt der Consumer
* (AgentSlotCard = Agent-Test-Slot, AgentResultTab = Themen-Tab).
*
* Dieser View ist die "Karten"-Darstellung für Themen mit wenigen
* Findings (z.B. Impressum). Dichte Themen (Cookie, bis ~1000 Zeilen)
* bekommen später einen eigenen Tabellen-View im gleichen Tab-Rahmen.
*/
import React, { useState } from 'react'
import type { Severity, SlotOutput } from './_agentTypes'
import { AgentFindingCard } from './AgentFindingCard'
import { AgentPflichtTable } from './AgentPflichtTable'
import { AgentRecommendationCard } from './AgentRecommendationCard'
import { AgentSpeedometer } from './AgentSpeedometer'
const SEV_ORDER: Record<Severity, number> = {
HIGH: 0, MEDIUM: 1, LOW: 2, INFO: 3,
}
const INITIAL_VISIBLE = 12
type Reconciled = { title?: string; field_id?: string; norm?: string; reconciled_in_label?: string; reconciled_in?: string }
export function AgentResultView({ output }: { output: SlotOutput }) {
const [showAll, setShowAll] = useState(false)
const reconciled = (output as { reconciled?: Reconciled[] }).reconciled || []
const sortedFindings = [...output.findings].sort(
(a, b) => SEV_ORDER[a.severity] - SEV_ORDER[b.severity],
)
const visible = showAll
? sortedFindings
: sortedFindings.slice(0, INITIAL_VISIBLE)
return (
<div className="space-y-3">
{output.notes && (
<div className="text-xs text-amber-700 bg-amber-50 px-2 py-1 rounded">
Hinweis: {output.notes}
</div>
)}
<AgentPflichtTable coverage={output.mc_coverage} />
<AgentSpeedometer
total={output.mc_total}
ok={output.mc_ok}
na={output.mc_na}
high={output.mc_high}
medium={output.mc_medium}
low={output.mc_low}
/>
{output.escalation_log.length > 0 && (
<div className="text-xs text-gray-600 border-l-2 border-violet-400 pl-2 space-y-0.5">
<div className="font-semibold text-violet-700">
LLM-Eskalation eingesetzt:
</div>
{output.escalation_log.map((e, i) => (
<div key={i}>
{e.stage} <code className="text-violet-700">{e.model}</code>{' '}
· {e.duration_ms} ms{' '}
{e.tokens_in ? `· ${e.tokens_in}${e.tokens_out} tok` : ''}{' '}
{e.success ? '✓' : `${e.error || ''}`}
</div>
))}
</div>
)}
{sortedFindings.length > 0 && (
<div className="space-y-2">
<div className="text-xs font-semibold uppercase text-gray-700">
Findings ({sortedFindings.length}) nach Schwere sortiert
</div>
<div className="space-y-2">
{visible.map(f => (
<AgentFindingCard key={f.check_id} f={f} />
))}
</div>
{sortedFindings.length > INITIAL_VISIBLE && (
<button
onClick={() => setShowAll(x => !x)}
className="text-xs text-blue-600 hover:underline"
>
{showAll
? 'Weniger anzeigen'
: `Alle ${sortedFindings.length} anzeigen`}
</button>
)}
</div>
)}
{reconciled.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold uppercase text-green-700">
In anderem Dokument abgedeckt ({reconciled.length})
</div>
{reconciled.map((f, i) => (
<div key={i} className="text-xs text-gray-600 bg-green-50 border border-green-100 px-2 py-1 rounded">
{f.title || f.field_id}
<span className="text-gray-400"> gefunden in </span>
<strong>{f.reconciled_in_label || f.reconciled_in}</strong>
{f.norm && <span className="text-gray-400"> · {f.norm}</span>}
</div>
))}
</div>
)}
{output.recommendations.length > 0 && (
<div className="space-y-2">
<div className="text-xs font-semibold uppercase text-gray-700">
Maßnahmen-Plan ({output.recommendations.length} konsolidiert)
</div>
<div className="space-y-2">
{output.recommendations.map(r => (
<AgentRecommendationCard key={r.recommendation_id} r={r} />
))}
</div>
</div>
)}
</div>
)
}
@@ -1,16 +1,26 @@
'use client' 'use client'
/** /**
* AgentSlotCard — ein Slot im Agent-Test: Slot-Header (Name, Dauer, * SlotCard — ein Slot im Agent-Test mit Sections:
* Konfidenz, Status-Badges, Artefakt-Link) + der geteilte * 1. Header (Slot-Name, duration, Vault-Link)
* AgentResultView (Coverage/Speedometer/Findings/Maßnahmen). * 2. Was wurde geprüft (MC-Coverage, collapsible)
* 3. Speedometer
* 4. Eskalationslog (wenn vorhanden)
* 5. Findings (sortiert HIGH → LOW)
* 6. Recommendations (gerollupt)
*/ */
import React from 'react' import React, { useState } from 'react'
import type { SlotOutput } from './_agentTypes' import type { SlotOutput, Severity } from './_agentTypes'
import { isOutputSkipped } from './_agentTypes' import { AgentFindingCard } from './AgentFindingCard'
import { AgentResultView } from './AgentResultView' import { AgentMcCoverage } from './AgentMcCoverage'
import { AgentRecommendationCard } from './AgentRecommendationCard'
import { AgentSpeedometer } from './AgentSpeedometer'
const SEV_ORDER: Record<Severity, number> = {
HIGH: 0, MEDIUM: 1, LOW: 2, INFO: 3,
}
export function AgentSlotCard({ export function AgentSlotCard({
slot, output, runId, slot, output, runId,
@@ -19,8 +29,15 @@ export function AgentSlotCard({
output: SlotOutput output: SlotOutput
runId: string runId: string
}) { }) {
const wasSkipped = isOutputSkipped(output) const [showAll, setShowAll] = useState(false)
const wasSkipped = output.mc_total > 0 &&
output.mc_ok === 0 && output.mc_na === 0 &&
output.mc_high === 0 && output.mc_medium === 0 && output.mc_low === 0
const allGreen = !wasSkipped && output.findings.length === 0 const allGreen = !wasSkipped && output.findings.length === 0
const sortedFindings = [...output.findings].sort(
(a, b) => SEV_ORDER[a.severity] - SEV_ORDER[b.severity],
)
const visible = showAll ? sortedFindings : sortedFindings.slice(0, 12)
return ( return (
<div className="rounded-lg border bg-white p-4 space-y-3 shadow-sm"> <div className="rounded-lg border bg-white p-4 space-y-3 shadow-sm">
<div className="flex items-baseline gap-3 flex-wrap"> <div className="flex items-baseline gap-3 flex-wrap">
@@ -48,7 +65,72 @@ export function AgentSlotCard({
</a> </a>
</div> </div>
<AgentResultView output={output} /> {output.notes && (
<div className="text-xs text-amber-700 bg-amber-50 px-2 py-1 rounded">
Hinweis: {output.notes}
</div>
)}
<AgentMcCoverage coverage={output.mc_coverage} />
<AgentSpeedometer
total={output.mc_total}
ok={output.mc_ok}
na={output.mc_na}
high={output.mc_high}
medium={output.mc_medium}
low={output.mc_low}
/>
{output.escalation_log.length > 0 && (
<div className="text-xs text-gray-600 border-l-2 border-violet-400 pl-2 space-y-0.5">
<div className="font-semibold text-violet-700">
LLM-Eskalation eingesetzt:
</div>
{output.escalation_log.map((e, i) => (
<div key={i}>
{e.stage} <code className="text-violet-700">{e.model}</code>{' '}
· {e.duration_ms} ms{' '}
{e.tokens_in ? `· ${e.tokens_in}${e.tokens_out} tok` : ''}{' '}
{e.success ? '✓' : `${e.error || ''}`}
</div>
))}
</div>
)}
{sortedFindings.length > 0 && (
<div className="space-y-2">
<div className="text-xs font-semibold uppercase text-gray-700">
Findings ({sortedFindings.length}) nach Schwere sortiert
</div>
<div className="space-y-2">
{visible.map(f => (
<AgentFindingCard key={f.check_id} f={f} />
))}
</div>
{sortedFindings.length > 12 && (
<button
onClick={() => setShowAll(x => !x)}
className="text-xs text-blue-600 hover:underline"
>
{showAll ? 'Weniger anzeigen' : `Alle ${sortedFindings.length} anzeigen`}
</button>
)}
</div>
)}
{output.recommendations.length > 0 && (
<div className="space-y-2">
<div className="text-xs font-semibold uppercase text-gray-700">
Maßnahmen-Plan ({output.recommendations.length} konsolidiert)
</div>
<div className="space-y-2">
{output.recommendations.map(r => (
<AgentRecommendationCard key={r.recommendation_id} r={r} />
))}
</div>
</div>
)}
</div> </div>
) )
} }
@@ -2,7 +2,7 @@
import React, { useState } from 'react' import React, { useState } from 'react'
export interface CheckItem { interface CheckItem {
id: string id: string
label: string label: string
passed: boolean passed: boolean
@@ -14,7 +14,7 @@ export interface CheckItem {
hint?: string hint?: string
} }
export interface DocResult { interface DocResult {
label: string label: string
url: string url: string
doc_type: string doc_type: string
@@ -27,14 +27,14 @@ export interface DocResult {
scenario?: string // regenerate | fix | import | skip scenario?: string // regenerate | fix | import | skip
} }
export const SCENARIO_LABELS: Record<string, { label: string; color: string; bg: string }> = { const SCENARIO_LABELS: Record<string, { label: string; color: string; bg: string }> = {
regenerate: { label: 'Neugenerierung', color: 'text-red-700', bg: 'bg-red-100' }, regenerate: { label: 'Neugenerierung', color: 'text-red-700', bg: 'bg-red-100' },
fix: { label: 'Korrekturen', color: 'text-amber-700', bg: 'bg-amber-100' }, fix: { label: 'Korrekturen', color: 'text-amber-700', bg: 'bg-amber-100' },
import: { label: 'Konform', color: 'text-green-700', bg: 'bg-green-100' }, import: { label: 'Konform', color: 'text-green-700', bg: 'bg-green-100' },
missing: { label: 'Fehlt', color: 'text-gray-600', bg: 'bg-gray-100' }, missing: { label: 'Fehlt', color: 'text-gray-600', bg: 'bg-gray-100' },
} }
export const DOC_TYPE_LABELS: Record<string, string> = { const DOC_TYPE_LABELS: Record<string, string> = {
dse: 'DSI', agb: 'AGB', impressum: 'Impressum', dse: 'DSI', agb: 'AGB', impressum: 'Impressum',
cookie: 'Cookie', widerruf: 'Widerruf', other: 'Sonstiges', cookie: 'Cookie', widerruf: 'Widerruf', other: 'Sonstiges',
social_media: 'Social Media', dsfa: 'DSFA', joint_controller: 'Art. 26', social_media: 'Social Media', dsfa: 'DSFA', joint_controller: 'Art. 26',
@@ -46,7 +46,7 @@ interface GroupedCheck {
children: CheckItem[] children: CheckItem[]
} }
export function groupChecks(checks: CheckItem[]): GroupedCheck[] { function groupChecks(checks: CheckItem[]): GroupedCheck[] {
const l1 = checks.filter(c => (c.level ?? 1) === 1) const l1 = checks.filter(c => (c.level ?? 1) === 1)
return l1.map(c => ({ return l1.map(c => ({
check: c, check: c,
@@ -54,7 +54,7 @@ export function groupChecks(checks: CheckItem[]): GroupedCheck[] {
})) }))
} }
export function CheckIcon({ passed, skipped, isInfo }: { passed: boolean; skipped?: boolean; isInfo?: boolean }) { function CheckIcon({ passed, skipped, isInfo }: { passed: boolean; skipped?: boolean; isInfo?: boolean }) {
if (skipped) { if (skipped) {
return ( return (
<svg className="w-4 h-4 text-gray-300 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 text-gray-300 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -1,8 +1,9 @@
'use client' 'use client'
import React, { useState, useCallback, useRef } from 'react' import React, { useState, useCallback } from 'react'
import { ComplianceResultTabs } from './ComplianceResultTabs' import { ChecklistView } from './ChecklistView'
import { DocumentRow } from './DocumentRow' import { DocumentRow } from './DocumentRow'
import { MigrationPanel } from './MigrationPanel'
import { PreScanWizard, useScanContext, isContextComplete } from './PreScanWizard' import { PreScanWizard, useScanContext, isContextComplete } from './PreScanWizard'
import { DOCUMENT_TYPES, type DocTypeId } from './_document_types' import { DOCUMENT_TYPES, type DocTypeId } from './_document_types'
import { import {
@@ -35,9 +36,6 @@ export function ComplianceCheckTab() {
if (typeof window === 'undefined') return [] if (typeof window === 'undefined') return []
try { return JSON.parse(localStorage.getItem(STORAGE_KEY_HISTORY) || '[]') } catch { return [] } try { return JSON.parse(localStorage.getItem(STORAGE_KEY_HISTORY) || '[]') } catch { return [] }
}) })
// SSE: progressive Themen-Tabs (additiv zum Polling).
const esRef = useRef<EventSource | null>(null)
React.useEffect(() => () => { try { esRef.current?.close() } catch { /* noop */ } }, [])
// Persist URLs and texts (not loading/error state) // Persist URLs and texts (not loading/error state)
React.useEffect(() => { React.useEffect(() => {
@@ -120,38 +118,6 @@ export function ComplianceCheckTab() {
reader.readAsText(file) reader.readAsText(file)
}, [updateDoc]) }, [updateDoc])
// SSE: füllt agent_outputs progressiv, sobald ein Thema fertig ist.
// Das Polling unten liefert weiterhin das finale Gesamtergebnis.
const openTopicStream = useCallback((checkId: string) => {
try { esRef.current?.close() } catch { /* noop */ }
const partial: any = { results: [], agent_outputs: {} }
const es = new EventSource(
`/api/sdk/v1/agent/compliance-check/${checkId}/stream`,
)
esRef.current = es
es.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data)
if (data.type === 'topic' && data.topic && data.output) {
partial.agent_outputs = {
...partial.agent_outputs, [data.topic]: data.output,
}
setResults((prev: any) =>
(prev && Array.isArray(prev.results) && prev.results.length > 0)
? prev // finales Ergebnis schon da → behalten
: { ...partial },
)
} else if (data.type === 'progress') {
if (data.msg) setProgress(data.msg)
if (typeof data.pct === 'number') setProgressPct(data.pct)
} else if (data.type === 'complete' || data.type === 'stream_close') {
try { es.close() } catch { /* noop */ }
}
} catch { /* noop */ }
}
es.onerror = () => { try { es.close() } catch { /* noop */ } }
}, [])
const filledCount = Object.values(docs).filter(d => d.url.trim() || d.text.trim()).length const filledCount = Object.values(docs).filter(d => d.url.trim() || d.text.trim()).length
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -192,7 +158,6 @@ export function ComplianceCheckTab() {
if (!check_id) throw new Error('Keine Check-ID erhalten') if (!check_id) throw new Error('Keine Check-ID erhalten')
setActiveCheckId(check_id) setActiveCheckId(check_id)
localStorage.setItem(STORAGE_KEY_CHECK_ID, check_id) localStorage.setItem(STORAGE_KEY_CHECK_ID, check_id)
openTopicStream(check_id)
// Poll for results (max 25 min = 500 polls x 3s) // Poll for results (max 25 min = 500 polls x 3s)
let attempts = 0 let attempts = 0
@@ -237,7 +202,6 @@ export function ComplianceCheckTab() {
setError(e instanceof Error ? e.message : 'Unbekannter Fehler') setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
setProgress('') setProgress('')
setProgressPct(0) setProgressPct(0)
try { esRef.current?.close() } catch { /* noop */ }
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -390,9 +354,106 @@ export function ComplianceCheckTab() {
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">{error}</div> <div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">{error}</div>
)} )}
{/* Results — strukturierte Themen-Tabs (Impressum, …) + Roh-Checkliste */} {/* Results */}
{results && results.results && ( {results && results.results && (
<ComplianceResultTabs results={results} /> <div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
{/* Business Profile */}
{results.business_profile && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-xs">
<div className="font-semibold text-blue-900 mb-1">Erkanntes Geschaeftsmodell</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-blue-700">
<span>Typ: <strong>{results.business_profile.business_type?.toUpperCase()}</strong></span>
<span>Branche: {results.business_profile.industry}</span>
{results.business_profile.has_online_shop && <span className="text-amber-700">Online-Shop</span>}
{results.business_profile.is_regulated_profession && <span className="text-amber-700">Regulierter Beruf ({results.business_profile.regulated_profession_type})</span>}
</div>
</div>
)}
{/* Extracted Profile — pre-fill suggestion */}
{results.extracted_profile?.company_profile && Object.keys(results.extracted_profile.company_profile).length > 0 && (
<div className="mb-4 p-3 bg-emerald-50 border border-emerald-200 rounded-lg text-xs">
<div className="flex items-center justify-between mb-1">
<span className="font-semibold text-emerald-900">Aus Dokumenten extrahiert</span>
<button className="text-emerald-700 hover:text-emerald-900 text-xs font-medium underline"
onClick={() => { /* TODO: navigate to company profile with pre-fill */ }}>
In Company Profile uebernehmen
</button>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-emerald-700">
{results.extracted_profile.company_profile.companyName && (
<span>Firma: <strong>{results.extracted_profile.company_profile.companyName}</strong></span>
)}
{results.extracted_profile.company_profile.legalForm && (
<span>Rechtsform: {results.extracted_profile.company_profile.legalForm.toUpperCase()}</span>
)}
{results.extracted_profile.company_profile.headquartersCity && (
<span>Sitz: {results.extracted_profile.company_profile.headquartersZip} {results.extracted_profile.company_profile.headquartersCity}</span>
)}
{results.extracted_profile.company_profile.dpoEmail && (
<span>DSB: {results.extracted_profile.company_profile.dpoEmail}</span>
)}
{results.extracted_profile.company_profile.ustIdNr && (
<span>USt-IdNr: {results.extracted_profile.company_profile.ustIdNr}</span>
)}
</div>
{results.extracted_profile.compliance_scope_hints?.length > 0 && (
<div className="mt-2 pt-2 border-t border-emerald-200 text-emerald-600">
<span className="font-medium">Scope-Hinweise: </span>
{results.extracted_profile.compliance_scope_hints.map((h: any, i: number) => (
<span key={i} className="inline-block bg-emerald-100 rounded px-1.5 py-0.5 mr-1 mb-1">
{h.source}
</span>
))}
</div>
)}
</div>
)}
{/* Banner Check Result */}
{results.banner_result && (
<div className={`mb-4 p-3 rounded-lg border text-xs ${
results.banner_result.violations > 0
? 'bg-amber-50 border-amber-200'
: results.banner_result.detected
? 'bg-green-50 border-green-200'
: 'bg-gray-50 border-gray-200'
}`}>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${
results.banner_result.violations > 0 ? 'bg-amber-500'
: results.banner_result.detected ? 'bg-green-500' : 'bg-gray-400'
}`} />
<span className="font-semibold text-gray-900">
Cookie-Banner-Check (automatisch)
</span>
</div>
<div className="mt-1 text-gray-600 ml-4">
{results.banner_result.detected ? (
<>
Banner erkannt{results.banner_result.provider ? ` (${results.banner_result.provider})` : ''}.
{results.banner_result.violations > 0
? ` ${results.banner_result.violations} Auffaelligkeit${results.banner_result.violations !== 1 ? 'en' : ''} gefunden.`
: ' Keine Auffaelligkeiten.'}
</>
) : (
'Kein Cookie-Banner erkannt oder Banner-Check nicht moeglich.'
)}
</div>
</div>
)}
<ChecklistView results={results.results} />
{/* Email + 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>
)} )}
{/* History */} {/* History */}
@@ -1,164 +0,0 @@
'use client'
/**
* ComplianceResultTabs standardisierte Ergebnis-Darstellung des
* Compliance-Checks: Kopf-Boxen (erkanntes Profil + Banner) ÜBER einer
* Tab-Leiste. Ein Tab je Themen-Agent (result.agent_outputs, P1: Impressum)
* via AgentResultTab + ein "Alle Checks (roh)"-Tab mit der bisherigen
* ChecklistView so geht nichts verloren, während die Themen-Tabs wachsen.
*/
import React, { useState } from 'react'
import { ChecklistView, DOC_TYPE_LABELS, type DocResult } from './ChecklistView'
import { DocResultView } from './DocResultView'
import { MigrationPanel } from './MigrationPanel'
import { RemediationPlan } from './RemediationPlan'
import { ResultSummary } from './ResultSummary'
export function ComplianceResultTabs({ results }: { results: any }) {
// Themen-Tabs aus der HAUPT-Engine (result.results) — nicht aus dem
// v3-Agent. Jedes Dokument = ein Tab mit der genauen Pflichtangaben-Tabelle.
const docs: DocResult[] = results.results || []
const tabs = docs.map((_: DocResult, i: number) => String(i)).concat('raw')
const [active, setActive] = useState<string>(tabs[0] ?? 'raw')
return (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm space-y-4">
{/* Audit-Kopf: Titel + check_id + 4 KPI-Kacheln */}
<ResultSummary results={results} />
{/* Kopf-Boxen über den Tabs */}
{results.business_profile && (
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg text-xs">
<div className="font-semibold text-blue-900 mb-1">Erkanntes Geschaeftsmodell</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-blue-700">
<span>Typ: <strong>{results.business_profile.business_type?.toUpperCase()}</strong></span>
<span>Branche: {results.business_profile.industry}</span>
{results.business_profile.has_online_shop && <span className="text-amber-700">Online-Shop</span>}
{results.business_profile.is_regulated_profession && <span className="text-amber-700">Regulierter Beruf ({results.business_profile.regulated_profession_type})</span>}
</div>
</div>
)}
{results.extracted_profile?.company_profile && Object.keys(results.extracted_profile.company_profile).length > 0 && (
<div className="p-3 bg-emerald-50 border border-emerald-200 rounded-lg text-xs">
<div className="flex items-center justify-between mb-1">
<span className="font-semibold text-emerald-900">Aus Dokumenten extrahiert</span>
<button className="text-emerald-700 hover:text-emerald-900 text-xs font-medium underline"
onClick={() => { /* TODO: navigate to company profile with pre-fill */ }}>
In Company Profile uebernehmen
</button>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-emerald-700">
{results.extracted_profile.company_profile.companyName && (
<span>Firma: <strong>{results.extracted_profile.company_profile.companyName}</strong></span>
)}
{results.extracted_profile.company_profile.legalForm && (
<span>Rechtsform: {results.extracted_profile.company_profile.legalForm.toUpperCase()}</span>
)}
{results.extracted_profile.company_profile.headquartersCity && (
<span>Sitz: {results.extracted_profile.company_profile.headquartersZip} {results.extracted_profile.company_profile.headquartersCity}</span>
)}
{results.extracted_profile.company_profile.dpoEmail && (
<span>DSB: {results.extracted_profile.company_profile.dpoEmail}</span>
)}
{results.extracted_profile.company_profile.ustIdNr && (
<span>USt-IdNr: {results.extracted_profile.company_profile.ustIdNr}</span>
)}
</div>
{results.extracted_profile.compliance_scope_hints?.length > 0 && (
<div className="mt-2 pt-2 border-t border-emerald-200 text-emerald-600">
<span className="font-medium">Scope-Hinweise: </span>
{results.extracted_profile.compliance_scope_hints.map((h: any, i: number) => (
<span key={i} className="inline-block bg-emerald-100 rounded px-1.5 py-0.5 mr-1 mb-1">
{h.source}
</span>
))}
</div>
)}
</div>
)}
{results.banner_result && (
<div className={`p-3 rounded-lg border text-xs ${
results.banner_result.violations > 0
? 'bg-amber-50 border-amber-200'
: results.banner_result.detected
? 'bg-green-50 border-green-200'
: 'bg-gray-50 border-gray-200'
}`}>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${
results.banner_result.violations > 0 ? 'bg-amber-500'
: results.banner_result.detected ? 'bg-green-500' : 'bg-gray-400'
}`} />
<span className="font-semibold text-gray-900">
Cookie-Banner-Check (automatisch)
</span>
</div>
<div className="mt-1 text-gray-600 ml-4">
{results.banner_result.detected ? (
<>
Banner erkannt{results.banner_result.provider ? ` (${results.banner_result.provider})` : ''}.
{results.banner_result.violations > 0
? ` ${results.banner_result.violations} Auffaelligkeit${results.banner_result.violations !== 1 ? 'en' : ''} gefunden.`
: ' Keine Auffaelligkeiten.'}
</>
) : (
'Kein Cookie-Banner erkannt oder Banner-Check nicht moeglich.'
)}
</div>
</div>
)}
{/* Tab-Leiste — ein Tab je Dokument (Haupt-Engine) + Übersicht */}
<div className="flex gap-1 border-b border-gray-200 flex-wrap">
{tabs.map(t => {
const tabClass = `px-3 py-1.5 text-sm font-medium border-b-2 -mb-px transition-colors flex items-center gap-1.5 ${
active === t
? 'border-purple-500 text-purple-700'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`
if (t === 'raw') {
return (
<button key={t} onClick={() => setActive(t)} className={tabClass}>
Alle Checks
</button>
)
}
const doc = docs[Number(t)]
const dot = doc.error ? 'bg-gray-300'
: doc.scenario === 'import' ? 'bg-green-500'
: doc.scenario === 'fix' ? 'bg-amber-500'
: doc.scenario === 'regenerate' ? 'bg-red-500' : 'bg-gray-400'
return (
<button key={t} onClick={() => setActive(t)} className={tabClass}>
<span className={`w-2 h-2 rounded-full ${dot}`} />
{DOC_TYPE_LABELS[doc.doc_type] || doc.doc_type}
</button>
)
})}
</div>
{/* Tab-Inhalt */}
{active === 'raw' ? (
<ChecklistView results={results.results} />
) : docs[Number(active)] ? (
<DocResultView doc={docs[Number(active)]} />
) : null}
{/* Abstellmaßnahmen + Ticket-Formulierung (Übergabe an anderes Team) */}
<RemediationPlan results={results} />
{/* Check-Footer (themenübergreifend) */}
{results.email_status && (
<div className="text-xs text-gray-500 flex items-center gap-2 border-t border-gray-100 pt-3">
<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>
)
}
@@ -1,104 +0,0 @@
'use client'
/**
* CookieDeclarationDiff Deklaration vs. Bibliothek".
*
* Zeigt pro Cookie der GEPRÜFTEN Teilmenge (Library-Treffer) die Feld-
* Abweichungen deklariert Library, plus einen ehrlichen Funnel
* (gesamt geprüft abweichend). Quelle: cookie-check `declaration_diff`.
*/
import React from 'react'
interface Diff {
field: string
declared: string
expected: string
severe?: boolean
}
interface DiffRow {
cookie: string
vendor: string
severity: string
diffs: Diff[]
measures: string[]
}
export interface DeclarationDiffData {
coverage: { total: number; checked: number; discrepant: number }
rows: DiffRow[]
}
const SEV_BADGE: Record<string, string> = {
HIGH: 'bg-red-100 text-red-700',
MEDIUM: 'bg-amber-100 text-amber-700',
LOW: 'bg-gray-100 text-gray-600',
}
function Funnel({ c }: { c: DeclarationDiffData['coverage'] }) {
const pct = c.total > 0 ? Math.round((c.checked / c.total) * 100) : 0
return (
<div className="text-xs text-gray-600 bg-slate-50 border border-gray-200 rounded-lg px-3 py-2">
<span className="font-semibold text-gray-800">{c.total}</span> Cookies ·{' '}
<span className="font-semibold text-gray-800">{c.checked}</span> gegen Bibliothek
geprüft (<span className="font-semibold">{pct}%</span>) · davon{' '}
<span className={`font-semibold ${c.discrepant > 0 ? 'text-red-700' : 'text-green-700'}`}>
{c.discrepant}
</span>{' '}
mit abweichender Deklaration
<div className="text-[10px] text-gray-400 mt-0.5">
Nicht in der Bibliothek enthaltene Cookies sind nicht prüfbar (kein Pass, kein Fail).
</div>
</div>
)
}
export function CookieDeclarationDiff({ data }: { data?: DeclarationDiffData }) {
if (!data || !data.coverage) return null
const { coverage, rows } = data
return (
<div className="space-y-3">
<div className="flex items-baseline justify-between gap-2">
<h3 className="text-sm font-semibold text-gray-900">Deklaration vs. Bibliothek</h3>
</div>
<Funnel c={coverage} />
{rows.length === 0 ? (
<p className="text-xs text-green-700 px-1">
Keine abweichenden Deklarationen in der geprüften Teilmenge.
</p>
) : (
<div className="space-y-2">
{rows.map((r, i) => (
<div key={i} className="border border-gray-200 rounded-lg overflow-hidden">
<div className="flex items-center gap-2 px-3 py-1.5 bg-slate-50 border-b text-xs">
<span className="font-mono font-medium text-gray-800 break-all">{r.cookie}</span>
{r.vendor && <span className="text-gray-400">· {r.vendor}</span>}
<span className="flex-1" />
<span className={`px-1.5 py-0.5 rounded text-[10px] ${SEV_BADGE[r.severity] || SEV_BADGE.LOW}`}>
{r.diffs.length} {r.diffs.length === 1 ? 'Abweichung' : 'Abweichungen'}
</span>
</div>
<div className="px-3 py-2 space-y-1">
{r.diffs.map((d, j) => (
<div key={j} className="flex items-center gap-2 text-[11px]">
<span className="text-gray-500 w-20 shrink-0">{d.field}</span>
<span className="text-gray-600">{d.declared}</span>
<span className="text-gray-400"></span>
<span className={`font-medium ${d.severe ? 'text-red-700' : 'text-gray-900'}`}>
{d.expected}
</span>
</div>
))}
{r.measures.length > 0 && (
<div className="text-[11px] text-blue-700 pt-1 border-t border-gray-100 mt-1">
<span className="font-medium">Maßnahme:</span> {r.measures.join(' ')}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
)
}
@@ -1,236 +0,0 @@
'use client'
/**
* CookieFindings bereitet die Library-Befunde bearbeitbar auf, statt als
* Fließtext-Liste. Zwei Sichten (Umschalter):
* - Nach Fehlertyp: je Typ eine Maßnahme + betroffene Cookies + Ticket-Text
* (= eine Ticket-Einheit). Getrennt in FINDINGS (zu beheben) und HINWEISE
* (neutral, gegen DSE zu prüfen: Drittland, EU-Alternative).
* - Matrix: Zeilen = Cookies, Spalten = Fehlertypen, Markierung wo nachzubessern
* ist (ein Cookie, alle Probleme auf einen Blick).
*/
import React, { useMemo, useState } from 'react'
import type { CookieFinding } from './CookieLibraryPanel'
const TYPE_LABEL: Record<string, string> = {
tracker_as_necessary: 'Tracker als „notwendig" deklariert',
missing_purpose: 'Zweck fehlt',
excessive_lifetime: 'Speicherdauer zu lang',
vague_duration: 'Speicherdauer nicht konkret',
missing_retention: 'Keine Speicherdauer/Löschfrist',
missing_opt_out: 'Opt-Out-/Widerspruchs-Link fehlt',
storage_transparency: 'Speichertyp nicht transparent',
third_country: 'Drittland-Transfer',
eu_alternative: 'EU-Alternative verfügbar',
}
const TYPE_MEASURE: Record<string, string> = {
tracker_as_necessary: 'Als einwilligungspflichtig einstufen (§ 25 Abs. 1 TDDDG).',
missing_purpose: 'Zweck je Cookie ergänzen (Art. 13 DSGVO).',
vague_duration: 'Konkrete Speicherdauer oder Löschkriterium angeben (Art. 5 Abs. 1 lit. e).',
missing_retention: 'Speicherdauer/Löschfrist je Verarbeiter festlegen (Art. 5 Abs. 1 lit. e).',
missing_opt_out: 'Opt-Out-/Widerspruchs-Link je Anbieter angeben (Art. 7 Abs. 3 + Art. 21).',
excessive_lifetime: 'Speicherdauer auf das Erforderliche reduzieren (Art. 5 Abs. 1 lit. e).',
storage_transparency: 'Speichertyp + -dauer je Objekt transparent ausweisen (§ 25 TDDDG).',
third_country: 'Geeignete Garantien je Verarbeiter prüfen (SCC Art. 46 / Art. 49).',
eu_alternative: 'EU-Alternative prüfen (kommerziell, kein Drittland-Transfer).',
}
const TYPE_ORDER = [
'tracker_as_necessary', 'missing_purpose', 'vague_duration', 'missing_retention',
'missing_opt_out', 'excessive_lifetime', 'storage_transparency',
'third_country', 'eu_alternative',
]
const SEV_ORDER: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
const SEV_COLOR: Record<string, string> = {
HIGH: 'bg-red-100 text-red-700',
MEDIUM: 'bg-amber-100 text-amber-700',
LOW: 'bg-blue-100 text-blue-700',
}
interface Group { type: string; items: CookieFinding[]; severity: string }
function groupByType(findings: CookieFinding[]): Group[] {
const m = new Map<string, CookieFinding[]>()
for (const f of findings) {
if (!m.has(f.type)) m.set(f.type, [])
m.get(f.type)!.push(f)
}
const groups = [...m.entries()].map(([type, items]) => ({
type, items,
severity: items.reduce(
(s, f) => (SEV_ORDER[f.severity] ?? 3) < (SEV_ORDER[s] ?? 3) ? f.severity : s, 'LOW'),
}))
groups.sort((a, b) =>
(TYPE_ORDER.indexOf(a.type) + 99) % 100 - (TYPE_ORDER.indexOf(b.type) + 99) % 100)
return groups
}
function cookieLabel(f: CookieFinding): string {
const v = f.vendor && f.vendor !== '—' ? ` (${f.vendor})` : ''
const d = f.declared ? `${f.declared}` : ''
return `${f.cookie}${v}${d}`
}
function ticketText(g: Group): string {
return [
`${TYPE_LABEL[g.type] || g.type}${g.items.length} betroffen`,
`Maßnahme: ${TYPE_MEASURE[g.type] || ''}`,
'',
...g.items.map(f => `- ${cookieLabel(f)}`),
].join('\n')
}
function GroupCard({ g }: { g: Group }) {
const [open, setOpen] = useState(false)
const [copied, setCopied] = useState(false)
const copy = () => {
navigator.clipboard?.writeText(ticketText(g)).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 1500)
}).catch(() => {})
}
return (
<div className="border-b last:border-b-0">
<button onClick={() => setOpen(o => !o)}
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-gray-50 text-xs">
<span className={`text-gray-400 transition-transform ${open ? 'rotate-90' : ''}`}></span>
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${SEV_COLOR[g.severity] || 'bg-gray-100'}`}>
{g.severity}
</span>
<span className="font-medium text-gray-800 flex-1 min-w-0 truncate">
{TYPE_LABEL[g.type] || g.type}
</span>
<span className="text-gray-500">{g.items.length}</span>
</button>
{open && (
<div className="px-4 pb-3 space-y-2">
<div className="text-xs text-gray-700 bg-blue-50 rounded px-2 py-1.5">
<span className="font-semibold">Maßnahme:</span> {TYPE_MEASURE[g.type] || '—'}
</div>
<table className="w-full text-[11px]">
<tbody>
{g.items.map((f, i) => (
<tr key={i} className="border-t border-gray-100 align-top">
<td className="px-2 py-1 font-mono text-gray-700 break-all w-40">{f.cookie}</td>
<td className="px-2 py-1 text-gray-400 w-32 truncate">{f.vendor}</td>
<td className="px-2 py-1 text-gray-500">{f.declared || ''}</td>
</tr>
))}
</tbody>
</table>
<button onClick={copy}
className="text-[11px] px-2 py-1 rounded bg-gray-100 text-gray-700 hover:bg-gray-200">
{copied ? '✓ Ticket-Text kopiert' : 'Ticket-Text kopieren'}
</button>
</div>
)}
</div>
)
}
function Section({ title, hint, groups }: { title: string; hint?: string; groups: Group[] }) {
if (!groups.length) return null
return (
<div className="border rounded-lg overflow-hidden">
<div className="px-3 py-2 bg-slate-50 border-b">
<span className="text-xs font-semibold text-gray-700">{title}</span>
{hint && <span className="text-[10px] text-gray-400 ml-2">{hint}</span>}
</div>
{groups.map(g => <GroupCard key={g.type} g={g} />)}
</div>
)
}
function Matrix({ findings }: { findings: CookieFinding[] }) {
const { rows, cols } = useMemo(() => {
const colSet = new Set(findings.map(f => f.type))
const cols = TYPE_ORDER.filter(t => colSet.has(t))
const rowMap = new Map<string, { label: string; vendor: string; hits: Record<string, string> }>()
for (const f of findings) {
const key = `${f.cookie}@@${f.vendor}`
if (!rowMap.has(key)) rowMap.set(key, { label: f.cookie, vendor: f.vendor, hits: {} })
rowMap.get(key)!.hits[f.type] = (f.kind === 'hinweis') ? '⚠' : '✗'
}
return { rows: [...rowMap.values()], cols }
}, [findings])
return (
<div className="border rounded-lg overflow-auto max-h-[32rem]">
<table className="w-full text-[11px]">
<thead className="bg-slate-50 sticky top-0">
<tr>
<th className="px-2 py-1.5 text-left font-semibold text-gray-600">Cookie</th>
{cols.map(c => (
<th key={c} className="px-1 py-1.5 text-center font-normal text-gray-500" title={TYPE_LABEL[c]}>
{(TYPE_LABEL[c] || c).split(' ')[0]}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-t border-gray-100">
<td className="px-2 py-1 font-mono text-gray-700 break-all">
{r.label}
{r.vendor && r.vendor !== '—' && <span className="text-gray-400 ml-1">· {r.vendor}</span>}
</td>
{cols.map(c => (
<td key={c} className={`px-1 py-1 text-center ${r.hits[c] === '✗' ? 'text-red-600' : r.hits[c] === '⚠' ? 'text-amber-600' : 'text-gray-200'}`}>
{r.hits[c] || '·'}
</td>
))}
</tr>
))}
</tbody>
</table>
<div className="px-2 py-1.5 text-[10px] text-gray-400 border-t">
= Handlung nötig · = Hinweis (zu prüfen) · Spalte = Fehlertyp (Tooltip)
</div>
</div>
)
}
export function CookieFindings({ findings }: { findings: CookieFinding[] }) {
const [mode, setMode] = useState<'type' | 'matrix'>('type')
const real = findings.filter(f => (f.kind ?? 'finding') !== 'hinweis')
const hints = findings.filter(f => (f.kind ?? 'finding') === 'hinweis')
if (!findings.length) {
return <div className="px-4 py-3 text-sm text-green-700 border rounded-lg">Keine Abweichungen gegen die Library.</div>
}
const btn = (m: 'type' | 'matrix', label: string) => (
<button onClick={() => setMode(m)}
className={`px-2.5 py-1 rounded text-xs ${mode === m ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
{label}
</button>
)
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-800">
{findings.length} Befund{findings.length !== 1 ? 'e' : ''}
<span className="text-xs font-normal text-gray-400 ml-2">
{real.length} zu beheben · {hints.length} Hinweise
</span>
</span>
<div className="flex items-center gap-1">
{btn('type', 'Nach Fehlertyp')}
{btn('matrix', 'Matrix')}
</div>
</div>
{mode === 'matrix' ? (
<Matrix findings={findings} />
) : (
<div className="space-y-3">
<Section title="Findings — zu beheben" groups={groupByType(real)} />
<Section title="Hinweise — neutral, gegen DSE/Doku zu prüfen"
hint="z.B. Drittland: interne Verträge können wir nicht einsehen"
groups={groupByType(hints)} />
</div>
)}
</div>
)
}
@@ -1,119 +0,0 @@
'use client'
/**
* CookieLibraryPanel Pro-Cookie-Abgleich gegen die Knowledge-Library:
* findet als notwendig" deklarierte Tracker + fehlende Zwecke und zeigt je
* Befund die Abstellmaßnahme. Lädt aus dem Snapshot (kein Re-Crawl).
*/
import React, { useEffect, useState } from 'react'
import { CookieFindings } from './CookieFindings'
export interface CookieFinding {
vendor: string
cookie: string
type: string
severity: string
declared: string
library_purpose: string
remediation: string
kind?: string
control?: { control_id?: string | null; regulation?: string; article?: string }
}
interface CheckData {
summary?: { checked?: number; in_library?: number; findings?: number }
findings?: CookieFinding[]
storage_inventory?: {
total?: number
by_type?: Record<string, number>
real_cookies?: number
other_storage?: number
}
drift?: {
declared_count?: number
browser_count?: number
high_findings?: number
low_findings?: number
}
}
const STORAGE_LABEL: Record<string, string> = {
cookie: 'Cookies', local_storage: 'Local Storage',
session_storage: 'Session Storage', indexeddb: 'IndexedDB',
framework_storage: 'Framework-Storage',
}
// Pure, testbar.
export function CookieFindingList({ data }: { data: CheckData }) {
const findings = data.findings || []
const s = data.summary || {}
const inv = data.storage_inventory
const drift = data.drift
const driftShown =
!!drift && ((drift.declared_count ?? 0) + (drift.browser_count ?? 0)) > 0
return (
<div className="space-y-3">
{(driftShown || (inv && (inv.total ?? 0) > 0)) && (
<div className="border rounded-lg overflow-hidden">
{driftShown && (
<div className="px-4 py-2.5 bg-amber-50 border-b text-xs text-amber-900">
<span className="font-semibold">Richtlinie Realität:</span>{' '}
<strong>{drift!.declared_count ?? 0}</strong> in der Cookie-Richtlinie
dokumentiert · <strong>{drift!.browser_count ?? 0}</strong> im Browser geladen
{(drift!.high_findings ?? 0) > 0 && (
<> · <strong className="text-red-700">{drift!.high_findings} undokumentiert geladen</strong></>
)}
{(drift!.low_findings ?? 0) > 0 && (
<> · {drift!.low_findings} dokumentiert, aber nicht geladen</>
)}
</div>
)}
{inv && (inv.total ?? 0) > 0 && (
<div className="px-4 py-2.5 bg-blue-50 text-xs text-blue-900">
<span className="font-semibold">Storage-Inventar:</span>{' '}
{inv.total} als Cookies" gelistet {' '}
<strong>{inv.real_cookies} echte Cookies</strong>
{(inv.other_storage ?? 0) > 0 && (
<> + <strong className="text-amber-700">{inv.other_storage} andere Endgeräte-Speicher</strong></>
)}
{inv.by_type && (
<span className="text-blue-700 ml-1">
({Object.entries(inv.by_type)
.map(([k, n]) => `${n} ${STORAGE_LABEL[k] || k}`)
.join(' · ')})
</span>
)}
</div>
)}
</div>
)}
<div className="text-[11px] text-gray-400">
{s.in_library ?? 0}/{s.checked ?? 0} Cookies in der Library erkannt
</div>
<CookieFindings findings={findings} />
</div>
)
}
export function CookieLibraryPanel(
{ snapshotId, data: provided }: { snapshotId: string; data?: CheckData },
) {
const [data, setData] = useState<CheckData | null>(provided ?? null)
const [loading, setLoading] = useState(!provided)
useEffect(() => {
if (provided) { setData(provided); setLoading(false); return }
let cancelled = false
fetch(`/api/sdk/v1/agent/snapshots/${snapshotId}/cookie-check`)
.then(r => r.json())
.then(d => { if (!cancelled) setData(d) })
.catch(() => { if (!cancelled) setData({ findings: [] }) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [snapshotId, provided])
if (loading) return <div className="text-xs text-gray-400">Library-Abgleich läuft</div>
return <CookieFindingList data={data || {}} />
}
@@ -1,369 +0,0 @@
'use client'
/**
* CookieResultView strukturierte Cookie-/Vendor-Auswertung aus einem
* gespeicherten Snapshot (cmp_vendors), OHNE Re-Crawl.
*
* Zwei Sichten (Umschalter):
* - Rechtliche Rolle: Eigene / Auftragsverarbeiter / Joint Controller (VVT)
* - Banner-Kategorie: Notwendig / Funktional / Statistik / Marketing die im
* Consent-Banner implementierte Einteilung. Pro Cookie wird die tatsächliche
* Kategorie laut Library gegengeprüft '→ sollte: Marketing' bei
* Fehl-Einsortierung (Tracker als notwendig = § 25 TDDDG-relevant).
*/
import React, { useMemo, useState } from 'react'
export interface SnapshotCookie {
name: string
expiry?: string
purpose?: string
is_third_party?: boolean
functional_role?: string
}
export interface SnapshotVendor {
name: string
cookies?: SnapshotCookie[]
category?: string
country?: string
recipient_type?: string
compliance_score?: number
compliance_flags?: string[]
opt_out_ok?: boolean
}
interface Snapshot {
id: string
site_domain?: string
created_at?: string
cmp_vendors?: SnapshotVendor[]
}
// name_lower → tatsächliche Kategorie laut Library (aus /cookie-check).
export type LibCategories = Record<string, string>
// name_lower → Speichertyp (cookie | local_storage | framework_storage | …).
export type StorageTypes = Record<string, string>
const STORAGE_LABEL: Record<string, string> = {
cookie: 'Cookie', local_storage: 'Local Storage',
session_storage: 'Session Storage', indexeddb: 'IndexedDB',
framework_storage: 'Framework',
}
const STORAGE_COLOR: Record<string, string> = {
cookie: 'bg-gray-100 text-gray-500',
local_storage: 'bg-purple-100 text-purple-700',
session_storage: 'bg-indigo-100 text-indigo-700',
indexeddb: 'bg-cyan-100 text-cyan-700',
framework_storage: 'bg-orange-100 text-orange-700',
}
const STORAGE_ORDER = ['cookie', 'local_storage', 'session_storage', 'indexeddb', 'framework_storage']
function storageOf(name: string, st?: StorageTypes): string {
return st?.[(name || '').toLowerCase()] || 'cookie'
}
const ROLE_LABEL: Record<string, string> = {
unknown: 'Unbekannt', ad_pixel: 'Werbe-Pixel', auth_token: 'Auth-Token',
preference: 'Präferenz', visitor_id: 'Besucher-ID', consent_state: 'Consent',
tracking: 'Tracking',
}
const CAT_COLOR: Record<string, string> = {
necessary: 'bg-green-100 text-green-700', functional: 'bg-blue-100 text-blue-700',
statistics: 'bg-amber-100 text-amber-700', marketing: 'bg-red-100 text-red-700',
}
const EEA = new Set([
'DE','FR','IE','NL','AT','BE','BG','HR','CY','CZ','DK','EE','FI','GR','HU',
'IT','LV','LT','LU','MT','PL','PT','RO','SK','SI','ES','SE','IS','LI','NO',
])
const GROUPS = [
{ key: 'own', label: 'Eigene Verarbeitungen (VVT, Art. 30)', test: (r: string) => !r || r === 'INTERNAL' || r === 'GROUP' },
{ key: 'proc', label: 'Auftragsverarbeiter (AVV, Art. 28)', test: (r: string) => r === 'PROCESSOR' },
{ key: 'joint', label: 'Eigenverantwortliche Dritte / Joint Controller (Art. 26)', test: (r: string) => r === 'JOINT_CONTROLLER' || r === 'CONTROLLER' },
{ key: 'other', label: 'Sonstige Empfänger', test: () => true },
]
// Banner-Kategorie-Sicht: kanonische Buckets + Labels.
const CAT_CANON: Record<string, string> = {
necessary: 'necessary', essential: 'necessary', notwendig: 'necessary',
essenziell: 'necessary', security: 'necessary', 'strictly necessary': 'necessary',
functional: 'functional', funktional: 'functional', preferences: 'functional',
preference: 'functional', präferenzen: 'functional',
statistics: 'statistics', statistik: 'statistics', analytics: 'statistics',
performance: 'statistics',
marketing: 'marketing', targeting: 'marketing', advertising: 'marketing',
werbung: 'marketing', social_media: 'marketing', social: 'marketing', ad: 'marketing',
}
const CANON_LABEL: Record<string, string> = {
necessary: 'Notwendig', functional: 'Funktional',
statistics: 'Statistik', marketing: 'Marketing', unknown: '—',
}
const CATEGORY_GROUPS = [
{ key: 'necessary', label: 'Notwendig (essenziell)' },
{ key: 'functional', label: 'Funktional' },
{ key: 'statistics', label: 'Statistik' },
{ key: 'marketing', label: 'Marketing' },
{ key: 'unknown', label: 'Ohne Kategorie' },
]
function canonCat(c?: string): string {
return CAT_CANON[(c || '').toLowerCase().trim()] || 'unknown'
}
// Tatsächliche Kategorie laut Library vs. deklarierte Banner-Kategorie.
function mismatch(name: string, declaredCanon: string, lib?: LibCategories) {
const raw = lib?.[name.toLowerCase()]
if (!raw) return null
const actual = canonCat(raw)
if (actual === 'unknown' || actual === declaredCanon) return null
// severe: als notwendig deklariert, laut Library einwilligungspflichtig.
const severe = declaredCanon === 'necessary'
&& (actual === 'marketing' || actual === 'statistics')
return { actual, severe }
}
function scoreColor(s?: number): string {
if (s == null) return 'text-gray-400'
return s >= 80 ? 'text-green-700' : s >= 50 ? 'text-amber-700' : 'text-red-700'
}
function Tile({ label, value, tone }: { label: string; value: React.ReactNode; tone: string }) {
return (
<div className="border border-gray-200 rounded-lg p-3 bg-white">
<div className={`text-2xl font-semibold leading-none ${tone}`}>{value}</div>
<div className="text-xs text-gray-500 mt-1.5">{label}</div>
</div>
)
}
function VendorRow(
{ v, lib, st, sf }:
{ v: SnapshotVendor; lib?: LibCategories; st?: StorageTypes; sf: string },
) {
const [open, setOpen] = useState(false)
const cookies = sf
? (v.cookies || []).filter(c => storageOf(c.name, st) === sf)
: (v.cookies || [])
const cat = (v.category || '').toLowerCase()
const declaredCanon = canonCat(v.category)
const drittland = !!v.country && !EEA.has((v.country || '').toUpperCase())
return (
<div>
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-gray-50 text-xs"
>
<span className={`text-gray-400 transition-transform ${open ? 'rotate-90' : ''}`}></span>
<span className="font-medium text-gray-800 flex-1 min-w-0 truncate">{v.name}</span>
{cat && (
<span className={`px-1.5 py-0.5 rounded text-[10px] ${CAT_COLOR[cat] || 'bg-gray-100 text-gray-600'}`}>
{v.category}
</span>
)}
{drittland && (
<span className="px-1.5 py-0.5 rounded text-[10px] bg-red-50 text-red-600" title="außerhalb EWR">
{v.country}
</span>
)}
<span className="text-gray-500 w-12 text-right" title="Cookies">{cookies.length}</span>
<span className={`w-10 text-right font-semibold ${scoreColor(v.compliance_score)}`}>
{v.compliance_score != null ? `${v.compliance_score}%` : '—'}
</span>
</button>
{open && cookies.length > 0 && (
<div className="ml-6 mb-1 border-l-2 border-gray-200">
<table className="w-full text-[11px]">
<thead className="text-gray-400">
<tr>
<th className="px-2 py-1 text-left font-normal">Cookie</th>
<th className="px-2 py-1 text-left font-normal">Speicher</th>
<th className="px-2 py-1 text-left font-normal">Rolle</th>
<th className="px-2 py-1 text-left font-normal">Zweck</th>
<th className="px-2 py-1 text-left font-normal">Laufzeit</th>
</tr>
</thead>
<tbody>
{cookies.map((c, i) => {
const mm = mismatch(c.name, declaredCanon, lib)
return (
<tr key={i} className="border-t border-gray-100 align-top">
<td className="px-2 py-1 font-mono text-gray-700 break-all w-40">
{c.name}
{mm && (
<span
className={`ml-1 inline-block px-1 py-0.5 rounded text-[9px] font-sans ${mm.severe ? 'bg-red-100 text-red-700' : 'bg-amber-100 text-amber-700'}`}
title="tatsächliche Kategorie laut Library"
>
sollte: {CANON_LABEL[mm.actual]}
</span>
)}
</td>
<td className="px-2 py-1 w-24">
{(() => {
const t = storageOf(c.name, st)
return t !== 'cookie' ? (
<span className={`px-1 py-0.5 rounded text-[9px] ${STORAGE_COLOR[t]}`}>
{STORAGE_LABEL[t] || t}
</span>
) : <span className="text-gray-300 text-[10px]">Cookie</span>
})()}
</td>
<td className="px-2 py-1 text-gray-500 w-24">
{c.functional_role && c.functional_role !== 'unknown'
? (ROLE_LABEL[c.functional_role] || c.functional_role)
: <span className="text-gray-300"></span>}
</td>
<td className="px-2 py-1 text-gray-500 break-words">
{c.purpose
? c.purpose
: <span className="text-amber-600 italic">kein Zweck</span>}
</td>
<td className="px-2 py-1 text-gray-400 w-24 whitespace-nowrap">{c.expiry || '—'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}
export function CookieResultView(
{ snapshot, cookieCategories, storageTypes }:
{ snapshot: Snapshot; cookieCategories?: LibCategories; storageTypes?: StorageTypes },
) {
const vendors = snapshot.cmp_vendors || []
const [viewMode, setViewMode] = useState<'role' | 'category'>('role')
const [storageFilter, setStorageFilter] = useState('')
// Speichertyp-Verteilung über alle Cookies (für die Filter-Chips + Zähler).
const storagePresent = useMemo(() => {
const counts: Record<string, number> = {}
for (const v of vendors)
for (const c of v.cookies || []) {
const t = storageOf(c.name, storageTypes)
counts[t] = (counts[t] || 0) + 1
}
return counts
}, [vendors, storageTypes])
const matchesSF = (v: SnapshotVendor) =>
!storageFilter || (v.cookies || []).some(c => storageOf(c.name, storageTypes) === storageFilter)
const stats = useMemo(() => {
const cookies = vendors.reduce((n, v) => n + (v.cookies?.length || 0), 0)
const marketing = vendors.filter(v => (v.category || '').toLowerCase() === 'marketing').length
const drittland = vendors.filter(v => v.country && !EEA.has(v.country.toUpperCase())).length
let misplaced = 0
for (const v of vendors) {
const dc = canonCat(v.category)
for (const c of v.cookies || []) {
if (mismatch(c.name, dc, cookieCategories)?.severe) misplaced++
}
}
return { cookies, marketing, drittland, misplaced }
}, [vendors, cookieCategories])
const grouped = useMemo(() => {
const sortByScore = (a: SnapshotVendor, b: SnapshotVendor) =>
(a.compliance_score ?? 100) - (b.compliance_score ?? 100)
if (viewMode === 'category') {
return CATEGORY_GROUPS
.map(g => ({ ...g, vendors: vendors.filter(v => canonCat(v.category) === g.key).filter(matchesSF).sort(sortByScore) }))
.filter(g => g.vendors.length > 0)
}
return GROUPS
.map(g => ({
...g,
vendors: vendors
.filter(v => GROUPS.find(gg => gg.test((v.recipient_type || '').toUpperCase()))?.key === g.key)
.filter(matchesSF)
.sort(sortByScore),
}))
.filter(g => g.vendors.length > 0)
}, [vendors, viewMode, storageFilter, storageTypes])
const toggleBtn = (mode: 'role' | 'category', label: string) => (
<button
onClick={() => setViewMode(mode)}
className={`px-2.5 py-1 rounded text-xs ${viewMode === mode ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{label}
</button>
)
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div>
<h2 className="text-lg font-semibold text-gray-900">
Cookie-Auswertung {snapshot.site_domain || 'Snapshot'}
</h2>
<p className="text-xs text-gray-500 mt-0.5">
aus gespeichertem Snapshot (kein Re-Crawl) ·{' '}
{snapshot.created_at ? snapshot.created_at.slice(0, 19).replace('T', ' ') : ''}
</p>
</div>
<div className="flex items-center gap-1">
<span className="text-[11px] text-gray-500 mr-1">Gruppierung:</span>
{toggleBtn('role', 'Rechtliche Rolle')}
{toggleBtn('category', 'Banner-Kategorie')}
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<Tile label="Anbieter" value={vendors.length} tone="text-gray-800" />
<Tile
label={storageFilter ? `${STORAGE_LABEL[storageFilter] || storageFilter} (gefiltert)` : 'Cookies gesamt'}
value={storageFilter ? (storagePresent[storageFilter] || 0) : stats.cookies}
tone="text-gray-800"
/>
<Tile label="Marketing-Anbieter" value={stats.marketing} tone={stats.marketing > 0 ? 'text-red-700' : 'text-gray-800'} />
<Tile label="Drittland (außerhalb EWR)" value={stats.drittland} tone={stats.drittland > 0 ? 'text-amber-700' : 'text-gray-800'} />
<Tile label="Falsch einsortiert (lt. Library)" value={stats.misplaced} tone={stats.misplaced > 0 ? 'text-red-700' : 'text-gray-800'} />
</div>
{Object.keys(storagePresent).filter(t => t !== 'cookie').length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
<span className="text-[11px] text-gray-500 mr-1">Speichertyp:</span>
<button
onClick={() => setStorageFilter('')}
className={`px-2 py-0.5 rounded text-[11px] ${!storageFilter ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
Alle ({stats.cookies})
</button>
{STORAGE_ORDER.filter(t => storagePresent[t]).map(t => (
<button
key={t}
onClick={() => setStorageFilter(f => f === t ? '' : t)}
className={`px-2 py-0.5 rounded text-[11px] ${storageFilter === t ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{STORAGE_LABEL[t] || t} ({storagePresent[t]})
</button>
))}
</div>
)}
{viewMode === 'category' && (
<p className="text-[11px] text-gray-500 -mt-1">
Banner-Kategorie wie im Consent-Tool deklariert. Badge{' '}
<span className="px-1 py-0.5 rounded text-[9px] bg-red-100 text-red-700"> sollte: </span>{' '}
zeigt die tatsächliche Kategorie laut Library (Fehl-Einsortierung).
</p>
)}
{grouped.map(g => (
<div key={g.key} className="border rounded-lg overflow-hidden">
<div className="px-3 py-2 bg-slate-50 border-b text-xs font-semibold text-gray-700">
{g.label} <span className="text-gray-400 font-normal">({g.vendors.length})</span>
</div>
<div className="divide-y divide-gray-100">
{g.vendors.map((v, i) => <VendorRow key={i} v={v} lib={cookieCategories} st={storageTypes} sf={storageFilter} />)}
</div>
</div>
))}
</div>
)
}
@@ -1,144 +0,0 @@
'use client'
/**
* DocResultView EIN Dokument-Prüfergebnis der HAUPT-Engine als saubere,
* immer-offene Pflichtangaben-Tabelle: Verdikt + Gruppen + extrahierte Texte
* (matched_text) pro Prüfpunkt.
*
* Quelle = result.results[doc] (die genaue Haupt-Doc-Check-Engine), NICHT
* der v3-Agent. Zeigt menschliche Labels + gefundene Snippets, keine internen
* IDs. Wiederverwendet die Render-Bausteine aus ChecklistView.
*/
import React from 'react'
import {
CheckIcon,
type DocResult,
groupChecks,
SCENARIO_LABELS,
} from './ChecklistView'
function Snippet({ text }: { text: string }) {
return (
<div className="text-xs text-gray-500 mt-0.5 font-mono break-words">
{text}"
</div>
)
}
function ScoreBar({ label, pct, blue }: { label: string; pct: number; blue?: boolean }) {
const color = blue
? pct >= 80 ? 'bg-blue-400' : 'bg-blue-300'
: pct === 100 ? 'bg-green-500' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500'
return (
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-gray-400">{label}</span>
<div className="w-12 h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-gray-600 w-9 text-right">{pct}%</span>
</div>
)
}
export function DocResultView({ doc }: { doc: DocResult }) {
if (doc.error) {
return (
<div className="text-sm text-amber-700 bg-amber-50 rounded p-3">
{doc.error}
</div>
)
}
const grouped = groupChecks(doc.checks)
const l1 = doc.checks.filter(c => (c.level ?? 1) === 1)
const l1Score = l1.filter(c => c.severity !== 'INFO')
const l1Passed = l1Score.filter(c => c.passed).length
const l2 = doc.checks.filter(c => (c.level ?? 1) === 2 && !c.skipped)
const l2Passed = l2.filter(c => c.passed).length
const sc = doc.scenario ? SCENARIO_LABELS[doc.scenario] : null
return (
<div className="space-y-3">
{/* Verdikt-Kopf */}
<div className="flex items-center flex-wrap gap-3 border rounded-lg px-4 py-3 bg-slate-50">
{sc && (
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${sc.bg} ${sc.color}`}>
{sc.label}
</span>
)}
<span className="text-sm text-gray-700">
{l1Passed}/{l1Score.length} Pflichtangaben
{l2.length > 0 && <>, {l2Passed}/{l2.length} Detailprüfungen</>}
</span>
<div className="flex gap-3 ml-auto">
<ScoreBar label="Pflicht" pct={doc.completeness_pct} />
{l2.length > 0 && (
<ScoreBar label="Detail" pct={doc.correctness_pct ?? 0} blue />
)}
</div>
</div>
{/* Pflichtangaben-Tabelle */}
<div className="border rounded-lg divide-y divide-gray-100">
{grouped.map(g => {
const l1Info = g.check.severity === 'INFO' && !g.check.passed
return (
<div key={g.check.id} className="px-4 py-2">
<div className="flex items-start gap-2">
<CheckIcon passed={g.check.passed} isInfo={l1Info} />
<div className="flex-1 min-w-0">
<div className={`text-sm ${
g.check.passed ? 'text-gray-800'
: l1Info ? 'text-gray-500' : 'text-red-700 font-medium'
}`}>
{g.check.label}
</div>
{g.check.passed && g.check.matched_text && g.children.length === 0 && (
<Snippet text={g.check.matched_text} />
)}
{!g.check.passed && g.check.hint && (
<div className={`text-xs mt-0.5 ${l1Info ? 'text-gray-400' : 'text-red-600/80'}`}>
{g.check.hint}
</div>
)}
</div>
</div>
{g.children.length > 0 && (
<div className="ml-6 mt-1 space-y-1 border-l-2 border-gray-200 pl-3">
{g.children.map(ch => {
const chInfo = ch.severity === 'INFO' && !ch.passed && !ch.skipped
return (
<div key={ch.id} className="flex items-start gap-2">
<CheckIcon passed={ch.passed} skipped={ch.skipped} isInfo={chInfo} />
<div className="flex-1 min-w-0">
<div className={`text-xs ${
ch.skipped ? 'text-gray-400 italic'
: ch.passed ? 'text-gray-600'
: chInfo ? 'text-gray-400' : 'text-red-600 font-medium'
}`}>
{ch.label}{ch.skipped && ' (übersprungen)'}
</div>
{ch.passed && ch.matched_text && <Snippet text={ch.matched_text} />}
{!ch.passed && !ch.skipped && ch.hint && (
<div className={`text-xs mt-0.5 ${chInfo ? 'text-gray-400' : 'text-red-500/80'}`}>
{ch.hint}
</div>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
{doc.word_count > 0 && (
<div className="text-xs text-gray-400">{doc.word_count} Wörter analysiert</div>
)}
</div>
)
}
@@ -1,140 +0,0 @@
'use client'
/**
* RemediationPlan Abstellmaßnahmen + Ticket-Formulierung.
*
* Aus den offenen Punkten (result.results, Haupt-Engine) je Finding eine
* Maßnahme + einen fertigen Ticket-Text ableiten und übergabebereit machen
* (Kopieren / JSON-Export). SCOPE: BreakPilot formuliert NUR Ticketsystem,
* Jira-Sync und Feedback-Loop baut ein anderes Team. Keine zweite Engine.
*/
import React, { useState } from 'react'
import { DOC_TYPE_LABELS, type DocResult } from './ChecklistView'
type Priority = 'Hoch' | 'Mittel' | 'Niedrig'
interface Remediation {
docType: string
docLabel: string
checkLabel: string
action: string
ticketTitle: string
ticketBody: string
priority: Priority
}
const PRIO_RANK: Record<Priority, number> = { Hoch: 0, Mittel: 1, Niedrig: 2 }
const PRIO_COLOR: Record<Priority, string> = {
Hoch: 'bg-red-100 text-red-700',
Mittel: 'bg-amber-100 text-amber-700',
Niedrig: 'bg-blue-100 text-blue-700',
}
function toPriority(sev: string): Priority {
const s = (sev || '').toUpperCase()
if (s === 'HIGH' || s === 'CRITICAL') return 'Hoch'
if (s === 'MEDIUM') return 'Mittel'
return 'Niedrig'
}
function buildRemediations(docs: DocResult[]): Remediation[] {
const out: Remediation[] = []
for (const d of docs) {
if (d.error) continue
const docLabel = DOC_TYPE_LABELS[d.doc_type] || d.doc_type
const failed = d.checks.filter(
c => !c.passed && !c.skipped && c.severity !== 'INFO',
)
for (const c of failed) {
const action = c.hint || `${c.label} im ${docLabel} ergänzen.`
out.push({
docType: d.doc_type,
docLabel,
checkLabel: c.label,
action,
ticketTitle: `Compliance: ${docLabel} ${c.label}`,
ticketBody:
`Dokument: ${docLabel}\nPrüfpunkt: ${c.label}\n` +
`Status: nicht erfüllt\nMaßnahme: ${action}`,
priority: toPriority(c.severity),
})
}
}
return out.sort((a, b) => PRIO_RANK[a.priority] - PRIO_RANK[b.priority])
}
export function RemediationPlan({ results }: { results: any }) {
const items = buildRemediations(results.results || [])
const [copied, setCopied] = useState<number | null>(null)
if (items.length === 0) {
return (
<div className="border rounded-lg p-4 text-sm text-green-700 bg-green-50">
Keine offenen Pflichtangaben kein Handlungsbedarf.
</div>
)
}
function copyTicket(i: number, body: string) {
navigator.clipboard?.writeText(body)
setCopied(i)
window.setTimeout(() => setCopied(null), 1500)
}
function exportAll() {
const payload = items.map(it => ({
title: it.ticketTitle,
body: it.ticketBody,
priority: it.priority,
doc_type: it.docType,
}))
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: 'application/json',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'breakpilot-tickets.json'
a.click()
URL.revokeObjectURL(url)
}
return (
<div className="border rounded-lg overflow-hidden">
<div className="px-4 py-2.5 bg-slate-50 border-b flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-gray-800">
Abstellmaßnahmen &amp; Tickets ({items.length})
</h3>
<button
onClick={exportAll}
className="text-xs px-2.5 py-1 rounded border border-gray-200 hover:bg-gray-100 text-gray-600"
>
Alle als JSON exportieren
</button>
</div>
<div className="divide-y divide-gray-100">
{items.map((it, i) => (
<div key={i} className="px-4 py-3 space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${PRIO_COLOR[it.priority]}`}>
{it.priority}
</span>
<span className="text-sm font-medium text-gray-800">
{it.docLabel}: {it.checkLabel}
</span>
</div>
<div className="text-xs text-gray-600">{it.action}</div>
<button
onClick={() => copyTicket(i, it.ticketBody)}
className="text-xs px-2 py-1 rounded bg-purple-50 text-purple-700 border border-purple-200 hover:bg-purple-100"
>
{copied === i ? 'Kopiert ✓' : 'Ticket-Text kopieren'}
</button>
</div>
))}
</div>
</div>
)
}
@@ -1,89 +0,0 @@
'use client'
/**
* ResultSummary Audit-Kopf: Titel + check_id + 4 KPI-Kacheln über den
* Dokument-Tabs. Co-Pilot-Ton (grün wenn gut, rot nur bei echten offenen
* Punkten, gelb für zu prüfen"). Rechnet aus result.results (Haupt-Engine).
*/
import React from 'react'
import type { CheckItem, DocResult } from './ChecklistView'
type Tone = 'gray' | 'green' | 'red' | 'amber'
const TONE: Record<Tone, string> = {
gray: 'text-gray-800',
green: 'text-green-700',
red: 'text-red-700',
amber: 'text-amber-700',
}
function Tile({ label, value, tone }: { label: string; value: React.ReactNode; tone: Tone }) {
return (
<div className="border border-gray-200 rounded-lg p-3 bg-white">
<div className={`text-2xl font-semibold leading-none ${TONE[tone]}`}>{value}</div>
<div className="text-xs text-gray-500 mt-1.5">{label}</div>
</div>
)
}
function isReview(c: CheckItem): boolean {
return c.severity === 'INFO' && !c.passed && !c.skipped
}
export function ResultSummary({ results }: { results: any }) {
const docs: DocResult[] = results.results || []
const company = results.extracted_profile?.company_profile?.companyName as string | undefined
let offen = 0
let zuPruefen = 0
let konform = 0
let checked = 0
for (const d of docs) {
if (d.error) continue
checked++
const l1Score = d.checks.filter(c => (c.level ?? 1) === 1 && c.severity !== 'INFO')
const l1Failed = l1Score.filter(c => !c.passed).length
const l2Failed = d.checks.filter(
c => (c.level ?? 1) === 2 && !c.skipped && !c.passed && c.severity !== 'INFO',
).length
offen += l1Failed + l2Failed
zuPruefen += d.checks.filter(isReview).length
if (l1Failed === 0 && (d.completeness_pct ?? 0) === 100) konform++
}
return (
<div className="space-y-3">
<div>
<h2 className="text-lg font-semibold text-gray-900">
Compliance-Check{company ? `: ${company}` : ''}
</h2>
<p className="text-xs text-gray-500 mt-0.5">
{results.check_id && (
<>ID <code className="bg-gray-100 px-1 rounded">{results.check_id}</code> · </>
)}
{docs.length} Dokument{docs.length !== 1 ? 'e' : ''} geprüft
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Tile label="Dokumente" value={docs.length} tone="gray" />
<Tile
label="Konform"
value={`${konform}/${checked || docs.length}`}
tone={checked > 0 && konform === checked ? 'green' : 'gray'}
/>
<Tile
label="Offene Pflichtangaben"
value={offen}
tone={offen > 0 ? 'red' : 'green'}
/>
<Tile
label="Zu prüfen"
value={zuPruefen}
tone={zuPruefen > 0 ? 'amber' : 'gray'}
/>
</div>
</div>
)
}
@@ -32,20 +32,12 @@ interface TextRef {
interface ScanFinding { interface ScanFinding {
code: string code: string
doc_title?: string
severity: string severity: string
text: string text: string
correction: string correction: string
text_reference: TextRef | null text_reference: TextRef | null
} }
interface DiscoveredDocument {
title: string
completeness_pct: number
word_count?: number
url?: string
}
interface ScanData { interface ScanData {
pages_scanned: number pages_scanned: number
pages_list: string[] pages_list: string[]
@@ -1,33 +0,0 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { AgentFindingCard } from '../AgentFindingCard'
import type { Finding } from '../_agentTypes'
const BASE: Finding = {
check_id: 'IMP-handelsregister', agent: 'impressum', agent_version: '3.0',
field_id: 'handelsregister', severity: 'HIGH', title: 'X',
norm: '§ 5 Abs. 1 Nr. 4 TMG', evidence: '', action: 'Tu etwas.',
confidence: 0.4,
sources: [{ source_type: 'regex', source_id: 'IMP-MC-004', confidence: 0.4 }],
}
describe('AgentFindingCard — 4-Status', () => {
it('INSUFFICIENT_EVIDENCE zeigt Verdikt-Pill + Prüf-Hinweis statt FAIL', () => {
const f: Finding = {
...BASE, status: 'insufficient_evidence', severity: 'INFO',
title: 'Handelsregister-Eintrag: Rechtsform nicht erkennbar',
}
render(<AgentFindingCard f={f} />)
expect(screen.getByText('Unzureichende Evidenz')).toBeInTheDocument()
expect(screen.getByText('Prüf-Hinweis')).toBeInTheDocument()
expect(screen.queryByText('Pflicht-Maßnahme')).not.toBeInTheDocument()
})
it('FAIL/HIGH zeigt KEINE Verdikt-Pill, aber Pflicht-Maßnahme', () => {
const f: Finding = { ...BASE, status: 'fail', severity: 'HIGH' }
render(<AgentFindingCard f={f} />)
expect(screen.queryByText('Unzureichende Evidenz')).not.toBeInTheDocument()
expect(screen.getByText('Pflicht-Maßnahme')).toBeInTheDocument()
})
})
@@ -1,29 +0,0 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { AgentPflichtTable } from '../AgentPflichtTable'
import type { McCoverage } from '../_agentTypes'
const COV: McCoverage[] = [
{ mc_id: 'IMP-MC-002', status: 'ok', label: 'Email-Adresse',
found: 'kundenbetreuung@bmw.de' },
{ mc_id: 'IMP-MC-010', status: 'possibly_applicable',
label: 'Verbraucher-Streitbeilegung-Hinweis' },
{ mc_id: 'IMP-MC-009', status: 'na', label: 'Verantwortlicher § 18 MStV' },
]
describe('AgentPflichtTable', () => {
it('zeigt Label + gefundenen Wert, aber KEINE mc_id', () => {
render(<AgentPflichtTable coverage={COV} />)
expect(screen.getByText('Email-Adresse')).toBeInTheDocument()
expect(screen.getByText('kundenbetreuung@bmw.de')).toBeInTheDocument()
// Reverse-Engineering-Schutz: mc_id darf NICHT erscheinen.
expect(screen.queryByText(/IMP-MC-/)).not.toBeInTheDocument()
})
it('Verdikt-Header zählt die Status', () => {
render(<AgentPflichtTable coverage={COV} />)
expect(screen.getByText(/1 vorhanden/)).toBeInTheDocument()
expect(screen.getByText(/1 zu prüfen/)).toBeInTheDocument()
})
})
@@ -1,100 +0,0 @@
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { AgentResultTab } from '../AgentResultTab'
import { ComplianceResultTabs } from '../ComplianceResultTabs'
import type { SlotOutput } from '../_agentTypes'
const IMPRESSUM_OUTPUT: SlotOutput = {
agent: 'impressum',
agent_version: '3.0',
duration_ms: 42,
confidence: 0.9,
notes: '12 §5-TMG-MCs geprüft · 2 Pflichtangabe(n) offen',
findings: [
{
check_id: 'IMP-kontakt_email', agent: 'impressum', agent_version: '3.0',
field_id: 'kontakt_email', severity: 'HIGH',
severity_reason: 'pflichtangabe_missing',
title: 'Pflichtangabe fehlt: Email-Adresse',
norm: '§ 5 Abs. 1 Nr. 2 TMG', evidence: '',
action: 'Pflichtangabe ergänzen: Email-Adresse.', confidence: 0.9,
sources: [{ source_type: 'regex', source_id: 'IMP-MC-002', confidence: 0.9 }],
},
{
check_id: 'IMP-kontakt_telefon', agent: 'impressum', agent_version: '3.0',
field_id: 'kontakt_telefon', severity: 'MEDIUM',
severity_reason: 'pflichtangabe_missing',
title: 'Pflichtangabe fehlt: Telefon',
norm: '§ 5 Abs. 1 Nr. 2 TMG', evidence: '',
action: 'Pflichtangabe ergänzen: Telefon.', confidence: 0.9,
sources: [{ source_type: 'regex', source_id: 'IMP-MC-003', confidence: 0.9 }],
},
],
recommendations: [
{
recommendation_id: 'rec1', title: 'Pflichtangaben ergänzen',
body: 'Email und Telefon im Impressum ergänzen.', severity: 'HIGH',
related_finding_ids: ['IMP-kontakt_email', 'IMP-kontakt_telefon'],
estimated_effort_hours: 0.5,
},
],
mc_coverage: [
{ mc_id: 'IMP-MC-002', status: 'high', reason: 'kein Pattern-Treffer' },
{ mc_id: 'IMP-MC-003', status: 'medium', reason: 'kein Pattern-Treffer' },
{ mc_id: 'IMP-MC-001', status: 'ok', reason: 'Pattern-Treffer' },
],
escalation_log: [],
mc_total: 3, mc_ok: 1, mc_na: 0, mc_high: 1, mc_medium: 1, mc_low: 0,
}
describe('AgentResultTab', () => {
it('rendert Findings nach Severity + Maßnahmen + Coverage', () => {
render(<AgentResultTab topicLabel="Impressum" output={IMPRESSUM_OUTPUT} />)
// Themen-Header + Severity-Ampel
expect(screen.getByRole('heading', { name: 'Impressum' })).toBeInTheDocument()
expect(screen.getByText('1 HIGH')).toBeInTheDocument()
expect(screen.getByText('1 MEDIUM')).toBeInTheDocument()
// Findings-Sektion mit Titeln
expect(screen.getByText(/Findings \(2\)/)).toBeInTheDocument()
expect(screen.getByText('Pflichtangabe fehlt: Email-Adresse')).toBeInTheDocument()
expect(screen.getByText('Pflichtangabe fehlt: Telefon')).toBeInTheDocument()
// Abstellmaßnahme (action) am HIGH-Finding
expect(screen.getByText('Pflicht-Maßnahme')).toBeInTheDocument()
expect(screen.getByText('Pflichtangabe ergänzen: Email-Adresse.')).toBeInTheDocument()
// Konsolidierter Maßnahmen-Plan
expect(screen.getByText(/Maßnahmen-Plan \(1 konsolidiert\)/)).toBeInTheDocument()
expect(screen.getByText('Pflichtangaben ergänzen')).toBeInTheDocument()
})
})
const DOC_RESULT = {
label: 'Impressum', url: 'https://example.com/impressum',
doc_type: 'impressum', word_count: 50, completeness_pct: 100,
correctness_pct: 100, findings_count: 0, error: '', scenario: 'import',
checks: [
{ id: 'name', label: 'Name des Anbieters', passed: true, severity: 'HIGH',
matched_text: 'Bayerische Motoren Werke Aktiengesellschaft', level: 1 },
{ id: 'email', label: 'E-Mail-Adresse', passed: true, severity: 'HIGH',
matched_text: 'kundenbetreuung@bmw.de', level: 1 },
],
}
describe('ComplianceResultTabs', () => {
it('rendert das Dokument-Tab der Haupt-Engine mit extrahierten Texten', () => {
// Themen-Tabs kommen aus result.results (Haupt-Engine), NICHT agent_outputs.
const result = { results: [DOC_RESULT] }
render(<ComplianceResultTabs results={result} />)
// Dokument-Tab + Übersicht
expect(screen.getByRole('button', { name: /Impressum/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Alle Checks/ })).toBeInTheDocument()
// DocResultView: menschliches Label + gefundener Text sichtbar
expect(screen.getByText('Name des Anbieters')).toBeInTheDocument()
expect(screen.getByText(/Bayerische Motoren Werke/)).toBeInTheDocument()
// Wechsel auf die Übersicht
fireEvent.click(screen.getByRole('button', { name: /Alle Checks/ }))
expect(
screen.getByText(/Dokumenten-Pruefung/),
).toBeInTheDocument()
})
})
@@ -1,34 +0,0 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { CookieDeclarationDiff } from '../CookieDeclarationDiff'
const DATA = {
coverage: { total: 761, checked: 244, discrepant: 1 },
rows: [{
cookie: '_ga', vendor: 'Google Analytics', severity: 'HIGH',
diffs: [
{ field: 'Kategorie', declared: 'notwendig', expected: 'Marketing', severe: true },
{ field: 'Laufzeit', declared: 'Session', expected: '2 Jahre' },
],
measures: ['Als einwilligungspflichtig (§ 25) einstufen.'],
}],
}
describe('CookieDeclarationDiff', () => {
it('zeigt den Funnel + Feld-Diffs deklariert→Library', () => {
render(<CookieDeclarationDiff data={DATA} />)
expect(screen.getByText('761')).toBeInTheDocument() // gesamt
expect(screen.getByText('244')).toBeInTheDocument() // geprüft
expect(screen.getByText('_ga')).toBeInTheDocument()
expect(screen.getByText('Kategorie')).toBeInTheDocument()
expect(screen.getByText('Marketing')).toBeInTheDocument() // Soll-Wert
expect(screen.getByText(/2 Abweichungen/)).toBeInTheDocument()
expect(screen.getByText(/Als einwilligungspflichtig/)).toBeInTheDocument()
})
it('rendert nichts ohne Daten', () => {
const { container } = render(<CookieDeclarationDiff data={undefined} />)
expect(container.firstChild).toBeNull()
})
})
@@ -1,42 +0,0 @@
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { CookieFindings } from '../CookieFindings'
const FINDINGS = [
{ vendor: 'Salesforce', cookie: '_ga', type: 'tracker_as_necessary', severity: 'HIGH',
declared: 'necessary', library_purpose: '', remediation: '', kind: 'finding' },
{ vendor: 'Acme', cookie: 'foo', type: 'missing_purpose', severity: 'MEDIUM',
declared: '', library_purpose: '', remediation: '', kind: 'finding' },
{ vendor: 'Google', cookie: '_gid', type: 'third_country', severity: 'MEDIUM',
declared: 'US', library_purpose: '', remediation: '', kind: 'hinweis' },
]
describe('CookieFindings', () => {
it('gruppiert nach Typ und trennt Findings von Hinweisen', () => {
render(<CookieFindings findings={FINDINGS} />)
expect(screen.getByText(/3 Befunde/)).toBeInTheDocument()
expect(screen.getByText(/Findings — zu beheben/)).toBeInTheDocument()
expect(screen.getByText(/Hinweise — neutral/)).toBeInTheDocument()
expect(screen.getByText(/Tracker als/)).toBeInTheDocument()
expect(screen.getByText('Drittland-Transfer')).toBeInTheDocument()
})
it('klappt eine Gruppe auf und zeigt Maßnahme + Ticket-Button', () => {
render(<CookieFindings findings={FINDINGS} />)
fireEvent.click(screen.getByText(/Zweck fehlt/))
expect(screen.getByText(/Maßnahme:/)).toBeInTheDocument()
expect(screen.getByText(/Ticket-Text kopieren/)).toBeInTheDocument()
})
it('schaltet auf die Matrix-Sicht um', () => {
render(<CookieFindings findings={FINDINGS} />)
fireEvent.click(screen.getByText('Matrix'))
expect(screen.getByText(/Handlung nötig/)).toBeInTheDocument()
})
it('zeigt grünen Hinweis bei 0 Befunden', () => {
render(<CookieFindings findings={[]} />)
expect(screen.getByText(/Keine Abweichungen/)).toBeInTheDocument()
})
})
@@ -1,50 +0,0 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { CookieFindingList } from '../CookieLibraryPanel'
describe('CookieFindingList', () => {
it('zeigt Befunde gruppiert nach Typ mit Severity + Library-Count', () => {
const data = {
summary: { checked: 10, in_library: 4, findings: 1 },
findings: [{
vendor: 'Salesforce', cookie: '_ga', type: 'tracker_as_necessary',
severity: 'HIGH', declared: 'necessary',
library_purpose: 'Besucher eindeutig unterscheiden',
remediation: 'Als einwilligungspflichtig (§ 25 TDDDG) einstufen.', kind: 'finding',
}],
}
render(<CookieFindingList data={data} />)
expect(screen.getByText(/1 Befund/)).toBeInTheDocument()
expect(screen.getByText('HIGH')).toBeInTheDocument()
expect(screen.getByText(/Tracker als/)).toBeInTheDocument() // Gruppen-Header
expect(screen.getByText(/4\/10 Cookies/)).toBeInTheDocument()
})
it('zeigt grünen Hinweis bei 0 Befunden', () => {
render(<CookieFindingList data={{ summary: { checked: 5, in_library: 2 }, findings: [] }} />)
expect(screen.getByText(/Keine Abweichungen/)).toBeInTheDocument()
})
it('zeigt den Drift-Strip (Richtlinie vs. Browser-Realität)', () => {
render(<CookieFindingList data={{
summary: { checked: 31, in_library: 8, findings: 0 },
drift: { declared_count: 0, browser_count: 31, high_findings: 31, low_findings: 0 },
findings: [],
}} />)
expect(screen.getByText(/Richtlinie ↔ Realität/)).toBeInTheDocument()
expect(screen.getByText(/31 undokumentiert geladen/)).toBeInTheDocument()
})
it('zeigt das Storage-Inventar (echte Cookies vs. andere)', () => {
render(<CookieFindingList data={{
summary: { checked: 100, in_library: 30, findings: 0 },
storage_inventory: { total: 100, real_cookies: 60, other_storage: 40,
by_type: { cookie: 60, framework_storage: 40 } },
findings: [],
}} />)
expect(screen.getByText(/Storage-Inventar/)).toBeInTheDocument()
expect(screen.getByText(/60 echte Cookies/)).toBeInTheDocument()
expect(screen.getByText(/40 andere Endgeräte-Speicher/)).toBeInTheDocument()
})
})
@@ -1,81 +0,0 @@
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { CookieResultView } from '../CookieResultView'
const SNAP = {
id: 'abc',
site_domain: 'bmw.de',
created_at: '2026-06-10T22:16:11',
cmp_vendors: [
{
name: 'Salesforce', category: 'necessary', country: 'US',
recipient_type: 'PROCESSOR', compliance_score: 91,
cookies: [
{ name: 'LSKey-c$Policy', functional_role: 'consent_state', purpose: '', expiry: '1 Jahr' },
{ name: 'sid', functional_role: 'auth_token', purpose: 'Login', expiry: 'Session' },
],
},
{
name: 'BMW AG — eShop', category: 'necessary', country: '',
recipient_type: 'INTERNAL', compliance_score: 100,
cookies: [{ name: 'x', functional_role: 'preference', purpose: 'Sprache' }],
},
{
name: 'Meta / Facebook', category: 'marketing', country: 'IE',
recipient_type: 'CONTROLLER', compliance_score: 100, cookies: [],
},
],
}
describe('CookieResultView', () => {
it('zeigt KPIs + Empfänger-Gruppen aus dem Snapshot', () => {
render(<CookieResultView snapshot={SNAP} />)
expect(screen.getByText(/Cookie-Auswertung/)).toBeInTheDocument()
// KPI-Kacheln vorhanden (3 Anbieter, 3 Cookies)
expect(screen.getByText('Anbieter')).toBeInTheDocument()
expect(screen.getByText('Cookies gesamt')).toBeInTheDocument()
expect(screen.getAllByText('3').length).toBeGreaterThanOrEqual(2)
// Gruppen: Eigene + Auftragsverarbeiter + Joint Controller (CONTROLLER)
expect(screen.getByText(/Eigene Verarbeitungen/)).toBeInTheDocument()
expect(screen.getByText(/Auftragsverarbeiter/)).toBeInTheDocument()
expect(screen.getByText(/Joint Controller/)).toBeInTheDocument()
expect(screen.getByText('Salesforce')).toBeInTheDocument()
expect(screen.getByText('Meta / Facebook')).toBeInTheDocument()
})
it('klappt einen Vendor auf und zeigt die Cookies', () => {
render(<CookieResultView snapshot={SNAP} />)
fireEvent.click(screen.getByText('Salesforce'))
expect(screen.getByText('LSKey-c$Policy')).toBeInTheDocument()
expect(screen.getByText(/kein Zweck/)).toBeInTheDocument() // leerer purpose
})
it('schaltet auf die Banner-Kategorie-Sicht um', () => {
render(<CookieResultView snapshot={SNAP} />)
fireEvent.click(screen.getByText('Banner-Kategorie'))
expect(screen.getByText(/Notwendig \(essenziell\)/)).toBeInTheDocument()
expect(screen.getByText('Salesforce')).toBeInTheDocument()
expect(screen.getByText('Meta / Facebook')).toBeInTheDocument()
})
it('markiert falsch einsortierte Cookies (Tracker als notwendig)', () => {
// 'sid' ist als necessary deklariert, Library sagt marketing → § 25-relevant.
render(<CookieResultView snapshot={SNAP} cookieCategories={{ sid: 'marketing' }} />)
expect(screen.getByText('Falsch einsortiert (lt. Library)')).toBeInTheDocument()
fireEvent.click(screen.getByText('Salesforce'))
expect(screen.getByText(/sollte: Marketing/)).toBeInTheDocument()
})
it('filtert nach Speichertyp (Framework vs. Cookie)', () => {
// LSKey-c$Policy ist Framework-Storage, alle anderen echte Cookies.
render(<CookieResultView snapshot={SNAP} storageTypes={{ 'lskey-c$policy': 'framework_storage' }} />)
const chip = screen.getByText(/Framework \(1\)/)
expect(chip).toBeInTheDocument() // Chip-Leiste erscheint (Nicht-Cookie vorhanden)
fireEvent.click(chip)
// Nur Salesforce (hat das Framework-Objekt) bleibt sichtbar.
expect(screen.getByText('Salesforce')).toBeInTheDocument()
expect(screen.queryByText('BMW AG — eShop')).not.toBeInTheDocument()
expect(screen.queryByText('Meta / Facebook')).not.toBeInTheDocument()
})
})
@@ -1,38 +0,0 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { RemediationPlan } from '../RemediationPlan'
describe('RemediationPlan', () => {
it('leitet Maßnahmen nur aus echten offenen Punkten ab', () => {
const results = {
results: [
{ doc_type: 'impressum', error: '', completeness_pct: 50, checks: [
{ id: 'a', label: 'Registernummer', passed: false, severity: 'HIGH', matched_text: '', level: 1, hint: 'HRB ergänzen' },
{ id: 'b', label: 'Telefon', passed: false, severity: 'MEDIUM', matched_text: '', level: 1 },
{ id: 'c', label: 'OK-Feld', passed: true, severity: 'HIGH', matched_text: 'x', level: 1 },
{ id: 'd', label: 'Info-Hinweis', passed: false, severity: 'INFO', matched_text: '', level: 1 },
] },
],
}
render(<RemediationPlan results={results} />)
// 2 Maßnahmen (HIGH + MEDIUM); OK + INFO ausgeschlossen
expect(screen.getByText(/Abstellmaßnahmen & Tickets \(2\)/)).toBeInTheDocument()
expect(screen.getByText(/Registernummer/)).toBeInTheDocument()
expect(screen.getByText('HRB ergänzen')).toBeInTheDocument() // hint = Maßnahme
expect(screen.queryByText(/Info-Hinweis/)).not.toBeInTheDocument()
expect(screen.queryByText(/OK-Feld/)).not.toBeInTheDocument()
})
it('zeigt Erfolg, wenn keine offenen Punkte', () => {
const results = {
results: [
{ doc_type: 'impressum', error: '', completeness_pct: 100, checks: [
{ id: 'a', label: 'X', passed: true, severity: 'HIGH', matched_text: 'x', level: 1 },
] },
],
}
render(<RemediationPlan results={results} />)
expect(screen.getByText(/kein Handlungsbedarf/)).toBeInTheDocument()
})
})
@@ -1,30 +0,0 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ResultSummary } from '../ResultSummary'
describe('ResultSummary', () => {
it('zeigt Firma im Titel + zählt Konform-KPI aus result.results', () => {
const results = {
check_id: 'abc123',
extracted_profile: { company_profile: { companyName: 'Bayerische Motoren Werke Aktiengesellschaft' } },
results: [
{ doc_type: 'impressum', completeness_pct: 100, correctness_pct: 100, error: '',
checks: [{ id: 'a', label: 'X', passed: true, severity: 'HIGH', matched_text: '', level: 1 }] },
{ doc_type: 'dse', completeness_pct: 50, correctness_pct: 50, error: '',
checks: [
{ id: 'b', label: 'Y', passed: false, severity: 'HIGH', matched_text: '', level: 1 },
{ id: 'c', label: 'Z', passed: false, severity: 'INFO', matched_text: '', level: 1 },
] },
],
}
render(<ResultSummary results={results} />)
expect(screen.getByText(/Bayerische Motoren Werke/)).toBeInTheDocument()
// 4 Kachel-Labels + Konform 1/2 (impressum konform, dse nicht)
expect(screen.getByText('Dokumente')).toBeInTheDocument()
expect(screen.getByText('Konform')).toBeInTheDocument()
expect(screen.getByText('Offene Pflichtangaben')).toBeInTheDocument()
expect(screen.getByText('Zu prüfen')).toBeInTheDocument()
expect(screen.getByText('1/2')).toBeInTheDocument()
})
})
@@ -10,15 +10,6 @@
export type Severity = 'HIGH' | 'MEDIUM' | 'LOW' | 'INFO' export type Severity = 'HIGH' | 'MEDIUM' | 'LOW' | 'INFO'
// Verdikt eines Checks — getrennt vom Risiko (severity).
// Applicability ≠ Compliance · Unknown ≠ Fail.
export type CheckStatus =
| 'pass'
| 'fail'
| 'not_applicable'
| 'insufficient_evidence'
| 'possibly_applicable'
export type SourceType = export type SourceType =
| 'mc' | 'mc'
| 'regex' | 'regex'
@@ -40,7 +31,6 @@ export interface Finding {
agent: string agent: string
agent_version: string agent_version: string
field_id?: string field_id?: string
status?: CheckStatus
severity: Severity severity: Severity
severity_reason?: string severity_reason?: string
title: string title: string
@@ -62,11 +52,8 @@ export interface Recommendation {
export interface McCoverage { export interface McCoverage {
mc_id: string mc_id: string
status: 'ok' | 'na' | 'high' | 'medium' | 'low' | 'skipped' | status: 'ok' | 'na' | 'high' | 'medium' | 'low' | 'skipped'
'insufficient_evidence' | 'possibly_applicable'
reason?: string reason?: string
label?: string // menschlicher Feldname (KEINE mc_id im Frontend zeigen)
found?: string // gefundener Text/Wert bei status=ok
} }
export interface EscalationLog { export interface EscalationLog {
@@ -92,8 +79,6 @@ export interface SlotOutput {
mc_high: number mc_high: number
mc_medium: number mc_medium: number
mc_low: number mc_low: number
mc_insufficient?: number
mc_possibly?: number
duration_ms: number duration_ms: number
confidence: number confidence: number
notes?: string notes?: string
@@ -166,25 +151,3 @@ export const SEVERITY_BG: Record<Severity, string> = {
LOW: '#eff6ff', LOW: '#eff6ff',
INFO: '#f8fafc', INFO: '#f8fafc',
} }
// Verdikt-Pill — nur für die Nicht-FAIL-Status (FAIL trägt die Severity).
export const STATUS_LABEL: Partial<Record<CheckStatus, string>> = {
not_applicable: 'Nicht anwendbar',
insufficient_evidence: 'Unzureichende Evidenz',
possibly_applicable: 'Evtl. relevant',
}
export const STATUS_STYLE: Partial<
Record<CheckStatus, { bg: string; fg: string }>
> = {
not_applicable: { bg: '#f1f5f9', fg: '#64748b' },
insufficient_evidence: { bg: '#e2e8f0', fg: '#475569' },
possibly_applicable: { bg: '#fef9c3', fg: '#854d0e' },
}
// Ein Output gilt als "übersprungen" (Dokument nicht ladbar), wenn MCs
// existieren, aber keiner ausgewertet wurde.
export function isOutputSkipped(o: SlotOutput): boolean {
return o.mc_total > 0 && o.mc_ok === 0 && o.mc_na === 0 &&
o.mc_high === 0 && o.mc_medium === 0 && o.mc_low === 0
}
@@ -16,7 +16,6 @@ export const DOCUMENT_TYPES = [
{ id: 'widerruf', label: 'Widerrufsbelehrung', required: false }, { id: 'widerruf', label: 'Widerrufsbelehrung', required: false },
{ id: 'dsb', label: 'DSB-Kontakt', required: false }, { id: 'dsb', label: 'DSB-Kontakt', required: false },
{ id: 'news', label: 'Blog/Newsroom (für § 18 MStV)', required: false }, { id: 'news', label: 'Blog/Newsroom (für § 18 MStV)', required: false },
{ id: 'legal_notice', label: 'Rechtlicher Hinweis / Disclaimer', required: false },
] as const ] as const
export type DocTypeId = typeof DOCUMENT_TYPES[number]['id'] export type DocTypeId = typeof DOCUMENT_TYPES[number]['id']
+1 -1
View File
@@ -7,7 +7,7 @@ import { BannerCheckTab } from './_components/BannerCheckTab'
import { ComplianceFAQ } from './_components/ComplianceFAQ' import { ComplianceFAQ } from './_components/ComplianceFAQ'
import { AgentTestTab } from './_components/AgentTestTab' import { AgentTestTab } from './_components/AgentTestTab'
type AnalysisTab = 'scan' | 'compliance-check' | 'banner-check' | 'agent-test' | 'impressum-check' | 'doc-check' type AnalysisTab = 'scan' | 'compliance-check' | 'banner-check' | 'agent-test'
const TABS: { id: AnalysisTab; label: string; desc: string }[] = [ const TABS: { id: AnalysisTab; label: string; desc: string }[] = [
{ id: 'scan', label: 'Website-Scan', desc: 'Rechtliche Dokumente finden + Dienstleister erkennen' }, { id: 'scan', label: 'Website-Scan', desc: 'Rechtliche Dokumente finden + Dienstleister erkennen' },
@@ -1,118 +0,0 @@
'use client'
/**
* Snapshot-Detail öffnet einen gespeicherten Check aus der Historie und
* zeigt die Ergebnis-Views aus den Rohdaten (kein Re-Crawl), als Modul-Tabs:
* Cookies & Tracking + Impressum + Datenschutzerklärung (AGB folgen).
* Doc-Agenten (Impressum/DSE) laufen beim Öffnen des Tabs auf dem gespeicherten
* Text generisch via AgentModuleTab.
*/
import React, { use as useUnwrap, useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { CookieLibraryPanel } from '../../_components/CookieLibraryPanel'
import { CookieDeclarationDiff } from '../../_components/CookieDeclarationDiff'
import { CookieResultView } from '../../_components/CookieResultView'
import { AgentModuleTab } from '../../_components/AgentModuleTab'
export default function SnapshotDetail(
{ params }: { params: Promise<{ snapshotId: string }> },
) {
const { snapshotId } = useUnwrap(params)
const [snap, setSnap] = useState<any>(null)
const [check, setCheck] = useState<any>(null) // cookie-check
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [tab, setTab] = useState<string>('')
useEffect(() => {
let cancelled = false
fetch(`/api/sdk/v1/agent/snapshots/${snapshotId}`)
.then(r => r.json())
.then(d => {
if (cancelled) return
if (d?.error) setError(d.error); else setSnap(d)
})
.catch(e => { if (!cancelled) setError(String(e)) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [snapshotId])
// Cookie-Abgleich einmal laden (Findings + cookie_categories für beide Views).
useEffect(() => {
let cancelled = false
fetch(`/api/sdk/v1/agent/snapshots/${snapshotId}/cookie-check`)
.then(r => r.json())
.then(d => { if (!cancelled) setCheck(d) })
.catch(() => { if (!cancelled) setCheck(null) })
return () => { cancelled = true }
}, [snapshotId])
const docs = snap?.doc_entries || []
const hasCookies = (snap?.cmp_vendors?.length ?? 0) > 0
const hasDoc = (dt: string) => docs.some(
(e: any) => e.doc_type === dt && (e.text || e.content || '').length > 100)
const modules = useMemo(() => [
...(hasCookies ? [{ key: 'cookie', label: 'Cookies & Tracking' }] : []),
...(hasDoc('impressum') ? [{ key: 'impressum', label: 'Impressum' }] : []),
...(hasDoc('dse') ? [{ key: 'dse', label: 'Datenschutzerklärung' }] : []),
...(hasDoc('agb') ? [{ key: 'agb', label: 'AGB' }] : []),
// eslint-disable-next-line react-hooks/exhaustive-deps
], [snap])
useEffect(() => {
if (!tab && modules.length) setTab(modules[0].key)
}, [modules, tab])
const tabBtn = (key: string, label: string) => (
<button key={key} onClick={() => setTab(key)}
className={`px-3 py-1.5 text-sm border-b-2 -mb-px ${tab === key ? 'border-blue-600 text-blue-700 font-medium' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
{label}
</button>
)
return (
<div className="p-6 max-w-6xl space-y-4">
<Link href="/sdk/agent/snapshots" className="text-xs text-blue-700 hover:underline">
Zurück zur Historie
</Link>
{loading ? (
<div className="text-sm text-gray-500">Lade Snapshot</div>
) : error || !snap ? (
<div className="text-sm text-red-600">Snapshot nicht gefunden.</div>
) : modules.length === 0 ? (
<div className="text-sm text-gray-500">
Dieser Snapshot enthält keine auswertbaren Daten.
</div>
) : (
<>
<div className="flex gap-1 border-b border-gray-200">
{modules.map(m => tabBtn(m.key, m.label))}
</div>
{tab === 'cookie' && hasCookies && (
<div className="space-y-4">
<CookieDeclarationDiff data={check?.declaration_diff} />
<CookieLibraryPanel snapshotId={snapshotId} data={check ?? undefined} />
<CookieResultView snapshot={snap} cookieCategories={check?.cookie_categories} storageTypes={check?.storage_inventory?.per_cookie} />
</div>
)}
{tab === 'impressum' && (
<AgentModuleTab snapshotId={snapshotId} docType="impressum" label="Impressum" />
)}
{tab === 'dse' && (
<AgentModuleTab snapshotId={snapshotId} docType="dse" label="Datenschutzerklärung" />
)}
{tab === 'agb' && (
<AgentModuleTab snapshotId={snapshotId} docType="agb" label="AGB" />
)}
</>
)}
</div>
)
}
@@ -1,71 +0,0 @@
'use client'
/**
* Check-Historie listet gespeicherte Snapshots (alle Sites/Module).
* Ein DSB/Mitarbeiter kann jeden früheren Check öffnen, ohne neuen Check
* zu starten. Daten kommen aus den Snapshot-Rohdaten.
*/
import React, { useEffect, useState } from 'react'
import Link from 'next/link'
interface SnapMeta {
id: string
check_id?: string
site_domain?: string
site_label?: string
created_at?: string
replay_count?: number
}
export default function SnapshotHistory() {
const [snaps, setSnaps] = useState<SnapMeta[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
fetch('/api/sdk/v1/agent/snapshots?limit=50')
.then(r => r.json())
.then(d => { if (!cancelled) setSnaps(d.snapshots || []) })
.catch(() => { if (!cancelled) setSnaps([]) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [])
return (
<div className="p-6 max-w-4xl space-y-4">
<div>
<h1 className="text-xl font-semibold text-gray-900">Check-Historie</h1>
<p className="text-xs text-gray-500 mt-1">
Frühere Compliance-Checks aus gespeicherten Snapshots jederzeit
ansehbar, ohne neuen Check zu starten.
</p>
</div>
{loading ? (
<div className="text-sm text-gray-500">Lade Historie</div>
) : snaps.length === 0 ? (
<div className="text-sm text-gray-400">Keine gespeicherten Checks gefunden.</div>
) : (
<div className="border rounded-lg divide-y divide-gray-100">
{snaps.map(s => (
<Link
key={s.id}
href={`/sdk/agent/snapshots/${s.id}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 text-sm"
>
<span className="font-medium text-gray-800 w-44 truncate">
{s.site_label || s.site_domain || 'unbekannt'}
</span>
<span className="text-gray-500 flex-1 min-w-0 truncate">{s.site_domain}</span>
<span className="text-xs text-gray-400 whitespace-nowrap">
{(s.created_at || '').slice(0, 16).replace('T', ' ')}
</span>
<span className="text-gray-300"></span>
</Link>
))}
</div>
)}
</div>
)
}
@@ -200,7 +200,7 @@ export function useCompanyProfileForm() {
try { try {
await fetch(profileApiUrl(), { await fetch(profileApiUrl(), {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildProfilePayload(formData, projectId ?? null, false)), body: JSON.stringify(buildProfilePayload(formData, projectId, false)),
}) })
setDraftSaveStatus('saved') setDraftSaveStatus('saved')
if (draftSaveTimerRef.current) clearTimeout(draftSaveTimerRef.current) if (draftSaveTimerRef.current) clearTimeout(draftSaveTimerRef.current)
@@ -217,7 +217,7 @@ export function useCompanyProfileForm() {
try { try {
await fetch(profileApiUrl(), { await fetch(profileApiUrl(), {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildProfilePayload(formData, projectId ?? null, false)), body: JSON.stringify(buildProfilePayload(formData, projectId, false)),
}) })
setCompanyProfile({ ...formData, isComplete: false, completedAt: null } as CompanyProfile) setCompanyProfile({ ...formData, isComplete: false, completedAt: null } as CompanyProfile)
setDraftSaveStatus('saved') setDraftSaveStatus('saved')
@@ -239,7 +239,7 @@ export function useCompanyProfileForm() {
try { try {
await fetch(profileApiUrl(), { await fetch(profileApiUrl(), {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildProfilePayload(formData, projectId ?? null, true)), body: JSON.stringify(buildProfilePayload(formData, projectId, true)),
}) })
} catch (err) { console.error('Failed to save company profile to backend:', err) } } catch (err) { console.error('Failed to save company profile to backend:', err) }
@@ -148,7 +148,7 @@ export function OverviewTab({
{ key: 'evidence_freshness', label: 'Aktualitaet', color: 'bg-yellow-500' }, { key: 'evidence_freshness', label: 'Aktualitaet', color: 'bg-yellow-500' },
{ key: 'control_effectiveness', label: 'Control-Wirksamkeit', color: 'bg-indigo-500' }, { key: 'control_effectiveness', label: 'Control-Wirksamkeit', color: 'bg-indigo-500' },
] as const).map(dim => { ] as const).map(dim => {
const value = (dashboard.multi_score as unknown as Record<string, number>)[dim.key] || 0 const value = (dashboard.multi_score as Record<string, number>)[dim.key] || 0
return ( return (
<div key={dim.key} className="flex items-center gap-3"> <div key={dim.key} className="flex items-center gap-3">
<span className="text-xs text-slate-600 w-44 truncate">{dim.label}</span> <span className="text-xs text-slate-600 w-44 truncate">{dim.label}</span>
@@ -7,12 +7,6 @@ import type {
TraceabilityMatrixData, TabKey, TraceabilityMatrixData, TabKey,
} from '../_components/types' } from '../_components/types'
export type {
DashboardData, Regulation, MappingsData, FindingsData,
RoadmapData, ModuleStatusData, NextAction, ScoreSnapshot,
TraceabilityMatrixData, TabKey,
} from '../_components/types'
export function useComplianceHub() { export function useComplianceHub() {
const [activeTab, setActiveTab] = useState<TabKey>('overview') const [activeTab, setActiveTab] = useState<TabKey>('overview')
const [dashboard, setDashboard] = useState<DashboardData | null>(null) const [dashboard, setDashboard] = useState<DashboardData | null>(null)
@@ -48,7 +48,7 @@ export default function ComplianceScopePage() {
// Migrate old decision format: drop decision if it has old-format fields // Migrate old decision format: drop decision if it has old-format fields
const migrateState = (state: ComplianceScopeState): ComplianceScopeState => { const migrateState = (state: ComplianceScopeState): ComplianceScopeState => {
if (state.decision) { if (state.decision) {
const d = state.decision as unknown as Record<string, unknown> const d = state.decision as Record<string, unknown>
// Old format had 'level' instead of 'determinedLevel', or docs with 'isMandatory' // Old format had 'level' instead of 'determinedLevel', or docs with 'isMandatory'
if (d.level || !d.determinedLevel) { if (d.level || !d.determinedLevel) {
return { ...state, decision: null } return { ...state, decision: null }
@@ -13,7 +13,6 @@ export interface Document {
} }
export interface Version { export interface Version {
published_at?: string
id: string id: string
document_id: string document_id: string
version: string version: string
@@ -258,7 +258,7 @@ export function ControlDetailView({
</div> </div>
<div className="text-xs text-gray-600 space-y-1"> <div className="text-xs text-gray-600 space-y-1">
<p>Pfad: {String(ctrl.generation_metadata.processing_path || '-')}</p> <p>Pfad: {String(ctrl.generation_metadata.processing_path || '-')}</p>
{!!ctrl.generation_metadata.similarity_status && ( {ctrl.generation_metadata.similarity_status && (
<p className="text-red-600">Similarity: {String(ctrl.generation_metadata.similarity_status)}</p> <p className="text-red-600">Similarity: {String(ctrl.generation_metadata.similarity_status)}</p>
)} )}
{Array.isArray(ctrl.generation_metadata.similar_controls) && ( {Array.isArray(ctrl.generation_metadata.similar_controls) && (
@@ -288,11 +288,11 @@ export function ControlDetail({
<h3 className="text-sm font-semibold text-gray-700">Generierungsdetails (intern)</h3> <h3 className="text-sm font-semibold text-gray-700">Generierungsdetails (intern)</h3>
</div> </div>
<div className="text-xs text-gray-600 space-y-1"> <div className="text-xs text-gray-600 space-y-1">
{!!ctrl.generation_metadata.processing_path && <p>Pfad: {String(ctrl.generation_metadata.processing_path)}</p>} {ctrl.generation_metadata.processing_path && <p>Pfad: {String(ctrl.generation_metadata.processing_path)}</p>}
{!!ctrl.generation_metadata.decomposition_method && <p>Methode: {String(ctrl.generation_metadata.decomposition_method)}</p>} {ctrl.generation_metadata.decomposition_method && <p>Methode: {String(ctrl.generation_metadata.decomposition_method)}</p>}
{!!ctrl.generation_metadata.pass0b_model && <p>LLM: {String(ctrl.generation_metadata.pass0b_model)}</p>} {ctrl.generation_metadata.pass0b_model && <p>LLM: {String(ctrl.generation_metadata.pass0b_model)}</p>}
{!!ctrl.generation_metadata.obligation_type && <p>Obligation-Typ: {String(ctrl.generation_metadata.obligation_type)}</p>} {ctrl.generation_metadata.obligation_type && <p>Obligation-Typ: {String(ctrl.generation_metadata.obligation_type)}</p>}
{!!ctrl.generation_metadata.similarity_status && <p className="text-red-600">Similarity: {String(ctrl.generation_metadata.similarity_status)}</p>} {ctrl.generation_metadata.similarity_status && <p className="text-red-600">Similarity: {String(ctrl.generation_metadata.similarity_status)}</p>}
{Array.isArray(ctrl.generation_metadata.similar_controls) && ( {Array.isArray(ctrl.generation_metadata.similar_controls) && (
<div> <div>
<p className="font-medium">Aehnliche Controls:</p> <p className="font-medium">Aehnliche Controls:</p>
@@ -67,7 +67,7 @@ export function AnalyticsDashboard({ siteId }: { siteId?: string }) {
setOverview(o) setOverview(o)
setTimeSeries(ts || []) setTimeSeries(ts || [])
setCategories(cats || {}) setCategories(cats || {})
setDevices((devs || { desktop: 0, mobile: 0, tablet: 0, unknown: 0 }) as DeviceStats) setDevices(devs || { desktop: 0, mobile: 0, tablet: 0, unknown: 0 })
}).catch(() => {}).finally(() => setLoading(false)) }).catch(() => {}).finally(() => setLoading(false))
}, [sid, days]) }, [sid, days])
@@ -190,7 +190,7 @@ export default function GeneratorSection({
{ruleResult && ( {ruleResult && (
<div className="flex gap-1.5 flex-wrap"> <div className="flex gap-1.5 flex-wrap">
{flagPills.map(({ key, label, color }) => {flagPills.map(({ key, label, color }) =>
(ruleResult.computedFlags as unknown as Record<string, boolean>)[key] ? ( ruleResult.computedFlags[key] ? (
<span key={key} className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${color}`}> <span key={key} className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${color}`}>
{label} {label}
</span> </span>
@@ -16,7 +16,7 @@ export default function RecommendedDocuments({ allTemplates, onUseTemplate }: Pr
const { state } = useSDK() const { state } = useSDK()
const [showOptional, setShowOptional] = useState(false) const [showOptional, setShowOptional] = useState(false)
const level = state?.complianceScope?.decision?.determinedLevel as ComplianceDepthLevel | undefined const level = state?.complianceScope?.determinedLevel as ComplianceDepthLevel | undefined
const scopeAnswers = state?.complianceScope?.answers || [] const scopeAnswers = state?.complianceScope?.answers || []
const recommendations = useMemo(() => { const recommendations = useMemo(() => {
@@ -24,7 +24,7 @@ export default function RecommendedDocuments({ allTemplates, onUseTemplate }: Pr
return evaluateTemplateRecommendations( return evaluateTemplateRecommendations(
scopeAnswers, scopeAnswers,
level, level,
(state?.companyProfile as unknown as Record<string, unknown>) || {}, (state?.companyProfile as Record<string, unknown>) || {},
) )
}, [level, scopeAnswers, state?.companyProfile]) }, [level, scopeAnswers, state?.companyProfile])
@@ -165,44 +165,6 @@ export interface FeaturesCtx {
HAS_WITHDRAWAL: boolean HAS_WITHDRAWAL: boolean
CONSUMER_WITHDRAWAL_TEXT: string CONSUMER_WITHDRAWAL_TEXT: string
SUPPORT_CHANNELS_TEXT: string SUPPORT_CHANNELS_TEXT: string
// ── Optionale Feature-Template-Variablen (per str() ausgegeben, daher string) ─
// Whistleblower (HinSchG)
WHISTLEBLOWER_CONTACT_NAME?: string
WHISTLEBLOWER_CONTACT_ROLE?: string
WHISTLEBLOWER_EMAIL?: string
WHISTLEBLOWER_PHONE?: string
WHISTLEBLOWER_URL?: string
// Videokonferenz
VIDEO_PROVIDER_NAME?: string
VIDEO_PROVIDER_COUNTRY?: string
VIDEO_PROVIDER_ROLE?: string
VIDEO_PROVIDER_PRIVACY_URL?: string
RECORDING_RETENTION_DAYS?: string
// KI / BYOD / Consent / Social Media
APPROVED_AI_SYSTEMS?: string
BYOD_COST_DETAILS?: string
NEWSLETTER_SIGNUP_URL?: string
SOCIAL_MEDIA_PLATFORMS_LIST?: string
EDITORIAL_EMAIL?: string
// Transfer / SCC (Empfänger im Drittland)
RECIPIENT_NAME?: string
RECIPIENT_COUNTRY?: string
RECIPIENT_ADDRESS?: string
RECIPIENT_CONTACT?: string
RECIPIENT_EMAIL?: string
RECIPIENT_ROLE?: string
TRANSFER_PURPOSE?: string
TRANSFER_MECHANISM?: string
TRANSFER_FREQUENCY?: string
DATA_CATEGORIES_TRANSFERRED?: string
DATA_SUBJECTS?: string
// DSI
DSI_TITLE?: string
SERVICE_SCOPE_DESCRIPTION?: string
FULFILLMENT_LOCATION?: string
GUIDELINES_URL?: string
PROCESSOR_LIST_URL?: string
} }
export interface TOMCtx { export interface TOMCtx {
@@ -95,7 +95,7 @@ function DocumentGeneratorPageInner() {
// Pre-fill TOM/DPA context from Compliance Scope Engine // Pre-fill TOM/DPA context from Compliance Scope Engine
useEffect(() => { useEffect(() => {
const scopeLevel = state?.complianceScope?.decision?.determinedLevel const scopeLevel = state?.complianceScope?.determinedLevel
if (scopeLevel) { if (scopeLevel) {
const defaults = getGeneratorDefaults(scopeLevel, state?.companyProfile as never) const defaults = getGeneratorDefaults(scopeLevel, state?.companyProfile as never)
setContext((prev) => ({ setContext((prev) => ({
@@ -104,7 +104,7 @@ function DocumentGeneratorPageInner() {
DPA: { ...prev.DPA, ...defaults.dpa }, DPA: { ...prev.DPA, ...defaults.dpa },
})) }))
} }
}, [state?.complianceScope?.decision?.determinedLevel, state?.companyProfile]) }, [state?.complianceScope?.determinedLevel, state?.companyProfile])
// ── MODULE WIRING: Backend Banner-Config → CONSENT + FEATURES ──────────── // ── MODULE WIRING: Backend Banner-Config → CONSENT + FEATURES ────────────
useEffect(() => { useEffect(() => {
@@ -12,8 +12,8 @@
* L4 = Zertifizierungsbereit (250 MA oder regulierte Branche) * L4 = Zertifizierungsbereit (250 MA oder regulierte Branche)
*/ */
import type { ComplianceDepthLevel } from '@/lib/sdk/compliance-scope-types/core-levels' import type { ComplianceDepthLevel } from '../../lib/sdk/compliance-scope-types/core-levels'
import type { CompanyProfile } from '@/lib/sdk/types' import type { CompanyProfile } from '../../lib/sdk/types'
import type { TOMCtx, DPACtx } from './contextBridge' import type { TOMCtx, DPACtx } from './contextBridge'
// ============================================================================ // ============================================================================
@@ -216,29 +216,33 @@ export function getGeneratorDefaults(
// CompanyProfile-Felder in TOM/DPA uebernehmen // CompanyProfile-Felder in TOM/DPA uebernehmen
if (profile) { if (profile) {
if (profile.companyName) { if (profile.company_name) {
dpaBase.AN_NAME = profile.companyName dpaBase.AN_NAME = profile.company_name
scopeSet.add('DPA.AN_NAME') scopeSet.add('DPA.AN_NAME')
} }
if (profile.headquartersStreet) { if (profile.address) {
dpaBase.AN_STRASSE = profile.headquartersStreet dpaBase.AN_STRASSE = profile.address
scopeSet.add('DPA.AN_STRASSE') scopeSet.add('DPA.AN_STRASSE')
} }
if (profile.headquartersCity && profile.headquartersZip) { if (profile.city && profile.postal_code) {
dpaBase.AN_PLZ_ORT = `${profile.headquartersZip} ${profile.headquartersCity}` dpaBase.AN_PLZ_ORT = `${profile.postal_code} ${profile.city}`
scopeSet.add('DPA.AN_PLZ_ORT') scopeSet.add('DPA.AN_PLZ_ORT')
} }
if (profile.dpoName) { if (profile.dpo_name) {
tomBase.ISB_NAME = tomBase.ISB_NAME || '' tomBase.ISB_NAME = tomBase.ISB_NAME || ''
dpaBase.AN_DSB_NAME = profile.dpoName dpaBase.AN_DSB_NAME = profile.dpo_name
scopeSet.add('DPA.AN_DSB_NAME') scopeSet.add('DPA.AN_DSB_NAME')
} }
if (profile.dpoEmail) { if (profile.dpo_email) {
dpaBase.AN_DSB_EMAIL = profile.dpoEmail dpaBase.AN_DSB_EMAIL = profile.dpo_email
scopeSet.add('DPA.AN_DSB_EMAIL') scopeSet.add('DPA.AN_DSB_EMAIL')
} }
// Unterzeichner/GF werden NICHT aus dem CompanyProfile befuellt — es enthaelt if (profile.ceo_name) {
// keine Person; diese Felder kommen aus dem TOM/DPA-Generator selbst. dpaBase.AN_UNTERZEICHNER_NAME = profile.ceo_name
tomBase.GF_NAME = profile.ceo_name
scopeSet.add('DPA.AN_UNTERZEICHNER_NAME')
scopeSet.add('TOM.GF_NAME')
}
} }
// Alle gesetzten TOM/DPA Felder als scope-set markieren // Alle gesetzten TOM/DPA Felder als scope-set markieren
@@ -9,8 +9,8 @@
* the CompanyProfile and scope answers. * the CompanyProfile and scope answers.
*/ */
import type { ComplianceDepthLevel } from '@/lib/sdk/compliance-scope-types/core-levels' import type { ComplianceDepthLevel } from '../../lib/sdk/compliance-scope-types/core-levels'
import type { ScopeProfilingAnswer } from '@/lib/sdk/compliance-scope-types/state' import type { ScopeProfilingAnswer } from '../../lib/sdk/compliance-scope-types/state'
// ============================================================================ // ============================================================================
// Template recommendation rules // Template recommendation rules
@@ -59,7 +59,7 @@ export function Section3Editor({ dsfa, onUpdate, isSubmitting }: SectionProps) {
<div className="bg-gray-50 rounded-xl p-6"> <div className="bg-gray-50 rounded-xl p-6">
<RiskMatrix <RiskMatrix
risks={dsfa.risks || []} risks={dsfa.risks || []}
onRiskSelect={(risk) => setSelectedRisk(risk as DSFARisk)} onRiskSelect={(risk) => setSelectedRisk(risk)}
onAddRisk={handleAddRisk} onAddRisk={handleAddRisk}
selectedRiskId={selectedRisk?.id} selectedRiskId={selectedRisk?.id}
readOnly={dsfa.status !== 'draft' && dsfa.status !== 'needs_update'} readOnly={dsfa.status !== 'draft' && dsfa.status !== 'needs_update'}
@@ -18,7 +18,9 @@ export { PublicFormConfig as SettingsTabContent } from './PublicFormConfig'
export function SettingsTab() { export function SettingsTab() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="bg-white rounded-xl border border-gray-200 p-6"> </div> <div className="bg-white rounded-xl border border-gray-200 p-6">
<SettingsTabContent />
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6"> <div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-slate-900 mb-2">Workflow-Konfiguration</h3> <h3 className="text-base font-semibold text-slate-900 mb-2">Workflow-Konfiguration</h3>
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
@@ -2,7 +2,6 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { createSDKDSR } from '@/lib/sdk/dsr/api' import { createSDKDSR } from '@/lib/sdk/dsr/api'
import type { DSRType, DSRSource } from '@/lib/sdk/dsr/types-core'
export function DSRCreateModal({ export function DSRCreateModal({
onClose, onClose,
@@ -11,11 +10,11 @@ export function DSRCreateModal({
onClose: () => void onClose: () => void
onSuccess: () => void onSuccess: () => void
}) { }) {
const [type, setType] = useState<DSRType>('access') const [type, setType] = useState<string>('access')
const [subjectName, setSubjectName] = useState('') const [subjectName, setSubjectName] = useState('')
const [subjectEmail, setSubjectEmail] = useState('') const [subjectEmail, setSubjectEmail] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [source, setSource] = useState<DSRSource>('web_form') const [source, setSource] = useState<string>('web_form')
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -81,7 +80,7 @@ export function DSRCreateModal({
</label> </label>
<select <select
value={type} value={type}
onChange={(e) => setType(e.target.value as DSRType)} onChange={(e) => setType(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm" className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
> >
<option value="access">Art. 15 - Auskunft</option> <option value="access">Art. 15 - Auskunft</option>
@@ -144,7 +143,7 @@ export function DSRCreateModal({
</label> </label>
<select <select
value={source} value={source}
onChange={(e) => setSource(e.target.value as DSRSource)} onChange={(e) => setSource(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm" className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
> >
<option value="web_form">Webformular</option> <option value="web_form">Webformular</option>
@@ -129,7 +129,7 @@ export function IstAssessment({ data, onChange }: Props) {
<label key={item.field} className="flex items-center gap-3 cursor-pointer p-2 rounded-lg hover:bg-gray-50"> <label key={item.field} className="flex items-center gap-3 cursor-pointer p-2 rounded-lg hover:bg-gray-50">
<input <input
type="checkbox" type="checkbox"
checked={(data as unknown as Record<string, unknown>)[item.field] as boolean} checked={(data as Record<string, unknown>)[item.field] as boolean}
onChange={e => update(item.field, e.target.checked)} onChange={e => update(item.field, e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-green-600" className="w-4 h-4 rounded border-gray-300 text-green-600"
/> />
@@ -152,7 +152,7 @@ export function IstAssessment({ data, onChange }: Props) {
<label key={item.field} className="flex items-center gap-3 cursor-pointer p-2 rounded-lg hover:bg-gray-50"> <label key={item.field} className="flex items-center gap-3 cursor-pointer p-2 rounded-lg hover:bg-gray-50">
<input <input
type="checkbox" type="checkbox"
checked={(data as unknown as Record<string, unknown>)[item.field] as boolean} checked={(data as Record<string, unknown>)[item.field] as boolean}
onChange={e => update(item.field, e.target.checked)} onChange={e => update(item.field, e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-green-600" className="w-4 h-4 rounded border-gray-300 text-green-600"
/> />
@@ -1,73 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
export interface ArchStage {
id: string
title: string
summary: string
input: string
logic: string
data_source: string
example: string
}
export interface ArchLibrary {
name: string
count: number
source_file: string
description: string
}
export interface ArchDataSource {
name: string
license: string
usage: string
status: string // "verwendet" | "ausgeschlossen"
}
export interface RiskEvidence {
mode: string
label: string
stat: string
source: string
license: string
attribution: string
retrieved: string
}
export interface Architecture {
stages: ArchStage[]
libraries: ArchLibrary[]
data_sources: ArchDataSource[]
norm_matching: string[]
evidence: RiskEvidence[]
}
/** Loads the data-driven IACE engine self-description (global, not per project). */
export function useArchitecture() {
const [data, setData] = useState<Architecture | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function load() {
setLoading(true)
try {
const res = await fetch('/api/sdk/v1/iace/architecture')
const json = res.ok ? ((await res.json()) as Architecture) : null
if (!cancelled) setData(json)
} catch (err) {
console.error('Failed to load IACE architecture:', err)
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => {
cancelled = true
}
}, [])
return { data, loading }
}
@@ -1,177 +0,0 @@
'use client'
import { useState } from 'react'
import { useArchitecture, type ArchStage } from './_hooks/useArchitecture'
export default function ArchitekturPage() {
const { data, loading } = useArchitecture()
const [open, setOpen] = useState<string | null>('grenzen')
if (loading) {
return <div className="text-sm text-gray-500 dark:text-gray-400 p-1">Lade Engine-Architektur</div>
}
if (!data) {
return <div className="text-sm text-red-600">Architektur konnte nicht geladen werden.</div>
}
return (
<div className="space-y-8 max-w-[1100px]">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Architektur &amp; Datenfluss</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-3xl mt-1">
Nachvollziehbar für Auditoren: <strong>woher jede Information stammt</strong> und{' '}
<strong>wie die Risikobeurteilung zustande kommt</strong> jede Station, jedes Gate, jede
Bibliothek und Datenquelle, in Reihenfolge. Die Zahlen sind <strong>live</strong> aus der
laufenden Engine.
</p>
</div>
{/* Pipeline flow */}
<section className="space-y-2">
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-300">Deterministische Pipeline</h2>
<div className="space-y-1">
{data.stages.map((s, i) => (
<StageRow
key={s.id}
stage={s}
last={i === data.stages.length - 1}
open={open === s.id}
onToggle={() => setOpen(open === s.id ? null : s.id)}
/>
))}
</div>
</section>
{/* Libraries */}
<section className="space-y-3">
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-300">Wissensbasen (Live-Bestand)</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{data.libraries.map((l) => (
<div key={l.name} className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-3">
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">{l.name}</span>
<span className="text-lg font-bold text-purple-600 tabular-nums">{l.count.toLocaleString('de-DE')}</span>
</div>
<p className="text-xs text-gray-500 mt-1">{l.description}</p>
<code className="text-[10px] text-gray-400 mt-1 block">{l.source_file}</code>
</div>
))}
</div>
</section>
{/* Norm matching */}
<section className="space-y-3">
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-300">Normen-Matching (DIN / ISO / OSHA)</h2>
<ul className="space-y-2">
{data.norm_matching.map((n, i) => (
<li key={i} className="flex gap-2 text-xs text-gray-600 dark:text-gray-300">
<span className="text-purple-500 mt-0.5 shrink-0"></span>
<span dangerouslySetInnerHTML={{ __html: inlineCode(n) }} />
</li>
))}
</ul>
</section>
{/* Data sources & licenses */}
<section className="space-y-3">
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-300">Datenquellen &amp; Lizenzen</h2>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-gray-500 border-b border-gray-200 dark:border-gray-700 text-left">
<th className="py-1.5 pr-3">Quelle</th>
<th className="py-1.5 pr-3">Lizenz</th>
<th className="py-1.5 pr-3">Nutzung</th>
<th className="py-1.5">Status</th>
</tr>
</thead>
<tbody>
{data.data_sources.map((d) => (
<tr key={d.name} className="border-b border-gray-100 dark:border-gray-700/50 align-top">
<td className="py-1.5 pr-3 text-gray-700 dark:text-gray-300 font-medium">{d.name}</td>
<td className="py-1.5 pr-3 text-gray-500">{d.license}</td>
<td className="py-1.5 pr-3 text-gray-500">{d.usage}</td>
<td className="py-1.5">
<span
className={`inline-block rounded px-1.5 py-0.5 font-medium ${
d.status === 'verwendet'
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{d.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{data.evidence.length > 0 && (
<p className="text-[11px] text-gray-400">
Belegte Kontaktmodus-Quoten (ESAW):{' '}
{data.evidence.map((e) => `${e.label} ${e.stat}`).join(' · ')} {data.evidence[0]?.attribution}.
</p>
)}
</section>
</div>
)
}
function StageRow({
stage,
last,
open,
onToggle,
}: {
stage: ArchStage
last: boolean
open: boolean
onToggle: () => void
}) {
return (
<div>
<button
onClick={onToggle}
className={`w-full text-left rounded-lg border p-3 transition-colors ${
open
? 'border-purple-300 bg-purple-50/60 dark:border-purple-700 dark:bg-purple-900/20'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-gray-800 dark:text-gray-200">{stage.title}</div>
<div className="text-xs text-gray-500 mt-0.5">{stage.summary}</div>
</div>
<span className="text-gray-400 text-xs shrink-0">{open ? '▲' : '▼'}</span>
</div>
{open && (
<dl className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2 text-xs">
<Field label="Input" value={stage.input} />
<Field label="Logik" value={stage.logic} />
<Field label="Datenquelle" value={stage.data_source} mono />
<Field label="Beispiel" value={stage.example} />
</dl>
)}
</button>
{!last && <div className="flex justify-center text-gray-300 dark:text-gray-600 text-xs leading-none py-0.5"></div>}
</div>
)
}
function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<dt className="text-[10px] uppercase tracking-wide text-gray-400">{label}</dt>
<dd className={`text-gray-600 dark:text-gray-300 ${mono ? 'font-mono text-[11px]' : ''}`}>{value}</dd>
</div>
)
}
// Renders `inline code` (single backticks) as <code> — the norm-matching bullets
// use backticks for function/identifier names.
function inlineCode(text: string): string {
const escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
return escaped.replace(/`([^`]+)`/g, '<code class="text-[11px] bg-gray-100 dark:bg-gray-700 rounded px-1">$1</code>')
}
@@ -1,69 +0,0 @@
'use client'
import type { DistanceComparison as DistanceComparisonData, DistanceToken } from '../_hooks/useBenchmark'
function fmt(t: DistanceToken): string {
const v = Number.isInteger(t.value) ? t.value.toString() : t.value.toString().replace('.', ',')
return `${v} ${t.unit}`
}
function TokenList({ tokens, tone }: { tokens: DistanceToken[]; tone: 'ok' | 'gap' | 'extra' }) {
if (!tokens.length) return <span className="text-xs text-gray-400"></span>
const cls =
tone === 'ok'
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: tone === 'gap'
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'
return (
<div className="flex flex-wrap gap-1">
{tokens.map((t, i) => (
<span key={i} className={`inline-block rounded px-1.5 py-0.5 text-[11px] tabular-nums ${cls}`}>
{fmt(t)}
</span>
))}
</div>
)
}
/**
* Distance/speed dimension comparison: do the engine's mm/mm-s values match the
* professional's (GT)? Confidence-aware tonality green = covered, amber = gap.
*/
export function DistanceComparison({ data }: { data?: DistanceComparisonData }) {
if (!data || data.gt_count === 0) return null
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 space-y-3">
<div>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">Abstands-/Geschwindigkeits-Vergleich (Fachmann vs. Tool)</h3>
<p className="text-xs text-gray-500 mt-0.5">
Deckt das Tool die konkreten mm-/mm-s-Werte des Fachmanns ab? Deterministischer Abgleich der
Maße aus den GT-Maßnahmen gegen die vom Tool vorgeschlagenen Maßnahmen.
</p>
</div>
<div className="flex items-baseline gap-3">
<span className="text-2xl font-bold text-purple-600 tabular-nums">{Math.round(data.agreement_pct)}%</span>
<span className="text-xs text-gray-500">
{data.matched_count} von {data.gt_count} Fachmann-Maßen abgedeckt
</span>
</div>
<dl className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
<div>
<dt className="text-[10px] uppercase tracking-wide text-gray-400 mb-1">Abgedeckt</dt>
<dd><TokenList tokens={data.matched} tone="ok" /></dd>
</div>
<div>
<dt className="text-[10px] uppercase tracking-wide text-gray-400 mb-1">Lücken (nur Fachmann)</dt>
<dd><TokenList tokens={data.gt_only} tone="gap" /></dd>
</div>
<div>
<dt className="text-[10px] uppercase tracking-wide text-gray-400 mb-1">Nur Tool</dt>
<dd><TokenList tokens={data.engine_only} tone="extra" /></dd>
</div>
</dl>
</div>
)
}
@@ -17,13 +17,6 @@ function ampelBand(band: string): Ampel {
return 'green' return 'green'
} }
// Tool confidence (how well-anchored the estimate is) → chip color.
function ampelConfidence(c: string): Ampel {
if (c === 'hoch') return 'green'
if (c === 'mittel') return 'yellow'
return 'red'
}
const cellColor: Record<Ampel, string> = { const cellColor: Record<Ampel, string> = {
red: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', red: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
yellow: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300', yellow: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
@@ -54,28 +47,18 @@ export function RiskComparison({ pairs, agreement }: { pairs?: RiskComparisonPai
<div> <div>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">Risikozahlen-Vergleich (Fachmann vs. Tool)</h3> <h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">Risikozahlen-Vergleich (Fachmann vs. Tool)</h3>
<p className="text-xs text-gray-500 mt-0.5"> <p className="text-xs text-gray-500 mt-0.5">
R = S × (F + W + P), Ampel wie in der Excel. Das Tool nennt einen <strong>Schätzbereich</strong>{' '} R = S × (F + W + P), Ampel wie in der Excel. Fine-Kinney (P×E×C) als zweite, US-anerkannte Bewertung.
(nicht einen exakten Punktwert) plus Konfidenz die endgültige Bewertung trifft der/die Sachverständige.
Fine-Kinney (P×E×C) als zweite, US-anerkannte Bewertung.
</p> </p>
</div> </div>
{agreement && agreement.n > 0 && ( {agreement && agreement.n > 0 && (
<> <div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<div className="grid grid-cols-2 md:grid-cols-5 gap-3"> <Stat label="Schwere S ±1" pct={agreement.severity_within1} />
<Stat label="Schwere S ±1" pct={agreement.severity_within1} /> <Stat label="Haeufigkeit F ±1" pct={agreement.frequency_within1} />
<Stat label="Haeufigkeit F ±1" pct={agreement.frequency_within1} /> <Stat label="Wahrsch. W ±1" pct={agreement.probability_within1} />
<Stat label="Wahrsch. W ±1" pct={agreement.probability_within1} /> <Stat label="Vermeidb. P ±1" pct={agreement.avoidance_within1} />
<Stat label="Vermeidb. P ±1" pct={agreement.avoidance_within1} /> <Stat label="Ranking (FK)" pct={agreement.rank_concordance} />
<Stat label="Ranking (FK)" pct={agreement.rank_concordance} /> </div>
</div>
{typeof agreement.high_confidence_pct === 'number' && (
<p className="text-xs text-gray-500">
Tool-Konfidenz: <strong>{Math.round(agreement.high_confidence_pct)}%</strong> der erkannten
Gefaehrdungen mit hoher Konfidenz (Verletzungsmechanismus eindeutig aus dem Szenario ableitbar).
</p>
)}
</>
)} )}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -84,42 +67,31 @@ export function RiskComparison({ pairs, agreement }: { pairs?: RiskComparisonPai
<tr className="text-gray-500 border-b border-gray-200 dark:border-gray-700"> <tr className="text-gray-500 border-b border-gray-200 dark:border-gray-700">
<th className="text-left py-1.5 px-2">Gefaehrdung</th> <th className="text-left py-1.5 px-2">Gefaehrdung</th>
<th className="px-1 text-center" colSpan={5}>Fachmann · S F W P <strong>R</strong></th> <th className="px-1 text-center" colSpan={5}>Fachmann · S F W P <strong>R</strong></th>
<th className="px-1 text-center border-l border-gray-200 dark:border-gray-700" colSpan={4}>Tool · S F W P</th> <th className="px-1 text-center border-l border-gray-200 dark:border-gray-700" colSpan={5}>Tool · S F W P <strong>R</strong> / FK</th>
<th className="px-1 text-center border-l border-gray-200 dark:border-gray-700">Risiko (Schätzbereich) / FK</th>
<th className="px-1 text-center border-l border-gray-200 dark:border-gray-700">Konfidenz</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{pairs.map((p, i) => ( {pairs.map((p, i) => {
<tr key={i} className="border-b border-gray-100 dark:border-gray-700/50"> const engR = p.eng_severity * (p.eng_frequency + p.eng_probability + p.eng_avoidance)
<td className="py-1 px-2 text-gray-700 dark:text-gray-300">{p.hazard_name || '—'}</td> return (
<td className="text-center text-gray-500">{p.gt_severity}</td> <tr key={i} className="border-b border-gray-100 dark:border-gray-700/50">
<td className="text-center text-gray-500">{p.gt_frequency}</td> <td className="py-1 px-2 text-gray-700 dark:text-gray-300">{p.hazard_name || '—'}</td>
<td className="text-center text-gray-500">{p.gt_probability}</td> <td className="text-center text-gray-500">{p.gt_severity}</td>
<td className="text-center text-gray-500">{p.gt_avoidance}</td> <td className="text-center text-gray-500">{p.gt_frequency}</td>
<td className={`text-center font-bold rounded ${cellColor[ampelEN(p.gt_risk)]}`}>{p.gt_risk}</td> <td className="text-center text-gray-500">{p.gt_probability}</td>
<td className="text-center text-gray-500 border-l border-gray-200 dark:border-gray-700">{p.eng_severity}</td> <td className="text-center text-gray-500">{p.gt_avoidance}</td>
<td className="text-center text-gray-500">{p.eng_frequency}</td> <td className={`text-center font-bold rounded ${cellColor[ampelEN(p.gt_risk)]}`}>{p.gt_risk}</td>
<td className="text-center text-gray-500">{p.eng_probability}</td> <td className="text-center text-gray-500 border-l border-gray-200 dark:border-gray-700">{p.eng_severity}</td>
<td className="text-center text-gray-500">{p.eng_avoidance}</td> <td className="text-center text-gray-500">{p.eng_frequency}</td>
<td className="text-center border-l border-gray-200 dark:border-gray-700 whitespace-nowrap"> <td className="text-center text-gray-500">{p.eng_probability}</td>
<span <td className="text-center text-gray-500">{p.eng_avoidance}</td>
className={`inline-block font-bold rounded px-1.5 ${cellColor[ampelEN(p.eng_risk_point)]}`} <td className="text-center">
title={`Schätzbereich R ${p.eng_risk_low}${p.eng_risk_high} (${p.eng_risk_level_range})`} <span className={`inline-block font-bold rounded px-1.5 ${cellColor[ampelEN(engR)]}`}>{engR}</span>
> <span className={`ml-1 inline-block rounded px-1 ${cellColor[ampelBand(p.fk_band)]}`} title={`Fine-Kinney ${p.fk_band}`}>FK&nbsp;{Math.round(p.fk_score)}</span>
{p.eng_risk_low}{p.eng_risk_high} </td>
</span> </tr>
<span className="ml-1 text-[10px] text-gray-400">{p.eng_risk_point}</span> )
<span className={`ml-1 inline-block rounded px-1 ${cellColor[ampelBand(p.fk_band)]}`} title={`Fine-Kinney ${p.fk_band}`}>FK&nbsp;{Math.round(p.fk_score)}</span> })}
<div className="text-[9px] text-gray-400 mt-0.5">{p.eng_risk_level_range}</div>
</td>
<td className="text-center border-l border-gray-200 dark:border-gray-700">
<span className={`inline-block rounded px-1.5 py-0.5 text-[10px] font-medium ${cellColor[ampelConfidence(p.confidence)]}`}>
{p.confidence}
</span>
</td>
</tr>
))}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -53,9 +53,6 @@ export interface RiskComparisonPair {
gt_severity: number; gt_frequency: number; gt_probability: number; gt_avoidance: number; gt_risk: number gt_severity: number; gt_frequency: number; gt_probability: number; gt_avoidance: number; gt_risk: number
eng_severity: number; eng_frequency: number; eng_probability: number; eng_avoidance: number eng_severity: number; eng_frequency: number; eng_probability: number; eng_avoidance: number
fk_score: number; fk_band: string fk_score: number; fk_band: string
eng_risk_point: number; eng_risk_low: number; eng_risk_high: number
eng_risk_level: string; eng_risk_level_range: string
confidence: string // hoch | mittel | niedrig
} }
export interface RiskAgreement { export interface RiskAgreement {
@@ -63,22 +60,6 @@ export interface RiskAgreement {
severity_within1: number; frequency_within1: number severity_within1: number; frequency_within1: number
probability_within1: number; avoidance_within1: number probability_within1: number; avoidance_within1: number
rank_concordance: number rank_concordance: number
high_confidence_pct: number
}
export interface DistanceToken {
value: number
unit: string // "mm" | "mm/s"
raw: string
}
export interface DistanceComparison {
gt_count: number
matched_count: number
agreement_pct: number
matched: DistanceToken[]
gt_only: DistanceToken[]
engine_only: DistanceToken[]
} }
export interface BenchmarkResult { export interface BenchmarkResult {
@@ -93,7 +74,6 @@ export interface BenchmarkResult {
risk_rank_pairs: { gt_rank: number; engine_rank: number; hazard_name: string; gt_risk_score: number }[] risk_rank_pairs: { gt_rank: number; engine_rank: number; hazard_name: string; gt_risk_score: number }[]
risk_comparison?: RiskComparisonPair[] risk_comparison?: RiskComparisonPair[]
risk_agreement?: RiskAgreement risk_agreement?: RiskAgreement
distances?: DistanceComparison
} }
interface UseBenchmarkReturn { interface UseBenchmarkReturn {
@@ -7,7 +7,6 @@ import { GTImportForm } from './_components/GTImportForm'
import { HazardComparisonTable } from './_components/HazardComparisonTable' import { HazardComparisonTable } from './_components/HazardComparisonTable'
import { CategoryBreakdown } from './_components/CategoryBreakdown' import { CategoryBreakdown } from './_components/CategoryBreakdown'
import { RiskComparison } from './_components/RiskComparison' import { RiskComparison } from './_components/RiskComparison'
import { DistanceComparison } from './_components/DistanceComparison'
export default function BenchmarkPage() { export default function BenchmarkPage() {
const { projectId } = useParams<{ projectId: string }>() const { projectId } = useParams<{ projectId: string }>()
@@ -107,9 +106,6 @@ export default function BenchmarkPage() {
{/* Risk-number comparison (tool vs professional) with traffic lights */} {/* Risk-number comparison (tool vs professional) with traffic lights */}
<RiskComparison pairs={result.risk_comparison} agreement={result.risk_agreement} /> <RiskComparison pairs={result.risk_comparison} agreement={result.risk_agreement} />
{/* Distance/speed dimension comparison */}
<DistanceComparison data={result.distances} />
{/* Hazard Comparison Table */} {/* Hazard Comparison Table */}
<HazardComparisonTable <HazardComparisonTable
matched={result.matched_pairs || []} matched={result.matched_pairs || []}
@@ -10,19 +10,13 @@ export function ComponentTreeNode({
onEdit, onEdit,
onDelete, onDelete,
onAddChild, onAddChild,
onMarkAbsent,
onToggleCE,
}: { }: {
component: Component component: Component
depth: number depth: number
onEdit: (c: Component) => void onEdit: (c: Component) => void
onDelete: (id: string) => void onDelete: (id: string) => void
onAddChild: (parentId: string) => void onAddChild: (parentId: string) => void
onMarkAbsent?: (id: string) => void
onToggleCE?: (id: string, value: boolean) => void
}) { }) {
const ceMarked = !!component.ce_marked
const safetyRelevant = !!(component.is_safety_relevant ?? component.safety_relevant)
const [expanded, setExpanded] = useState(true) const [expanded, setExpanded] = useState(true)
const hasChildren = component.children && component.children.length > 0 const hasChildren = component.children && component.children.length > 0
@@ -59,18 +53,6 @@ export function ComponentTreeNode({
Bibliothek Bibliothek
</span> </span>
)} )}
{ceMarked && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700"
title="Zugekauft mit eigener CE / Konformitaetserklaerung — Nachweis via Hersteller-DoC">
CE
</span>
)}
{ceMarked && safetyRelevant && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800"
title="Die CE der Box deckt die integrierte Sicherheitsfunktion NICHT — PL/SIL nach EN ISO 13849-1 / IEC 62061 muss validiert werden">
Sicherheitsfunktion validieren (PL/SIL)
</span>
)}
</div> </div>
{component.description && ( {component.description && (
@@ -92,24 +74,7 @@ export function ComponentTreeNode({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg> </svg>
</button> </button>
{onToggleCE && ( <button onClick={() => onDelete(component.id)} title="Loeschen"
<button onClick={() => onToggleCE(component.id, !ceMarked)}
title={ceMarked ? 'CE-Markierung entfernen' : 'Als zugekaufte CE-Komponente markieren (Roboter/Aktor/SPS)'}
className={`p-1 rounded transition-colors ${ceMarked ? 'text-blue-600 bg-blue-50' : 'text-gray-400 hover:text-blue-600 hover:bg-blue-50'}`}>
<svg className="w-4 h-4" 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>
</button>
)}
{onMarkAbsent && (
<button onClick={() => onMarkAbsent(component.id)} title="Als nicht vorhanden markieren"
className="p-1 text-gray-400 hover:text-amber-600 hover:bg-amber-50 rounded transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
</button>
)}
<button onClick={() => onDelete(component.id)} title="Loeschen (in Geloescht verschieben)"
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"> className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> <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-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
@@ -128,8 +93,6 @@ export function ComponentTreeNode({
onEdit={onEdit} onEdit={onEdit}
onDelete={onDelete} onDelete={onDelete}
onAddChild={onAddChild} onAddChild={onAddChild}
onMarkAbsent={onMarkAbsent}
onToggleCE={onToggleCE}
/> />
))} ))}
</div> </div>
@@ -1,57 +0,0 @@
'use client'
import { Component } from './types'
interface Action {
label: string
onClick: (id: string) => void
variant: 'primary' | 'danger' | 'neutral'
}
const VARIANT: Record<string, string> = {
primary: 'text-green-700 border-green-300 hover:bg-green-50',
danger: 'text-red-700 border-red-300 hover:bg-red-50',
neutral: 'text-gray-600 border-gray-300 hover:bg-gray-50',
}
// PresenceSection renders a flat list of components in one presence state
// (nicht_vorhanden / geloescht) with the moves the expert can apply to each.
export function PresenceSection({
title, hint, accent, components, actions,
}: {
title: string
hint: string
accent: string
components: Component[]
actions: Action[]
}) {
if (components.length === 0) return null
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className={`px-4 py-3 border-l-4 ${accent} bg-gray-50 dark:bg-gray-750`}>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
{title} <span className="text-gray-400">({components.length})</span>
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">{hint}</p>
</div>
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
{components.map((c) => (
<li key={c.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
<div className="min-w-0">
<span className="text-sm font-medium text-gray-900 dark:text-white">{c.name}</span>
{c.type && <span className="ml-2 text-xs text-gray-400">{c.type}</span>}
</div>
<div className="flex items-center gap-2 shrink-0">
{actions.map((a) => (
<button key={a.label} onClick={() => a.onClick(c.id)}
className={`px-2.5 py-1 text-xs font-medium border rounded-md transition-colors ${VARIANT[a.variant]}`}>
{a.label}
</button>
))}
</div>
</li>
))}
</ul>
</div>
)
}
@@ -1,5 +1,3 @@
export type PresenceStatus = 'vorhanden' | 'nicht_vorhanden' | 'geloescht'
export interface Component { export interface Component {
id: string id: string
name: string name: string
@@ -7,16 +5,7 @@ export interface Component {
version: string version: string
description: string description: string
safety_relevant: boolean safety_relevant: boolean
// is_safety_relevant is the backend's field name (the form's `safety_relevant`
// does not currently round-trip). Read this when deriving CE obligations.
is_safety_relevant?: boolean
// ce_marked: bought component carrying its own CE / DoC (robot, actuator, SPS).
// Safe semantics — no hazard suppression; drives provenance + the PL/SIL
// validation note when also safety-relevant.
ce_marked?: boolean ce_marked?: boolean
// presence_status: 'vorhanden' (default) | 'nicht_vorhanden' (engine negation
// verdict, awaiting expert review) | 'geloescht' (soft-deleted, restorable).
presence_status?: PresenceStatus
parent_id: string | null parent_id: string | null
children: Component[] children: Component[]
library_component_id?: string library_component_id?: string
@@ -1,7 +1,7 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Component, ComponentFormData, LibraryComponent, EnergySource, PresenceStatus, buildTree } from '../_components/types' import { Component, ComponentFormData, LibraryComponent, EnergySource, buildTree } from '../_components/types'
export function useComponents(projectId: string) { export function useComponents(projectId: string) {
const [components, setComponents] = useState<Component[]>([]) const [components, setComponents] = useState<Component[]>([])
@@ -47,41 +47,14 @@ export function useComponents(projectId: string) {
return false return false
} }
// Move a component between presence states (vorhanden / nicht_vorhanden /
// geloescht). Used for the expert's bidirectional review of auto-detected
// components.
async function setPresence(id: string, status: PresenceStatus) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ presence_status: status }),
})
if (res.ok) await fetchComponents()
} catch (err) {
console.error('Failed to set presence:', err)
}
}
// Mark a component as a bought CE product (robot, actuator, SPS ...). Safe
// semantics: no hazard suppression — only provenance + PL/SIL note.
async function setCEMarked(id: string, value: boolean) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ce_marked: value }),
})
if (res.ok) await fetchComponents()
} catch (err) {
console.error('Failed to set ce_marked:', err)
}
}
// Soft-delete: the component moves to the "Geloescht" list (restorable); it is
// not removed from the project, so nothing silently disappears.
async function handleDelete(id: string) { async function handleDelete(id: string) {
await setPresence(id, 'geloescht') if (!confirm('Komponente wirklich loeschen? Unterkomponenten werden ebenfalls entfernt.')) return
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components/${id}`, { method: 'DELETE' })
if (res.ok) await fetchComponents()
} catch (err) {
console.error('Failed to delete component:', err)
}
} }
async function handleAddFromLibrary(libraryComps: LibraryComponent[], energySrcs: EnergySource[]) { async function handleAddFromLibrary(libraryComps: LibraryComponent[], energySrcs: EnergySource[]) {
@@ -115,7 +88,5 @@ export function useComponents(projectId: string) {
handleSubmit, handleSubmit,
handleDelete, handleDelete,
handleAddFromLibrary, handleAddFromLibrary,
setPresence,
setCEMarked,
} }
} }
@@ -2,25 +2,17 @@
import { useState } from 'react' import { useState } from 'react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { Component, buildTree } from './_components/types' import { Component } from './_components/types'
import { ComponentTreeNode } from './_components/ComponentTreeNode' import { ComponentTreeNode } from './_components/ComponentTreeNode'
import { ComponentForm } from './_components/ComponentForm' import { ComponentForm } from './_components/ComponentForm'
import { ComponentLibraryModal } from './_components/ComponentLibraryModal' import { ComponentLibraryModal } from './_components/ComponentLibraryModal'
import { PresenceSection } from './_components/PresenceSection'
import { useComponents } from './_hooks/useComponents' import { useComponents } from './_hooks/useComponents'
export default function ComponentsPage() { export default function ComponentsPage() {
const params = useParams() const params = useParams()
const projectId = params.projectId as string const projectId = params.projectId as string
const { loading, components, handleSubmit, handleDelete, handleAddFromLibrary, setPresence, setCEMarked } = useComponents(projectId) const { loading, tree, handleSubmit, handleDelete, handleAddFromLibrary } = useComponents(projectId)
// Split auto-detected components by presence so the expert can review the
// engine's best-effort negation verdicts and move items in both directions.
const present = components.filter((c) => !c.presence_status || c.presence_status === 'vorhanden')
const negated = components.filter((c) => c.presence_status === 'nicht_vorhanden')
const deleted = components.filter((c) => c.presence_status === 'geloescht')
const tree = buildTree(present)
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false)
const [editingComponent, setEditingComponent] = useState<Component | null>(null) const [editingComponent, setEditingComponent] = useState<Component | null>(null)
@@ -118,9 +110,7 @@ export default function ComponentsPage() {
<div className="py-1"> <div className="py-1">
{tree.map((component) => ( {tree.map((component) => (
<ComponentTreeNode key={component.id} component={component} depth={0} <ComponentTreeNode key={component.id} component={component} depth={0}
onEdit={handleEdit} onDelete={handleDelete} onAddChild={handleAddChild} onEdit={handleEdit} onDelete={handleDelete} onAddChild={handleAddChild} />
onMarkAbsent={(id) => setPresence(id, 'nicht_vorhanden')}
onToggleCE={setCEMarked} />
))} ))}
</div> </div>
</div> </div>
@@ -150,27 +140,6 @@ export default function ComponentsPage() {
</div> </div>
) )
)} )}
<PresenceSection
title="Nicht vorhanden"
hint={'Vom System als verneint erkannt (z. B. „keine Pneumatik"). Pruefen Sie und verschieben Sie bei Bedarf zu Vorhanden.'}
accent="border-amber-400"
components={negated}
actions={[
{ label: '→ Vorhanden', variant: 'primary', onClick: (id) => setPresence(id, 'vorhanden') },
{ label: 'Loeschen', variant: 'danger', onClick: handleDelete },
]}
/>
<PresenceSection
title="Geloescht"
hint="Entfernte Komponenten bleiben zur Nachvollziehbarkeit sichtbar und koennen wiederhergestellt werden."
accent="border-gray-400"
components={deleted}
actions={[
{ label: 'Wiederherstellen', variant: 'neutral', onClick: (id) => setPresence(id, 'vorhanden') },
]}
/>
</div> </div>
) )
} }
@@ -3,7 +3,7 @@
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
interface Component { id: string; name: string; component_type: string } interface Component { id: string; name: string; component_type: string }
interface Hazard { id: string; name: string; category: string; operational_states?: string[]; component_id?: string } interface Hazard { id: string; name: string; category: string; operational_states?: string[] }
interface Mitigation { id: string; name?: string; title?: string; reduction_type: string; hazard_id?: string; linked_hazard_ids?: string[] } interface Mitigation { id: string; name?: string; title?: string; reduction_type: string; hazard_id?: string; linked_hazard_ids?: string[] }
export interface GraphNode { export interface GraphNode {
@@ -56,7 +56,6 @@ export function useKnowledgeGraph(projectId: string) {
setHazards((j.hazards || j || []).map((h: Record<string, unknown>) => ({ setHazards((j.hazards || j || []).map((h: Record<string, unknown>) => ({
id: h.id as string, name: h.name as string, category: h.category as string || '', id: h.id as string, name: h.name as string, category: h.category as string || '',
operational_states: (h.operational_states || []) as string[], operational_states: (h.operational_states || []) as string[],
component_id: (h.component_id || '') as string,
}))) })))
} }
if (mitRes.ok) { if (mitRes.ok) {
@@ -90,20 +89,17 @@ export function useKnowledgeGraph(projectId: string) {
}) })
// Hazard nodes // Hazard nodes
const compIdSet = new Set(components.map((c) => c.id))
hazards.forEach((h) => { hazards.forEach((h) => {
graphNodes.push({ graphNodes.push({
id: `haz-${h.id}`, type: 'hazard', id: `haz-${h.id}`, type: 'hazard',
label: h.name, subLabel: h.category, label: h.name, subLabel: h.category,
color: NODE_COLORS.hazard, color: NODE_COLORS.hazard,
}) })
// Edge: the component that actually causes this hazard → hazard. // Edge: first component → hazard (simplified — could be per component_id)
// Only drawn when the hazard carries a component_id that maps to a known if (components.length > 0) {
// component node (no synthetic "all from the first component" edges).
if (h.component_id && compIdSet.has(h.component_id)) {
graphEdges.push({ graphEdges.push({
id: `e-comp-haz-${h.id}`, id: `e-comp-haz-${h.id}`,
source: `comp-${h.component_id}`, source: `comp-${components[0].id}`,
target: `haz-${h.id}`, target: `haz-${h.id}`,
label: 'erzeugt', label: 'erzeugt',
}) })
@@ -1,53 +0,0 @@
'use client'
import { OshaAnchor, MinimumDistance } from '../_hooks/useMinimumDistances'
const RELATION_LABEL: Record<string, string> = {
value_source: 'Wertquelle',
public_domain_crossref: 'Public-Domain-Pendant',
}
function distanceLine(d: MinimumDistance): string {
if (d.formula_description) return d.formula_description
if (d.recommended_min_mm && d.recommended_max_mm)
return `${d.recommended_min_mm}${d.recommended_max_mm} mm (empfohlen, sicherheitsseitig gerundet)`
if (d.recommended_mm) return `${d.recommended_mm} mm (empfohlen, sicherheitsseitig gerundet)`
return d.context
}
/** Renders the OSHA safety-distance anchor for one measure (audit view). */
export function OshaDistanceNote({ entry }: { entry: OshaAnchor }) {
const { link, distances } = entry
if (!distances.length) return null
return (
<div className="px-12 pb-2">
<div className="rounded border border-amber-200 dark:border-amber-800 bg-amber-50/50 dark:bg-amber-900/15 p-2">
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] font-semibold uppercase tracking-wide text-amber-700 dark:text-amber-300">
OSHA-Sicherheitsabstand
</span>
<span className="text-[10px] text-amber-600/80 dark:text-amber-400/80">
{RELATION_LABEL[link.relation] || link.relation}
</span>
</div>
{distances.map((d) => (
<div key={d.id} className="text-[11px] text-gray-600 dark:text-gray-300 mb-1">
<span className="font-medium">{distanceLine(d)}</span>
<span className="text-gray-400">
{' · '}
{d.source_cfr} · {d.license}
</span>
{(d.eu_norm_hints || []).map((h, i) => (
<span key={i} className="block text-[10px] text-gray-400 mt-0.5">
EU: {h.norm}
{h.din_comparison_note ? `${h.din_comparison_note}` : ''}
</span>
))}
</div>
))}
{link.note && <p className="text-[10px] text-gray-400 italic mt-0.5">{link.note}</p>}
</div>
</div>
)
}
@@ -1,77 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
export interface EuNormHint {
norm: string
section?: string
din_comparison_note?: string
}
export interface MinimumDistance {
id: string
source_cfr?: string
source_table?: string
license: string
context: string
body_part?: string
recommended_mm?: number
recommended_min_mm?: number
recommended_max_mm?: number
formula_description?: string
formula_mm_per_second?: number
rounding_note?: string
eu_norm_hints?: EuNormHint[]
}
export interface MeasureDistanceLink {
measure_id: string
measure_name?: string
distance_ids: string[]
relation: string // "value_source" | "public_domain_crossref"
note?: string
}
export interface OshaAnchor {
link: MeasureDistanceLink
distances: MinimumDistance[]
}
/**
* Loads the OSHA minimum-distance link table ONCE and returns a lookup keyed by
* (lower-cased) measure name. A persisted mitigation stores the measure name
* verbatim, so the Maßnahmen tab can surface the OSHA anchor by matching on name
* no per-row request, no catalog ID needed.
*/
export function useMinimumDistances() {
const [byName, setByName] = useState<Record<string, OshaAnchor>>({})
useEffect(() => {
let cancelled = false
async function load() {
try {
const res = await fetch('/api/sdk/v1/iace/minimum-distances')
if (!res.ok) return
const json = (await res.json()) as { distances: MinimumDistance[]; links: MeasureDistanceLink[] }
const byId = Object.fromEntries((json.distances || []).map((d) => [d.id, d]))
const map: Record<string, OshaAnchor> = {}
for (const link of json.links || []) {
if (!link.measure_name) continue
map[link.measure_name.toLowerCase()] = {
link,
distances: link.distance_ids.map((id) => byId[id]).filter(Boolean),
}
}
if (!cancelled) setByName(map)
} catch (err) {
console.error('Failed to load minimum distances:', err)
}
}
load()
return () => {
cancelled = true
}
}, [])
return { byName }
}
@@ -31,7 +31,7 @@ export function useMitigations(projectId: string) {
const raw = json.mitigations || json || [] const raw = json.mitigations || json || []
// Map API fields (name, hazard_id) to frontend fields (title, linked_hazard_ids/names) // Map API fields (name, hazard_id) to frontend fields (title, linked_hazard_ids/names)
const hazardMap = Object.fromEntries(hazardList.map((h) => [h.id, h.name])) const hazardMap = Object.fromEntries(hazardList.map((h) => [h.id, h.name]))
const hazardStatesMap = Object.fromEntries(hazardList.map((h) => [h.id, (h as unknown as Record<string, unknown>).operational_states || []])) const hazardStatesMap = Object.fromEntries(hazardList.map((h) => [h.id, (h as Record<string, unknown>).operational_states || []]))
const mits: Mitigation[] = raw.map((m: Record<string, unknown>) => ({ const mits: Mitigation[] = raw.map((m: Record<string, unknown>) => ({
id: m.id as string, id: m.id as string,
title: (m.title || m.name || '') as string, title: (m.title || m.name || '') as string,
@@ -9,8 +9,6 @@ import { SuggestMeasuresModal } from './_components/SuggestMeasuresModal'
import { MitigationForm } from './_components/MitigationForm' import { MitigationForm } from './_components/MitigationForm'
import { StatusBadge } from './_components/StatusBadge' import { StatusBadge } from './_components/StatusBadge'
import { MitigationHints } from './_components/MitigationHints' import { MitigationHints } from './_components/MitigationHints'
import { OshaDistanceNote } from './_components/OshaDistanceNote'
import { useMinimumDistances } from './_hooks/useMinimumDistances'
import { ProtectiveMeasure } from './_components/types' import { ProtectiveMeasure } from './_components/types'
import { useMitigations } from './_hooks/useMitigations' import { useMitigations } from './_hooks/useMitigations'
@@ -26,7 +24,6 @@ export default function MitigationsPage() {
} = useMitigations(projectId) } = useMitigations(projectId)
const [measureNorms, setMeasureNorms] = useState<Record<string, string[]>>({}) const [measureNorms, setMeasureNorms] = useState<Record<string, string[]>>({})
const { byName: oshaByName } = useMinimumDistances()
useEffect(() => { useEffect(() => {
fetch('/api/sdk/v1/iace/protective-measures-library') fetch('/api/sdk/v1/iace/protective-measures-library')
@@ -279,9 +276,6 @@ export default function MitigationsPage() {
{refs?.length > 0 && ( {refs?.length > 0 && (
<p className="px-12 pb-2 text-[11px] text-blue-500">Normen: {refs.join(', ')}</p> <p className="px-12 pb-2 text-[11px] text-blue-500">Normen: {refs.join(', ')}</p>
)} )}
{oshaByName[title.toLowerCase()] && (
<OshaDistanceNote entry={oshaByName[title.toLowerCase()]} />
)}
{instances.map((m) => { {instances.map((m) => {
const isDetailOpen = expandedMeasure === m.id const isDetailOpen = expandedMeasure === m.id
return ( return (
@@ -1,59 +0,0 @@
'use client'
import { useState } from 'react'
import { RiskDataSources as RiskDataSourcesData } from '../_hooks/useRiskDataSources'
/**
* Collapsible evidence panel: the real public-statistics figures (Eurostat ESAW
* 2023) that anchor the W/S tiers, with license + ready-to-print attribution.
* Confidence-aware tonality informs the source, does not alarm.
*/
export function RiskDataSources({ data }: { data: RiskDataSourcesData }) {
const [open, setOpen] = useState(false)
if (!data.evidence?.length) return null
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between px-4 py-3 text-left"
>
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
Datenquellen &amp; Evidenz <span className="text-gray-400 font-normal">({data.evidence.length} belegte Kontaktmodi)</span>
</span>
<span className="text-gray-400 text-xs">{open ? '▲' : '▼'}</span>
</button>
{open && (
<div className="px-4 pb-4 space-y-3">
<p className="text-xs text-gray-500 dark:text-gray-400">{data.note}</p>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-gray-500 border-b border-gray-200 dark:border-gray-700 text-left">
<th className="py-1.5 pr-3">Kontaktmodus</th>
<th className="py-1.5 pr-3">Belegte Quote</th>
<th className="py-1.5 pr-3">Quelle</th>
<th className="py-1.5">Lizenz</th>
</tr>
</thead>
<tbody>
{data.evidence.map((e) => (
<tr key={e.mode} className="border-b border-gray-100 dark:border-gray-700/50 align-top">
<td className="py-1.5 pr-3 text-gray-700 dark:text-gray-300 font-medium">{e.label}</td>
<td className="py-1.5 pr-3 text-gray-600 dark:text-gray-300 tabular-nums">{e.stat}</td>
<td className="py-1.5 pr-3 text-gray-500">{e.source}</td>
<td className="py-1.5 text-gray-500">{e.license}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-[10px] text-gray-400">
{data.evidence[0]?.attribution} · Tiers verankern die Quoten-<em>Ordnung</em>; die Werte sind an
BreakPilot-Ground-Truth kalibriert (keine Norm-Tabelle reproduziert).
</p>
</div>
)}
</div>
)
}
@@ -1,111 +0,0 @@
'use client'
import type { RiskMatrixData } from '../_hooks/useRiskMatrix'
// Severity × Probability(W) grid — the classic risk matrix. Cells are coloured by
// the WORST (dominant) risk band of the hazards they hold, deliberately muted
// (confidence-aware tonality: inform, don't alarm). The number is the hazard
// count in that bucket.
const LEVELS = ['vernachlaessigbar', 'gering', 'mittel', 'hoch', 'kritisch'] as const
const LEVEL_LABEL: Record<string, string> = {
vernachlaessigbar: 'vernachlässigbar',
gering: 'gering',
mittel: 'mittel',
hoch: 'hoch',
kritisch: 'kritisch',
}
const LEVEL_BG: Record<string, string> = {
kritisch: 'bg-red-200 text-red-900 dark:bg-red-900/50 dark:text-red-200',
hoch: 'bg-orange-200 text-orange-900 dark:bg-orange-900/50 dark:text-orange-200',
mittel: 'bg-yellow-200 text-yellow-900 dark:bg-yellow-900/40 dark:text-yellow-200',
gering: 'bg-lime-200 text-lime-900 dark:bg-lime-900/40 dark:text-lime-200',
vernachlaessigbar: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200',
}
const LEVEL_DOT: Record<string, string> = {
kritisch: 'bg-red-400', hoch: 'bg-orange-400', mittel: 'bg-yellow-400',
gering: 'bg-lime-400', vernachlaessigbar: 'bg-green-300',
}
export function RiskMatrix({ data }: { data: RiskMatrixData }) {
if (!data || data.total === 0) return null
// Index cells by "severity-probability" for O(1) lookup.
const cellMap = new Map(data.matrix.map((c) => [`${c.severity}-${c.probability}`, c]))
const severities = [5, 4, 3, 2, 1] // rows, worst on top
const probabilities = [1, 2, 3, 4, 5] // columns
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-4">
<div>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">Risiko-Matrix (Schwere × Wahrscheinlichkeit)</h3>
<p className="text-xs text-gray-500 mt-0.5">
{data.total} Gefährdungen, automatisch geschätzt (BreakPilot-Modell). Die Zahl je Feld ist die
Anzahl der Gefährdungen; die Farbe zeigt das höchste Risiko-Band im Feld. Schätzung Sie
entscheiden mit Ihrem/Ihrer Sachverständigen.
</p>
</div>
<div className="flex gap-4 flex-wrap items-start">
{/* Matrix grid */}
<div className="inline-block">
<div className="flex">
<div className="w-16" />
<div className="text-center text-[10px] text-gray-500 flex-1" style={{ minWidth: 200 }}>
Wahrscheinlichkeit (W)
</div>
</div>
<div className="flex">
<div className="w-16 flex items-center justify-center -rotate-90 text-[10px] text-gray-500 whitespace-nowrap">
Schwere (S)
</div>
<table className="border-collapse">
<tbody>
{severities.map((s) => (
<tr key={s}>
<td className="text-[10px] text-gray-400 pr-1 text-right align-middle w-4">{s}</td>
{probabilities.map((w) => {
const cell = cellMap.get(`${s}-${w}`)
const bg = cell ? LEVEL_BG[cell.dominant_level] : 'bg-gray-50 dark:bg-gray-900/40 text-gray-300'
return (
<td key={w} className="p-0.5">
<div
className={`w-11 h-11 rounded flex items-center justify-center text-sm font-bold ${bg}`}
title={cell ? `S${s} × W${w}: ${cell.count} Gefährdung(en) · ${LEVEL_LABEL[cell.dominant_level]}` : `S${s} × W${w}: 0`}
>
{cell ? cell.count : ''}
</div>
</td>
)
})}
</tr>
))}
<tr>
<td />
{probabilities.map((w) => (
<td key={w} className="text-[10px] text-gray-400 text-center">{w}</td>
))}
</tr>
</tbody>
</table>
</div>
</div>
{/* Level summary + confidence */}
<div className="space-y-2 text-xs min-w-[180px]">
{LEVELS.slice().reverse().map((lvl) => (
<div key={lvl} className="flex items-center gap-2">
<span className={`inline-block w-3 h-3 rounded-sm ${LEVEL_DOT[lvl]}`} />
<span className="text-gray-600 dark:text-gray-300 flex-1">{LEVEL_LABEL[lvl]}</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">{data.level_counts[lvl] || 0}</span>
</div>
))}
<div className="pt-2 mt-1 border-t border-gray-100 dark:border-gray-700 text-gray-500">
Tool-Konfidenz: <strong>{Math.round(data.high_confidence_pct)}%</strong> mit hoher Konfidenz
(Verletzungsmechanismus eindeutig).
</div>
</div>
</div>
</div>
)
}
@@ -1,47 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
export interface RiskEvidence {
mode: string
label: string
stat: string
source: string
license: string
attribution: string
retrieved: string
}
export interface RiskDataSources {
note: string
evidence: RiskEvidence[]
}
/**
* Loads the license-tagged public-statistics evidence behind the risk-frequency
* anchors (Eurostat ESAW hsw_ph3_08, 2023). Global, not per project so an
* auditor can see WHERE the W/S tiers come from and the source is cited.
*/
export function useRiskDataSources() {
const [data, setData] = useState<RiskDataSources | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const res = await fetch('/api/sdk/v1/iace/risk-data-sources')
if (!res.ok) return
const json = (await res.json()) as RiskDataSources
if (!cancelled) setData(json)
} catch (err) {
console.error('Failed to load risk data sources:', err)
}
}
load()
return () => {
cancelled = true
}
}, [])
return { data }
}
@@ -1,64 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
export interface HazardRiskDetail {
hazard_id: string
name: string
category: string
zone: string
severity: number
frequency: number
probability: number
avoidance: number
risk_point: number
risk_low: number
risk_high: number
level: string
level_range: string
confidence: string
}
export interface RiskMatrixCell {
severity: number
probability: number
count: number
dominant_level: string
hazard_ids: string[]
}
export interface RiskMatrixData {
hazards: HazardRiskDetail[]
matrix: RiskMatrixCell[]
level_counts: Record<string, number>
total: number
high_confidence_pct: number
}
/** Loads the project-wide confidence-aware risk matrix (computed server-side). */
export function useRiskMatrix(projectId: string) {
const [data, setData] = useState<RiskMatrixData | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function load() {
setLoading(true)
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/risk-matrix`)
const json = res.ok ? ((await res.json()) as RiskMatrixData) : null
if (!cancelled) setData(json)
} catch (err) {
console.error('Failed to load risk matrix:', err)
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => {
cancelled = true
}
}, [projectId])
return { data, loading }
}
@@ -2,18 +2,12 @@
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { useRiskAssessment } from './_hooks/useRiskAssessment' import { useRiskAssessment } from './_hooks/useRiskAssessment'
import { useRiskMatrix } from './_hooks/useRiskMatrix'
import { useRiskDataSources } from './_hooks/useRiskDataSources'
import { RiskModelCard } from './_components/RiskModelCard' import { RiskModelCard } from './_components/RiskModelCard'
import { RiskMatrix } from './_components/RiskMatrix'
import { RiskDataSources } from './_components/RiskDataSources'
export default function RisikobewertungPage() { export default function RisikobewertungPage() {
const params = useParams<{ projectId: string }>() const params = useParams<{ projectId: string }>()
const projectId = params.projectId const projectId = params.projectId
const { hazards, suggestions, loading } = useRiskAssessment(projectId) const { hazards, suggestions, loading } = useRiskAssessment(projectId)
const { data: matrix } = useRiskMatrix(projectId)
const { data: dataSources } = useRiskDataSources()
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -27,10 +21,6 @@ export default function RisikobewertungPage() {
</p> </p>
</div> </div>
{matrix && matrix.total > 0 && <RiskMatrix data={matrix} />}
{dataSources && <RiskDataSources data={dataSources} />}
{loading && ( {loading && (
<div className="text-sm text-gray-500 dark:text-gray-400">Lade Gefaehrdungen</div> <div className="text-sm text-gray-500 dark:text-gray-400">Lade Gefaehrdungen</div>
)} )}
-1
View File
@@ -7,7 +7,6 @@ import IACEFlowFAB from './[projectId]/_components/IACEFlowFAB'
const IACE_NAV_ITEMS = [ const IACE_NAV_ITEMS = [
{ id: 'overview', label: 'Uebersicht', href: '', icon: 'grid' }, { id: 'overview', label: 'Uebersicht', href: '', icon: 'grid' },
{ id: 'architektur', label: 'Architektur & Datenfluss', href: '/architektur', icon: 'activity' },
{ id: 'order', label: 'Auftrag', href: '/order', icon: 'briefcase' }, { id: 'order', label: 'Auftrag', href: '/order', icon: 'briefcase' },
{ id: 'interview', label: 'Grenzen & Verwendung', href: '/interview', icon: 'chat' }, { id: 'interview', label: 'Grenzen & Verwendung', href: '/interview', icon: 'chat' },
{ id: 'operational-states', label: 'Betriebszustaende', href: '/operational-states', icon: 'activity' }, { id: 'operational-states', label: 'Betriebszustaende', href: '/operational-states', icon: 'activity' },
+4 -19
View File
@@ -134,14 +134,9 @@ export default function IACEDashboardPage() {
machine_type: '', machine_type: '',
manufacturer: '', manufacturer: '',
}) })
const [machineTypes, setMachineTypes] = useState<{ key: string; label_de: string; group: string }[]>([])
useEffect(() => { useEffect(() => {
fetchProjects() fetchProjects()
fetch('/api/sdk/v1/iace/machine-types')
.then((r) => (r.ok ? r.json() : null))
.then((j) => j && setMachineTypes(j.machine_types || []))
.catch((err) => console.error('Failed to fetch machine types:', err))
}, []) }, [])
async function fetchProjects() { async function fetchProjects() {
@@ -313,23 +308,13 @@ export default function IACEDashboardPage() {
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Maschinentyp Maschinentyp
</label> </label>
<select <input
type="text"
value={formData.machine_type} value={formData.machine_type}
onChange={(e) => setFormData({ ...formData, machine_type: e.target.value })} onChange={(e) => setFormData({ ...formData, machine_type: e.target.value })}
placeholder="z.B. Industrieroboter"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white" className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
> />
<option value=""> Maschinentyp wählen </option>
{Array.from(new Set(machineTypes.map((m) => m.group))).map((group) => (
<optgroup key={group} label={group}>
{machineTypes.filter((m) => m.group === group).map((m) => (
<option key={m.key} value={m.key}>{m.label_de}</option>
))}
</optgroup>
))}
</select>
<p className="mt-1 text-xs text-gray-400">
Steuert, welche maschinenspezifischen Gefährdungs-Patterns greifen bitte aus der Liste wählen.
</p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
@@ -55,7 +55,7 @@ export function DeletionLogicSection({
{policy.deletionTrigger === 'RETENTION_DRIVER' && ( {policy.deletionTrigger === 'RETENTION_DRIVER' && (
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Aufbewahrungstreiber</label> <label className="block text-sm font-medium text-gray-700 mb-1">Aufbewahrungstreiber</label>
<select value={policy.retentionDriver ?? ""} <select value={policy.retentionDriver}
onChange={(e) => { onChange={(e) => {
const driver = e.target.value as RetentionDriverType const driver = e.target.value as RetentionDriverType
const meta = RETENTION_DRIVER_META[driver] const meta = RETENTION_DRIVER_META[driver]
@@ -78,13 +78,13 @@ export function DeletionLogicSection({
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Aufbewahrungsdauer</label> <label className="block text-sm font-medium text-gray-700 mb-1">Aufbewahrungsdauer</label>
<input type="number" min={0} value={policy.retentionDuration ?? ""} <input type="number" min={0} value={policy.retentionDuration}
onChange={(e) => set('retentionDuration', parseInt(e.target.value) || 0)} onChange={(e) => set('retentionDuration', parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500" /> className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Einheit</label> <label className="block text-sm font-medium text-gray-700 mb-1">Einheit</label>
<select value={policy.retentionUnit ?? ""} onChange={(e) => set('retentionUnit', e.target.value as RetentionUnit)} <select value={policy.retentionUnit} onChange={(e) => set('retentionUnit', e.target.value as RetentionUnit)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"> className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500">
<option value="DAYS">Tage</option> <option value="DAYS">Tage</option>
<option value="MONTHS">Monate</option> <option value="MONTHS">Monate</option>
@@ -232,7 +232,7 @@ export function StorageSection({
className="text-purple-600 focus:ring-purple-500 rounded" /> className="text-purple-600 focus:ring-purple-500 rounded" />
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<input type="text" value={loc.provider ?? ""} <input type="text" value={loc.provider}
onChange={(e) => updateStorageLocationItem(idx, (s) => ({ ...s, provider: e.target.value }))} onChange={(e) => updateStorageLocationItem(idx, (s) => ({ ...s, provider: e.target.value }))}
placeholder="Anbieter" placeholder="Anbieter"
className="w-full px-2 py-1 border border-gray-200 rounded text-sm focus:ring-1 focus:ring-purple-500" /> className="w-full px-2 py-1 border border-gray-200 rounded text-sm focus:ring-1 focus:ring-purple-500" />
@@ -235,12 +235,12 @@ function ComplianceResultView({
{issue.recommendation && ( {issue.recommendation && (
<p className="text-xs text-gray-500 mt-1 italic">Empfehlung: {issue.recommendation}</p> <p className="text-xs text-gray-500 mt-1 italic">Empfehlung: {issue.recommendation}</p>
)} )}
{issue.policyId && ( {issue.affectedPolicyId && (
<button <button
onClick={() => { setEditingId(issue.policyId!); setTab('editor') }} onClick={() => { setEditingId(issue.affectedPolicyId!); setTab('editor') }}
className="text-xs text-purple-600 hover:text-purple-800 font-medium mt-1" className="text-xs text-purple-600 hover:text-purple-800 font-medium mt-1"
> >
Zur Loeschfrist: {issue.policyId} Zur Loeschfrist: {issue.affectedPolicyId}
</button> </button>
)} )}
</div> </div>
@@ -98,7 +98,7 @@ function GeneratedPreview({
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{renderTriggerBadge(getEffectiveDeletionTrigger(gp))} {renderTriggerBadge(getEffectiveDeletionTrigger(gp))}
<span className="inline-block text-xs font-medium px-2 py-0.5 rounded-full bg-blue-100 text-blue-800"> <span className="inline-block text-xs font-medium px-2 py-0.5 rounded-full bg-blue-100 text-blue-800">
{formatRetentionDuration(gp.retentionDuration, gp.retentionUnit)} {formatRetentionDuration(gp)}
</span> </span>
{gp.retentionDriver && ( {gp.retentionDriver && (
<span className="inline-block text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100 text-gray-600"> <span className="inline-block text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">
@@ -157,7 +157,7 @@ function ProfilingWizard({
const totalSteps = PROFILING_STEPS.length const totalSteps = PROFILING_STEPS.length
const progress = getProfilingProgress(profilingAnswers) const progress = getProfilingProgress(profilingAnswers)
const allComplete = PROFILING_STEPS.every((step, idx) => const allComplete = PROFILING_STEPS.every((step, idx) =>
isStepComplete(profilingAnswers.filter((a) => a.stepIndex === idx), step.id), isStepComplete(step, profilingAnswers.filter((a) => a.stepIndex === idx)),
) )
const currentStep: ProfilingStep | undefined = PROFILING_STEPS[profilingStep] const currentStep: ProfilingStep | undefined = PROFILING_STEPS[profilingStep]
@@ -200,7 +200,7 @@ function ProfilingWizard({
return ( return (
<div key={question.id} className="border-t border-gray-100 pt-4"> <div key={question.id} className="border-t border-gray-100 pt-4">
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
{question.question} {question.label}
{question.helpText && ( {question.helpText && (
<span className="block text-xs text-gray-400 font-normal mt-0.5">{question.helpText}</span> <span className="block text-xs text-gray-400 font-normal mt-0.5">{question.helpText}</span>
)} )}
@@ -245,7 +245,7 @@ function ProfilingWizard({
{question.type === 'multi' && question.options && ( {question.type === 'multi' && question.options && (
<div className="space-y-2"> <div className="space-y-2">
{question.options.map((opt) => { {question.options.map((opt) => {
const selectedValues: string[] = Array.isArray(currentAnswer?.value) ? currentAnswer.value : [] const selectedValues: string[] = currentAnswer?.value || []
const isSelected = selectedValues.includes(opt.value) const isSelected = selectedValues.includes(opt.value)
return ( return (
<label key={opt.value} <label key={opt.value}
@@ -271,7 +271,7 @@ function ProfilingWizard({
)} )}
{question.type === 'number' && ( {question.type === 'number' && (
<input type="number" value={(currentAnswer?.value ?? '') as string | number} <input type="number" value={currentAnswer?.value ?? ''}
onChange={(e) => handleProfilingAnswer(profilingStep, question.id, e.target.value ? parseInt(e.target.value) : '')} onChange={(e) => handleProfilingAnswer(profilingStep, question.id, e.target.value ? parseInt(e.target.value) : '')}
min={0} placeholder="Bitte Zahl eingeben" min={0} placeholder="Bitte Zahl eingeben"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500" /> className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
@@ -187,7 +187,7 @@ export function UebersichtTab({
<div className="flex flex-wrap gap-1.5 mb-3"> <div className="flex flex-wrap gap-1.5 mb-3">
{renderTriggerBadge(trigger)} {renderTriggerBadge(trigger)}
<span className="inline-block text-xs font-medium px-2 py-0.5 rounded-full bg-blue-100 text-blue-800"> <span className="inline-block text-xs font-medium px-2 py-0.5 rounded-full bg-blue-100 text-blue-800">
{formatRetentionDuration(p.retentionDuration, p.retentionUnit)} {formatRetentionDuration(p)}
</span> </span>
{renderStatusBadge(p.status)} {renderStatusBadge(p.status)}
{overdue && ( {overdue && (
@@ -4,7 +4,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader' import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import { import {
LoeschfristPolicy, LoeschfristPolicy,
createEmptyPolicy, createEmptyLegalHold, createEmptyStorageLocation, createEmptyLegalHold, createEmptyStorageLocation,
isPolicyOverdue, getActiveLegalHolds, isPolicyOverdue, getActiveLegalHolds,
} from '@/lib/sdk/loeschfristen-types' } from '@/lib/sdk/loeschfristen-types'
import { import {
@@ -271,7 +271,7 @@ export default function LoeschfristenPage() {
}, []) }, [])
const handleGenerate = useCallback(() => { const handleGenerate = useCallback(() => {
const generated = generatePoliciesFromProfile(profilingAnswers).generatedPolicies const generated = generatePoliciesFromProfile(profilingAnswers)
setGeneratedPolicies(generated) setGeneratedPolicies(generated)
setSelectedGenerated(new Set(generated.map((p) => p.policyId))) setSelectedGenerated(new Set(generated.map((p) => p.policyId)))
}, [profilingAnswers]) }, [profilingAnswers])
@@ -2,7 +2,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useSDK, ServiceModule } from '@/lib/sdk' import { useSDK, ServiceModule } from '@/lib/sdk'
import type { RiskSeverity } from '@/lib/sdk/types/enums'
// ============================================================================= // =============================================================================
// TYPES // TYPES
@@ -98,7 +97,7 @@ function mapBackendToDisplay(m: BackendModule): Omit<DisplayModule, 'status' | '
description: m.description || '', description: m.description || '',
category: categorizeModule(m.display_name || m.name), category: categorizeModule(m.display_name || m.name),
regulations: [], regulations: [],
criticality: (m.criticality || 'MEDIUM').toUpperCase() as RiskSeverity, criticality: (m.criticality || 'MEDIUM').toUpperCase(),
processesPersonalData: m.processes_pii, processesPersonalData: m.processes_pii,
hasAIComponents: m.ai_components, hasAIComponents: m.ai_components,
requirementsCount: m.regulation_count || 0, requirementsCount: m.regulation_count || 0,
+5 -5
View File
@@ -153,7 +153,7 @@ export default function SDKDashboard() {
</div> </div>
<div> <div>
<h3 className="font-semibold text-gray-900">Erkannte Regulierungen</h3> <h3 className="font-semibold text-gray-900">Erkannte Regulierungen</h3>
<p className="text-xs text-gray-500">Basierend auf Ihrem Scope-Profiling (Level {state.complianceScope.decision.determinedLevel})</p> <p className="text-xs text-gray-500">Basierend auf Ihrem Scope-Profiling (Level {state.complianceScope.decision.level})</p>
</div> </div>
</div> </div>
<Link href={projectId ? `/sdk/compliance-scope?project=${projectId}` : '/sdk/compliance-scope'} <Link href={projectId ? `/sdk/compliance-scope?project=${projectId}` : '/sdk/compliance-scope'}
@@ -165,11 +165,11 @@ export default function SDKDashboard() {
{(state.complianceScope.decision.requiredDocuments || []).length > 0 ? ( {(state.complianceScope.decision.requiredDocuments || []).length > 0 ? (
['DSGVO', 'AI Act', 'NIS2', 'HinSchG', 'TTDSG'].filter(reg => { ['DSGVO', 'AI Act', 'NIS2', 'HinSchG', 'TTDSG'].filter(reg => {
const docs = state.complianceScope?.decision?.requiredDocuments || [] const docs = state.complianceScope?.decision?.requiredDocuments || []
const triggers = state.complianceScope?.decision?.triggeredHardTriggers || [] const triggers = state.complianceScope?.decision?.hardTriggers || []
if (reg === 'DSGVO') return true if (reg === 'DSGVO') return true
if (reg === 'AI Act') return triggers.some((t) => t.ruleId.toLowerCase().includes('ai') || t.ruleId.toLowerCase().includes('ki')) if (reg === 'AI Act') return triggers.some((t: string) => t.toLowerCase().includes('ai') || t.toLowerCase().includes('ki'))
if (reg === 'NIS2') return triggers.some((t) => t.ruleId.toLowerCase().includes('nis') || t.ruleId.toLowerCase().includes('kritisch')) if (reg === 'NIS2') return triggers.some((t: string) => t.toLowerCase().includes('nis') || t.toLowerCase().includes('kritisch'))
if (reg === 'HinSchG') return triggers.some((t) => t.ruleId.toLowerCase().includes('whistleblower') || t.ruleId.toLowerCase().includes('hinweis')) if (reg === 'HinSchG') return triggers.some((t: string) => t.toLowerCase().includes('whistleblower') || t.toLowerCase().includes('hinweis'))
return false return false
}).map(reg => ( }).map(reg => (
<span key={reg} className="px-3 py-1.5 bg-green-50 text-green-700 border border-green-200 rounded-lg text-sm font-medium"> <span key={reg} className="px-3 py-1.5 bg-green-50 text-green-700 border border-green-200 rounded-lg text-sm font-medium">
@@ -134,11 +134,11 @@ export function RiskCard({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-medium text-gray-700">{m.controlId || `Mitigation ${idx + 1}`}</span> <span className="font-medium text-gray-700">{m.controlId || `Mitigation ${idx + 1}`}</span>
<span className={`px-2 py-0.5 text-xs rounded-full ${ <span className={`px-2 py-0.5 text-xs rounded-full ${
m.status === 'COMPLETED' ? 'bg-green-100 text-green-700' : m.status === 'IMPLEMENTED' ? 'bg-green-100 text-green-700' :
m.status === 'IN_PROGRESS' ? 'bg-yellow-100 text-yellow-700' : m.status === 'IN_PROGRESS' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-500' 'bg-gray-100 text-gray-500'
}`}> }`}>
{m.status === 'COMPLETED' ? 'Implementiert' : {m.status === 'IMPLEMENTED' ? 'Implementiert' :
m.status === 'IN_PROGRESS' ? 'In Bearbeitung' : m.status || 'Geplant'} m.status === 'IN_PROGRESS' ? 'In Bearbeitung' : m.status || 'Geplant'}
</span> </span>
</div> </div>
@@ -74,7 +74,7 @@ export default function RollenkonzeptPage() {
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{mergedRoles.map(role => ( {mergedRoles.map(role => (
<RoleCard key={role.role_key} role={role} onSave={(id, data) => updateRole(id, data).then(() => {})} onSendTest={sendTestEmail} /> <RoleCard key={role.role_key} role={role} onSave={updateRole} onSendTest={sendTestEmail} />
))} ))}
</div> </div>
)} )}
@@ -130,9 +130,9 @@ export default function RollenkonzeptPage() {
loading={reviewHook.loading} loading={reviewHook.loading}
statusFilter={reviewHook.statusFilter} statusFilter={reviewHook.statusFilter}
onFilterChange={reviewHook.setStatusFilter} onFilterChange={reviewHook.setStatusFilter}
onApprove={(id) => reviewHook.approveReview(id).then(() => {})} onApprove={reviewHook.approveReview}
onReject={(id, comment) => reviewHook.rejectReview(id, comment).then(() => {})} onReject={reviewHook.rejectReview}
onSendNotification={(id) => reviewHook.sendNotification(id).then(() => {})} onSendNotification={reviewHook.sendNotification}
/> />
)} )}
</div> </div>
+1 -1
View File
@@ -9,7 +9,7 @@ export interface SDKFlowStep {
package: 'vorbereitung' | 'analyse' | 'dokumentation' | 'rechtliche-texte' | 'betrieb' package: 'vorbereitung' | 'analyse' | 'dokumentation' | 'rechtliche-texte' | 'betrieb'
seq: number seq: number
checkpointId?: string checkpointId?: string
checkpointType?: 'REQUIRED' | 'RECOMMENDED' | 'OPTIONAL' | 'CONDITIONAL' checkpointType?: 'REQUIRED' | 'RECOMMENDED'
checkpointReviewer?: 'NONE' | 'DSB' | 'LEGAL' checkpointReviewer?: 'NONE' | 'DSB' | 'LEGAL'
// Beschreibung // Beschreibung
+1 -1
View File
@@ -110,7 +110,7 @@ export default function TOMPage() {
const lastModifiedFormatted = useMemo(() => { const lastModifiedFormatted = useMemo(() => {
if (!state?.metadata?.lastModified) return null if (!state?.metadata?.lastModified) return null
try { try {
const date = new Date(state.metadata?.lastModified as string) const date = new Date(state.metadata.lastModified)
return date.toLocaleDateString('de-DE', { return date.toLocaleDateString('de-DE', {
day: '2-digit', day: '2-digit',
month: '2-digit', month: '2-digit',

Some files were not shown because too many files have changed in this diff Show More