113616cc1f
Deploy App / deploy (push) Successful in 1m18s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
628 lines
23 KiB
TypeScript
628 lines
23 KiB
TypeScript
"use client";
|
||
|
||
// Die Bestandsaufnahme: alles erfassen, was heute da ist.
|
||
//
|
||
// Der einzige Sammel-Dialog, der geblieben ist. Er arbeitet ausschliesslich über die
|
||
// bestehenden Endpunkte -- was er schreibt, hätte man auch einzeln über die Matrix erfassen
|
||
// können; er nimmt einem nur das Suchen ab.
|
||
//
|
||
// Erfasst werden NUR Tatsachen: Kontostand, Guthaben, Kaufpreis, Restschuld. Renditen,
|
||
// Lohnentwicklung und Teuerung stehen bewusst nicht hier -- sie sind Annahmen, und eine
|
||
// Annahme gilt immer nur für einen Zeitraum. Sie werden in den Phasenzellen der Matrix
|
||
// gesetzt und dort bestätigt (SPEZIFIKATION 3.14).
|
||
|
||
import { useState } from "react";
|
||
import { Check, Plus, Trash2 } from "lucide-react";
|
||
import { api } from "@/lib/api-client";
|
||
import { Button, Modal, useConfirm } from "@/components/ui";
|
||
import { MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
|
||
import { formatChf } from "@/lib/format";
|
||
import { CATEGORY_LABELS, ownerLabel, num, type PhaseData } from "@/lib/elements";
|
||
import type { PlanInput } from "@/lib/types";
|
||
|
||
type Scope = "HOUSEHOLD" | "PERSON_A" | "PERSON_B";
|
||
|
||
interface Kind {
|
||
key: string;
|
||
category: string;
|
||
// Beschriftung des Knopfs. Bewusst der KATEGORIENAME und keine erfundene Umgangssprache:
|
||
// Die Matrix, die Rechenwege und der PDF-Bericht sprechen dieselbe Sprache -- wer hier eine
|
||
// andere Vokabel lernt, sucht sie spaeter vergeblich. Die Alltagssprache steht in `hint`.
|
||
label: string;
|
||
// Vorschlag fuer den Namen des angelegten Elements. Hier darf es konkret werden: Die
|
||
// Kategorie ist "Ausgaben", die Zeile heisst "Lebenshaltung".
|
||
defaultName: string;
|
||
hint: string;
|
||
personOnly?: boolean;
|
||
// SCHRITT 1 -- der Bestand von heute. Nur Zahlen, die man nachschlagen kann: Kontostand,
|
||
// Guthaben, Kaufpreis, Restschuld.
|
||
baseFields: (v: PhaseData, set: (p: Partial<PhaseData>) => void) => React.ReactNode;
|
||
// SCHRITT 2 -- die Annahmen fuer EINE Lebensphase. Alles, was pro Jahr geschieht und sich
|
||
// von Phase zu Phase aendern darf: Renditen, Lohnentwicklung, Wertsteigerung, Zins.
|
||
//
|
||
// Die Trennung ist der Kern: Ein Guthaben ist eine Tatsache, eine Rendite eine Annahme --
|
||
// und Annahmen gelten immer nur fuer einen Zeitraum. Bis 0.37 standen beide zusammen in der
|
||
// Bestandsaufnahme, wodurch eine Annahme wie eine Tatsache aussah.
|
||
phaseFields?: (v: PhaseData, set: (p: Partial<PhaseData>) => void) => React.ReactNode;
|
||
}
|
||
|
||
const KINDS: Kind[] = [
|
||
{
|
||
key: "income",
|
||
category: "INCOME",
|
||
label: "Einkommen",
|
||
defaultName: "Lohn",
|
||
hint: "Dein Nettolohn – das, was auf dem Konto ankommt.",
|
||
personOnly: true,
|
||
baseFields: (v, set) => (
|
||
<MoneyField
|
||
label="Nettolohn pro Jahr"
|
||
help="Was tatsächlich aufs Konto kommt, nach allen Abzügen. FPT rechnet für die AHV intern auf den Bruttolohn hoch."
|
||
value={num(v.amount)}
|
||
onChange={(x) => set({ amount: x })}
|
||
/>
|
||
),
|
||
phaseFields: (v, set) => (
|
||
<NumberField
|
||
label="Jährliche Lohnerhöhung (%)"
|
||
help="Nominal, also inklusive Teuerung. 1 % ist ein vorsichtiger Ausgangswert."
|
||
step={0.5}
|
||
value={num(v.teuerungsausgleich, 1)}
|
||
onChange={(x) => set({ teuerungsausgleich: x })}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
key: "expense",
|
||
category: "EXPENSE",
|
||
label: "Ausgaben",
|
||
defaultName: "Lebenshaltung",
|
||
hint: "Alles, was du im Jahr ausgibst – Wohnen, Essen, Versicherungen, Freizeit.",
|
||
baseFields: (v, set) => (
|
||
<MoneyField
|
||
label="Ausgaben pro Jahr"
|
||
help="In heutiger Kaufkraft. Die Teuerung rechnet FPT selbst dazu."
|
||
value={num(v.amount)}
|
||
onChange={(x) => set({ amount: x })}
|
||
/>
|
||
),
|
||
phaseFields: (v, set) => (
|
||
<NumberField
|
||
label="Jährliche Teuerung (%)"
|
||
help="Leer lassen heisst: die Inflationsannahme des Szenarios. Eine eigene Zahl nur, wenn du für diese Ausgaben etwas anderes erwartest."
|
||
step={0.25}
|
||
value={num(v.teuerungsausgleich)}
|
||
onChange={(x) => set({ teuerungsausgleich: x })}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
key: "pk",
|
||
category: "PENSION_FUND",
|
||
label: "Pensionskasse",
|
||
defaultName: "Pensionskasse",
|
||
hint: "Dein Altersguthaben laut PK-Ausweis.",
|
||
personOnly: true,
|
||
baseFields: (v, set) => (
|
||
<MoneyField
|
||
label="Altersguthaben heute"
|
||
help="Steht auf deinem Vorsorgeausweis."
|
||
value={num(v.currentValue)}
|
||
onChange={(x) => set({ currentValue: x })}
|
||
/>
|
||
),
|
||
phaseFields: (v, set) => (
|
||
<>
|
||
<NumberField
|
||
label="Verzinsung (%/Jahr)"
|
||
help="Der BVG-Mindestzins liegt aktuell tief; viele Kassen verzinsen etwas darüber."
|
||
step={0.25}
|
||
value={num(v.expectedReturn, 1.25)}
|
||
onChange={(x) => set({ expectedReturn: x })}
|
||
/>
|
||
{/* Der PK-Beitrag steht hier und nicht bei der Sparquote: Er stammt aus dem
|
||
Bruttolohn und belastet das Cash-Konto nicht -- aus der Quote liesse er sich
|
||
gar nicht verteilen (SPEZIFIKATION 4.6.3). */}
|
||
<MoneyField
|
||
label="Jährlicher Beitrag (Arbeitnehmer + Arbeitgeber)"
|
||
help="Die Altersgutschrift laut Vorsorgeausweis. Sie kommt aus dem Bruttolohn und schmälert deine Sparquote deshalb nicht."
|
||
value={num(v.annualContribution)}
|
||
onChange={(x) => set({ annualContribution: x })}
|
||
/>
|
||
</>
|
||
),
|
||
},
|
||
{
|
||
key: "p3a",
|
||
category: "PILLAR_3A",
|
||
label: "Säule 3a",
|
||
defaultName: "Säule 3a",
|
||
hint: "Ein Konto oder Depot der gebundenen Vorsorge. Mehrere Konten legst du einzeln an.",
|
||
personOnly: true,
|
||
baseFields: (v, set) => (
|
||
<MoneyField label="Guthaben heute" value={num(v.currentValue)} onChange={(x) => set({ currentValue: x })} />
|
||
),
|
||
phaseFields: (v, set) => (
|
||
<NumberField
|
||
label="Erwartete Rendite (%/Jahr)"
|
||
help="Auf einem Konto praktisch 0, in einer Wertschriftenlösung deutlich mehr."
|
||
step={0.5}
|
||
value={num(v.expectedReturn, 2)}
|
||
onChange={(x) => set({ expectedReturn: x })}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
key: "etf",
|
||
category: "OTHER_ASSET",
|
||
label: "Sonstiges Vermögen",
|
||
defaultName: "Wertschriften",
|
||
hint: "Depot, ETF, Sparkonto – alles freie Vermögen ausser dem Cash-Konto.",
|
||
baseFields: (v, set) => (
|
||
<MoneyField label="Wert heute" value={num(v.startValue)} onChange={(x) => set({ startValue: x })} />
|
||
),
|
||
phaseFields: (v, set) => (
|
||
<NumberField
|
||
label="Erwartete Rendite (%/Jahr)"
|
||
help="Langfristiger Durchschnitt. Für ein gemischtes Portfolio sind 3 bis 5 % ein üblicher Ansatz."
|
||
step={0.5}
|
||
value={num(v.expectedReturn, 4)}
|
||
onChange={(x) => set({ expectedReturn: x })}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
key: "re",
|
||
category: "REAL_ESTATE",
|
||
label: "Immobilie",
|
||
defaultName: "Wohneigentum",
|
||
hint: "Haus oder Wohnung samt Hypothek.",
|
||
baseFields: (v, set) => (
|
||
<>
|
||
<MoneyField
|
||
label="Kaufpreis"
|
||
help="Der ursprüngliche Kaufpreis – Grundlage der Grundstückgewinnsteuer bei einem späteren Verkauf."
|
||
value={num(v.purchasePrice)}
|
||
onChange={(x) => set({ purchasePrice: x })}
|
||
/>
|
||
<MoneyField label="Hypothek heute" value={num(v.mortgage)} onChange={(x) => set({ mortgage: x })} />
|
||
</>
|
||
),
|
||
phaseFields: (v, set) => (
|
||
<>
|
||
<NumberField
|
||
label="Hypothekarzins (%)"
|
||
step={0.25}
|
||
value={num(v.interestRate, 2)}
|
||
onChange={(x) => set({ interestRate: x })}
|
||
/>
|
||
<NumberField
|
||
label="Wertsteigerung (%/Jahr)"
|
||
help="Auf den Wert der Liegenschaft, nicht auf dein Eigenkapital – dort wirkt sie gehebelt."
|
||
step={0.25}
|
||
value={num(v.valueGrowth, 1)}
|
||
onChange={(x) => set({ valueGrowth: x })}
|
||
/>
|
||
<div className="sm:col-span-2">
|
||
<SelectField
|
||
label="Sind die Hypothekarzinsen in deinen Ausgaben schon enthalten?"
|
||
help="Wenn ja, rechnet FPT sie nicht noch einmal dazu – sonst zählten sie doppelt."
|
||
value={v.interestHandling ?? "INCLUDED"}
|
||
onChange={(x: "INCLUDED" | "ADD") => set({ interestHandling: x })}
|
||
options={[
|
||
{ value: "INCLUDED", label: "Ja, sind in der Lebenshaltung enthalten" },
|
||
{ value: "ADD", label: "Nein, bitte zusätzlich rechnen" },
|
||
]}
|
||
/>
|
||
</div>
|
||
</>
|
||
),
|
||
},
|
||
{
|
||
key: "debt",
|
||
category: "OTHER_DEBT",
|
||
label: "Sonstige Schulden",
|
||
defaultName: "Schulden",
|
||
hint: "Kredite ausserhalb der Hypothek.",
|
||
baseFields: (v, set) => (
|
||
<MoneyField label="Restschuld heute" value={num(v.startValue)} onChange={(x) => set({ startValue: x })} />
|
||
),
|
||
},
|
||
];
|
||
|
||
// Der Entwurf des GANZEN Schritts, nicht der einzelnen Karte.
|
||
//
|
||
// Zwei Gruende dafuer, dass er hier oben liegt und nicht in der Karte: Beim Wechsel des
|
||
// Reiters verschwinden die Karten des vorigen Bereichs vom Bildschirm -- mit ihrem eigenen
|
||
// Zustand waeren alle getippten Zahlen still weg. Und die Summe "Vermoegen heute" laesst sich
|
||
// nur berechnen, wenn alle Entwuerfe an EINEM Ort liegen.
|
||
export interface InventoryDraft {
|
||
base: Record<string, PhaseData>;
|
||
names: Record<string, string>;
|
||
// Cash ist kein Element, sondern eine Eigenschaft des Szenarios (`Scenario.initialCash`) --
|
||
// deshalb kein Plus-Knopf, sondern ein festes Feld. Es fehlte bis 0.39 ganz: Die Summe
|
||
// «Vermögen heute» rechnete es bereits mit, erfassen konnte man es hier aber nirgends.
|
||
// `null` = unberührt (dann wird beim Speichern auch nichts geschrieben).
|
||
cash: number | null;
|
||
}
|
||
|
||
export function emptyInventoryDraft(): InventoryDraft {
|
||
return { base: {}, names: {}, cash: null };
|
||
}
|
||
|
||
// Bestand eines Elements aus Stammdaten -- dieselbe Rechnung wie in der Kachel «Kennzahlen»,
|
||
// hier nur auf dem Entwurf statt auf dem gespeicherten Stand.
|
||
function wealthOf(category: string, d: PhaseData): number {
|
||
if (category === "OTHER_ASSET") return num(d.startValue);
|
||
if (category === "PENSION_FUND" || category === "PILLAR_3A") return num(d.currentValue);
|
||
if (category === "REAL_ESTATE") return num(d.purchasePrice) - num(d.mortgage);
|
||
if (category === "OTHER_DEBT") return -num(d.startValue);
|
||
return 0;
|
||
}
|
||
|
||
// Die Stammdaten-Felder einer Kategorie. Eigene Komponente, weil sie an ZWEI Orten
|
||
// erscheinen: im Assistenten-Schritt 1 (als Liste) und in der Matrix-Spalte «Start» (einzeln).
|
||
// Zwei Implementierungen derselben Felder liefen garantiert auseinander.
|
||
export function BaseFields({
|
||
category,
|
||
values,
|
||
set,
|
||
}: {
|
||
category: string;
|
||
values: PhaseData;
|
||
set: (patch: Partial<PhaseData>) => void;
|
||
}) {
|
||
const kind = KINDS.find((k) => k.category === category);
|
||
return <>{kind?.baseFields(values, set)}</>;
|
||
}
|
||
|
||
export function InventoryFields({
|
||
plan,
|
||
draft,
|
||
setDraft,
|
||
onChanged,
|
||
}: {
|
||
plan: PlanInput;
|
||
draft: InventoryDraft;
|
||
setDraft: (fn: (d: InventoryDraft) => InventoryDraft) => void;
|
||
onChanged: () => void;
|
||
}) {
|
||
const scopes: Scope[] =
|
||
plan.householdType === "COUPLE" ? ["HOUSEHOLD", "PERSON_A", "PERSON_B"] : ["PERSON_A"];
|
||
const [page, setPage] = useState(0);
|
||
const scope = scopes[page];
|
||
const cash = draft.cash ?? Math.round(plan.initialCash || 0);
|
||
|
||
// Live-Summe aus dem ENTWURF: Sie soll sich beim Tippen bewegen, nicht erst beim Speichern.
|
||
const today = plan.elements.reduce(
|
||
(sum, e) => sum + wealthOf(e.category, draft.base[e.id] ?? e.baseData ?? {}),
|
||
cash
|
||
);
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
{/* Cash steht ausserhalb der Reiter: Es ist kein Element und gehört keiner Person --
|
||
es ist der Topf, aus dem alles fliesst und in den alles zurückfliesst. */}
|
||
<div className="rounded-xl border border-border bg-surface-2 p-3">
|
||
<MoneyField
|
||
label="Cash heute (Konto- und Sparguthaben)"
|
||
help="Alles, was sofort verfügbar ist. Aus diesem Topf zahlt FPT die Ausgaben; was du sparst und nicht zuteilst, bleibt hier liegen."
|
||
value={cash}
|
||
onChange={(x) => setDraft((d) => ({ ...d, cash: x }))}
|
||
/>
|
||
</div>
|
||
|
||
{scopes.length > 1 && (
|
||
<div className="flex gap-1 rounded-lg border border-border bg-surface-2 p-1 text-xs">
|
||
{scopes.map((sc, i) => (
|
||
<button
|
||
key={sc}
|
||
type="button"
|
||
onClick={() => setPage(i)}
|
||
className={`flex-1 rounded-md px-2 py-1 font-medium ${
|
||
page === i ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface"
|
||
}`}
|
||
>
|
||
{sc === "HOUSEHOLD" ? "Gemeinsam" : ownerLabel(plan.persons, sc)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<ScopeElements plan={plan} scope={scope} draft={draft} setDraft={setDraft} onChanged={onChanged} />
|
||
|
||
{/* Die einzige Zahl, die schon vor jeder Zeitplanung etwas aussagt -- und die erste
|
||
Rueckmeldung auf die Bestandsaufnahme. */}
|
||
<div className="sticky bottom-0 flex items-baseline justify-between gap-3 border-t border-border bg-surface px-1 pt-3">
|
||
<span className="text-xs font-semibold uppercase tracking-wide text-faint">Vermögen heute</span>
|
||
<span className="text-lg font-semibold tabular-nums text-fg">{formatChf(today)}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ScopeElements({
|
||
plan,
|
||
scope,
|
||
draft,
|
||
setDraft,
|
||
onChanged,
|
||
}: {
|
||
plan: PlanInput;
|
||
scope: Scope;
|
||
draft: InventoryDraft;
|
||
setDraft: (fn: (d: InventoryDraft) => InventoryDraft) => void;
|
||
onChanged: () => void;
|
||
}) {
|
||
const [busy, setBusy] = useState<string | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const kinds = KINDS.filter((k) => (scope === "HOUSEHOLD" ? !k.personOnly : true));
|
||
const mine = plan.elements.filter((e) => (e.ownerRole ?? "HOUSEHOLD") === scope);
|
||
|
||
// Das Element entsteht SOFORT -- es braucht eine Id, an der die Werte haengen. Nur die
|
||
// Zahlen werden bis zum Abschluss des Schritts zurueckgehalten.
|
||
async function add(kind: Kind) {
|
||
setBusy(kind.key);
|
||
setError(null);
|
||
try {
|
||
const suffix =
|
||
plan.householdType === "COUPLE" && scope !== "HOUSEHOLD" ? ` ${ownerLabel(plan.persons, scope)}` : "";
|
||
await api.post(`/api/scenarios/${plan.id}/elements`, {
|
||
category: kind.category,
|
||
name: `${kind.defaultName}${suffix}`,
|
||
ownerRole: scope,
|
||
});
|
||
onChanged();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Anlegen fehlgeschlagen.");
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-3">
|
||
{/* Der Knopf traegt den Kategorienamen, die Erklaerung steht darunter -- so lernt man
|
||
beim ersten Kontakt die Vokabel, die auch die Matrix verwendet. */}
|
||
<div className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">
|
||
{kinds.map((k) => (
|
||
<button
|
||
key={k.key}
|
||
type="button"
|
||
disabled={busy === k.key}
|
||
onClick={() => add(k)}
|
||
className="flex items-start gap-1.5 rounded-lg border border-dashed border-border px-2.5 py-1.5 text-left transition-colors hover:border-accent hover:bg-accent-soft/20"
|
||
>
|
||
<Plus className="mt-0.5 h-3.5 w-3.5 shrink-0 text-accent" />
|
||
<span className="min-w-0">
|
||
<span className="block text-xs font-semibold text-fg">{k.label}</span>
|
||
<span className="block text-[11px] leading-snug text-muted">{k.hint}</span>
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{mine.length === 0 ? (
|
||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
|
||
Noch nichts erfasst. Wähle oben, was zutrifft – du kannst jederzeit weitere Positionen ergänzen.
|
||
</p>
|
||
) : (
|
||
<div className="flex flex-col gap-2">
|
||
{mine.map((el) => (
|
||
<BaseDataCard
|
||
key={el.id}
|
||
plan={plan}
|
||
elementId={el.id}
|
||
draft={draft}
|
||
setDraft={setDraft}
|
||
onChanged={onChanged}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
{error && <p className="text-sm text-danger">{error}</p>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BaseDataCard({
|
||
plan,
|
||
elementId,
|
||
draft,
|
||
setDraft,
|
||
onChanged,
|
||
}: {
|
||
plan: PlanInput;
|
||
elementId: string;
|
||
draft: InventoryDraft;
|
||
setDraft: (fn: (d: InventoryDraft) => InventoryDraft) => void;
|
||
onChanged: () => void;
|
||
}) {
|
||
const el = plan.elements.find((x) => x.id === elementId)!;
|
||
const kind = KINDS.find((k) => k.category === el.category);
|
||
const values = draft.base[el.id] ?? el.baseData ?? {};
|
||
const name = draft.names[el.id] ?? el.name;
|
||
|
||
const set = (patch: Partial<PhaseData>) =>
|
||
setDraft((d) => ({ ...d, base: { ...d.base, [el.id]: { ...(d.base[el.id] ?? el.baseData ?? {}), ...patch } } }));
|
||
|
||
async function remove() {
|
||
await api.delete(`/api/elements/${el.id}`);
|
||
setDraft((d) => {
|
||
const base = { ...d.base };
|
||
const names = { ...d.names };
|
||
delete base[el.id];
|
||
delete names[el.id];
|
||
return { ...d, base, names };
|
||
});
|
||
onChanged();
|
||
}
|
||
|
||
return (
|
||
<div className="rounded-xl border border-border p-3">
|
||
<div className="mb-2 flex items-center gap-2">
|
||
<span className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||
{CATEGORY_LABELS[el.category]}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={remove}
|
||
className="ml-auto rounded p-1 text-faint hover:bg-danger-soft hover:text-danger"
|
||
aria-label="Entfernen"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
<div className="sm:col-span-2">
|
||
<TextField
|
||
label="Bezeichnung"
|
||
value={name}
|
||
onChange={(v) => setDraft((d) => ({ ...d, names: { ...d.names, [el.id]: v } }))}
|
||
/>
|
||
</div>
|
||
{kind ? (
|
||
kind.baseFields(values, set)
|
||
) : (
|
||
// Die AHV ist die einzige Kategorie ohne Bestand: Es gibt kein Guthaben zum
|
||
// Nachschlagen, nur eine Beitragskarriere. Eine leere Karte sähe nach einem Fehler
|
||
// aus -- deshalb steht hier, warum nichts zu tun ist.
|
||
<p className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||
Hier gibt es nichts zu erfassen. Die AHV-Rente entsteht aus deiner Beitragskarriere – FPT leitet sie
|
||
aus den Einkommen dieses Plans ab. Beim Übergang in die Pension kannst du sie prüfen und um
|
||
Beitragslücken vor Planbeginn ergänzen.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Schreibt den gesammelten Entwurf. Wird vom Schritt-Dialog beim Abschliessen aufgerufen --
|
||
// es gibt bewusst keinen Speichern-Knopf je Karte mehr.
|
||
export async function saveInventoryDraft(plan: PlanInput, draft: InventoryDraft): Promise<void> {
|
||
for (const el of plan.elements) {
|
||
const nextName = draft.names[el.id]?.trim();
|
||
if (nextName && nextName !== el.name) {
|
||
await api.patch(`/api/elements/${el.id}`, { name: nextName });
|
||
}
|
||
const base = draft.base[el.id];
|
||
if (base) await api.put(`/api/elements/${el.id}/base`, base);
|
||
}
|
||
// Cash hängt am Szenario, nicht an einem Element -- eigener Endpunkt.
|
||
if (draft.cash !== null && draft.cash !== plan.initialCash) {
|
||
await api.patch(`/api/scenarios/${plan.id}`, { initialCash: draft.cash });
|
||
}
|
||
|
||
// Die AHV entsteht am Schluss von selbst -- je Person eine.
|
||
//
|
||
// Sie hat als einzige Kategorie KEINEN Bestand: Es gibt kein Guthaben, das man nachschlagen
|
||
// könnte, nur eine Beitragskarriere. Ein Plus-Knopf mit einer leeren Karte darunter wäre
|
||
// deshalb sinnlos gewesen. Weglassen ist aber keine Option: In der Schweiz hat jede Person
|
||
// eine AHV, und ohne das Element rechnet der Plan ab der Pensionierung mit einer Rente von 0.
|
||
//
|
||
// IDEMPOTENT: Wer die Bestandsaufnahme später korrigiert und erneut abschliesst, bekommt
|
||
// kein zweites Element -- geprüft wird je Person, nicht global.
|
||
for (const person of plan.persons) {
|
||
const exists = plan.elements.some((e) => e.category === "AHV" && e.ownerRole === person.role);
|
||
if (exists) continue;
|
||
const name = person.name?.trim() ? `AHV ${person.name.trim()}` : "AHV";
|
||
await api.post(`/api/scenarios/${plan.id}/elements`, {
|
||
category: "AHV",
|
||
name,
|
||
ownerRole: person.role,
|
||
});
|
||
}
|
||
}
|
||
|
||
|
||
|
||
// --- Der Dialog drumherum -------------------------------------------------------------------
|
||
//
|
||
// Gespeichert wird EINMAL, beim Abschliessen. Ein Speichern-Knopf je Element hätte bedeutet,
|
||
// dass ein Reiter-Wechsel die getippten Zahlen verwirft -- deshalb liegt der Entwurf hier
|
||
// oben und nicht in den einzelnen Karten.
|
||
export function InventoryDialog({
|
||
plan,
|
||
onClose,
|
||
onChanged,
|
||
onSaved,
|
||
}: {
|
||
plan: PlanInput;
|
||
onClose: () => void;
|
||
// Der Plan hat sich geaendert (Element angelegt oder geloescht) -- neu laden, aber den
|
||
// Dialog OFFEN lassen. Getrennt von onSaved, denn das schliesst ihn.
|
||
onChanged: () => void;
|
||
onSaved: () => void;
|
||
}) {
|
||
const [draft, setDraft] = useState<InventoryDraft>(emptyInventoryDraft);
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const confirm = useConfirm();
|
||
|
||
const dirty =
|
||
Object.keys(draft.base).length > 0 ||
|
||
Object.keys(draft.names).length > 0 ||
|
||
(draft.cash !== null && draft.cash !== plan.initialCash);
|
||
|
||
async function closeGuarded() {
|
||
if (!dirty) return onClose();
|
||
const ok = await confirm({
|
||
title: "Eingaben verwerfen?",
|
||
message:
|
||
"Du hast Angaben erfasst, die noch nicht gespeichert sind. Schliesst du jetzt, gehen sie verloren. " +
|
||
"Mit «Bestandsaufnahme abschliessen» werden sie übernommen.",
|
||
confirmLabel: "Verwerfen",
|
||
danger: true,
|
||
});
|
||
if (ok) onClose();
|
||
}
|
||
|
||
async function save() {
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
await saveInventoryDraft(plan, draft);
|
||
setDraft(emptyInventoryDraft());
|
||
onSaved();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Modal
|
||
title="Bestandsaufnahme"
|
||
subtitle="Alles, was du heute hast – Einkommen, Ausgaben, Guthaben, Immobilien, Schulden"
|
||
onClose={closeGuarded}
|
||
xwide
|
||
>
|
||
<div className="flex flex-col gap-4">
|
||
<div className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
|
||
Hier stehen nur Zahlen, die du <strong className="text-fg">nachschlagen</strong> kannst. Renditen,
|
||
Lohnentwicklung und Teuerung kommen später – sie sind Annahmen, und eine Annahme gilt immer nur für
|
||
einen bestimmten Zeitraum. Du triffst sie in den Lebensphasen.
|
||
</div>
|
||
|
||
<InventoryFields
|
||
plan={plan}
|
||
draft={draft}
|
||
setDraft={(fn) => setDraft((d) => fn(d))}
|
||
onChanged={onChanged}
|
||
/>
|
||
|
||
{error && <p className="text-sm text-danger">{error}</p>}
|
||
<div className="flex items-center justify-between border-t border-border pt-3">
|
||
<Button variant="ghost" onClick={closeGuarded}>
|
||
Abbrechen
|
||
</Button>
|
||
<Button disabled={saving} onClick={save}>
|
||
<Check className="h-4 w-4" /> {saving ? "Wird gespeichert…" : "Bestandsaufnahme abschliessen"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|