From c4296246359c1307fcd1afdf9472d82538963029 Mon Sep 17 00:00:00 2001 From: kelle Date: Tue, 21 Jul 2026 10:15:54 +0200 Subject: [PATCH] Fix: Server/Client-Grenze -- Uebergangsregeln nach lib verschoben Ursache des 500 (danke fuer die Meldung): lib/decisions.ts importierte isTransitionAnswered und isCashTransitionAnswered aus der CLIENT- Komponente ElementDetail. Der PDF-Bericht laeuft serverseitig und brach deshalb ab: "Attempted to call isCashTransitionAnswered() from the server but it is on the client." Die fuenf reinen Uebergangs-Regeln liegen jetzt in lib/transitions.ts; ElementDetail reicht sie nur noch weiter, damit bestehende Importe unveraendert bleiben. Neuer Waechter-Test: kein Modul unter src/lib darf aus src/components importieren. Gegen den echten Fehler verifiziert -- er schlaegt an. Weder tsc noch der Build hatten das gemeldet, nur die Produktion. 225 Tests. Co-Authored-By: Claude Opus 4.8 --- src/components/ElementDetail.tsx | 74 ++++----------------------- src/lib/decisions.ts | 5 +- src/lib/server-boundary.test.ts | 52 +++++++++++++++++++ src/lib/transitions.ts | 85 ++++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 66 deletions(-) create mode 100644 src/lib/server-boundary.test.ts create mode 100644 src/lib/transitions.ts diff --git a/src/components/ElementDetail.tsx b/src/components/ElementDetail.tsx index 1b5fdbc..b5b7808 100644 --- a/src/components/ElementDetail.tsx +++ b/src/components/ElementDetail.tsx @@ -6,6 +6,15 @@ import { AlertTriangle } from "lucide-react"; import { FieldLabel, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField"; import { formatChf } from "@/lib/format"; import { api } from "@/lib/api-client"; +// Die reinen Uebergangs-Regeln liegen in lib/transitions.ts (auch serverseitig nutzbar) und +// werden hier nur weitergereicht, damit bestehende Importe unveraendert bleiben. +export { + withTransitionDefaults, + isTransitionAnswered, + withCashTransitionDefaults, + isCashTransitionAnswered, + cashTransitionSummary, +} from "@/lib/transitions"; import { buildRateWrites, changedRateFields, @@ -229,53 +238,6 @@ export function AhvReviewFields({ ); } -export function withTransitionDefaults(category: ElementCategory, isRetirement: boolean, td: TransitionData): TransitionData { - const out = { ...td }; - if (category === "AHV") { - // Ein Speichern der Prüfung markiert sie als erledigt (gleiches Muster wie bei PK/3a). - if (isRetirement && out.reviewed === undefined) out.reviewed = true; - } else if (category === "REAL_ESTATE" || category === "OTHER_ASSET") { - if (out.decision === undefined) out.decision = "HOLD"; - } else if (category === "PENSION_FUND") { - if (isRetirement) { - if (out.payoutMode === undefined) out.payoutMode = "PENSION"; - } else if (out.withdrawalMode === undefined) { - out.withdrawalMode = "NONE"; - } - } else if (category === "PILLAR_3A") { - if (!isRetirement && out.withdrawalMode === undefined) out.withdrawalMode = "NONE"; - } - return out; -} - -// --- Cash-Übergang: einmalige Sonderein-/ausgaben --- - -export function withCashTransitionDefaults(ct: CashTransitionData): CashTransitionData { - return ct.mode === undefined ? { ...ct, mode: "NONE" } : { ...ct }; -} - -export function isCashTransitionAnswered(ct: CashTransitionData): boolean { - return ct.mode !== undefined; -} - -// Kurzfassung für die Übergangszelle der Cash-Zeile. -export function cashTransitionSummary(ct: CashTransitionData): string { - const mode = ct.mode; - if (mode === undefined) return "?"; - const inn = `+${formatChf(num(ct.inflowAmount))}`; - const out = `−${formatChf(num(ct.outflowAmount))}`; - switch (mode) { - case "INFLOW": - return inn; - case "OUTFLOW": - return out; - case "BOTH": - return `${inn} / ${out}`; - default: - return "1:1"; - } -} - // Eingabefelder für den Cash-Entscheid. Erfassungs-Konventionen bewusst wie bei den // laufenden Flows: Zufluss nominal (wie Einkommen), Kosten real (wie Ausgaben). export function CashTransitionFields({ @@ -389,24 +351,6 @@ export function CashTransitionFields({ ); } -// "Beantwortet" = ein konkreter Entscheid liegt vor (kein offenes Fragezeichen). -export function isTransitionAnswered(category: ElementCategory, isRetirement: boolean, td: TransitionData): boolean { - switch (category) { - case "AHV": - // Nur bei der Pensionierung ist eine Prüfung der Beitragskarriere nötig. - return isRetirement ? td.reviewed === true : true; - case "REAL_ESTATE": - case "OTHER_ASSET": - return td.decision !== undefined; - case "PENSION_FUND": - return isRetirement ? td.payoutMode !== undefined : td.withdrawalMode !== undefined; - case "PILLAR_3A": - return isRetirement ? true : td.withdrawalMode !== undefined; - default: - return true; - } -} - // --- Wiederverwendbare Feldgruppen (Detail-Panel, Erstell-Popup, Übergangs-Review) --- export function ElementPhaseFields({ diff --git a/src/lib/decisions.ts b/src/lib/decisions.ts index 8615efa..aaebf46 100644 --- a/src/lib/decisions.ts +++ b/src/lib/decisions.ts @@ -4,7 +4,10 @@ // und zwei Umsetzungen derselben Regel driften garantiert auseinander. Deshalb hier als // reine Funktion, die beide benutzen. -import { isCashTransitionAnswered, isTransitionAnswered } from "@/components/ElementDetail"; +// WICHTIG: aus lib/transitions, NICHT aus der Client-Komponente ElementDetail. Ein Import +// von dort macht diese Datei serverseitig unbenutzbar ("Attempted to call ... from the +// server but it is on the client") -- genau daran scheiterte der PDF-Bericht. +import { isCashTransitionAnswered, isTransitionAnswered } from "@/lib/transitions"; import type { ElementCategory } from "@/lib/elements"; import type { ElementInput, PlanInput } from "@/lib/types"; import type { PhaseComputed, PlanComputed } from "@/lib/calculations"; diff --git a/src/lib/server-boundary.test.ts b/src/lib/server-boundary.test.ts new file mode 100644 index 0000000..15f6cbc --- /dev/null +++ b/src/lib/server-boundary.test.ts @@ -0,0 +1,52 @@ +// Wächter über die Server/Client-Grenze. +// +// Anlass: `lib/decisions.ts` importierte zwei reine Funktionen aus `components/ElementDetail` +// -- einer Client-Komponente. Lokal lief alles (Vitest kennt die Grenze nicht), und auch der +// Build meldete nichts. Erst in der Produktion brach der PDF-Bericht ab: +// «Attempted to call isCashTransitionAnswered() from the server but it is on the client.» +// +// Regel: Module unter `src/lib` sind von SERVER und Client benutzbar und dürfen deshalb +// nichts aus `src/components` importieren. Wer eine reine Funktion aus einer Komponente +// braucht, verschiebt sie nach `src/lib`. +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; + +const LIB = path.join(process.cwd(), "src", "lib"); + +function libFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...libFiles(full)); + else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) out.push(full); + } + return out; +} + +describe("Server/Client-Grenze", () => { + it("kein Modul in src/lib importiert aus src/components", () => { + const offenders: string[] = []; + + for (const file of libFiles(LIB)) { + const src = readFileSync(file, "utf8"); + // Nur echte Import-/Export-Anweisungen, keine Erwähnungen in Kommentaren. + const re = /^\s*(?:import|export)\s[^;]*?from\s+["']@\/components\/([^"']+)["']/gm; + let m: RegExpExecArray | null; + while ((m = re.exec(src))) { + offenders.push(`${path.relative(process.cwd(), file)} -> @/components/${m[1]}`); + } + } + + expect( + offenders, + `Diese lib-Module importieren aus components und brechen serverseitig:\n ${offenders.join( + "\n " + )}\nReine Funktionen gehören nach src/lib.` + ).toEqual([]); + }); + + it("findet überhaupt lib-Module (der Wächter selbst muss greifen)", () => { + expect(libFiles(LIB).length).toBeGreaterThan(10); + }); +}); diff --git a/src/lib/transitions.ts b/src/lib/transitions.ts new file mode 100644 index 0000000..dc1d098 --- /dev/null +++ b/src/lib/transitions.ts @@ -0,0 +1,85 @@ +// Übergangs-Entscheide: reine Regeln, ohne React. +// +// Diese Funktionen lagen ursprünglich in `ElementDetail.tsx` -- einer CLIENT-Komponente. +// Sobald der Server sie brauchte (PDF-Bericht: Zählung der offenen Entscheide), führte das +// zum Abbruch: «Attempted to call isCashTransitionAnswered() from the server but it is on +// the client.» Deshalb liegen sie hier, wo beide Seiten sie benutzen dürfen. + +import { num } from "@/lib/elements"; +import { formatChf } from "@/lib/format"; +import type { CashTransitionData, ElementCategory, TransitionData } from "@/lib/elements"; + +// Vorbelegung expliziter Entscheide, damit ein blosses "Speichern" den sichtbaren Default +// (Halten / Kein Bezug / Rente) auch tatsächlich persistiert. +export function withTransitionDefaults( + category: ElementCategory, + isRetirement: boolean, + td: TransitionData +): TransitionData { + const out = { ...td }; + if (category === "AHV") { + // Ein Speichern der Prüfung markiert sie als erledigt (gleiches Muster wie bei PK/3a). + if (isRetirement && out.reviewed === undefined) out.reviewed = true; + } else if (category === "REAL_ESTATE" || category === "OTHER_ASSET") { + if (out.decision === undefined) out.decision = "HOLD"; + } else if (category === "PENSION_FUND") { + if (isRetirement) { + if (out.payoutMode === undefined) out.payoutMode = "PENSION"; + } else if (out.withdrawalMode === undefined) { + out.withdrawalMode = "NONE"; + } + } else if (category === "PILLAR_3A") { + if (!isRetirement && out.withdrawalMode === undefined) out.withdrawalMode = "NONE"; + } + return out; +} + +// "Beantwortet" = ein konkreter Entscheid liegt vor (kein offenes Fragezeichen). +export function isTransitionAnswered( + category: ElementCategory, + isRetirement: boolean, + td: TransitionData +): boolean { + switch (category) { + case "AHV": + // Nur bei der Pensionierung ist eine Prüfung der Beitragskarriere nötig. + return isRetirement ? td.reviewed === true : true; + case "REAL_ESTATE": + case "OTHER_ASSET": + return td.decision !== undefined; + case "PENSION_FUND": + return isRetirement ? td.payoutMode !== undefined : td.withdrawalMode !== undefined; + case "PILLAR_3A": + return isRetirement ? true : td.withdrawalMode !== undefined; + default: + return true; + } +} + +// --- Cash-Übergang: einmalige Sonderein-/ausgaben --- + +export function withCashTransitionDefaults(ct: CashTransitionData): CashTransitionData { + return ct.mode === undefined ? { ...ct, mode: "NONE" } : { ...ct }; +} + +export function isCashTransitionAnswered(ct: CashTransitionData): boolean { + return ct.mode !== undefined; +} + +// Kurzfassung für die Übergangszelle der Cash-Zeile. +export function cashTransitionSummary(ct: CashTransitionData): string { + const mode = ct.mode; + if (mode === undefined) return "?"; + const inn = `+${formatChf(num(ct.inflowAmount))}`; + const out = `−${formatChf(num(ct.outflowAmount))}`; + switch (mode) { + case "INFLOW": + return inn; + case "OUTFLOW": + return out; + case "BOTH": + return `${inn} / ${out}`; + default: + return "1:1"; + } +}