Initial commit: FPT Financial Planning Tool
Deploy App / deploy (push) Successful in 3m3s

This commit is contained in:
2026-07-08 19:19:39 +02:00
commit 4f990c9686
60 changed files with 12436 additions and 0 deletions
+318
View File
@@ -0,0 +1,318 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { PhaseCard } from "@/components/PhaseCard";
import { TransitionPanel } from "@/components/TransitionPanel";
import { Dashboard } from "@/components/Dashboard";
import { HouseholdSettings } from "@/components/HouseholdSettings";
import { api } from "@/lib/api-client";
import type { HouseholdInput, PlanInput } from "@/lib/types";
import type { PlanComputed } from "@/lib/calculations";
interface PlanListItem {
id: string;
name: string;
parentPlanId: string | null;
branchFromPhaseId: string | null;
phases: { id: string; name: string; sequenceNumber: number }[];
}
export function AppShell({ initialHousehold }: { initialHousehold: HouseholdInput }) {
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 [showNewPlan, setShowNewPlan] = useState(false);
const [showScenario, setShowScenario] = useState(false);
const loadPlans = useCallback(async (preferId?: string) => {
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
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) => {
setLoading(true);
try {
const data = await api.get<{ plan: PlanInput; computed: PlanComputed }>(`/api/plans/${planId}`);
setDetail(data);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf beim Mount, kein synchrones setState
loadPlans();
}, [loadPlans]);
useEffect(() => {
if (selectedPlanId) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf, kein synchrones setState
loadDetail(selectedPlanId);
} else {
setDetail(null);
setLoading(false);
}
}, [selectedPlanId, loadDetail]);
function refreshCurrent() {
if (selectedPlanId) loadDetail(selectedPlanId);
}
async function handleAddPhase() {
if (!selectedPlanId) return;
const lastPhase = detail?.plan.phases[detail.plan.phases.length - 1];
await api.post(`/api/plans/${selectedPlanId}/phases`, {
name: lastPhase ? `Neue Phase ${detail!.plan.phases.length + 1}` : "Erste Lebensphase",
durationYears: 10,
incomeMode: "HOUSEHOLD",
});
refreshCurrent();
}
async function handleDeletePlan(id: string) {
if (!confirm("Diesen Plan wirklich loeschen?")) return;
await api.delete(`/api/plans/${id}`);
const remaining = await loadPlans();
if (selectedPlanId === id) {
setSelectedPlanId(remaining[0]?.id ?? 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="text-xl font-semibold text-zinc-900 dark:text-zinc-50">
Financial Planning Tool
</h1>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowSettings((v) => !v)}
className="rounded-md border border-zinc-300 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
Grundprofil
</button>
<button
type="button"
onClick={async () => {
await api.post("/api/auth/logout");
window.location.href = "/login";
}}
className="rounded-md border border-zinc-300 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
Abmelden
</button>
</div>
</header>
{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-700">
{plans.map((p) => (
<div key={p.id} className="flex items-center">
<button
type="button"
onClick={() => setSelectedPlanId(p.id)}
className={`rounded-t-md px-3 py-1.5 text-sm font-medium ${
selectedPlanId === p.id
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "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-xs text-zinc-400 hover:text-red-600"
aria-label="Plan loeschen"
>
</button>
)}
</div>
))}
<div className="relative">
<button
type="button"
onClick={() => setShowNewPlan((v) => !v)}
className="rounded-md px-2 py-1.5 text-sm text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
+ 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)}
/>
)}
</div>
{detail && detail.plan.phases.length > 0 && (
<div className="relative">
<button
type="button"
onClick={() => setShowScenario((v) => !v)}
className="rounded-md px-2 py-1.5 text-sm text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
+ 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>
)}
</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} />
)}
</div>
);
})}
</div>
<button
type="button"
onClick={handleAddPhase}
className="self-start rounded-md border border-zinc-300 px-3 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
+ 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-md border border-zinc-200 bg-white p-3 shadow-lg dark:border-zinc-700 dark:bg-zinc-900">
<input
className="mb-2 w-full rounded-md border border-zinc-300 px-2 py-1 text-sm dark:border-zinc-600 dark:bg-zinc-800"
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-md bg-zinc-900 px-2 py-1 text-xs text-white dark:bg-zinc-100 dark:text-zinc-900"
>
Erstellen
</button>
<button type="button" onClick={onClose} className="text-xs text-zinc-500">
Abbrechen
</button>
</div>
</div>
);
}
function ScenarioPopover({
phases,
onCreate,
onClose,
}: {
phases: { id: string; name: string }[];
onCreate: (name: string, branchFromPhaseId: string) => void;
onClose: () => void;
}) {
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-md border border-zinc-200 bg-white p-3 shadow-lg dark:border-zinc-700 dark:bg-zinc-900">
<input
className="mb-2 w-full rounded-md border border-zinc-300 px-2 py-1 text-sm dark:border-zinc-600 dark:bg-zinc-800"
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-md border border-zinc-300 px-2 py-1 text-sm dark:border-zinc-600 dark:bg-zinc-800"
value={branchFromPhaseId}
onChange={(e) => setBranchFromPhaseId(e.target.value)}
>
{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-md bg-zinc-900 px-2 py-1 text-xs text-white dark:bg-zinc-100 dark:text-zinc-900"
>
Erstellen
</button>
<button type="button" onClick={onClose} className="text-xs text-zinc-500">
Abbrechen
</button>
</div>
</div>
);
}