feat(vendor-assessment): Pruefprotokoll + Frontend + Sidebar
Build + Deploy / build-admin-compliance (push) Successful in 2m16s
Build + Deploy / build-ai-sdk (push) Successful in 58s
Build + Deploy / build-developer-portal (push) Successful in 1m13s
Build + Deploy / build-tts (push) Successful in 1m43s
CI / loc-budget (push) Failing after 17s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
Build + Deploy / build-backend-compliance (push) Successful in 3m27s
Build + Deploy / build-document-crawler (push) Successful in 45s
Build + Deploy / build-dsms-gateway (push) Successful in 30s
Build + Deploy / build-dsms-node (push) Successful in 19s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / nodejs-build (push) Successful in 2m35s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 43s
CI / test-python-backend (push) Successful in 37s
CI / test-python-document-crawler (push) Successful in 26s
CI / test-python-dsms-gateway (push) Successful in 21s
CI / validate-canonical-controls (push) Successful in 14s
Build + Deploy / trigger-orca (push) Successful in 3m33s

Phase 4-5: Professional Pruefprotokoll report builder with styled HTML
output (Kopfdaten, Kategorie-Scores, L1/L2 Check-Hierarchie, Findings,
Freigabe-Block). Frontend at /sdk/vendor-assessment with 3-step flow:
DocumentUploader → AssessmentProgress → PruefprotokollView.

Sidebar: "Use-Case Audits" → "Vertragspruefung" renamed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-12 23:24:12 +02:00
parent 0326d5baab
commit 0b9150f16f
9 changed files with 1119 additions and 3 deletions
@@ -0,0 +1,53 @@
/**
* Vendor Assessment Status/Detail Proxy
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.COMPLIANCE_BACKEND_URL || 'http://backend-compliance:8002'
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
try {
const resp = await fetch(
`${BACKEND_URL}/api/vendor-compliance/assessments/${id}`,
{ signal: AbortSignal.timeout(10000) },
)
const data = await resp.json()
return NextResponse.json(data, { status: resp.status })
} catch (error) {
console.error('Assessment status proxy error:', error)
return NextResponse.json(
{ error: 'Backend nicht erreichbar' },
{ status: 503 },
)
}
}
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
try {
const resp = await fetch(
`${BACKEND_URL}/api/vendor-compliance/assessments/${id}/approve`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(10000),
},
)
const data = await resp.json()
return NextResponse.json(data, { status: resp.status })
} catch (error) {
console.error('Assessment approve proxy error:', error)
return NextResponse.json(
{ error: 'Backend nicht erreichbar' },
{ status: 503 },
)
}
}
@@ -0,0 +1,41 @@
/**
* Vendor Assessment API Proxy
* Proxies to backend-compliance (Python FastAPI)
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.COMPLIANCE_BACKEND_URL || 'http://backend-compliance:8002'
export async function POST(request: NextRequest) {
try {
const body = await request.text()
const resp = await fetch(`${BACKEND_URL}/api/vendor-compliance/assessments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: AbortSignal.timeout(10000),
})
const data = await resp.json()
return NextResponse.json(data, { status: resp.status })
} catch (error) {
console.error('Vendor assessment proxy error:', error)
return NextResponse.json(
{ error: 'Backend nicht erreichbar' },
{ status: 503 },
)
}
}
export async function GET() {
try {
const resp = await fetch(`${BACKEND_URL}/api/vendor-compliance/assessments`, {
signal: AbortSignal.timeout(10000),
})
const data = await resp.json()
return NextResponse.json(data)
} catch (error) {
console.error('Vendor assessment list proxy error:', error)
return NextResponse.json({ assessments: [] })
}
}
@@ -0,0 +1,93 @@
'use client'
import React, { useEffect, useRef, useState } from 'react'
interface Props {
assessmentId: string
backendUrl: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onComplete: (result: any) => void
onError: (msg: string) => void
}
export function AssessmentProgress({ assessmentId, backendUrl, onComplete, onError }: Props) {
const [progress, setProgress] = useState('Initialisierung...')
const [dots, setDots] = useState(0)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
const dotTimer = setInterval(() => setDots(d => (d + 1) % 4), 500)
pollRef.current = setInterval(async () => {
try {
const res = await fetch(
`${backendUrl}/api/vendor-compliance/assessments/${assessmentId}`,
)
if (!res.ok) return
const data = await res.json()
if (data.status === 'completed' && data.result) {
if (pollRef.current) clearInterval(pollRef.current)
clearInterval(dotTimer)
onComplete(data.result)
return
}
if (data.status === 'failed') {
if (pollRef.current) clearInterval(pollRef.current)
clearInterval(dotTimer)
onError(data.error || 'Pruefung fehlgeschlagen')
return
}
if (data.progress) {
setProgress(data.progress)
}
} catch {
// retry silently
}
}, 2000)
return () => {
if (pollRef.current) clearInterval(pollRef.current)
clearInterval(dotTimer)
}
}, [assessmentId, backendUrl, onComplete, onError])
return (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto mb-6 relative">
<div className="absolute inset-0 border-4 border-blue-200 rounded-full" />
<div className="absolute inset-0 border-4 border-blue-600 rounded-full border-t-transparent animate-spin" />
</div>
<h2 className="text-xl font-semibold text-gray-800 mb-2">
Vertragspruefung laeuft
</h2>
<p className="text-gray-600 text-sm mb-6">
{progress}{'.'.repeat(dots)}
</p>
<div className="max-w-md mx-auto">
<div className="flex items-center gap-3 text-left text-xs text-gray-500 mb-2">
<span className="w-5 h-5 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">1</span>
Text extrahieren
</div>
<div className="flex items-center gap-3 text-left text-xs text-gray-500 mb-2">
<span className="w-5 h-5 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">2</span>
Checklisten pruefen (L1/L2)
</div>
<div className="flex items-center gap-3 text-left text-xs text-gray-500 mb-2">
<span className="w-5 h-5 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">3</span>
Cross-Check zwischen Dokumenten
</div>
<div className="flex items-center gap-3 text-left text-xs text-gray-500">
<span className="w-5 h-5 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">4</span>
Pruefprotokoll generieren
</div>
</div>
</div>
)
}
@@ -0,0 +1,154 @@
'use client'
import React, { useState } from 'react'
interface DocumentEntry {
doc_type: string
label: string
url: string
}
interface Props {
onStart: (vendorName: string, documents: DocumentEntry[]) => void
}
const DOC_TYPES = [
{ value: 'auto', label: 'Automatisch erkennen' },
{ value: 'avv', label: 'AVV / Auftragsverarbeitungsvertrag' },
{ value: 'scc', label: 'SCC / Standardvertragsklauseln' },
{ value: 'tom_annex', label: 'TOM-Anlage (Art. 32)' },
{ value: 'sub_processor_list', label: 'Sub-Processor-Liste' },
{ value: 'agb', label: 'AGB / Nutzungsbedingungen' },
]
export function DocumentUploader({ onStart }: Props) {
const [vendorName, setVendorName] = useState('')
const [entries, setEntries] = useState<DocumentEntry[]>([
{ doc_type: 'auto', label: '', url: '' },
])
const [loading, setLoading] = useState(false)
const updateEntry = (idx: number, field: keyof DocumentEntry, value: string) => {
setEntries(prev => {
const copy = [...prev]
copy[idx] = { ...copy[idx], [field]: value }
return copy
})
}
const addEntry = () => {
setEntries(prev => [...prev, { doc_type: 'auto', label: '', url: '' }])
}
const removeEntry = (idx: number) => {
if (entries.length <= 1) return
setEntries(prev => prev.filter((_, i) => i !== idx))
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const valid = entries.filter(d => d.url.trim())
if (!vendorName.trim() || valid.length === 0) return
setLoading(true)
onStart(vendorName.trim(), valid.map(d => ({
...d,
label: d.label || `${DOC_TYPES.find(t => t.value === d.doc_type)?.label || d.doc_type}: ${vendorName}`,
})))
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Vendor Name */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Auftragsverarbeiter / Provider *
</label>
<input
type="text"
value={vendorName}
onChange={e => setVendorName(e.target.value)}
placeholder="z.B. SysEleven GmbH, Amazon Web Services, Microsoft"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
required
/>
</div>
{/* Documents */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">Dokumente</h2>
<p className="text-sm text-gray-500 mb-4">
Fuegen Sie die URLs der Vertragsdokumente hinzu. Das System erkennt den Dokumenttyp automatisch.
</p>
<div className="space-y-3">
{entries.map((entry, idx) => (
<div key={idx} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
<div className="w-52 shrink-0">
<select
value={entry.doc_type}
onChange={e => updateEntry(idx, 'doc_type', e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-white"
>
{DOC_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div className="flex-1">
<input
type="url"
value={entry.url}
onChange={e => updateEntry(idx, 'url', e.target.value)}
placeholder="https://example.com/avv.pdf"
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
required
/>
</div>
<div className="w-44 shrink-0">
<input
type="text"
value={entry.label}
onChange={e => updateEntry(idx, 'label', e.target.value)}
placeholder="Bezeichnung (optional)"
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
/>
</div>
{entries.length > 1 && (
<button
type="button"
onClick={() => removeEntry(idx)}
className="p-2 text-gray-400 hover:text-red-500"
title="Entfernen"
>
&times;
</button>
)}
</div>
))}
</div>
<button
type="button"
onClick={addEntry}
className="mt-3 w-full py-2.5 border-2 border-dashed border-gray-300 rounded-lg text-gray-500 hover:border-blue-400 hover:text-blue-600 text-sm"
>
+ Weiteres Dokument hinzufuegen
</button>
</div>
{/* Submit */}
<div className="flex items-center gap-4">
<button
type="submit"
disabled={loading || !vendorName.trim() || !entries.some(e => e.url.trim())}
className="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Wird gestartet...' : 'Pruefung starten'}
</button>
<p className="text-xs text-gray-400">
Dokumente werden automatisch gegen Art. 28 DSGVO, Art. 32, Art. 44-49 und weitere Anforderungen geprueft.
</p>
</div>
</form>
)
}
@@ -0,0 +1,313 @@
'use client'
import React, { useState } from 'react'
interface Props {
result: {
vendor_name: string
documents: DocumentResult[]
findings: Finding[]
overall_score: number
category_scores: Record<string, number>
cross_check_findings: CrossCheckFinding[]
report_html: string
checked_at: string
}
onReset: () => void
}
interface DocumentResult {
label: string
doc_type: string
completeness_pct: number
correctness_pct: number
checks: Check[]
findings_count: number
error: string
}
interface Check {
id: string
label: string
passed: boolean
severity: string
level: number
parent: string | null
skipped: boolean
hint: string
}
interface Finding {
id: string
category: string
severity: string
type: string
title: string
description: string
document_label: string
document_type: string
}
interface CrossCheckFinding {
id: string
label: string
severity: string
hint: string
}
const CAT_LABELS: Record<string, string> = {
INSTRUCTION: 'Weisungsgebundenheit',
CONFIDENTIALITY: 'Vertraulichkeit',
TOM: 'TOM (Art. 32)',
SUBPROCESSOR: 'Unterauftragsverarbeitung',
DATA_SUBJECT_RIGHTS: 'Betroffenenrechte',
DELETION: 'Loeschung/Rueckgabe',
AUDIT_RIGHTS: 'Audit-/Inspektionsrechte',
INCIDENT: 'Datenschutzverletzungen',
TRANSFER: 'Drittlandtransfer',
LIABILITY: 'Haftung',
AVV_CONTENT: 'AVV Inhalt',
}
const SEV_COLORS: Record<string, string> = {
CRITICAL: 'bg-red-100 text-red-700 border-red-200',
HIGH: 'bg-orange-100 text-orange-700 border-orange-200',
MEDIUM: 'bg-yellow-100 text-yellow-700 border-yellow-200',
LOW: 'bg-green-100 text-green-700 border-green-200',
}
function scoreColor(s: number) {
if (s >= 80) return 'text-green-600'
if (s >= 50) return 'text-yellow-600'
return 'text-red-600'
}
function scoreBg(s: number) {
if (s >= 80) return 'bg-green-50 border-green-200'
if (s >= 50) return 'bg-yellow-50 border-yellow-200'
return 'bg-red-50 border-red-200'
}
function verdict(s: number) {
if (s >= 80) return 'Bestanden'
if (s >= 50) return 'Bedingt bestanden'
return 'Nicht bestanden'
}
export function PruefprotokollView({ result, onReset }: Props) {
const [expandedDoc, setExpandedDoc] = useState<number | null>(null)
const [showHtml, setShowHtml] = useState(false)
const criticalCount = result.findings.filter(f => f.severity === 'CRITICAL').length
+ result.cross_check_findings.filter(f => f.severity === 'CRITICAL').length
const totalFindings = result.findings.length + result.cross_check_findings.length
return (
<div className="space-y-6">
{/* Score Overview */}
<div className={`rounded-xl border-2 p-8 text-center ${scoreBg(result.overall_score)}`}>
<div className="text-sm text-gray-500 mb-1">Pruefprotokoll {result.vendor_name}</div>
<div className={`text-6xl font-extrabold ${scoreColor(result.overall_score)}`}>
{result.overall_score}%
</div>
<div className="text-lg text-gray-600 mt-1">{verdict(result.overall_score)}</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-white rounded-xl border p-5 text-center">
<div className="text-3xl font-bold text-gray-900">{result.documents.length}</div>
<div className="text-xs text-gray-500 mt-1 uppercase tracking-wide">Dokumente</div>
</div>
<div className="bg-white rounded-xl border p-5 text-center">
<div className="text-3xl font-bold text-gray-900">{totalFindings}</div>
<div className="text-xs text-gray-500 mt-1 uppercase tracking-wide">Findings</div>
</div>
<div className="bg-white rounded-xl border p-5 text-center">
<div className="text-3xl font-bold text-red-600">{criticalCount}</div>
<div className="text-xs text-gray-500 mt-1 uppercase tracking-wide">Kritisch</div>
</div>
</div>
{/* Category Scores */}
{Object.keys(result.category_scores).length > 0 && (
<div className="bg-white rounded-xl border p-5">
<h3 className="font-semibold text-gray-800 mb-4">Kategorie-Uebersicht</h3>
<div className="space-y-3">
{Object.entries(result.category_scores)
.sort(([, a], [, b]) => a - b)
.map(([cat, score]) => (
<div key={cat} className="flex items-center gap-3">
<span className="text-sm text-gray-700 w-48 truncate">{CAT_LABELS[cat] || cat}</span>
<div className="flex-1 h-3 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${score >= 80 ? 'bg-green-500' : score >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${score}%` }}
/>
</div>
<span className={`text-sm font-bold w-12 text-right ${scoreColor(score)}`}>{score}%</span>
</div>
))}
</div>
</div>
)}
{/* Cross-Check Findings */}
{result.cross_check_findings.length > 0 && (
<div className="bg-white rounded-xl border p-5">
<h3 className="font-semibold text-gray-800 mb-4">
Dokumenten-Cross-Check
<span className="ml-2 text-sm font-normal text-gray-400">{result.cross_check_findings.length} Findings</span>
</h3>
<div className="space-y-2">
{result.cross_check_findings.map(f => (
<div key={f.id} className={`border-l-4 rounded-r-lg p-3 ${
f.severity === 'CRITICAL' ? 'border-red-500 bg-red-50' :
f.severity === 'HIGH' ? 'border-orange-500 bg-orange-50' :
'border-yellow-500 bg-yellow-50'
}`}>
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded text-xs font-bold border ${SEV_COLORS[f.severity] || ''}`}>
{f.severity}
</span>
<span className="text-sm font-medium text-gray-900">{f.label}</span>
</div>
<p className="text-xs text-gray-600 mt-1 leading-relaxed">{f.hint}</p>
</div>
))}
</div>
</div>
)}
{/* Documents Detail */}
<div className="bg-white rounded-xl border p-5">
<h3 className="font-semibold text-gray-800 mb-4">Gepruefte Dokumente</h3>
<div className="space-y-3">
{result.documents.map((doc, i) => (
<div key={i} className="border border-gray-200 rounded-lg overflow-hidden">
<button
onClick={() => setExpandedDoc(expandedDoc === i ? null : i)}
className="w-full text-left p-4 hover:bg-gray-50 flex items-center justify-between"
>
<div className="flex items-center gap-3">
<span className="px-2 py-0.5 bg-indigo-100 text-indigo-700 rounded text-xs font-bold">
{doc.doc_type.toUpperCase()}
</span>
<span className="font-medium text-gray-900 text-sm">{doc.label}</span>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<span className={`text-sm font-bold ${scoreColor(doc.completeness_pct)}`}>
{doc.completeness_pct}%
</span>
<span className="text-xs text-gray-400 ml-1">vollstaendig</span>
</div>
{doc.findings_count > 0 && (
<span className="px-2 py-0.5 bg-red-100 text-red-700 rounded-full text-xs font-bold">
{doc.findings_count}
</span>
)}
<span className="text-gray-400 text-sm">{expandedDoc === i ? '▲' : '▼'}</span>
</div>
</button>
{expandedDoc === i && (
<div className="border-t p-4 bg-gray-50">
{doc.error ? (
<p className="text-red-600 text-sm">{doc.error}</p>
) : (
<div className="space-y-1">
{doc.checks.filter(c => c.level === 1).map(c => {
const l2s = doc.checks.filter(l => l.level === 2 && l.parent === c.id)
return (
<div key={c.id}>
<div className={`flex items-center gap-2 py-1 ${!c.passed && !c.skipped ? 'text-red-700' : ''}`}>
<span>{c.passed ? '✓' : c.skipped ? '—' : '✗'}</span>
<span className="text-sm">{c.label}</span>
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold ${SEV_COLORS[c.severity] || ''}`}>
{c.severity}
</span>
</div>
{!c.passed && !c.skipped && c.hint && (
<div className="ml-6 mb-1 text-xs text-red-600 bg-red-50 border-l-2 border-red-300 pl-2 py-1">
{c.hint}
</div>
)}
{l2s.map(l2 => (
<div key={l2.id} className={`ml-6 flex items-center gap-2 py-0.5 text-xs ${!l2.passed ? 'text-red-600' : 'text-gray-500'}`}>
<span>{l2.passed ? '✓' : l2.skipped ? '—' : '✗'}</span>
<span>{l2.label}</span>
</div>
))}
</div>
)
})}
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
{/* Findings Detail */}
{result.findings.length > 0 && (
<div className="bg-white rounded-xl border p-5">
<h3 className="font-semibold text-gray-800 mb-4">
Alle Findings
<span className="ml-2 text-sm font-normal text-gray-400">{result.findings.length}</span>
</h3>
<div className="space-y-2">
{[...result.findings]
.sort((a, b) => {
const order: Record<string, number> = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
return (order[a.severity] ?? 4) - (order[b.severity] ?? 4)
})
.map(f => (
<div key={f.id} className="border border-gray-200 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 rounded text-xs font-bold border ${SEV_COLORS[f.severity] || ''}`}>
{f.severity}
</span>
<span className="text-sm font-medium text-gray-900">{f.title}</span>
</div>
<div className="text-xs text-gray-400 mb-1">
{CAT_LABELS[f.category] || f.category} | {f.document_label}
</div>
{f.description && (
<p className="text-xs text-gray-600 leading-relaxed">{f.description}</p>
)}
</div>
))}
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-3">
<button
onClick={onReset}
className="px-5 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 text-sm font-medium"
>
Neue Pruefung
</button>
{result.report_html && (
<button
onClick={() => setShowHtml(!showHtml)}
className="px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium"
>
{showHtml ? 'Protokoll ausblenden' : 'Pruefprotokoll (Druckversion)'}
</button>
)}
</div>
{/* Print-ready HTML report */}
{showHtml && result.report_html && (
<div className="bg-white rounded-xl border p-6 mt-4">
<div dangerouslySetInnerHTML={{ __html: result.report_html }} />
</div>
)}
</div>
)
}
@@ -0,0 +1,143 @@
'use client'
import React, { useState, useCallback } from 'react'
import { DocumentUploader } from './_components/DocumentUploader'
import { AssessmentProgress } from './_components/AssessmentProgress'
import { PruefprotokollView } from './_components/PruefprotokollView'
type View = 'upload' | 'progress' | 'result'
interface AssessmentResult {
vendor_name: string
documents: DocumentResult[]
findings: Finding[]
overall_score: number
category_scores: Record<string, number>
cross_check_findings: CrossCheckFinding[]
report_html: string
checked_at: string
}
interface DocumentResult {
label: string
url: string
doc_type: string
word_count: number
completeness_pct: number
correctness_pct: number
checks: Check[]
findings_count: number
error: string
}
interface Check {
id: string
label: string
passed: boolean
severity: string
level: number
parent: string | null
skipped: boolean
hint: string
matched_text: string
}
interface Finding {
id: string
category: string
severity: string
type: string
title: string
description: string
recommendation: string
document_label: string
document_type: string
}
interface CrossCheckFinding {
id: string
label: string
severity: string
hint: string
}
const BACKEND_URL = process.env.NEXT_PUBLIC_COMPLIANCE_API_URL || ''
export default function VendorAssessmentPage() {
const [view, setView] = useState<View>('upload')
const [assessmentId, setAssessmentId] = useState<string>('')
const [result, setResult] = useState<AssessmentResult | null>(null)
const [error, setError] = useState('')
const handleStartAssessment = useCallback(async (
vendorName: string,
documents: Array<{ doc_type: string; label: string; url: string }>,
) => {
setError('')
try {
const res = await fetch(`${BACKEND_URL}/api/vendor-compliance/assessments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vendor_name: vendorName, documents }),
})
if (!res.ok) throw new Error(await res.text())
const data = await res.json()
setAssessmentId(data.assessment_id)
setView('progress')
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Starten')
}
}, [])
const handleComplete = useCallback((data: AssessmentResult) => {
setResult(data)
setView('result')
}, [])
const handleReset = useCallback(() => {
setView('upload')
setAssessmentId('')
setResult(null)
setError('')
}, [])
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-5xl mx-auto px-4">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Vertragspruefung</h1>
<p className="text-gray-600 mt-2">
Automatisierte Pruefung von Auftragsverarbeitungsvertraegen gem. Art. 28 DSGVO
</p>
</div>
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-red-700 text-sm">{error}</p>
<button onClick={() => setError('')} className="text-xs text-red-500 mt-1 underline">
Schliessen
</button>
</div>
)}
{view === 'upload' && (
<DocumentUploader onStart={handleStartAssessment} />
)}
{view === 'progress' && assessmentId && (
<AssessmentProgress
assessmentId={assessmentId}
backendUrl={BACKEND_URL}
onComplete={handleComplete}
onError={(msg) => { setError(msg); setView('upload') }}
/>
)}
{view === 'result' && result && (
<PruefprotokollView result={result} onReset={handleReset} />
)}
</div>
</div>
)
}
@@ -96,15 +96,15 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
projectId={projectId} projectId={projectId}
/> />
<AdditionalModuleItem <AdditionalModuleItem
href="/sdk/use-case-audit" href="/sdk/vendor-assessment"
icon={ icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" /> d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg> </svg>
} }
label="Use-Case Audits" label="Vertragspruefung"
isActive={pathname?.startsWith('/sdk/use-case-audit') ?? false} isActive={pathname?.startsWith('/sdk/vendor-assessment') ?? false}
collapsed={collapsed} collapsed={collapsed}
projectId={projectId} projectId={projectId}
/> />
@@ -26,6 +26,9 @@ from compliance.services.dsi_document_checker import (
from compliance.services.vendor_assessment_cross_check import ( from compliance.services.vendor_assessment_cross_check import (
cross_check_documents, cross_check_documents,
) )
from compliance.services.vendor_assessment_report import (
build_pruefprotokoll,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -84,6 +87,7 @@ class AssessmentResult(BaseModel):
overall_score: int = 0 overall_score: int = 0
category_scores: dict[str, int] = {} category_scores: dict[str, int] = {}
cross_check_findings: list[dict] = [] cross_check_findings: list[dict] = []
report_html: str = ""
checked_at: str = "" checked_at: str = ""
@@ -320,6 +324,12 @@ async def _run_assessment(assessment_id: str, req: AssessmentRequest):
checked_at=datetime.now(timezone.utc).isoformat(), checked_at=datetime.now(timezone.utc).isoformat(),
) )
# 8. Generate Pruefprotokoll HTML
try:
result.report_html = build_pruefprotokoll(result.model_dump())
except Exception as e:
logger.warning("Report generation failed: %s", e)
job["status"] = "completed" job["status"] = "completed"
job["progress"] = "" job["progress"] = ""
job["result"] = result job["result"] = result
@@ -0,0 +1,309 @@
"""
Vendor Assessment Pruefprotokoll — HTML report builder.
Generates a professional assessment report styled like a real DSB
Pruefprotokoll for vendor contract analysis (Art. 28 DSGVO).
"""
from datetime import datetime, timezone
def build_pruefprotokoll(result: dict) -> str:
"""Build HTML Pruefprotokoll from assessment result."""
vendor = result.get("vendor_name", "Unbekannt")
docs = result.get("documents", [])
findings = result.get("findings", [])
cross = result.get("cross_check_findings", [])
cat_scores = result.get("category_scores", {})
overall = result.get("overall_score", 0)
checked_at = result.get("checked_at", datetime.now(timezone.utc).isoformat())
verdict = _verdict(overall)
now_str = _format_date(checked_at)
protocol_nr = f"VP-{datetime.now().strftime('%Y')}-{abs(hash(vendor)) % 10000:04d}"
html = [_style(), '<div class="report">']
# ── 1. Kopfdaten ────────────────────────────────────────────────
html.append(f'''
<div class="header">
<h1>Pruefprotokoll</h1>
<h2>Auftragsverarbeitung gem. Art. 28 DSGVO</h2>
</div>
<table class="meta">
<tr><td class="label">Protokoll-Nr.</td><td>{protocol_nr}</td></tr>
<tr><td class="label">Pruefungsdatum</td><td>{now_str}</td></tr>
<tr><td class="label">Auftragsverarbeiter</td><td><strong>{vendor}</strong></td></tr>
<tr><td class="label">Pruefungsumfang</td><td>{len(docs)} Dokument(e)</td></tr>
<tr><td class="label">Pruefer</td><td>Automatisierte Pruefung (BreakPilot Compliance SDK)</td></tr>
<tr><td class="label">Freigabe</td><td><em>Ausstehend</em></td></tr>
</table>''')
# ── 2. Zusammenfassung ──────────────────────────────────────────
critical_count = sum(1 for f in findings if _get(f, "severity") == "CRITICAL")
critical_count += sum(1 for f in cross if f.get("severity") == "CRITICAL")
total_findings = len(findings) + len(cross)
html.append(f'''
<div class="score-box {verdict["class"]}">
<div class="score-value">{overall}%</div>
<div class="score-label">{verdict["label"]}</div>
</div>
<div class="summary-row">
<div class="stat"><span class="stat-value">{len(docs)}</span><span class="stat-label">Dokumente</span></div>
<div class="stat"><span class="stat-value">{total_findings}</span><span class="stat-label">Findings</span></div>
<div class="stat"><span class="stat-value red">{critical_count}</span><span class="stat-label">Kritisch</span></div>
</div>''')
# ── Kategorie-Scores ────────────────────────────────────────────
if cat_scores:
html.append('<h3>Kategorie-Uebersicht</h3><table class="cat-table">')
html.append('<tr><th>Kategorie</th><th>Score</th><th>Status</th></tr>')
for cat, score in sorted(cat_scores.items(), key=lambda x: x[1]):
status = _cat_status(score)
html.append(f'''<tr>
<td>{_cat_label(cat)}</td>
<td>{_bar(score)}</td>
<td><span class="badge {status["class"]}">{status["label"]}</span></td>
</tr>''')
html.append('</table>')
# ── 3. Gepruefte Dokumente ──────────────────────────────────────
html.append('<h3>Gepruefte Dokumente</h3>')
for i, doc in enumerate(docs):
_render_document(html, doc, i + 1)
# ── 4. Cross-Check Findings ─────────────────────────────────────
if cross:
html.append('<h3>Dokumenten-Cross-Check</h3>')
for f in cross:
sev = f.get("severity", "MEDIUM")
html.append(f'''<div class="finding {sev.lower()}">
<span class="sev-badge {sev.lower()}">{sev}</span>
<strong>{f.get("label", "")}</strong>
<p class="hint">{f.get("hint", "")}</p>
</div>''')
# ── 5. Findings ─────────────────────────────────────────────────
if findings:
html.append('<h3>Findings (sortiert nach Schweregrad)</h3>')
sorted_findings = sorted(findings,
key=lambda f: {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}.get(
_get(f, "severity"), 4))
for f in sorted_findings:
sev = _get(f, "severity")
html.append(f'''<div class="finding {sev.lower()}">
<span class="sev-badge {sev.lower()}">{sev}</span>
<strong>{_get(f, "title")}</strong>
<div class="finding-meta">{_get(f, "category")} | {_get(f, "document_label")}</div>
<p class="hint">{_get(f, "description")}</p>
</div>''')
# ── 6. Freigabe-Block ───────────────────────────────────────────
html.append(f'''
<div class="approval-block">
<h3>Freigabe</h3>
<div class="approval-options">
<label><input type="checkbox" disabled> Pruefprotokoll wird freigegeben</label>
<label><input type="checkbox" disabled> Pruefprotokoll wird mit Auflagen freigegeben</label>
<label><input type="checkbox" disabled> Pruefprotokoll wird abgelehnt</label>
</div>
<div class="signature">
<div>Datum: _______________</div>
<div>Unterschrift DSB: _______________</div>
</div>
</div>''')
html.append('</div>')
return "\n".join(html)
def _render_document(html: list[str], doc: dict, num: int):
"""Render a single document section with L1/L2 checks."""
label = doc.get("label", "Dokument")
dtype = doc.get("doc_type", "").upper()
comp = doc.get("completeness_pct", 0)
corr = doc.get("correctness_pct", 0)
error = doc.get("error", "")
checks = doc.get("checks", [])
html.append(f'''<div class="doc-section">
<h4>{num}. {label} <span class="doc-type">{dtype}</span></h4>''')
if error:
html.append(f'<div class="error">{error}</div></div>')
return
html.append(f'''
<div class="doc-scores">
<span>Vollstaendigkeit: {_bar(comp)}</span>
<span>Korrektheit: {_bar(corr)}</span>
</div>''')
# L1/L2 hierarchy
l1_checks = [c for c in checks if c.get("level") == 1]
l2_by_parent = {}
for c in checks:
if c.get("level") == 2 and c.get("parent"):
l2_by_parent.setdefault(c["parent"], []).append(c)
if l1_checks:
html.append('<table class="check-table">')
for c in l1_checks:
passed = c.get("passed", False)
skipped = c.get("skipped", False)
icon = _icon(passed, skipped)
sev = c.get("severity", "")
html.append(f'''<tr class="l1 {'pass' if passed else 'fail' if not skipped else 'skip'}">
<td class="icon">{icon}</td>
<td>{c.get("label", "")}</td>
<td><span class="sev-badge {sev.lower()}">{sev}</span></td>
</tr>''')
if not passed and not skipped and c.get("hint"):
html.append(f'<tr><td></td><td colspan="2" class="hint-cell">{c["hint"]}</td></tr>')
for l2 in l2_by_parent.get(c.get("id", ""), []):
l2_passed = l2.get("passed", False)
l2_icon = _icon(l2_passed, l2.get("skipped", False))
html.append(f'''<tr class="l2 {'pass' if l2_passed else 'fail'}">
<td class="icon">{l2_icon}</td>
<td style="padding-left:24px">{l2.get("label", "")}</td>
<td></td>
</tr>''')
html.append('</table>')
html.append('</div>')
# ── Helpers ─────────────────────────────────────────────────────────
def _get(obj, key, default=""):
"""Get from dict or Pydantic model."""
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
def _verdict(score: int) -> dict:
if score >= 80:
return {"label": "Bestanden", "class": "green"}
if score >= 50:
return {"label": "Bedingt bestanden — Nachbesserung erforderlich", "class": "yellow"}
return {"label": "Nicht bestanden", "class": "red"}
def _cat_status(score: int) -> dict:
if score >= 80:
return {"label": "Bestanden", "class": "green"}
if score >= 50:
return {"label": "Teilweise", "class": "yellow"}
return {"label": "Mangelhaft", "class": "red"}
_CAT_LABELS = {
"INSTRUCTION": "Weisungsgebundenheit (Art. 28(3)(a))",
"CONFIDENTIALITY": "Vertraulichkeit (Art. 28(3)(b))",
"TOM": "Technische/Org. Massnahmen (Art. 32)",
"SUBPROCESSOR": "Unterauftragsverarbeitung (Art. 28(3)(d))",
"DATA_SUBJECT_RIGHTS": "Betroffenenrechte (Art. 28(3)(e))",
"DELETION": "Loeschung/Rueckgabe (Art. 28(3)(g))",
"AUDIT_RIGHTS": "Audit-/Inspektionsrechte (Art. 28(3)(h))",
"INCIDENT": "Datenschutzverletzungen (Art. 33)",
"TRANSFER": "Drittlandtransfer (Art. 44-49)",
"LIABILITY": "Haftung (Art. 82)",
"AVV_CONTENT": "AVV Inhalt (Art. 28(3))",
"GENERAL": "Allgemein",
}
def _cat_label(cat: str) -> str:
return _CAT_LABELS.get(cat, cat)
def _bar(pct: int) -> str:
color = "#22c55e" if pct >= 80 else "#eab308" if pct >= 50 else "#ef4444"
return (
f'<div style="display:inline-block;width:100px;height:8px;background:#e5e7eb;'
f'border-radius:4px;overflow:hidden;vertical-align:middle;margin-right:6px">'
f'<div style="width:{pct}%;height:100%;background:{color};border-radius:4px"></div>'
f'</div><span style="font-weight:600;color:{color}">{pct}%</span>'
)
def _icon(passed: bool, skipped: bool = False) -> str:
if skipped:
return '<span style="color:#d1d5db">&mdash;</span>'
if passed:
return '<span style="color:#22c55e;font-weight:bold">&#10003;</span>'
return '<span style="color:#ef4444;font-weight:bold">&#10007;</span>'
def _format_date(iso: str) -> str:
try:
dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
return dt.strftime("%d.%m.%Y %H:%M")
except (ValueError, AttributeError):
return datetime.now().strftime("%d.%m.%Y %H:%M")
def _style() -> str:
return '''<style>
.report { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 800px; margin: 0 auto; color: #1f2937; }
.header { text-align: center; border-bottom: 2px solid #1f2937; padding-bottom: 12px; margin-bottom: 20px; }
.header h1 { margin: 0; font-size: 24px; }
.header h2 { margin: 4px 0 0; font-size: 14px; color: #6b7280; font-weight: normal; }
.meta { width: 100%; margin-bottom: 24px; border-collapse: collapse; }
.meta td { padding: 4px 12px; font-size: 13px; border-bottom: 1px solid #f3f4f6; }
.meta .label { font-weight: 600; color: #6b7280; width: 180px; }
.score-box { text-align: center; padding: 20px; border-radius: 12px; margin: 16px 0; }
.score-box.green { background: #f0fdf4; border: 2px solid #86efac; }
.score-box.yellow { background: #fefce8; border: 2px solid #fde047; }
.score-box.red { background: #fef2f2; border: 2px solid #fca5a5; }
.score-value { font-size: 48px; font-weight: 800; }
.green .score-value { color: #16a34a; }
.yellow .score-value { color: #ca8a04; }
.red .score-value { color: #dc2626; }
.score-label { font-size: 14px; color: #6b7280; margin-top: 4px; }
.summary-row { display: flex; gap: 16px; margin: 16px 0 24px; }
.stat { flex: 1; text-align: center; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; }
.stat-value { display: block; font-size: 28px; font-weight: 700; color: #1f2937; }
.stat-value.red { color: #dc2626; }
.stat-label { display: block; font-size: 11px; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.5px; }
h3 { font-size: 16px; margin: 28px 0 12px; padding-bottom: 6px; border-bottom: 1px solid #e5e7eb; }
.cat-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.cat-table th { text-align: left; padding: 6px 8px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; font-weight: 600; color: #6b7280; }
.cat-table td { padding: 8px; border-bottom: 1px solid #f3f4f6; }
.badge { padding: 2px 8px; border-radius: 9999px; font-size: 11px; font-weight: 600; }
.badge.green { background: #dcfce7; color: #16a34a; }
.badge.yellow { background: #fef9c3; color: #ca8a04; }
.badge.red { background: #fee2e2; color: #dc2626; }
.doc-section { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; margin: 12px 0; }
.doc-section h4 { margin: 0 0 8px; font-size: 14px; }
.doc-type { background: #e0e7ff; color: #4338ca; padding: 1px 6px; border-radius: 4px; font-size: 11px; font-weight: 600; }
.doc-scores { display: flex; gap: 24px; margin-bottom: 12px; font-size: 13px; }
.error { color: #dc2626; font-size: 13px; }
.check-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.check-table td { padding: 4px 6px; }
.check-table .icon { width: 20px; text-align: center; }
.l1 { font-weight: 500; }
.l1.fail td { background: #fef2f2; }
.l2 { color: #6b7280; }
.hint-cell { font-size: 11px; color: #dc2626; padding: 2px 6px 8px 26px !important; background: #fef2f2; border-left: 3px solid #fca5a5; }
.sev-badge { padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 700; }
.sev-badge.critical { background: #fee2e2; color: #dc2626; }
.sev-badge.high { background: #ffedd5; color: #ea580c; }
.sev-badge.medium { background: #fef9c3; color: #ca8a04; }
.sev-badge.low { background: #dcfce7; color: #16a34a; }
.finding { border-left: 4px solid; padding: 10px 14px; margin: 8px 0; border-radius: 0 8px 8px 0; background: #fff; }
.finding.critical { border-color: #dc2626; background: #fef2f2; }
.finding.high { border-color: #ea580c; background: #fff7ed; }
.finding.medium { border-color: #ca8a04; background: #fefce8; }
.finding.low { border-color: #16a34a; background: #f0fdf4; }
.finding-meta { font-size: 11px; color: #9ca3af; margin: 2px 0; }
.hint { font-size: 12px; color: #4b5563; margin: 6px 0 0; line-height: 1.5; }
.approval-block { margin-top: 32px; padding: 20px; border: 2px dashed #d1d5db; border-radius: 8px; }
.approval-options label { display: block; margin: 8px 0; font-size: 13px; }
.signature { display: flex; gap: 40px; margin-top: 24px; font-size: 13px; color: #6b7280; }
</style>'''