Uebersicht der offenen Punkte statt Assistent, Bestaetigung je Phasenzelle
Deploy App / deploy (push) Successful in 1m55s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 15:02:15 +02:00
parent 6ff144d7e1
commit f22b0a3f27
22 changed files with 710 additions and 1006 deletions
+568
View File
@@ -0,0 +1,568 @@
"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>;
}
export function emptyInventoryDraft(): InventoryDraft {
return { base: {}, names: {} };
}
// 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];
// 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 ?? {}),
plan.initialCash || 0
);
return (
<div className="flex flex-col gap-4">
{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 { 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?.baseFields(values, set)}
</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);
}
}
// --- 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,
onSaved,
}: {
plan: PlanInput;
onClose: () => 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;
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={onSaved}
/>
{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>
);
}