2026-07-17 23:22:44 +05:30

223 lines
13 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { AppShell } from "../../components/app-shell";
import { apiFetch } from "@/lib/api";
type Household = { id: string; name: string };
type Budget = { id: string; name: string; category?: string | null; limitAmount: number; spentAmount: number; progressPercent: number; remainingAmount: number; status: string };
type Goal = { id: string; name: string; targetAmount: number; currentAmount: number; progressPercent: number; remainingAmount: number; status: string };
type InvestmentSummary = { holdings: Array<{ id: string; symbol: string; name: string; marketValue: number; quantity: number; price: number }>; totalMarketValue: number };
type NetWorthSummary = { computed: { assets: number; liabilities: number; netWorth: number; breakdown: Record<string, number> }; history: Array<{ id: string; snapshotDate: string; netWorth: number }> };
type Recurring = { id: string; merchant: string; cadence: string; averageAmount: number; nextExpectedDate?: string | null; confidence: number };
export default function PlanningPage() {
const [households, setHouseholds] = useState<Household[]>([]);
const [householdId, setHouseholdId] = useState("");
const [budgets, setBudgets] = useState<Budget[]>([]);
const [goals, setGoals] = useState<Goal[]>([]);
const [investments, setInvestments] = useState<InvestmentSummary>({ holdings: [], totalMarketValue: 0 });
const [netWorth, setNetWorth] = useState<NetWorthSummary | null>(null);
const [recurring, setRecurring] = useState<Recurring[]>([]);
const [status, setStatus] = useState("");
const [budgetForm, setBudgetForm] = useState({ name: "", category: "", limitAmount: "500", spentAmount: "0" });
const [goalForm, setGoalForm] = useState({ name: "", targetAmount: "1000", currentAmount: "0" });
const [investmentForm, setInvestmentForm] = useState({ symbol: "", name: "", quantity: "1", price: "100" });
const load = async (selectedHousehold = householdId) => {
const [householdRes, goalRes, investmentRes, netWorthRes, recurringRes] = await Promise.all([
apiFetch<Household[]>("/api/households"),
apiFetch<Goal[]>("/api/planning/goals"),
apiFetch<InvestmentSummary>("/api/planning/investments"),
apiFetch<NetWorthSummary>("/api/planning/net-worth"),
apiFetch<Recurring[]>("/api/planning/recurring"),
]);
if (!householdRes.error && householdRes.data) {
setHouseholds(householdRes.data);
if (!selectedHousehold && householdRes.data[0]?.id) {
selectedHousehold = householdRes.data[0].id;
setHouseholdId(selectedHousehold);
}
}
if (!goalRes.error && goalRes.data) setGoals(goalRes.data);
if (!investmentRes.error && investmentRes.data) setInvestments(investmentRes.data);
if (!netWorthRes.error && netWorthRes.data) setNetWorth(netWorthRes.data);
if (!recurringRes.error && recurringRes.data) setRecurring(recurringRes.data);
if (selectedHousehold) {
const budgetRes = await apiFetch<Budget[]>(`/api/planning/budgets?householdId=${encodeURIComponent(selectedHousehold)}`);
if (!budgetRes.error && budgetRes.data) setBudgets(budgetRes.data);
}
};
useEffect(() => {
load();
}, []);
const createBudget = async (event: React.FormEvent) => {
event.preventDefault();
if (!householdId) return setStatus("Create or select a household first.");
const res = await apiFetch<Budget>("/api/planning/budgets", {
method: "POST",
body: JSON.stringify({
householdId,
name: budgetForm.name,
category: budgetForm.category || undefined,
limitAmount: Number(budgetForm.limitAmount),
spentAmount: Number(budgetForm.spentAmount),
}),
});
setStatus(res.error ? res.error.message ?? "Budget create failed." : "Budget created.");
if (!res.error) {
setBudgetForm({ name: "", category: "", limitAmount: "500", spentAmount: "0" });
load(householdId);
}
};
const createGoal = async (event: React.FormEvent) => {
event.preventDefault();
const res = await apiFetch<Goal>("/api/planning/goals", {
method: "POST",
body: JSON.stringify({ name: goalForm.name, targetAmount: Number(goalForm.targetAmount), currentAmount: Number(goalForm.currentAmount) }),
});
setStatus(res.error ? res.error.message ?? "Goal create failed." : "Goal created.");
if (!res.error) {
setGoalForm({ name: "", targetAmount: "1000", currentAmount: "0" });
load(householdId);
}
};
const createInvestment = async (event: React.FormEvent) => {
event.preventDefault();
const res = await apiFetch("/api/planning/investments", {
method: "POST",
body: JSON.stringify({
symbol: investmentForm.symbol,
name: investmentForm.name,
quantity: Number(investmentForm.quantity),
price: Number(investmentForm.price),
}),
});
setStatus(res.error ? res.error.message ?? "Investment create failed." : "Investment added.");
if (!res.error) {
setInvestmentForm({ symbol: "", name: "", quantity: "1", price: "100" });
load(householdId);
}
};
const snapshotNetWorth = async () => {
const res = await apiFetch("/api/planning/net-worth/snapshots", { method: "POST", body: JSON.stringify({}) });
setStatus(res.error ? res.error.message ?? "Snapshot failed." : "Net worth snapshot saved.");
if (!res.error) load(householdId);
};
const detectRecurring = async () => {
const res = await apiFetch<{ detected: number }>("/api/planning/recurring/detect", { method: "POST", body: JSON.stringify({}) });
setStatus(res.error ? res.error.message ?? "Detection failed." : `Detected ${res.data?.detected ?? 0} recurring transactions.`);
if (!res.error) load(householdId);
};
const input = "rounded-lg border border-border bg-background/50 px-3 py-2 text-sm";
const card = "rounded-lg border border-border bg-background/40 p-4";
return (
<AppShell title="Planning" subtitle="Budgets, goals, investments, net worth, and recurring transactions.">
<div className="grid gap-6">
{status ? <div className="rounded-lg border border-accent/20 bg-accent/10 p-3 text-sm">{status}</div> : null}
<section className="grid gap-4 md:grid-cols-3">
<div className={card}>
<div className="text-sm text-muted-foreground">Net worth</div>
<div className="mt-2 text-3xl font-bold">${netWorth?.computed.netWorth?.toLocaleString() ?? "0"}</div>
<button onClick={snapshotNetWorth} className="mt-4 rounded-lg bg-primary px-3 py-2 text-sm font-bold text-primary-foreground">Save snapshot</button>
</div>
<div className={card}>
<div className="text-sm text-muted-foreground">Investment value</div>
<div className="mt-2 text-3xl font-bold">${investments.totalMarketValue.toLocaleString()}</div>
</div>
<div className={card}>
<div className="text-sm text-muted-foreground">Recurring detected</div>
<div className="mt-2 text-3xl font-bold">{recurring.length}</div>
<button onClick={detectRecurring} className="mt-4 rounded-lg bg-secondary px-3 py-2 text-sm font-bold">Run detection</button>
</div>
</section>
<section className="grid gap-6 lg:grid-cols-2">
<div className="glass-panel rounded-2xl p-6">
<h2 className="text-xl font-bold">Shared budgets</h2>
<select className={`${input} mt-4 w-full`} value={householdId} onChange={(e) => { setHouseholdId(e.target.value); load(e.target.value); }}>
<option value="">Select household</option>
{households.map((household) => <option key={household.id} value={household.id}>{household.name}</option>)}
</select>
<form onSubmit={createBudget} className="mt-4 grid gap-3 sm:grid-cols-2">
<input className={input} placeholder="Budget name" value={budgetForm.name} onChange={(e) => setBudgetForm({ ...budgetForm, name: e.target.value })} required />
<input className={input} placeholder="Category" value={budgetForm.category} onChange={(e) => setBudgetForm({ ...budgetForm, category: e.target.value })} />
<input className={input} type="number" min="0" step="0.01" value={budgetForm.limitAmount} onChange={(e) => setBudgetForm({ ...budgetForm, limitAmount: e.target.value })} />
<input className={input} type="number" min="0" step="0.01" value={budgetForm.spentAmount} onChange={(e) => setBudgetForm({ ...budgetForm, spentAmount: e.target.value })} />
<button className="rounded-lg bg-primary px-3 py-2 text-sm font-bold text-primary-foreground sm:col-span-2">Add budget</button>
</form>
<div className="mt-4 space-y-2">
{budgets.map((budget) => (
<div key={budget.id} className={card}>
<div className="flex justify-between gap-3"><b>{budget.name}</b><span>{budget.progressPercent}%</span></div>
<div className="text-sm text-muted-foreground">${Number(budget.spentAmount).toLocaleString()} of ${Number(budget.limitAmount).toLocaleString()} spent</div>
</div>
))}
</div>
</div>
<div className="glass-panel rounded-2xl p-6">
<h2 className="text-xl font-bold">Personal goals</h2>
<form onSubmit={createGoal} className="mt-4 grid gap-3 sm:grid-cols-3">
<input className={`${input} sm:col-span-3`} placeholder="Goal name" value={goalForm.name} onChange={(e) => setGoalForm({ ...goalForm, name: e.target.value })} required />
<input className={input} type="number" min="0" step="0.01" value={goalForm.targetAmount} onChange={(e) => setGoalForm({ ...goalForm, targetAmount: e.target.value })} />
<input className={input} type="number" min="0" step="0.01" value={goalForm.currentAmount} onChange={(e) => setGoalForm({ ...goalForm, currentAmount: e.target.value })} />
<button className="rounded-lg bg-primary px-3 py-2 text-sm font-bold text-primary-foreground">Add goal</button>
</form>
<div className="mt-4 space-y-2">
{goals.map((goal) => (
<div key={goal.id} className={card}>
<div className="flex justify-between gap-3"><b>{goal.name}</b><span>{goal.progressPercent}%</span></div>
<div className="text-sm text-muted-foreground">${Number(goal.currentAmount).toLocaleString()} of ${Number(goal.targetAmount).toLocaleString()}</div>
</div>
))}
</div>
</div>
</section>
<section className="grid gap-6 lg:grid-cols-2">
<div className="glass-panel rounded-2xl p-6">
<h2 className="text-xl font-bold">Investments</h2>
<form onSubmit={createInvestment} className="mt-4 grid gap-3 sm:grid-cols-2">
<input className={input} placeholder="Symbol" value={investmentForm.symbol} onChange={(e) => setInvestmentForm({ ...investmentForm, symbol: e.target.value })} required />
<input className={input} placeholder="Name" value={investmentForm.name} onChange={(e) => setInvestmentForm({ ...investmentForm, name: e.target.value })} required />
<input className={input} type="number" min="0" step="0.0001" value={investmentForm.quantity} onChange={(e) => setInvestmentForm({ ...investmentForm, quantity: e.target.value })} />
<input className={input} type="number" min="0" step="0.01" value={investmentForm.price} onChange={(e) => setInvestmentForm({ ...investmentForm, price: e.target.value })} />
<button className="rounded-lg bg-primary px-3 py-2 text-sm font-bold text-primary-foreground sm:col-span-2">Add holding</button>
</form>
<div className="mt-4 space-y-2">
{investments.holdings.map((holding) => (
<div key={holding.id} className={card}>
<div className="flex justify-between gap-3"><b>{holding.symbol}</b><span>${Number(holding.marketValue).toLocaleString()}</span></div>
<div className="text-sm text-muted-foreground">{holding.name}</div>
</div>
))}
</div>
</div>
<div className="glass-panel rounded-2xl p-6">
<h2 className="text-xl font-bold">Recurring transactions</h2>
<div className="mt-4 space-y-2">
{recurring.map((item) => (
<div key={item.id} className={card}>
<div className="flex justify-between gap-3"><b className="capitalize">{item.merchant}</b><span>{item.cadence}</span></div>
<div className="text-sm text-muted-foreground">${Number(item.averageAmount).toLocaleString()} average · {Math.round(Number(item.confidence) * 100)}% confidence</div>
</div>
))}
{!recurring.length ? <div className="text-sm text-muted-foreground">Run detection after importing transactions.</div> : null}
</div>
</div>
</section>
</div>
</AppShell>
);
}