Fix: Server/Client-Grenze -- Uebergangsregeln nach lib verschoben
Deploy App / deploy (push) Successful in 1m5s
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:
@@ -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,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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user