V3: 3 Farbschemata, Grundprofil auf Plan-Ebene, Erstellungs-Popups, integer-Zahlenfelder mit Beschleunigungs-Spinner, Carry-Forward des Zielwerts, gefuehrter Uebergang
Deploy App / deploy (push) Successful in 1m51s

- Theming: semantische CSS-Tokens + 3 waehlbare Schemata (Hell/Dunkel/Warm/Sunset), Umschalter im Profil-Menue, FOUC-frei via Inline-Script, localStorage; Klassen-Sweep aller Komponenten, Recharts aus Tokens
- Datenmodell: Household entfaellt; Plan traegt Haushaltsform/Personen/Inflation selbst (Person -> planId, Plan -> userId); destruktive Migration (TRUNCATE); Onboarding/HouseholdSettings entfernt; Plan-Erstellung & -Einstellungen mit Profilfeldern
- Popups: Element-Erstellung mit Inline-Feldern (geteilte ElementPhaseFields/ElementTransitionFields), Phase- und Plan-Popups mit Direkteingabe
- Zahlenfelder: 1'000er-Runden entfernt (floorToThousand/roundToHundred weg), integer MoneyInput mit beschleunigendem Press-and-Hold-Spinner, 0-Bug-Fix, harte Live-Caps
- Quote: Amortisation + Tilgung neu quotenwirksam; Restquote sichtbar (sinkt beim Verteilen); Invest-Deckel = verfuegbares Kapital + fortgeschriebener Zielwert
- Carry-Forward: Startwert der Folgephase = Zielwert der Vorphase minus Uebergangs-Bezug (live abgeleitet); optionale Zusatzinvestition aus verfuegbarem Kapital
- Matrix: Zelle zeigt Start -> Ziel; Uebergangs-Spaltenkopf mit "n offen"-Badge + gefuehrtem Pruef-Panel

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 14:49:42 +02:00
parent b775ab77cb
commit 32adfb4476
32 changed files with 1706 additions and 1290 deletions
+71 -105
View File
@@ -12,10 +12,10 @@ import {
} from "lucide-react";
import { PlanView } from "@/components/PlanView";
import { Dashboard } from "@/components/Dashboard";
import { HouseholdSettings } from "@/components/HouseholdSettings";
import { ProfileMenu } from "@/components/ProfileMenu";
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
import { api } from "@/lib/api-client";
import type { HouseholdInput, PlanInput } from "@/lib/types";
import type { PlanInput } from "@/lib/types";
import type { PlanComputed } from "@/lib/calculations";
interface PlanListItem {
@@ -26,15 +26,7 @@ interface PlanListItem {
phases: { id: string; name: string; sequenceNumber: number }[];
}
export function AppShell({
initialHousehold,
username,
}: {
initialHousehold: HouseholdInput;
username: string;
}) {
const [household, setHousehold] = useState(initialHousehold);
const [showSettings, setShowSettings] = useState(false);
export function AppShell({ username }: { username: string }) {
const [plans, setPlans] = useState<PlanListItem[]>([]);
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
const [detail, setDetail] = useState<{ plan: PlanInput; computed: PlanComputed } | null>(null);
@@ -46,9 +38,7 @@ export function AppShell({
const loadPlans = useCallback(async (preferId?: string) => {
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
setPlans(data.plans);
if (preferId) {
setSelectedPlanId(preferId);
}
if (preferId) setSelectedPlanId(preferId);
return data.plans;
}, []);
@@ -84,9 +74,7 @@ export function AppShell({
if (!confirm("Diesen Plan wirklich loeschen?")) return;
await api.delete(`/api/plans/${id}`);
await loadPlans();
if (selectedPlanId === id) {
setSelectedPlanId(null);
}
if (selectedPlanId === id) setSelectedPlanId(null);
}
const activePlan = plans.find((p) => p.id === selectedPlanId) ?? null;
@@ -94,10 +82,10 @@ export function AppShell({
const sidebar = (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 px-4 py-4">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-indigo-600 dark:bg-indigo-500">
<PiggyBank className="h-5 w-5 text-white" />
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-accent">
<PiggyBank className="h-5 w-5 text-accent-fg" />
</div>
<span className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">FPT</span>
<span className="text-sm font-semibold text-fg">FPT</span>
</div>
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto px-3 pb-4">
@@ -108,9 +96,7 @@ export function AppShell({
setSidebarOpen(false);
}}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${
selectedPlanId === null
? "bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300"
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
selectedPlanId === null ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<LayoutDashboard className="h-4 w-4" />
@@ -118,19 +104,17 @@ export function AppShell({
</button>
<div className="mt-4 flex items-center justify-between px-3">
<span className="text-[11px] font-semibold uppercase tracking-wide text-zinc-400">Plaene</span>
<span className="text-[11px] font-semibold uppercase tracking-wide text-faint">Plaene</span>
<button
type="button"
onClick={() => setShowNewPlan(true)}
aria-label="Neuen Plan erstellen"
className="rounded-md p-1 text-indigo-600 hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-500/10"
className="rounded-md p-1 text-accent hover:bg-accent-soft"
>
<Plus className="h-4 w-4" />
</button>
</div>
{plans.length === 0 && (
<p className="px-3 py-2 text-xs text-zinc-400">Noch keine Plaene.</p>
)}
{plans.length === 0 && <p className="px-3 py-2 text-xs text-faint">Noch keine Plaene.</p>}
{plans.map((p) => (
<button
key={p.id}
@@ -140,14 +124,12 @@ export function AppShell({
setSidebarOpen(false);
}}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${
selectedPlanId === p.id
? "bg-indigo-50 font-medium text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300"
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
selectedPlanId === p.id ? "bg-accent-soft font-medium text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<FolderKanban className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{p.name}</span>
<span className="text-[11px] text-zinc-400">{p.phases.length}</span>
<span className="text-[11px] text-faint">{p.phases.length}</span>
</button>
))}
</nav>
@@ -157,20 +139,18 @@ export function AppShell({
return (
<div className="flex min-h-screen w-full">
{/* Sidebar Desktop */}
<aside className="hidden w-60 shrink-0 border-r border-zinc-200 bg-white lg:block dark:border-zinc-800 dark:bg-zinc-900">
{sidebar}
</aside>
<aside className="hidden w-60 shrink-0 border-r border-border bg-surface lg:block">{sidebar}</aside>
{/* Sidebar Mobile (Overlay) */}
{sidebarOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
<div className="absolute inset-0 bg-black/40" onClick={() => setSidebarOpen(false)} />
<aside className="absolute left-0 top-0 h-full w-64 border-r border-zinc-200 bg-white shadow-xl dark:border-zinc-800 dark:bg-zinc-900">
<aside className="absolute left-0 top-0 h-full w-64 border-r border-border bg-surface shadow-xl">
<button
type="button"
onClick={() => setSidebarOpen(false)}
aria-label="Menue schliessen"
className="absolute right-2 top-3 rounded-md p-1.5 text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
className="absolute right-2 top-3 rounded-md p-1.5 text-faint hover:bg-surface-2"
>
<X className="h-4 w-4" />
</button>
@@ -181,33 +161,23 @@ export function AppShell({
{/* Hauptbereich */}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-3 border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900">
<header className="flex items-center gap-3 border-b border-border bg-surface px-4 py-3">
<button
type="button"
onClick={() => setSidebarOpen(true)}
aria-label="Menue oeffnen"
className="rounded-lg border border-zinc-200 p-2 text-zinc-600 lg:hidden dark:border-zinc-700 dark:text-zinc-300"
className="rounded-lg border border-border p-2 text-muted lg:hidden"
>
<Menu className="h-4 w-4" />
</button>
<h1 className="min-w-0 flex-1 truncate text-base font-semibold text-zinc-900 dark:text-zinc-50">
<h1 className="min-w-0 flex-1 truncate text-base font-semibold text-fg">
{activePlan ? activePlan.name : "Uebersicht"}
</h1>
<ProfileMenu username={username} onOpenHouseholdSettings={() => setShowSettings(true)} />
<ProfileMenu username={username} />
</header>
<main className="flex-1 px-4 py-6 lg:px-8">
{showSettings && (
<div className="mb-6">
<HouseholdSettings
household={household}
onUpdated={setHousehold}
onClose={() => setShowSettings(false)}
/>
</div>
)}
{loading && <p className="text-sm text-zinc-500">Laedt</p>}
{loading && <p className="text-sm text-muted">Laedt</p>}
{!loading && selectedPlanId === null && (
<DashboardHome
@@ -226,7 +196,7 @@ export function AppShell({
<button
type="button"
onClick={() => setShowScenario(true)}
className="flex items-center gap-1.5 rounded-lg border border-zinc-300 px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
>
<Plus className="h-4 w-4" />
Szenario
@@ -235,23 +205,16 @@ export function AppShell({
<button
type="button"
onClick={() => handleDeletePlan(selectedPlanId)}
className="flex items-center gap-1.5 rounded-lg border border-zinc-300 px-3 py-1.5 text-sm font-medium text-zinc-500 hover:border-red-200 hover:bg-red-50 hover:text-red-600 dark:border-zinc-700 dark:hover:border-red-500/30 dark:hover:bg-red-950"
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
>
<Trash2 className="h-4 w-4" />
Plan loeschen
</button>
</div>
<PlanView
plan={detail.plan}
household={household}
computed={detail.computed}
onChanged={refreshCurrent}
/>
<PlanView plan={detail.plan} computed={detail.computed} onChanged={refreshCurrent} />
{detail.plan.phases.length > 0 && (
<Dashboard plan={detail.plan} computed={detail.computed} allPlans={plans} />
)}
{detail.plan.phases.length > 0 && <Dashboard plan={detail.plan} computed={detail.computed} allPlans={plans} />}
</div>
)}
</main>
@@ -260,10 +223,8 @@ export function AppShell({
{/* Dialoge */}
{showNewPlan && (
<PlanDialog
title="Neuen Plan erstellen"
defaultName="Basisplan"
onCreate={async (name) => {
const { plan } = await api.post<{ plan: { id: string } }>("/api/plans", { name });
onCreate={async (name, profile) => {
const { plan } = await api.post<{ plan: { id: string } }>("/api/plans", { name, ...profile });
setShowNewPlan(false);
await loadPlans(plan.id);
}}
@@ -305,10 +266,8 @@ function DashboardHome({
return (
<div className="flex flex-col gap-6">
<div>
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">
Willkommen, {username}
</h2>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
<h2 className="text-xl font-semibold text-fg">Willkommen, {username}</h2>
<p className="mt-1 text-sm text-muted">
Waehlen Sie einen Plan oder erstellen Sie einen neuen, um Ihre finanzielle Zukunft zu planen.
</p>
</div>
@@ -317,16 +276,16 @@ function DashboardHome({
{plans.map((p) => (
<div
key={p.id}
className="group relative flex cursor-pointer flex-col gap-2 rounded-xl border border-zinc-200/70 bg-white p-4 shadow-sm transition-shadow hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900"
className="group relative flex cursor-pointer flex-col gap-2 rounded-xl border border-border bg-surface p-4 shadow-sm transition-shadow hover:shadow-md"
onClick={() => onSelect(p.id)}
>
<div className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-100 dark:bg-indigo-500/20">
<FolderKanban className="h-5 w-5 text-indigo-600 dark:text-indigo-300" />
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-soft">
<FolderKanban className="h-5 w-5 text-accent-soft-fg" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-zinc-900 dark:text-zinc-100">{p.name}</div>
<div className="text-xs text-zinc-500">
<div className="truncate font-medium text-fg">{p.name}</div>
<div className="text-xs text-muted">
{p.phases.length} {p.phases.length === 1 ? "Phase" : "Phasen"}
{p.parentPlanId ? " · Szenario" : ""}
</div>
@@ -338,7 +297,7 @@ function DashboardHome({
e.stopPropagation();
onDelete(p.id);
}}
className="rounded-md p-1.5 text-zinc-300 opacity-0 transition-opacity hover:bg-red-50 hover:text-red-600 group-hover:opacity-100 dark:hover:bg-red-950"
className="rounded-md p-1.5 text-faint opacity-0 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
>
<Trash2 className="h-4 w-4" />
</button>
@@ -349,7 +308,7 @@ function DashboardHome({
<button
type="button"
onClick={onCreate}
className="flex min-h-20 items-center justify-center gap-2 rounded-xl border border-dashed border-indigo-300 bg-indigo-50/40 text-sm font-medium text-indigo-700 hover:bg-indigo-50 dark:border-indigo-500/30 dark:bg-indigo-500/5 dark:text-indigo-300 dark:hover:bg-indigo-500/15"
className="flex min-h-20 items-center justify-center gap-2 rounded-xl border border-dashed border-accent bg-accent-soft text-sm font-medium text-accent-soft-fg hover:bg-accent-soft"
>
<Plus className="h-4 w-4" />
Neuer Plan
@@ -360,43 +319,50 @@ function DashboardHome({
}
function PlanDialog({
title,
defaultName,
onCreate,
onClose,
}: {
title: string;
defaultName: string;
onCreate: (name: string) => void;
onCreate: (name: string, profile: ProfileDraft) => void;
onClose: () => void;
}) {
const [name, setName] = useState(defaultName);
const [name, setName] = useState("Basisplan");
const [draft, setDraft] = useState<ProfileDraft>(emptyProfileDraft);
const [saving, setSaving] = useState(false);
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-900"
className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">{title}</h2>
<input
autoFocus
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name des Plans"
/>
<h2 className="text-base font-semibold text-fg">Neuen Plan erstellen</h2>
<div>
<label className="mb-1 block text-xs font-medium text-muted">Name des Plans</label>
<input
autoFocus
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name des Plans"
/>
</div>
<PlanProfileFields draft={draft} onChange={setDraft} />
<div className="flex gap-2">
<button
type="button"
onClick={() => onCreate(name)}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
disabled={saving}
onClick={() => {
setSaving(true);
onCreate(name.trim() || "Plan", draft);
}}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
>
Erstellen
{saving ? "..." : "Erstellen"}
</button>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2"
>
Abbrechen
</button>
@@ -421,19 +387,19 @@ function ScenarioDialog({
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-900"
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">Szenario erstellen</h2>
<h2 className="text-base font-semibold text-fg">Szenario erstellen</h2>
<input
autoFocus
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name des Szenarios"
/>
<label className="text-xs text-zinc-500">Verzweigen ab Phase</label>
<label className="text-xs text-muted">Verzweigen ab Phase</label>
<select
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
value={branchFromPhaseId}
onChange={(e) => setBranchFromPhaseId(e.target.value)}
>
@@ -447,14 +413,14 @@ function ScenarioDialog({
<button
type="button"
onClick={() => onCreate(name, branchFromPhaseId)}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover"
>
Erstellen
</button>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2"
>
Abbrechen
</button>
+13 -13
View File
@@ -86,15 +86,15 @@ export function Dashboard({
<StatCard label="Geschaetzter Nachlass" value={computed.nachlass} help="Endvermoegen der letzten Phase - potenziell vererbbar." />
</div>
<section className="rounded-xl border border-zinc-200/70 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
<LineChartIcon className="h-4 w-4 text-indigo-500 dark:text-indigo-400" />
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-fg">
<LineChartIcon className="h-4 w-4 text-accent" />
Vermoegensverlauf
</h3>
<a
href={`/api/plans/${plan.id}/export`}
className="flex items-center gap-1.5 rounded-lg border border-zinc-300 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted hover:bg-surface-2"
>
<Download className="h-3.5 w-3.5" />
CSV-Export
@@ -102,9 +102,9 @@ export function Dashboard({
</div>
{otherPlans.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
<span className="text-xs text-zinc-500">Vergleichen mit:</span>
<span className="text-xs text-muted">Vergleichen mit:</span>
{otherPlans.map((p) => (
<label key={p.id} className="flex items-center gap-1 text-xs text-zinc-600 dark:text-zinc-300">
<label key={p.id} className="flex items-center gap-1 text-xs text-muted">
<input
type="checkbox"
checked={compareIds.includes(p.id)}
@@ -118,15 +118,15 @@ export function Dashboard({
<WealthChart series={series} />
</section>
<section className="rounded-xl border border-zinc-200/70 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<h3 className="mb-3 flex items-center gap-1.5 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
<BarChart3 className="h-4 w-4 text-indigo-500 dark:text-indigo-400" />
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<h3 className="mb-3 flex items-center gap-1.5 text-sm font-semibold text-fg">
<BarChart3 className="h-4 w-4 text-accent" />
Vermoegensaufteilung pro Phase (Endvermoegen)
</h3>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={barData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-zinc-200 dark:stroke-zinc-700" />
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="phase" tick={{ fontSize: 11 }} />
<YAxis
tick={{ fontSize: 11 }}
@@ -147,11 +147,11 @@ export function Dashboard({
function StatCard({ label, value, help }: { label: string; value: number; help?: string }) {
return (
<div className="rounded-xl border border-zinc-200/70 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="text-xs text-zinc-500 dark:text-zinc-400" title={help}>
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<div className="text-xs text-muted" title={help}>
{label}
</div>
<div className="mt-1 text-xl font-semibold text-indigo-600 dark:text-indigo-400">
<div className="mt-1 text-xl font-semibold text-accent">
{formatChf(value)} CHF
</div>
</div>
+376 -287
View File
@@ -2,7 +2,8 @@
import { useState } from "react";
import { Trash2 } from "lucide-react";
import { MoneyField, NumberField, SelectField } from "@/components/FormField";
import { FieldLabel, MoneyField, NumberField, SelectField } from "@/components/FormField";
import { formatChf } from "@/lib/format";
import { api } from "@/lib/api-client";
import { CATEGORY_LABELS, num } from "@/lib/elements";
import { PILLAR_3A_MAX_ANNUAL } from "@/lib/constants";
@@ -16,6 +17,10 @@ export interface CellContext {
durationYears: number;
isRetirementTransition: boolean;
carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, fuer Bezugs-Maxima
carried: boolean; // Phase >= 2: Startwert wird aus der Vorphase fortgeschrieben
derivedStart: number; // fortgeschriebener Basis-Startwert (read-only Anzeige)
quotaRateMax: number; // Max fuer eine Sparrate/Verzehrrate dieses Elements
capitalMax?: number; // Max fuer Startkapital/Neuinvestition (undefined = kein Cap)
}
interface Props {
@@ -27,6 +32,337 @@ interface Props {
onDeleteElement: () => void;
}
// Read-only Anzeige eines abgeleiteten (fortgeschriebenen) Wertes.
function DerivedField({ label, value, help }: { label: string; value: number; help?: string }) {
return (
<div>
<FieldLabel label={label} help={help} />
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
{formatChf(value)}
</div>
</div>
);
}
// --- Wiederverwendbare Feldgruppen (Detail-Panel, Erstell-Popup, Uebergangs-Review) ---
export function ElementPhaseFields({
element,
context,
pd,
setP,
}: {
element: { category: ElementCategory };
context: CellContext;
pd: PhaseData;
setP: (patch: Partial<PhaseData>) => void;
}) {
const carried = context.carried;
switch (element.category) {
case "INCOME":
return <MoneyField label="Jahreseinkommen (CHF)" value={num(pd.amount)} onChange={(v) => setP({ amount: v })} />;
case "EXPENSE":
return <MoneyField label="Jahresausgaben (CHF)" value={num(pd.amount)} onChange={(v) => setP({ amount: v })} />;
case "AHV":
if (!context.ownerWorking) {
return (
<p className="col-span-2 text-sm text-muted">
Die AHV-Rente wird automatisch aus den bisherigen Ausfalljahren berechnet (siehe Kennzahl in der
Matrix). Bei Ehepaaren greift die Plafonierung auf 150% der Maximalrente.
</p>
);
}
return (
<NumberField
label="Geplante Ausfalljahre"
help="Jahre ohne AHV-Beitraege in dieser Phase. Jedes Ausfalljahr kuerzt die spaetere Rente um 1/44."
value={num(pd.gapYears)}
min={0}
max={context.durationYears}
onChange={(v) => setP({ gapYears: Math.max(0, Math.min(context.durationYears, Math.round(v))) })}
/>
);
case "PENSION_FUND":
if (!context.ownerWorking) {
return (
<p className="col-span-2 text-sm text-muted">
Die PK-Rente wird aus dem beim Pensions-Uebergang gewaehlten Umwandlungssatz berechnet (siehe
Kennzahl). Bei reinem Kapitalbezug erscheint hier &quot;Vollstaendig bezogen&quot;.
</p>
);
}
return (
<>
{carried ? (
<>
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
<MoneyField
label="Zusatzeinlage aus Kapital (CHF)"
help="Aufstockung aus dem verfuegbaren Kapital dieser Phase."
value={num(pd.additionalInvestment)}
max={context.capitalMax}
onChange={(v) => setP({ additionalInvestment: v })}
/>
</>
) : (
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} max={context.capitalMax} onChange={(v) => setP({ currentValue: v })} />
)}
<MoneyField
label="Jaehrliche Einzahlung (CHF)"
help="Arbeitnehmer- und Arbeitgeberbeitraege. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben beruecksichtigt)."
value={num(pd.annualContribution)}
onChange={(v) => setP({ annualContribution: v })}
/>
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
</>
);
case "PILLAR_3A":
if (!context.ownerWorking) {
return <p className="col-span-2 text-sm text-muted">Die Saeule 3a wird beim Pensions-Uebergang vollstaendig bezogen.</p>;
}
return (
<>
{carried ? (
<>
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
<MoneyField
label="Zusatzeinlage aus Kapital (CHF)"
help="Aufstockung aus dem verfuegbaren Kapital dieser Phase."
value={num(pd.additionalInvestment)}
max={context.capitalMax}
onChange={(v) => setP({ additionalInvestment: v })}
/>
</>
) : (
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} max={context.capitalMax} onChange={(v) => setP({ currentValue: v })} />
)}
<MoneyField
label="Jaehrliche Einzahlung (CHF)"
help={`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und hoechstens die Sparquote. Wird von der Sparquote abgezogen.`}
value={num(pd.annualContribution)}
max={Math.min(PILLAR_3A_MAX_ANNUAL, context.quotaRateMax)}
onChange={(v) => setP({ annualContribution: v })}
/>
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
</>
);
case "REAL_ESTATE":
if (carried) {
return (
<>
<DerivedField label="Startwert Netto (fortgeschrieben)" value={context.derivedStart} help="Kaufpreis minus fortgeschriebene Resthypothek." />
<MoneyField
label="Amortisation (CHF/Jahr)"
help="Jaehrliche Reduktion der Hypothek. Zaehlt gegen die Sparquote."
value={num(pd.amortization)}
max={context.quotaRateMax}
onChange={(v) => setP({ amortization: v })}
/>
</>
);
}
return (
<>
<MoneyField label="Kaufpreis (CHF)" value={num(pd.purchasePrice)} max={context.capitalMax != null ? context.capitalMax + num(pd.mortgage) : undefined} onChange={(v) => setP({ purchasePrice: v })} />
<MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} />
<MoneyField
label="Amortisation (CHF/Jahr)"
help="Jaehrliche Reduktion der Hypothek. Zaehlt gegen die Sparquote."
value={num(pd.amortization)}
max={context.quotaRateMax}
onChange={(v) => setP({ amortization: v })}
/>
</>
);
case "OTHER_ASSET":
return (
<>
{carried ? (
<>
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
<MoneyField
label="Zusatzinvestition aus Kapital (CHF)"
help="Neuinvestition aus dem verfuegbaren Kapital dieser Phase."
value={num(pd.additionalInvestment)}
max={context.capitalMax}
onChange={(v) => setP({ additionalInvestment: v })}
/>
</>
) : (
<MoneyField label="Startwert (CHF)" value={num(pd.startValue)} max={context.capitalMax} onChange={(v) => setP({ startValue: v })} />
)}
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
<MoneyField
label={context.isConsumption ? "Jaehrliche Bezugsrate (CHF)" : "Jaehrlicher Sparbeitrag (CHF)"}
help={
context.isConsumption
? "In dieser Verzehrphase deckt dieser Betrag die Verzehrquote (max. die Verzehrquote)."
: "Wird von der Sparquote abgezogen (max. die Sparquote)."
}
value={num(pd.annualContribution)}
max={context.quotaRateMax}
onChange={(v) => setP({ annualContribution: v })}
/>
</>
);
case "OTHER_DEBT":
return (
<>
{carried ? (
<DerivedField label="Restschuld (fortgeschrieben)" value={Math.abs(context.derivedStart)} help="Fortgeschriebene Restschuld aus der Vorphase." />
) : (
<MoneyField label="Restschuld (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
)}
<MoneyField
label="Jaehrliche Tilgung (CHF)"
help="Zaehlt gegen die Sparquote (max. die Sparquote)."
value={num(pd.annualRepayment)}
max={context.quotaRateMax}
onChange={(v) => setP({ annualRepayment: v })}
/>
</>
);
}
}
export function ElementTransitionFields({
element,
context,
td,
setT,
}: {
element: { category: ElementCategory };
context: CellContext;
td: TransitionData;
setT: (patch: Partial<TransitionData>) => void;
}) {
switch (element.category) {
case "INCOME":
case "EXPENSE":
case "AHV":
return (
<p className="col-span-2 text-sm text-muted">
Fuer diese Kategorie gibt es im Uebergang keine Eingaben. Die Werte werden 1:1 in die naechste
Lebensphase uebernommen und koennen dort angepasst werden.
</p>
);
case "PENSION_FUND":
if (context.isRetirementTransition) {
const mode = td.payoutMode ?? "PENSION";
return (
<>
<SelectField
label="Bezugsart bei Pensionierung"
value={mode}
onChange={(v: "CAPITAL" | "PENSION" | "COMBI") => setT({ payoutMode: v })}
options={[
{ value: "PENSION", label: "Rente" },
{ value: "CAPITAL", label: "Kapitalbezug" },
{ value: "COMBI", label: "Kombination" },
]}
/>
{(mode === "PENSION" || mode === "COMBI") && (
<NumberField
label="Umwandlungssatz (%)"
help="Jaehrliche Rente = verrentetes Kapital x Umwandlungssatz."
step={0.1}
value={num(td.conversionRate, 6)}
onChange={(v) => setT({ conversionRate: v })}
/>
)}
{(mode === "CAPITAL" || mode === "COMBI") && (
<NumberField label="Kapitalbezugssteuer (%)" step={0.5} value={num(td.capitalTaxRate, 8)} onChange={(v) => setT({ capitalTaxRate: v })} />
)}
{mode === "COMBI" && (
<MoneyField
label="Davon Kapitalbezug (CHF)"
help={`Der Rest wird verrentet. Maximal ${formatChf(context.carriedEndValue)}.`}
value={num(td.capitalAmount)}
max={context.carriedEndValue}
onChange={(v) => setT({ capitalAmount: v })}
/>
)}
</>
);
}
return (
<MoneyField
label="PK-Bezug (CHF)"
help={`Optionaler Bezug. Maximal ${formatChf(context.carriedEndValue)} (Endwert der Vorphase).`}
value={num(td.withdrawal)}
max={context.carriedEndValue}
onChange={(v) => setT({ withdrawal: v })}
/>
);
case "PILLAR_3A":
if (context.isRetirementTransition) {
return (
<NumberField
label="Kapitalbezugssteuer (%)"
help="Die Saeule 3a wird bei Pensionierung vollstaendig bezogen."
step={0.5}
value={num(td.capitalTaxRate, 8)}
onChange={(v) => setT({ capitalTaxRate: v })}
/>
);
}
return (
<MoneyField
label="3a-Bezug (CHF)"
help={`Maximal ${formatChf(context.carriedEndValue)} (Endwert der Vorphase).`}
value={num(td.withdrawal)}
max={context.carriedEndValue}
onChange={(v) => setT({ withdrawal: v })}
/>
);
case "REAL_ESTATE": {
const decision = td.decision ?? "HOLD";
return (
<>
<SelectField
label="Entscheidung"
value={decision}
onChange={(v: "HOLD" | "SELL") => setT({ decision: v })}
options={[
{ value: "HOLD", label: "Halten" },
{ value: "SELL", label: "Verkaufen" },
]}
/>
{decision === "SELL" && (
<>
<MoneyField label="Verkaufspreis (CHF)" value={num(td.salePrice)} onChange={(v) => setT({ salePrice: v })} />
<NumberField label="Grundstueckgewinnsteuer (%)" step={1} value={num(td.saleTaxRate, 20)} onChange={(v) => setT({ saleTaxRate: v })} />
</>
)}
</>
);
}
case "OTHER_ASSET": {
const decision = td.decision ?? "HOLD";
return (
<SelectField
label="Entscheidung"
value={decision}
onChange={(v: "HOLD" | "SELL") => setT({ decision: v })}
options={[
{ value: "HOLD", label: "Halten" },
{ value: "SELL", label: "Verkaufen" },
]}
/>
);
}
case "OTHER_DEBT":
return (
<MoneyField
label="Sofortige Tilgung (CHF)"
help="Wird sofort getilgt und vom verfuegbaren Kapital der naechsten Phase abgezogen."
value={num(td.immediateRepayment)}
onChange={(v) => setT({ immediateRepayment: v })}
/>
);
}
}
export function ElementDetail({ element, context, phaseData, transitionData, onSaved, onDeleteElement }: Props) {
const [pd, setPd] = useState<PhaseData>({ ...phaseData });
const [td, setTd] = useState<TransitionData>({ ...transitionData });
@@ -52,43 +388,6 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
}
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
{CATEGORY_LABELS[element.category]}
{isTransition ? " · Uebergang" : ""}
</div>
<div className="text-base font-semibold text-zinc-900 dark:text-zinc-100">{element.name}</div>
</div>
<button
type="button"
onClick={onDeleteElement}
className="flex items-center gap-1 rounded-lg border border-zinc-300 px-2 py-1 text-xs text-zinc-500 hover:border-red-200 hover:bg-red-50 hover:text-red-600 dark:border-zinc-700 dark:hover:bg-red-950"
>
<Trash2 className="h-3.5 w-3.5" /> Element loeschen
</button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{isTransition ? renderTransitionFields() : renderPhaseFields()}
</div>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div>
<button
type="button"
disabled={saving}
onClick={save}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
{saving ? "Speichern..." : "Speichern"}
</button>
</div>
</div>
);
function setP(patch: Partial<PhaseData>) {
setPd((prev) => ({ ...prev, ...patch }));
}
@@ -96,254 +395,44 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
setTd((prev) => ({ ...prev, ...patch }));
}
function renderPhaseFields() {
switch (element.category) {
case "INCOME":
return (
<MoneyField label="Jahreseinkommen (CHF)" value={num(pd.amount)} onChange={(v) => setP({ amount: v })} />
);
case "EXPENSE":
return (
<MoneyField label="Jahresausgaben (CHF)" value={num(pd.amount)} onChange={(v) => setP({ amount: v })} />
);
case "AHV":
if (!context.ownerWorking) {
return (
<p className="col-span-2 text-sm text-zinc-500 dark:text-zinc-400">
Die AHV-Rente wird automatisch aus den bisherigen Ausfalljahren berechnet (siehe Kennzahl in der
Matrix). Bei Ehepaaren greift die Plafonierung auf 150% der Maximalrente.
</p>
);
}
return (
<NumberField
label="Geplante Ausfalljahre"
help="Jahre ohne AHV-Beitraege in dieser Phase. Jedes Ausfalljahr kuerzt die spaetere Rente um 1/44."
value={num(pd.gapYears)}
min={0}
max={context.durationYears}
onChange={(v) => setP({ gapYears: Math.max(0, Math.min(context.durationYears, Math.round(v))) })}
/>
);
case "PENSION_FUND":
if (!context.ownerWorking) {
return (
<p className="col-span-2 text-sm text-zinc-500 dark:text-zinc-400">
Die PK-Rente wird aus dem beim Pensions-Uebergang gewaehlten Umwandlungssatz berechnet (siehe
Kennzahl). Bei reinem Kapitalbezug erscheint hier &quot;Vollstaendig bezogen&quot;.
</p>
);
}
return (
<>
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
<MoneyField
label="Jaehrliche Einzahlung (CHF)"
help="Arbeitnehmer- und Arbeitgeberbeitraege. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben beruecksichtigt)."
value={num(pd.annualContribution)}
onChange={(v) => setP({ annualContribution: v })}
/>
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
</>
);
case "PILLAR_3A":
if (!context.ownerWorking) {
return (
<p className="col-span-2 text-sm text-zinc-500 dark:text-zinc-400">
Die Saeule 3a wird beim Pensions-Uebergang vollstaendig bezogen.
</p>
);
}
return (
<>
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
<NumberField
label="Jaehrliche Einzahlung (CHF)"
help={`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK). Wird von der Sparquote abgezogen. Schritte von 100.`}
step={100}
min={0}
max={PILLAR_3A_MAX_ANNUAL}
value={num(pd.annualContribution)}
onChange={(v) => setP({ annualContribution: Math.max(0, Math.min(PILLAR_3A_MAX_ANNUAL, Math.round(v / 100) * 100)) })}
/>
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
</>
);
case "REAL_ESTATE":
return (
<>
<MoneyField label="Kaufpreis (CHF)" value={num(pd.purchasePrice)} onChange={(v) => setP({ purchasePrice: v })} />
<MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} />
<MoneyField
label="Amortisation (CHF/Jahr)"
help="Jaehrliche Reduktion der Hypothek."
value={num(pd.amortization)}
onChange={(v) => setP({ amortization: v })}
/>
</>
);
case "OTHER_ASSET":
return (
<>
<MoneyField label="Startwert (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
<MoneyField
label={context.isConsumption ? "Jaehrliche Bezugsrate (CHF)" : "Jaehrlicher Sparbeitrag (CHF)"}
help={
context.isConsumption
? "In dieser Verzehrphase wird dieser Betrag jaehrlich entnommen und deckt die Verzehrquote."
: "Wird von der Sparquote abgezogen."
}
value={num(pd.annualContribution)}
onChange={(v) => setP({ annualContribution: v })}
/>
</>
);
case "OTHER_DEBT":
return (
<>
<MoneyField label="Restschuld (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
<MoneyField label="Jaehrliche Tilgung (CHF)" value={num(pd.annualRepayment)} onChange={(v) => setP({ annualRepayment: v })} />
</>
);
}
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">
{CATEGORY_LABELS[element.category]}
{isTransition ? " · Uebergang" : ""}
</div>
<div className="text-base font-semibold text-fg">{element.name}</div>
</div>
<button
type="button"
onClick={onDeleteElement}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
>
<Trash2 className="h-3.5 w-3.5" /> Element loeschen
</button>
</div>
function renderTransitionFields() {
switch (element.category) {
case "INCOME":
case "EXPENSE":
case "AHV":
return (
<p className="col-span-2 text-sm text-zinc-500 dark:text-zinc-400">
Fuer diese Kategorie gibt es im Uebergang keine Eingaben. Die Werte werden 1:1 in die naechste
Lebensphase uebernommen und koennen dort angepasst werden.
</p>
);
case "PENSION_FUND":
if (context.isRetirementTransition) {
const mode = td.payoutMode ?? "PENSION";
return (
<>
<SelectField
label="Bezugsart bei Pensionierung"
value={mode}
onChange={(v: "CAPITAL" | "PENSION" | "COMBI") => setT({ payoutMode: v })}
options={[
{ value: "PENSION", label: "Rente" },
{ value: "CAPITAL", label: "Kapitalbezug" },
{ value: "COMBI", label: "Kombination" },
]}
/>
{(mode === "PENSION" || mode === "COMBI") && (
<NumberField
label="Umwandlungssatz (%)"
help="Jaehrliche Rente = verrentetes Kapital x Umwandlungssatz."
step={0.1}
value={num(td.conversionRate, 6)}
onChange={(v) => setT({ conversionRate: v })}
/>
)}
{(mode === "CAPITAL" || mode === "COMBI") && (
<NumberField
label="Kapitalbezugssteuer (%)"
step={0.5}
value={num(td.capitalTaxRate, 8)}
onChange={(v) => setT({ capitalTaxRate: v })}
/>
)}
{mode === "COMBI" && (
<MoneyField
label="Davon Kapitalbezug (CHF)"
help={`Der Rest wird verrentet. Maximal ${context.carriedEndValue.toLocaleString("de-CH")}.`}
value={num(td.capitalAmount)}
max={context.carriedEndValue}
onChange={(v) => setT({ capitalAmount: v })}
/>
)}
</>
);
}
return (
<MoneyField
label="PK-Bezug (CHF)"
help={`Optionaler Bezug. Maximal ${context.carriedEndValue.toLocaleString("de-CH")} (Endwert der Vorphase).`}
value={num(td.withdrawal)}
max={context.carriedEndValue}
onChange={(v) => setT({ withdrawal: v })}
/>
);
case "PILLAR_3A":
if (context.isRetirementTransition) {
return (
<NumberField
label="Kapitalbezugssteuer (%)"
help="Die Saeule 3a wird bei Pensionierung vollstaendig bezogen."
step={0.5}
value={num(td.capitalTaxRate, 8)}
onChange={(v) => setT({ capitalTaxRate: v })}
/>
);
}
return (
<MoneyField
label="3a-Bezug (CHF)"
help={`Maximal ${context.carriedEndValue.toLocaleString("de-CH")} (Endwert der Vorphase).`}
value={num(td.withdrawal)}
max={context.carriedEndValue}
onChange={(v) => setT({ withdrawal: v })}
/>
);
case "REAL_ESTATE": {
const decision = td.decision ?? "HOLD";
return (
<>
<SelectField
label="Entscheidung"
value={decision}
onChange={(v: "HOLD" | "SELL") => setT({ decision: v })}
options={[
{ value: "HOLD", label: "Halten" },
{ value: "SELL", label: "Verkaufen" },
]}
/>
{decision === "SELL" && (
<>
<MoneyField label="Verkaufspreis (CHF)" value={num(td.salePrice)} onChange={(v) => setT({ salePrice: v })} />
<NumberField
label="Grundstueckgewinnsteuer (%)"
step={1}
value={num(td.saleTaxRate, 20)}
onChange={(v) => setT({ saleTaxRate: v })}
/>
</>
)}
</>
);
}
case "OTHER_ASSET": {
const decision = td.decision ?? "HOLD";
return (
<SelectField
label="Entscheidung"
value={decision}
onChange={(v: "HOLD" | "SELL") => setT({ decision: v })}
options={[
{ value: "HOLD", label: "Halten" },
{ value: "SELL", label: "Verkaufen" },
]}
/>
);
}
case "OTHER_DEBT":
return (
<MoneyField
label="Sofortige Tilgung (CHF)"
help="Wird sofort getilgt und vom verfuegbaren Kapital der naechsten Phase abgezogen."
value={num(td.immediateRepayment)}
onChange={(v) => setT({ immediateRepayment: v })}
/>
);
}
}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{isTransition ? (
<ElementTransitionFields element={element} context={context} td={td} setT={setT} />
) : (
<ElementPhaseFields element={element} context={context} pd={pd} setP={setP} />
)}
</div>
{error && <p className="text-sm text-danger">{error}</p>}
<div>
<button
type="button"
disabled={saving}
onClick={save}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
>
{saving ? "Speichern..." : "Speichern"}
</button>
</div>
</div>
);
}
+57 -33
View File
@@ -3,14 +3,14 @@
import { useEffect, useRef, useState } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { InfoBubble } from "@/components/InfoBubble";
import { floorToThousand, formatChf, parseChfInput } from "@/lib/format";
import { formatChf, parseChfInput } from "@/lib/format";
const baseInputClass =
"w-full rounded-lg border border-zinc-300 bg-white px-2.5 py-1.5 text-sm text-zinc-900 shadow-sm transition-colors focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100";
"w-full rounded-lg border border-border bg-input px-2.5 py-1.5 text-sm text-fg shadow-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25";
export function FieldLabel({ label, help }: { label: string; help?: string }) {
return (
<label className="mb-1 flex items-center text-xs font-medium text-zinc-600 dark:text-zinc-400">
<label className="mb-1 flex items-center text-xs font-medium text-muted">
{label}
{help && <InfoBubble text={help} />}
</label>
@@ -44,32 +44,34 @@ export function NumberField({
step={step ?? "any"}
min={min}
max={max}
onFocus={(e) => e.target.select()}
onFocus={(e) => e.currentTarget.select()}
onChange={(e) => onChange(e.target.valueAsNumber || 0)}
/>
</div>
);
}
// Roher Betrags-Input ohne Label (fuer kompakte Tabellenzellen o.ae.). Zeigt den Wert
// formatiert mit 1'000er-Trennzeichen an, solange das Feld nicht fokussiert ist, rundet
// beim Verlassen des Feldes auf ein Vielfaches von 1'000 ABwaerts (siehe lib/format.ts)
// und bietet Pfeil-Buttons zum Erhoehen/Verringern in 1'000er-Schritten.
// Optionales `max` kappt Eingaben live auf das verfuegbare Budget (z. B. Sparquote).
// Ganzzahliges Betragsfeld. Zeigt den Wert unfokussiert mit 1'000er-Trennzeichen an,
// akzeptiert fokussiert beliebige ganze Zahlen (keine Nachkommastellen) und bietet
// Pfeil-Buttons mit Klick-und-Halten-BESCHLEUNIGUNG (1 -> 10 -> 100 -> 1'000 -> ...).
// Optionales `max` (und `min`) klammern die Eingabe hart (Live-Cap).
export function MoneyInput({
value,
onChange,
className,
min = 0,
max,
}: {
value: number;
onChange: (value: number) => void;
className?: string;
min?: number;
max?: number;
}) {
const [focused, setFocused] = useState(false);
const [text, setText] = useState(() => String(Math.floor(value || 0)));
const [text, setText] = useState(() => String(Math.round(value || 0)));
const valueRef = useRef(value);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
valueRef.current = value;
}, [value]);
@@ -77,13 +79,14 @@ export function MoneyInput({
const holdInterval = useRef<ReturnType<typeof setInterval> | null>(null);
function clamp(v: number): number {
let result = Math.max(0, v);
if (max != null) result = Math.min(result, Math.max(0, floorToThousand(max)));
let result = Math.round(v);
if (min != null) result = Math.max(min, result);
if (max != null) result = Math.min(result, Math.round(max));
return result;
}
function step(delta: number) {
onChange(clamp(floorToThousand(valueRef.current) + delta));
function stepBy(delta: number) {
onChange(clamp(Math.round(valueRef.current) + delta));
}
function stopHold() {
@@ -97,72 +100,91 @@ export function MoneyInput({
}
}
// Klick-und-Halten: sofortiger erster Schritt, nach kurzer Verzoegerung
// fortlaufende Wiederholung, bis losgelassen wird.
function startHold(delta: number) {
step(delta);
// Klick = 1 Schritt. Halten: nach 400 ms Wiederholung im 70-ms-Takt, wobei die
// Schrittweite mit der Haltedauer waechst (immer schneller).
function startHold(sign: number) {
stepBy(sign);
const start = Date.now();
holdTimeout.current = setTimeout(() => {
holdInterval.current = setInterval(() => step(delta), 100);
holdInterval.current = setInterval(() => {
const s = (Date.now() - start) / 1000;
const mag = s < 1.5 ? 1 : s < 3 ? 10 : s < 4.5 ? 100 : s < 6 ? 1000 : 10000;
stepBy(sign * mag);
}, 70);
}, 400);
}
useEffect(() => stopHold, []);
const arrowBtn =
"flex flex-1 items-center justify-center text-faint hover:bg-accent-soft hover:text-accent-soft-fg";
return (
<div className={`relative ${className ?? "w-full"}`}>
<input
ref={inputRef}
type="text"
inputMode="numeric"
className={`${baseInputClass} w-full pr-6`}
value={focused ? text : formatChf(value)}
onFocus={() => {
setFocused(true);
// Default-0 sofort leeren, damit man direkt lostippen kann.
const current = Math.floor(value || 0);
const current = Math.round(value || 0);
// Default-0 sofort leeren; sonst Wert markieren, damit man ihn ueberschreiben kann.
setText(current === 0 ? "" : String(current));
requestAnimationFrame(() => inputRef.current?.select());
}}
onChange={(e) => setText(e.target.value.replace(min < 0 ? /[^0-9-]/g : /[^0-9]/g, ""))}
onKeyDown={(e) => {
if (e.key === "ArrowUp") {
e.preventDefault();
stepBy(e.shiftKey ? 100 : 1);
} else if (e.key === "ArrowDown") {
e.preventDefault();
stepBy(e.shiftKey ? -100 : -1);
}
}}
onChange={(e) => setText(e.target.value.replace(/[^0-9-]/g, ""))}
onBlur={() => {
setFocused(false);
onChange(clamp(floorToThousand(parseChfInput(text))));
onChange(clamp(parseChfInput(text)));
}}
/>
<div className="absolute inset-y-0 right-0 flex w-6 flex-col overflow-hidden rounded-r-lg border-l border-zinc-300 dark:border-zinc-700">
<div className="absolute inset-y-0 right-0 flex w-6 flex-col overflow-hidden rounded-r-lg border-l border-border">
<button
type="button"
tabIndex={-1}
aria-label="Um 1'000 erhoehen"
aria-label="Erhoehen"
onMouseDown={(e) => {
e.preventDefault();
startHold(1000);
startHold(1);
}}
onMouseUp={stopHold}
onMouseLeave={stopHold}
onTouchStart={(e) => {
e.preventDefault();
startHold(1000);
startHold(1);
}}
onTouchEnd={stopHold}
className="flex flex-1 items-center justify-center text-zinc-500 hover:bg-indigo-50 hover:text-indigo-600 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-indigo-400"
className={arrowBtn}
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
tabIndex={-1}
aria-label="Um 1'000 verringern"
aria-label="Verringern"
onMouseDown={(e) => {
e.preventDefault();
startHold(-1000);
startHold(-1);
}}
onMouseUp={stopHold}
onMouseLeave={stopHold}
onTouchStart={(e) => {
e.preventDefault();
startHold(-1000);
startHold(-1);
}}
onTouchEnd={stopHold}
className="flex flex-1 items-center justify-center border-t border-zinc-300 text-zinc-500 hover:bg-indigo-50 hover:text-indigo-600 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-indigo-400"
className={`${arrowBtn} border-t border-border`}
>
<ChevronDown className="h-3 w-3" />
</button>
@@ -176,18 +198,20 @@ export function MoneyField({
help,
value,
onChange,
min,
max,
}: {
label: string;
help?: string;
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
}) {
return (
<div>
<FieldLabel label={label} help={help} />
<MoneyInput value={value} onChange={onChange} max={max} />
<MoneyInput value={value} onChange={onChange} min={min} max={max} />
</div>
);
}
-105
View File
@@ -1,105 +0,0 @@
"use client";
import { useState } from "react";
import { NumberField, SelectField } from "@/components/FormField";
import { api } from "@/lib/api-client";
import type { HouseholdInput, HouseholdType } from "@/lib/types";
export function HouseholdSettings({
household,
onUpdated,
onClose,
}: {
household: HouseholdInput;
onUpdated: (household: HouseholdInput) => void;
onClose: () => void;
}) {
const [householdType, setHouseholdType] = useState<HouseholdType>(household.householdType);
const [inflationRateDefault, setInflationRateDefault] = useState(household.inflationRateDefault);
const [persons, setPersons] = useState(
household.persons.map((p) => ({ role: p.role, age: p.age, retirementAge: p.retirementAge }))
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
function handleTypeChange(type: HouseholdType) {
setHouseholdType(type);
if (type === "SINGLE") {
setPersons((p) => p.slice(0, 1));
} else if (persons.length < 2) {
setPersons((p) => [...p, { role: "PERSON_B" as const, age: 35, retirementAge: 65 }]);
}
}
async function handleSubmit() {
setSaving(true);
setError(null);
try {
const { household: updated } = await api.patch<{ household: HouseholdInput }>("/api/household", {
householdType,
inflationRateDefault,
persons,
});
onUpdated(updated);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<div className="flex flex-col gap-4 rounded-xl border border-zinc-200/70 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<SelectField
label="Haushaltsform"
value={householdType}
onChange={handleTypeChange}
options={[
{ value: "SINGLE", label: "Einzelperson" },
{ value: "COUPLE", label: "Paar (zwei Personen)" },
]}
/>
{persons.map((person, index) => (
<div key={person.role} className="grid grid-cols-2 gap-3">
<NumberField
label={`Alter (${person.role === "PERSON_A" ? "Person A" : "Person B"})`}
value={person.age}
onChange={(v) => setPersons((prev) => prev.map((p, i) => (i === index ? { ...p, age: v } : p)))}
/>
<NumberField
label="Geplantes Pensionierungsalter"
value={person.retirementAge}
onChange={(v) =>
setPersons((prev) => prev.map((p, i) => (i === index ? { ...p, retirementAge: v } : p)))
}
/>
</div>
))}
<NumberField
label="Erwartete Inflationsrate (%)"
value={inflationRateDefault}
step={0.1}
onChange={setInflationRateDefault}
/>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="flex gap-2">
<button
type="button"
disabled={saving}
onClick={handleSubmit}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
{saving ? "Speichern..." : "Speichern"}
</button>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
Abbrechen
</button>
</div>
</div>
);
}
+2 -2
View File
@@ -14,12 +14,12 @@ export function InfoBubble({ text }: { text: string }) {
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onClick={() => setOpen((o) => !o)}
className="flex h-4 w-4 items-center justify-center rounded-full bg-indigo-100 text-indigo-500 hover:bg-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-300 dark:hover:bg-indigo-500/30"
className="flex h-4 w-4 items-center justify-center rounded-full bg-accent-soft text-accent-soft-fg hover:opacity-80"
>
<Info className="h-2.5 w-2.5" strokeWidth={2.5} />
</button>
{open && (
<span className="absolute left-1/2 top-6 z-20 w-64 -translate-x-1/2 rounded-lg border border-zinc-200 bg-white p-2.5 text-xs leading-snug text-zinc-700 shadow-lg dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200">
<span className="absolute left-1/2 top-6 z-20 w-64 -translate-x-1/2 rounded-lg border border-border bg-surface p-2.5 text-xs leading-snug text-fg shadow-lg">
{text}
</span>
)}
-119
View File
@@ -1,119 +0,0 @@
"use client";
import { useState } from "react";
import { PiggyBank } from "lucide-react";
import { api } from "@/lib/api-client";
import { NumberField, SelectField } from "@/components/FormField";
import type { HouseholdInput, HouseholdType, PersonRole } from "@/lib/types";
interface PersonDraft {
role: PersonRole;
age: number;
retirementAge: number;
}
export function Onboarding({ onDone }: { onDone: (household: HouseholdInput) => void }) {
const [householdType, setHouseholdType] = useState<HouseholdType>("SINGLE");
const [inflationRateDefault, setInflationRateDefault] = useState(1.5);
const [persons, setPersons] = useState<PersonDraft[]>([
{ role: "PERSON_A", age: 35, retirementAge: 65 },
]);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
function handleTypeChange(type: HouseholdType) {
setHouseholdType(type);
if (type === "SINGLE") {
setPersons((p) => p.slice(0, 1));
} else if (persons.length < 2) {
setPersons((p) => [...p, { role: "PERSON_B", age: 35, retirementAge: 65 }]);
}
}
function updatePerson(index: number, patch: Partial<PersonDraft>) {
setPersons((prev) => prev.map((p, i) => (i === index ? { ...p, ...patch } : p)));
}
async function handleSubmit() {
setSaving(true);
setError(null);
try {
const { household } = await api.post<{ household: HouseholdInput }>("/api/household", {
householdType,
inflationRateDefault,
persons,
});
onDone(household);
} catch (e) {
setError(e instanceof Error ? e.message : "Unbekannter Fehler.");
} finally {
setSaving(false);
}
}
return (
<div className="mx-auto flex w-full max-w-xl flex-1 flex-col justify-center px-6 py-16">
<div className="mb-2 flex items-center gap-2">
<PiggyBank className="h-7 w-7 text-indigo-600 dark:text-indigo-400" />
<h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
Willkommen beim Financial Planning Tool
</h1>
</div>
<p className="mb-8 text-sm text-zinc-600 dark:text-zinc-400">
Bevor es losgeht, brauchen wir ein paar Eckdaten zu Ihrem Haushalt.
</p>
<div className="flex flex-col gap-5 rounded-xl border border-zinc-200/70 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<SelectField
label="Haushaltsform"
help="Waehlen Sie, ob Sie alleine oder gemeinsam mit einer Partnerin / einem Partner planen."
value={householdType}
onChange={handleTypeChange}
options={[
{ value: "SINGLE", label: "Einzelperson" },
{ value: "COUPLE", label: "Paar (zwei Personen)" },
]}
/>
{persons.map((person, index) => (
<div key={person.role} className="grid grid-cols-2 gap-3 rounded-xl border border-zinc-100 bg-zinc-50/60 p-3 dark:border-zinc-800 dark:bg-zinc-800/30">
<div className="col-span-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">
{householdType === "COUPLE" ? (person.role === "PERSON_A" ? "Person A" : "Person B") : "Ihre Angaben"}
</div>
<NumberField
label="Aktuelles Alter"
help="Ihr heutiges Alter in vollen Jahren."
value={person.age}
onChange={(v) => updatePerson(index, { age: v })}
/>
<NumberField
label="Geplantes Pensionierungsalter"
help="Das Alter, in dem Sie voraussichtlich in Rente gehen moechten. Dient nur der groben Orientierung bei der Phasenplanung."
value={person.retirementAge}
onChange={(v) => updatePerson(index, { retirementAge: v })}
/>
</div>
))}
<NumberField
label="Erwartete Inflationsrate (%)"
help="Langfristige Annahme zur jaehrlichen Teuerung. Kann pro Lebensphase individuell ueberschrieben werden."
value={inflationRateDefault}
step={0.1}
onChange={setInflationRateDefault}
/>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<button
type="button"
disabled={saving}
onClick={handleSubmit}
className="mt-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
{saving ? "Speichern..." : "Weiter"}
</button>
</div>
</div>
);
}
+9 -9
View File
@@ -4,20 +4,20 @@ import { useState } from "react";
import { Trash2 } from "lucide-react";
import { NumberField, TextField } from "@/components/FormField";
import { api } from "@/lib/api-client";
import type { HouseholdInput, PhaseInput } from "@/lib/types";
import type { PhaseInput } from "@/lib/types";
export function PhaseDetail({
phase,
maxDurationYears,
isLast,
household,
inflationDefault,
onSaved,
onDeleted,
}: {
phase: PhaseInput;
maxDurationYears: number | null;
isLast: boolean;
household: HouseholdInput;
inflationDefault: number;
onSaved: () => void;
onDeleted: () => void;
}) {
@@ -55,14 +55,14 @@ export function PhaseDetail({
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="text-xs font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
<div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">
Lebensphase
</div>
{isLast && (
<button
type="button"
onClick={remove}
className="flex items-center gap-1 rounded-lg border border-zinc-300 px-2 py-1 text-xs text-zinc-500 hover:border-red-200 hover:bg-red-50 hover:text-red-600 dark:border-zinc-700 dark:hover:bg-red-950"
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
>
<Trash2 className="h-3.5 w-3.5" /> Phase loeschen
</button>
@@ -81,20 +81,20 @@ export function PhaseDetail({
/>
<NumberField
label="Inflationsrate (%)"
help="Ueberschreibt die Standardannahme aus dem Grundprofil."
value={inflationRate ?? household.inflationRateDefault}
help="Ueberschreibt die Standardannahme aus dem Plan-Grundprofil."
value={inflationRate ?? inflationDefault}
step={0.1}
onChange={setInflationRate}
/>
</div>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
{error && <p className="text-sm text-danger">{error}</p>}
<div>
<button
type="button"
disabled={saving}
onClick={save}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400"
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
>
{saving ? "Speichern..." : "Speichern"}
</button>
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { NumberField, SelectField } from "@/components/FormField";
import type { HouseholdType, PersonRole } from "@/lib/types";
export interface ProfileDraft {
householdType: HouseholdType;
inflationRateDefault: number;
persons: { role: PersonRole; age: number; retirementAge: number }[];
}
export function emptyProfileDraft(): ProfileDraft {
return {
householdType: "SINGLE",
inflationRateDefault: 1.5,
persons: [{ role: "PERSON_A", age: 35, retirementAge: 65 }],
};
}
// Gemeinsame Formularfelder fuer das Grundprofil eines Plans (Haushaltsform, Personen,
// Inflation). Wird beim Plan-Erstellen und in den Plan-Einstellungen verwendet.
export function PlanProfileFields({
draft,
onChange,
}: {
draft: ProfileDraft;
onChange: (next: ProfileDraft) => void;
}) {
function setType(type: HouseholdType) {
if (type === "SINGLE") {
onChange({ ...draft, householdType: type, persons: draft.persons.slice(0, 1) });
} else {
const persons =
draft.persons.length < 2
? [...draft.persons, { role: "PERSON_B" as PersonRole, age: 35, retirementAge: 65 }]
: draft.persons;
onChange({ ...draft, householdType: type, persons });
}
}
function updatePerson(index: number, patch: Partial<ProfileDraft["persons"][number]>) {
onChange({
...draft,
persons: draft.persons.map((p, i) => (i === index ? { ...p, ...patch } : p)),
});
}
return (
<div className="flex flex-col gap-4">
<SelectField
label="Haushaltsform"
help="Waehlen Sie, ob Sie alleine oder gemeinsam mit einer Partnerin / einem Partner planen."
value={draft.householdType}
onChange={setType}
options={[
{ value: "SINGLE", label: "Einzelperson" },
{ value: "COUPLE", label: "Paar (zwei Personen)" },
]}
/>
{draft.persons.map((person, index) => (
<div key={person.role} className="grid grid-cols-2 gap-3 rounded-xl border border-border bg-surface-2 p-3">
<div className="col-span-2 text-xs font-semibold uppercase tracking-wide text-faint">
{draft.householdType === "COUPLE" ? (person.role === "PERSON_A" ? "Person A" : "Person B") : "Ihre Angaben"}
</div>
<NumberField
label="Aktuelles Alter"
value={person.age}
min={0}
max={120}
onChange={(v) => updatePerson(index, { age: Math.round(v) })}
/>
<NumberField
label="Pensionierungsalter"
help="Steuert die Ableitung des Phasentyps (Erwerb/Pension)."
value={person.retirementAge}
min={30}
max={100}
onChange={(v) => updatePerson(index, { retirementAge: Math.round(v) })}
/>
</div>
))}
<NumberField
label="Erwartete Inflationsrate (%)"
help="Langfristige Annahme zur jaehrlichen Teuerung. Kann pro Lebensphase individuell ueberschrieben werden."
value={draft.inflationRateDefault}
step={0.1}
onChange={(v) => onChange({ ...draft, inflationRateDefault: v })}
/>
</div>
);
}
+581 -148
View File
@@ -12,13 +12,21 @@ import {
Landmark,
PiggyBank,
Plus,
Settings2,
ShoppingCart,
TrendingUp,
Wallet,
X,
} from "lucide-react";
import { Timeline } from "@/components/Timeline";
import { ElementDetail, type CellContext } from "@/components/ElementDetail";
import {
ElementDetail,
ElementPhaseFields,
ElementTransitionFields,
type CellContext,
} from "@/components/ElementDetail";
import { PhaseDetail } from "@/components/PhaseDetail";
import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import {
@@ -27,12 +35,10 @@ import {
PERSON_ONLY_CATEGORIES,
num,
type ElementCategory,
type PhaseData,
} from "@/lib/elements";
import { resolveRetirementAge, type PhaseComputed, type PlanComputed } from "@/lib/calculations";
import type { ElementInput, HouseholdInput, PlanInput } from "@/lib/types";
const PERSON_A_COLOR = "#4f46e5";
const PERSON_B_COLOR = "#0ea5e9";
import type { PhaseComputed, PlanComputed } from "@/lib/calculations";
import type { ElementInput, PlanInput } from "@/lib/types";
const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
INCOME: <Wallet className="h-4 w-4" />,
@@ -53,6 +59,14 @@ const TRANSITION_CATEGORIES: ElementCategory[] = [
"OTHER_DEBT",
];
const VALUE_CATEGORIES: ElementCategory[] = [
"PENSION_FUND",
"PILLAR_3A",
"REAL_ESTATE",
"OTHER_ASSET",
"OTHER_DEBT",
];
type Column =
| { kind: "phase"; phase: PhaseComputed }
| { kind: "transition"; fromPhase: PhaseComputed; toPhase: PhaseComputed };
@@ -64,18 +78,19 @@ type Selection =
export function PlanView({
plan,
household,
computed,
onChanged,
}: {
plan: PlanInput;
household: HouseholdInput;
computed: PlanComputed;
onChanged: () => void;
}) {
const [selected, setSelected] = useState<Selection | null>(null);
const [collapsedCats, setCollapsedCats] = useState<Set<ElementCategory>>(new Set());
const [showAdd, setShowAdd] = useState(false);
const [showAddPhase, setShowAddPhase] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [reviewFromPhaseId, setReviewFromPhaseId] = useState<string | null>(null);
const columns = useMemo<Column[]>(() => {
const cols: Column[] = [];
@@ -88,12 +103,12 @@ export function PlanView({
return cols;
}, [computed.phases]);
const personAxes = household.persons.map((p) => ({
const personAxes = plan.persons.map((p) => ({
role: p.role,
label: p.role === "PERSON_A" ? "Person A" : "Person B",
currentAge: p.age,
retirementAge: resolveRetirementAge(p.role, plan, p.retirementAge),
color: p.role === "PERSON_A" ? PERSON_A_COLOR : PERSON_B_COLOR,
retirementAge: p.retirementAge,
color: p.role === "PERSON_A" ? "var(--person-a)" : "var(--person-b)",
}));
const elementsByCategory = useMemo(() => {
@@ -116,48 +131,117 @@ export function PlanView({
return !!before?.working && !!after && !after.working;
}
async function handleAddPhase() {
await api.post(`/api/plans/${plan.id}/phases`, {});
// Baut den Kontext (inkl. Live-Caps) fuer eine Phasenzelle.
function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext {
const ce = computedElement(phase.id, element.id);
const ownerWorking =
element.ownerRole && element.ownerRole !== "HOUSEHOLD"
? phase.persons.find((p) => p.role === element.ownerRole)?.working ?? false
: phase.type !== "PENSION";
const otherQuota = phase.quotaAllocated - (ce?.quotaUse ?? 0);
const quotaRateMax = Math.max(0, Math.abs(phase.quota) - otherQuota);
const capitalMax =
phase.availableCapital == null
? undefined
: Math.max(0, phase.availableCapital - (phase.availableCapitalUsed - (ce?.capitalUse ?? 0)));
const derivedStart = (ce?.startValue ?? 0) - (ce?.capitalUse ?? 0);
return {
kind: "phase",
phaseId: phase.id,
ownerWorking,
isConsumption: phase.isConsumption,
durationYears: phase.durationYears,
isRetirementTransition: false,
carriedEndValue: ce?.endValue ?? 0,
carried: ce?.carried ?? false,
derivedStart,
quotaRateMax,
capitalMax,
};
}
function buildTransitionContext(fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined, element: ElementInput): CellContext {
const ce = computedElement(fromPhase.id, element.id);
return {
kind: "transition",
phaseId: fromPhase.id,
ownerWorking: true,
isConsumption: fromPhase.isConsumption,
durationYears: fromPhase.durationYears,
isRetirementTransition: toPhase ? isRetirementTransition(element, fromPhase, toPhase) : false,
carriedEndValue: ce?.endValue ?? 0,
carried: ce?.carried ?? false,
derivedStart: 0,
quotaRateMax: 0,
capitalMax: undefined,
};
}
// Anzahl offener (noch nicht getroffener) Uebergangs-Entscheide an einer Grenze.
function transitionOpenCount(fromPhase: PhaseComputed, toPhase: PhaseComputed): number {
let n = 0;
for (const el of plan.elements) {
if (!TRANSITION_CATEGORIES.includes(el.category)) continue;
const ce = computedElement(fromPhase.id, el.id);
if (ce && ce.status !== "ACTIVE") continue;
const td = el.transitionValues[fromPhase.id] ?? {};
if (el.category === "REAL_ESTATE" || el.category === "OTHER_ASSET") {
if (td.decision === undefined) n++;
} else if (el.category === "PENSION_FUND" && isRetirementTransition(el, fromPhase, toPhase)) {
if (td.payoutMode === undefined) n++;
}
}
return n;
}
function transitionElements(fromPhase: PhaseComputed): ElementInput[] {
return plan.elements
.filter((el) => TRANSITION_CATEGORIES.includes(el.category))
.filter((el) => {
const ce = computedElement(fromPhase.id, el.id);
return !ce || ce.status === "ACTIVE";
})
.sort((a, b) => a.orderIndex - b.orderIndex);
}
async function handleAddPhase(payload: { name?: string; durationYears?: number; inflationRate?: number | null }) {
await api.post(`/api/plans/${plan.id}/phases`, payload);
setShowAddPhase(false);
onChanged();
}
const hasPhases = computed.phases.length > 0;
const firstPhase = computed.phases[0] ?? null;
return (
<div className="flex flex-col gap-5">
<Timeline phases={computed.phases} persons={personAxes} />
{/* Pensionsalter-Overrides */}
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-zinc-200/70 bg-white px-4 py-3 text-sm shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<span className="text-xs font-semibold uppercase tracking-wide text-zinc-400">Pensionsalter (Plan)</span>
{household.persons.map((p) => (
<label key={p.role} className="flex items-center gap-1.5 text-xs text-zinc-600 dark:text-zinc-300">
{p.role === "PERSON_A" ? "Person A" : "Person B"}:
<input
type="number"
defaultValue={resolveRetirementAge(p.role, plan, p.retirementAge)}
className="w-16 rounded-lg border border-zinc-300 px-2 py-1 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-800"
onBlur={async (e) => {
const val = parseInt(e.target.value, 10);
if (!Number.isFinite(val)) return;
await api.patch(`/api/plans/${plan.id}`, {
[p.role === "PERSON_A" ? "retirementAgeA" : "retirementAgeB"]: val,
});
onChanged();
}}
/>
</label>
{/* Plan-Profil */}
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-surface px-4 py-3 text-sm shadow-sm">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">Grundprofil (Plan)</span>
{plan.persons.map((p) => (
<span key={p.role} className="text-xs text-muted">
{plan.householdType === "COUPLE" ? (p.role === "PERSON_A" ? "Person A" : "Person B") : "Person"}: {p.age} J., Pension {p.retirementAge}
</span>
))}
<span className="text-[11px] text-zinc-400">Standard aus Grundprofil, hier pro Plan uebersteuerbar.</span>
<span className="text-xs text-muted">Inflation {plan.inflationRateDefault}%</span>
<button
type="button"
onClick={() => setShowSettings(true)}
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"
>
<Settings2 className="h-3.5 w-3.5" /> Einstellungen
</button>
</div>
{!hasPhases && (
<div className="rounded-xl border border-dashed border-zinc-300 bg-white p-8 text-center dark:border-zinc-700 dark:bg-zinc-900">
<p className="text-sm text-zinc-500">Dieser Plan hat noch keine Lebensphasen.</p>
<div className="rounded-xl border border-dashed border-border bg-surface p-8 text-center">
<p className="text-sm text-muted">Dieser Plan hat noch keine Lebensphasen.</p>
<button
type="button"
onClick={handleAddPhase}
className="mt-3 inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 dark:bg-indigo-500"
onClick={() => setShowAddPhase(true)}
className="mt-3 inline-flex items-center gap-1.5 rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg hover:bg-accent-hover"
>
<Plus className="h-4 w-4" /> Erste Lebensphase
</button>
@@ -169,14 +253,14 @@ export function PlanView({
<button
type="button"
onClick={() => setShowAdd(true)}
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
className="flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover"
>
<Plus className="h-4 w-4" /> Finanzielles Element
</button>
<button
type="button"
onClick={handleAddPhase}
className="flex items-center gap-1.5 rounded-lg border border-dashed border-indigo-300 bg-indigo-50/50 px-3 py-1.5 text-sm font-medium text-indigo-700 hover:bg-indigo-50 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300"
onClick={() => setShowAddPhase(true)}
className="flex items-center gap-1.5 rounded-lg border border-dashed border-accent bg-accent-soft px-3 py-1.5 text-sm font-medium text-accent-soft-fg hover:bg-accent-soft"
>
<Plus className="h-4 w-4" /> Lebensphase
</button>
@@ -185,11 +269,11 @@ export function PlanView({
{/* Matrix */}
{hasPhases && (
<div className="overflow-x-auto rounded-xl border border-zinc-200/70 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="overflow-x-auto rounded-xl border border-border bg-surface shadow-sm">
<table className="w-full border-collapse text-sm">
<thead>
<tr>
<th className="sticky left-0 z-20 min-w-44 border-b border-r border-zinc-200 bg-zinc-50 px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:border-zinc-800 dark:bg-zinc-800/60">
<th className="sticky left-0 z-20 min-w-44 border-b border-r border-border bg-surface-2 px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-faint">
Finanzielle Elemente
</th>
{columns.map((col) =>
@@ -201,12 +285,11 @@ export function PlanView({
active={selected?.type === "phase" && selected.phaseId === col.phase.id}
/>
) : (
<th
<TransitionHeader
key={`t-${col.fromPhase.id}`}
className="border-b border-r border-zinc-200 bg-indigo-50/40 px-2 py-2 text-center text-[11px] font-medium text-indigo-500 dark:border-zinc-800 dark:bg-indigo-500/5"
>
Uebergang
</th>
openCount={transitionOpenCount(col.fromPhase, col.toPhase)}
onClick={() => setReviewFromPhaseId(col.fromPhase.id)}
/>
)
)}
</tr>
@@ -218,9 +301,9 @@ export function PlanView({
const collapsed = collapsedCats.has(cat);
return (
<FragmentRows key={cat}>
<tr className="bg-zinc-50/60 dark:bg-zinc-800/30">
<tr className="bg-surface-2">
<td
className="sticky left-0 z-10 cursor-pointer border-b border-r border-zinc-200 bg-zinc-50/90 px-3 py-1.5 dark:border-zinc-800 dark:bg-zinc-800/60"
className="sticky left-0 z-10 cursor-pointer border-b border-r border-border bg-surface-2 px-3 py-1.5"
onClick={() =>
setCollapsedCats((prev) => {
const next = new Set(prev);
@@ -230,21 +313,21 @@ export function PlanView({
})
}
>
<span className="flex items-center gap-1.5 text-xs font-semibold text-zinc-600 dark:text-zinc-300">
<span className="flex items-center gap-1.5 text-xs font-semibold text-muted">
{collapsed ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
<span className="text-indigo-500 dark:text-indigo-400">{CATEGORY_ICON[cat]}</span>
<span className="text-accent">{CATEGORY_ICON[cat]}</span>
{CATEGORY_LABELS[cat]}
</span>
</td>
<td colSpan={columns.length} className="border-b border-zinc-200 dark:border-zinc-800" />
<td colSpan={columns.length} className="border-b border-border" />
</tr>
{!collapsed &&
els.map((el) => (
<tr key={el.id} className="hover:bg-zinc-50/50 dark:hover:bg-zinc-800/20">
<td className="sticky left-0 z-10 border-b border-r border-zinc-200 bg-white px-3 py-1.5 dark:border-zinc-800 dark:bg-zinc-900">
<div className="truncate text-xs font-medium text-zinc-800 dark:text-zinc-200">{el.name}</div>
<tr key={el.id} className="hover:bg-surface-2">
<td className="sticky left-0 z-10 border-b border-r border-border bg-surface px-3 py-1.5">
<div className="truncate text-xs font-medium text-fg">{el.name}</div>
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
<div className="text-[10px] text-zinc-400">
<div className="text-[10px] text-faint">
{el.ownerRole === "PERSON_A" ? "Person A" : "Person B"}
</div>
)}
@@ -260,11 +343,11 @@ export function PlanView({
<td
key={col.phase.id}
onClick={() => setSelected({ type: "phaseCell", elementId: el.id, phaseId: col.phase.id })}
className={`cursor-pointer border-b border-r border-zinc-200 px-2 py-1.5 text-center text-xs dark:border-zinc-800 ${
isSel ? "bg-indigo-100 dark:bg-indigo-500/20" : ""
} ${ce?.locked ? "text-zinc-400" : "text-zinc-700 dark:text-zinc-200"}`}
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-xs ${
isSel ? "bg-accent-soft" : ""
} ${ce?.locked ? "text-faint" : "text-fg"}`}
>
{ce?.summary ?? ""}
{phaseCellContent(ce)}
</td>
);
}
@@ -280,9 +363,9 @@ export function PlanView({
canTransition &&
setSelected({ type: "transitionCell", elementId: el.id, fromPhaseId: col.fromPhase.id })
}
className={`border-b border-r border-zinc-200 px-2 py-1.5 text-center text-[11px] dark:border-zinc-800 ${
canTransition ? "cursor-pointer text-indigo-500" : "text-zinc-300 dark:text-zinc-600"
} ${isSel ? "bg-indigo-100 dark:bg-indigo-500/20" : "bg-indigo-50/30 dark:bg-indigo-500/5"}`}
className={`border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${
canTransition ? "cursor-pointer text-accent" : "text-faint"
} ${isSel ? "bg-accent-soft" : "bg-accent-soft/40"}`}
>
{canTransition ? transitionSummary(el, col.fromPhase, col.toPhase) : "→"}
</td>
@@ -295,7 +378,7 @@ export function PlanView({
})}
{plan.elements.length === 0 && (
<tr>
<td className="sticky left-0 bg-white px-3 py-4 text-xs text-zinc-400 dark:bg-zinc-900" colSpan={columns.length + 1}>
<td className="sticky left-0 bg-surface px-3 py-4 text-xs text-faint" colSpan={columns.length + 1}>
Noch keine finanziellen Elemente. Fuegen Sie oben Ihr erstes Element hinzu.
</td>
</tr>
@@ -307,25 +390,74 @@ export function PlanView({
{/* Detail-Panel */}
{selected && (
<div className="rounded-xl border border-indigo-200 bg-white p-4 shadow-sm dark:border-indigo-500/30 dark:bg-zinc-900">
{renderDetail()}
</div>
<div className="rounded-xl border border-accent bg-surface p-4 shadow-sm">{renderDetail()}</div>
)}
{showAdd && (
{showAdd && firstPhase && (
<AddElementDialog
household={household}
plan={plan}
firstPhase={firstPhase}
onClose={() => setShowAdd(false)}
onCreate={async (payload) => {
await api.post(`/api/plans/${plan.id}/elements`, payload);
onCreated={() => {
setShowAdd(false);
onChanged();
}}
/>
)}
{showAddPhase && (
<AddPhaseDialog
maxDurationYears={nextPhaseCap()}
onClose={() => setShowAddPhase(false)}
onCreate={handleAddPhase}
/>
)}
{showSettings && (
<PlanSettingsDialog
plan={plan}
onClose={() => setShowSettings(false)}
onSaved={() => {
setShowSettings(false);
onChanged();
}}
/>
)}
{reviewFromPhaseId && (() => {
const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId);
if (!fromPhase) return null;
const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1;
const toPhase = computed.phases[toIndex];
const els = transitionElements(fromPhase);
return (
<TransitionReviewDialog
fromPhase={fromPhase}
toPhase={toPhase}
elements={els}
buildContext={(el) => buildTransitionContext(fromPhase, toPhase, el)}
isRetirement={(el) => (toPhase ? isRetirementTransition(el, fromPhase, toPhase) : false)}
onClose={() => setReviewFromPhaseId(null)}
onSaved={() => {
setReviewFromPhaseId(null);
onChanged();
}}
/>
);
})()}
</div>
);
// Naechste Phasen-Kappung (fuer das Phase-Popup).
function nextPhaseCap(): number | null {
// Simpel aus den Personen ableiten (Jahre nach Planbeginn = Summe der Dauern).
const yearsBefore = plan.phases.reduce((s, p) => s + p.durationYears, 0);
const caps = plan.persons
.map((p) => p.retirementAge - (p.age + yearsBefore))
.filter((d) => d > 0);
return caps.length > 0 ? Math.min(...caps) : null;
}
function renderDetail() {
if (!selected) return null;
@@ -339,7 +471,7 @@ export function PlanView({
phase={phaseInput}
maxDurationYears={phase.maxDurationYears}
isLast={isLast}
household={household}
inflationDefault={plan.inflationRateDefault}
onSaved={onChanged}
onDeleted={() => {
setSelected(null);
@@ -354,19 +486,7 @@ export function PlanView({
if (selected.type === "phaseCell") {
const phase = computed.phases.find((p) => p.id === selected.phaseId)!;
const ownerWorking = element.ownerRole && element.ownerRole !== "HOUSEHOLD"
? phase.persons.find((p) => p.role === element.ownerRole)?.working ?? false
: phase.type !== "PENSION";
const ce = computedElement(phase.id, element.id);
const context: CellContext = {
kind: "phase",
phaseId: phase.id,
ownerWorking,
isConsumption: phase.isConsumption,
durationYears: phase.durationYears,
isRetirementTransition: false,
carriedEndValue: ce?.endValue ?? 0,
};
const context = buildPhaseContext(phase, element);
return (
<ElementDetail
element={element}
@@ -383,16 +503,7 @@ export function PlanView({
const fromPhase = computed.phases.find((p) => p.id === selected.fromPhaseId)!;
const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1;
const toPhase = computed.phases[toIndex];
const ce = computedElement(fromPhase.id, element.id);
const context: CellContext = {
kind: "transition",
phaseId: fromPhase.id,
ownerWorking: true,
isConsumption: fromPhase.isConsumption,
durationYears: fromPhase.durationYears,
isRetirementTransition: toPhase ? isRetirementTransition(element, fromPhase, toPhase) : false,
carriedEndValue: ce?.endValue ?? 0,
};
const context = buildTransitionContext(fromPhase, toPhase, element);
return (
<ElementDetail
element={element}
@@ -417,10 +528,10 @@ export function PlanView({
switch (el.category) {
case "REAL_ESTATE":
case "OTHER_ASSET":
return td.decision === "SELL" ? "Verkauf" : "Halten";
return td.decision === "SELL" ? "Verkauf" : td.decision === "HOLD" ? "Halten" : "?";
case "PENSION_FUND":
if (isRetirementTransition(el, fromPhase, toPhase)) {
return td.payoutMode === "CAPITAL" ? "Kapital" : td.payoutMode === "COMBI" ? "Kombi" : "Rente";
return td.payoutMode === "CAPITAL" ? "Kapital" : td.payoutMode === "COMBI" ? "Kombi" : td.payoutMode === "PENSION" ? "Rente" : "?";
}
return num(td.withdrawal) > 0 ? `${formatChf(num(td.withdrawal))}` : "→";
case "PILLAR_3A":
@@ -434,63 +545,108 @@ export function PlanView({
}
}
// Zellinhalt: Start- UND Zielwert fuer wertbehaftete Elemente, sonst die Kennzahl.
function phaseCellContent(ce: ReturnType<PhaseComputed["elements"]["find"]> | undefined): React.ReactNode {
if (!ce) return "";
if (ce.note) return ce.note;
if (VALUE_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) {
return (
<span className="whitespace-nowrap">
{formatChf(ce.startValue)} <span className="text-faint"></span> {formatChf(ce.endValue)}
</span>
);
}
return ce.summary || "";
}
function PhaseHeader({ phase, onClick, active }: { phase: PhaseComputed; onClick: () => void; active: boolean }) {
const quotaLabel = phase.isConsumption ? "Verzehr" : "Sparquote";
const quotaRemaining = Math.max(0, phase.quotaRemaining);
return (
<th
onClick={onClick}
className={`min-w-40 cursor-pointer border-b border-r border-zinc-200 px-2 py-2 text-left align-top dark:border-zinc-800 ${
active ? "bg-indigo-100 dark:bg-indigo-500/20" : "bg-white dark:bg-zinc-900"
className={`min-w-40 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
active ? "bg-accent-soft" : "bg-surface"
}`}
>
<div className="flex items-center gap-1">
<span className="truncate text-xs font-semibold text-zinc-800 dark:text-zinc-100">{phase.name}</span>
<span className="truncate text-xs font-semibold text-fg">{phase.name}</span>
{phase.incomplete ? (
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-red-500" />
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-danger" />
) : (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-500" />
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
)}
</div>
<div className="mt-0.5 flex flex-wrap gap-1">
<span className="rounded bg-zinc-100 px-1 text-[10px] text-zinc-500 dark:bg-zinc-800">
<span className="rounded bg-surface-2 px-1 text-[10px] text-muted">
{phase.type === "ERWERB" ? "Erwerb" : phase.type === "PENSION" ? "Pension" : "Misch"}
</span>
<span className="text-[10px] text-zinc-400">{phase.durationYears} J.</span>
<span className="text-[10px] text-zinc-400">Alter {phase.persons.map((p) => p.startAge).join("/")}</span>
<span className="text-[10px] text-faint">{phase.durationYears} J.</span>
<span className="text-[10px] text-faint">Alter {phase.persons.map((p) => p.startAge).join("/")}</span>
</div>
<div className="mt-1 space-y-0.5 text-[10px] leading-tight text-zinc-500 dark:text-zinc-400">
<div className="mt-1 space-y-0.5 text-[10px] leading-tight text-muted">
<div>Einkommen {formatChf(phase.incomeTotal)}</div>
<div>Ausgaben {formatChf(phase.expenseTotal)}</div>
<div className={phase.quotaComplete ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400"}>
<div className={phase.quotaComplete ? "text-success" : "text-danger"}>
{quotaLabel} {formatChf(Math.abs(phase.quota))}
{!phase.quotaComplete && <span> · offen {formatChf(quotaRemaining)}</span>}
</div>
<div className={phase.availableCapitalComplete ? "" : "text-red-600 dark:text-red-400"}>
<div className={phase.availableCapitalComplete ? "" : "text-danger"}>
Kapital {phase.availableCapital === null ? "n.a." : formatChf(phase.availableCapital)}
{phase.availableCapital !== null && !phase.availableCapitalComplete && (
<span> · offen {formatChf(Math.max(0, phase.availableCapitalRemaining))}</span>
)}
</div>
</div>
</th>
);
}
function TransitionHeader({ openCount, onClick }: { openCount: number; onClick: () => void }) {
return (
<th
onClick={onClick}
className={`cursor-pointer border-b border-r border-border px-2 py-2 text-center align-top text-[11px] font-medium ${
openCount > 0 ? "bg-accent text-accent-fg" : "bg-accent-soft text-accent-soft-fg"
}`}
>
<div>Uebergang</div>
{openCount > 0 ? (
<div className="mt-1 rounded-full bg-accent-fg/20 px-1.5 py-0.5 text-[10px] font-semibold">
{openCount} offen
</div>
) : (
<div className="mt-1 text-[10px] opacity-80">pruefen</div>
)}
</th>
);
}
function FragmentRows({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
// --- Dialog: neues finanzielles Element mit Direkteingabe der Phase-1-Werte ---
function AddElementDialog({
household,
plan,
firstPhase,
onClose,
onCreate,
onCreated,
}: {
household: HouseholdInput;
plan: PlanInput;
firstPhase: PhaseComputed;
onClose: () => void;
onCreate: (payload: { category: ElementCategory; name: string; ownerRole: string | null }) => void;
onCreated: () => void;
}) {
const [category, setCategory] = useState<ElementCategory>("INCOME");
const [name, setName] = useState("");
const [ownerRole, setOwnerRole] = useState<string>(household.householdType === "COUPLE" ? "PERSON_A" : "PERSON_A");
const [ownerRole, setOwnerRole] = useState<string>("PERSON_A");
const [pd, setPd] = useState<PhaseData>({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const needsPerson = PERSON_ONLY_CATEGORIES.includes(category);
const isCouple = household.householdType === "COUPLE";
const isCouple = plan.householdType === "COUPLE";
const ownerOptions = needsPerson
? isCouple
@@ -510,24 +666,62 @@ function AddElementDialog({
{ value: "PERSON_A", label: "Person A" },
];
// Kontext fuer die Phase-1-Felder des neuen Elements.
const owner = needsPerson || ownerRole !== "HOUSEHOLD" ? ownerRole : null;
const ownerWorking =
owner && owner !== "HOUSEHOLD"
? firstPhase.persons.find((p) => p.role === owner)?.working ?? false
: firstPhase.type !== "PENSION";
const context: CellContext = {
kind: "phase",
phaseId: firstPhase.id,
ownerWorking,
isConsumption: firstPhase.isConsumption,
durationYears: firstPhase.durationYears,
isRetirementTransition: false,
carriedEndValue: 0,
carried: false,
derivedStart: 0,
quotaRateMax: Math.max(0, Math.abs(firstPhase.quota) - firstPhase.quotaAllocated),
capitalMax: firstPhase.availableCapital == null ? undefined : Math.max(0, firstPhase.availableCapital - firstPhase.availableCapitalUsed),
};
async function create() {
setSaving(true);
setError(null);
try {
const { element } = await api.post<{ element: { id: string } }>(`/api/plans/${plan.id}/elements`, {
category,
name: name.trim() || CATEGORY_LABELS[category],
ownerRole,
});
// Ist-Zustand direkt in Phase 1 speichern (sofern Felder ausgefuellt).
if (Object.keys(pd).length > 0) {
await api.put(`/api/elements/${element.id}/phase/${firstPhase.id}`, pd);
}
onCreated();
} catch (e) {
setError(e instanceof Error ? e.message : "Erstellen fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-md flex-col gap-3 rounded-2xl border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-900"
>
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">Finanzielles Element</h2>
<DialogShell title="Finanzielles Element" onClose={onClose} wide>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-400">Kategorie</label>
<label className="mb-1 block text-xs font-medium text-muted">Kategorie</label>
<select
value={category}
onChange={(e) => {
const c = e.target.value as ElementCategory;
setCategory(c);
setPd({});
if (PERSON_ONLY_CATEGORIES.includes(c) && ownerRole === "HOUSEHOLD") setOwnerRole("PERSON_A");
if (!name) setName(CATEGORY_LABELS[c]);
}}
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
>
{CATEGORY_ORDER.map((c) => (
<option key={c} value={c}>
@@ -537,20 +731,11 @@ function AddElementDialog({
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-400">Bezeichnung</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={CATEGORY_LABELS[category]}
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-400">Zuordnung</label>
<label className="mb-1 block text-xs font-medium text-muted">Zuordnung</label>
<select
value={ownerRole}
onChange={(e) => setOwnerRole(e.target.value)}
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
>
{ownerOptions.map((o) => (
<option key={o.value} value={o.value}>
@@ -559,23 +744,271 @@ function AddElementDialog({
))}
</select>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => onCreate({ category, name: name.trim() || CATEGORY_LABELS[category], ownerRole })}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
Erstellen
</button>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
Abbrechen
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-medium text-muted">Bezeichnung</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={CATEGORY_LABELS[category]}
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
/>
</div>
</div>
<div className="mt-1 border-t border-border pt-3">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Werte (erste Lebensphase)</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<ElementPhaseFields
element={{ category }}
context={context}
pd={pd}
setP={(patch) => setPd((prev) => ({ ...prev, ...patch }))}
/>
</div>
</div>
{error && <p className="text-sm text-danger">{error}</p>}
<DialogActions saving={saving} onConfirm={create} onClose={onClose} confirmLabel="Erstellen" />
</DialogShell>
);
}
// --- Dialog: neue Lebensphase ---
function AddPhaseDialog({
maxDurationYears,
onClose,
onCreate,
}: {
maxDurationYears: number | null;
onClose: () => void;
onCreate: (payload: { name?: string; durationYears?: number; inflationRate?: number | null }) => void;
}) {
const cap = maxDurationYears;
const [name, setName] = useState("");
const [durationYears, setDurationYears] = useState(cap ?? 10);
const [saving, setSaving] = useState(false);
return (
<DialogShell title="Neue Lebensphase" onClose={onClose}>
<div className="grid grid-cols-1 gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-muted">Bezeichnung (optional)</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="automatisch (Erwerb/Pension)"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted">
Dauer (Jahre){cap != null ? ` · max. ${cap}` : ""}
</label>
<input
type="number"
value={durationYears}
min={1}
max={cap ?? undefined}
onFocus={(e) => e.currentTarget.select()}
onChange={(e) => {
const v = e.target.valueAsNumber || 1;
setDurationYears(cap != null ? Math.min(v, cap) : v);
}}
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
/>
</div>
</div>
<DialogActions
saving={saving}
onConfirm={() => {
setSaving(true);
onCreate({ name: name.trim() || undefined, durationYears });
}}
onClose={onClose}
confirmLabel="Erstellen"
/>
</DialogShell>
);
}
// --- Dialog: Plan-Einstellungen (Grundprofil bearbeiten) ---
function PlanSettingsDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) {
const [draft, setDraft] = useState<ProfileDraft>({
householdType: plan.householdType,
inflationRateDefault: plan.inflationRateDefault,
persons: plan.persons.map((p) => ({ role: p.role, age: p.age, retirementAge: p.retirementAge })),
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
setSaving(true);
setError(null);
try {
await api.patch(`/api/plans/${plan.id}`, draft);
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<DialogShell title="Plan-Einstellungen" onClose={onClose}>
<PlanProfileFields draft={draft} onChange={setDraft} />
{error && <p className="text-sm text-danger">{error}</p>}
<DialogActions saving={saving} onConfirm={save} onClose={onClose} confirmLabel="Speichern" />
</DialogShell>
);
}
// --- Dialog: geführter Übergang ---
function TransitionReviewDialog({
fromPhase,
toPhase,
elements,
buildContext,
isRetirement,
onClose,
onSaved,
}: {
fromPhase: PhaseComputed;
toPhase: PhaseComputed | undefined;
elements: ElementInput[];
buildContext: (el: ElementInput) => CellContext;
isRetirement: (el: ElementInput) => boolean;
onClose: () => void;
onSaved: () => void;
}) {
const [tds, setTds] = useState<Record<string, import("@/lib/elements").TransitionData>>(() =>
Object.fromEntries(elements.map((e) => [e.id, { ...(e.transitionValues[fromPhase.id] ?? {}) }]))
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function saveAll() {
setSaving(true);
setError(null);
try {
for (const e of elements) {
await api.put(`/api/elements/${e.id}/transition/${fromPhase.id}`, tds[e.id] ?? {});
}
onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<DialogShell title={`Übergang prüfen: ${fromPhase.name}${toPhase?.name ?? "Ende"}`} onClose={onClose} wide>
<p className="text-sm text-muted">
Gehen Sie die Positionen durch und treffen Sie je Element den Übergangs-Entscheid (Halten, Verkaufen,
Bezug). Danach werden gehaltene Werte automatisch in die nächste Phase fortgeschrieben.
</p>
<div className="flex flex-col gap-3">
{elements.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
An diesem Übergang gibt es keine zu entscheidenden Positionen.
</p>
)}
{elements.map((el) => {
const ctx = buildContext(el);
const retire = isRetirement(el);
const hint =
(el.category === "PENSION_FUND" || el.category === "PILLAR_3A") && !retire
? "Hier könnten Sie optional Kapital beziehen."
: el.category === "PENSION_FUND" && retire
? "Pensionierung: Bezugsart wählen (Rente / Kapital / Kombination)."
: el.category === "PILLAR_3A" && retire
? "Wird bei Pensionierung vollständig bezogen."
: null;
return (
<div key={el.id} className="rounded-xl border border-border bg-surface-2 p-3">
<div className="mb-2 flex items-center gap-2">
<span className="text-accent">{CATEGORY_ICON[el.category]}</span>
<span className="text-sm font-semibold text-fg">{el.name}</span>
<span className="text-xs text-faint">{CATEGORY_LABELS[el.category]}</span>
</div>
{hint && <p className="mb-2 text-xs text-accent-soft-fg">{hint}</p>}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<ElementTransitionFields
element={el}
context={ctx}
td={tds[el.id] ?? {}}
setT={(patch) => setTds((prev) => ({ ...prev, [el.id]: { ...prev[el.id], ...patch } }))}
/>
</div>
</div>
);
})}
</div>
{error && <p className="text-sm text-danger">{error}</p>}
<DialogActions saving={saving} onConfirm={saveAll} onClose={onClose} confirmLabel="Alle speichern" />
</DialogShell>
);
}
// --- gemeinsame Dialog-Bausteine ---
function DialogShell({
title,
onClose,
children,
wide,
}: {
title: string;
onClose: () => void;
children: React.ReactNode;
wide?: boolean;
}) {
return (
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className={`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`}
>
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-fg">{title}</h2>
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
{children}
</div>
</div>
);
}
function DialogActions({
saving,
onConfirm,
onClose,
confirmLabel,
}: {
saving: boolean;
onConfirm: () => void;
onClose: () => void;
confirmLabel: string;
}) {
return (
<div className="flex gap-2 pt-1">
<button
type="button"
disabled={saving}
onClick={onConfirm}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
>
{saving ? "..." : confirmLabel}
</button>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2"
>
Abbrechen
</button>
</div>
);
}
+56 -75
View File
@@ -1,59 +1,77 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { KeyRound, LogOut, Settings, UserCircle2 } from "lucide-react";
import { KeyRound, LogOut, Palette, UserCircle2 } from "lucide-react";
import { api } from "@/lib/api-client";
import { getEffectiveTheme, setTheme, THEMES, type Theme } from "@/lib/theme";
export function ProfileMenu({
username,
onOpenHouseholdSettings,
}: {
username: string;
onOpenHouseholdSettings: () => void;
}) {
export function ProfileMenu({ username }: { username: string }) {
const [open, setOpen] = useState(false);
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
const [theme, setThemeState] = useState<Theme>("light");
const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setThemeState(getEffectiveTheme());
}, []);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
function chooseTheme(t: Theme) {
setTheme(t);
setThemeState(t);
}
return (
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 rounded-full border border-zinc-200 bg-white py-1 pl-1 pr-3 text-sm shadow-sm hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800 dark:hover:bg-zinc-700"
className="flex items-center gap-2 rounded-full border border-border bg-surface py-1 pl-1 pr-3 text-sm shadow-sm hover:bg-surface-2"
>
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-indigo-100 text-xs font-semibold uppercase text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300">
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-accent-soft text-xs font-semibold uppercase text-accent-soft-fg">
{username.slice(0, 2)}
</span>
<span className="hidden font-medium text-zinc-700 sm:inline dark:text-zinc-200">{username}</span>
<span className="hidden font-medium text-fg sm:inline">{username}</span>
</button>
{open && (
<div className="absolute right-0 top-11 z-30 w-56 overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
<div className="border-b border-zinc-100 px-4 py-3 dark:border-zinc-700">
<div className="absolute right-0 top-11 z-30 w-60 overflow-hidden rounded-xl border border-border bg-surface shadow-lg">
<div className="border-b border-border px-4 py-3">
<div className="flex items-center gap-2">
<UserCircle2 className="h-4 w-4 text-indigo-500 dark:text-indigo-400" />
<span className="text-sm font-medium text-zinc-800 dark:text-zinc-100">{username}</span>
<UserCircle2 className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-fg">{username}</span>
</div>
</div>
<MenuItem
icon={<Settings className="h-4 w-4" />}
label="Grundprofil bearbeiten"
onClick={() => {
setOpen(false);
onOpenHouseholdSettings();
}}
/>
<div className="border-b border-border px-4 py-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-muted">
<Palette className="h-3.5 w-3.5" /> Farbschema
</div>
<div className="grid grid-cols-3 gap-1">
{THEMES.map((t) => (
<button
key={t.value}
type="button"
onClick={() => chooseTheme(t.value)}
className={`rounded-lg border px-2 py-1.5 text-xs font-medium ${
theme === t.value
? "border-accent bg-accent-soft text-accent-soft-fg"
: "border-border text-muted hover:bg-surface-2"
}`}
>
{t.label}
</button>
))}
</div>
</div>
<MenuItem
icon={<KeyRound className="h-4 w-4" />}
label="Passwort aendern"
@@ -78,20 +96,12 @@ export function ProfileMenu({
);
}
function MenuItem({
icon,
label,
onClick,
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
}) {
function MenuItem({ icon, label, onClick }: { icon: React.ReactNode; label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-left text-sm text-zinc-700 hover:bg-indigo-50 hover:text-indigo-700 dark:text-zinc-200 dark:hover:bg-indigo-500/10 dark:hover:text-indigo-300"
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-left text-sm text-muted hover:bg-accent-soft hover:text-accent-soft-fg"
>
{icon}
{label}
@@ -127,55 +137,26 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
}
const inputClass =
"w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
"w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg shadow-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25";
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
<form
onSubmit={handleSubmit}
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-900"
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">Passwort aendern</h2>
<input
type="password"
placeholder="Aktuelles Passwort"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className={inputClass}
/>
<input
type="password"
placeholder="Neues Passwort"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className={inputClass}
/>
<input
type="password"
placeholder="Neues Passwort bestaetigen"
autoComplete="new-password"
value={newPasswordConfirm}
onChange={(e) => setNewPasswordConfirm(e.target.value)}
className={inputClass}
/>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
{done && <p className="text-sm text-emerald-600 dark:text-emerald-400">Passwort geaendert.</p>}
<h2 className="text-base font-semibold text-fg">Passwort aendern</h2>
<input type="password" placeholder="Aktuelles Passwort" autoComplete="current-password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} className={inputClass} />
<input type="password" placeholder="Neues Passwort" autoComplete="new-password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className={inputClass} />
<input type="password" placeholder="Neues Passwort bestaetigen" autoComplete="new-password" value={newPasswordConfirm} onChange={(e) => setNewPasswordConfirm(e.target.value)} className={inputClass} />
{error && <p className="text-sm text-danger">{error}</p>}
{done && <p className="text-sm text-success">Passwort geaendert.</p>}
<div className="flex gap-2 pt-1">
<button
type="submit"
disabled={saving}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
<button type="submit" disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50">
{saving ? "..." : "Speichern"}
</button>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
<button type="button" onClick={onClose} className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2">
Abbrechen
</button>
</div>
+6 -6
View File
@@ -32,10 +32,10 @@ export function Timeline({ phases, persons }: { phases: PhaseComputed[]; persons
}
return (
<div className="rounded-xl border border-zinc-200/70 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">Zeitachse</h3>
<div className="flex gap-3 text-xs text-zinc-500">
<h3 className="text-sm font-semibold text-fg">Zeitachse</h3>
<div className="flex gap-3 text-xs text-muted">
{persons.map((p) => (
<span key={p.role} className="flex items-center gap-1">
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: p.color }} />
@@ -65,18 +65,18 @@ export function Timeline({ phases, persons }: { phases: PhaseComputed[]; persons
)}
{/* Achse */}
<div className="relative h-2 w-full rounded-full bg-gradient-to-r from-indigo-200 to-indigo-400 dark:from-indigo-500/30 dark:to-indigo-500/60">
<div className="relative h-2 w-full rounded-full bg-gradient-to-r from-accent-soft to-accent">
{boundaries.slice(1).map((b) => (
<div
key={b.year}
className="absolute top-0 h-2 w-px bg-white/70 dark:bg-zinc-900/70"
className="absolute top-0 h-2 w-px bg-surface/70"
style={{ left: pct(minAge + b.year) }}
/>
))}
</div>
{/* Alters-Beschriftung */}
<div className="mt-1 flex justify-between text-[11px] text-zinc-500">
<div className="mt-1 flex justify-between text-[11px] text-muted">
<span>{minAge} J.</span>
<span>{maxAge} J.</span>
</div>
+2 -2
View File
@@ -23,7 +23,7 @@ export interface TimelineSeries {
// ueberlagerte Plaene fuer den Szenario-Vergleich.
export function WealthChart({ series }: { series: TimelineSeries[] }) {
if (series.length === 0 || series[0].computed.phases.length === 0) {
return <p className="text-sm text-zinc-500">Noch keine Phasen vorhanden.</p>;
return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
}
// Datenpunkte je Phasen-Index; X-Achse = Phasenname des Hauptplans.
@@ -46,7 +46,7 @@ export function WealthChart({ series }: { series: TimelineSeries[] }) {
<div className="h-80 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-zinc-200 dark:stroke-zinc-700" />
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="phase" tick={{ fontSize: 11 }} />
<YAxis
tick={{ fontSize: 11 }}