Files
breakpilot-compliance/admin-compliance/app/sdk/agent/_components/AuditReportTab.tsx
T
Benjamin Admin d720db07dd feat(audit-report): deterministischer Textreport je Audit (MD + PDF) + Bericht-Tab
Firmen-tauglicher Bericht aus den Snapshot-Modulergebnissen (kein Re-Crawl, kein
LLM): Einleitung, Testumfang+Methodik, Management-Summary (4-Status), Detail-
befunde je Modul, Maßnahmen, Rechtlicher Hinweis. Co-Pilot-Tonalität, Tracking-
statt Cookie-Rohzahl, Norm nur referenziert (kein Normtext).
- audit_report.py: assemble_report (pur) + render_markdown + render_pdf (reportlab)
- snapshot_check_routes: GET /report (struktur+md) + GET /report.pdf
- Frontend: AuditReportTab + Proxys (report, report/pdf) + "Bericht"-Tab
- Tests: 5 Assembler (compliance/tests → CI-geprüft) + 1 Vitest

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-13 14:50:45 +02:00

103 lines
3.8 KiB
TypeScript

'use client'
/**
* AuditReportTab — rendert den deterministischen Audit-Textreport eines
* Snapshots (Sektionen aus /report, kein Re-Crawl) + Download als PDF/Markdown.
* Bewusst ohne Markdown-Lib + ohne dangerouslySetInnerHTML (Befundtexte können
* Site-Inhalte enthalten → XSS-sicher über React-Textknoten).
*/
import React, { useEffect, useState } from 'react'
type Section = { title: string; level: number; body?: string }
type Report = { meta?: Record<string, unknown>; sections?: Section[]; totals?: Record<string, unknown> }
function Inline({ text }: { text: string }) {
// **fett** sicher rendern; _kursiv_-Marker entfernen.
const parts = text.split(/\*\*(.+?)\*\*/g)
return <>{parts.map((p, i) => (i % 2
? <strong key={i}>{p}</strong>
: <React.Fragment key={i}>{p.replace(/_/g, '')}</React.Fragment>))}</>
}
function Body({ body }: { body: string }) {
const out: React.ReactNode[] = []
let bullets: string[] = []
const flush = (k: string) => {
if (bullets.length) {
const items = bullets
out.push(<ul key={'u' + k} className="list-disc ml-5 space-y-1">
{items.map((b, j) => <li key={j} className="text-sm text-gray-700"><Inline text={b} /></li>)}
</ul>)
bullets = []
}
}
body.split('\n').map(l => l.trim()).filter(Boolean).forEach((l, i) => {
if (l.startsWith('- ')) { bullets.push(l.slice(2)) }
else { flush('p' + i); out.push(<p key={i} className="text-sm text-gray-700"><Inline text={l} /></p>) }
})
flush('end')
return <div className="space-y-1.5">{out}</div>
}
export function AuditReportTab({ snapshotId }: { snapshotId: string }) {
const [rep, setRep] = useState<Report | null>(null)
const [md, setMd] = useState('')
const [loading, setLoading] = useState(true)
const [err, setErr] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
fetch(`/api/sdk/v1/agent/snapshots/${snapshotId}/report`)
.then(r => r.json())
.then(d => {
if (cancelled) return
if (d?.error) setErr(d.error)
else { setRep(d.report); setMd(d.markdown || '') }
})
.catch(e => { if (!cancelled) setErr(String(e)) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [snapshotId])
const downloadMd = () => {
const blob = new Blob([md], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = 'audit-report.md'; a.click()
URL.revokeObjectURL(url)
}
if (loading) return <div className="text-sm text-gray-500">Bericht wird erstellt</div>
if (err || !rep) return <div className="text-sm text-red-600">{err || 'Kein Bericht verfügbar.'}</div>
return (
<div className="space-y-4">
<div className="flex gap-2 flex-wrap">
<a href={`/api/sdk/v1/agent/snapshots/${snapshotId}/report/pdf`}
target="_blank" rel="noopener"
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700">
PDF herunterladen
</a>
<button onClick={downloadMd}
className="px-3 py-1.5 text-sm rounded-lg border border-blue-200 text-blue-700 hover:bg-blue-50">
Markdown herunterladen
</button>
</div>
<div className="border border-gray-200 rounded-xl p-5 space-y-3 bg-white">
{(rep.sections || []).map((s, i) => s.level <= 2 ? (
<div key={i} className="space-y-1.5">
<h2 className="text-base font-semibold text-gray-900 border-b border-gray-100 pb-1">{s.title}</h2>
{s.body && <Body body={s.body} />}
</div>
) : (
<div key={i} className="space-y-1 ml-1">
<h3 className="text-sm font-semibold text-gray-800">{s.title}</h3>
{s.body && <Body body={s.body} />}
</div>
))}
</div>
</div>
)
}