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
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:
@@ -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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user