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 = {
|
||||
household: Household;
|
||||
members: HouseholdMember[];
|
||||
@ -103,6 +121,18 @@ export default function HouseholdSettingsPage() {
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [dashboard, setDashboard] = useState<DashboardData | null>(null);
|
||||
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 [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<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) => {
|
||||
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<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(
|
||||
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 (
|
||||
<AppShell title="Households" subtitle="Shared financial dashboard for partner and family money.">
|
||||
@ -256,6 +370,128 @@ export default function HouseholdSettingsPage() {
|
||||
</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="glass-panel rounded-2xl p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user