Bestandsaufnahme: Kategorienamen, Sammel-Speichern, Matrix-Spalte Start
Deploy App / deploy (push) Successful in 1m39s
Deploy App / deploy (push) Successful in 1m39s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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"}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user