d720db07dd
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>
103 lines
3.8 KiB
TypeScript
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>
|
|
)
|
|
}
|