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 { FieldLabel, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
|
||||||
import { formatChf } from "@/lib/format";
|
import { formatChf } from "@/lib/format";
|
||||||
import { api } from "@/lib/api-client";
|
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 {
|
import {
|
||||||
buildRateWrites,
|
buildRateWrites,
|
||||||
changedRateFields,
|
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
|
// Eingabefelder für den Cash-Entscheid. Erfassungs-Konventionen bewusst wie bei den
|
||||||
// laufenden Flows: Zufluss nominal (wie Einkommen), Kosten real (wie Ausgaben).
|
// laufenden Flows: Zufluss nominal (wie Einkommen), Kosten real (wie Ausgaben).
|
||||||
export function CashTransitionFields({
|
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) ---
|
// --- Wiederverwendbare Feldgruppen (Detail-Panel, Erstell-Popup, Übergangs-Review) ---
|
||||||
|
|
||||||
export function ElementPhaseFields({
|
export function ElementPhaseFields({
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
// und zwei Umsetzungen derselben Regel driften garantiert auseinander. Deshalb hier als
|
// und zwei Umsetzungen derselben Regel driften garantiert auseinander. Deshalb hier als
|
||||||
// reine Funktion, die beide benutzen.
|
// 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 { ElementCategory } from "@/lib/elements";
|
||||||
import type { ElementInput, PlanInput } from "@/lib/types";
|
import type { ElementInput, PlanInput } from "@/lib/types";
|
||||||
import type { PhaseComputed, PlanComputed } from "@/lib/calculations";
|
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