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>