"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) => 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) => 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) => ( set({ amount: x })} /> ), phaseFields: (v, set) => ( 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) => ( set({ amount: x })} /> ), phaseFields: (v, set) => ( set({ teuerungsausgleich: x })} /> ), }, { key: "pk", category: "PENSION_FUND", label: "Pensionskasse", defaultName: "Pensionskasse", hint: "Dein Altersguthaben laut PK-Ausweis.", personOnly: true, baseFields: (v, set) => ( set({ currentValue: x })} /> ), phaseFields: (v, set) => ( <> 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). */} 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) => ( set({ currentValue: x })} /> ), phaseFields: (v, set) => ( 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) => ( set({ startValue: x })} /> ), phaseFields: (v, set) => ( set({ expectedReturn: x })} /> ), }, { key: "re", category: "REAL_ESTATE", label: "Immobilie", defaultName: "Wohneigentum", hint: "Haus oder Wohnung samt Hypothek.", baseFields: (v, set) => ( <> set({ purchasePrice: x })} /> set({ mortgage: x })} /> ), phaseFields: (v, set) => ( <> set({ interestRate: x })} /> set({ valueGrowth: x })} />
set({ interestHandling: x })} options={[ { value: "INCLUDED", label: "Ja, sind in der Lebenshaltung enthalten" }, { value: "ADD", label: "Nein, bitte zusätzlich rechnen" }, ]} />
), }, { key: "debt", category: "OTHER_DEBT", label: "Sonstige Schulden", defaultName: "Schulden", hint: "Kredite ausserhalb der Hypothek.", baseFields: (v, set) => ( 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; names: Record; // 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) => 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 (
{/* 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. */}
setDraft((d) => ({ ...d, cash: x }))} />
{scopes.length > 1 && (
{scopes.map((sc, i) => ( ))}
)} {/* Die einzige Zahl, die schon vor jeder Zeitplanung etwas aussagt -- und die erste Rueckmeldung auf die Bestandsaufnahme. */}
Vermögen heute {formatChf(today)}
); } 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(null); const [error, setError] = useState(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 (
{/* Der Knopf traegt den Kategorienamen, die Erklaerung steht darunter -- so lernt man beim ersten Kontakt die Vokabel, die auch die Matrix verwendet. */}
{kinds.map((k) => ( ))}
{mine.length === 0 ? (

Noch nichts erfasst. Wähle oben, was zutrifft – du kannst jederzeit weitere Positionen ergänzen.

) : (
{mine.map((el) => ( ))}
)} {error &&

{error}

}
); } 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) => 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 (
{CATEGORY_LABELS[el.category]}
setDraft((d) => ({ ...d, names: { ...d.names, [el.id]: v } }))} />
{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.

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.

)}
); } // 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 { 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(emptyInventoryDraft); const [saving, setSaving] = useState(false); const [error, setError] = useState(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 (
Hier stehen nur Zahlen, die du nachschlagen 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.
setDraft((d) => fn(d))} onChanged={onChanged} /> {error &&

{error}

}
); }