V3: 3 Farbschemata, Grundprofil auf Plan-Ebene, Erstellungs-Popups, integer-Zahlenfelder mit Beschleunigungs-Spinner, Carry-Forward des Zielwerts, gefuehrter Uebergang
Deploy App / deploy (push) Successful in 1m51s

- Theming: semantische CSS-Tokens + 3 waehlbare Schemata (Hell/Dunkel/Warm/Sunset), Umschalter im Profil-Menue, FOUC-frei via Inline-Script, localStorage; Klassen-Sweep aller Komponenten, Recharts aus Tokens
- Datenmodell: Household entfaellt; Plan traegt Haushaltsform/Personen/Inflation selbst (Person -> planId, Plan -> userId); destruktive Migration (TRUNCATE); Onboarding/HouseholdSettings entfernt; Plan-Erstellung & -Einstellungen mit Profilfeldern
- Popups: Element-Erstellung mit Inline-Feldern (geteilte ElementPhaseFields/ElementTransitionFields), Phase- und Plan-Popups mit Direkteingabe
- Zahlenfelder: 1'000er-Runden entfernt (floorToThousand/roundToHundred weg), integer MoneyInput mit beschleunigendem Press-and-Hold-Spinner, 0-Bug-Fix, harte Live-Caps
- Quote: Amortisation + Tilgung neu quotenwirksam; Restquote sichtbar (sinkt beim Verteilen); Invest-Deckel = verfuegbares Kapital + fortgeschriebener Zielwert
- Carry-Forward: Startwert der Folgephase = Zielwert der Vorphase minus Uebergangs-Bezug (live abgeleitet); optionale Zusatzinvestition aus verfuegbarem Kapital
- Matrix: Zelle zeigt Start -> Ziel; Uebergangs-Spaltenkopf mit "n offen"-Badge + gefuehrtem Pruef-Panel

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 14:49:42 +02:00
parent b775ab77cb
commit 32adfb4476
32 changed files with 1706 additions and 1290 deletions
+75 -74
View File
@@ -3,10 +3,9 @@ import {
AHV_FULL_CONTRIBUTION_YEARS,
AHV_MAX_ANNUAL_SINGLE,
} from "@/lib/constants";
import { floorToThousand } from "@/lib/format";
import { num } from "@/lib/elements";
import type { ElementCategory } from "@/lib/elements";
import type { HouseholdInput, PersonRole, PlanInput } from "@/lib/types";
import type { PersonRole, PlanInput } from "@/lib/types";
export type PhaseType = "ERWERB" | "PENSION" | "MIXED";
export type ElementStatus = "ACTIVE" | "SOLD" | "SETTLED";
@@ -28,11 +27,12 @@ export interface ElementPhaseComputed {
ownerRole: string | null;
status: ElementStatus;
locked: boolean; // verkauft/getilgt -> in dieser Phase nicht mehr editierbar
carried: boolean; // Startwert wird aus der Vorphase fortgeschrieben (Phase >= 2)
startValue: number; // Netto-Wert zu Phasenbeginn (Aktiven +, Schulden -)
endValue: number; // Netto-Wert am Phasenende
incomeContribution: number; // Beitrag zum Phasen-Einkommen
expenseContribution: number; // Beitrag zu den Phasen-Ausgaben
quotaUse: number; // Betrag, der Spar-/Verzehrquote verbraucht (3a/Sonstiges Vermoegen)
quotaUse: number; // Betrag, der Spar-/Verzehrquote verbraucht (3a/Vermoegen/Amort./Tilgung)
capitalUse: number; // verbrauchtes verfuegbares Startkapital (Aufstockung/Neuinvestition)
summary: string; // Kennzahl fuer die eingeklappte Zelle
note: string | null; // z. B. "Verkauft", "Getilgt", "Vollstaendig bezogen"
@@ -51,9 +51,11 @@ export interface PhaseComputed {
quota: number; // Einkommen - Ausgaben (Sparquote wenn >=0, Verzehrquote wenn <0)
isConsumption: boolean;
quotaAllocated: number; // verteilte Sparbeitraege bzw. gedeckte Verzehr-Bezuege
quotaRemaining: number; // |quota| - quotaAllocated (offener Rest, kann negativ = ueberzogen)
quotaComplete: boolean;
availableCapital: number | null; // null in der ersten Phase
availableCapitalUsed: number;
availableCapitalRemaining: number; // 0 in der ersten Phase
availableCapitalComplete: boolean;
incomplete: boolean; // roter Status (Quote/Kapital nicht vollstaendig verteilt)
elements: ElementPhaseComputed[];
@@ -68,28 +70,16 @@ export interface PlanComputed {
nachlass: number;
}
// Loest das effektive Pensionsalter einer Person auf (Plan-Override vor Profil-Default).
export function resolveRetirementAge(
role: PersonRole,
plan: { retirementAgeA: number | null; retirementAgeB: number | null },
profileDefault: number
): number {
const override = role === "PERSON_A" ? plan.retirementAgeA : plan.retirementAgeB;
return override ?? profileDefault;
}
// Maximale Dauer einer neuen Phase, die yearsBefore Jahre nach Planbeginn startet:
// bis zum naechsten Pensionsereignis einer noch erwerbenden Person (null = unbegrenzt).
export function maxPhaseDuration(
persons: { role: PersonRole; age: number; retirementAge: number }[],
plan: { retirementAgeA: number | null; retirementAgeB: number | null },
yearsBefore: number
): number | null {
const caps: number[] = [];
for (const p of persons) {
const ra = resolveRetirementAge(p.role, plan, p.retirementAge);
const startAge = p.age + yearsBefore;
if (startAge < ra) caps.push(ra - startAge);
if (startAge < p.retirementAge) caps.push(p.retirementAge - startAge);
}
return caps.length > 0 ? Math.min(...caps) : null;
}
@@ -108,23 +98,23 @@ function emptyCarry(): Carry {
return { status: "ACTIVE", value: 0, mortgage: 0, owed: 0, pkPensionAnnual: 0, hasCarry: false };
}
// Zinseszins mit jaehrlichem Beitrag; kein Zwischen-Runden mehr (1'000er-Konzept entfernt),
// nur das Endresultat wird auf ganze Franken gerundet.
function growAsset(startValue: number, expectedReturn: number, annual: number, years: number): number {
let v = floorToThousand(startValue);
let v = startValue;
for (let y = 0; y < years; y++) {
v = floorToThousand(v * (1 + expectedReturn / 100) + annual);
v = v * (1 + expectedReturn / 100) + annual;
}
return Math.max(0, v);
return Math.max(0, Math.round(v));
}
export function computePlan(plan: PlanInput, household: HouseholdInput): PlanComputed {
export function computePlan(plan: PlanInput): PlanComputed {
const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
const persons = household.persons;
const persons = plan.persons;
// Pensionsalter je Person (aufgeloest).
// Pensionsalter je Person (liegt direkt am plan-eigenen Personensatz).
const retirementAge = new Map<string, number>();
for (const p of persons) {
retirementAge.set(p.id, resolveRetirementAge(p.role, plan, p.retirementAge));
}
for (const p of persons) retirementAge.set(p.id, p.retirementAge);
// Kumulierte AHV-Ausfalljahre je Person (ueber die Erwerbsphasen aufsummiert).
const gapYearsByPerson = new Map<string, number>();
@@ -185,14 +175,14 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
if (!owner || workingByPerson.get(owner.id)) continue; // nur pensionierte Personen
const gap = gapYearsByPerson.get(owner.id) ?? 0;
const factor = Math.max(0, (AHV_FULL_CONTRIBUTION_YEARS - gap) / AHV_FULL_CONTRIBUTION_YEARS);
ahvUncapped.set(owner.id, floorToThousand(AHV_MAX_ANNUAL_SINGLE * factor));
ahvUncapped.set(owner.id, Math.round(AHV_MAX_ANNUAL_SINGLE * factor));
}
const ahvFinal = new Map(ahvUncapped);
if (household.householdType === "COUPLE" && ahvUncapped.size === 2) {
if (plan.householdType === "COUPLE" && ahvUncapped.size === 2) {
const sum = [...ahvUncapped.values()].reduce((a, b) => a + b, 0);
const cap = AHV_MAX_ANNUAL_SINGLE * AHV_COUPLE_CAP_FACTOR;
if (sum > cap && sum > 0) {
for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, floorToThousand(v * (cap / sum)));
for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, Math.round(v * (cap / sum)));
}
}
@@ -217,6 +207,7 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
ownerRole: e.ownerRole,
status: carry.status,
locked: carry.status !== "ACTIVE",
carried: carry.hasCarry,
startValue: 0,
endValue: 0,
incomeContribution: 0,
@@ -242,14 +233,14 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
switch (e.category) {
case "INCOME": {
const amount = floorToThousand(num(pd.amount));
const amount = Math.round(num(pd.amount));
ec.incomeContribution = amount;
incomeTotal += amount;
ec.summary = fmt(amount);
break;
}
case "EXPENSE": {
const amount = floorToThousand(num(pd.amount));
const amount = Math.round(num(pd.amount));
ec.expenseContribution = amount;
expenseTotal += amount;
ec.summary = fmt(amount);
@@ -278,13 +269,15 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
ec.note = "Vollstaendig bezogen";
ec.summary = "Bezogen";
} else {
const start = floorToThousand(num(pd.currentValue));
const contribution = floorToThousand(num(pd.annualContribution));
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
const start = base + topUp;
const contribution = Math.round(num(pd.annualContribution));
const r = num(pd.expectedReturn);
ec.startValue = start;
ec.endValue = growAsset(start, r, contribution, phase.durationYears);
ec.capitalUse = Math.max(0, start - carry.value);
capitalUsed += ec.capitalUse;
ec.capitalUse = topUp;
capitalUsed += topUp;
// PK-Beitraege zaehlen NICHT gegen die Sparquote (in Ausgaben enthalten).
ec.summary = fmt(ec.endValue);
}
@@ -295,13 +288,15 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
ec.note = "Vollstaendig bezogen";
ec.summary = "Bezogen";
} else {
const start = floorToThousand(num(pd.currentValue));
const contribution = roundToHundred(num(pd.annualContribution));
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
const start = base + topUp;
const contribution = Math.round(num(pd.annualContribution));
const r = num(pd.expectedReturn);
ec.startValue = start;
ec.endValue = growAsset(start, r, contribution, phase.durationYears);
ec.capitalUse = Math.max(0, start - carry.value);
capitalUsed += ec.capitalUse;
ec.capitalUse = topUp;
capitalUsed += topUp;
ec.quotaUse = contribution; // zaehlt gegen die Sparquote
quotaAllocated += contribution;
ec.summary = fmt(ec.endValue);
@@ -309,15 +304,16 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
break;
}
case "OTHER_ASSET": {
const start = floorToThousand(num(pd.startValue));
const contribution = floorToThousand(num(pd.annualContribution));
const base = carry.hasCarry ? carry.value : Math.round(num(pd.startValue));
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
const start = base + topUp;
const contribution = Math.round(num(pd.annualContribution));
const r = num(pd.expectedReturn);
ec.startValue = start;
ec.capitalUse = Math.max(0, start - carry.value);
capitalUsed += ec.capitalUse;
// In Erwerbsphasen (Sparen) wird eingezahlt, in Verzehrphasen bezogen -- das
// Vorzeichen ergibt sich aus der Phasenquote (siehe unten). Hier immer als
// Beitrag verbucht; die Verzehr-Logik nutzt denselben Betrag als Bezug.
ec.capitalUse = topUp;
capitalUsed += topUp;
// In Erwerbsphasen Sparbeitrag, in Verzehrphasen Bezugsrate -- beides zaehlt gegen
// die Quote (Vorzeichen ergibt sich aus der Phasenquote).
ec.quotaUse = contribution;
quotaAllocated += contribution;
ec.endValue = growAsset(start, r, contribution, phase.durationYears);
@@ -325,9 +321,9 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
break;
}
case "REAL_ESTATE": {
const purchase = floorToThousand(num(pd.purchasePrice));
const mortgageStart = carry.hasCarry ? carry.mortgage : floorToThousand(num(pd.mortgage));
const amort = floorToThousand(num(pd.amortization));
const purchase = Math.round(num(pd.purchasePrice));
const mortgageStart = carry.hasCarry ? carry.mortgage : Math.round(num(pd.mortgage));
const amort = Math.round(num(pd.amortization));
const mortgageEnd = Math.max(0, mortgageStart - amort * phase.durationYears);
ec.startValue = purchase - mortgageStart;
ec.endValue = purchase - mortgageEnd;
@@ -335,17 +331,23 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
ec.capitalUse = Math.max(0, purchase - mortgageStart); // Eigenkapital bei Neukauf
capitalUsed += ec.capitalUse;
}
// Amortisation ist quotenwirksam (jaehrlicher Budgetbetrag).
ec.quotaUse = amort;
quotaAllocated += amort;
carry.mortgage = mortgageEnd; // fuer Uebergang
ec.summary = fmt(ec.endValue);
break;
}
case "OTHER_DEBT": {
const owedStart = carry.hasCarry ? carry.owed : floorToThousand(num(pd.startValue));
const repay = floorToThousand(num(pd.annualRepayment));
const owedStart = carry.hasCarry ? carry.owed : Math.round(num(pd.startValue));
const repay = Math.round(num(pd.annualRepayment));
const owedEnd = Math.max(0, owedStart - repay * phase.durationYears);
ec.startValue = -owedStart;
ec.endValue = -owedEnd;
carry.owed = owedEnd;
// Tilgung ist quotenwirksam (jaehrlicher Budgetbetrag).
ec.quotaUse = repay;
quotaAllocated += repay;
ec.summary = fmt(ec.endValue);
if (owedEnd === 0) ec.note = "Wird getilgt";
break;
@@ -361,16 +363,18 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
// Sparphase: alles verteilt, wenn quotaAllocated == quota. Verzehrphase: gedeckt,
// wenn Bezuege (quotaAllocated) den Fehlbetrag decken.
const quotaTarget = Math.abs(quota);
const quotaComplete = Math.abs(quotaTarget - quotaAllocated) < 1;
const quotaRemaining = quotaTarget - quotaAllocated;
const quotaComplete = Math.abs(quotaRemaining) < 1;
const availableCapital = incomingCapital;
const availableCapitalUsed = capitalUsed;
const availableCapitalRemaining = availableCapital === null ? 0 : availableCapital - availableCapitalUsed;
const availableCapitalComplete =
availableCapital === null || Math.abs(availableCapital - availableCapitalUsed) < 1;
availableCapital === null || Math.abs(availableCapitalRemaining) < 1;
const incomplete = !quotaComplete || !availableCapitalComplete;
const inflationRate = phase.inflationRate ?? household.inflationRateDefault;
const inflationRate = phase.inflationRate ?? plan.inflationRateDefault;
cumulativeInflation = cumulativeInflation * (1 + inflationRate / 100);
const startWealthNominal = elementsComputed.reduce((s, ec) => s + ec.startValue, 0);
@@ -389,9 +393,11 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
quota,
isConsumption,
quotaAllocated,
quotaRemaining,
quotaComplete,
availableCapital,
availableCapitalUsed,
availableCapitalRemaining,
availableCapitalComplete,
incomplete,
elements: elementsComputed,
@@ -409,7 +415,7 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
const td = e.transitionValues[phase.id] ?? {};
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
const ownerRetiresNext =
!!owner && !!nextPhase && workingByPerson.get(owner.id) === true && retiresInPhase(owner.id, nextPhase, persons, retirementAge, yearsBefore + phase.durationYears);
!!owner && !!nextPhase && workingByPerson.get(owner.id) === true && retiresInPhase(owner.id, persons, retirementAge, yearsBefore + phase.durationYears);
if (carry.status !== "ACTIVE") {
carry.hasCarry = true;
@@ -422,22 +428,22 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
const value = ec.endValue;
const mode = td.payoutMode ?? "PENSION";
if (mode === "CAPITAL") {
const net = floorToThousand(value * (1 - num(td.capitalTaxRate) / 100));
const net = Math.round(value * (1 - num(td.capitalTaxRate) / 100));
outgoing += net;
carry.value = 0;
carry.pkPensionAnnual = 0;
} else if (mode === "PENSION") {
carry.pkPensionAnnual = floorToThousand((value * num(td.conversionRate)) / 100);
carry.pkPensionAnnual = Math.round((value * num(td.conversionRate)) / 100);
carry.value = 0;
} else {
const capital = Math.min(value, floorToThousand(num(td.capitalAmount)));
const net = floorToThousand(capital * (1 - num(td.capitalTaxRate) / 100));
const capital = Math.min(value, Math.round(num(td.capitalAmount)));
const net = Math.round(capital * (1 - num(td.capitalTaxRate) / 100));
outgoing += net;
carry.pkPensionAnnual = floorToThousand(((value - capital) * num(td.conversionRate)) / 100);
carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate)) / 100);
carry.value = 0;
}
} else {
const withdrawal = Math.min(ec.endValue, floorToThousand(num(td.withdrawal)));
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
carry.value = ec.endValue - withdrawal;
outgoing += withdrawal;
}
@@ -445,11 +451,11 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
}
case "PILLAR_3A": {
if (ownerRetiresNext) {
const net = floorToThousand(ec.endValue * (1 - num(td.capitalTaxRate) / 100));
const net = Math.round(ec.endValue * (1 - num(td.capitalTaxRate) / 100));
outgoing += net;
carry.value = 0;
} else {
const withdrawal = Math.min(ec.endValue, floorToThousand(num(td.withdrawal)));
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
carry.value = ec.endValue - withdrawal;
outgoing += withdrawal;
}
@@ -466,18 +472,18 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
}
case "REAL_ESTATE": {
if (td.decision === "SELL") {
const purchase = floorToThousand(num(e.phaseValues[phase.id]?.purchasePrice));
const salePrice = floorToThousand(num(td.salePrice));
const purchase = Math.round(num(e.phaseValues[phase.id]?.purchasePrice));
const salePrice = Math.round(num(td.salePrice));
const gain = Math.max(0, salePrice - purchase);
const tax = gain * (num(td.saleTaxRate) / 100);
outgoing += floorToThousand(salePrice - carry.mortgage - tax);
outgoing += Math.round(salePrice - carry.mortgage - tax);
carry.status = "SOLD";
}
// HOLD: carry.mortgage bereits gesetzt.
break;
}
case "OTHER_DEBT": {
const immediate = Math.min(carry.owed, floorToThousand(num(td.immediateRepayment)));
const immediate = Math.min(carry.owed, Math.round(num(td.immediateRepayment)));
if (immediate > 0) {
carry.owed = Math.max(0, carry.owed - immediate);
outgoing -= immediate; // sofortige Tilgung mindert das verfuegbare Kapital
@@ -491,7 +497,7 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
carry.hasCarry = true;
}
incomingCapital = nextPhase ? floorToThousand(outgoing) : null;
incomingCapital = nextPhase ? Math.round(outgoing) : null;
yearsBefore += phase.durationYears;
}
@@ -503,11 +509,10 @@ function personByRole(persons: { id: string; role: PersonRole }[], role: string)
return persons.find((p) => p.role === role) ?? null;
}
// Prueft, ob eine Person in der gegebenen Phase (mit gegebenem Jahres-Offset) pensioniert ist,
// obwohl sie in der Vorphase noch erwerbend war.
// Prueft, ob eine Person mit dem gegebenen Jahres-Offset (zu Beginn der Folgephase) pensioniert
// ist, obwohl sie in der Vorphase noch erwerbend war.
function retiresInPhase(
personId: string,
phase: { durationYears: number },
persons: { id: string; role: PersonRole; age: number }[],
retirementAge: Map<string, number>,
yearsBeforeNext: number
@@ -519,10 +524,6 @@ function retiresInPhase(
return startAgeNext >= ra;
}
function roundToHundred(v: number): number {
return Math.round((v || 0) / 100) * 100;
}
function fmt(v: number): string {
const rounded = Math.round(v || 0);
const sign = rounded < 0 ? "-" : "";
+4
View File
@@ -59,6 +59,9 @@ export interface PhaseData {
startValue?: number;
expectedReturn?: number;
annualContribution?: number;
// PENSION_FUND / PILLAR_3A / OTHER_ASSET (ab Phase 2): zusaetzliche Einlage aus dem
// verfuegbaren Kapital der Phase. Der Basis-Startwert wird aus der Vorphase fortgeschrieben.
additionalInvestment?: number;
// REAL_ESTATE
purchasePrice?: number;
mortgage?: number;
@@ -99,6 +102,7 @@ export const phaseDataSchema = z
startValue: nonNeg.optional(),
expectedReturn: z.number().min(-50).max(100).optional(),
annualContribution: nonNeg.optional(),
additionalInvestment: nonNeg.optional(),
purchasePrice: nonNeg.optional(),
mortgage: nonNeg.optional(),
amortization: nonNeg.optional(),
-8
View File
@@ -10,14 +10,6 @@ export function formatChf(value: number): string {
return sign + withSeparators;
}
// Rundet ABwaerts auf ein Vielfaches von 1'000. Bewusst floor statt round: Betraege wie
// z. B. eine verfuegbare Sparquote von 1'450 CHF liessen sich sonst nicht vollstaendig
// auf Wertschriften verteilen (nur 1'000er-Schritte moeglich) -- durch Abrunden bleibt
// der angezeigte/verplanbare Betrag immer tatsaechlich erreichbar.
export function floorToThousand(value: number): number {
return Math.floor((value || 0) / 1000) * 1000;
}
export function parseChfInput(text: string): number {
const cleaned = text.replace(/[^0-9-]/g, "");
const parsed = parseInt(cleaned, 10);
+14 -27
View File
@@ -2,9 +2,10 @@ import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { phaseDataSchema, transitionDataSchema } from "@/lib/elements";
import type { PhaseData, TransitionData } from "@/lib/elements";
import type { HouseholdInput, PlanInput } from "@/lib/types";
import type { PlanInput } from "@/lib/types";
export const planInclude = {
persons: { orderBy: { role: "asc" } },
phases: { orderBy: { sequenceNumber: "asc" } },
elements: {
orderBy: { orderIndex: "asc" },
@@ -13,21 +14,6 @@ export const planInclude = {
} satisfies Prisma.PlanInclude;
export type PlanWithRelations = Prisma.PlanGetPayload<{ include: typeof planInclude }>;
export type HouseholdWithPersons = Prisma.HouseholdGetPayload<{ include: { persons: true } }>;
export function toHouseholdInput(household: HouseholdWithPersons): HouseholdInput {
return {
id: household.id,
householdType: household.householdType,
inflationRateDefault: household.inflationRateDefault,
persons: household.persons.map((p) => ({
id: p.id,
role: p.role,
age: p.age,
retirementAge: p.retirementAge,
})),
};
}
function parsePhaseData(raw: unknown): PhaseData {
const parsed = phaseDataSchema.safeParse(raw);
@@ -43,8 +29,14 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
return {
id: plan.id,
name: plan.name,
retirementAgeA: plan.retirementAgeA,
retirementAgeB: plan.retirementAgeB,
householdType: plan.householdType,
inflationRateDefault: plan.inflationRateDefault,
persons: plan.persons.map((p) => ({
id: p.id,
role: p.role,
age: p.age,
retirementAge: p.retirementAge,
})),
phases: plan.phases.map((phase) => ({
id: phase.id,
sequenceNumber: phase.sequenceNumber,
@@ -70,15 +62,10 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
};
}
// Liefert den Haushalt des eingeloggten Benutzers (pro Konto genau einer).
export async function getHouseholdOrNull(userId: string): Promise<HouseholdWithPersons | null> {
return prisma.household.findFirst({ where: { userId }, include: { persons: true } });
}
// Laedt einen Plan inkl. Phasen + Elemente, aber nur wenn er dem Benutzer gehoert.
// Laedt einen Plan inkl. Profil + Phasen + Elemente, aber nur wenn er dem Benutzer gehoert.
export async function getOwnedPlan(planId: string, userId: string) {
return prisma.plan.findFirst({
where: { id: planId, household: { userId } },
where: { id: planId, userId },
include: planInclude,
});
}
@@ -86,13 +73,13 @@ export async function getOwnedPlan(planId: string, userId: string) {
// Laedt eine Phase (Basisdaten), aber nur wenn sie dem Benutzer gehoert.
export async function getOwnedPhase(phaseId: string, userId: string) {
return prisma.phase.findFirst({
where: { id: phaseId, plan: { household: { userId } } },
where: { id: phaseId, plan: { userId } },
});
}
// Laedt ein Element (Basisdaten), aber nur wenn es dem Benutzer gehoert.
export async function getOwnedElement(elementId: string, userId: string) {
return prisma.financialElement.findFirst({
where: { id: elementId, plan: { household: { userId } } },
where: { id: elementId, plan: { userId } },
});
}
+35
View File
@@ -0,0 +1,35 @@
// Theme-Verwaltung: drei waehlbare Schemata, persistiert in localStorage und als
// data-theme am <html> gesetzt. Ohne gespeicherte Wahl folgt die Oberflaeche der
// OS-Einstellung (siehe globals.css, prefers-color-scheme).
export type Theme = "light" | "dark" | "warm";
export const THEMES: { value: Theme; label: string }[] = [
{ value: "light", label: "Hell" },
{ value: "dark", label: "Dunkel" },
{ value: "warm", label: "Warm" },
];
const STORAGE_KEY = "fpt-theme";
export function getStoredTheme(): Theme | null {
if (typeof window === "undefined") return null;
const v = window.localStorage.getItem(STORAGE_KEY);
return v === "light" || v === "dark" || v === "warm" ? v : null;
}
// Das effektiv aktive Theme (gespeicherte Wahl oder OS-Ableitung).
export function getEffectiveTheme(): Theme {
const stored = getStoredTheme();
if (stored) return stored;
if (typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
}
return "light";
}
export function setTheme(theme: Theme): void {
if (typeof window === "undefined") return;
window.localStorage.setItem(STORAGE_KEY, theme);
document.documentElement.setAttribute("data-theme", theme);
}
+7 -10
View File
@@ -1,5 +1,7 @@
// Domain-Typen fuer Berechnungslogik und API-Payloads. Entkoppelt von den generierten
// Prisma-Typen, damit die Berechnung unabhaengig testbar bleibt.
//
// V3-Rework: Das Grundprofil (Haushaltsform, Personen, Inflation) liegt neu direkt am Plan.
import type { ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements";
@@ -10,17 +12,9 @@ export interface PersonInput {
id: string;
role: PersonRole;
age: number;
// Bereits aufgeloestes Pensionsalter (Plan-Override oder Profil-Default).
retirementAge: number;
}
export interface HouseholdInput {
id: string;
householdType: HouseholdType;
inflationRateDefault: number;
persons: PersonInput[];
}
export interface PhaseInput {
id: string;
sequenceNumber: number;
@@ -40,11 +34,14 @@ export interface ElementInput {
transitionValues: Record<string, TransitionData>;
}
// Ein Plan ist selbsttragend: er traegt sein eigenes Grundprofil (Haushaltsform, Personen,
// Inflationsannahme) plus die Phasenkette und die finanziellen Elemente.
export interface PlanInput {
id: string;
name: string;
retirementAgeA: number | null;
retirementAgeB: number | null;
householdType: HouseholdType;
inflationRateDefault: number;
persons: PersonInput[];
phases: PhaseInput[];
elements: ElementInput[];
}