PDF-Berichte (Roadmap 11)
Deploy App / deploy (push) Successful in 3m5s

Neuer Unterpunkt "Berichte" je Plan: Liste plus Assistent (Titel, Notiz,
nominal ODER real, Plan-/Ist-Daten, bis zu drei Szenarien, gespeicherte
Analysen). Layout immer gleich, Auswahl bestimmt nur die Bausteine.

Die PDF-Datei wird ALS DATEI abgelegt (BYTEA in Postgres, nicht im
Container-Dateisystem): Ein Bericht muss in drei Jahren byte-identisch
wieder herunterladbar sein -- eine Neuerzeugung koennte das nach
Aenderungen an Plan, Rechenkern oder Layout nicht garantieren.

Kennzahlen je Szenario inkl. offener Entscheide. Deren Zaehlung liegt neu
als reine Funktion in decisions.ts, die Matrix UND Bericht benutzen --
sonst nennen beide verschiedene Zahlen.

Zu jeder Kennzahl ihre Grundlage als Verweis; die vollstaendigen Annahmen
einmal je Szenario. Haftungsausschluss ist verpflichtend (per Test).

Technik: pdfkit in der Node-Runtime statt Headless-Browser;
@react-pdf/renderer bricht mit React 19. Als externes Paket deklariert,
weil pdfkit Font-Metriken ueber Dateipfade laedt.

Spezifikation 0.25 (3.11 neu), 9 Tests (212 -> 221).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 08:30:18 +02:00
parent 489390a4a3
commit d9ef980edf
17 changed files with 1988 additions and 47 deletions
+322
View File
@@ -0,0 +1,322 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { ArrowLeft, Download, FileText, Plus, Trash2 } from "lucide-react";
import { Button, useConfirm, useToast } from "@/components/ui";
import { InfoBubble } from "@/components/InfoBubble";
import { api } from "@/lib/api-client";
import { ANALYSIS_TYPE_LABEL, type AnalysisType, type SavedAnalysisMeta } from "@/lib/analyses";
import { MAX_REPORT_SCENARIOS, type ReportMetric, type ReportSource } from "@/lib/report";
interface StoredReport {
id: string;
title: string;
config: { metric?: string; source?: string; scenarioIds?: string[]; analysisIds?: string[]; comment?: string | null };
pdfBytes: number;
createdAt: string;
author: string;
}
const dt = (iso: string) =>
new Date(iso).toLocaleString("de-CH", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
export function ReportsView({
planId,
scenarios,
hasActuals,
}: {
planId: string;
scenarios: { id: string; name: string; isBase: boolean }[];
hasActuals: boolean;
}) {
const [reports, setReports] = useState<StoredReport[] | null>(null);
const [analyses, setAnalyses] = useState<SavedAnalysisMeta[]>([]);
const [error, setError] = useState<string | null>(null);
const [mode, setMode] = useState<"list" | "new">("list");
const [busy, setBusy] = useState(false);
const confirm = useConfirm();
const toast = useToast();
// Formular
const [title, setTitle] = useState("");
const [comment, setComment] = useState("");
const [metric, setMetric] = useState<ReportMetric>("nominal");
const [source, setSource] = useState<ReportSource>("PLAN");
const [scenarioIds, setScenarioIds] = useState<string[]>([]);
const [analysisIds, setAnalysisIds] = useState<string[]>([]);
const load = useCallback(async () => {
const [r, a] = await Promise.all([
api.get<{ reports: StoredReport[] }>(`/api/plans/${planId}/reports`),
api.get<{ analyses: SavedAnalysisMeta[] }>(`/api/plans/${planId}/analyses`),
]);
setReports(r.reports);
setAnalyses(a.analyses);
}, [planId]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [r, a] = await Promise.all([
api.get<{ reports: StoredReport[] }>(`/api/plans/${planId}/reports`),
api.get<{ analyses: SavedAnalysisMeta[] }>(`/api/plans/${planId}/analyses`),
]);
if (cancelled) return;
setReports(r.reports);
setAnalyses(a.analyses);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
}
})();
return () => {
cancelled = true;
};
}, [planId]);
function startNew() {
const base = scenarios.find((s) => s.isBase) ?? scenarios[0];
setTitle(`Finanzplanung ${new Date().getFullYear()}`);
setComment("");
setMetric("nominal");
setSource("PLAN");
setScenarioIds(base ? [base.id] : []);
setAnalysisIds([]);
setMode("new");
}
function toggleScenario(id: string) {
setScenarioIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : prev.length >= MAX_REPORT_SCENARIOS ? prev : [...prev, id]
);
}
async function create() {
setBusy(true);
try {
await api.post(`/api/plans/${planId}/reports`, {
title: title.trim() || "Finanzplanung",
metric,
source,
scenarioIds,
analysisIds,
comment: comment.trim() || undefined,
});
await load();
setMode("list");
toast("success", "Bericht erstellt.");
} catch (e) {
toast("error", e instanceof Error ? e.message : "Bericht konnte nicht erstellt werden.");
} finally {
setBusy(false);
}
}
async function remove(r: StoredReport) {
const ok = await confirm({
title: "Bericht löschen?",
message: `«${r.title}» wird endgültig entfernt. Die Datei lässt sich danach nicht mehr herunterladen.`,
confirmLabel: "Löschen",
danger: true,
});
if (!ok) return;
try {
await api.delete(`/api/plans/${planId}/reports/${r.id}`);
await load();
toast("success", "Bericht gelöscht.");
} catch (e) {
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
}
}
if (mode === "new") {
return (
<div className="flex flex-col gap-4">
<div>
<h2 className="text-lg font-semibold text-fg">Neuer Bericht</h2>
<p className="text-sm text-muted">
Der Bericht wird als PDF erzeugt und abgelegt. Er hält den Stand von heute fest spätere Änderungen am
Plan verändern ihn nicht mehr.
</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-muted">Titel</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted">Notiz auf dem Deckblatt (optional)</label>
<input
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="z. B. «Stand nach Beratungsgespräch»"
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-xl border border-border bg-surface-2 px-3 py-2">
<label className="flex items-center gap-1.5 text-xs text-muted">
<span className="font-medium">Werte</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
{(["nominal", "real"] as ReportMetric[]).map((m) => (
<button
key={m}
type="button"
onClick={() => setMetric(m)}
className={`rounded-md px-2 py-0.5 font-medium ${metric === m ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"}`}
>
{m === "real" ? "Real" : "Nominal"}
</button>
))}
</div>
<InfoBubble text="Der Bericht führt durchgängig EINE Leitgrösse. Beides nebeneinander würde jede Tabellenspalte verdoppeln." />
</label>
<label className="flex items-center gap-1.5 text-xs text-muted">
<span className="font-medium">Grundlage</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
{(["PLAN", "ACTUAL"] as ReportSource[]).map((s) => (
<button
key={s}
type="button"
disabled={s === "ACTUAL" && !hasActuals}
onClick={() => setSource(s)}
title={s === "ACTUAL" && !hasActuals ? "Für diesen Plan sind keine effektiven Werte erfasst." : undefined}
className={`rounded-md px-2 py-0.5 font-medium disabled:opacity-40 ${
source === s ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{s === "ACTUAL" ? "Effektiv" : "Plan"}
</button>
))}
</div>
</label>
</div>
<div>
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
Szenarien (max. {MAX_REPORT_SCENARIOS})
<InfoBubble text="Mehr als drei Szenarien machen die Vergleichstabelle unlesbar. Das Basisszenario führt den Bericht an und liefert die Zusammenfassung." />
</div>
<div className="flex flex-col gap-2">
{scenarios.map((s) => {
const checked = scenarioIds.includes(s.id);
const full = !checked && scenarioIds.length >= MAX_REPORT_SCENARIOS;
return (
<label
key={s.id}
className={`flex items-center gap-2 rounded-xl border p-3 text-sm ${
checked ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2"
} ${full ? "opacity-40" : ""}`}
>
<input type="checkbox" checked={checked} disabled={full} onChange={() => toggleScenario(s.id)} />
<span className="font-medium text-fg">{s.name}</span>
{s.isBase && <span className="rounded bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent-fg">Basis</span>}
</label>
);
})}
</div>
</div>
<div>
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
Gespeicherte Analysen einbinden
<InfoBubble text="Übernommen werden die beim Speichern eingefrorenen Zahlen es wird nichts neu gerechnet." />
</div>
{analyses.length === 0 ? (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-xs text-muted">
Noch keine Analysen gespeichert. Unter «Analysen» kannst du Ergebnisse festhalten.
</p>
) : (
<div className="flex flex-col gap-1.5">
{analyses.map((a) => (
<label key={a.id} className="flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs">
<input
type="checkbox"
checked={analysisIds.includes(a.id)}
onChange={() =>
setAnalysisIds((prev) => (prev.includes(a.id) ? prev.filter((x) => x !== a.id) : [...prev, a.id]))
}
/>
<span className="font-medium text-fg">{a.name}</span>
<span className="text-faint">{ANALYSIS_TYPE_LABEL[a.type as AnalysisType]}</span>
</label>
))}
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={() => setMode("list")}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
<Button disabled={busy || scenarioIds.length === 0} onClick={create}>
{busy ? "Wird erstellt…" : "Bericht erstellen"}
</Button>
</div>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-fg">Berichte</h2>
<Button onClick={startNew} disabled={scenarios.length === 0}>
<Plus className="h-4 w-4" /> Neuer Bericht
</Button>
</div>
<p className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
Ein Bericht ist ein <strong className="text-fg">festes Dokument</strong>: Die erzeugte PDF-Datei wird abgelegt
und lässt sich jederzeit unverändert wieder herunterladen auch wenn du den Plan danach weiterentwickelst.
</p>
{error && <p className="text-xs text-danger">{error}</p>}
{!reports && !error && <p className="text-xs text-muted">Wird geladen</p>}
{reports && reports.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
Noch kein Bericht erstellt.
</p>
)}
{reports && reports.length > 0 && (
<div className="flex flex-col gap-2">
{reports.map((r) => (
<div key={r.id} className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-surface-2 p-3">
<FileText className="h-4 w-4 shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-fg">{r.title}</div>
<div className="text-[11px] text-muted">
{dt(r.createdAt)} · {r.author} · {r.config.metric === "real" ? "real" : "nominal"} ·{" "}
{r.config.source === "ACTUAL" ? "effektiv" : "Plan"} · {(r.config.scenarioIds ?? []).length} Szenario
{(r.config.scenarioIds ?? []).length === 1 ? "" : "s"} · {Math.round(r.pdfBytes / 1024)} KB
</div>
</div>
<a
href={`/api/plans/${planId}/reports/${r.id}`}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:bg-surface hover:text-fg"
>
<Download className="h-3.5 w-3.5" /> PDF
</a>
<button
type="button"
onClick={() => remove(r)}
className="rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
</div>
);
}