Fix: Server/Client-Grenze -- Uebergangsregeln nach lib verschoben
Deploy App / deploy (push) Successful in 1m5s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 10:15:54 +02:00
parent 8b50f8d4ae
commit c429624635
4 changed files with 150 additions and 66 deletions
+9 -65
View File
@@ -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({
+4 -1
View File
@@ -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";
+52
View File
@@ -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);
});
});
+85
View File
@@ -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";
}
}