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
-131
View File
@@ -1,131 +0,0 @@
"use client";
// Der FPT-Assistent -- das Herzstück der Anwendung.
//
// Er ersetzt die frühere Karte «Nächste Schritte». Der Unterschied ist nicht kosmetisch: Die
// alte Karte leitete AB, was zu tun wäre, und liess einen damit allein. Hier führt jeder
// Schritt sein eigenes Werkzeug mit sich -- und davor eine Seite, die erklärt, worum es
// überhaupt geht.
//
// Zwei Gestaltungsentscheide, die zusammengehören:
//
// 1. Der Haken ist MANUELL. Das Tool masst sich nicht an zu wissen, wann jemand mit einem
// Schritt fertig ist -- «genug geplant» ist eine Einschätzung, keine Messgrösse.
// 2. Daneben steht der ABGELEITETE Stand («0 Lebensphasen»). Ein abgehakter Schritt ohne
// Substanz fällt so auf, ohne dass das Tool den Haken verweigert.
//
// Erledigte Schritte rutschen nach unten und werden blass -- oben steht immer das, was als
// Nächstes ansteht.
import { useState } from "react";
import { Check, ChevronRight, Lock, Sparkles } from "lucide-react";
import { api } from "@/lib/api-client";
import { ASSISTANT_STEPS, stepBlockedReason, stepStatus, type AssistantProgress } from "@/lib/assistant";
import type { PlanInput } from "@/lib/types";
export function Assistant({
plan,
progress,
onOpenStep,
onChanged,
}: {
plan: PlanInput;
progress: AssistantProgress;
onOpenStep: (index: number) => void;
onChanged: () => void;
}) {
const [busy, setBusy] = useState<number | null>(null);
const done = progress.filter(Boolean).length;
const allDone = done === ASSISTANT_STEPS.length;
async function toggle(index: number, next: boolean) {
setBusy(index);
try {
await api.post(`/api/scenarios/${plan.id}/assistant`, { step: index, done: next });
onChanged();
} finally {
setBusy(null);
}
}
// Offene zuerst, erledigte darunter -- die Reihenfolge innerhalb der Gruppen bleibt.
const ordered = [...ASSISTANT_STEPS].sort((a, b) => {
const da = progress[a.index] ? 1 : 0;
const db = progress[b.index] ? 1 : 0;
return da - db || a.index - b.index;
});
return (
<div
data-tour="assistant"
className={`flex h-full flex-col rounded-xl border px-3 py-2.5 shadow-sm transition-colors ${
allDone ? "border-success bg-success-soft" : "border-attention bg-attention-soft"
}`}
>
<div className="mb-2 flex items-center gap-2">
<Sparkles className={`h-4 w-4 ${allDone ? "text-success" : "text-attention-fg"}`} />
<span className="text-xs font-semibold uppercase tracking-wide text-fg">FPT-Assistent</span>
<span className={`ml-auto text-[11px] font-semibold ${allDone ? "text-success" : "text-muted"}`}>
{done} / {ASSISTANT_STEPS.length}
</span>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-auto">
{ordered.map((step) => {
const isDone = progress[step.index] === true;
const blocked = stepBlockedReason(plan, step.index);
const status = stepStatus(plan, step.index);
return (
<div
key={step.index}
className={`flex items-start gap-2 rounded-lg px-1.5 py-1 transition-colors ${
isDone ? "opacity-50" : "hover:bg-surface/60"
}`}
>
<button
type="button"
aria-label={isDone ? "Als offen markieren" : "Als erledigt markieren"}
disabled={busy === step.index}
onClick={() => toggle(step.index, !isDone)}
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
isDone ? "border-success bg-success text-white" : "border-border bg-surface hover:border-accent"
}`}
>
{isDone && <Check className="h-3 w-3" />}
</button>
<button
type="button"
onClick={() => onOpenStep(step.index)}
className="min-w-0 flex-1 text-left"
>
<span
className={`flex items-center gap-1 text-xs font-semibold ${
isDone ? "text-muted line-through" : "text-fg"
}`}
>
<span className="truncate">
{step.index + 1}. {step.title}
</span>
{blocked && !isDone && <Lock className="h-3 w-3 shrink-0 text-faint" />}
<ChevronRight className="h-3 w-3 shrink-0 text-faint" />
</span>
{!isDone && (
<span className="mt-0.5 block truncate text-[11px] text-muted">
{status || step.short}
</span>
)}
</button>
</div>
);
})}
</div>
{allDone && (
<p className="mt-2 border-t border-success/30 pt-2 text-[11px] text-success">
Alle Schritte erledigt. Dein Plan steht verfeinere ihn jederzeit über die Matrix.
</p>
)}
</div>
);
}
-228
View File
@@ -1,228 +0,0 @@
"use client";
// Das Popup eines Assistenten-Schritts.
//
// Immer gleich aufgebaut: SEITE 1 erklärt, worum es geht -- welche Fragen man sich stellt,
// welche Möglichkeiten das Tool bietet, worauf es ankommt. Erst danach folgt das Werkzeug.
//
// Warum die Erklärseite nicht übersprungen wird: Finanzplanung scheitert selten an der
// Bedienung und fast immer daran, dass unklar ist, was der Schritt eigentlich bezweckt. Wer
// es schon weiss, klickt unten links auf «Selbst erledigen» und ist in zwei Sekunden draussen.
import { useState } from "react";
import { ArrowLeft, ArrowRight, Check, Lock } from "lucide-react";
import { Button, Modal, useConfirm } from "@/components/ui";
import { ASSISTANT_STEPS, stepBlockedReason } from "@/lib/assistant";
import type { PlanInput } from "@/lib/types";
export interface StepExplainer {
// Kurze Einordnung ganz oben.
lead: string;
// Die Fragen, die dieser Schritt beantwortet.
questions: string[];
// Was man wissen sollte, bevor man loslegt.
notes: { title: string; text: string }[];
}
export const STEP_EXPLAINERS: StepExplainer[] = [
{
lead:
"Bevor du planst, hältst du fest, was heute da ist. Alles Weitere baut darauf auf ohne Bestandsaufnahme rechnet das Tool ins Leere.",
questions: [
"Was verdienst du, was gibst du aus?",
"Welche Guthaben hast du Pensionskasse, Säule 3a, Wertschriften, Konto?",
"Besitzt du Wohneigentum? Wie hoch ist die Hypothek?",
"Hast du Schulden ausserhalb der Hypothek?",
],
notes: [
{
title: "Nur Tatsachen, noch keine Annahmen",
text: "Hier stehen Zahlen, die du nachschlagen kannst: Kontostand, Guthaben, Kaufpreis, Restschuld. Renditen, Lohnentwicklung und Sparraten kommen im nächsten Schritt sie sind Annahmen und gelten immer nur für einen Zeitraum.",
},
{
title: "Lieber grob als gar nicht",
text: "Ein geschätzter Wert ist besser als ein leeres Feld. Du kannst jede Zahl später überall im Tool korrigieren.",
},
{
title: "Getrennt nach Person",
text: "Pensionskasse und Säule 3a gehören immer einer Person. Wertschriften, Immobilien und Schulden können gemeinsam sein.",
},
],
},
{
lead:
"Eine Lebensphase ist ein Abschnitt, in dem die Verhältnisse ungefähr gleich bleiben: ähnliches Einkommen, ähnliche Ausgaben, dieselben Annahmen. Jetzt legst du die erste an.",
questions: [
"Wie lange bleiben deine Verhältnisse ungefähr so wie heute?",
"Womit rechnest du in dieser Zeit Renditen, Lohnentwicklung, Teuerung?",
"Wohin fliesst das Geld, das am Ende des Jahres übrig bleibt?",
],
notes: [
{
title: "Was eine Lebensphase zusammenhält",
text: "Nicht ein Kalenderabschnitt, sondern eine Konstellation: Solange du gleich viel verdienst, ähnlich viel ausgibst und dieselben Annahmen gelten, ist es eine Phase. Ändert sich etwas grundlegend ein Hauskauf, Teilzeit, die Pensionierung beginnt eine neue.",
},
{
title: "Annahmen gelten pro Phase",
text: "Die Rendite, die du hier einträgst, gilt nur für diese Jahre. In einer späteren Phase kannst du eine andere setzen etwa vorsichtiger, wenn du im Ruhestand anders anlegst.",
},
{
title: "Der Rest bleibt auf dem Konto",
text: "Am Schluss verteilst du deinen Sparbetrag. Was du nicht zuteilst, sammelt sich als Cash an rechnerisch richtig, aber meist nicht die Absicht.",
},
{
title: "Bis zur Pensionierung",
text: "Im Basisszenario wird mit Pensionsalter 65 gerechnet. Eine Lebensphase darf diesen Zeitpunkt nicht überspannen FPT begrenzt die Dauer deshalb automatisch.",
},
],
},
];
export function AssistantStepDialog({
plan,
stepIndex,
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;
stepIndex: number;
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;
}) {
const step = ASSISTANT_STEPS[stepIndex];
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={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 disabled={saving} onClick={finish}>
<Check className="h-4 w-4" /> {saving ? "Wird gespeichert…" : "Schritt abschliessen"}
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-4">
<p className="text-sm text-fg">{explainer.lead}</p>
{explainer.questions.length > 0 && (
<div className="rounded-xl border border-border bg-surface-2 p-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
Diese Fragen beantwortest du hier
</p>
<ul className="flex flex-col gap-1.5">
{explainer.questions.map((q) => (
<li key={q} className="flex items-start gap-2 text-sm text-fg">
<ArrowRight className="mt-0.5 h-3.5 w-3.5 shrink-0 text-accent" />
{q}
</li>
))}
</ul>
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
{explainer.notes.map((n) => (
<div key={n.title} className="rounded-xl border border-border p-3">
<p className="text-sm font-semibold text-fg">{n.title}</p>
<p className="mt-1 text-xs leading-relaxed text-muted">{n.text}</p>
</div>
))}
</div>
{blocked && (
<p className="flex items-start gap-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{blocked}
</p>
)}
<div className="flex items-center justify-between border-t border-border pt-3">
<Button
variant="ghost"
onClick={() => {
onToggleDone(!isDone);
void closeGuarded();
}}
>
{isDone ? "Wieder als offen markieren" : "Selbst erledigen"}
</Button>
{canGuide ? (
<Button onClick={() => setShowTool(true)}>
Schritt für Schritt <ArrowRight className="h-4 w-4" />
</Button>
) : (
<Button
onClick={() => {
onToggleDone(true);
onClose();
}}
>
<Check className="h-4 w-4" /> Verstanden
</Button>
)}
</div>
</div>
)}
</Modal>
);
}
+3
View File
@@ -484,6 +484,9 @@ export function RateDistributionDialog({
for (const [elementId, data] of byElement) {
await api.put(`/api/elements/${elementId}/phase/${phaseId}`, data);
}
// Die Verteilung zählt eigens als offener Punkt: Man kann jede Zelle angeschaut und die
// Quote trotzdem nie verteilt haben -- dann bliebe alles still auf dem Cash-Konto.
await api.put(`/api/phases/${phaseId}`, { ratesConfirmed: true });
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
+4 -1
View File
@@ -1068,7 +1068,10 @@ export function ElementDetail({
if (isTransition) {
await api.put(`/api/elements/${element.id}/transition/${context.phaseId}`, td);
} else {
const payload: PhaseData = { ...pd };
// Speichern heisst zugleich BESTAETIGEN: Wer die Zelle geoeffnet und gespeichert hat,
// hat hingeschaut. Der Haken steht neben den Werten und friert nichts ein -- leere
// Felder erben weiterhin live aus der Vorphase (SPEZIFIKATION 3.12.4).
const payload: PhaseData = { ...pd, confirmed: true };
// Einkommen/Ausgaben ab Phase 2: entspricht der Basiswert dem fortgeschriebenen Wert
// der Vorphase, KEINEN Override speichern -> Wert bleibt live vererbt (Änderungen in
// früheren Phasen wirken sich weiter aus). Nur ein bewusst abweichender Wert wird fix.
@@ -1,30 +1,25 @@
"use client";
// Die Werkzeuge der Assistenten-Schritte.
// Die Bestandsaufnahme: alles erfassen, was heute da ist.
//
// Alle arbeiten ausschliesslich über die bestehenden Endpunkte -- der Assistent ist
// Orchestrierung, kein zweiter Datenpfad. Was er schreibt, hätte man auch von Hand über die
// Matrix schreiben können; er nimmt einem nur das Suchen ab.
// 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 { Plus, Trash2 } from "lucide-react";
import { Check, Plus, Trash2 } from "lucide-react";
import { api } from "@/lib/api-client";
import { Button } from "@/components/ui";
import { Button, Modal, useConfirm } from "@/components/ui";
import { MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
import { RateDistributionDialog } from "@/components/DistributionDialogs";
import { formatChf } from "@/lib/format";
import { CATEGORY_LABELS, ownerLabel, num, type PhaseData } from "@/lib/elements";
import { maxPhaseDuration, type PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
// ============================================================================================
// Schritt 1: Bestandsaufnahme
// ============================================================================================
//
// Legt Elemente an und schreibt ihre STAMMDATEN -- ohne dass Lebensphasen existieren müssen.
// Das ist der Grund, warum es `baseData` gibt: Eine Bestandsaufnahme ist keine Aussage über
// eine Phase, sondern über den Stand von heute.
type Scope = "HOUSEHOLD" | "PERSON_A" | "PERSON_B";
interface Kind {
@@ -241,12 +236,12 @@ const KINDS: Kind[] = [
// 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 {
export interface InventoryDraft {
base: Record<string, PhaseData>;
names: Record<string, string>;
}
export function emptyStep1Draft(): Step1Draft {
export function emptyInventoryDraft(): InventoryDraft {
return { base: {}, names: {} };
}
@@ -276,15 +271,15 @@ export function BaseFields({
return <>{kind?.baseFields(values, set)}</>;
}
export function Step1Elements({
export function InventoryFields({
plan,
draft,
setDraft,
onChanged,
}: {
plan: PlanInput;
draft: Step1Draft;
setDraft: (fn: (d: Step1Draft) => Step1Draft) => void;
draft: InventoryDraft;
setDraft: (fn: (d: InventoryDraft) => InventoryDraft) => void;
onChanged: () => void;
}) {
const scopes: Scope[] =
@@ -338,8 +333,8 @@ function ScopeElements({
}: {
plan: PlanInput;
scope: Scope;
draft: Step1Draft;
setDraft: (fn: (d: Step1Draft) => Step1Draft) => void;
draft: InventoryDraft;
setDraft: (fn: (d: InventoryDraft) => InventoryDraft) => void;
onChanged: () => void;
}) {
const [busy, setBusy] = useState<string | null>(null);
@@ -422,8 +417,8 @@ function BaseDataCard({
}: {
plan: PlanInput;
elementId: string;
draft: Step1Draft;
setDraft: (fn: (d: Step1Draft) => Step1Draft) => void;
draft: InventoryDraft;
setDraft: (fn: (d: InventoryDraft) => InventoryDraft) => void;
onChanged: () => void;
}) {
const el = plan.elements.find((x) => x.id === elementId)!;
@@ -477,7 +472,7 @@ function BaseDataCard({
// 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> {
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) {
@@ -489,195 +484,85 @@ export async function saveStep1Draft(plan: PlanInput, draft: Step1Draft): Promis
}
// ============================================================================================
// Schritt 2: die erste Lebensphase
// ============================================================================================
//
// Drei Seiten, die zusammen EINE Frage beantworten: "Wie sehen die naechsten Jahre aus?"
//
// 1. Name und Dauer -> die Phase entsteht
// 2. Jaehrliche Annahmen je Element -> was in dieser Zeit gilt
// 3. Sparquote verteilen -> wohin das Geld fliesst, das uebrig bleibt
//
// Erst nach Seite 3 ist der Schritt fertig. Waere er nach Seite 2 zu Ende, staende eine Phase
// da, in der der ganze Ueberschuss stumm auf dem Cash-Konto liegen bliebe -- rechnerisch
// richtig, aber nie die Absicht.
export interface Step2Draft {
name: string;
years: number;
// Jaehrliche Annahmen je Element, gesammelt bis zum Anlegen der Phase.
phase: Record<string, PhaseData>;
// Ist die Phase bereits angelegt? Danach wird nur noch die Sparquote verteilt.
createdPhaseId: string | null;
}
export function emptyStep2Draft(): Step2Draft {
return { name: "Erwerbsjahre", years: 10, phase: {}, createdPhaseId: null };
}
export function Step2FirstPhase({
// --- 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,
computed,
draft,
setDraft,
onChanged,
onClose,
onSaved,
}: {
plan: PlanInput;
computed: PlanComputed;
draft: Step2Draft;
setDraft: (fn: (d: Step2Draft) => Step2Draft) => void;
onChanged: () => void;
onClose: () => void;
onSaved: () => void;
}) {
const [busy, setBusy] = useState(false);
const [draft, setDraft] = useState<InventoryDraft>(emptyInventoryDraft);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showRates, setShowRates] = useState(false);
const confirm = useConfirm();
// Die Phase darf nicht ueber eine Pensionierung hinausreichen -- die Rechnung leitet den
// Erwerbsstatus am PHASENBEGINN ab. Statt das hinterher als Fehler zu melden, steht die
// Grenze hier und begrenzt das Feld.
const cap = maxPhaseDuration(
plan.persons.map((x) => ({ role: x.role, age: x.age, retirementAge: x.retirementAge })),
0
);
const maxYears = cap ?? 60;
const years = Math.min(Math.max(1, Math.round(draft.years)), maxYears);
const dirty = Object.keys(draft.base).length > 0 || Object.keys(draft.names).length > 0;
const existing = plan.phases.length > 0 ? plan.phases[0] : null;
const phaseId = draft.createdPhaseId ?? existing?.id ?? null;
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 createPhase() {
setBusy(true);
async function save() {
setSaving(true);
setError(null);
try {
const { phase } = await api.post<{ phase: { id: string } }>(`/api/scenarios/${plan.id}/phases`, {
name: draft.name.trim() || "Erste Lebensphase",
durationYears: years,
});
// Die jaehrlichen Annahmen gehoeren zu DIESER Phase -- deshalb erst jetzt schreibbar.
for (const [elementId, values] of Object.entries(draft.phase)) {
if (Object.keys(values).length > 0) {
await api.put(`/api/elements/${elementId}/phase/${phase.id}`, values);
}
}
setDraft((d) => ({ ...d, createdPhaseId: phase.id }));
onChanged();
setShowRates(true);
await saveInventoryDraft(plan, draft);
setDraft(emptyInventoryDraft());
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Anlegen fehlgeschlagen.");
} finally {
setBusy(false);
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
setSaving(false);
}
}
// Seite 3: die Sparquote verteilen. Erst moeglich, wenn die Phase steht.
if (showRates && phaseId) {
return (
<RateDistributionDialog
plan={plan}
computed={computed}
phaseId={phaseId}
onClose={() => setShowRates(false)}
onSaved={() => {
setShowRates(false);
onChanged();
}}
/>
);
}
if (existing && !draft.createdPhaseId) {
return (
<div className="flex flex-col gap-3">
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
Die erste Lebensphase «{existing.name}» steht bereits ({existing.durationYears} Jahre). Die jährlichen
Annahmen und die Sparquote änderst du direkt in der Matrix oder hier über die Verteilung.
</p>
<Button onClick={() => setShowRates(true)}>Sparquote verteilen</Button>
</div>
);
}
return (
<div className="flex flex-col gap-4">
{/* Seite 1: Name und Dauer. */}
<div className="grid gap-3 sm:grid-cols-2">
<TextField
label="Wie nennst du diese Phase?"
value={draft.name}
onChange={(v) => setDraft((d) => ({ ...d, name: v }))}
/>
<NumberField
label="Wie viele Jahre dauert sie?"
help={
cap !== null
? `Höchstens ${cap} Jahre danach beginnt die Pensionierung, und dort muss eine neue Lebensphase starten.`
: "Frei wählbar."
}
value={years}
min={1}
max={maxYears}
step={1}
onChange={(v) => setDraft((d) => ({ ...d, years: Math.round(v) }))}
<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>
{plan.startYear && (
<p className="text-xs text-faint">
Das sind die Jahre {plan.startYear} bis {plan.startYear + years - 1}
{plan.persons.length > 0 && (
<>
{" · "}
{plan.persons.map((x) => `${ownerLabel(plan.persons, x.role)} ${x.age}${x.age + years}`).join(" · ")}
</>
)}
</p>
)}
{/* Seite 2: die jaehrlichen Annahmen. */}
<div className="border-t border-border pt-3">
<p className="mb-1 text-sm font-semibold text-fg">Womit rechnest du in dieser Zeit?</p>
<p className="mb-3 text-xs text-muted">
Diese Annahmen gelten nur für diese Lebensphase. In einer späteren Phase kannst du andere setzen etwa
eine vorsichtigere Rendite, wenn du im Ruhestand anders anlegst.
</p>
{plan.elements.length === 0 ? (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
Es sind noch keine finanziellen Elemente erfasst. Geh zurück zu Schritt 1.
</p>
) : (
<div className="flex flex-col gap-2">
{plan.elements.map((el) => {
const kind = KINDS.find((k) => k.category === el.category);
if (!kind?.phaseFields) return null;
const values = draft.phase[el.id] ?? {};
const set = (patch: Partial<PhaseData>) =>
setDraft((d) => ({ ...d, phase: { ...d.phase, [el.id]: { ...(d.phase[el.id] ?? {}), ...patch } } }));
return (
<div key={el.id} className="rounded-xl border border-border p-3">
<div className="mb-2 flex items-baseline gap-2">
<span className="text-sm font-semibold text-fg">{el.name}</span>
<span className="text-xs text-faint">{CATEGORY_LABELS[el.category]}</span>
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">
{ownerLabel(plan.persons, el.ownerRole)}
</span>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2">{kind.phaseFields(values, set)}</div>
</div>
);
})}
</div>
)}
</div>
{error && <p className="text-sm text-danger">{error}</p>}
<Button disabled={busy || plan.elements.length === 0} onClick={createPhase}>
{busy ? "…" : "Lebensphase anlegen und Sparquote verteilen"}
</Button>
</div>
</Modal>
);
}
+112 -75
View File
@@ -14,6 +14,7 @@ import {
Maximize2,
PiggyBank,
Sparkles,
ClipboardList,
Copy,
HelpCircle,
History as HistoryIcon,
@@ -44,19 +45,9 @@ import {
type RetirementDecision,
} from "@/lib/retirement-decision";
import { RetirementFields } from "@/components/RetirementFields";
import { Assistant } from "@/components/Assistant";
import { AssistantStepDialog } from "@/components/AssistantStepDialog";
import {
Step1Elements,
Step2FirstPhase,
BaseFields,
emptyStep1Draft,
emptyStep2Draft,
saveStep1Draft,
type Step1Draft,
type Step2Draft,
} from "@/components/AssistantSteps";
import { normalizeProgress } from "@/lib/assistant";
import { ReviewTile } from "@/components/ReviewTile";
import { BaseFields, InventoryDialog } from "@/components/InventoryDialog";
import { reviewPlan, unconfirmedCells } from "@/lib/review";
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
import {
@@ -208,14 +199,38 @@ export function PlanView({
// weil beide mehrere Elemente auf einmal bearbeiten.
const [distribute, setDistribute] = useState<{ kind: "capital" | "rates"; phaseId: string } | null>(null);
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 [step2Draft, setStep2Draft] = useState<Step2Draft>(emptyStep2Draft);
const progress = normalizeProgress(plan.assistantProgress);
const [showInventory, setShowInventory] = useState(false);
// Eine Zelle, deren Annahmen noch nie jemand angeschaut hat. Sie traegt dieselbe
// Attention-Farbe wie ein offener Uebergang -- "hier fehlt was" sieht ueberall gleich aus.
const cellOpen = (elementId: string, phaseId: string) =>
unconfirmedCells(plan, computed, phaseId).includes(elementId);
// Alle Annahmen einer Phase auf einmal bestaetigen. Bei sechs Elementen und fuenf Phasen
// waeren es sonst dreissig einzelne Klicks.
async function confirmPhase(phaseId: string, phaseName: string) {
const ids = unconfirmedCells(plan, computed, phaseId);
if (ids.length === 0) return;
const ok = await confirmDialog({
title: "Annahmen bestätigen",
message:
`In «${phaseName}» sind ${ids.length} ${ids.length === 1 ? "Annahme" : "Annahmen"} noch nicht bestätigt. ` +
"Damit hältst du fest, dass du sie angeschaut hast die Werte selbst bleiben unverändert und erben " +
"weiterhin aus der Vorphase.",
confirmLabel: "Alle bestätigen",
});
if (!ok) return;
for (const id of ids) {
const el = plan.elements.find((x) => x.id === id)!;
await api.put(`/api/elements/${id}/phase/${phaseId}`, {
...(el.phaseValues[phaseId] ?? {}),
confirmed: true,
});
}
toast("success", "Annahmen bestätigt.");
onChanged();
}
// Was ist in diesem Plan noch offen? Abgeleitet, nicht gespeichert -- deshalb immer aktuell.
const review = reviewPlan(plan, computed);
// Die Tour (Start-Knopf, Auto-Start bei Plan-Erstellung, Rendering) liegt seit dem
// Layout-Umbau in AppShell -- sie liest die data-tour-Ziele im DOM dieser Ansicht.
// Nur-Lese-Detailansicht (Roadmap Nr. 43). Der Rechenweg wird erst beim Öffnen erzeugt.
@@ -416,17 +431,22 @@ export function PlanView({
/>
<FiguresTile plan={plan} computed={computed} />
<QuickActionsTile
onInventory={() => setShowInventory(true)}
onHistory={onOpenHistory}
onTour={onStartTour}
onNewScenario={onCopyScenario}
onTraces={onOpenTraces}
exportHref={exportHref}
/>
<Assistant
plan={plan}
progress={progress}
onOpenStep={(i) => setAssistantStep(i)}
onChanged={onChanged}
<ReviewTile
review={review}
onOpenInventory={() => setShowInventory(true)}
onAddPhase={() => setShowAddPhase(true)}
onJump={(g) =>
g.kind === "phase"
? setPanel({ kind: "phase", phaseId: g.id })
: setReviewFromPhaseId(g.id)
}
/>
</div>
@@ -441,14 +461,14 @@ export function PlanView({
</div>
</div>
{!hasPhases && plan.elements.length === 0 && (
{plan.elements.length === 0 && (
<EmptyState
icon={<Plus className="h-6 w-6" />}
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."
icon={<Sparkles className="h-6 w-6" />}
title="Beginne mit einer Bestandsaufnahme"
text="Halte fest, was du heute hast Einkommen, Ausgaben, Guthaben, Immobilien, Schulden. Danach legst du deine erste Lebensphase an, und ab da zeigt dir FPT, was noch zu entscheiden ist."
>
<Button onClick={() => setAssistantStep(0)}>
<Sparkles className="h-4 w-4" /> Mit dem Assistenten starten
<Button onClick={() => setShowInventory(true)}>
<Sparkles className="h-4 w-4" /> Bestandsaufnahme starten
</Button>
</EmptyState>
)}
@@ -600,6 +620,11 @@ export function PlanView({
? () => void deletePhase(col.phase.id, col.phase.name)
: null
}
openCells={unconfirmedCells(plan, computed, col.phase.id).length}
ratesOpen={
plan.phases.find((x) => x.id === col.phase.id)?.ratesConfirmed !== true
}
onConfirmAll={() => void confirmPhase(col.phase.id, col.phase.name)}
active={panel?.kind === "phase" && panel.phaseId === col.phase.id}
/>
) : (
@@ -615,10 +640,10 @@ export function PlanView({
<div className="text-xs font-semibold text-fg">Noch keine Lebensphasen</div>
<button
type="button"
onClick={() => setAssistantStep(1)}
onClick={() => setShowAddPhase(true)}
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
Erste Lebensphase anlegen
</button>
</th>
)}
@@ -830,9 +855,19 @@ export function PlanView({
<td
key={col.phase.id}
onClick={() => setPanel({ kind: "cell", elementId: el.id, phaseId: col.phase.id })}
title={cellDiff(el.id, col.phase.id) ? "Weicht von der Vorlage ab" : undefined}
title={
cellOpen(el.id, col.phase.id)
? "Annahmen dieser Phase noch nicht bestätigt"
: cellDiff(el.id, col.phase.id)
? "Weicht von der Vorlage ab"
: undefined
}
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-xs ${
ce?.locked ? "text-faint" : "text-fg"
} ${
cellOpen(el.id, col.phase.id)
? "ring-1 ring-inset ring-attention/50"
: ""
} ${cellDiff(el.id, col.phase.id)}`}
>
{phaseCellContent(ce, col.phase, valueMode, planEndOf(ce?.elementId, col.phase.id))}
@@ -930,48 +965,15 @@ export function PlanView({
/>
)}
{assistantStep !== null && (
<AssistantStepDialog
{showInventory && (
<InventoryDialog
plan={plan}
stepIndex={assistantStep}
isDone={progress[assistantStep] === true}
onToggleDone={async (done) => {
await api.post(`/api/scenarios/${plan.id}/assistant`, { step: assistantStep, done });
onClose={() => setShowInventory(false)}
onSaved={() => {
setShowInventory(false);
onChanged();
}}
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}
draft={step1Draft}
setDraft={(fn) => setStep1Draft((d) => fn(d))}
onChanged={onChanged}
/>
)}
{assistantStep === 1 && (
<Step2FirstPhase
plan={plan}
computed={computed}
draft={step2Draft}
setDraft={(fn) => setStep2Draft((d) => fn(d))}
onChanged={onChanged}
/>
)}
</AssistantStepDialog>
/>
)}
{reviewFromPhaseId && (() => {
@@ -1392,6 +1394,9 @@ function PhaseHeader({
onDistributeCapital,
onDistributeRates,
onDelete,
openCells,
ratesOpen,
onConfirmAll,
active,
}: {
phase: PhaseComputed;
@@ -1406,6 +1411,11 @@ function PhaseHeader({
onDistributeRates: () => void;
// null = nicht die letzte Phase (nur die letzte lässt sich löschen).
onDelete: (() => void) | null;
// Wie viele Annahmen dieser Phase sind noch nicht bestätigt? 0 = alles angeschaut.
openCells: number;
// Wurde die Spar-/Verzehrquote verteilt?
ratesOpen: boolean;
onConfirmAll: () => void;
active: boolean;
}) {
const quotaLabel = phase.isConsumption ? "Verzehrquote" : "Sparquote";
@@ -1428,6 +1438,21 @@ function PhaseHeader({
>
<div className="flex items-center gap-1">
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-fg">{phase.name}</span>
{/* Offene Punkte dieser Phase -- ein Klick bestätigt alle Annahmen auf einmal. Bei
sechs Elementen und fünf Phasen wären es sonst dreissig einzelne Klicks. */}
{openCells > 0 && (
<button
type="button"
title={`${openCells} ${openCells === 1 ? "Annahme" : "Annahmen"} nicht bestätigt alle bestätigen`}
onClick={(e) => {
e.stopPropagation();
onConfirmAll();
}}
className="shrink-0 rounded-full bg-attention px-1.5 py-0.5 text-[10px] font-semibold text-attention-fg hover:opacity-90"
>
{openCells}
</button>
)}
{diffKind === "added" && (
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
)}
@@ -1536,7 +1561,12 @@ function PhaseHeader({
e.stopPropagation();
onDistributeRates();
}}
className="mt-1 w-full rounded border border-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent transition-colors hover:bg-accent hover:text-accent-fg"
title={ratesOpen ? "Noch nicht verteilt -- was du nicht zuteilst, bleibt auf dem Cash-Konto" : undefined}
className={`mt-1 w-full rounded px-1.5 py-0.5 text-[10px] font-semibold transition-colors ${
ratesOpen
? "border border-attention bg-attention text-attention-fg hover:opacity-90"
: "border border-accent text-accent hover:bg-accent hover:text-accent-fg"
}`}
>
{phase.isConsumption ? "Bezug verteilen" : "Sparquote verteilen"}
</button>
@@ -2557,12 +2587,14 @@ function Figure({ label, value }: { label: string; value: number | null }) {
}
function QuickActionsTile({
onInventory,
onHistory,
onTour,
onNewScenario,
onTraces,
exportHref,
}: {
onInventory?: () => void;
onHistory?: () => void;
onTour?: () => void;
onNewScenario?: () => void;
@@ -2573,6 +2605,11 @@ function QuickActionsTile({
"flex w-full items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg";
return (
<Tile tour="toolbar" title="Schnellaktionen">
{onInventory && (
<button type="button" onClick={onInventory} className={cls}>
<ClipboardList className="h-3.5 w-3.5" /> Bestandsaufnahme
</button>
)}
{onNewScenario && (
<button type="button" onClick={onNewScenario} className={cls}>
<Copy className="h-3.5 w-3.5" /> Neues Szenario
+111
View File
@@ -0,0 +1,111 @@
"use client";
// Die Übersicht der offenen Punkte -- oben rechts, wo bis 0.38 der Assistent stand.
//
// Sie listet keine Schritte auf, sondern zeigt einen ZUSTAND: Was ist in diesem Plan noch
// nicht entschieden oder nicht bestätigt? Der Unterschied fällt auf, sobald man einen
// bestehenden Plan öffnet -- der Assistent bot dort Schritte an, die längst erledigt waren.
//
// Zwei Sonderzustände ganz am Anfang: Ohne Elemente und ohne Lebensphasen gibt es
// naturgemäss nichts Offenes, obwohl der Plan leer ist. Eine "0" wäre dort eine Lüge; deshalb
// steht dort eine Aufforderung statt einer Zahl.
import { CheckCircle2, ClipboardList, Sparkles } from "lucide-react";
import type { PlanReview } from "@/lib/review";
export function ReviewTile({
review,
onOpenInventory,
onAddPhase,
onJump,
}: {
review: PlanReview;
onOpenInventory: () => void;
onAddPhase: () => void;
onJump: (group: { id: string; kind: "phase" | "transition" }) => void;
}) {
const done = review.stage === "DONE";
const starting = review.stage === "NO_ELEMENTS" || review.stage === "NO_PHASES";
return (
<div
data-tour="assistant"
className={`flex h-full flex-col rounded-xl border px-3 py-2.5 shadow-sm transition-colors ${
done ? "border-success bg-success-soft" : "border-attention bg-attention-soft"
}`}
>
<div className="mb-2 flex items-center gap-2">
{done ? (
<CheckCircle2 className="h-4 w-4 text-success" />
) : (
<ClipboardList className="h-4 w-4 text-attention-fg" />
)}
<span className="text-xs font-semibold uppercase tracking-wide text-fg">Offene Punkte</span>
{!starting && (
<span className={`ml-auto text-[11px] font-semibold ${done ? "text-success" : "text-muted"}`}>
{done ? "keine" : review.total}
</span>
)}
</div>
{review.stage === "NO_ELEMENTS" && (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<p className="text-[11px] leading-snug text-muted">
Dein Plan ist noch leer. Erfasse zuerst, was du heute hast danach planst du deine erste Lebensphase.
</p>
<button
type="button"
onClick={onOpenInventory}
className="mt-auto flex items-center justify-center gap-1.5 rounded-lg bg-accent px-2 py-1.5 text-[11px] font-semibold text-accent-fg hover:opacity-90"
>
<Sparkles className="h-3.5 w-3.5" /> Bestandsaufnahme
</button>
</div>
)}
{review.stage === "NO_PHASES" && (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<p className="text-[11px] leading-snug text-muted">
Deine Bestandsaufnahme steht. Jetzt fehlt die erste Lebensphase erst dann gibt es etwas zu
entscheiden.
</p>
<button
type="button"
onClick={onAddPhase}
className="mt-auto flex items-center justify-center gap-1.5 rounded-lg bg-accent px-2 py-1.5 text-[11px] font-semibold text-accent-fg hover:opacity-90"
>
Erste Lebensphase anlegen
</button>
</div>
)}
{review.stage === "DONE" && (
<div className="flex min-h-0 flex-1 flex-col justify-center gap-1">
<p className="text-sm font-semibold text-success">Alles entschieden</p>
<p className="text-[11px] leading-snug text-muted">
In allen Lebensphasen und Übergängen ist nichts mehr offen. Änderst du etwas, erscheint es hier wieder.
</p>
</div>
)}
{review.stage === "OPEN" && (
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-auto">
{review.groups.map((g) => (
<button
key={`${g.kind}-${g.id}`}
type="button"
onClick={() => onJump(g)}
className="rounded-lg px-1.5 py-1 text-left transition-colors hover:bg-surface/60"
>
<span className="flex items-baseline gap-1.5">
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-fg">{g.title}</span>
<span className="shrink-0 text-[11px] font-semibold text-attention-fg">{g.open}</span>
</span>
<span className="block truncate text-[11px] text-muted">{g.reasons.join(" · ")}</span>
</button>
))}
</div>
)}
</div>
);
}