Neues Feld Plan.initialCash (additiv, Default 0). computePlan startet die erste Phase
mit diesem Cash-Bestand statt 0. In der Matrix ist die Cash-Zelle der ersten Phase
klickbar und oeffnet ein Popup zum Setzen des Anfangswerts (PATCH /api/plans/{id}).
Szenario-Kopie uebernimmt den Wert. Golden-Test ergaenzt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
@@ -76,6 +76,7 @@ model Plan {
|
|||||||
name String
|
name String
|
||||||
householdType HouseholdType
|
householdType HouseholdType
|
||||||
inflationRateDefault Float
|
inflationRateDefault Float
|
||||||
|
initialCash Float @default(0)
|
||||||
|
|
||||||
parentPlanId String?
|
parentPlanId String?
|
||||||
parentPlan Plan? @relation("PlanScenarios", fields: [parentPlanId], references: [id], onDelete: SetNull)
|
parentPlan Plan? @relation("PlanScenarios", fields: [parentPlanId], references: [id], onDelete: SetNull)
|
||||||
|
|||||||
@@ -23,10 +23,13 @@ export async function GET(
|
|||||||
return NextResponse.json({ plan: planInput, computed });
|
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([
|
const patchSchema = z.union([
|
||||||
z.object({ name: z.string().min(1).max(120) }),
|
|
||||||
planProfileSchema.extend({ name: z.string().min(1).max(120).optional() }),
|
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(
|
export async function PATCH(
|
||||||
@@ -67,7 +70,10 @@ export async function PATCH(
|
|||||||
|
|
||||||
const updated = await prisma.plan.update({
|
const updated = await prisma.plan.update({
|
||||||
where: { id: plan.id },
|
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 } });
|
return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export async function POST(
|
|||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
householdType: source.householdType,
|
householdType: source.householdType,
|
||||||
inflationRateDefault: source.inflationRateDefault,
|
inflationRateDefault: source.inflationRateDefault,
|
||||||
|
initialCash: source.initialCash,
|
||||||
parentPlanId: source.id,
|
parentPlanId: source.id,
|
||||||
persons: {
|
persons: {
|
||||||
create: source.persons.map((p) => ({ role: p.role, name: p.name, age: p.age, retirementAge: p.retirementAge })),
|
create: source.persons.map((p) => ({ role: p.role, name: p.name, age: p.age, retirementAge: p.retirementAge })),
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
} from "@/components/ElementDetail";
|
} from "@/components/ElementDetail";
|
||||||
import { PhaseDetail } from "@/components/PhaseDetail";
|
import { PhaseDetail } from "@/components/PhaseDetail";
|
||||||
import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields";
|
import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||||
|
import { MoneyField } from "@/components/FormField";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { formatChf } from "@/lib/format";
|
import { formatChf } from "@/lib/format";
|
||||||
import {
|
import {
|
||||||
@@ -94,6 +95,7 @@ export function PlanView({
|
|||||||
const [reviewFromPhaseId, setReviewFromPhaseId] = useState<string | null>(null);
|
const [reviewFromPhaseId, setReviewFromPhaseId] = useState<string | null>(null);
|
||||||
const [editTransition, setEditTransition] = useState<{ elementId: string; fromPhaseId: string } | null>(null);
|
const [editTransition, setEditTransition] = useState<{ elementId: string; fromPhaseId: string } | null>(null);
|
||||||
const [editPhaseCell, setEditPhaseCell] = useState<{ elementId: string; phaseId: string } | null>(null);
|
const [editPhaseCell, setEditPhaseCell] = useState<{ elementId: string; phaseId: string } | null>(null);
|
||||||
|
const [showCashInit, setShowCashInit] = useState(false);
|
||||||
|
|
||||||
const columns = useMemo<Column[]>(() => {
|
const columns = useMemo<Column[]>(() => {
|
||||||
const cols: Column[] = [];
|
const cols: Column[] = [];
|
||||||
@@ -332,13 +334,16 @@ export function PlanView({
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-faint">verfuegbares Kapital</span>
|
<span className="text-[10px] text-faint">verfuegbares Kapital</span>
|
||||||
</td>
|
</td>
|
||||||
{columns.map((col) =>
|
{columns.map((col) => {
|
||||||
col.kind === "phase" ? (
|
const isFirst = col.kind === "phase" && col.phase.sequenceNumber === 1;
|
||||||
|
return col.kind === "phase" ? (
|
||||||
<td
|
<td
|
||||||
key={`cash-${col.phase.id}`}
|
key={`cash-${col.phase.id}`}
|
||||||
|
onClick={isFirst ? () => 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 ${
|
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"}`}
|
||||||
>
|
>
|
||||||
<span className="whitespace-nowrap">
|
<span className="whitespace-nowrap">
|
||||||
{formatChf(col.phase.cashStart)} <span className="text-faint">→</span> {formatChf(col.phase.cashEnd)}
|
{formatChf(col.phase.cashStart)} <span className="text-faint">→</span> {formatChf(col.phase.cashEnd)}
|
||||||
@@ -348,8 +353,8 @@ export function PlanView({
|
|||||||
<td key={`cash-t-${col.fromPhase.id}`} className="border-b border-r border-border px-2 py-1.5 text-center text-[11px] text-faint">
|
<td key={`cash-t-${col.fromPhase.id}`} className="border-b border-r border-border px-2 py-1.5 text-center text-[11px] text-faint">
|
||||||
→
|
→
|
||||||
</td>
|
</td>
|
||||||
)
|
);
|
||||||
)}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
{CATEGORY_ORDER.map((cat) => {
|
{CATEGORY_ORDER.map((cat) => {
|
||||||
const els = elementsByCategory.get(cat)!;
|
const els = elementsByCategory.get(cat)!;
|
||||||
@@ -475,6 +480,17 @@ export function PlanView({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showCashInit && (
|
||||||
|
<CashInitialDialog
|
||||||
|
plan={plan}
|
||||||
|
onClose={() => setShowCashInit(false)}
|
||||||
|
onSaved={() => {
|
||||||
|
setShowCashInit(false);
|
||||||
|
onChanged();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{reviewFromPhaseId && (() => {
|
{reviewFromPhaseId && (() => {
|
||||||
const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId);
|
const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId);
|
||||||
if (!fromPhase) return null;
|
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<string | null>(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 (
|
||||||
|
<DialogShell title="Cash-Anfangswert" onClose={onClose}>
|
||||||
|
<p className="text-sm text-muted">Startbestand des Cash-Kontos zu Beginn der ersten Lebensphase.</p>
|
||||||
|
<MoneyField label="Anfangswert (CHF)" value={value} onChange={setValue} />
|
||||||
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
|
<DialogActions saving={saving} onConfirm={save} onClose={onClose} confirmLabel="Speichern" />
|
||||||
|
</DialogShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Dialog: Element-Werte einer Lebensphase (per Klick auf eine Phasenzelle) ---
|
// --- Dialog: Element-Werte einer Lebensphase (per Klick auf eine Phasenzelle) ---
|
||||||
function PhaseCellDialog({
|
function PhaseCellDialog({
|
||||||
element,
|
element,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ function plan(opts: {
|
|||||||
age: number;
|
age: number;
|
||||||
retirementAge: number;
|
retirementAge: number;
|
||||||
inflation?: number;
|
inflation?: number;
|
||||||
|
initialCash?: number;
|
||||||
phases: { id: string; durationYears: number }[];
|
phases: { id: string; durationYears: number }[];
|
||||||
elements: ReturnType<typeof el>[];
|
elements: ReturnType<typeof el>[];
|
||||||
household?: "SINGLE" | "COUPLE";
|
household?: "SINGLE" | "COUPLE";
|
||||||
@@ -29,6 +30,7 @@ function plan(opts: {
|
|||||||
name: "T",
|
name: "T",
|
||||||
householdType: opts.household ?? "SINGLE",
|
householdType: opts.household ?? "SINGLE",
|
||||||
inflationRateDefault: opts.inflation ?? 2,
|
inflationRateDefault: opts.inflation ?? 2,
|
||||||
|
initialCash: opts.initialCash ?? 0,
|
||||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge }],
|
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 })),
|
phases: opts.phases.map((p, i) => ({ id: p.id, sequenceNumber: i + 1, name: p.id, durationYears: p.durationYears, inflationRate: null })),
|
||||||
elements: opts.elements,
|
elements: opts.elements,
|
||||||
@@ -103,6 +105,22 @@ describe("V4 Golden Tests", () => {
|
|||||||
expect(ph.cashNegative).toBe(true);
|
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", () => {
|
it("indexRate 0 -> Einkommen bleibt nominal flach", () => {
|
||||||
const p = plan({
|
const p = plan({
|
||||||
age: 40,
|
age: 40,
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
|||||||
const result: PhaseComputed[] = [];
|
const result: PhaseComputed[] = [];
|
||||||
let yearsBefore = 0;
|
let yearsBefore = 0;
|
||||||
let cumulativeInflation = 1;
|
let cumulativeInflation = 1;
|
||||||
let cashCarryIn = 0;
|
let cashCarryIn = Math.round(plan.initialCash || 0);
|
||||||
let ruinAge: number | null = null;
|
let ruinAge: number | null = null;
|
||||||
|
|
||||||
for (let i = 0; i < phases.length; i++) {
|
for (let i = 0; i < phases.length; i++) {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
|
|||||||
name: plan.name,
|
name: plan.name,
|
||||||
householdType: plan.householdType,
|
householdType: plan.householdType,
|
||||||
inflationRateDefault: plan.inflationRateDefault,
|
inflationRateDefault: plan.inflationRateDefault,
|
||||||
|
initialCash: plan.initialCash,
|
||||||
persons: plan.persons.map((p) => ({
|
persons: plan.persons.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
role: p.role,
|
role: p.role,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface PlanInput {
|
|||||||
name: string;
|
name: string;
|
||||||
householdType: HouseholdType;
|
householdType: HouseholdType;
|
||||||
inflationRateDefault: number;
|
inflationRateDefault: number;
|
||||||
|
initialCash: number; // Anfangswert des Cash-Kontos in der ersten Lebensphase
|
||||||
persons: PersonInput[];
|
persons: PersonInput[];
|
||||||
phases: PhaseInput[];
|
phases: PhaseInput[];
|
||||||
elements: ElementInput[];
|
elements: ElementInput[];
|
||||||
|
|||||||
Reference in New Issue
Block a user