Modul-Review 2 Feinschliff: Assistent, Tour, 3-Spalten-Ansicht
Deploy App / deploy (push) Successful in 1m8s

Sechs Punkte aus der Testrunde:

Assistent:
- Willkommens-Screen (Gesamtbild der 5 Schritte) + persistente Schritt-Leiste
- Rendite-Feld bei PK/3a/Wertschriften ergaenzt (war fest verdrahtet)
- Schritt 5: Hypothekarzinsen (falls nicht in Ausgaben) korrekt bei den
  Ausgaben dazugerechnet; "noch zu verteilen" prominent nach oben;
  Verteilung nach Gemeinsam/Person A/Person B gruppiert

Tour:
- Spotlight: Rest abgedunkelt (9999px box-shadow), staerkerer Puls
- Karte groesser + springt auf die gegenueberliegende Bildschirmhaelfte
- "Ueberspringen"-Knopf

Ansicht:
- Grundprofil + Zeitachse + Kennzahlen (Endvermoegen nom/real, Ruin) als
  ein kompakter 3-Spalten-Block (25/50/25%) statt zweier breiter Zeilen
- neue Modal-Groesse xwide

Kein Eingriff in den Rechenkern; 267 Tests unveraendert. SPEZIFIKATION 0.29.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 22:42:09 +02:00
parent 52c48a9956
commit efcc04c645
6 changed files with 381 additions and 178 deletions
+63 -31
View File
@@ -343,6 +343,7 @@ export function PlanView({
const hasPhases = computed.phases.length > 0;
const firstPhase = computed.phases[0] ?? null;
const lastPhase = computed.phases[computed.phases.length - 1] ?? null;
return (
<div className="flex flex-col gap-5">
@@ -360,39 +361,70 @@ export function PlanView({
onOpenSensitivity={onOpenSensitivity}
/>
{/* Szenario-Profil */}
<div
data-tour="profil"
className={`flex flex-wrap items-center gap-3 rounded-xl border px-4 py-3 text-sm shadow-sm ${
diff?.profileChanged ? "border-diff bg-diff-soft" : "border-border bg-surface"
}`}
>
<span className="text-xs font-semibold uppercase tracking-wide text-faint">
Grundprofil (Szenario){diff?.profileChanged ? " · abweichend" : ""}
</span>
{plan.persons.map((p) => (
<span key={p.role} className="text-xs text-muted">
{personLabel(p.role)}: {p.age} J., Pension {p.retirementAge}
</span>
))}
<span className="text-xs text-muted">Inflation {plan.inflationRateDefault}%</span>
<button
type="button"
onClick={() => setPanel({ kind: "profile" })}
className="ml-auto flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1 text-xs font-medium text-muted hover:bg-surface-2"
{/* Kompakter Klotz: Grundprofil (25 %) · Zeitachse (50 %) · Kennzahlen (25 %). Auf
schmalen Screens stapeln die drei. */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
{/* Grundprofil -- Daten untereinander, «Bearbeiten» oben rechts. */}
<div
data-tour="profil"
className={`flex flex-col gap-2 rounded-xl border px-4 py-3 shadow-sm lg:col-span-1 ${
diff?.profileChanged ? "border-diff bg-diff-soft" : "border-border bg-surface"
}`}
>
<Settings2 className="h-3.5 w-3.5" /> Profil bearbeiten
</button>
</div>
<div className="flex items-start justify-between gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">
Grundprofil{diff?.profileChanged ? " · abweichend" : ""}
</span>
<button
type="button"
onClick={() => setPanel({ kind: "profile" })}
className="flex shrink-0 items-center gap-1 rounded-lg border border-border px-2 py-0.5 text-xs font-medium text-muted hover:bg-surface-2"
>
<Settings2 className="h-3.5 w-3.5" /> Bearbeiten
</button>
</div>
<div className="flex flex-col gap-1">
{plan.persons.map((p) => (
<span key={p.role} className="text-xs text-muted">
{personLabel(p.role)}: {p.age} J., Pension {p.retirementAge}
</span>
))}
<span className="text-xs text-muted">Inflation {plan.inflationRateDefault}%</span>
</div>
</div>
<div data-tour="timeline">
<Timeline
phases={computed.phases}
persons={personAxes}
ruinAge={computed.ruinAge}
actualYears={actualYears}
startYear={plan.startYear}
/>
{/* Zeitachse */}
<div data-tour="timeline" className="lg:col-span-2">
<Timeline
phases={computed.phases}
persons={personAxes}
ruinAge={computed.ruinAge}
actualYears={actualYears}
startYear={plan.startYear}
/>
</div>
{/* Kennzahlen: Endvermögen nominal + real (+ Ruin, falls vorhanden). */}
<div className="flex flex-col justify-center gap-2 rounded-xl border border-border bg-surface px-4 py-3 shadow-sm lg:col-span-1">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">Endvermögen</span>
<div>
<div className="text-[11px] text-muted">Nominal</div>
<div className="text-lg font-bold text-fg">
{lastPhase ? formatChf(Math.round(lastPhase.endWealthNominal)) : "—"}
</div>
</div>
<div>
<div className="text-[11px] text-muted">Real (heutige Kaufkraft)</div>
<div className="text-lg font-bold text-fg">
{lastPhase ? formatChf(Math.round(lastPhase.endWealthReal)) : "—"}
</div>
</div>
{computed.ruinAge !== null && (
<div className="mt-0.5 rounded-lg bg-danger-soft px-2 py-1 text-xs font-medium text-danger">
Kapital reicht nur bis Alter {computed.ruinAge}
</div>
)}
</div>
</div>
{/* Anzeige-Umschalter (nominal/real, Plan/Ist) -- direkt über der Matrix, weil er nur
+216 -94
View File
@@ -16,8 +16,8 @@
// Ehrliche Grenze: Bricht die Sequenz mittendrin ab (Netzfehler), existiert ein Teil-Plan
// (siehe SPEZIFIKATION 9.23).
import { Fragment, useMemo, useRef, useState } from "react";
import { ArrowLeft, ArrowRight, Flag, Plus, Sparkles, Trash2 } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { ArrowLeft, ArrowRight, CalendarRange, Check, Coins, Flag, PiggyBank, Plus, Sparkles, Trash2, User, Wallet } from "lucide-react";
import { Modal, Button } from "@/components/ui";
import { MoneyField, NumberField, TextField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
@@ -41,6 +41,16 @@ const STEP_TITLES = [
"Fertig",
];
// Die fünf «Tu-was»-Schritte für den Willkommens-Screen und die persistente Schritt-Leiste.
// «Fertig» (Zusammenfassung) ist der Abschluss und trägt keine eigene Nummer.
const WIZARD_OVERVIEW: { icon: React.ComponentType<{ className?: string }>; title: string; desc: string }[] = [
{ icon: User, title: "Über dich", desc: "Haushalt, Alter und Pensionsalter." },
{ icon: CalendarRange, title: "Lebensphasen", desc: "Die Abschnitte deiner Planung bis zur Pension und danach." },
{ icon: Wallet, title: "Einkommen & Ausgaben", desc: "Was reinkommt, was rausgeht, was auf dem Konto liegt." },
{ icon: PiggyBank, title: "Vorsorge & Vermögen", desc: "Was du heute besitzt PK, 3a, Wertschriften, Wohneigentum." },
{ icon: Coins, title: "Sparen & Verteilen", desc: "Deine Sparquote auf die Sparziele aufteilen." },
];
const SEGMENT_META: Record<SegmentType, { label: string; phase: string; def: string; color: string; soft: string }> = {
ERWERB: {
label: "Erwerb",
@@ -86,10 +96,10 @@ type Scope = "SELF" | "HOUSEHOLD" | "PERSON_A" | "PERSON_B";
type Category = "pk" | "p3a" | "etf" | "re" | "debt";
interface ScopeAssets {
pk: { on: boolean; value: number; contribution: number };
pk: { on: boolean; value: number; contribution: number; ret: number };
// selfEmployed: «grosse Säule 3a» ohne PK -> höhere Obergrenze (Roadmap-Feedback C7c).
p3a: { on: boolean; value: number; contribution: number; selfEmployed: boolean };
etf: { on: boolean; value: number; contribution: number };
p3a: { on: boolean; value: number; contribution: number; selfEmployed: boolean; ret: number };
etf: { on: boolean; value: number; contribution: number; ret: number };
// growth = Wertsteigerung %/Jahr; interestIncluded = Zinsen bereits in den Ausgaben.
re: { on: boolean; price: number; mortgage: number; amortization: number; interest: number; growth: number; interestIncluded: boolean };
debt: { on: boolean; value: number; repayment: number };
@@ -97,9 +107,9 @@ interface ScopeAssets {
function emptyScope(): ScopeAssets {
return {
pk: { on: false, value: 0, contribution: 0 },
p3a: { on: false, value: 0, contribution: 0, selfEmployed: false },
etf: { on: false, value: 0, contribution: 0 },
pk: { on: false, value: 0, contribution: 0, ret: 1.5 },
p3a: { on: false, value: 0, contribution: 0, selfEmployed: false, ret: 2 },
etf: { on: false, value: 0, contribution: 0, ret: 5 },
re: { on: false, price: 0, mortgage: 0, amortization: 0, interest: 1.5, growth: 1, interestIncluded: true },
debt: { on: false, value: 0, repayment: 0 },
};
@@ -112,6 +122,7 @@ export function PlanWizard({
onCreated: (scenarioId: string) => void;
onClose: () => void;
}) {
const [started, setStarted] = useState(false);
const [step, setStep] = useState(0);
const [planName, setPlanName] = useState("Meine Planung");
const [profile, setProfile] = useState<ProfileDraft>(emptyProfileDraft);
@@ -245,14 +256,14 @@ export function PlanWizard({
await addEl("PENSION_FUND", `Pensionskasse${suffix}`, scope.vorsorgeRole!, {
currentValue: a.pk.value,
annualContribution: a.pk.contribution,
expectedReturn: 1.5,
expectedReturn: a.pk.ret,
});
}
if (a.p3a.on) {
await addEl("PILLAR_3A", `Säule 3a${suffix}`, scope.vorsorgeRole!, {
currentValue: a.p3a.value,
annualContribution: Math.min(a.p3a.contribution, max3aFor(scope.id)),
expectedReturn: 2,
expectedReturn: a.p3a.ret,
selfEmployed3a: a.p3a.selfEmployed,
});
}
@@ -261,7 +272,7 @@ export function PlanWizard({
await addEl("OTHER_ASSET", `Wertschriften${suffix}`, scope.otherRole, {
startValue: a.etf.value,
annualContribution: a.etf.contribution,
expectedReturn: 5,
expectedReturn: a.etf.ret,
});
}
if (a.re.on) {
@@ -324,7 +335,15 @@ export function PlanWizard({
// Die Sparquote entsteht aus Nettoeinkommen minus Ausgaben. PK-Beiträge kommen VOM
// Bruttolohn (vor dem Netto) und reduzieren sie deshalb NICHT -- genau wie im Rechenkern.
const totalIncome = profile.persons.reduce((s, p) => s + (incomes[p.role] ?? 0), 0);
const sparBase = totalIncome - expenses;
// Hypothekarzinsen, die NICHT bereits in den Ausgaben stecken, werden hier den Ausgaben
// zugerechnet -- so wie sie der Rechenkern bei "interestHandling: ADD" behandelt. Sonst
// zeigte die Vorschau eine zu hohe Sparquote.
const addedInterest = renderedScopes.reduce((sum, sc) => {
const re = assets[sc.id].re;
return sum + (re.on && !re.interestIncluded ? Math.round((re.mortgage * re.interest) / 100) : 0);
}, 0);
const effectiveExpenses = expenses + addedInterest;
const sparBase = totalIncome - effectiveExpenses;
const allocatedFromQuote = renderedScopes.reduce((sum, sc) => {
const a = assets[sc.id];
return (
@@ -344,20 +363,102 @@ export function PlanWizard({
const scopeTag = (sc: { label: string }) => (isCouple ? ` · ${sc.label}` : "");
const max3aFor = (sc: Scope) => (assets[sc].p3a.selfEmployed ? PILLAR_3A_MAX_SELF_EMPLOYED : PILLAR_3A_MAX_ANNUAL);
// Willkommens-Screen vor dem ersten Schritt: das Gesamtbild der fünf Schritte, damit der
// Nutzer von Anfang an weiss, was auf ihn zukommt (Roadmap-Feedback).
if (!started) {
return (
<Modal title="Plan erstellen geführt" subtitle="In 5 Schritten zu deinem Finanzplan" onClose={onClose} xwide>
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
Erstelle deinen Finanzplan in fünf einfachen Schritten das dauert nur ein paar Minuten, und alles lässt
sich später jederzeit ändern.
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{WIZARD_OVERVIEW.map((s, i) => (
<div key={i} className="flex items-start gap-3 rounded-xl border border-border bg-surface-2 p-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent-soft-fg">
<s.icon className="h-4 w-4" />
</span>
<div className="min-w-0">
<div className="text-sm font-semibold text-fg">
{i + 1}. {s.title}
</div>
<p className="text-xs text-muted">{s.desc}</p>
</div>
</div>
))}
</div>
<div className="flex items-center justify-between border-t border-border pt-3">
<Button variant="ghost" size="sm" onClick={onClose}>
Abbrechen
</Button>
<Button onClick={() => setStarted(true)}>
Los gehts <ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
</Modal>
);
}
return (
<Modal
title="Plan erstellen geführt"
subtitle={`Schritt ${step + 1} von ${STEP_TITLES.length}: ${STEP_TITLES[step]}`}
subtitle={step < 5 ? `Schritt ${step + 1} von 5: ${STEP_TITLES[step]}` : "Zusammenfassung"}
onClose={onClose}
wide
xwide
>
<div className="flex gap-1">
{STEP_TITLES.map((_, i) => (
<div key={i} className={`h-1 flex-1 rounded-full ${i <= step ? "bg-accent" : "bg-surface-2"}`} />
))}
</div>
<div className="flex gap-5">
{/* Persistente Schritt-Leiste (Desktop): der Nutzer sieht immer, wo er steht und was folgt. */}
<nav className="hidden w-48 shrink-0 flex-col gap-1 sm:flex">
{WIZARD_OVERVIEW.map((s, i) => {
const state = step > i ? "done" : step === i ? "current" : "todo";
return (
<div
key={i}
className={`flex items-start gap-2 rounded-lg px-2 py-1.5 text-xs ${
state === "current" ? "bg-accent-soft text-accent-soft-fg" : "text-muted"
}`}
>
<span
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold ${
state === "done"
? "bg-accent text-accent-fg"
: state === "current"
? "border border-accent-soft-fg text-accent-soft-fg"
: "border border-border text-faint"
}`}
>
{state === "done" ? <Check className="h-3 w-3" /> : i + 1}
</span>
<div className="min-w-0">
<div className={`font-semibold ${state === "todo" ? "text-faint" : ""}`}>{s.title}</div>
{state === "current" && <p className="mt-0.5 leading-snug">{s.desc}</p>}
</div>
</div>
);
})}
<div className="mt-1 flex items-center gap-2 rounded-lg px-2 py-1.5 text-xs">
<span
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full ${
step === 5 ? "bg-accent text-accent-fg" : "border border-border text-faint"
}`}
>
<Flag className="h-3 w-3" />
</span>
<span className={step === 5 ? "font-semibold text-fg" : "text-faint"}>Fertig</span>
</div>
</nav>
{step === 0 && (
<div className="flex min-w-0 flex-1 flex-col gap-4">
{/* Fortschritt (Mobile, wo die Leiste ausgeblendet ist). */}
<div className="flex gap-1 sm:hidden">
{WIZARD_OVERVIEW.map((_, i) => (
<div key={i} className={`h-1 flex-1 rounded-full ${i <= step ? "bg-accent" : "bg-surface-2"}`} />
))}
</div>
{step === 0 && (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
Ein paar Angaben zu dir daraus baut das Tool dein Grundgerüst. Alles lässt sich später ändern.
@@ -507,6 +608,7 @@ export function PlanWizard({
onChange={(v) => setScope(sc.id, "pk", { on: v })}
>
<MoneyField label="Aktuelles Guthaben (CHF)" value={assets[sc.id].pk.value} onChange={(v) => setScope(sc.id, "pk", { value: v })} />
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={assets[sc.id].pk.ret} onChange={(v) => setScope(sc.id, "pk", { ret: v })} />
</WizardToggle>
)}
{sc.cats.includes("p3a") && (
@@ -517,6 +619,7 @@ export function PlanWizard({
onChange={(v) => setScope(sc.id, "p3a", { on: v })}
>
<MoneyField label="Aktuelles Guthaben (CHF)" value={assets[sc.id].p3a.value} onChange={(v) => setScope(sc.id, "p3a", { value: v })} />
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={assets[sc.id].p3a.ret} onChange={(v) => setScope(sc.id, "p3a", { ret: v })} />
<label className="col-span-full flex cursor-pointer items-center gap-2 text-xs text-muted">
<input
type="checkbox"
@@ -536,6 +639,7 @@ export function PlanWizard({
onChange={(v) => setScope(sc.id, "etf", { on: v })}
>
<MoneyField label="Aktueller Wert (CHF)" value={assets[sc.id].etf.value} onChange={(v) => setScope(sc.id, "etf", { value: v })} />
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={assets[sc.id].etf.ret} onChange={(v) => setScope(sc.id, "etf", { ret: v })} />
</WizardToggle>
)}
{sc.cats.includes("re") && (
@@ -588,12 +692,18 @@ export function PlanWizard({
<div className="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
<span className="text-muted">{totalIncome.toLocaleString("de-CH")} CHF Einkommen</span>
<span className="text-faint"></span>
<span className="text-muted">{expenses.toLocaleString("de-CH")} CHF Ausgaben</span>
<span className="text-muted">{effectiveExpenses.toLocaleString("de-CH")} CHF Ausgaben</span>
<span className="text-faint">=</span>
<span className={`text-lg font-bold ${sparBase >= 0 ? "text-fg" : "text-danger"}`}>
{sparBase.toLocaleString("de-CH")} CHF / Jahr
</span>
</div>
{addedInterest > 0 && (
<p className="mt-1 text-[11px] text-faint">
Enthält {addedInterest.toLocaleString("de-CH")} CHF Hypothekarzinsen, die du im vorigen Schritt als «noch
nicht in den Ausgaben» markiert hast.
</p>
)}
<p className="mt-1 text-xs text-muted">
{sparBase >= 0
? "Dieser Betrag bleibt dir jährlich zum Sparen, Amortisieren oder Anlegen. Verteile ihn unten was übrig bleibt, wächst auf dem Cash-Konto."
@@ -625,62 +735,73 @@ export function PlanWizard({
</div>
)}
{/* Verteilung der Sparquote auf 3a / Wertschriften / Amortisation / Schuldtilgung. */}
{anyQuoteTarget ? (
<div className="rounded-xl border border-border p-3">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
Wohin fliesst deine Sparquote?
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{renderedScopes.map((sc) => (
<Fragment key={sc.id}>
{assets[sc.id].p3a.on && (
<MoneyField
label={`Säule 3a: Einzahlung / Jahr (CHF)${scopeTag(sc)}`}
help={`Maximal ${max3aFor(sc.id).toLocaleString("de-CH")} CHF${assets[sc.id].p3a.selfEmployed ? " (Selbstständig ohne PK)" : " (mit PK)"}.`}
value={assets[sc.id].p3a.contribution}
max={max3aFor(sc.id)}
onChange={(v) => setScope(sc.id, "p3a", { contribution: v })}
/>
)}
{assets[sc.id].etf.on && (
<MoneyField
label={`Wertschriften: Sparbeitrag / Jahr (CHF)${scopeTag(sc)}`}
value={assets[sc.id].etf.contribution}
onChange={(v) => setScope(sc.id, "etf", { contribution: v })}
/>
)}
{assets[sc.id].re.on && (
<MoneyField
label={`Wohneigentum: Amortisation / Jahr (CHF)${scopeTag(sc)}`}
value={assets[sc.id].re.amortization}
onChange={(v) => setScope(sc.id, "re", { amortization: v })}
/>
)}
{assets[sc.id].debt.on && (
<MoneyField
label={`Schulden: Rückzahlung / Jahr (CHF)${scopeTag(sc)}`}
value={assets[sc.id].debt.repayment}
onChange={(v) => setScope(sc.id, "debt", { repayment: v })}
/>
)}
</Fragment>
))}
</div>
{/* "Noch zu verteilen" prominent -- direkt unter der PK, ueber der Verteilung. */}
{anyQuoteTarget && (
<div
className={`flex items-center justify-between rounded-xl border px-4 py-2.5 text-sm ${
restToCash < 0 ? "border-danger bg-danger-soft" : "border-border bg-surface-2"
}`}
>
<span className="font-medium text-fg">Noch nicht verteilt (bleibt auf dem Cash-Konto)</span>
<span className={`text-base font-bold ${restToCash < 0 ? "text-danger" : "text-fg"}`}>
{restToCash.toLocaleString("de-CH")} CHF / Jahr
</span>
</div>
)}
{anyQuoteTarget && restToCash < 0 && (
<p className="-mt-2 text-[11px] text-danger">
Du verteilst mehr, als deine Sparquote hergibt die Differenz muss aus dem Cash-Bestand kommen und kann ihn
ins Minus ziehen.
</p>
)}
{/* Live-Rest: was nach der Verteilung aufs Cash geht. */}
<div className="mt-3 flex items-center justify-between border-t border-border pt-2 text-sm">
<span className="text-muted">Rest bleibt auf dem Cash-Konto</span>
<span className={`font-semibold ${restToCash < 0 ? "text-danger" : "text-fg"}`}>
{restToCash.toLocaleString("de-CH")} CHF / Jahr
</span>
</div>
{restToCash < 0 && (
<p className="mt-1 text-[11px] text-danger">
Du verteilst mehr, als deine Sparquote hergibt die Differenz muss aus dem Cash-Bestand kommen und kann
ihn ins Minus ziehen.
</p>
)}
{/* Verteilung der Sparquote -- gruppiert nach Eigentuemer-Bereich (Gemeinsam/A/B). */}
{anyQuoteTarget ? (
<div className="flex flex-col gap-3">
{renderedScopes
.filter((sc) => {
const a = assets[sc.id];
return a.p3a.on || a.etf.on || a.re.on || a.debt.on;
})
.map((sc) => (
<div key={sc.id} className="rounded-xl border border-border p-3">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
{isCouple ? sc.label : "Wohin fliesst deine Sparquote?"}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{assets[sc.id].p3a.on && (
<MoneyField
label="Säule 3a: Einzahlung / Jahr (CHF)"
help={`Maximal ${max3aFor(sc.id).toLocaleString("de-CH")} CHF${assets[sc.id].p3a.selfEmployed ? " (Selbstständig ohne PK)" : " (mit PK)"}.`}
value={assets[sc.id].p3a.contribution}
max={max3aFor(sc.id)}
onChange={(v) => setScope(sc.id, "p3a", { contribution: v })}
/>
)}
{assets[sc.id].etf.on && (
<MoneyField
label="Wertschriften: Sparbeitrag / Jahr (CHF)"
value={assets[sc.id].etf.contribution}
onChange={(v) => setScope(sc.id, "etf", { contribution: v })}
/>
)}
{assets[sc.id].re.on && (
<MoneyField
label="Wohneigentum: Amortisation / Jahr (CHF)"
value={assets[sc.id].re.amortization}
onChange={(v) => setScope(sc.id, "re", { amortization: v })}
/>
)}
{assets[sc.id].debt.on && (
<MoneyField
label="Schulden: Rückzahlung / Jahr (CHF)"
value={assets[sc.id].debt.repayment}
onChange={(v) => setScope(sc.id, "debt", { repayment: v })}
/>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted">
@@ -714,25 +835,26 @@ export function PlanWizard({
{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" size="sm" onClick={onClose}>
Abbrechen
</Button>
<div className="flex gap-2">
{step > 0 && (
<Button variant="secondary" onClick={() => setStep(step - 1)} disabled={saving}>
<ArrowLeft className="h-4 w-4" /> Zurück
<div className="flex items-center justify-between border-t border-border pt-3">
<Button variant="ghost" size="sm" onClick={onClose}>
Abbrechen
</Button>
)}
{step < STEP_TITLES.length - 1 ? (
<Button onClick={goNext} disabled={step === 1 && !step1Valid}>
Weiter <ArrowRight className="h-4 w-4" />
</Button>
) : (
<Button onClick={create} disabled={saving}>
<Sparkles className="h-4 w-4" /> {saving ? "Wird angelegt…" : "Plan erstellen"}
</Button>
)}
<div className="flex gap-2">
{/* Zurück führt in Schritt 0 auf den Willkommens-Screen. */}
<Button variant="secondary" onClick={() => (step > 0 ? setStep(step - 1) : setStarted(false))} disabled={saving}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
{step < STEP_TITLES.length - 1 ? (
<Button onClick={goNext} disabled={step === 1 && !step1Valid}>
Weiter <ArrowRight className="h-4 w-4" />
</Button>
) : (
<Button onClick={create} disabled={saving}>
<Sparkles className="h-4 w-4" /> {saving ? "Wird angelegt…" : "Plan erstellen"}
</Button>
)}
</div>
</div>
</div>
</div>
</Modal>
+44 -31
View File
@@ -58,6 +58,9 @@ export function Tour({ onClose }: { onClose: () => void }) {
[]
);
const [index, setIndex] = useState(0);
// Die Karte springt auf die dem Ziel GEGENÜBERliegende Bildschirmhälfte, damit sie das
// hervorgehobene Element nie verdeckt.
const [cardAtBottom, setCardAtBottom] = useState(true);
const step = steps[index];
useEffect(() => {
@@ -66,6 +69,9 @@ export function Tour({ onClose }: { onClose: () => void }) {
if (!el) return;
el.classList.add("tour-highlight");
el.scrollIntoView({ block: "center", behavior: "smooth" });
const rect = el.getBoundingClientRect();
// eslint-disable-next-line react-hooks/set-state-in-effect -- Position aus der DOM-Lage des Ziels
setCardAtBottom(rect.top + rect.height / 2 < window.innerHeight * 0.5);
return () => el.classList.remove("tour-highlight");
}, [step]);
@@ -81,15 +87,20 @@ export function Tour({ onClose }: { onClose: () => void }) {
}
return (
<div className="ui-pop fixed bottom-4 left-1/2 z-40 w-[26rem] max-w-[calc(100vw-2rem)] -translate-x-1/2 rounded-2xl border border-border bg-surface p-4 shadow-2xl">
<div
key={index}
className={`ui-pop fixed left-1/2 z-50 w-[32rem] max-w-[calc(100vw-2rem)] -translate-x-1/2 rounded-2xl border-2 border-accent bg-surface p-5 shadow-2xl ${
cardAtBottom ? "bottom-8" : "top-8"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent-soft text-accent-soft-fg">
<Lightbulb className="h-4 w-4" />
<div className="flex items-center gap-2.5">
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-accent text-accent-fg shadow-sm">
<Lightbulb className="h-5 w-5" />
</span>
<div>
<div className="text-sm font-semibold text-fg">{step.title}</div>
<div className="text-[11px] text-faint">
<div className="text-base font-semibold text-fg">{step.title}</div>
<div className="text-[11px] font-medium uppercase tracking-wide text-accent">
Schritt {index + 1} von {steps.length}
</div>
</div>
@@ -103,31 +114,33 @@ export function Tour({ onClose }: { onClose: () => void }) {
<X className="h-4 w-4" />
</button>
</div>
<p className="mt-2 text-sm leading-relaxed text-muted">{step.text}</p>
<div className="mt-3 flex items-center justify-between">
<div className="flex gap-1">
{steps.map((_, i) => (
<span
key={i}
className={`h-1.5 w-1.5 rounded-full ${i === index ? "bg-accent" : "bg-border-strong"}`}
/>
))}
</div>
<div className="flex gap-2">
{index > 0 && (
<Button variant="secondary" size="sm" onClick={() => setIndex(index - 1)}>
Zurück
</Button>
)}
{index < steps.length - 1 ? (
<Button size="sm" onClick={() => setIndex(index + 1)}>
Weiter
</Button>
) : (
<Button size="sm" onClick={finish}>
Fertig
</Button>
)}
<p className="mt-3 text-sm leading-relaxed text-muted">{step.text}</p>
<div className="mt-4 flex items-center justify-between gap-2">
<button type="button" onClick={finish} className="text-xs font-medium text-faint hover:text-muted">
Überspringen
</button>
<div className="flex items-center gap-3">
<div className="flex gap-1">
{steps.map((_, i) => (
<span key={i} className={`h-1.5 w-1.5 rounded-full ${i === index ? "bg-accent" : "bg-border-strong"}`} />
))}
</div>
<div className="flex gap-2">
{index > 0 && (
<Button variant="secondary" size="sm" onClick={() => setIndex(index - 1)}>
Zurück
</Button>
)}
{index < steps.length - 1 ? (
<Button size="sm" onClick={() => setIndex(index + 1)}>
Weiter
</Button>
) : (
<Button size="sm" onClick={finish}>
Fertig
</Button>
)}
</div>
</div>
</div>
</div>
+4 -1
View File
@@ -128,16 +128,19 @@ export function Modal({
onClose,
children,
wide,
xwide,
}: {
title: string;
subtitle?: string;
onClose: () => void;
children: React.ReactNode;
wide?: boolean;
xwide?: boolean;
}) {
const panelRef = useRef<HTMLDivElement | null>(null);
useEscClose(onClose);
useFocusTrap(panelRef);
const maxWidth = xwide ? "max-w-4xl" : wide ? "max-w-2xl" : "max-w-md";
return (
<div
className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8"
@@ -149,7 +152,7 @@ export function Modal({
aria-modal="true"
aria-label={title}
onClick={(e) => e.stopPropagation()}
className={`ui-pop flex w-full ${wide ? "max-w-2xl" : "max-w-md"} flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl`}
className={`ui-pop flex w-full ${maxWidth} flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">