Uebersicht der offenen Punkte statt Assistent, Bestaetigung je Phasenzelle
Deploy App / deploy (push) Successful in 1m55s
Deploy App / deploy (push) Successful in 1m55s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 })),
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.");
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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[];
|
||||
|
||||
@@ -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)",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user