6ff144d7e1
Deploy App / deploy (push) Successful in 1m57s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
229 lines
8.7 KiB
TypeScript
229 lines
8.7 KiB
TypeScript
"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>
|
||
);
|
||
}
|