361 lines
15 KiB
TypeScript
361 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { FormEvent, useEffect, useMemo, useState } from "react";
|
|
import { AppShell } from "../../../components/app-shell";
|
|
import { apiFetch } from "@/lib/api";
|
|
|
|
type Household = {
|
|
id: string;
|
|
name: string;
|
|
members?: HouseholdMember[];
|
|
};
|
|
|
|
type HouseholdMember = {
|
|
id: string;
|
|
userId: string;
|
|
role: string;
|
|
joinedAt: string;
|
|
user?: {
|
|
email: string;
|
|
fullName?: string | null;
|
|
};
|
|
};
|
|
|
|
type HouseholdAccount = {
|
|
displayId: string;
|
|
institutionName: string;
|
|
accountType: string;
|
|
mask?: string | null;
|
|
currentBalance: number;
|
|
availableBalance: number;
|
|
isoCurrencyCode: string;
|
|
ownerUserId?: string | null;
|
|
ownershipType: "mine" | "theirs" | "joint" | string;
|
|
lastBalanceSync?: string | null;
|
|
syncStatus: string;
|
|
};
|
|
|
|
type CashflowMonth = {
|
|
month: string;
|
|
income: number;
|
|
expenses: number;
|
|
net: number;
|
|
transactionCount: number;
|
|
};
|
|
|
|
type RecentTransaction = {
|
|
date: string;
|
|
description: string;
|
|
amount: number;
|
|
source: string;
|
|
category: string;
|
|
account?: {
|
|
institutionName?: string;
|
|
mask?: string | null;
|
|
ownerUserId?: string | null;
|
|
ownershipType?: string;
|
|
};
|
|
};
|
|
|
|
type DashboardData = {
|
|
household: Household;
|
|
members: HouseholdMember[];
|
|
summary: {
|
|
memberCount: number;
|
|
accountCount: number;
|
|
totalBalance: number;
|
|
availableBalance: number;
|
|
monthlyIncome: number;
|
|
monthlyExpenses: number;
|
|
monthlyNet: number;
|
|
};
|
|
ownershipBreakdown: Record<string, { accountCount: number; balance: number }>;
|
|
accounts: HouseholdAccount[];
|
|
cashflow: CashflowMonth[];
|
|
recentTransactions: RecentTransaction[];
|
|
};
|
|
|
|
const money = new Intl.NumberFormat("en-US", {
|
|
style: "currency",
|
|
currency: "USD",
|
|
});
|
|
|
|
const inputClass = "w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20";
|
|
const cardClass = "rounded-xl border border-border bg-secondary/10 p-5";
|
|
|
|
function formatMoney(value: number) {
|
|
return money.format(value || 0);
|
|
}
|
|
|
|
function monthLabel(value: string) {
|
|
const [year, month] = value.split("-").map(Number);
|
|
return new Date(year, month - 1, 1).toLocaleDateString("en-US", { month: "short" });
|
|
}
|
|
|
|
function accountLabel(account: HouseholdAccount | RecentTransaction["account"]) {
|
|
if (!account) return "Household account";
|
|
const mask = account.mask ? ` ending ${account.mask}` : "";
|
|
return `${account.institutionName ?? "Account"}${mask}`;
|
|
}
|
|
|
|
export default function HouseholdSettingsPage() {
|
|
const [households, setHouseholds] = useState<Household[]>([]);
|
|
const [selectedId, setSelectedId] = useState("");
|
|
const [dashboard, setDashboard] = useState<DashboardData | null>(null);
|
|
const [newName, setNewName] = useState("");
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [status, setStatus] = useState("");
|
|
|
|
const selectedHousehold = useMemo(
|
|
() => households.find((household) => household.id === selectedId) ?? null,
|
|
[households, selectedId],
|
|
);
|
|
|
|
useEffect(() => {
|
|
loadHouseholds();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!selectedId) {
|
|
setDashboard(null);
|
|
return;
|
|
}
|
|
loadDashboard(selectedId);
|
|
}, [selectedId]);
|
|
|
|
const loadHouseholds = async () => {
|
|
setLoading(true);
|
|
const res = await apiFetch<Household[]>("/api/households");
|
|
setLoading(false);
|
|
if (res.error) {
|
|
setStatus(res.error.message ?? "Unable to load households.");
|
|
return;
|
|
}
|
|
const list = res.data ?? [];
|
|
setHouseholds(list);
|
|
setSelectedId((current) => current || list[0]?.id || "");
|
|
};
|
|
|
|
const loadDashboard = async (id: string) => {
|
|
setStatus("Loading shared dashboard...");
|
|
const res = await apiFetch<DashboardData>(`/api/households/${id}/dashboard`);
|
|
if (res.error) {
|
|
setDashboard(null);
|
|
setStatus(res.error.message ?? "Unable to load shared dashboard.");
|
|
return;
|
|
}
|
|
setDashboard(res.data);
|
|
setStatus("");
|
|
};
|
|
|
|
const createHousehold = async (event: FormEvent) => {
|
|
event.preventDefault();
|
|
const name = newName.trim();
|
|
if (!name) return;
|
|
setSaving(true);
|
|
const res = await apiFetch<Household>("/api/households", {
|
|
method: "POST",
|
|
body: JSON.stringify({ name }),
|
|
});
|
|
setSaving(false);
|
|
if (res.error) {
|
|
setStatus(res.error.message ?? "Unable to create household.");
|
|
return;
|
|
}
|
|
setNewName("");
|
|
await loadHouseholds();
|
|
if (res.data?.id) setSelectedId(res.data.id);
|
|
};
|
|
|
|
const maxCashflow = Math.max(
|
|
1,
|
|
...(dashboard?.cashflow ?? []).map((month) => Math.max(month.income, month.expenses)),
|
|
);
|
|
|
|
return (
|
|
<AppShell title="Households" subtitle="Shared financial dashboard for partner and family money.">
|
|
<div className="space-y-6">
|
|
<div className="glass-panel rounded-2xl p-6 shadow-sm">
|
|
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
|
|
<div>
|
|
<p className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Active household</p>
|
|
<div className="mt-3 flex flex-col gap-3 sm:flex-row">
|
|
<select value={selectedId} onChange={(event) => setSelectedId(event.target.value)} className={inputClass}>
|
|
{households.map((household) => (
|
|
<option key={household.id} value={household.id}>
|
|
{household.name}
|
|
</option>
|
|
))}
|
|
{!households.length && <option value="">No households yet</option>}
|
|
</select>
|
|
<button
|
|
type="button"
|
|
onClick={() => selectedId && loadDashboard(selectedId)}
|
|
disabled={!selectedId}
|
|
className="rounded-lg border border-border px-4 py-2 text-sm font-semibold text-foreground hover:bg-secondary disabled:opacity-50"
|
|
>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
{selectedHousehold && (
|
|
<p className="mt-3 text-sm text-muted-foreground">
|
|
{selectedHousehold.name} combines joint, mine, and partner-owned accounts without exposing stable account IDs in the page data.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<form onSubmit={createHousehold} className="rounded-xl border border-border bg-background/60 p-4">
|
|
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Create household</label>
|
|
<input
|
|
value={newName}
|
|
onChange={(event) => setNewName(event.target.value)}
|
|
placeholder="Household name"
|
|
className={`${inputClass} mt-2`}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={saving || !newName.trim()}
|
|
className="mt-3 w-full rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
>
|
|
{saving ? "Creating..." : "Create"}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
{status && <p className="mt-4 text-sm text-muted-foreground">{status}</p>}
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="glass-panel rounded-2xl p-8 text-sm text-muted-foreground">Loading households...</div>
|
|
) : dashboard ? (
|
|
<>
|
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
<div className={cardClass}>
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total balance</p>
|
|
<p className="mt-2 text-3xl font-bold text-foreground">{formatMoney(dashboard.summary.totalBalance)}</p>
|
|
<p className="mt-1 text-sm text-muted-foreground">{dashboard.summary.accountCount} shared accounts</p>
|
|
</div>
|
|
<div className={cardClass}>
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Available</p>
|
|
<p className="mt-2 text-3xl font-bold text-foreground">{formatMoney(dashboard.summary.availableBalance)}</p>
|
|
<p className="mt-1 text-sm text-muted-foreground">Current liquid view</p>
|
|
</div>
|
|
<div className={cardClass}>
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Monthly net</p>
|
|
<p className={`mt-2 text-3xl font-bold ${dashboard.summary.monthlyNet >= 0 ? "text-green-500" : "text-red-500"}`}>
|
|
{formatMoney(dashboard.summary.monthlyNet)}
|
|
</p>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
{formatMoney(dashboard.summary.monthlyIncome)} in, {formatMoney(dashboard.summary.monthlyExpenses)} out
|
|
</p>
|
|
</div>
|
|
<div className={cardClass}>
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Members</p>
|
|
<p className="mt-2 text-3xl font-bold text-foreground">{dashboard.summary.memberCount}</p>
|
|
<p className="mt-1 text-sm text-muted-foreground">Active household access</p>
|
|
</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">
|
|
<h2 className="text-lg font-bold text-foreground">Accounts by ownership</h2>
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Mine / theirs / joint</span>
|
|
</div>
|
|
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
|
{(["mine", "theirs", "joint"] as const).map((key) => {
|
|
const item = dashboard.ownershipBreakdown[key] ?? { accountCount: 0, balance: 0 };
|
|
return (
|
|
<div key={key} className="rounded-xl border border-border bg-background/50 p-4">
|
|
<p className="text-sm font-semibold capitalize text-foreground">{key}</p>
|
|
<p className="mt-2 text-2xl font-bold text-foreground">{formatMoney(item.balance)}</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">{item.accountCount} accounts</p>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="mt-5 space-y-3">
|
|
{dashboard.accounts.map((account) => (
|
|
<div key={account.displayId} className="flex flex-col gap-3 rounded-xl border border-border bg-background/40 p-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<p className="font-semibold text-foreground">{accountLabel(account)}</p>
|
|
<p className="text-sm capitalize text-muted-foreground">
|
|
{account.accountType} · {account.ownershipType} · {account.syncStatus}
|
|
</p>
|
|
</div>
|
|
<p className="text-lg font-bold text-foreground">{formatMoney(account.currentBalance)}</p>
|
|
</div>
|
|
))}
|
|
{!dashboard.accounts.length && <p className="text-sm text-muted-foreground">No household accounts linked yet.</p>}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="glass-panel rounded-2xl p-6 shadow-sm">
|
|
<h2 className="text-lg font-bold text-foreground">Members</h2>
|
|
<div className="mt-4 space-y-3">
|
|
{dashboard.members.map((member) => (
|
|
<div key={member.id} className="flex items-center justify-between gap-3 rounded-xl border border-border bg-background/40 p-4">
|
|
<div className="min-w-0">
|
|
<p className="truncate font-semibold text-foreground">{member.user?.fullName || member.user?.email || member.userId}</p>
|
|
<p className="truncate text-sm text-muted-foreground">{member.user?.email}</p>
|
|
</div>
|
|
<span className="rounded-full border border-border bg-secondary/60 px-3 py-1 text-xs font-semibold capitalize text-muted-foreground">
|
|
{member.role}
|
|
</span>
|
|
</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">
|
|
<h2 className="text-lg font-bold text-foreground">Six-month cashflow</h2>
|
|
<div className="mt-5 space-y-4">
|
|
{dashboard.cashflow.map((month) => (
|
|
<div key={month.month}>
|
|
<div className="mb-2 flex items-center justify-between text-sm">
|
|
<span className="font-semibold text-foreground">{monthLabel(month.month)}</span>
|
|
<span className={month.net >= 0 ? "text-green-500" : "text-red-500"}>{formatMoney(month.net)}</span>
|
|
</div>
|
|
<div className="grid h-2 grid-cols-2 overflow-hidden rounded-full bg-secondary">
|
|
<div className="bg-green-500" style={{ width: `${Math.max(4, (month.income / maxCashflow) * 100)}%` }} />
|
|
<div className="justify-self-end bg-red-500" style={{ width: `${Math.max(4, (month.expenses / maxCashflow) * 100)}%` }} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="glass-panel rounded-2xl p-6 shadow-sm">
|
|
<h2 className="text-lg font-bold text-foreground">Recent shared transactions</h2>
|
|
<div className="mt-4 divide-y divide-border">
|
|
{dashboard.recentTransactions.map((transaction, index) => (
|
|
<div key={`${transaction.date}-${index}`} className="flex flex-col gap-2 py-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="min-w-0">
|
|
<p className="truncate font-semibold text-foreground">{transaction.description}</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{new Date(transaction.date).toLocaleDateString()} · {transaction.category} · {accountLabel(transaction.account)}
|
|
</p>
|
|
</div>
|
|
<p className={`font-bold ${transaction.amount < 0 ? "text-green-500" : "text-foreground"}`}>
|
|
{formatMoney(transaction.amount)}
|
|
</p>
|
|
</div>
|
|
))}
|
|
{!dashboard.recentTransactions.length && <p className="py-4 text-sm text-muted-foreground">No shared transactions yet.</p>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="glass-panel rounded-2xl p-8 text-sm text-muted-foreground">
|
|
Create a household to start viewing shared accounts and cashflow.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</AppShell>
|
|
);
|
|
}
|