Major rework: multi-user accounts (register/login, per-user data isolation), new layout with sidebar/dashboard/profile menu, matrix phase view with collapsible category columns, life timeline with ages per phase, live budget capping, collapsible transitions, mobile support
Deploy App / deploy (push) Successful in 1m47s

This commit is contained in:
2026-07-11 17:31:18 +02:00
parent 2e65bb3a2d
commit 685ff46c27
31 changed files with 1479 additions and 721 deletions
+430 -223
View File
@@ -1,11 +1,20 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { LogOut, PiggyBank, Plus, Settings, X } from "lucide-react";
import { PhaseCard } from "@/components/PhaseCard";
import {
FolderKanban,
LayoutDashboard,
Menu,
PiggyBank,
Plus,
Trash2,
X,
} from "lucide-react";
import { PhaseCard, formatAges } from "@/components/PhaseCard";
import { TransitionPanel } from "@/components/TransitionPanel";
import { Dashboard } from "@/components/Dashboard";
import { HouseholdSettings } from "@/components/HouseholdSettings";
import { ProfileMenu } from "@/components/ProfileMenu";
import { api } from "@/lib/api-client";
import type { HouseholdInput, PlanInput } from "@/lib/types";
import type { PlanComputed } from "@/lib/calculations";
@@ -18,13 +27,20 @@ interface PlanListItem {
phases: { id: string; name: string; sequenceNumber: number }[];
}
export function AppShell({ initialHousehold }: { initialHousehold: HouseholdInput }) {
export function AppShell({
initialHousehold,
username,
}: {
initialHousehold: HouseholdInput;
username: string;
}) {
const [household, setHousehold] = useState(initialHousehold);
const [showSettings, setShowSettings] = useState(false);
const [plans, setPlans] = useState<PlanListItem[]>([]);
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
const [detail, setDetail] = useState<{ plan: PlanInput; computed: PlanComputed } | null>(null);
const [loading, setLoading] = useState(true);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [showNewPlan, setShowNewPlan] = useState(false);
const [showScenario, setShowScenario] = useState(false);
@@ -33,11 +49,8 @@ export function AppShell({ initialHousehold }: { initialHousehold: HouseholdInpu
setPlans(data.plans);
if (preferId) {
setSelectedPlanId(preferId);
} else if (!selectedPlanId && data.plans.length > 0) {
setSelectedPlanId(data.plans[0].id);
}
return data.plans;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadDetail = useCallback(async (planId: string) => {
@@ -51,17 +64,16 @@ export function AppShell({ initialHousehold }: { initialHousehold: HouseholdInpu
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf beim Mount, kein synchrones setState
loadPlans();
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf beim Mount
loadPlans().finally(() => setLoading(false));
}, [loadPlans]);
useEffect(() => {
if (selectedPlanId) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf, kein synchrones setState
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf
loadDetail(selectedPlanId);
} else {
setDetail(null);
setLoading(false);
}
}, [selectedPlanId, loadDetail]);
@@ -70,10 +82,9 @@ export function AppShell({ initialHousehold }: { initialHousehold: HouseholdInpu
}
async function handleAddPhase() {
if (!selectedPlanId) return;
const lastPhase = detail?.plan.phases[detail.plan.phases.length - 1];
if (!selectedPlanId || !detail) return;
await api.post(`/api/plans/${selectedPlanId}/phases`, {
name: lastPhase ? `Neue Phase ${detail!.plan.phases.length + 1}` : "Erste Lebensphase",
name: detail.plan.phases.length === 0 ? "Erste Lebensphase" : `Neue Phase ${detail.plan.phases.length + 1}`,
durationYears: 10,
incomeMode: "HOUSEHOLD",
});
@@ -83,206 +94,391 @@ export function AppShell({ initialHousehold }: { initialHousehold: HouseholdInpu
async function handleDeletePlan(id: string) {
if (!confirm("Diesen Plan wirklich loeschen?")) return;
await api.delete(`/api/plans/${id}`);
const remaining = await loadPlans();
await loadPlans();
if (selectedPlanId === id) {
setSelectedPlanId(remaining[0]?.id ?? null);
setSelectedPlanId(null);
}
}
return (
<div className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 px-4 py-8">
<header className="flex items-center justify-between">
<h1 className="flex items-center gap-2 text-xl font-semibold text-zinc-900 dark:text-zinc-50">
<PiggyBank className="h-6 w-6 text-indigo-600 dark:text-indigo-400" />
Financial Planning Tool
</h1>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowSettings((v) => !v)}
className="flex items-center gap-1.5 rounded-lg border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-700 shadow-sm hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
>
<Settings className="h-3.5 w-3.5" />
Grundprofil
</button>
<button
type="button"
onClick={async () => {
await api.post("/api/auth/logout");
window.location.href = "/login";
}}
className="flex items-center gap-1.5 rounded-lg border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-700 shadow-sm hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
>
<LogOut className="h-3.5 w-3.5" />
Abmelden
</button>
</div>
</header>
const activePlan = plans.find((p) => p.id === selectedPlanId) ?? null;
{showSettings && (
<HouseholdSettings
household={household}
onUpdated={setHousehold}
onClose={() => setShowSettings(false)}
/>
)}
{/* Tab-Leiste */}
<div className="flex flex-wrap items-center gap-2 border-b border-zinc-200 pb-2 dark:border-zinc-800">
{plans.map((p) => (
<div key={p.id} className="flex items-center">
<button
type="button"
onClick={() => setSelectedPlanId(p.id)}
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
selectedPlanId === p.id
? "bg-indigo-600 text-white shadow-sm dark:bg-indigo-500"
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
}`}
>
{p.name}
</button>
{selectedPlanId === p.id && (
<button
type="button"
onClick={() => handleDeletePlan(p.id)}
className="ml-1 text-zinc-400 hover:text-red-600"
aria-label="Plan loeschen"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
<div className="relative">
<button
type="button"
onClick={() => setShowNewPlan((v) => !v)}
className="flex items-center gap-1 rounded-lg px-2 py-1.5 text-sm text-indigo-600 hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-500/10"
>
<Plus className="h-3.5 w-3.5" />
Plan
</button>
{showNewPlan && (
<NewPlanPopover
onCreate={async (name) => {
const { plan } = await api.post<{ plan: { id: string } }>("/api/plans", { name });
setShowNewPlan(false);
await loadPlans(plan.id);
}}
onClose={() => setShowNewPlan(false)}
/>
)}
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>
{detail && detail.plan.phases.length > 0 && (
<div className="relative">
<button
type="button"
onClick={() => setShowScenario((v) => !v)}
className="flex items-center gap-1 rounded-lg px-2 py-1.5 text-sm text-indigo-600 hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-500/10"
>
<Plus className="h-3.5 w-3.5" />
Szenario
</button>
{showScenario && (
<ScenarioPopover
phases={detail.plan.phases}
onCreate={async (name, branchFromPhaseId) => {
const { planId } = await api.post<{ planId: string }>(
`/api/plans/${selectedPlanId}/scenario`,
{ name, branchFromPhaseId }
);
setShowScenario(false);
await loadPlans(planId);
}}
onClose={() => setShowScenario(false)}
/>
)}
</div>
)}
<span className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">FPT</span>
</div>
{loading && <p className="text-sm text-zinc-500">Laedt</p>}
{!loading && plans.length === 0 && (
<p className="text-sm text-zinc-500">
Noch kein Plan vorhanden. Erstellen Sie oben Ihren ersten Plan.
</p>
)}
{!loading && detail && (
<>
<div className="flex flex-col gap-3">
{detail.plan.phases.map((phase, i) => {
const computedPhase = detail.computed.phases.find((c) => c.id === phase.id)!;
const nextPhase = detail.plan.phases[i + 1];
return (
<div key={phase.id} className="flex flex-col gap-3">
<PhaseCard
household={household}
phase={phase}
computed={computedPhase}
isLast={i === detail.plan.phases.length - 1}
onChanged={refreshCurrent}
/>
{nextPhase && (
<TransitionPanel
phase={phase}
computed={computedPhase}
nextPhaseName={nextPhase.name}
onChanged={refreshCurrent}
/>
)}
</div>
);
})}
</div>
<button
type="button"
onClick={handleAddPhase}
className="flex items-center gap-1.5 self-start rounded-lg border border-dashed border-indigo-300 bg-indigo-50/50 px-3 py-2 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 dark:hover:bg-indigo-500/20"
>
<Plus className="h-4 w-4" />
Phase hinzufuegen
</button>
{detail.plan.phases.length > 0 && (
<Dashboard plan={detail.plan} computed={detail.computed} allPlans={plans} />
)}
</>
)}
</div>
);
}
function NewPlanPopover({ onCreate, onClose }: { onCreate: (name: string) => void; onClose: () => void }) {
const [name, setName] = useState("Basisplan");
return (
<div className="absolute left-0 top-10 z-10 w-64 rounded-lg border border-zinc-200 bg-white p-3 shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
<input
className="mb-2 w-full 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-600 dark:bg-zinc-900"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name des Plans"
/>
<div className="flex gap-2">
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto px-3 pb-4">
<button
type="button"
onClick={() => onCreate(name)}
className="rounded-lg bg-indigo-600 px-2 py-1 text-xs font-medium text-white hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
onClick={() => {
setSelectedPlanId(null);
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"
}`}
>
Erstellen
<LayoutDashboard className="h-4 w-4" />
Uebersicht
</button>
<button type="button" onClick={onClose} className="text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300">
Abbrechen
<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>
<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"
>
<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.map((p) => (
<button
key={p.id}
type="button"
onClick={() => {
setSelectedPlanId(p.id);
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"
}`}
>
<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>
</button>
))}
</nav>
</div>
);
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>
{/* 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">
<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"
>
<X className="h-4 w-4" />
</button>
{sidebar}
</aside>
</div>
)}
{/* 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">
<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"
>
<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">
{activePlan ? activePlan.name : "Uebersicht"}
</h1>
<ProfileMenu username={username} onOpenHouseholdSettings={() => setShowSettings(true)} />
</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 && selectedPlanId === null && (
<DashboardHome
username={username}
plans={plans}
onSelect={setSelectedPlanId}
onCreate={() => setShowNewPlan(true)}
onDelete={handleDeletePlan}
/>
)}
{!loading && detail && selectedPlanId && (
<div className="flex flex-col gap-6">
{/* Plan-Kopf mit Aktionen */}
<div className="flex flex-wrap items-center gap-2">
<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 dark:hover:bg-indigo-500/20"
>
<Plus className="h-4 w-4" />
Phase hinzufuegen
</button>
{detail.plan.phases.length > 0 && (
<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"
>
<Plus className="h-4 w-4" />
Szenario
</button>
)}
<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"
>
<Trash2 className="h-4 w-4" />
Plan loeschen
</button>
</div>
{/* Phasen mit Lebenslinie */}
<div className="flex flex-col">
{detail.plan.phases.map((phase, i) => {
const computedPhase = detail.computed.phases.find((c) => c.id === phase.id)!;
const nextPhase = detail.plan.phases[i + 1];
const startAges = computedPhase.ages.map((a) => a.startAge).join("·");
return (
<div key={phase.id} className="flex gap-3">
{/* Lebenslinie */}
<div className="hidden w-12 flex-col items-center sm:flex">
<div
title={`Alter zu Beginn: ${formatAges(computedPhase)}`}
className="flex h-9 w-12 items-center justify-center rounded-full border border-indigo-200 bg-indigo-50 text-[11px] font-semibold text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/15 dark:text-indigo-300"
>
{startAges}
</div>
<div className="w-px flex-1 bg-indigo-200 dark:bg-indigo-500/30" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-3 pb-3">
<PhaseCard
household={household}
phase={phase}
computed={computedPhase}
isFirst={i === 0}
isLast={i === detail.plan.phases.length - 1}
onChanged={refreshCurrent}
/>
{nextPhase && (
<TransitionPanel
phase={phase}
computed={computedPhase}
nextPhaseName={nextPhase.name}
onChanged={refreshCurrent}
/>
)}
</div>
</div>
);
})}
{detail.computed.phases.length > 0 && (
<div className="hidden w-12 flex-col items-center sm:flex">
<div
title="Alter am Ende der letzten Phase"
className="flex h-9 w-12 items-center justify-center rounded-full border border-zinc-300 bg-white text-[11px] font-semibold text-zinc-600 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-300"
>
{detail.computed.phases[detail.computed.phases.length - 1].ages
.map((a) => a.endAge)
.join("·")}
</div>
</div>
)}
</div>
{detail.plan.phases.length === 0 && (
<p className="text-sm text-zinc-500">
Dieser Plan hat noch keine Phasen. Fuegen Sie oben die erste Lebensphase hinzu.
</p>
)}
{detail.plan.phases.length > 0 && (
<Dashboard plan={detail.plan} computed={detail.computed} allPlans={plans} />
)}
</div>
)}
</main>
</div>
{/* Dialoge */}
{showNewPlan && (
<PlanDialog
title="Neuen Plan erstellen"
defaultName="Basisplan"
onCreate={async (name) => {
const { plan } = await api.post<{ plan: { id: string } }>("/api/plans", { name });
setShowNewPlan(false);
await loadPlans(plan.id);
}}
onClose={() => setShowNewPlan(false)}
/>
)}
{showScenario && detail && selectedPlanId && (
<ScenarioDialog
phases={detail.plan.phases}
onCreate={async (name, branchFromPhaseId) => {
const { planId } = await api.post<{ planId: string }>(`/api/plans/${selectedPlanId}/scenario`, {
name,
branchFromPhaseId,
});
setShowScenario(false);
await loadPlans(planId);
}}
onClose={() => setShowScenario(false)}
/>
)}
</div>
);
}
// Startansicht: Begruessung + Plan-Kacheln.
function DashboardHome({
username,
plans,
onSelect,
onCreate,
onDelete,
}: {
username: string;
plans: PlanListItem[];
onSelect: (id: string) => void;
onCreate: () => void;
onDelete: (id: string) => void;
}) {
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">
Waehlen Sie einen Plan oder erstellen Sie einen neuen, um Ihre finanzielle Zukunft zu planen.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{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"
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>
<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">
{p.phases.length} {p.phases.length === 1 ? "Phase" : "Phasen"}
{p.parentPlanId ? " · Szenario" : ""}
</div>
</div>
<button
type="button"
aria-label="Plan loeschen"
onClick={(e) => {
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"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
<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"
>
<Plus className="h-4 w-4" />
Neuer Plan
</button>
</div>
</div>
);
}
function ScenarioPopover({
function PlanDialog({
title,
defaultName,
onCreate,
onClose,
}: {
title: string;
defaultName: string;
onCreate: (name: string) => void;
onClose: () => void;
}) {
const [name, setName] = useState(defaultName);
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-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"
>
<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"
/>
<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"
>
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
</button>
</div>
</div>
</div>
);
}
function ScenarioDialog({
phases,
onCreate,
onClose,
@@ -294,36 +490,47 @@ function ScenarioPopover({
const [name, setName] = useState("Neues Szenario");
const [branchFromPhaseId, setBranchFromPhaseId] = useState(phases[phases.length - 1]?.id ?? "");
return (
<div className="absolute left-0 top-10 z-10 w-72 rounded-lg border border-zinc-200 bg-white p-3 shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
<input
className="mb-2 w-full 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-600 dark:bg-zinc-900"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name des Szenarios"
/>
<label className="mb-1 block text-xs text-zinc-500">Verzweigen ab Phase</label>
<select
className="mb-2 w-full 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-600 dark:bg-zinc-900"
value={branchFromPhaseId}
onChange={(e) => setBranchFromPhaseId(e.target.value)}
<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"
>
{phases.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<div className="flex gap-2">
<button
type="button"
onClick={() => onCreate(name, branchFromPhaseId)}
className="rounded-lg bg-indigo-600 px-2 py-1 text-xs font-medium text-white hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">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"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name des Szenarios"
/>
<label className="text-xs text-zinc-500">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"
value={branchFromPhaseId}
onChange={(e) => setBranchFromPhaseId(e.target.value)}
>
Erstellen
</button>
<button type="button" onClick={onClose} className="text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300">
Abbrechen
</button>
{phases.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<div className="flex gap-2">
<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"
>
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
</button>
</div>
</div>
</div>
);
+17 -4
View File
@@ -54,14 +54,17 @@ export function NumberField({
// 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).
export function MoneyInput({
value,
onChange,
className,
max,
}: {
value: number;
onChange: (value: number) => void;
className?: string;
max?: number;
}) {
const [focused, setFocused] = useState(false);
const [text, setText] = useState(() => String(Math.floor(value || 0)));
@@ -72,8 +75,14 @@ export function MoneyInput({
const holdTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
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)));
return result;
}
function step(delta: number) {
onChange(floorToThousand(valueRef.current) + delta);
onChange(clamp(floorToThousand(valueRef.current) + delta));
}
function stopHold() {
@@ -107,12 +116,14 @@ export function MoneyInput({
value={focused ? text : formatChf(value)}
onFocus={() => {
setFocused(true);
setText(String(Math.floor(value || 0)));
// Default-0 sofort leeren, damit man direkt lostippen kann.
const current = Math.floor(value || 0);
setText(current === 0 ? "" : String(current));
}}
onChange={(e) => setText(e.target.value.replace(/[^0-9-]/g, ""))}
onBlur={() => {
setFocused(false);
onChange(floorToThousand(parseChfInput(text)));
onChange(clamp(floorToThousand(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">
@@ -164,16 +175,18 @@ export function MoneyField({
help,
value,
onChange,
max,
}: {
label: string;
help?: string;
value: number;
onChange: (value: number) => void;
max?: number;
}) {
return (
<div>
<FieldLabel label={label} help={help} />
<MoneyInput value={value} onChange={onChange} />
<MoneyInput value={value} onChange={onChange} max={max} />
</div>
);
}
+26 -6
View File
@@ -2,23 +2,38 @@
import { useState } from "react";
import { LineChart, Line, ResponsiveContainer } from "recharts";
import { AlertTriangle, ChevronDown, ChevronRight, Trash2 } from "lucide-react";
import { AlertTriangle, ChevronDown, ChevronRight, Trash2, Users } from "lucide-react";
import { PhaseForm } from "@/components/PhaseForm";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import type { HouseholdInput, PhaseInput } from "@/lib/types";
import type { PhaseComputed } from "@/lib/calculations";
// Formatiert die Altersspannen der Personen einer Phase, z. B. "3545" (Single)
// oder "A 3545 · B 3343" (Paar).
export function formatAges(computed: PhaseComputed): string {
if (computed.ages.length === 0) return "";
if (computed.ages.length === 1) {
const a = computed.ages[0];
return `${a.startAge}${a.endAge}`;
}
return computed.ages
.map((a) => `${a.role === "PERSON_A" ? "A" : "B"} ${a.startAge}${a.endAge}`)
.join(" · ");
}
export function PhaseCard({
household,
phase,
computed,
isFirst,
isLast,
onChanged,
}: {
household: HouseholdInput;
phase: PhaseInput;
computed: PhaseComputed;
isFirst: boolean;
isLast: boolean;
onChanged: () => void;
}) {
@@ -48,15 +63,19 @@ export function PhaseCard({
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex w-full items-center gap-4 px-4 py-3 text-left hover:bg-zinc-50 dark:hover:bg-zinc-800/50"
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-zinc-50 sm:gap-4 dark:hover:bg-zinc-800/50"
>
<span className="text-indigo-500 dark:text-indigo-400">
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</span>
<div className="flex-1">
<div className="flex items-baseline gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="font-medium text-zinc-900 dark:text-zinc-100">{phase.name}</span>
<span className="text-xs text-zinc-500">{phase.durationYears} Jahre</span>
<span className="flex items-center gap-1 text-xs text-indigo-600 dark:text-indigo-400">
<Users className="h-3 w-3" />
Alter {formatAges(computed)}
</span>
{computed.savingsWarning && (
<span className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
<AlertTriangle className="h-3 w-3" /> Sparquote ueberschritten
@@ -67,7 +86,7 @@ export function PhaseCard({
Start {formatChf(computed.startWealthNominal)} CHF Ende {formatChf(computed.endWealthNominal)} CHF (nominal)
</div>
</div>
<div className="h-8 w-24">
<div className="hidden h-8 w-24 sm:block">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={sparklineData}>
<Line type="monotone" dataKey="value" stroke="#4f46e5" strokeWidth={1.5} dot={false} />
@@ -91,9 +110,10 @@ export function PhaseCard({
</button>
{expanded && (
<PhaseForm
key={`${phase.id}:${phase.securities.length}:${phase.incomingCapital}`}
key={`${phase.id}:${phase.securities.length}:${phase.realEstates.length}:${phase.incomingCapital}`}
household={household}
phase={phase}
isFirstPhase={isFirst}
onSaved={() => {
onChanged();
}}
+357 -299
View File
@@ -4,11 +4,12 @@ import { useState } from "react";
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
ChevronRight,
Gift,
Home,
Plus,
PiggyBank,
ShoppingCart,
TrendingUp,
Wallet,
X,
@@ -47,11 +48,12 @@ function personLabel(household: HouseholdInput, personId: string | null) {
interface Props {
household: HouseholdInput;
phase: PhaseInput;
isFirstPhase: boolean;
onSaved: () => void;
onCancel: () => void;
}
export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
export function PhaseForm({ household, phase, isFirstPhase, onSaved, onCancel }: Props) {
const [name, setName] = useState(phase.name);
const [durationYears, setDurationYears] = useState(phase.durationYears);
const [inflationRate, setInflationRate] = useState<number | null>(phase.inflationRate);
@@ -71,11 +73,10 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
const totalExpense = expenseEntries.reduce((s, e) => s + e.amount, 0);
const savingsQuota = totalIncome - totalExpense;
// Amortisationsraten aller Immobilien zaehlen gleichwertig mit den Sparbeitraegen der
// Wertschriften gegen dieselbe verfuegbare Sparquote.
// Wertschriften gegen dieselbe verfuegbare Sparquote (ein gemeinsamer Topf).
const allocated =
securities.reduce((s, sec) => s + sec.annualContribution, 0) +
realEstates.reduce((s, re) => s + re.amortization, 0);
const overAllocated = allocated > savingsQuota;
const savingsRemaining = savingsQuota - allocated > 0.5;
const allocatedStartCapital = securities.reduce(
@@ -84,6 +85,19 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
);
const startCapitalRemaining = phase.incomingCapital - allocatedStartCapital > 0.5;
// Live-Kappung: pro Feld das noch verfuegbare Budget (eigener Anteil zaehlt nicht
// gegen sich selbst, damit man einen bestehenden Wert wieder erhoehen/senken kann).
function maxContributionFor(current: number): number {
return Math.max(0, savingsQuota - (allocated - current));
}
function maxStartValueFor(sec: SecurityInput): number | undefined {
// In der ersten Phase wird der Ist-Bestand frei erfasst -- kein Limit.
if (isFirstPhase) return undefined;
const ownExtra = Math.max(0, sec.startValue - sec.carriedBaseValue);
const remaining = Math.max(0, phase.incomingCapital - (allocatedStartCapital - ownExtra));
return sec.carriedBaseValue + remaining;
}
async function handleSave() {
const missingPurchasePrice = realEstates.find((re) => !re.purchasePrice || re.purchasePrice <= 0);
if (missingPurchasePrice) {
@@ -120,10 +134,10 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
}
return (
<div className="flex flex-col gap-6 border-t border-zinc-200 p-4 dark:border-zinc-700">
{/* Basis */}
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="col-span-2 sm:col-span-2">
<div className="flex flex-col gap-5 border-t border-zinc-200 p-4 dark:border-zinc-800">
{/* Basis-Kopfzeile */}
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="col-span-2 lg:col-span-1">
<TextField
label="Bezeichnung der Lebensphase"
help="Ein frei waehlbarer Name, z. B. 'Kinder zuhause' oder 'Fruehpensionierung'."
@@ -139,7 +153,7 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
onChange={setDurationYears}
/>
<NumberField
label="Inflationsrate dieser Phase (%)"
label="Inflationsrate (%)"
help="Ueberschreibt fuer diese Phase die im Grundprofil hinterlegte Standardannahme."
value={inflationRate ?? household.inflationRateDefault}
step={0.1}
@@ -159,109 +173,119 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
)}
</section>
{/* Einkommen */}
<Section title="Einkommen" icon={<Wallet className="h-4 w-4" />}>
{incomeEntries.map((entry, i) => (
<div key={entry.id} className="grid grid-cols-[1fr_1fr_auto] items-end gap-2">
{incomeMode === "PER_PERSON" ? (
<SelectField
label="Person"
value={(entry.personId ?? household.persons[0]?.id ?? "") as string}
onChange={(v) =>
setIncomeEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, personId: v } : e)))
}
options={household.persons.map((p) => ({
value: p.id,
label: p.role === "PERSON_A" ? "Person A" : "Person B",
}))}
/>
) : (
<TextField
label="Bezeichnung (optional)"
value={entry.label ?? ""}
onChange={(v) =>
setIncomeEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, label: v || null } : e)))
}
/>
)}
<MoneyField
label="Geschaetztes Jahreseinkommen (CHF)"
help="Ihr erwartetes Bruttoeinkommen pro Jahr waehrend dieser Lebensphase."
value={entry.amount}
onChange={(v) =>
setIncomeEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, amount: v } : e)))
{/* Matrix: Kategorien als Spalten */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
{/* Einkommen & Ausgaben */}
<CollapsibleColumn
title="Einkommen & Ausgaben"
icon={<Wallet className="h-4 w-4" />}
summary={`${formatChf(totalIncome)} / ${formatChf(totalExpense)}`}
>
<div className="flex flex-col gap-2">
<div className="text-[11px] font-semibold uppercase tracking-wide text-zinc-400">Einkommen</div>
{incomeEntries.map((entry, i) => (
<EntryCard key={entry.id} onRemove={() => setIncomeEntries((prev) => prev.filter((_, idx) => idx !== i))}>
{incomeMode === "PER_PERSON" ? (
<SelectField
label="Person"
value={(entry.personId ?? household.persons[0]?.id ?? "") as string}
onChange={(v) =>
setIncomeEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, personId: v } : e)))
}
options={household.persons.map((p) => ({
value: p.id,
label: p.role === "PERSON_A" ? "Person A" : "Person B",
}))}
/>
) : (
<TextField
label="Bezeichnung (optional)"
value={entry.label ?? ""}
onChange={(v) =>
setIncomeEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, label: v || null } : e)))
}
/>
)}
<MoneyField
label="Jahreseinkommen (CHF)"
help="Ihr erwartetes Bruttoeinkommen pro Jahr waehrend dieser Lebensphase."
value={entry.amount}
onChange={(v) =>
setIncomeEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, amount: v } : e)))
}
/>
</EntryCard>
))}
<AddButton
label="Einkommensposten"
onClick={() =>
setIncomeEntries((prev) => [
...prev,
{ id: tempId(), personId: incomeMode === "PER_PERSON" ? household.persons[0]?.id ?? null : null, label: null, amount: 0 },
])
}
/>
<RemoveButton onClick={() => setIncomeEntries((prev) => prev.filter((_, idx) => idx !== i))} />
</div>
))}
<AddButton
label="Einkommensposten"
onClick={() =>
setIncomeEntries((prev) => [
...prev,
{ id: tempId(), personId: incomeMode === "PER_PERSON" ? household.persons[0]?.id ?? null : null, label: null, amount: 0 },
])
}
/>
</Section>
{/* Ausgaben */}
<Section title="Ausgaben" icon={<ShoppingCart className="h-4 w-4" />}>
{expenseEntries.map((entry, i) => (
<div key={entry.id} className="grid grid-cols-[1fr_auto] items-end gap-2">
<MoneyField
label="Geschaetzte Gesamtausgaben (CHF/Jahr)"
help="Saemtliche laufenden Kosten des Haushalts pro Jahr - inkl. Lebenshaltung, Hypothekarzinsen, laufende Einkommens- und Vermoegenssteuern. Keine separate Kategorisierung noetig."
value={entry.amount}
onChange={(v) =>
setExpenseEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, amount: v } : e)))
}
<div className="mt-2 text-[11px] font-semibold uppercase tracking-wide text-zinc-400">Ausgaben</div>
{expenseEntries.map((entry, i) => (
<EntryCard key={entry.id} onRemove={() => setExpenseEntries((prev) => prev.filter((_, idx) => idx !== i))}>
<MoneyField
label="Gesamtausgaben (CHF/Jahr)"
help="Saemtliche laufenden Kosten des Haushalts pro Jahr - inkl. Lebenshaltung, Hypothekarzinsen, laufende Einkommens- und Vermoegenssteuern."
value={entry.amount}
onChange={(v) =>
setExpenseEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, amount: v } : e)))
}
/>
</EntryCard>
))}
<AddButton
label="Ausgabenposten"
onClick={() => setExpenseEntries((prev) => [...prev, { id: tempId(), label: null, amount: 0 }])}
/>
<RemoveButton onClick={() => setExpenseEntries((prev) => prev.filter((_, idx) => idx !== i))} />
</div>
))}
<AddButton
label="Ausgabenposten"
onClick={() => setExpenseEntries((prev) => [...prev, { id: tempId(), label: null, amount: 0 }])}
/>
<div className="rounded-xl bg-indigo-50/60 px-3 py-2 text-sm text-zinc-600 dark:bg-indigo-500/10 dark:text-zinc-300">
Verfuegbare Sparquote (CHF/Jahr): <strong>{formatChf(savingsQuota)}</strong>
{" "}(Details und Verteilung siehe Wertschriften weiter unten)
</div>
</Section>
</CollapsibleColumn>
{/* Wertschriften */}
<Section title="Wertschriften" icon={<TrendingUp className="h-4 w-4" />}>
{securities.map((s, i) => (
<div key={s.id} className="grid grid-cols-2 gap-2 rounded-xl border border-zinc-100 bg-zinc-50/60 p-3 sm:grid-cols-5 dark:border-zinc-800 dark:bg-zinc-800/30">
<TextField
label="Name"
help="Frei waehlbare Bezeichnung, z. B. 'Globaler ETF' oder 'Schweizer Aktien'."
value={s.name}
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, name: v } : x)))}
/>
<MoneyField
label="Startwert (CHF)"
help="Wert dieser Position zu Beginn der Phase."
value={s.startValue}
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, startValue: v } : x)))}
/>
<NumberField
label="Erwartete Rendite (%/Jahr)"
help="Ihre Annahme zur durchschnittlichen jaehrlichen Wertentwicklung dieser Anlage."
step={0.1}
value={s.expectedReturn}
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, expectedReturn: v } : x)))}
/>
<MoneyField
label="Jaehrlicher Sparbeitrag (CHF)"
help="Der Betrag aus Ihrer verfuegbaren Sparquote, den Sie jaehrlich in diese Position investieren moechten."
value={s.annualContribution}
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, annualContribution: v } : x)))}
/>
<div className="flex items-end gap-2">
<div className="flex-1">
{/* Wertschriften */}
<CollapsibleColumn
title="Wertschriften"
icon={<TrendingUp className="h-4 w-4" />}
summary={`${securities.length}`}
>
<div className="flex flex-col gap-2">
{securities.map((s, i) => (
<EntryCard key={s.id} onRemove={() => setSecurities((prev) => prev.filter((_, idx) => idx !== i))}>
<TextField
label="Name"
help="Frei waehlbare Bezeichnung, z. B. 'Globaler ETF' oder 'Schweizer Aktien'."
value={s.name}
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, name: v } : x)))}
/>
<MoneyField
label="Startwert (CHF)"
help={
isFirstPhase
? "Wert dieser Position zu Beginn der Phase."
: "Wert zu Beginn der Phase. Erhoehungen gegenueber dem uebernommenen Wert werden vom verfuegbaren Startkapital abgezogen."
}
value={s.startValue}
max={maxStartValueFor(s)}
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, startValue: v } : x)))}
/>
<NumberField
label="Rendite (%/Jahr)"
help="Ihre Annahme zur durchschnittlichen jaehrlichen Wertentwicklung dieser Anlage."
step={0.1}
value={s.expectedReturn}
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, expectedReturn: v } : x)))}
/>
<MoneyField
label="Sparbeitrag (CHF/Jahr)"
help="Der Betrag aus Ihrer verfuegbaren Sparquote, den Sie jaehrlich in diese Position investieren. Wird automatisch auf die verbleibende Sparquote begrenzt."
value={s.annualContribution}
max={maxContributionFor(s.annualContribution)}
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, annualContribution: v } : x)))}
/>
<SelectField
label="Gehoert zu"
help="Rein informativ: Person A, Person B oder gemeinsam. Hat keinen Einfluss auf die Berechnung."
@@ -273,182 +297,199 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
{ value: "PERSON_B", label: "Person B" },
]}
/>
</div>
<RemoveButton onClick={() => setSecurities((prev) => prev.filter((_, idx) => idx !== i))} />
</div>
</EntryCard>
))}
<AddButton
label="Wertschrift"
onClick={() =>
setSecurities((prev) => [
...prev,
{
id: tempId(),
name: "",
startValue: 0,
expectedReturn: 0,
annualContribution: 0,
ownerTag: "HOUSEHOLD",
saleTaxRate: 0,
carriedBaseValue: 0,
},
])
}
/>
</div>
))}
<AddButton
label="Wertschrift"
onClick={() =>
setSecurities((prev) => [
...prev,
{
id: tempId(),
name: "",
startValue: 0,
expectedReturn: 0,
annualContribution: 0,
ownerTag: "HOUSEHOLD",
saleTaxRate: 0,
carriedBaseValue: 0,
},
])
}
/>
<div className="flex flex-col gap-1.5 rounded-xl bg-indigo-50/60 px-3 py-2 text-sm dark:bg-indigo-500/10">
<div className="flex items-center gap-2">
</CollapsibleColumn>
{/* Immobilien */}
<CollapsibleColumn title="Immobilien" icon={<Home className="h-4 w-4" />} summary={`${realEstates.length}`}>
<div className="flex flex-col gap-2">
{realEstates.map((re, i) => (
<EntryCard key={re.id} onRemove={() => setRealEstates((prev) => prev.filter((_, idx) => idx !== i))}>
<TextField
label="Bezeichnung"
help="Z. B. 'Eigenheim' oder 'Ferienwohnung'."
value={re.name}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, name: v } : x)))}
/>
<MoneyField
label="Kaufpreis (CHF)"
help="Pflichtfeld. Der Kaufpreis bleibt ueber die ganze Haltedauer fix -- nur die Hypothek sinkt durch Amortisation."
value={re.purchasePrice}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, purchasePrice: v } : x)))}
/>
<MoneyField
label="Hypothek (CHF)"
help="Ausstehender Hypothekarbetrag zu Beginn der Phase."
value={re.mortgage}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, mortgage: v } : x)))}
/>
<MoneyField
label="Amortisation (CHF/Jahr)"
help="Betrag, um den die Hypothek pro Jahr reduziert wird. Zaehlt zusammen mit den Sparbeitraegen gegen die verfuegbare Sparquote."
value={re.amortization}
max={maxContributionFor(re.amortization)}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, amortization: v } : x)))}
/>
</EntryCard>
))}
<AddButton
label="Immobilie"
onClick={() =>
setRealEstates((prev) => [
...prev,
{ id: tempId(), name: "", purchasePrice: 0, mortgage: 0, amortization: 0 },
])
}
/>
</div>
</CollapsibleColumn>
{/* Sondereinnahmen / -ausgaben */}
<CollapsibleColumn
title="Sondereinnahmen / -ausgaben"
icon={<Gift className="h-4 w-4" />}
summary={`${oneTimeEvents.length}`}
>
<div className="flex flex-col gap-2">
{oneTimeEvents.map((ev, i) => (
<EntryCard key={ev.id} onRemove={() => setOneTimeEvents((prev) => prev.filter((_, idx) => idx !== i))}>
<SelectField
label="Art"
help="Einmalige Einnahme (z. B. Erbschaft) oder einmalige Ausgabe (z. B. Poolbau)."
value={ev.type}
onChange={(v: OneTimeEventType) =>
setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, type: v } : x)))
}
options={[
{ value: "INCOME", label: "Einnahme" },
{ value: "EXPENSE", label: "Ausgabe" },
]}
/>
<MoneyField
label="Betrag (CHF)"
value={ev.amount}
onChange={(v) => setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, amount: v } : x)))}
/>
<TextField
label="Beschreibung"
value={ev.description ?? ""}
onChange={(v) =>
setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, description: v || null } : x)))
}
/>
</EntryCard>
))}
<AddButton
label="Sondereintrag"
onClick={() =>
setOneTimeEvents((prev) => [...prev, { id: tempId(), type: "EXPENSE", amount: 0, description: null }])
}
/>
</div>
</CollapsibleColumn>
{/* Pensionierung */}
<CollapsibleColumn
title="Pensionierung"
icon={<PiggyBank className="h-4 w-4" />}
summary={`${retirementInfos.length}`}
>
<div className="flex flex-col gap-2">
{retirementInfos.map((r, i) => (
<EntryCard key={r.id} onRemove={() => setRetirementInfos((prev) => prev.filter((_, idx) => idx !== i))}>
<SelectField
label="Person"
value={r.personId}
onChange={(v) => setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, personId: v } : x)))}
options={household.persons.map((p) => ({ value: p.id, label: personLabel(household, p.id) }))}
/>
<MoneyField
label="AHV-Rente (CHF/Jahr)"
help="Zusammengesetzt mit der PK-Rente zur 'Erwarteten Rente'. Bei Ehepaaren max. 1.5x AHV-Maximalrente gemeinsam."
value={r.ahvAmount}
onChange={(v) => setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, ahvAmount: v } : x)))}
/>
<MoneyField
label="PK-Rente (CHF/Jahr)"
help="Pensionskassenrente (2. Saeule)."
value={r.pkPensionAmount}
onChange={(v) =>
setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, pkPensionAmount: v } : x)))
}
/>
<MoneyField
label="Kapitalbezug brutto (CHF)"
help="Zusammengesetzt aus Saeule 3a und/oder Kapitalbezug aus der Pensionskasse."
value={r.lumpSumAmount}
onChange={(v) =>
setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, lumpSumAmount: v } : x)))
}
/>
<NumberField
label="Kapitalbezugssteuer (%)"
help="Realistische Bandbreite: ca. 3-15% des Bruttobetrags."
value={r.lumpSumTaxRate}
onChange={(v) =>
setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, lumpSumTaxRate: v } : x)))
}
/>
</EntryCard>
))}
<AddButton
label="Pensionierungsangaben"
onClick={() =>
setRetirementInfos((prev) => [
...prev,
{
id: tempId(),
personId: household.persons[0]?.id ?? "",
ahvAmount: 0,
pkPensionAmount: 0,
lumpSumAmount: 0,
lumpSumTaxRate: 8,
},
])
}
/>
</div>
</CollapsibleColumn>
</div>
{/* Budget-Status */}
<div className="flex flex-col gap-1.5 rounded-xl bg-indigo-50/60 px-3 py-2 text-sm dark:bg-indigo-500/10">
{!isFirstPhase && (
<div className="flex flex-wrap items-center gap-2">
<StatusDot ok={!startCapitalRemaining} />
Verfuegbares Startkapital (aus Verkaeufen der Vorphase): <strong>{formatChf(phase.incomingCapital)}</strong> CHF
{" "} zugewiesen: {formatChf(allocatedStartCapital)}
</div>
<div className="flex items-center gap-2">
<StatusDot ok={!savingsRemaining} />
Verfuegbare Sparquote (CHF/Jahr): <strong>{formatChf(savingsQuota)}</strong>
{" "} zugewiesen: {formatChf(allocated)}
</div>
{overAllocated && (
<div className="flex items-center gap-1.5 text-amber-700 dark:text-amber-400">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
Die zugewiesenen Sparbeitraege uebersteigen die verfuegbare Sparquote.
</div>
)}
)}
<div className="flex flex-wrap items-center gap-2">
<StatusDot ok={!savingsRemaining} />
Verfuegbare Sparquote (CHF/Jahr): <strong>{formatChf(savingsQuota)}</strong>
{" "} zugewiesen: {formatChf(allocated)}
</div>
</Section>
{/* Immobilien */}
<Section title="Immobilien" icon={<Home className="h-4 w-4" />}>
{realEstates.map((re, i) => (
<div key={re.id} className="grid grid-cols-2 gap-2 rounded-xl border border-zinc-100 bg-zinc-50/60 p-3 sm:grid-cols-4 dark:border-zinc-800 dark:bg-zinc-800/30">
<TextField
label="Bezeichnung"
help="Z. B. 'Eigenheim' oder 'Ferienwohnung'."
value={re.name}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, name: v } : x)))}
/>
<MoneyField
label="Kaufpreis (CHF)"
help="Pflichtfeld. Der Kaufpreis bleibt ueber die ganze Haltedauer fix -- es wird keine Wertsteigerung angenommen, nur die Hypothek sinkt durch Amortisation."
value={re.purchasePrice}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, purchasePrice: v } : x)))}
/>
<MoneyField
label="Hypothek (CHF)"
help="Ausstehender Hypothekarbetrag zu Beginn der Phase."
value={re.mortgage}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, mortgage: v } : x)))}
/>
<MoneyField
label="Amortisationsrate (CHF/Jahr)"
help="Betrag, um den die Hypothek pro Jahr reduziert wird. Zaehlt zusammen mit den Sparbeitraegen der Wertschriften gegen die verfuegbare Sparquote."
value={re.amortization}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, amortization: v } : x)))}
/>
<div className="flex items-end">
<RemoveButton onClick={() => setRealEstates((prev) => prev.filter((_, idx) => idx !== i))} />
</div>
</div>
))}
<AddButton
label="Immobilie"
onClick={() =>
setRealEstates((prev) => [
...prev,
{ id: tempId(), name: "", purchasePrice: 0, mortgage: 0, amortization: 0 },
])
}
/>
</Section>
{/* Sondereinnahmen/-ausgaben */}
<Section title="Sondereinnahmen / -ausgaben" icon={<Gift className="h-4 w-4" />}>
{oneTimeEvents.map((ev, i) => (
<div key={ev.id} className="grid grid-cols-[auto_1fr_2fr_auto] items-end gap-2">
<SelectField
label="Art"
help="Einmalige Einnahme (z. B. Erbschaft) oder einmalige Ausgabe (z. B. Poolbau)."
value={ev.type}
onChange={(v: OneTimeEventType) => setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, type: v } : x)))}
options={[
{ value: "INCOME", label: "Einnahme" },
{ value: "EXPENSE", label: "Ausgabe" },
]}
/>
<MoneyField
label="Betrag (CHF)"
value={ev.amount}
onChange={(v) => setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, amount: v } : x)))}
/>
<TextField
label="Beschreibung"
value={ev.description ?? ""}
onChange={(v) => setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, description: v || null } : x)))}
/>
<RemoveButton onClick={() => setOneTimeEvents((prev) => prev.filter((_, idx) => idx !== i))} />
</div>
))}
<AddButton
label="Sondereinnahme/-ausgabe"
onClick={() => setOneTimeEvents((prev) => [...prev, { id: tempId(), type: "EXPENSE", amount: 0, description: null }])}
/>
</Section>
{/* Pensionierung */}
<Section title="Pensionierung (optional)" icon={<PiggyBank className="h-4 w-4" />}>
{retirementInfos.map((r, i) => (
<div key={r.id} className="grid grid-cols-2 gap-2 rounded-xl border border-zinc-100 bg-zinc-50/60 p-3 sm:grid-cols-4 dark:border-zinc-800 dark:bg-zinc-800/30">
<SelectField
label="Person"
value={r.personId}
onChange={(v) => setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, personId: v } : x)))}
options={household.persons.map((p) => ({ value: p.id, label: personLabel(household, p.id) }))}
/>
<MoneyField
label="AHV-Rente (CHF/Jahr)"
help="Zusammengesetzt mit der PK-Rente zur 'Erwarteten Rente'. Bei Ehepaaren max. 1.5x AHV-Maximalrente gemeinsam."
value={r.ahvAmount}
onChange={(v) => setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, ahvAmount: v } : x)))}
/>
<MoneyField
label="PK-Rente (CHF/Jahr)"
help="Pensionskassenrente (2. Saeule)."
value={r.pkPensionAmount}
onChange={(v) => setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, pkPensionAmount: v } : x)))}
/>
<MoneyField
label="Kapitalbezug brutto (CHF)"
help="Zusammengesetzt aus Saeule 3a und/oder Kapitalbezug aus der Pensionskasse."
value={r.lumpSumAmount}
onChange={(v) => setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, lumpSumAmount: v } : x)))}
/>
<NumberField
label="Geschaetzte Kapitalbezugssteuer (%)"
help="Realistische Bandbreite: ca. 3-15% des Bruttobetrags."
value={r.lumpSumTaxRate}
onChange={(v) => setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, lumpSumTaxRate: v } : x)))}
/>
<div className="flex items-end">
<RemoveButton onClick={() => setRetirementInfos((prev) => prev.filter((_, idx) => idx !== i))} />
</div>
</div>
))}
<AddButton
label="Pensionierungsangaben"
onClick={() =>
setRetirementInfos((prev) => [
...prev,
{
id: tempId(),
personId: household.persons[0]?.id ?? "",
ahvAmount: 0,
pkPensionAmount: 0,
lumpSumAmount: 0,
lumpSumTaxRate: 8,
},
])
}
/>
</Section>
</div>
{error && (
<p className="flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400">
@@ -478,26 +519,59 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
);
}
function Section({
// Eine einklappbare Kategorien-Spalte der Matrix (Phase x Kategorie).
function CollapsibleColumn({
title,
icon,
summary,
children,
}: {
title: string;
icon: React.ReactNode;
summary?: string;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(true);
return (
<section className="flex flex-col gap-2">
<h4 className="flex items-center gap-1.5 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
<section className="flex flex-col self-start rounded-xl border border-zinc-100 bg-zinc-50/60 dark:border-zinc-800 dark:bg-zinc-800/30">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-1.5 px-3 py-2.5 text-left"
>
<span className="text-indigo-500 dark:text-indigo-400">{icon}</span>
{title}
</h4>
{children}
<span className="flex-1 text-sm font-semibold text-zinc-800 dark:text-zinc-100">{title}</span>
{summary != null && (
<span className="rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-medium text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300">
{summary}
</span>
)}
<span className="text-zinc-400">
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</span>
</button>
{open && <div className="px-3 pb-3">{children}</div>}
</section>
);
}
// Kompakte Karte fuer einen einzelnen Eintrag (Felder vertikal gestapelt).
function EntryCard({ children, onRemove }: { children: React.ReactNode; onRemove: () => void }) {
return (
<div className="relative flex flex-col gap-2 rounded-lg border border-zinc-200/70 bg-white p-2.5 pr-8 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<button
type="button"
onClick={onRemove}
aria-label="Entfernen"
className="absolute right-1.5 top-1.5 rounded-md p-1 text-zinc-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950"
>
<X className="h-3.5 w-3.5" />
</button>
{children}
</div>
);
}
function AddButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
@@ -513,24 +587,8 @@ function AddButton({ label, onClick }: { label: string; onClick: () => void }) {
function StatusDot({ ok }: { ok: boolean }) {
return ok ? (
<CheckCircle2
className="h-4 w-4 shrink-0 text-emerald-500"
aria-label="Vollstaendig verteilt"
/>
<CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-500" aria-label="Vollstaendig verteilt" />
) : (
<XCircle className="h-4 w-4 shrink-0 text-red-500" aria-label="Noch nicht vollstaendig verteilt" />
);
}
function RemoveButton({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
aria-label="Entfernen"
className="rounded-lg border border-zinc-300 px-2 py-1.5 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"
>
<X className="h-3.5 w-3.5" />
</button>
);
}
+185
View File
@@ -0,0 +1,185 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { KeyRound, LogOut, Settings, UserCircle2 } from "lucide-react";
import { api } from "@/lib/api-client";
export function ProfileMenu({
username,
onOpenHouseholdSettings,
}: {
username: string;
onOpenHouseholdSettings: () => void;
}) {
const [open, setOpen] = useState(false);
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
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"
>
<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">
{username.slice(0, 2)}
</span>
<span className="hidden font-medium text-zinc-700 sm:inline dark:text-zinc-200">{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="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>
</div>
</div>
<MenuItem
icon={<Settings className="h-4 w-4" />}
label="Grundprofil bearbeiten"
onClick={() => {
setOpen(false);
onOpenHouseholdSettings();
}}
/>
<MenuItem
icon={<KeyRound className="h-4 w-4" />}
label="Passwort aendern"
onClick={() => {
setOpen(false);
setShowPasswordDialog(true);
}}
/>
<MenuItem
icon={<LogOut className="h-4 w-4" />}
label="Abmelden"
onClick={async () => {
await api.post("/api/auth/logout");
window.location.href = "/login";
}}
/>
</div>
)}
{showPasswordDialog && <ChangePasswordDialog onClose={() => setShowPasswordDialog(false)} />}
</div>
);
}
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"
>
{icon}
{label}
</button>
);
}
function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newPasswordConfirm, setNewPasswordConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [done, setDone] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (newPassword !== newPasswordConfirm) {
setError("Die neuen Passwoerter stimmen nicht ueberein.");
return;
}
setSaving(true);
try {
await api.post("/api/auth/change-password", { currentPassword, newPassword });
setDone(true);
setTimeout(onClose, 1200);
} catch (err) {
setError(err instanceof Error ? err.message : "Passwort aendern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
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";
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"
>
<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>}
<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"
>
{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"
>
Abbrechen
</button>
</div>
</form>
</div>
);
}
+20 -5
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { ArrowDown, CheckCircle2 } from "lucide-react";
import { ArrowDown, CheckCircle2, ChevronDown, ChevronRight } from "lucide-react";
import { MoneyInput } from "@/components/FormField";
import { api } from "@/lib/api-client";
import { floorToThousand, formatChf } from "@/lib/format";
@@ -36,6 +36,7 @@ export function TransitionPanel({
}) {
const [items, setItems] = useState<ItemDraft[]>([]);
const [loaded, setLoaded] = useState(false);
const [expanded, setExpanded] = useState(false);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -167,11 +168,23 @@ export function TransitionPanel({
}
return (
<div className="mx-2 flex flex-col gap-3 rounded-xl border border-dashed border-indigo-200 bg-indigo-50/40 p-4 dark:border-indigo-500/30 dark:bg-indigo-500/5">
<div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
<div className="mx-2 flex flex-col rounded-xl border border-dashed border-indigo-200 bg-indigo-50/40 dark:border-indigo-500/30 dark:bg-indigo-500/5">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex w-full flex-wrap items-center gap-1.5 px-4 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400"
>
<span>
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</span>
<ArrowDown className="h-3.5 w-3.5" />
Uebergang {nextPhaseName}
</div>
<span className="flex-1">Uebergang {nextPhaseName}</span>
<span className="normal-case tracking-normal text-zinc-500 dark:text-zinc-400">
Startkapital aus Verkaeufen: <strong className="text-indigo-600 dark:text-indigo-400">{formatChf(totalAvailableCapital)} CHF</strong>
</span>
</button>
{expanded && (
<div className="flex flex-col gap-3 px-4 pb-4">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-zinc-500">
@@ -260,6 +273,8 @@ export function TransitionPanel({
</span>
)}
</div>
</div>
)}
</div>
);
}