Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd420ff85b |
@@ -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 & 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']
|
||||||
|
|||||||
@@ -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 & 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 & 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
||||||
return escaped.replace(/`([^`]+)`/g, '<code class="text-[11px] bg-gray-100 dark:bg-gray-700 rounded px-1">$1</code>')
|
|
||||||
}
|
|
||||||
-69
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+30
-58
@@ -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 {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 {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 || []}
|
||||||
|
|||||||
+1
-38
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-8
@@ -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',
|
||||||
})
|
})
|
||||||
|
|||||||
-53
@@ -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 (
|
||||||
|
|||||||
-59
@@ -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 & 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-47
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user