From eddd46c049e331f3d65e1e2220a18af3f136fb31 Mon Sep 17 00:00:00 2001 From: MOHAN Date: Fri, 17 Jul 2026 23:22:44 +0530 Subject: [PATCH] Add planning workspace UI --- app/api/planning/budgets/[id]/route.ts | 6 + app/api/planning/budgets/route.ts | 10 + app/api/planning/goals/[id]/route.ts | 6 + app/api/planning/goals/route.ts | 10 + app/api/planning/investments/route.ts | 10 + app/api/planning/net-worth/route.ts | 6 + app/api/planning/net-worth/snapshots/route.ts | 6 + app/api/planning/recurring/detect/route.ts | 6 + app/api/planning/recurring/route.ts | 6 + app/planning/page.tsx | 222 ++++++++++++++++++ components/app-shell.tsx | 1 + 11 files changed, 289 insertions(+) create mode 100644 app/api/planning/budgets/[id]/route.ts create mode 100644 app/api/planning/budgets/route.ts create mode 100644 app/api/planning/goals/[id]/route.ts create mode 100644 app/api/planning/goals/route.ts create mode 100644 app/api/planning/investments/route.ts create mode 100644 app/api/planning/net-worth/route.ts create mode 100644 app/api/planning/net-worth/snapshots/route.ts create mode 100644 app/api/planning/recurring/detect/route.ts create mode 100644 app/api/planning/recurring/route.ts create mode 100644 app/planning/page.tsx diff --git a/app/api/planning/budgets/[id]/route.ts b/app/api/planning/budgets/[id]/route.ts new file mode 100644 index 0000000..1fc7273 --- /dev/null +++ b/app/api/planning/budgets/[id]/route.ts @@ -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}`); +} diff --git a/app/api/planning/budgets/route.ts b/app/api/planning/budgets/route.ts new file mode 100644 index 0000000..36bd76b --- /dev/null +++ b/app/api/planning/budgets/route.ts @@ -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"); +} diff --git a/app/api/planning/goals/[id]/route.ts b/app/api/planning/goals/[id]/route.ts new file mode 100644 index 0000000..71f91d8 --- /dev/null +++ b/app/api/planning/goals/[id]/route.ts @@ -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}`); +} diff --git a/app/api/planning/goals/route.ts b/app/api/planning/goals/route.ts new file mode 100644 index 0000000..aa37f13 --- /dev/null +++ b/app/api/planning/goals/route.ts @@ -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"); +} diff --git a/app/api/planning/investments/route.ts b/app/api/planning/investments/route.ts new file mode 100644 index 0000000..c56aacf --- /dev/null +++ b/app/api/planning/investments/route.ts @@ -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"); +} diff --git a/app/api/planning/net-worth/route.ts b/app/api/planning/net-worth/route.ts new file mode 100644 index 0000000..6e0c337 --- /dev/null +++ b/app/api/planning/net-worth/route.ts @@ -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"); +} diff --git a/app/api/planning/net-worth/snapshots/route.ts b/app/api/planning/net-worth/snapshots/route.ts new file mode 100644 index 0000000..e062e51 --- /dev/null +++ b/app/api/planning/net-worth/snapshots/route.ts @@ -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"); +} diff --git a/app/api/planning/recurring/detect/route.ts b/app/api/planning/recurring/detect/route.ts new file mode 100644 index 0000000..06526d3 --- /dev/null +++ b/app/api/planning/recurring/detect/route.ts @@ -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"); +} diff --git a/app/api/planning/recurring/route.ts b/app/api/planning/recurring/route.ts new file mode 100644 index 0000000..7d2eefd --- /dev/null +++ b/app/api/planning/recurring/route.ts @@ -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"); +} diff --git a/app/planning/page.tsx b/app/planning/page.tsx new file mode 100644 index 0000000..ac81d73 --- /dev/null +++ b/app/planning/page.tsx @@ -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 }; 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([]); + const [householdId, setHouseholdId] = useState(""); + const [budgets, setBudgets] = useState([]); + const [goals, setGoals] = useState([]); + const [investments, setInvestments] = useState({ holdings: [], totalMarketValue: 0 }); + const [netWorth, setNetWorth] = useState(null); + const [recurring, setRecurring] = useState([]); + 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("/api/households"), + apiFetch("/api/planning/goals"), + apiFetch("/api/planning/investments"), + apiFetch("/api/planning/net-worth"), + apiFetch("/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(`/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("/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("/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 ( + +
+ {status ?
{status}
: null} +
+
+
Net worth
+
${netWorth?.computed.netWorth?.toLocaleString() ?? "0"}
+ +
+
+
Investment value
+
${investments.totalMarketValue.toLocaleString()}
+
+
+
Recurring detected
+
{recurring.length}
+ +
+
+ +
+
+

Shared budgets

+ +
+ setBudgetForm({ ...budgetForm, name: e.target.value })} required /> + setBudgetForm({ ...budgetForm, category: e.target.value })} /> + setBudgetForm({ ...budgetForm, limitAmount: e.target.value })} /> + setBudgetForm({ ...budgetForm, spentAmount: e.target.value })} /> + +
+
+ {budgets.map((budget) => ( +
+
{budget.name}{budget.progressPercent}%
+
${Number(budget.spentAmount).toLocaleString()} of ${Number(budget.limitAmount).toLocaleString()} spent
+
+ ))} +
+
+ +
+

Personal goals

+
+ setGoalForm({ ...goalForm, name: e.target.value })} required /> + setGoalForm({ ...goalForm, targetAmount: e.target.value })} /> + setGoalForm({ ...goalForm, currentAmount: e.target.value })} /> + +
+
+ {goals.map((goal) => ( +
+
{goal.name}{goal.progressPercent}%
+
${Number(goal.currentAmount).toLocaleString()} of ${Number(goal.targetAmount).toLocaleString()}
+
+ ))} +
+
+
+ +
+
+

Investments

+
+ setInvestmentForm({ ...investmentForm, symbol: e.target.value })} required /> + setInvestmentForm({ ...investmentForm, name: e.target.value })} required /> + setInvestmentForm({ ...investmentForm, quantity: e.target.value })} /> + setInvestmentForm({ ...investmentForm, price: e.target.value })} /> + +
+
+ {investments.holdings.map((holding) => ( +
+
{holding.symbol}${Number(holding.marketValue).toLocaleString()}
+
{holding.name}
+
+ ))} +
+
+ +
+

Recurring transactions

+
+ {recurring.map((item) => ( +
+
{item.merchant}{item.cadence}
+
${Number(item.averageAmount).toLocaleString()} average ยท {Math.round(Number(item.confidence) * 100)}% confidence
+
+ ))} + {!recurring.length ?
Run detection after importing transactions.
: null} +
+
+
+
+
+ ); +} diff --git a/components/app-shell.tsx b/components/app-shell.tsx index 532c0bc..2584e77 100644 --- a/components/app-shell.tsx +++ b/components/app-shell.tsx @@ -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" },