From 59ea77ed448ba32cc20af5e509570470b2fa0d03 Mon Sep 17 00:00:00 2001 From: MOHAN Date: Thu, 16 Jul 2026 15:33:57 +0530 Subject: [PATCH] feat: add household goals UI --- .../households/[id]/goals/[goalId]/route.ts | 10 + app/api/households/[id]/goals/route.ts | 14 ++ app/settings/households/page.tsx | 236 ++++++++++++++++++ 3 files changed, 260 insertions(+) create mode 100644 app/api/households/[id]/goals/[goalId]/route.ts create mode 100644 app/api/households/[id]/goals/route.ts diff --git a/app/api/households/[id]/goals/[goalId]/route.ts b/app/api/households/[id]/goals/[goalId]/route.ts new file mode 100644 index 0000000..3d6bea2 --- /dev/null +++ b/app/api/households/[id]/goals/[goalId]/route.ts @@ -0,0 +1,10 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +type RouteContext = { + params: { id: string; goalId: string }; +}; + +export async function PATCH(req: NextRequest, { params }: RouteContext) { + return proxyRequest(req, `households/${params.id}/goals/${params.goalId}`); +} diff --git a/app/api/households/[id]/goals/route.ts b/app/api/households/[id]/goals/route.ts new file mode 100644 index 0000000..e9178f2 --- /dev/null +++ b/app/api/households/[id]/goals/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +type RouteContext = { + params: { id: string }; +}; + +export async function GET(req: NextRequest, { params }: RouteContext) { + return proxyRequest(req, `households/${params.id}/goals`); +} + +export async function POST(req: NextRequest, { params }: RouteContext) { + return proxyRequest(req, `households/${params.id}/goals`); +} diff --git a/app/settings/households/page.tsx b/app/settings/households/page.tsx index 7048e39..2ab3627 100644 --- a/app/settings/households/page.tsx +++ b/app/settings/households/page.tsx @@ -57,6 +57,24 @@ type RecentTransaction = { }; }; +type HouseholdGoal = { + id: string; + name: string; + description?: string | null; + targetAmount: number; + currentAmount: number; + remainingAmount: number; + progressPercent: number; + isoCurrencyCode: string; + targetDate?: string | null; + priority: "low" | "medium" | "high" | string; + status: "active" | "paused" | "completed" | "archived" | string; + createdBy?: { + email: string; + fullName?: string | null; + }; +}; + type DashboardData = { household: Household; members: HouseholdMember[]; @@ -103,6 +121,18 @@ export default function HouseholdSettingsPage() { const [selectedId, setSelectedId] = useState(""); const [dashboard, setDashboard] = useState(null); const [newName, setNewName] = useState(""); + const [goals, setGoals] = useState([]); + const [goalStatus, setGoalStatus] = useState(""); + const [goalSaving, setGoalSaving] = useState(false); + const [goalDraft, setGoalDraft] = useState({ + name: "", + description: "", + targetAmount: "", + currentAmount: "", + targetDate: "", + priority: "medium" as "low" | "medium" | "high", + }); + const [goalContributions, setGoalContributions] = useState>({}); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [status, setStatus] = useState(""); @@ -119,9 +149,11 @@ export default function HouseholdSettingsPage() { useEffect(() => { if (!selectedId) { setDashboard(null); + setGoals([]); return; } loadDashboard(selectedId); + loadGoals(selectedId); }, [selectedId]); const loadHouseholds = async () => { @@ -149,6 +181,16 @@ export default function HouseholdSettingsPage() { setStatus(""); }; + const loadGoals = async (id: string) => { + const res = await apiFetch(`/api/households/${id}/goals`); + if (res.error) { + setGoalStatus(res.error.message ?? "Unable to load shared goals."); + return; + } + setGoals(res.data ?? []); + setGoalStatus(""); + }; + const createHousehold = async (event: FormEvent) => { event.preventDefault(); const name = newName.trim(); @@ -168,10 +210,82 @@ export default function HouseholdSettingsPage() { if (res.data?.id) setSelectedId(res.data.id); }; + const createGoal = async (event: FormEvent) => { + event.preventDefault(); + if (!selectedId || !goalDraft.name.trim()) return; + const targetAmount = Number.parseFloat(goalDraft.targetAmount); + const currentAmount = goalDraft.currentAmount ? Number.parseFloat(goalDraft.currentAmount) : 0; + if (!Number.isFinite(targetAmount) || targetAmount <= 0) { + setGoalStatus("Goal target must be greater than zero."); + return; + } + if (!Number.isFinite(currentAmount) || currentAmount < 0 || currentAmount > targetAmount) { + setGoalStatus("Current amount must be between zero and the target."); + return; + } + + setGoalSaving(true); + const res = await apiFetch(`/api/households/${selectedId}/goals`, { + method: "POST", + body: JSON.stringify({ + name: goalDraft.name, + description: goalDraft.description || undefined, + targetAmount, + currentAmount, + targetDate: goalDraft.targetDate || undefined, + priority: goalDraft.priority, + }), + }); + setGoalSaving(false); + if (res.error) { + setGoalStatus(res.error.message ?? "Unable to create shared goal."); + return; + } + setGoalDraft({ name: "", description: "", targetAmount: "", currentAmount: "", targetDate: "", priority: "medium" }); + await loadGoals(selectedId); + }; + + const contributeToGoal = async (goal: HouseholdGoal) => { + if (!selectedId) return; + const contribution = Number.parseFloat(goalContributions[goal.id] ?? ""); + if (!Number.isFinite(contribution) || contribution <= 0) { + setGoalStatus("Contribution must be greater than zero."); + return; + } + const nextAmount = Math.min(goal.targetAmount, goal.currentAmount + contribution); + const nextStatus = nextAmount >= goal.targetAmount ? "completed" : goal.status; + const res = await apiFetch(`/api/households/${selectedId}/goals/${goal.id}`, { + method: "PATCH", + body: JSON.stringify({ currentAmount: nextAmount, status: nextStatus }), + }); + if (res.error) { + setGoalStatus(res.error.message ?? "Unable to update shared goal."); + return; + } + setGoalContributions((prev) => ({ ...prev, [goal.id]: "" })); + await loadGoals(selectedId); + }; + + const setGoalStatusValue = async (goal: HouseholdGoal, status: "active" | "paused" | "completed" | "archived") => { + if (!selectedId) return; + const res = await apiFetch(`/api/households/${selectedId}/goals/${goal.id}`, { + method: "PATCH", + body: JSON.stringify({ status }), + }); + if (res.error) { + setGoalStatus(res.error.message ?? "Unable to update shared goal."); + return; + } + await loadGoals(selectedId); + }; + const maxCashflow = Math.max( 1, ...(dashboard?.cashflow ?? []).map((month) => Math.max(month.income, month.expenses)), ); + const activeGoalTotal = goals + .filter((goal) => goal.status !== "archived") + .reduce((sum, goal) => sum + goal.currentAmount, 0); return ( @@ -256,6 +370,128 @@ export default function HouseholdSettingsPage() { +
+
+
+
+

Shared goals

+

{formatMoney(activeGoalTotal)} saved across {goals.filter((goal) => goal.status !== "archived").length} visible goals.

+
+
+
+ setGoalDraft((prev) => ({ ...prev, name: event.target.value }))} + placeholder="Goal name" + className={inputClass} + /> + setGoalDraft((prev) => ({ ...prev, targetAmount: event.target.value }))} + placeholder="Target amount" + type="number" + min="0" + step="0.01" + className={inputClass} + /> + setGoalDraft((prev) => ({ ...prev, currentAmount: event.target.value }))} + placeholder="Current amount" + type="number" + min="0" + step="0.01" + className={inputClass} + /> + setGoalDraft((prev) => ({ ...prev, targetDate: event.target.value }))} + type="date" + className={inputClass} + /> + + +