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
Deploy App / deploy (push) Successful in 1m47s
This commit is contained in:
+430
-223
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user