feat: add household goals UI

This commit is contained in:
MOHAN 2026-07-16 15:33:57 +05:30
parent 2b17c36ed2
commit 59ea77ed44
3 changed files with 260 additions and 0 deletions

View File

@ -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}`);
}

View File

@ -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`);
}

View File

@ -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 = { type DashboardData = {
household: Household; household: Household;
members: HouseholdMember[]; members: HouseholdMember[];
@ -103,6 +121,18 @@ export default function HouseholdSettingsPage() {
const [selectedId, setSelectedId] = useState(""); const [selectedId, setSelectedId] = useState("");
const [dashboard, setDashboard] = useState<DashboardData | null>(null); const [dashboard, setDashboard] = useState<DashboardData | null>(null);
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [goals, setGoals] = useState<HouseholdGoal[]>([]);
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<Record<string, string>>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
@ -119,9 +149,11 @@ export default function HouseholdSettingsPage() {
useEffect(() => { useEffect(() => {
if (!selectedId) { if (!selectedId) {
setDashboard(null); setDashboard(null);
setGoals([]);
return; return;
} }
loadDashboard(selectedId); loadDashboard(selectedId);
loadGoals(selectedId);
}, [selectedId]); }, [selectedId]);
const loadHouseholds = async () => { const loadHouseholds = async () => {
@ -149,6 +181,16 @@ export default function HouseholdSettingsPage() {
setStatus(""); setStatus("");
}; };
const loadGoals = async (id: string) => {
const res = await apiFetch<HouseholdGoal[]>(`/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) => { const createHousehold = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
const name = newName.trim(); const name = newName.trim();
@ -168,10 +210,82 @@ export default function HouseholdSettingsPage() {
if (res.data?.id) setSelectedId(res.data.id); 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<HouseholdGoal>(`/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<HouseholdGoal>(`/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<HouseholdGoal>(`/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( const maxCashflow = Math.max(
1, 1,
...(dashboard?.cashflow ?? []).map((month) => Math.max(month.income, month.expenses)), ...(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 ( return (
<AppShell title="Households" subtitle="Shared financial dashboard for partner and family money."> <AppShell title="Households" subtitle="Shared financial dashboard for partner and family money.">
@ -256,6 +370,128 @@ export default function HouseholdSettingsPage() {
</div> </div>
</div> </div>
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
<div className="glass-panel rounded-2xl p-6 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-bold text-foreground">Shared goals</h2>
<p className="mt-1 text-sm text-muted-foreground">{formatMoney(activeGoalTotal)} saved across {goals.filter((goal) => goal.status !== "archived").length} visible goals.</p>
</div>
</div>
<form onSubmit={createGoal} className="mt-5 grid gap-3 md:grid-cols-2">
<input
value={goalDraft.name}
onChange={(event) => setGoalDraft((prev) => ({ ...prev, name: event.target.value }))}
placeholder="Goal name"
className={inputClass}
/>
<input
value={goalDraft.targetAmount}
onChange={(event) => setGoalDraft((prev) => ({ ...prev, targetAmount: event.target.value }))}
placeholder="Target amount"
type="number"
min="0"
step="0.01"
className={inputClass}
/>
<input
value={goalDraft.currentAmount}
onChange={(event) => setGoalDraft((prev) => ({ ...prev, currentAmount: event.target.value }))}
placeholder="Current amount"
type="number"
min="0"
step="0.01"
className={inputClass}
/>
<input
value={goalDraft.targetDate}
onChange={(event) => setGoalDraft((prev) => ({ ...prev, targetDate: event.target.value }))}
type="date"
className={inputClass}
/>
<select
value={goalDraft.priority}
onChange={(event) => setGoalDraft((prev) => ({ ...prev, priority: event.target.value as "low" | "medium" | "high" }))}
className={inputClass}
>
<option value="low">Low priority</option>
<option value="medium">Medium priority</option>
<option value="high">High priority</option>
</select>
<button
type="submit"
disabled={goalSaving || !goalDraft.name.trim()}
className="rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{goalSaving ? "Creating..." : "Create goal"}
</button>
<textarea
value={goalDraft.description}
onChange={(event) => setGoalDraft((prev) => ({ ...prev, description: event.target.value }))}
placeholder="Description"
className={`${inputClass} md:col-span-2 min-h-20`}
/>
</form>
{goalStatus && <p className="mt-3 text-sm text-muted-foreground">{goalStatus}</p>}
</div>
<div className="glass-panel rounded-2xl p-6 shadow-sm">
<h2 className="text-lg font-bold text-foreground">Goal progress</h2>
<div className="mt-4 space-y-3">
{goals.filter((goal) => goal.status !== "archived").map((goal) => (
<div key={goal.id} className="rounded-xl border border-border bg-background/40 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="font-semibold text-foreground">{goal.name}</p>
<span className="rounded-full border border-border bg-secondary/60 px-2 py-0.5 text-[11px] font-semibold capitalize text-muted-foreground">{goal.priority}</span>
<span className="rounded-full border border-border bg-background px-2 py-0.5 text-[11px] font-semibold capitalize text-muted-foreground">{goal.status}</span>
</div>
{goal.description && <p className="mt-1 text-sm text-muted-foreground">{goal.description}</p>}
<p className="mt-1 text-xs text-muted-foreground">
{formatMoney(goal.currentAmount)} of {formatMoney(goal.targetAmount)}
{goal.targetDate ? ` by ${new Date(goal.targetDate).toLocaleDateString()}` : ""}
</p>
</div>
<p className="text-lg font-bold text-foreground">{goal.progressPercent.toFixed(0)}%</p>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-secondary">
<div className="h-full bg-primary" style={{ width: `${Math.min(goal.progressPercent, 100)}%` }} />
</div>
<div className="mt-3 flex flex-col gap-2 sm:flex-row">
<input
type="number"
min="0"
step="0.01"
value={goalContributions[goal.id] ?? ""}
onChange={(event) => setGoalContributions((prev) => ({ ...prev, [goal.id]: event.target.value }))}
placeholder="Add amount"
className={inputClass}
/>
<button
type="button"
onClick={() => contributeToGoal(goal)}
className="rounded-lg border border-border px-4 py-2 text-sm font-semibold text-foreground hover:bg-secondary"
>
Add
</button>
{goal.status !== "completed" ? (
<button type="button" onClick={() => setGoalStatusValue(goal, "completed")} className="rounded-lg border border-border px-4 py-2 text-sm font-semibold text-foreground hover:bg-secondary">
Complete
</button>
) : (
<button type="button" onClick={() => setGoalStatusValue(goal, "active")} className="rounded-lg border border-border px-4 py-2 text-sm font-semibold text-foreground hover:bg-secondary">
Reopen
</button>
)}
</div>
</div>
))}
{!goals.filter((goal) => goal.status !== "archived").length && <p className="text-sm text-muted-foreground">No shared goals yet.</p>}
</div>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]"> <div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<div className="glass-panel rounded-2xl p-6 shadow-sm"> <div className="glass-panel rounded-2xl p-6 shadow-sm">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">