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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user