Tour-Korrekturen (Spotlight, Schritte) + 3a-Schalter nach Schritt 5
Deploy App / deploy (push) Successful in 1m1s
Deploy App / deploy (push) Successful in 1m1s
Tour: - Spotlight neu aus vier fixed-Abdunkelflaechen um die Bounding-Box statt box-shadow -- funktioniert jetzt unabhaengig von z-index (sticky Matrix-Koepfe blieben hell) und overflow (im Matrix-Scrollbereich blieb fast alles hell); folgt dem Ziel per requestAnimationFrame - Schritte ueberarbeitet: Zeitachse + Endvermoegen neu erklaert; der vormalige "Analysen"-Schritt beschreibt jetzt die obere Funktions-Leiste; neuer Schritt fuer das linke Menue (Analysen/Berichte/Effektive Werte); unsichtbare Ziele werden uebersprungen Wizard: - 3a-Schalter "Selbststaendig ohne PK" von Schritt 4 nach Schritt 5 (er betrifft die Einzahlung / Sparraten-Verteilung) SPEZIFIKATION 0.30 (9.24 neu gefasst). 267 Tests unveraendert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+9
-12
@@ -207,23 +207,20 @@ body {
|
||||
}
|
||||
|
||||
/* Hervorhebung des aktuellen Tour-Ziels. */
|
||||
/* Spotlight: Der riesige (9999px) erste Schatten dunkelt ALLES um das Element herum ab, das
|
||||
Element selbst bleibt hell (der Schatten liegt ausserhalb). Der zweite Schatten ist der
|
||||
pulsierende Akzent-Rahmen. So braucht es kein separates Overlay, und es gibt keine
|
||||
Transparenz-Probleme. */
|
||||
@keyframes tour-pulse-kf {
|
||||
/* Tour-Spotlight: Der abgedunkelte Bereich entsteht aus vier fixed-Flächen UM das Ziel herum
|
||||
(in Tour.tsx aus der Bounding-Box berechnet) -- das funktioniert unabhängig von z-index und
|
||||
overflow. Hier nur der pulsierende Rahmen, der als eigene fixed-Fläche über dem Loch liegt. */
|
||||
@keyframes tour-ring-kf {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.55), 0 0 0 3px color-mix(in srgb, var(--accent) 85%, transparent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 85%, transparent), 0 0 14px 2px color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.55), 0 0 0 9px color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
box-shadow: 0 0 0 7px color-mix(in srgb, var(--accent) 50%, transparent), 0 0 22px 6px color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
}
|
||||
}
|
||||
.tour-highlight {
|
||||
position: relative;
|
||||
z-index: 40;
|
||||
border-radius: 0.75rem;
|
||||
animation: tour-pulse-kf 1.4s ease-in-out infinite;
|
||||
.tour-ring {
|
||||
border: 2px solid var(--accent);
|
||||
animation: tour-ring-kf 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
|
||||
@@ -378,6 +378,8 @@ function AppShellInner({ username }: { username: string }) {
|
||||
|
||||
{plans.map((p) => {
|
||||
const navHere = planNav?.planId === p.id;
|
||||
// Tour-Ziel «menu»: die Menü-Gruppe des gerade offenen Plans.
|
||||
const isActivePlan = p.scenarios.some((s) => s.id === selectedScenarioId);
|
||||
const subItem = (tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports", label: string, Icon: typeof FolderKanban) => (
|
||||
<button
|
||||
type="button"
|
||||
@@ -391,7 +393,7 @@ function AppShellInner({ username }: { username: string }) {
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<div key={p.id} className="mb-1">
|
||||
<div key={p.id} className="mb-1" data-tour={isActivePlan ? "menu" : undefined}>
|
||||
{/* Plan-Name: Klick öffnet das Plan-Dashboard. */}
|
||||
<div className="group flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold text-fg">
|
||||
<button
|
||||
@@ -630,7 +632,7 @@ function AppShellInner({ username }: { username: string }) {
|
||||
Monte-Carlo/Einflussfaktoren sind hier bewusst NICHT mehr -- sie laufen über
|
||||
die eigenen Menüpunkte (Analysen / Effektive Werte). Hier bleiben die reinen
|
||||
Szenario-Aktionen plus Tour und CSV-Export. */}
|
||||
<div className="flex flex-wrap items-center gap-2" data-tour="analysen">
|
||||
<div className="flex flex-wrap items-center gap-2" data-tour="toolbar">
|
||||
{detail.plan.phases.length > 0 && (
|
||||
<Button variant="secondary" onClick={() => setShowHistory(true)}>
|
||||
<History className="h-4 w-4" />
|
||||
|
||||
@@ -405,7 +405,7 @@ export function PlanView({
|
||||
</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">
|
||||
<div data-tour="endwealth" 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>
|
||||
|
||||
@@ -620,15 +620,6 @@ export function PlanWizard({
|
||||
>
|
||||
<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"
|
||||
checked={assets[sc.id].p3a.selfEmployed}
|
||||
onChange={(e) => setScope(sc.id, "p3a", { selfEmployed: e.target.checked })}
|
||||
/>
|
||||
Selbstständig ohne Pensionskasse («grosse Säule 3a»)
|
||||
<InfoBubble text={`Selbstständigerwerbende ohne PK dürfen bis 20 % des Nettoerwerbseinkommens einzahlen, gedeckelt auf ca. ${PILLAR_3A_MAX_SELF_EMPLOYED.toLocaleString("de-CH")} CHF – statt der ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF mit PK.`} />
|
||||
</label>
|
||||
</WizardToggle>
|
||||
)}
|
||||
{sc.cats.includes("etf") && (
|
||||
@@ -770,13 +761,26 @@ export function PlanWizard({
|
||||
</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 })}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5 sm:col-span-2">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={assets[sc.id].p3a.selfEmployed}
|
||||
onChange={(e) => setScope(sc.id, "p3a", { selfEmployed: e.target.checked })}
|
||||
/>
|
||||
Selbstständig ohne Pensionskasse («grosse Säule 3a»)
|
||||
<InfoBubble text={`Selbstständigerwerbende ohne PK dürfen bis 20 % des Nettoerwerbseinkommens einzahlen, gedeckelt auf ca. ${PILLAR_3A_MAX_SELF_EMPLOYED.toLocaleString("de-CH")} CHF – statt der ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF mit PK.`} />
|
||||
</label>
|
||||
<div className="sm:max-w-[calc(50%-0.375rem)]">
|
||||
<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 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{assets[sc.id].etf.on && (
|
||||
<MoneyField
|
||||
|
||||
+137
-74
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
// Interaktive Kurz-Tour über die Planansicht (Teil von Roadmap Nr. 10). Bewusst simpel
|
||||
// gehalten: eine feste Karte unten rechts plus ein pulsierender Rahmen um das jeweilige
|
||||
// Ziel-Element (per data-tour-Attribut) -- kein Spotlight-Overlay, keine Positionierungs-
|
||||
// Engine. Ziele, die es im aktuellen Plan nicht gibt (z. B. Übergänge bei nur einer
|
||||
// Phase), werden zur Laufzeit übersprungen.
|
||||
// Interaktive Kurz-Tour über die Planansicht (Teil von Roadmap Nr. 10).
|
||||
//
|
||||
// Spotlight ohne Engine-Bibliothek: Der abgedunkelte Bereich entsteht aus VIER fixed-Flächen
|
||||
// um die Bounding-Box des Ziels herum (oben/unten/links/rechts). Das lässt ein «Loch» am Ziel
|
||||
// frei und funktioniert -- anders als ein box-shadow-Trick -- unabhängig von z-index (sticky
|
||||
// Matrix-Köpfe) und overflow (Matrix-Scrollbereich). Ziele, die es im aktuellen Plan nicht
|
||||
// gibt oder die unsichtbar sind, werden übersprungen.
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Lightbulb, X } from "lucide-react";
|
||||
@@ -22,12 +24,17 @@ const STEPS: TourStep[] = [
|
||||
{
|
||||
target: "profil",
|
||||
title: "Dein Grundprofil",
|
||||
text: "Alter, Pensionsalter und Inflationsannahme. Alles hier gilt für dieses Szenario – über «Profil» änderst du es jederzeit.",
|
||||
text: "Alter, Pensionsalter und Inflationsannahme – die Basis dieses Szenarios. Über «Bearbeiten» änderst du sie jederzeit.",
|
||||
},
|
||||
{
|
||||
target: "timeline",
|
||||
title: "Die Zeitachse",
|
||||
text: "Deine Lebensphasen im Überblick: Breite = Dauer, Fähnchen = Pensionierung. Die ganze Planung denkt in diesen Phasen.",
|
||||
text: "Deine Lebensphasen im Zeitverlauf: Die Breite zeigt die Dauer, die Fähnchen die Pensionierungen – und falls es dazu kommt, den Punkt, an dem das Kapital ausgeht. Die ganze Planung denkt in diesen Phasen.",
|
||||
},
|
||||
{
|
||||
target: "endwealth",
|
||||
title: "Dein Endvermögen",
|
||||
text: "Das Ergebnis auf einen Blick: das Vermögen am Ende der Planung – nominal und real (in heutiger Kaufkraft). Reicht das Kapital nicht bis zum Schluss, steht hier stattdessen das Alter, in dem es ausgeht.",
|
||||
},
|
||||
{
|
||||
target: "matrix",
|
||||
@@ -45,37 +52,71 @@ const STEPS: TourStep[] = [
|
||||
text: "Dein Ausgleichskonto: Was übrig bleibt, landet hier – was fehlt, macht es rot. Negativ = Liquiditätslücke.",
|
||||
},
|
||||
{
|
||||
target: "analysen",
|
||||
title: "Analysen",
|
||||
text: "Grafiken, Live-Simulation, Monte-Carlo und Einflussfaktoren: Was passiert, wenn ich hier drehe – wie sicher ist mein Plan – und welche Annahme entscheidet wirklich?",
|
||||
target: "toolbar",
|
||||
title: "Aktionen zum Szenario",
|
||||
text: "Hier oben: die Änderungshistorie ansehen, ein neues Szenario aus diesem ableiten, die Rechenwege einsehen, die Matrix als CSV exportieren – und diese Tour jederzeit neu starten.",
|
||||
},
|
||||
{
|
||||
target: "menu",
|
||||
title: "Analysen & Berichte",
|
||||
text: "Links im Menü öffnest du die grossen Werkzeuge: unter «Analysen» die Grafiken, die Live-Simulation, Monte-Carlo und die Einflussfaktoren; unter «Berichte» erzeugst du ein PDF. «Effektive Werte» hält deine echten Zahlen gegen den Plan.",
|
||||
},
|
||||
];
|
||||
|
||||
interface Box {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function Tour({ onClose }: { onClose: () => void }) {
|
||||
// Nur Schritte, deren Ziel im DOM existiert (hängt vom Plan ab).
|
||||
// Nur Schritte, deren Ziel im DOM existiert UND sichtbar ist (z. B. die Menü-Leiste ist auf
|
||||
// schmalen Screens ausgeblendet -- getClientRects() ist dann leer).
|
||||
const steps = useMemo(
|
||||
() => STEPS.filter((s) => document.querySelector(`[data-tour="${s.target}"]`) !== null),
|
||||
() =>
|
||||
STEPS.filter((s) => {
|
||||
const el = document.querySelector(`[data-tour="${s.target}"]`);
|
||||
return el !== null && el.getClientRects().length > 0;
|
||||
}),
|
||||
[]
|
||||
);
|
||||
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 [rect, setRect] = useState<Box | null>(null);
|
||||
const step = steps[index];
|
||||
|
||||
useEffect(() => {
|
||||
if (!step) return;
|
||||
const el = document.querySelector(`[data-tour="${step.target}"]`);
|
||||
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");
|
||||
|
||||
// Die Bounding-Box jedes Frame nachführen (folgt dem sanften Scrollen und Resize), aber
|
||||
// den State nur bei tatsächlicher Änderung neu setzen.
|
||||
let raf = 0;
|
||||
const update = () => {
|
||||
const r = el.getBoundingClientRect();
|
||||
setRect((prev) =>
|
||||
prev && prev.top === r.top && prev.left === r.left && prev.width === r.width && prev.height === r.height
|
||||
? prev
|
||||
: { top: r.top, left: r.left, width: r.width, height: r.height }
|
||||
);
|
||||
raf = requestAnimationFrame(update);
|
||||
};
|
||||
update();
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [step]);
|
||||
|
||||
if (!step) return null;
|
||||
if (!step || !rect) return null;
|
||||
|
||||
const pad = 6;
|
||||
const hole: Box = {
|
||||
top: Math.max(0, rect.top - pad),
|
||||
left: Math.max(0, rect.left - pad),
|
||||
width: rect.width + pad * 2,
|
||||
height: rect.height + pad * 2,
|
||||
};
|
||||
const cardAtBottom = rect.top + rect.height / 2 < window.innerHeight * 0.5;
|
||||
|
||||
function finish() {
|
||||
try {
|
||||
@@ -86,63 +127,85 @@ export function Tour({ onClose }: { onClose: () => void }) {
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Vier Abdunkel-Flächen um das Loch. pointer-events-none, damit nichts blockiert wird.
|
||||
const dim = "fixed z-[45] bg-black/60 pointer-events-none";
|
||||
const belowTop = hole.top + hole.height;
|
||||
const rightOfLeft = hole.left + hole.width;
|
||||
|
||||
return (
|
||||
<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.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-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 className={dim} style={{ top: 0, left: 0, width: "100vw", height: hole.top }} />
|
||||
<div className={dim} style={{ top: belowTop, left: 0, width: "100vw", height: `calc(100vh - ${belowTop}px)` }} />
|
||||
<div className={dim} style={{ top: hole.top, left: 0, width: hole.left, height: hole.height }} />
|
||||
<div
|
||||
className={dim}
|
||||
style={{ top: hole.top, left: rightOfLeft, width: `calc(100vw - ${rightOfLeft}px)`, height: hole.height }}
|
||||
/>
|
||||
|
||||
{/* Pulsierender Rahmen ums Ziel. */}
|
||||
<div
|
||||
className="tour-ring pointer-events-none fixed z-[46] rounded-xl"
|
||||
style={{ top: hole.top, left: hole.left, width: hole.width, height: hole.height }}
|
||||
/>
|
||||
|
||||
{/* Karte -- springt auf die dem Ziel gegenüberliegende Bildschirmhälfte. */}
|
||||
<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.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-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>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={finish}
|
||||
aria-label="Tour beenden"
|
||||
className="rounded-md p-1 text-faint hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={finish}
|
||||
aria-label="Tour beenden"
|
||||
className="rounded-md p-1 text-faint hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user