feat: add household goals UI
This commit is contained in:
parent
2b17c36ed2
commit
59ea77ed44
10
app/api/households/[id]/goals/[goalId]/route.ts
Normal file
10
app/api/households/[id]/goals/[goalId]/route.ts
Normal 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}`);
|
||||||
|
}
|
||||||
14
app/api/households/[id]/goals/route.ts
Normal file
14
app/api/households/[id]/goals/route.ts
Normal 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`);
|
||||||
|
}
|
||||||
@ -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">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user