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
+3
View File
@@ -10,6 +10,8 @@ import { planDurationChange } from "@/lib/phaseplan";
const updatePhaseSchema = z.object({
name: z.string().min(1).max(120).optional(),
durationYears: z.number().int().min(1).max(80).optional(),
// Die Spar-/Verzehrquote dieser Phase wurde bewusst verteilt (SPEZIFIKATION 3.14).
ratesConfirmed: z.boolean().optional(),
});
export async function PUT(
@@ -58,6 +60,7 @@ export async function PUT(
data: {
name: parsed.data.name ?? undefined,
durationYears: duration ?? undefined,
ratesConfirmed: parsed.data.ratesConfirmed ?? undefined,
},
});
if (neighbour) {
@@ -1,34 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedScenario } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { ASSISTANT_STEP_COUNT, normalizeProgress } from "@/lib/assistant";
// Fortschritt des FPT-Assistenten setzen (ein Haken je Schritt).
//
// Bewusst OHNE `touchScenario`: Das Abhaken ist eine Notiz des Benutzers über sich selbst,
// keine Änderung am Plan. Eine Version dafür anzulegen würde die Historie mit Einträgen
// fluten, die inhaltlich nichts unterscheiden.
const bodySchema = z.object({
step: z.number().int().min(0).max(ASSISTANT_STEP_COUNT - 1),
done: z.boolean(),
});
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const scenario = await getOwnedScenario(scenarioId, userId);
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const parsed = bodySchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const progress = normalizeProgress(scenario.assistantProgress);
progress[parsed.data.step] = parsed.data.done;
await prisma.scenario.update({ where: { id: scenarioId }, data: { assistantProgress: progress } });
return NextResponse.json({ ok: true, progress });
}
@@ -33,7 +33,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// kopiert. Szenario-eigen sind nur Inflation, Cash-Anfangswert und Pensionsalter.
inflationRateDefault: source.inflationRateDefault,
initialCash: source.initialCash,
assistantProgress: source.assistantProgress ?? undefined,
persons: {
create: source.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })),
},
-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>
);
}
-174
View File
@@ -1,174 +0,0 @@
// Der FPT-Assistent und die Element-Stammdaten (0.36).
//
// Der Assistent selbst ist UI; getestet wird hier die reine Logik dahinter -- der Fortschritt,
// der abgeleitete Stand und die Voraussetzungen je Schritt. Dazu die Eigenschaft, wegen der
// `baseData` überhaupt existiert: Ein Element muss sich erfassen lassen, BEVOR es Phasen gibt.
import { describe, it, expect } from "vitest";
import {
ASSISTANT_STEP_COUNT,
ASSISTANT_STEPS,
emptyProgress,
normalizeProgress,
stepBlockedReason,
stepStatus,
} from "@/lib/assistant";
import { computePlan } from "@/lib/calculations";
import type { PhaseData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
function plan(over: Partial<PlanInput> = {}): PlanInput {
return {
id: "plan",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
startYear: 2026,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [],
elements: [],
...over,
};
}
function el(category: string, baseData: PhaseData = {}, phaseValues: Record<string, PhaseData> = {}) {
return {
id: `e-${category}-${Math.random().toString(36).slice(2, 7)}`,
category: category as never,
name: category,
ownerRole: "PERSON_A" as never,
orderIndex: 0,
phaseValues,
transitionValues: {},
baseData,
};
}
describe("Assistenten-Fortschritt", () => {
it("startet mit lauter offenen Schritten", () => {
expect(emptyProgress()).toHaveLength(ASSISTANT_STEP_COUNT);
expect(emptyProgress().some(Boolean)).toBe(false);
expect(ASSISTANT_STEPS).toHaveLength(ASSISTANT_STEP_COUNT);
});
it("verwirft kaputte Daten, statt daran zu scheitern", () => {
// Der Fortschritt liegt als JSON in der Datenbank -- ein alter oder manipulierter Stand
// darf die Ansicht nicht zerlegen.
expect(normalizeProgress(null)).toEqual(emptyProgress());
expect(normalizeProgress([true])).toEqual(emptyProgress()); // falsche Länge
expect(normalizeProgress([true, false, true])).toEqual(emptyProgress()); // ebenso
expect(normalizeProgress("kaputt")).toEqual(emptyProgress());
const gut = [true, false];
expect(normalizeProgress(gut)).toEqual(gut);
});
});
describe("Abgeleiteter Stand neben dem Haken", () => {
// Der Haken ist bewusst manuell -- aber der Assistent soll nichts Falsches behaupten.
// Deshalb steht daneben, was tatsächlich da ist.
it("zählt die erfassten Elemente", () => {
expect(stepStatus(plan(), 0)).toBe("noch nichts erfasst");
expect(stepStatus(plan({ elements: [el("INCOME")] }), 0)).toBe("1 Element");
expect(stepStatus(plan({ elements: [el("INCOME"), el("EXPENSE")] }), 0)).toBe("2 Elemente");
});
it("zählt die Lebensphasen -- auch wenn es keine gibt", () => {
expect(stepStatus(plan(), 1)).toBe("0 Lebensphasen");
expect(
stepStatus(
plan({ phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 5, cashTransition: {} }] }),
1
)
).toBe("1 Lebensphase");
});
});
describe("Voraussetzungen je Schritt", () => {
it("sperrt die Lebensphase, solange keine Elemente erfasst sind", () => {
// Eine Phase ohne Elemente waere ein leerer Zeitraum -- es gaebe nichts zu planen.
expect(stepBlockedReason(plan(), 1)).toContain("finanziellen Elemente");
expect(stepBlockedReason(plan({ elements: [el("INCOME")] }), 1)).toBeNull();
});
it("lässt die Bestandsaufnahme immer zu -- sie braucht keine Zeitachse", () => {
expect(stepBlockedReason(plan(), 0)).toBeNull();
});
});
describe("Element-Stammdaten (baseData)", () => {
// Der Grund für den ganzen Umbau: Eine Bestandsaufnahme ist keine Aussage über eine Phase.
it("erlaubt Elemente ohne jede Lebensphase", () => {
const p = plan({ elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 3 })] });
const c = computePlan(p);
expect(c.phases).toHaveLength(0);
expect(c.ruinAge).toBeNull();
});
it("dient der ersten Phase als Startwert", () => {
const p = plan({
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 10, cashTransition: {} }],
elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 0 }, { p1: {} })],
});
const asset = computePlan(p).phases[0].elements.find((e) => e.category === "OTHER_ASSET")!;
expect(asset.startValue).toBe(100000);
});
it("ist die Wurzel der Vererbung -- Phase 1 erbt die Rendite von dort", () => {
// Vor 0.36 hatte Phase 1 nichts, von dem sie hätte erben können, und fiel auf 0.
const p = plan({
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 10, cashTransition: {} }],
elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 5 }, { p1: {} })],
});
const asset = computePlan(p).phases[0].elements.find((e) => e.category === "OTHER_ASSET")!;
expect(asset.endValue).toBe(Math.round(100000 * Math.pow(1.05, 10)));
});
it("weicht einem erfassten Phasenwert -- der gewinnt", () => {
const p = plan({
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 10, cashTransition: {} }],
elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 5 }, { p1: { expectedReturn: 0 } })],
});
const asset = computePlan(p).phases[0].elements.find((e) => e.category === "OTHER_ASSET")!;
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);
});
});
-89
View File
@@ -1,89 +0,0 @@
// Der FPT-Assistent: der Weg von der leeren Matrix zum Plan.
//
// Er ersetzt die frueheren "Naechsten Schritte". Der Unterschied ist nicht kosmetisch: Die
// alte Karte leitete AB, was zu tun waere; hier fuehrt jeder Schritt sein eigenes Werkzeug
// mit sich. Der Nutzer haelt den Fortschritt selbst fest (Haken) -- das Tool masst sich nicht
// an zu wissen, wann jemand mit einem Schritt fertig IST.
//
// Damit der Assistent trotzdem nichts Falsches behauptet, steht neben jedem Haken der
// ABGELEITETE Stand ("0 Lebensphasen"). Ein abgehakter Schritt ohne Substanz faellt so auf,
// ohne dass das Tool den Haken verweigert.
//
// Stand 0.38: Der Assistent fuehrt bis zur ERSTEN Lebensphase. Alles danach (weitere Phasen,
// Uebergaenge, Pensionierung) wird als eigener Ablauf nachgezogen -- lieber zwei Schritte,
// die tragen, als sieben, die nur behaupten zu fuehren.
import { z } from "zod";
import type { PlanInput } from "@/lib/types";
export const ASSISTANT_STEP_COUNT = 2;
export type AssistantProgress = boolean[];
export const assistantProgressSchema = z.array(z.boolean()).length(ASSISTANT_STEP_COUNT);
export function emptyProgress(): AssistantProgress {
return Array.from({ length: ASSISTANT_STEP_COUNT }, () => false);
}
export function normalizeProgress(raw: unknown): AssistantProgress {
const parsed = assistantProgressSchema.safeParse(raw);
return parsed.success ? parsed.data : emptyProgress();
}
export interface AssistantStep {
index: number;
title: string;
short: string;
// Ein Satz, der sagt, worum es geht -- steht in der Liste unter dem Titel.
lead: string;
// Hat dieser Schritt ein Werkzeug, oder ist er reine Information?
guided: boolean;
}
export const ASSISTANT_STEPS: AssistantStep[] = [
{
index: 0,
title: "Bestandsaufnahme",
short: "Was du hast",
lead: "Alle Konten, Guthaben, Immobilien und Schulden erfassen mit ihrem heutigen Stand.",
guided: true,
},
{
index: 1,
title: "Erste Lebensphase",
short: "Die ersten Jahre",
lead: "Wie lange die erste Phase dauert, womit du in dieser Zeit rechnest und wohin dein Sparbetrag fliesst.",
guided: true,
},
];
// --- Abgeleiteter Stand je Schritt -----------------------------------------------------------
//
// Bewusst NICHT zum Erzwingen des Hakens, sondern als Gegenprobe daneben. Wer "Erste
// Lebensphase" abhakt, ohne eine anzulegen, sieht "0 Lebensphasen" -- das reicht.
export function stepStatus(plan: PlanInput, index: number): string {
const phases = plan.phases.length;
const elements = plan.elements.length;
switch (index) {
case 0:
return elements === 0
? "noch nichts erfasst"
: `${elements} ${elements === 1 ? "Element" : "Elemente"}`;
case 1:
if (phases === 0) return "0 Lebensphasen";
return `${phases} ${phases === 1 ? "Lebensphase" : "Lebensphasen"}`;
default:
return "";
}
}
// Ein Schritt laesst sich erst sinnvoll oeffnen, wenn seine Voraussetzung erfuellt ist. Der
// Haken bleibt trotzdem jederzeit setzbar -- gesperrt wird nur das Werkzeug, nicht die
// Selbstauskunft.
export function stepBlockedReason(plan: PlanInput, index: number): string | null {
if (index === 1 && plan.elements.length === 0) {
return "Erfasse zuerst deine finanziellen Elemente ohne sie gibt es in einer Lebensphase nichts zu planen.";
}
return null;
}
+4
View File
@@ -50,6 +50,9 @@ export const CATEGORY_ORDER: ElementCategory[] = [
// Defaults, das UI zeigt je nach Kontext nur die relevanten Felder.
export interface PhaseData {
// Hat jemand die Annahmen dieser Zelle einmal angeschaut? Steht NEBEN den Werten und
// kopiert nichts -- eine bestaetigte Phase erbt weiterhin live von der Vorphase.
confirmed?: boolean;
// INCOME / EXPENSE
amount?: number;
// INCOME / EXPENSE: jährlicher Teuerungsausgleich (%). Indexiert den Flow über die
@@ -173,6 +176,7 @@ export const cashTransitionSchema = z
export const phaseDataSchema = z
.object({
confirmed: z.boolean().optional(),
amount: nonNeg.optional(),
teuerungsausgleich: z.number().min(-20).max(50).optional(),
gapYears: z.number().int().min(0).optional(),
+5 -2
View File
@@ -76,8 +76,11 @@ describe("Datenbank-Migrationen", () => {
expect(personCols).not.toContain("name");
expect(personCols).not.toContain("age");
const scenCols2 = await cols("Scenario");
expect(scenCols2).toContain("assistantProgress");
// Der Assistenten-Fortschritt ist mit dem Assistenten entfallen (0.39): Was offen ist,
// wird abgeleitet und nicht gespeichert.
expect(await cols("Scenario")).not.toContain("assistantProgress");
// Die Spar-/Verzehrquote je Phase zaehlt eigens als offener Punkt.
expect(await cols("Phase")).toContain("ratesConfirmed");
// Der Pensionierungs-Entscheid hängt am ELEMENT und bewusst an keiner Phase (0.34) --
// nur so überlebt er eine Verschiebung der Zeitachse. Die Stammdaten (0.36) ebenso: Sie
+1 -2
View File
@@ -2,7 +2,6 @@ import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { cashTransitionSchema, phaseDataSchema, transitionDataSchema } from "@/lib/elements";
import { retirementDecisionSchema } from "@/lib/retirement-decision";
import { normalizeProgress } from "@/lib/assistant";
import type { CashTransitionData, PhaseData, TransitionData } from "@/lib/elements";
import type { RetirementDecision } from "@/lib/retirement-decision";
import type { PlanInput } from "@/lib/types";
@@ -51,7 +50,6 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
inflationRateDefault: plan.inflationRateDefault,
initialCash: plan.initialCash,
startYear: plan.plan.startYear,
assistantProgress: normalizeProgress(plan.assistantProgress),
// Name und Alter vom Plan, Pensionsalter vom Szenario. Fehlt zu einer Rolle die
// Plan-Person, greift ein Notbehelf -- die Berechnung darf daran nicht scheitern.
persons: plan.persons.map((p) => {
@@ -70,6 +68,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
name: phase.name,
durationYears: phase.durationYears,
cashTransition: parseCashTransition(phase.cashTransition),
ratesConfirmed: phase.ratesConfirmed,
sourcePhaseId: phase.sourcePhaseId,
})),
elements: plan.elements.map((e) => {
+141
View File
@@ -0,0 +1,141 @@
// Die Übersicht der offenen Punkte (0.39).
//
// Sie ersetzt den FPT-Assistenten. Getestet wird die Regel dahinter: Wann gilt etwas als
// offen, wann als erledigt -- und die beiden Sonderzustände am Anfang, in denen eine Zahl
// eine Lüge wäre.
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import { reviewPlan, unconfirmedCells, isCellConfirmed } from "@/lib/review";
import type { PhaseData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
function plan(over: Partial<PlanInput> = {}): PlanInput {
return {
id: "plan",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
startYear: 2026,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [],
elements: [],
...over,
};
}
function phase(id: string, seq: number, years: number, ratesConfirmed = true) {
return { id, sequenceNumber: seq, name: id, durationYears: years, cashTransition: { mode: "NONE" as const }, ratesConfirmed };
}
let n = 0;
function el(category: string, phaseValues: Record<string, PhaseData> = {}) {
return {
id: `e${n++}`,
category: category as never,
name: category,
ownerRole: "HOUSEHOLD" as never,
orderIndex: n,
phaseValues,
transitionValues: {},
baseData: {},
};
}
const ok = { confirmed: true };
describe("Die beiden Startzustände", () => {
// Ohne Elemente und ohne Phasen gibt es naturgemäss nichts Offenes -- der Plan ist trotzdem
// leer. Eine «0» wäre dort eine Lüge, deshalb eigene Zustände.
it("meldet einen Plan ohne Elemente als NO_ELEMENTS, nicht als fertig", () => {
const p = plan();
const r = reviewPlan(p, computePlan(p));
expect(r.stage).toBe("NO_ELEMENTS");
expect(r.total).toBe(0);
});
it("meldet Elemente ohne Lebensphasen als NO_PHASES", () => {
const p = plan({ elements: [el("OTHER_ASSET")] });
expect(reviewPlan(p, computePlan(p)).stage).toBe("NO_PHASES");
});
});
describe("Offene Punkte je Phase", () => {
it("zählt jede unbestätigte Zelle", () => {
const p = plan({
phases: [phase("p1", 1, 10)],
elements: [el("INCOME", { p1: {} }), el("EXPENSE", { p1: {} }), el("OTHER_ASSET", { p1: ok })],
});
const c = computePlan(p);
expect(unconfirmedCells(p, c, "p1")).toHaveLength(2);
const r = reviewPlan(p, c);
expect(r.stage).toBe("OPEN");
expect(r.total).toBe(2);
expect(r.groups[0].reasons[0]).toContain("2 Annahmen");
});
it("zählt die nicht verteilte Sparquote eigens", () => {
// Man kann jede Zelle angeschaut und die Quote trotzdem nie verteilt haben -- dann bliebe
// alles still auf dem Cash-Konto.
const p = plan({
phases: [phase("p1", 1, 10, false)],
elements: [el("INCOME", { p1: ok })],
});
const r = reviewPlan(p, computePlan(p));
expect(r.total).toBe(1);
expect(r.groups[0].reasons[0]).toContain("Sparquote nicht verteilt");
});
it("ist fertig, wenn alles bestätigt und verteilt ist", () => {
const p = plan({
phases: [phase("p1", 1, 10)],
elements: [el("INCOME", { p1: ok }), el("EXPENSE", { p1: ok })],
});
const r = reviewPlan(p, computePlan(p));
expect(r.stage).toBe("DONE");
expect(r.total).toBe(0);
expect(r.groups).toHaveLength(0);
});
it("verlangt keine Bestätigung für ein verkauftes Element", () => {
// Ein verkauftes Haus trägt in der Folgephase keine Annahmen mehr.
const p = plan({
phases: [phase("p1", 1, 5), phase("p2", 2, 5)],
elements: [
el("INCOME", { p1: ok, p2: ok }),
{
...el("REAL_ESTATE", { p1: { ...ok, purchasePrice: 800000, mortgage: 500000 }, p2: {} }),
transitionValues: { p1: { decision: "SELL" as const, salePrice: 900000 } },
},
],
});
const c = computePlan(p);
// In p2 ist die Immobilie verkauft -- nur das Einkommen zählt, und das ist bestätigt.
expect(unconfirmedCells(p, c, "p2")).toHaveLength(0);
});
});
describe("Bestätigen friert nichts ein", () => {
// Der wichtigste Punkt: Der Haken steht NEBEN den Werten. Eine bestätigte Phase erbt
// weiterhin live aus der Vorphase -- sonst wäre die Feld-Vererbung hinfällig.
it("lässt die Vererbung unberührt", () => {
const p = plan({
phases: [phase("p1", 1, 10), phase("p2", 2, 10)],
elements: [
{
...el("OTHER_ASSET"),
baseData: { startValue: 100000, expectedReturn: 5 },
// p2 ist bestätigt, hat aber KEINEN eigenen Renditewert -- sie muss erben.
phaseValues: { p1: ok, p2: ok },
},
],
});
const c = computePlan(p);
const p2 = c.phases[1].elements.find((e) => e.category === "OTHER_ASSET")!;
// 100'000 über 20 Jahre zu 5 % -- die Rendite gilt in beiden Phasen. Auf den Franken
// genau geht es nicht: Der Rechenkern rundet an jeder Phasengrenze.
expect(p2.endValue).toBeCloseTo(100000 * Math.pow(1.05, 20), -1);
expect(isCellConfirmed(p, "p2", p.elements[0].id)).toBe(true);
});
});
+116
View File
@@ -0,0 +1,116 @@
// Was ist in diesem Plan noch offen?
//
// Ersetzt den FPT-Assistenten. Der Unterschied ist nicht die Groesse, sondern der Charakter:
// Der Assistent war ein ABLAUF ("tu dies, dann das") und funktionierte nur beim ersten
// Aufsetzen -- wer einen bestehenden Plan oeffnete, bekam Schritte angeboten, die laengst
// erledigt waren. Diese Uebersicht ist ein ZUSTAND. Sie leitet ab, was tatsaechlich fehlt,
// und traegt damit bei jedem Plan, in jeder Reihenfolge, auch beim zwanzigsten Szenario.
//
// Der Mechanismus dahinter ist derselbe, den die Uebergaenge seit 0.35 haben, jetzt auf
// PHASENWERTE ausgeweitet: Eine neue Lebensphase uebernimmt alle Werte der Vorphase, aber sie
// gelten als unbestaetigt, bis jemand hingeschaut hat. Vorbelegen ja, stillschweigend
// uebernehmen nein.
//
// WICHTIG: Bestaetigen heisst "ich habe hingeschaut", NICHT "festnageln". Der Haken steht
// neben den Werten, er kopiert sie nicht in die Phase -- sonst waere jede bestaetigte Phase
// eingefroren und die Feld-Vererbung (SPEZIFIKATION 3.12.4) waere hinfaellig.
import { openTransitionCount, type DecisionCounts } from "@/lib/decisions";
import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
// Wo steht der Plan insgesamt? Die ersten beiden Zustaende sind KEINE Zaehlung offener
// Punkte -- ohne Elemente und ohne Phasen gibt es naturgemaess nichts Offenes, der Plan ist
// aber trotzdem leer. Diese beiden Faelle brauchen eine Aufforderung, keine Statistik.
export type ReviewStage = "NO_ELEMENTS" | "NO_PHASES" | "OPEN" | "DONE";
export interface ReviewGroup {
// Sprungziel: Phasen-Id bzw. die Von-Phase eines Uebergangs.
id: string;
kind: "phase" | "transition";
title: string;
open: number;
// Kurze Klartexte, was genau fehlt.
reasons: string[];
}
export interface PlanReview {
stage: ReviewStage;
total: number;
groups: ReviewGroup[];
}
// Braucht dieses Element in dieser Phase eine Bestaetigung? Alles, was in der Phase AKTIV ist:
// Ein verkauftes Haus oder eine getilgte Schuld traegt keine Annahmen mehr.
export function needsConfirmation(
computed: PlanComputed,
phaseId: string,
elementId: string
): boolean {
const ph = computed.phases.find((p) => p.id === phaseId);
const ce = ph?.elements.find((e) => e.elementId === elementId);
return !!ce && ce.status === "ACTIVE";
}
// Ist der Phasenwert dieses Elements bestaetigt?
export function isCellConfirmed(plan: PlanInput, phaseId: string, elementId: string): boolean {
return plan.elements.find((e) => e.id === elementId)?.phaseValues[phaseId]?.confirmed === true;
}
// Unbestaetigte Zellen einer Phase.
export function unconfirmedCells(plan: PlanInput, computed: PlanComputed, phaseId: string): string[] {
return plan.elements
.filter((e) => needsConfirmation(computed, phaseId, e.id) && !isCellConfirmed(plan, phaseId, e.id))
.map((e) => e.id);
}
export function reviewPlan(plan: PlanInput, computed: PlanComputed): PlanReview {
if (plan.elements.length === 0) return { stage: "NO_ELEMENTS", total: 0, groups: [] };
if (computed.phases.length === 0) return { stage: "NO_PHASES", total: 0, groups: [] };
const groups: ReviewGroup[] = [];
computed.phases.forEach((ph, i) => {
const reasons: string[] = [];
let open = 0;
// 1. Annahmen je Element -- Renditen, Lohnentwicklung, Teuerung, Zins.
const cells = unconfirmedCells(plan, computed, ph.id);
if (cells.length > 0) {
open += cells.length;
reasons.push(`${cells.length} ${cells.length === 1 ? "Annahme" : "Annahmen"} nicht bestätigt`);
}
// 2. Die Spar- bzw. Verzehrquote. Zaehlt eigens: Man kann jede Zelle angeschaut haben und
// die Verteilung trotzdem nie getroffen haben -- dann bleibt alles still auf dem Cash.
const phase = plan.phases.find((p) => p.id === ph.id);
if (phase && phase.ratesConfirmed !== true) {
open += 1;
reasons.push(ph.isConsumption ? "Bezüge nicht verteilt" : "Sparquote nicht verteilt");
}
if (open > 0) groups.push({ id: ph.id, kind: "phase", title: ph.name, open, reasons });
// 3. Der Uebergang NACH dieser Phase.
const next = computed.phases[i + 1];
if (!next) return;
const counts: DecisionCounts = openTransitionCount(plan, computed, ph, next);
const tOpen = counts.open + counts.unconfirmed;
if (tOpen > 0) {
const r: string[] = [];
if (counts.open > 0) r.push(`${counts.open} ${counts.open === 1 ? "Entscheid" : "Entscheide"} offen`);
if (counts.unconfirmed > 0)
r.push(`${counts.unconfirmed} ${counts.unconfirmed === 1 ? "Vorgabe" : "Vorgaben"} ungeprüft`);
groups.push({
id: ph.id,
kind: "transition",
title: `Übergang nach ${next.name}`,
open: tOpen,
reasons: r,
});
}
});
const total = groups.reduce((n, g) => n + g.open, 0);
return { stage: total === 0 ? "DONE" : "OPEN", total, groups };
}
+2 -3
View File
@@ -5,7 +5,6 @@
import type { CashTransitionData, ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements";
import type { RetirementDecision } from "@/lib/retirement-decision";
import type { AssistantProgress } from "@/lib/assistant";
export type HouseholdType = "SINGLE" | "COUPLE";
export type PersonRole = "PERSON_A" | "PERSON_B";
@@ -25,6 +24,8 @@ export interface PhaseInput {
durationYears: number;
// Cash-Entscheid beim Übergang NACH dieser Phase (einmalige Sonderein-/ausgaben).
cashTransition: CashTransitionData;
// Wurde die Spar-/Verzehrquote dieser Phase einmal bewusst verteilt?
ratesConfirmed?: boolean;
// Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
sourcePhaseId?: string | null;
}
@@ -83,8 +84,6 @@ export interface PlanInput {
// Kalenderjahr des Planbeginns (Jahr 1) -- nur für die Darstellung, nicht für die
// Berechnung. Optional, damit Berechnungs-Fixtures es nicht setzen müssen.
startYear?: number | null;
// Fortschritt des FPT-Assistenten (sieben Schritte).
assistantProgress?: AssistantProgress | null;
persons: PersonInput[];
phases: PhaseInput[];
elements: ElementInput[];
-2
View File
@@ -34,8 +34,6 @@ const EXEMPT: Record<string, string> = {
"plans/[planId]/actuals/[setId]": "dito (Löschen eines Ist-Satzes)",
"plans/[planId]/analyses": "gespeicherte Analysen sind read-only Momentaufnahmen kein Szenario betroffen",
"plans/[planId]/analyses/[analysisId]": "dito (Öffnen/Löschen einer Analyse)",
"scenarios/[scenarioId]/assistant":
"das Abhaken eines Assistenten-Schritts ist eine Notiz des Benutzers über sich selbst, keine Planänderung eine Version dafür würde die Historie mit inhaltsgleichen Einträgen fluten",
"plans/[planId]/reports": "Berichte sind erzeugte Dokumente sie verändern kein Szenario",
"plans/[planId]/reports/[reportId]": "dito (Herunterladen/Löschen eines Berichts)",
};