Bericht zusätzlich als PDF exportieren

Adds a "Als PDF exportieren" button to the scan report page next to the
existing JSON export, fulfilling Task #3.

Implementation:
- artifacts/skillguard/src/pages/scan-report.tsx
  - New primary button "Als PDF exportieren" (FileDown icon) grouped with
    the existing JSON export button in the report header.
  - handleExportPdf opens a new window, writes a self-contained print-
    friendly HTML document, and triggers window.print() (browser
    "Als PDF speichern"). No new dependencies added.
  - buildReportHtml(data) generates the document containing: title +
    verdict, metadata (date, source, file count, KI flag), optional AI
    warning, Risiko-Score with summary text, Achsen-Zusammenfassung table
    (severity + axis counts incl. total), all findings (severity/axis/
    rule/detection, location, description, snippet, remediation), and the
    checked files table.
  - All labels are German via VERDICT/SEVERITY/AXIS/SOURCE/KIND label maps;
    no emojis used; print-friendly inline CSS with page-break-inside avoid
    on findings.
  - User-provided content is escaped via escapeHtml to prevent HTML
    injection in the generated document.

Verification:
- pnpm --filter @workspace/skillguard typecheck passes.
- Created test scan (id 8) via API, confirmed button renders and German
  label mapping is correct for verdict/severity/axis.

Deviations: none. Chose the browser print-to-PDF approach (zero new deps)
over a client-side PDF library, which keeps the bundle lean and produces
selectable, print-friendly output.

Replit-Task-Id: e3f37193-89fd-42de-8cec-9383c8406b25
This commit is contained in:
amertensreplit 2026-06-10 13:57:05 +00:00
parent 9f7b67972f
commit 87d71c1dca
2 changed files with 213 additions and 5 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -11,7 +11,8 @@ import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { VerdictBadge, SeverityBadge, AxisBadge } from "@/components/ui-helpers"; import { VerdictBadge, SeverityBadge, AxisBadge } from "@/components/ui-helpers";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import { ShieldQuestion, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield } from "lucide-react"; import { ShieldQuestion, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown } from "lucide-react";
import type { ScanDetail } from "@workspace/api-client-react";
export default function ScanReport() { export default function ScanReport() {
const [, params] = useRoute("/berichte/:id"); const [, params] = useRoute("/berichte/:id");
@ -78,6 +79,19 @@ export default function ScanReport() {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
const handleExportPdf = () => {
const html = buildReportHtml(data);
const printWindow = window.open("", "_blank");
if (!printWindow) return;
printWindow.document.open();
printWindow.document.write(html);
printWindow.document.close();
printWindow.focus();
printWindow.onload = () => {
printWindow.print();
};
};
return ( return (
<div className="space-y-6 pb-12"> <div className="space-y-6 pb-12">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4"> <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
@ -100,10 +114,16 @@ export default function ScanReport() {
)} )}
</div> </div>
</div> </div>
<Button onClick={handleExport} variant="outline" className="gap-2"> <div className="flex flex-wrap gap-2">
<Download className="w-4 h-4" /> <Button onClick={handleExportPdf} variant="default" className="gap-2">
Bericht exportieren (JSON) <FileDown className="w-4 h-4" />
</Button> Als PDF exportieren
</Button>
<Button onClick={handleExport} variant="outline" className="gap-2">
<Download className="w-4 h-4" />
Bericht exportieren (JSON)
</Button>
</div>
</div> </div>
{data.aiError && ( {data.aiError && (
@ -337,3 +357,191 @@ export default function ScanReport() {
</div> </div>
); );
} }
const VERDICT_LABELS: Record<string, string> = {
pass: "Freigabe",
review: "Manuelle Prüfung",
block: "Blockieren",
};
const SEVERITY_LABELS: Record<string, string> = {
critical: "Kritisch",
high: "Hoch",
medium: "Mittel",
low: "Niedrig",
info: "Info",
};
const AXIS_LABELS: Record<string, string> = {
security: "IT-Sicherheit",
privacy: "Datenschutz",
};
const SOURCE_LABELS: Record<string, string> = {
upload: "Upload",
url: "URL",
paste: "Einfügung",
};
const KIND_LABELS: Record<string, string> = {
instruction: "Anweisung",
script: "Skript",
resource: "Ressource",
};
function escapeHtml(value: unknown): string {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function riskSummary(score: number): string {
if (score < 30) return "Geringes Risiko. Keine bedenklichen Muster gefunden.";
if (score < 70) return "Mittleres Risiko. Einige Auffälligkeiten erfordern Prüfung.";
return "Hohes Risiko. Kritische Sicherheitsprobleme erkannt.";
}
function buildReportHtml(data: ScanDetail): string {
const title = data.name || `Scan #${data.id}`;
const verdict = VERDICT_LABELS[data.verdict] ?? data.verdict;
const source = SOURCE_LABELS[data.source] ?? data.source;
const counts = data.findingCounts;
const findingsHtml = data.findings.length === 0
? `<p class="empty">Keine Auffälligkeiten gefunden. Der analysierte Skill entspricht den Sicherheits- und Datenschutzrichtlinien.</p>`
: data.findings.map((f, i) => {
const location = (f.file || f.line)
? `<div class="meta-line">Fundstelle: ${escapeHtml(f.file || "unbekannt")}${f.line ? `:${escapeHtml(f.line)}` : ""}</div>`
: "";
const snippet = f.snippet
? `<pre class="snippet">${escapeHtml(f.snippet)}</pre>`
: "";
const remediation = f.remediation
? `<div class="remediation"><strong>Empfehlung:</strong> ${escapeHtml(f.remediation)}</div>`
: "";
return `
<div class="finding sev-${escapeHtml(f.severity)}">
<div class="finding-head">
<span class="finding-num">${i + 1}.</span>
<span class="finding-title">${escapeHtml(f.title)}</span>
</div>
<div class="badges">
<span class="tag">Schweregrad: ${escapeHtml(SEVERITY_LABELS[f.severity] ?? f.severity)}</span>
<span class="tag">Bereich: ${escapeHtml(AXIS_LABELS[f.axis] ?? f.axis)}</span>
<span class="tag">Regel: ${escapeHtml(f.ruleId)}</span>
<span class="tag">Erkennung: ${f.detectedBy === "ai" ? "KI" : "Statisch"}</span>
</div>
${location}
<p class="finding-desc">${escapeHtml(f.description)}</p>
${snippet}
${remediation}
</div>`;
}).join("");
const aiWarning = data.aiError
? `<div class="warning">KI-Analyse nicht durchgeführt: ${escapeHtml(data.aiError)}. Die statische Analyse wurde dennoch abgeschlossen.</div>`
: "";
return `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<title>SkillGuard Bericht - ${escapeHtml(title)}</title>
<style>
* { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; color: #1e293b; margin: 32px; font-size: 12px; line-height: 1.5; }
h1 { font-size: 22px; margin: 0 0 4px; }
h2 { font-size: 15px; margin: 24px 0 8px; border-bottom: 1px solid #cbd5e1; padding-bottom: 4px; }
.subtitle { color: #475569; margin: 0 0 16px; font-size: 12px; }
.verdict { display: inline-block; padding: 2px 10px; border: 1px solid #475569; border-radius: 4px; font-weight: bold; }
.summary-grid { display: flex; gap: 16px; margin: 12px 0; flex-wrap: wrap; }
.score-box { border: 1px solid #cbd5e1; border-radius: 6px; padding: 12px 18px; text-align: center; min-width: 120px; }
.score-num { font-size: 32px; font-weight: bold; }
.score-label { color: #64748b; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
table { width: 100%; border-collapse: collapse; margin: 8px 0; }
th, td { border: 1px solid #cbd5e1; padding: 6px 8px; text-align: left; }
th { background: #f1f5f9; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
.warning { border: 1px solid #f59e0b; background: #fffbeb; padding: 8px 12px; border-radius: 4px; margin: 12px 0; }
.finding { border: 1px solid #cbd5e1; border-left-width: 4px; border-radius: 4px; padding: 10px 12px; margin: 10px 0; page-break-inside: avoid; }
.finding.sev-critical { border-left-color: #e11d48; }
.finding.sev-high { border-left-color: #f97316; }
.finding.sev-medium { border-left-color: #f59e0b; }
.finding.sev-low { border-left-color: #3b82f6; }
.finding.sev-info { border-left-color: #94a3b8; }
.finding-head { font-size: 14px; font-weight: bold; margin-bottom: 6px; }
.finding-num { color: #64748b; margin-right: 4px; }
.badges { margin: 4px 0 8px; }
.tag { display: inline-block; background: #f1f5f9; border: 1px solid #cbd5e1; border-radius: 3px; padding: 1px 6px; margin: 0 4px 4px 0; font-size: 11px; }
.meta-line { color: #475569; font-family: "Courier New", monospace; font-size: 11px; margin-bottom: 6px; }
.finding-desc { margin: 6px 0; }
.snippet { background: #0f172a; color: #f1f5f9; padding: 8px 10px; border-radius: 4px; font-family: "Courier New", monospace; font-size: 11px; white-space: pre-wrap; word-break: break-word; }
.remediation { background: #ecfdf5; border: 1px solid #a7f3d0; border-radius: 4px; padding: 8px 10px; margin-top: 8px; }
.empty { color: #475569; }
.footer { margin-top: 24px; color: #94a3b8; font-size: 10px; border-top: 1px solid #e2e8f0; padding-top: 8px; }
@media print { body { margin: 0; } }
</style>
</head>
<body>
<h1>SkillGuard Sicherheitsbericht</h1>
<p class="subtitle">
${escapeHtml(title)} &nbsp;|&nbsp; <span class="verdict">${escapeHtml(verdict)}</span><br />
Erstellt am ${escapeHtml(formatDate(data.createdAt))} &nbsp;|&nbsp; Quelle: ${escapeHtml(source)} &nbsp;|&nbsp; ${escapeHtml(data.fileCount)} ${data.fileCount === 1 ? "Datei" : "Dateien"}${data.aiUsed ? " &nbsp;|&nbsp; KI-Analyse aktiv" : ""}
</p>
${aiWarning}
<h2>Risiko-Score</h2>
<div class="summary-grid">
<div class="score-box">
<div class="score-num">${escapeHtml(data.riskScore)}</div>
<div class="score-label">/ 100</div>
</div>
<div style="flex:1; min-width:200px; align-self:center;">${riskSummary(data.riskScore)}</div>
</div>
<h2>Achsen-Zusammenfassung</h2>
<table>
<thead>
<tr><th>Kennzahl</th><th>Anzahl</th></tr>
</thead>
<tbody>
<tr><td>Kritisch</td><td class="num">${escapeHtml(counts.critical)}</td></tr>
<tr><td>Hoch</td><td class="num">${escapeHtml(counts.high)}</td></tr>
<tr><td>Mittel</td><td class="num">${escapeHtml(counts.medium)}</td></tr>
<tr><td>Niedrig</td><td class="num">${escapeHtml(counts.low)}</td></tr>
<tr><td>Info</td><td class="num">${escapeHtml(counts.info)}</td></tr>
<tr><td>IT-Sicherheit</td><td class="num">${escapeHtml(counts.security)}</td></tr>
<tr><td>Datenschutz</td><td class="num">${escapeHtml(counts.privacy)}</td></tr>
<tr><td><strong>Gesamt</strong></td><td class="num"><strong>${escapeHtml(counts.total)}</strong></td></tr>
</tbody>
</table>
<h2>Auffälligkeiten (${data.findings.length})</h2>
${findingsHtml}
<h2>Geprüfte Dateien (${data.files.length})</h2>
<table>
<thead>
<tr><th>Pfad</th><th>Typ</th><th>Sprache</th><th>Größe</th></tr>
</thead>
<tbody>
${data.files.length === 0
? `<tr><td colspan="4">Keine Dateien verfügbar.</td></tr>`
: data.files.map(file => `
<tr>
<td>${escapeHtml(file.path)}</td>
<td>${escapeHtml(KIND_LABELS[file.kind] ?? file.kind)}</td>
<td>${escapeHtml(file.language || "-")}</td>
<td class="num">${escapeHtml(file.size)} B</td>
</tr>`).join("")}
</tbody>
</table>
<div class="footer">SkillGuard - Erstellt am ${escapeHtml(formatDate(new Date()))}</div>
</body>
</html>`;
}