Add planning workspace UI
This commit is contained in:
parent
3ed58738b1
commit
eddd46c049
6
app/api/planning/budgets/[id]/route.ts
Normal file
6
app/api/planning/budgets/[id]/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
return proxyRequest(req, `planning/budgets/${params.id}`);
|
||||
}
|
||||
10
app/api/planning/budgets/route.ts
Normal file
10
app/api/planning/budgets/route.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "planning/budgets");
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "planning/budgets");
|
||||
}
|
||||
6
app/api/planning/goals/[id]/route.ts
Normal file
6
app/api/planning/goals/[id]/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
return proxyRequest(req, `planning/goals/${params.id}`);
|
||||
}
|
||||
10
app/api/planning/goals/route.ts
Normal file
10
app/api/planning/goals/route.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "planning/goals");
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "planning/goals");
|
||||
}
|
||||
10
app/api/planning/investments/route.ts
Normal file
10
app/api/planning/investments/route.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "planning/investments");
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "planning/investments");
|
||||
}
|
||||
6
app/api/planning/net-worth/route.ts
Normal file
6
app/api/planning/net-worth/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "planning/net-worth");
|
||||
}
|
||||
6
app/api/planning/net-worth/snapshots/route.ts
Normal file
6
app/api/planning/net-worth/snapshots/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "planning/net-worth/snapshots");
|
||||
}
|
||||
6
app/api/planning/recurring/detect/route.ts
Normal file
6
app/api/planning/recurring/detect/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "planning/recurring/detect");
|
||||
}
|
||||
6
app/api/planning/recurring/route.ts
Normal file
6
app/api/planning/recurring/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "planning/recurring");
|
||||
}
|
||||
222
app/planning/page.tsx
Normal file
222
app/planning/page.tsx
Normal file
@ -0,0 +1,222 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -10,6 +10,7 @@ const navItems = [
|
||||
{ href: "/app", label: "Dashboard" },
|
||||
{ href: "/app/connect", label: "Accounts" },
|
||||
{ href: "/transactions", label: "Transactions" },
|
||||
{ href: "/planning", label: "Planning" },
|
||||
{ href: "/bills", label: "Bills" },
|
||||
{ href: "/credit-score", label: "Credit Score" },
|
||||
{ href: "/rules", label: "Rules" },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user