Files
FPT/src/components/InventoryDialog.tsx
T
2026-08-17 20:04:45 +02:00

628 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}