diff --git a/prisma/migrations/20260715120000_plan_initial_cash/migration.sql b/prisma/migrations/20260715120000_plan_initial_cash/migration.sql new file mode 100644 index 0000000..a08122b --- /dev/null +++ b/prisma/migrations/20260715120000_plan_initial_cash/migration.sql @@ -0,0 +1,2 @@ +-- Anfangswert des Cash-Kontos (erste Lebensphase), pro Plan. Additiv, Default 0. +ALTER TABLE "Plan" ADD COLUMN "initialCash" DOUBLE PRECISION NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 64aab8e..5cbf450 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -76,6 +76,7 @@ model Plan { name String householdType HouseholdType inflationRateDefault Float + initialCash Float @default(0) parentPlanId String? parentPlan Plan? @relation("PlanScenarios", fields: [parentPlanId], references: [id], onDelete: SetNull) diff --git a/src/app/api/plans/[planId]/route.ts b/src/app/api/plans/[planId]/route.ts index f1e067b..9e53438 100644 --- a/src/app/api/plans/[planId]/route.ts +++ b/src/app/api/plans/[planId]/route.ts @@ -23,10 +23,13 @@ export async function GET( return NextResponse.json({ plan: planInput, computed }); } -// Name allein aendern ODER das ganze Plan-Profil (Haushaltsform/Personen/Inflation). +// Name/Cash-Anfangswert aendern ODER das ganze Plan-Profil (Haushaltsform/Personen/Inflation). const patchSchema = z.union([ - z.object({ name: z.string().min(1).max(120) }), planProfileSchema.extend({ name: z.string().min(1).max(120).optional() }), + z.object({ + name: z.string().min(1).max(120).optional(), + initialCash: z.number().min(0).max(1_000_000_000).optional(), + }), ]); export async function PATCH( @@ -67,7 +70,10 @@ export async function PATCH( const updated = await prisma.plan.update({ where: { id: plan.id }, - data: { name: data.name }, + data: { + name: "name" in data ? data.name : undefined, + initialCash: "initialCash" in data && data.initialCash != null ? Math.round(data.initialCash) : undefined, + }, }); return NextResponse.json({ plan: { id: updated.id, name: updated.name } }); } diff --git a/src/app/api/plans/[planId]/scenario/route.ts b/src/app/api/plans/[planId]/scenario/route.ts index fc08c3c..6eb82cb 100644 --- a/src/app/api/plans/[planId]/scenario/route.ts +++ b/src/app/api/plans/[planId]/scenario/route.ts @@ -40,6 +40,7 @@ export async function POST( name: parsed.data.name, householdType: source.householdType, inflationRateDefault: source.inflationRateDefault, + initialCash: source.initialCash, parentPlanId: source.id, persons: { create: source.persons.map((p) => ({ role: p.role, name: p.name, age: p.age, retirementAge: p.retirementAge })), diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx index adc1268..dbe4341 100644 --- a/src/components/PlanView.tsx +++ b/src/components/PlanView.tsx @@ -29,6 +29,7 @@ import { } from "@/components/ElementDetail"; import { PhaseDetail } from "@/components/PhaseDetail"; import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields"; +import { MoneyField } from "@/components/FormField"; import { api } from "@/lib/api-client"; import { formatChf } from "@/lib/format"; import { @@ -94,6 +95,7 @@ export function PlanView({ const [reviewFromPhaseId, setReviewFromPhaseId] = useState(null); const [editTransition, setEditTransition] = useState<{ elementId: string; fromPhaseId: string } | null>(null); const [editPhaseCell, setEditPhaseCell] = useState<{ elementId: string; phaseId: string } | null>(null); + const [showCashInit, setShowCashInit] = useState(false); const columns = useMemo(() => { const cols: Column[] = []; @@ -332,13 +334,16 @@ export function PlanView({ verfuegbares Kapital - {columns.map((col) => - col.kind === "phase" ? ( + {columns.map((col) => { + const isFirst = col.kind === "phase" && col.phase.sequenceNumber === 1; + return col.kind === "phase" ? ( setShowCashInit(true) : undefined} + title={isFirst ? "Cash-Anfangswert bearbeiten" : undefined} className={`border-b border-r border-border px-2 py-1.5 text-center text-xs ${ - col.phase.cashNegative ? "font-semibold text-danger" : "text-fg" - }`} + isFirst ? "cursor-pointer hover:bg-accent-soft" : "" + } ${col.phase.cashNegative ? "font-semibold text-danger" : "text-fg"}`} > {formatChf(col.phase.cashStart)} {formatChf(col.phase.cashEnd)} @@ -348,8 +353,8 @@ export function PlanView({ → - ) - )} + ); + })} {CATEGORY_ORDER.map((cat) => { const els = elementsByCategory.get(cat)!; @@ -475,6 +480,17 @@ export function PlanView({ /> )} + {showCashInit && ( + setShowCashInit(false)} + onSaved={() => { + setShowCashInit(false); + onChanged(); + }} + /> + )} + {reviewFromPhaseId && (() => { const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId); if (!fromPhase) return null; @@ -1038,6 +1054,35 @@ function TransitionReviewDialog({ ); } +// --- Dialog: Cash-Anfangswert (erste Lebensphase) --- +function CashInitialDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) { + const [value, setValue] = useState(plan.initialCash); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + async function save() { + setSaving(true); + setError(null); + try { + await api.patch(`/api/plans/${plan.id}`, { initialCash: value }); + onSaved(); + } catch (e) { + setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen."); + } finally { + setSaving(false); + } + } + + return ( + +

Startbestand des Cash-Kontos zu Beginn der ersten Lebensphase.

+ + {error &&

{error}

} + +
+ ); +} + // --- Dialog: Element-Werte einer Lebensphase (per Klick auf eine Phasenzelle) --- function PhaseCellDialog({ element, diff --git a/src/lib/calculations.test.ts b/src/lib/calculations.test.ts index 587d118..efde012 100644 --- a/src/lib/calculations.test.ts +++ b/src/lib/calculations.test.ts @@ -20,6 +20,7 @@ function plan(opts: { age: number; retirementAge: number; inflation?: number; + initialCash?: number; phases: { id: string; durationYears: number }[]; elements: ReturnType[]; household?: "SINGLE" | "COUPLE"; @@ -29,6 +30,7 @@ function plan(opts: { name: "T", householdType: opts.household ?? "SINGLE", inflationRateDefault: opts.inflation ?? 2, + initialCash: opts.initialCash ?? 0, persons: [{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge }], phases: opts.phases.map((p, i) => ({ id: p.id, sequenceNumber: i + 1, name: p.id, durationYears: p.durationYears, inflationRate: null })), elements: opts.elements, @@ -103,6 +105,22 @@ describe("V4 Golden Tests", () => { expect(ph.cashNegative).toBe(true); }); + it("Cash-Anfangswert fliesst in die erste Phase ein", () => { + const p = plan({ + age: 40, + retirementAge: 60, + initialCash: 50000, + phases: [{ id: "p1", durationYears: 3 }], + elements: [ + el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 } }), + el("EXPENSE", "HOUSEHOLD", { p1: { amount: 100000, teuerungsausgleich: 0 } }), + ], + }); + const ph = computePlan(p).phases[0]; + expect(ph.cashStart).toBe(50000); + expect(ph.cashEnd).toBe(50000); // Quote 0, keine Raten -> Cash unveraendert + }); + it("indexRate 0 -> Einkommen bleibt nominal flach", () => { const p = plan({ age: 40, diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index 4b35589..0ad43eb 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -124,7 +124,7 @@ export function computePlan(plan: PlanInput): PlanComputed { const result: PhaseComputed[] = []; let yearsBefore = 0; let cumulativeInflation = 1; - let cashCarryIn = 0; + let cashCarryIn = Math.round(plan.initialCash || 0); let ruinAge: number | null = null; for (let i = 0; i < phases.length; i++) { diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 20361e7..e60e749 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -31,6 +31,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput { name: plan.name, householdType: plan.householdType, inflationRateDefault: plan.inflationRateDefault, + initialCash: plan.initialCash, persons: plan.persons.map((p) => ({ id: p.id, role: p.role, diff --git a/src/lib/types.ts b/src/lib/types.ts index d113670..b721907 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -42,6 +42,7 @@ export interface PlanInput { name: string; householdType: HouseholdType; inflationRateDefault: number; + initialCash: number; // Anfangswert des Cash-Kontos in der ersten Lebensphase persons: PersonInput[]; phases: PhaseInput[]; elements: ElementInput[];