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
+12 -40
View File
@@ -27,6 +27,12 @@ import { Timeline } from "@/components/Timeline";
import { Sparkline } from "@/components/Sparkline";
import { CapitalDistributionDialog, RateDistributionDialog } from "@/components/DistributionDialogs";
import { capitalPot } from "@/lib/distribution";
import {
isRetirementTransition,
openTransitionCount,
transitionInactive as inactiveAtTransition,
TRANSITION_CATEGORIES,
} from "@/lib/decisions";
import { Tour, TOUR_DONE_KEY } from "@/components/Tour";
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
@@ -75,15 +81,6 @@ const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
// Kategorien mit einem Übergangs-Entscheid. AHV ist dabei ein Sonderfall: nur beim
// Pensions-Übergang ist die Beitragskarriere zu prüfen (siehe transitionInactive).
const TRANSITION_CATEGORIES: ElementCategory[] = [
"AHV",
"PENSION_FUND",
"PILLAR_3A",
"REAL_ESTATE",
"OTHER_ASSET",
"OTHER_DEBT",
];
const VALUE_CATEGORIES: ElementCategory[] = [
"PENSION_FUND",
"PILLAR_3A",
@@ -244,12 +241,8 @@ export function PlanView({
return computed.phases.find((p) => p.id === phaseId)?.elements.find((e) => e.elementId === elementId);
}
function isRetirementTransition(element: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): boolean {
if (!element.ownerRole || element.ownerRole === "HOUSEHOLD") return false;
const before = fromPhase.persons.find((p) => p.role === element.ownerRole);
const after = toPhase.persons.find((p) => p.role === element.ownerRole);
return !!before?.working && !!after && !after.working;
}
// isRetirementTransition/transitionInactive/openTransitionCount kommen aus lib/decisions --
// der Bericht zaehlt mit derselben Regel (sonst meldet er andere Zahlen als die Matrix).
// Beitragskarriere des Element-Besitzers (nur für AHV relevant).
function careerFor(element: ElementInput) {
@@ -315,20 +308,8 @@ export function PlanView({
// Am Übergang nichts (mehr) zu tun: verkauft/getilgt ODER PK/3a nach der Pensionierung
// (Besitzer ist zu Beginn der Von-Phase bereits pensioniert -> bereits bezogen/verrentet)
// ODER AHV ausserhalb des Pensions-Übergangs.
function transitionInactive(el: ElementInput, fromPhase: PhaseComputed, toPhase?: PhaseComputed): boolean {
const ce = computedElement(fromPhase.id, el.id);
if (ce && ce.status !== "ACTIVE") return true;
if (el.category === "AHV") {
return !(toPhase && isRetirementTransition(el, fromPhase, toPhase));
}
if (el.category === "PENSION_FUND" || el.category === "PILLAR_3A") {
if (el.ownerRole && el.ownerRole !== "HOUSEHOLD") {
const owner = fromPhase.persons.find((p) => p.role === el.ownerRole);
if (owner && !owner.working) return true;
}
}
return false;
}
const transitionInactive = (el: ElementInput, fromPhase: PhaseComputed, toPhase?: PhaseComputed) =>
inactiveAtTransition(computed, el, fromPhase, toPhase);
function cashTransitionFor(phaseId: string): CashTransitionData {
return plan.phases.find((p) => p.id === phaseId)?.cashTransition ?? {};
@@ -336,17 +317,8 @@ export function PlanView({
// Anzahl offener (noch nicht getroffener) Übergangs-Entscheide an einer Grenze.
// Der Cash-Entscheid (einmalige Sonderein-/ausgaben) zählt mit.
function transitionOpenCount(fromPhase: PhaseComputed, toPhase: PhaseComputed): number {
let n = isCashTransitionAnswered(cashTransitionFor(fromPhase.id)) ? 0 : 1;
for (const el of plan.elements) {
if (!TRANSITION_CATEGORIES.includes(el.category)) continue;
if (transitionInactive(el, fromPhase, toPhase)) continue;
const td = el.transitionValues[fromPhase.id] ?? {};
const retire = toPhase ? isRetirementTransition(el, fromPhase, toPhase) : false;
if (!isTransitionAnswered(el.category, retire, td)) n++;
}
return n;
}
const transitionOpenCount = (fromPhase: PhaseComputed, toPhase: PhaseComputed) =>
openTransitionCount(plan, computed, fromPhase, toPhase);
// Ist der Übergangs-Entscheid dieses Elements noch offen?
function transitionUnanswered(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): boolean {