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