Bestandsaufnahme: Kategorienamen, Sammel-Speichern, Matrix-Spalte Start
Deploy App / deploy (push) Successful in 1m39s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 11:37:16 +02:00
parent 42d5585969
commit 2f6b788bd4
5 changed files with 454 additions and 98 deletions
+45 -10
View File
@@ -11,7 +11,7 @@
import { useState } from "react";
import { ArrowLeft, ArrowRight, Check, Lock } from "lucide-react";
import { Button, Modal } from "@/components/ui";
import { Button, Modal, useConfirm } from "@/components/ui";
import { ASSISTANT_STEPS, stepBlockedReason } from "@/lib/assistant";
import type { PlanInput } from "@/lib/types";
@@ -188,6 +188,12 @@ export function AssistantStepDialog({
isDone,
onToggleDone,
onClose,
// Gibt es ungespeicherte Eingaben? Der Dialog fragt dann beim Schliessen nach, statt sie
// still zu verwerfen -- ohne Speichern-Knopf je Feld wäre das sonst die einzige Stelle,
// an der Arbeit verloren gehen kann.
dirty = false,
// Wird beim Abschliessen aufgerufen und schreibt, was das Werkzeug gesammelt hat.
onCommit,
children,
}: {
plan: PlanInput;
@@ -195,6 +201,8 @@ export function AssistantStepDialog({
isDone: boolean;
onToggleDone: (done: boolean) => void;
onClose: () => void;
dirty?: boolean;
onCommit?: () => Promise<void>;
// Das Werkzeug des Schritts. Fehlt es, ist der Schritt reine Information.
children?: React.ReactNode;
}) {
@@ -202,30 +210,57 @@ export function AssistantStepDialog({
const explainer = STEP_EXPLAINERS[stepIndex];
const blocked = stepBlockedReason(plan, stepIndex);
const [showTool, setShowTool] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const confirm = useConfirm();
const canGuide = step.guided && !blocked && !!children;
// Schliessen mit ungespeicherten Eingaben: nachfragen statt still verwerfen.
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 «Schritt abschliessen» werden sie übernommen.",
confirmLabel: "Verwerfen",
danger: true,
});
if (ok) onClose();
}
async function finish() {
setSaving(true);
setError(null);
try {
if (onCommit) await onCommit();
onToggleDone(true);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<Modal
title={`Schritt ${stepIndex + 1}: ${step.title}`}
subtitle={showTool ? undefined : step.lead}
onClose={onClose}
onClose={closeGuarded}
xwide
>
{showTool ? (
<div className="flex flex-col gap-4">
{children}
{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={() => setShowTool(false)}>
<ArrowLeft className="h-4 w-4" /> Zurück zur Erklärung
</Button>
<Button
onClick={() => {
onToggleDone(true);
onClose();
}}
>
<Check className="h-4 w-4" /> Schritt abschliessen
<Button disabled={saving} onClick={finish}>
<Check className="h-4 w-4" /> {saving ? "Wird gespeichert…" : "Schritt abschliessen"}
</Button>
</div>
</div>
@@ -270,7 +305,7 @@ export function AssistantStepDialog({
variant="ghost"
onClick={() => {
onToggleDone(!isDone);
onClose();
void closeGuarded();
}}
>
{isDone ? "Wieder als offen markieren" : "Selbst erledigen"}
+149 -74
View File
@@ -7,7 +7,7 @@
// Matrix schreiben können; er nimmt einem nur das Suchen ab.
import { useState } from "react";
import { AlertTriangle, ArrowLeft, ArrowRight, Check, Plus, Trash2 } from "lucide-react";
import { AlertTriangle, ArrowLeft, ArrowRight, Plus, Trash2 } from "lucide-react";
import { api } from "@/lib/api-client";
import { Button } from "@/components/ui";
import { MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
@@ -19,7 +19,6 @@ import {
DEFAULT_PLANNING_HORIZON_YEARS,
MAX_PLANNING_HORIZON_YEARS,
MIN_PLANNING_HORIZON_YEARS,
PILLAR_3A_MAX_ANNUAL,
} from "@/lib/constants";
import { planSegments, defaultOpenDuration, type PlanSegment } from "@/lib/phaseplan";
import { fixpointPersonsOf, type PlanComputed } from "@/lib/calculations";
@@ -38,7 +37,13 @@ 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;
fields: (v: PhaseData, set: (p: Partial<PhaseData>) => void, plan: PlanInput) => React.ReactNode;
@@ -48,8 +53,9 @@ const KINDS: Kind[] = [
{
key: "income",
category: "INCOME",
label: "Erwerbseinkommen",
hint: "Dein Nettolohn also das, was auf dem Konto ankommt.",
label: "Einkommen",
defaultName: "Lohn",
hint: "Dein Nettolohn das, was auf dem Konto ankommt.",
personOnly: true,
fields: (v, set) => (
<>
@@ -72,7 +78,8 @@ const KINDS: Kind[] = [
{
key: "expense",
category: "EXPENSE",
label: "Lebenshaltung",
label: "Ausgaben",
defaultName: "Lebenshaltung",
hint: "Alles, was du im Jahr ausgibst Wohnen, Essen, Versicherungen, Freizeit.",
fields: (v, set) => (
<MoneyField
@@ -87,6 +94,7 @@ const KINDS: Kind[] = [
key: "pk",
category: "PENSION_FUND",
label: "Pensionskasse",
defaultName: "Pensionskasse",
hint: "Dein Altersguthaben laut PK-Ausweis.",
personOnly: true,
fields: (v, set) => (
@@ -111,6 +119,7 @@ const KINDS: Kind[] = [
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,
fields: (v, set) => (
@@ -133,7 +142,8 @@ const KINDS: Kind[] = [
{
key: "etf",
category: "OTHER_ASSET",
label: "Wertschriften und Sparguthaben",
label: "Sonstiges Vermögen",
defaultName: "Wertschriften",
hint: "Depot, ETF, Sparkonto alles freie Vermögen ausser dem Cash-Konto.",
fields: (v, set) => (
<>
@@ -151,7 +161,8 @@ const KINDS: Kind[] = [
{
key: "re",
category: "REAL_ESTATE",
label: "Wohneigentum",
label: "Immobilie",
defaultName: "Wohneigentum",
hint: "Haus oder Wohnung samt Hypothek.",
fields: (v, set) => (
<>
@@ -197,7 +208,8 @@ const KINDS: Kind[] = [
{
key: "debt",
category: "OTHER_DEBT",
label: "Schulden",
label: "Sonstige Schulden",
defaultName: "Schulden",
hint: "Kredite ausserhalb der Hypothek.",
fields: (v, set) => (
<MoneyField label="Restschuld heute" value={num(v.startValue)} onChange={(x) => set({ startValue: x })} />
@@ -205,12 +217,71 @@ const KINDS: Kind[] = [
},
];
export function Step1Elements({ plan, onChanged }: { plan: PlanInput; onChanged: () => void }) {
// 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 Step1Draft {
base: Record<string, PhaseData>;
names: Record<string, string>;
}
export function emptyStep1Draft(): Step1Draft {
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,
plan,
}: {
category: string;
values: PhaseData;
set: (patch: Partial<PhaseData>) => void;
plan: PlanInput;
}) {
const kind = KINDS.find((k) => k.category === category);
return <>{kind?.fields(values, set, plan)}</>;
}
export function Step1Elements({
plan,
draft,
setDraft,
onChanged,
}: {
plan: PlanInput;
draft: Step1Draft;
setDraft: (fn: (d: Step1Draft) => Step1Draft) => 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 && (
@@ -230,22 +301,14 @@ export function Step1Elements({ plan, onChanged }: { plan: PlanInput; onChanged:
</div>
)}
<ScopeElements plan={plan} scope={scope} onChanged={onChanged} />
<ScopeElements plan={plan} scope={scope} draft={draft} setDraft={setDraft} onChanged={onChanged} />
{scopes.length > 1 && (
<div className="flex justify-between">
<Button variant="ghost" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
<Button
variant="secondary"
disabled={page === scopes.length - 1}
onClick={() => setPage((p) => p + 1)}
>
Weiter <ArrowRight className="h-4 w-4" />
</Button>
</div>
)}
{/* 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>
);
}
@@ -253,10 +316,14 @@ export function Step1Elements({ plan, onChanged }: { plan: PlanInput; onChanged:
function ScopeElements({
plan,
scope,
draft,
setDraft,
onChanged,
}: {
plan: PlanInput;
scope: Scope;
draft: Step1Draft;
setDraft: (fn: (d: Step1Draft) => Step1Draft) => void;
onChanged: () => void;
}) {
const [busy, setBusy] = useState<string | null>(null);
@@ -264,14 +331,17 @@ function ScopeElements({
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 label = plan.householdType === "COUPLE" && scope !== "HOUSEHOLD" ? ` ${ownerLabel(plan.persons, scope)}` : "";
const suffix =
plan.householdType === "COUPLE" && scope !== "HOUSEHOLD" ? ` ${ownerLabel(plan.persons, scope)}` : "";
await api.post(`/api/scenarios/${plan.id}/elements`, {
category: kind.category,
name: `${kind.label}${label}`,
name: `${kind.defaultName}${suffix}`,
ownerRole: scope,
});
onChanged();
@@ -284,16 +354,22 @@ function ScopeElements({
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
{/* 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-center gap-1.5 rounded-lg border border-dashed border-border px-2.5 py-1.5 text-xs font-medium text-muted transition-colors hover:border-accent hover:text-accent-soft-fg"
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="h-3.5 w-3.5" /> {k.label}
<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>
@@ -305,7 +381,14 @@ function ScopeElements({
) : (
<div className="flex flex-col gap-2">
{mine.map((el) => (
<BaseDataCard key={el.id} plan={plan} elementId={el.id} onChanged={onChanged} />
<BaseDataCard
key={el.id}
plan={plan}
elementId={el.id}
draft={draft}
setDraft={setDraft}
onChanged={onChanged}
/>
))}
</div>
)}
@@ -317,43 +400,36 @@ function ScopeElements({
function BaseDataCard({
plan,
elementId,
draft,
setDraft,
onChanged,
}: {
plan: PlanInput;
elementId: string;
draft: Step1Draft;
setDraft: (fn: (d: Step1Draft) => Step1Draft) => void;
onChanged: () => void;
}) {
const el = plan.elements.find((x) => x.id === elementId)!;
const kind = KINDS.find((k) => k.category === el.category);
const [draft, setDraft] = useState<PhaseData>(el.baseData ?? {});
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [name, setName] = useState(el.name);
const values = draft.base[el.id] ?? el.baseData ?? {};
const name = draft.names[el.id] ?? el.name;
async function save() {
setSaving(true);
try {
if (name.trim() && name.trim() !== el.name) {
await api.patch(`/api/elements/${el.id}`, { name: name.trim() });
}
await api.put(`/api/elements/${el.id}/base`, draft);
setSaved(true);
onChanged();
} finally {
setSaving(false);
}
}
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();
}
const set = (p: Partial<PhaseData>) => {
setDraft((d) => ({ ...d, ...p }));
setSaved(false);
};
return (
<div className="rounded-xl border border-border p-3">
<div className="mb-2 flex items-center gap-2">
@@ -371,26 +447,31 @@ function BaseDataCard({
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<TextField label="Bezeichnung" value={name} onChange={setName} />
<TextField
label="Bezeichnung"
value={name}
onChange={(v) => setDraft((d) => ({ ...d, names: { ...d.names, [el.id]: v } }))}
/>
</div>
{kind?.fields(draft, set, plan)}
{el.category === "PILLAR_3A" && (
<p className="sm:col-span-2 text-xs text-faint">
Hinweis: Der jährliche Höchstbetrag liegt bei {formatChf(PILLAR_3A_MAX_ANNUAL)}. Wie viel du einzahlst,
legst du in Schritt 4 fest.
</p>
)}
</div>
<div className="mt-3 flex items-center gap-2">
<Button size="sm" disabled={saving} onClick={save}>
{saving ? "…" : saved ? "Gespeichert" : "Speichern"}
</Button>
{saved && <Check className="h-4 w-4 text-success" />}
{kind?.fields(values, set, plan)}
</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 saveStep1Draft(plan: PlanInput, draft: Step1Draft): 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);
}
}
// ============================================================================================
// Schritt 2: Eckdaten und Pensionsplanung
// ============================================================================================
@@ -435,14 +516,8 @@ export function Step2Timeline({
/>
)}
<div className="flex justify-between">
<Button variant="ghost" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
<Button variant="secondary" disabled={page === pages.length - 1} onClick={() => setPage((p) => p + 1)}>
Weiter <ArrowRight className="h-4 w-4" />
</Button>
</div>
{/* Kein Zurueck/Weiter: Die Reiter oben sind der Weg durch die Bereiche. Zwei
Navigationen fuer dieselbe Bewegung sind eine zu viel. */}
</div>
);
}
+204 -9
View File
@@ -13,6 +13,7 @@ import {
Pencil,
Maximize2,
PiggyBank,
Sparkles,
Copy,
HelpCircle,
History as HistoryIcon,
@@ -50,6 +51,10 @@ import {
Step2Timeline,
Step3Phases,
StepWalkthrough,
BaseFields,
emptyStep1Draft,
saveStep1Draft,
type Step1Draft,
} from "@/components/AssistantSteps";
import { normalizeProgress } from "@/lib/assistant";
import { RetirementPanel } from "@/components/RetirementPanel";
@@ -125,6 +130,8 @@ type Panel =
| { kind: "transition"; elementId: string; fromPhaseId: string }
| { kind: "cashTransition"; fromPhaseId: string }
| { kind: "cashInitial" }
// Stammdaten eines Elements: der Bestand bei Planbeginn (Spalte «Start»).
| { kind: "base"; elementId: string }
| { kind: "profile" };
export function PlanView({
@@ -204,6 +211,10 @@ export function PlanView({
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
// Offener Assistenten-Schritt (Index) bzw. offene Pensionsplanung (Rolle).
const [assistantStep, setAssistantStep] = useState<number | null>(null);
// Der Entwurf der Bestandsaufnahme. Er liegt bewusst HIER und nicht in den einzelnen
// Karten: Beim Wechsel des Reiters (Gemeinsam -> Person A) verschwinden die Karten des
// vorigen Bereichs, und mit ihrem eigenen Zustand waeren alle getippten Zahlen still weg.
const [step1Draft, setStep1Draft] = useState<Step1Draft>(emptyStep1Draft);
const [retirementRole, setRetirementRole] = useState<PersonRole | null>(null);
const progress = normalizeProgress(plan.assistantProgress);
// Die Tour (Start-Knopf, Auto-Start bei Plan-Erstellung, Rendering) liegt seit dem
@@ -432,14 +443,14 @@ export function PlanView({
</div>
</div>
{!hasPhases && (
{!hasPhases && plan.elements.length === 0 && (
<EmptyState
icon={<Plus className="h-6 w-6" />}
title="Noch keine Lebensphasen"
text="Deine Planung besteht aus Lebensphasen Abschnitten, in denen deine Finanzen ähnlich laufen (z. B. «bis zur Pension»). Leg die erste an, danach kommen die finanziellen Elemente."
title="Noch nichts erfasst"
text="Fang mit dem an, was du heute hast Einkommen, Ausgaben, Guthaben, Immobilien. Die Lebensphasen kommen danach; sie ergeben sich aus deiner Pensionsplanung."
>
<Button onClick={() => setShowAddPhase(true)}>
<Plus className="h-4 w-4" /> Erste Lebensphase
<Button onClick={() => setAssistantStep(0)}>
<Sparkles className="h-4 w-4" /> Mit dem Assistenten starten
</Button>
</EmptyState>
)}
@@ -463,8 +474,12 @@ export function PlanView({
)}
{/* Matrix: eigener Scrollbereich, damit Phasen-Köpfe (oben) UND Elementnamen (links)
beim Scrollen sichtbar bleiben. */}
{hasPhases && (
beim Scrollen sichtbar bleiben.
Sie erscheint, sobald es ELEMENTE gibt -- Lebensphasen sind dafür nicht nötig. Bis
0.36 hing sie an den Phasen, wodurch die Bestandsaufnahme aus Schritt 1 im
Nichts verschwand: erfasst, gespeichert, unsichtbar. */}
{(hasPhases || plan.elements.length > 0) && (
<div data-tour="matrix" className="max-h-[75vh] overflow-auto rounded-xl border border-border bg-surface shadow-sm">
{/* Feste Spaltenbreiten: Alle Phasenspalten sind gleich breit -- bei einer einzigen
Phase bleibt die Tabelle dadurch schmal, bei vielen wird horizontal gescrollt.
@@ -557,6 +572,17 @@ export function PlanView({
)}
</div>
</th>
{/* Die STAMMDATEN-Spalte. Sie zeigt den Bestand bei Planbeginn -- die einzige
Grösse, die keiner Phase gehört (SPEZIFIKATION 3.14.3). Ohne sie gäbe es
keinen Ort, an dem sich ein Startwert ändern liesse, sobald Phasen
existieren: Man bearbeitete ihn in Phase 1 und schriebe dabei still einen
Phasenwert, der die Stammdaten überdeckt. */}
<th className="sticky top-0 z-30 w-28 min-w-28 border-b border-r border-border bg-surface-2 px-2 py-2 text-center align-top">
<div className="text-xs font-semibold text-fg">Start</div>
<div className="text-[10px] font-normal text-faint">
{plan.startYear ? plan.startYear : "bei Planbeginn"}
</div>
</th>
{columns.map((col) =>
col.kind === "phase" ? (
<PhaseHeader
@@ -586,6 +612,18 @@ export function PlanView({
/>
)
)}
{!hasPhases && (
<th className="sticky top-0 z-30 border-b border-border bg-surface-2 px-3 py-2 text-left align-top">
<div className="text-xs font-semibold text-fg">Noch keine Lebensphasen</div>
<button
type="button"
onClick={() => setAssistantStep(1)}
className="mt-1 rounded-lg border border-dashed border-accent px-2 py-1 text-[11px] font-normal text-accent-soft-fg hover:bg-accent-soft/30"
>
Weiter mit der Pensionsplanung
</button>
</th>
)}
</tr>
</thead>
<tbody>
@@ -597,6 +635,16 @@ export function PlanView({
</span>
<span className="text-[10px] text-faint">verfügbares Kapital</span>
</td>
<td
onClick={() => setPanel({ kind: "cashInitial" })}
title="Cash-Anfangswert bearbeiten"
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-xs text-fg hover:bg-accent-soft ${
diff?.cashInitialChanged ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : ""
}`}
>
{formatChf(Math.round(plan.initialCash || 0))}
</td>
{!hasPhases && <td className="border-b border-border" />}
{columns.map((col) => {
const isFirst = col.kind === "phase" && col.phase.sequenceNumber === 1;
return col.kind === "phase" ? (
@@ -665,6 +713,16 @@ export function PlanView({
{CATEGORY_LABELS[cat]}
</span>
</td>
{/* Stammdaten-Summe der Kategorie. */}
<td className="border-b border-r border-border bg-surface-2 px-2 py-1.5 text-center text-xs font-semibold text-muted">
{collapsed
? (() => {
const sum = els.reduce((n, e) => n + baseAmount(e), 0);
return sum !== 0 ? formatChf(sum) : "";
})()
: ""}
</td>
{!hasPhases && <td className="border-b border-border bg-surface-2" />}
{/* Zugeklappt: Summe der Elementwerte je Phase -- sonst verschwindet die
Information beim Zuklappen. Aufgeklappt bleibt die Zeile leer, weil
man die Einzelwerte darunter sieht. */}
@@ -757,6 +815,16 @@ export function PlanView({
className="mt-0.5"
/>
</td>
{/* Stammdaten: der Bestand bei Planbeginn. Ein Klick öffnet dieselbe
Bearbeitung wie im Assistenten-Schritt 1. */}
<td
onClick={() => setPanel({ kind: "base", elementId: el.id })}
title="Bestand bei Planbeginn bearbeiten"
className="cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-xs text-fg hover:bg-accent-soft"
>
{baseCellText(el)}
</td>
{!hasPhases && <td className="border-b border-border" />}
{columns.map((col) => {
if (col.kind === "phase") {
const ce = computedElement(col.phase.id, el.id);
@@ -873,9 +941,29 @@ export function PlanView({
await api.post(`/api/scenarios/${plan.id}/assistant`, { step: assistantStep, done });
onChanged();
}}
onClose={() => setAssistantStep(null)}
onClose={() => {
setAssistantStep(null);
setStep1Draft(emptyStep1Draft());
}}
dirty={
assistantStep === 0 &&
(Object.keys(step1Draft.base).length > 0 || Object.keys(step1Draft.names).length > 0)
}
onCommit={async () => {
if (assistantStep === 0) {
await saveStep1Draft(plan, step1Draft);
setStep1Draft(emptyStep1Draft());
}
}}
>
{assistantStep === 0 && <Step1Elements plan={plan} onChanged={onChanged} />}
{assistantStep === 0 && (
<Step1Elements
plan={plan}
draft={step1Draft}
setDraft={(fn) => setStep1Draft((d) => fn(d))}
onChanged={onChanged}
/>
)}
{assistantStep === 1 && <Step2Timeline plan={plan} computed={computed} onChanged={onChanged} />}
{assistantStep === 2 && <Step3Phases plan={plan} onChanged={onChanged} />}
{assistantStep === 3 && (
@@ -1144,6 +1232,16 @@ export function PlanView({
}
case "cashInitial":
return <CashInitialPanel key="cash-init" plan={plan} onClose={close} onSaved={closeAndRefresh} />;
case "base":
return (
<BaseDataPanel
key={`base-${panel.elementId}`}
plan={plan}
elementId={panel.elementId}
onClose={close}
onSaved={closeAndRefresh}
/>
);
case "profile":
return <ProfilePanel key="profile" plan={plan} onClose={close} onSaved={closeAndRefresh} />;
}
@@ -2291,6 +2389,39 @@ function ElementMetaPanel({
);
}
// Bestand eines Elements laut Stammdaten. Schulden zaehlen negativ, eine Immobilie mit ihrem
// Eigenkapital (Kaufpreis minus Hypothek) -- dieselbe Rechnung wie in der Kachel «Kennzahlen».
function baseAmount(el: ElementInput): number {
const b = el.baseData ?? {};
switch (el.category) {
case "OTHER_ASSET":
return Math.round(num(b.startValue));
case "PENSION_FUND":
case "PILLAR_3A":
return Math.round(num(b.currentValue));
case "REAL_ESTATE":
return Math.round(num(b.purchasePrice) - num(b.mortgage));
case "OTHER_DEBT":
return -Math.round(num(b.startValue));
case "INCOME":
case "EXPENSE":
return Math.round(num(b.amount));
default:
return 0;
}
}
// Was in der Zelle steht. Einkommen und Ausgaben sind FLUESSE, keine Bestaende -- sie tragen
// deshalb den Zusatz «/Jahr», damit die Spalte nicht zwei verschiedene Dinge gleich aussehen
// laesst. Die AHV hat bei Planbeginn schlicht nichts.
function baseCellText(el: ElementInput): string {
if (el.category === "AHV") return "";
const v = baseAmount(el);
if (v === 0) return "";
const s = formatChf(v);
return el.category === "INCOME" || el.category === "EXPENSE" ? `${s}/J.` : s;
}
// --- Kacheln des oberen Bereichs -----------------------------------------------------------
//
// Vier gleichrangige Blöcke: was gilt (Grundeinstellungen), was herauskommt (Kennzahlen),
@@ -2484,3 +2615,67 @@ function QuickActionsTile({
</Tile>
);
}
// --- Panel: Stammdaten eines Elements (Spalte «Start») -------------------------------------
//
// Dieselben Felder wie im Assistenten-Schritt 1, nur einzeln statt als Liste. Bewusst EIN
// Speicherort: Wer den Startwert stattdessen in Phase 1 bearbeitete, schriebe einen Phasenwert,
// der die Stammdaten überdeckt -- dieselbe Zahl an zwei Orten.
function BaseDataPanel({
plan,
elementId,
onClose,
onSaved,
}: {
plan: PlanInput;
elementId: string;
onClose: () => void;
onSaved: () => void;
}) {
const el = plan.elements.find((x) => x.id === elementId);
const [draft, setDraft] = useState<PhaseData>(el?.baseData ?? {});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!el) return null;
const set = (patch: Partial<PhaseData>) => setDraft((d) => ({ ...d, ...patch }));
async function save() {
setSaving(true);
setError(null);
try {
await api.put(`/api/elements/${elementId}/base`, draft);
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
setSaving(false);
}
}
return (
<InspectorShell
title={el.name}
subtitle={`${CATEGORY_LABELS[el.category]} · Bestand bei Planbeginn${plan.startYear ? " " + plan.startYear : ""}`}
onClose={onClose}
>
<div className="flex flex-col gap-3">
<p className="rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
Diese Werte gehören zum Element, nicht zu einer Lebensphase sie beschreiben den Stand am Anfang deiner
Planung. Abweichende Werte für einzelne Phasen erfasst du in der jeweiligen Zelle.
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<BaseFields category={el.category} values={draft} set={set} plan={plan} />
</div>
{error && <p className="text-sm text-danger">{error}</p>}
<div className="flex gap-2 pt-1">
<Button disabled={saving} onClick={save}>
{saving ? "…" : "Speichern"}
</Button>
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
</div>
</div>
</InspectorShell>
);
}
+39
View File
@@ -143,3 +143,42 @@ describe("Element-Stammdaten (baseData)", () => {
expect(asset.endValue).toBe(100000);
});
});
describe("Stammdaten sind ein eigener Speicherort (0.37)", () => {
// Der Fund aus dem Memo: Nach der Bestandsaufnahme blieb die Matrix leer. Die Ursache lag
// nicht in der Rechnung, sondern in der Darstellung -- die Werte waren da, nur unsichtbar.
// Der Test nagelt fest, dass sie ohne jede Lebensphase abrufbar sind.
it("liefert Bestände auch ohne Lebensphasen", () => {
const p = plan({
elements: [
el("OTHER_ASSET", { startValue: 120000 }),
el("PENSION_FUND", { currentValue: 310000 }),
el("OTHER_DEBT", { startValue: 40000 }),
],
});
expect(p.phases).toHaveLength(0);
// Genau die Rechnung, die Matrix und Kennzahlen-Kachel anzeigen.
const sum = p.elements.reduce((n, e) => {
const b = e.baseData ?? {};
if (e.category === "OTHER_ASSET") return n + (b.startValue ?? 0);
if (e.category === "PENSION_FUND") return n + (b.currentValue ?? 0);
if (e.category === "OTHER_DEBT") return n - (b.startValue ?? 0);
return n;
}, 0);
expect(sum).toBe(390000);
});
it("bleibt erhalten, wenn später Phasen dazukommen", () => {
// Die Stammdaten dürfen von einer Phase nicht überschrieben, sondern nur überlagert
// werden -- sonst gäbe es dieselbe Zahl an zwei Orten.
const base = el("OTHER_ASSET", { startValue: 120000, expectedReturn: 4 });
const p = plan({
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 5, cashTransition: {} }],
elements: [{ ...base, phaseValues: { p1: {} } }],
});
const asset = computePlan(p).phases[0].elements.find((e) => e.category === "OTHER_ASSET")!;
expect(asset.startValue).toBe(120000);
// Und die Stammdaten selbst sind unverändert geblieben.
expect(p.elements[0].baseData?.startValue).toBe(120000);
});
});