1507 lines
73 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;
status?: string;
joinedAt: string;
user?: {
email: string;
fullName?: string | null;
};
};
type HouseholdInvite = {
id: string;
email: string;
role: string;
status: string;
expiresAt: string;
acceptedAt?: string | null;
createdAt: string;
};
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 HealthScore = {
score: number;
rating: "excellent" | "strong" | "building" | "needs_attention" | string;
components: {
cashflow: number;
balanceBuffer: number;
goalProgress: number;
collaboration: number;
syncHealth: number;
};
metrics: {
savingsRate: number;
bufferMonths: number;
activeGoalCount: number;
jointAccountCount: number;
collaborativeTransactionCount: number;
};
recommendations: string[];
};
type RecentTransaction = {
date: string;
description: string;
amount: number;
source: string;
category: string;
account?: {
institutionName?: string;
mask?: string | null;
ownerUserId?: string | null;
ownershipType?: string;
};
};
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[];
privacyMode: {
enabled: boolean;
hideIndividualBalances: boolean;
hideIndividualTransactions: boolean;
};
summary: {
memberCount: number;
accountCount: number;
totalBalance: number;
availableBalance: number;
monthlyIncome: number;
monthlyExpenses: number;
monthlyNet: number;
};
ownershipBreakdown: Record<string, { accountCount: number; balance: number }>;
healthScore: HealthScore;
accounts: HouseholdAccount[];
cashflow: CashflowMonth[];
recentTransactions: RecentTransaction[];
};
type FairSplitResult = {
expenseAmount: number;
splitMode: "equal" | "income_weighted" | "custom";
mineMonthlyIncome: number;
yoursMonthlyIncome: number;
totalIncome: number;
incomeShares: {
mine: number;
yours: number;
};
split: {
minePercent: number;
yoursPercent: number;
mineAmount: number;
yoursAmount: number;
};
transactionDefaults: {
attribution: "ours";
splitMode: "equal" | "custom";
splitMinePercent: number;
splitYoursPercent: number;
};
rationale: string;
};
type MoneyDatePrompt = {
id: string;
topic: string;
question: string;
why: string;
actionLabel: string;
priority: "high" | "medium" | "low";
};
type MoneyDatePromptResponse = {
householdId: string;
generatedAt: string;
cadenceSuggestion: "weekly" | "monthly" | string;
prompts: MoneyDatePrompt[];
};
type DebtPayoffResult = {
strategy: "avalanche" | "snowball" | "custom";
monthlyExtraPayment: number;
monthlyPayment: number;
totalStartingBalance: number;
totalInterestPaid: number;
payoffMonths: number;
payoffYears: number;
contributionSplit: {
splitMode: "equal" | "income_weighted" | "custom";
minePercent: number;
yoursPercent: number;
mineAmount: number;
yoursAmount: number;
rationale: string;
};
orderedDebts: Array<{
name: string;
startingBalance: number;
annualPercentageRate: number;
minimumPayment: number;
payoffOrder: number;
}>;
timeline: Array<{
month: number;
targetDebt: string;
totalRemainingBalance: number;
interestPaid: number;
principalPaid: number;
paidOffDebts: string[];
}>;
recommendations: string[];
};
type FutureScenarioResult = {
name: string;
type: "goal" | "net_worth" | "income_change" | "expense_change";
horizonMonths: number;
startingBalance: number;
finalBalance: number;
targetAmount: number | null;
targetReachedMonth: number | null;
monthlyNetChange: number;
totalContributions: number;
eventTotal: number;
projectedGrowth: number;
milestones: Array<{ month: number; label: string; balance: number }>;
recommendation: string;
};
type FutureScenarioResponse = {
generatedAt: string;
scenarios: FutureScenarioResult[];
comparison: {
bestFinalBalance: FutureScenarioResult;
earliestTarget: FutureScenarioResult | null;
};
};
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";
const collaboratorRoles = ["admin", "member", "viewer", "accountant", "advisor"] as const;
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}`;
}
function healthRatingLabel(value: string) {
return value.replace(/_/g, " ");
}
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 [goals, setGoals] = useState<HouseholdGoal[]>([]);
const [invites, setInvites] = useState<HouseholdInvite[]>([]);
const [inviteDraft, setInviteDraft] = useState({ email: "", role: "accountant" });
const [collaborationStatus, setCollaborationStatus] = useState("");
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 [fairSplitDraft, setFairSplitDraft] = useState({
expenseAmount: "",
mineMonthlyIncome: "",
yoursMonthlyIncome: "",
splitMode: "income_weighted" as "equal" | "income_weighted" | "custom",
customMinePercent: "50",
});
const [fairSplitResult, setFairSplitResult] = useState<FairSplitResult | null>(null);
const [fairSplitStatus, setFairSplitStatus] = useState("");
const [debtDraft, setDebtDraft] = useState({
strategy: "avalanche" as "avalanche" | "snowball" | "custom",
monthlyExtraPayment: "300",
splitMode: "income_weighted" as "equal" | "income_weighted" | "custom",
mineMonthlyIncome: "",
yoursMonthlyIncome: "",
customMinePercent: "50",
debts: [
{ name: "Credit card", balance: "", annualPercentageRate: "", minimumPayment: "", priority: "1" },
{ name: "Personal loan", balance: "", annualPercentageRate: "", minimumPayment: "", priority: "2" },
],
});
const [debtPayoffResult, setDebtPayoffResult] = useState<DebtPayoffResult | null>(null);
const [debtPayoffStatus, setDebtPayoffStatus] = useState("");
const [futureDraft, setFutureDraft] = useState({
scenarios: [
{ name: "Shared milestone", type: "goal", startingBalance: "", monthlyContribution: "", monthlyIncome: "", monthlyExpenses: "", targetAmount: "", horizonMonths: "36", annualGrowthRate: "0" },
{ name: "Stretch plan", type: "net_worth", startingBalance: "", monthlyContribution: "", monthlyIncome: "", monthlyExpenses: "", targetAmount: "", horizonMonths: "36", annualGrowthRate: "3" },
],
});
const [futureResult, setFutureResult] = useState<FutureScenarioResponse | null>(null);
const [futureStatus, setFutureStatus] = useState("");
const [privacyStatus, setPrivacyStatus] = useState("");
const [moneyDatePrompts, setMoneyDatePrompts] = useState<MoneyDatePromptResponse | null>(null);
const [moneyDateStatus, setMoneyDateStatus] = 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);
setGoals([]);
setInvites([]);
setMoneyDatePrompts(null);
return;
}
loadDashboard(selectedId);
loadGoals(selectedId);
loadInvites(selectedId);
loadMoneyDatePrompts(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 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 loadInvites = async (id: string) => {
const res = await apiFetch<HouseholdInvite[]>(`/api/households/${id}/invites`);
if (res.error) {
setInvites([]);
return;
}
setInvites(res.data ?? []);
};
const loadMoneyDatePrompts = async (id: string) => {
setMoneyDateStatus("Loading prompts...");
const res = await apiFetch<MoneyDatePromptResponse>(`/api/households/${id}/money-date-prompts`);
if (res.error) {
setMoneyDatePrompts(null);
setMoneyDateStatus(res.error.message ?? "Unable to load money date prompts.");
return;
}
setMoneyDatePrompts(res.data ?? null);
setMoneyDateStatus("");
};
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 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 inviteCollaborator = async (event: FormEvent) => {
event.preventDefault();
if (!selectedId || !inviteDraft.email.trim()) return;
const res = await apiFetch<HouseholdInvite>(`/api/households/${selectedId}/invites`, {
method: "POST",
body: JSON.stringify({
email: inviteDraft.email,
role: inviteDraft.role,
}),
});
if (res.error) {
setCollaborationStatus(res.error.message ?? "Unable to invite collaborator.");
return;
}
setInviteDraft({ email: "", role: "accountant" });
setCollaborationStatus("Invite sent.");
await loadInvites(selectedId);
};
const calculateFairSplit = async (event: FormEvent) => {
event.preventDefault();
if (!selectedId) return;
const expenseAmount = Number.parseFloat(fairSplitDraft.expenseAmount);
if (!Number.isFinite(expenseAmount) || expenseAmount <= 0) {
setFairSplitStatus("Enter an expense amount greater than zero.");
return;
}
setFairSplitStatus("Calculating fair split...");
const res = await apiFetch<FairSplitResult>(`/api/households/${selectedId}/fair-split`, {
method: "POST",
body: JSON.stringify({
expenseAmount,
mineMonthlyIncome: Number.parseFloat(fairSplitDraft.mineMonthlyIncome || "0"),
yoursMonthlyIncome: Number.parseFloat(fairSplitDraft.yoursMonthlyIncome || "0"),
splitMode: fairSplitDraft.splitMode,
customMinePercent: fairSplitDraft.splitMode === "custom"
? Number.parseFloat(fairSplitDraft.customMinePercent)
: undefined,
}),
});
if (res.error) {
setFairSplitResult(null);
setFairSplitStatus(res.error.message ?? "Unable to calculate split.");
return;
}
setFairSplitResult(res.data);
setFairSplitStatus("");
};
const updateDebtDraft = (index: number, field: "name" | "balance" | "annualPercentageRate" | "minimumPayment" | "priority", value: string) => {
setDebtDraft((prev) => ({
...prev,
debts: prev.debts.map((debt, i) => i === index ? { ...debt, [field]: value } : debt),
}));
};
const addDebtDraftRow = () => {
setDebtDraft((prev) => ({
...prev,
debts: [
...prev.debts,
{ name: `Debt ${prev.debts.length + 1}`, balance: "", annualPercentageRate: "", minimumPayment: "", priority: String(prev.debts.length + 1) },
],
}));
};
const calculateDebtPayoff = async (event: FormEvent) => {
event.preventDefault();
if (!selectedId) return;
const debts = debtDraft.debts
.filter((debt) => debt.name.trim() && debt.balance && debt.minimumPayment)
.map((debt) => ({
name: debt.name,
balance: Number.parseFloat(debt.balance),
annualPercentageRate: Number.parseFloat(debt.annualPercentageRate || "0"),
minimumPayment: Number.parseFloat(debt.minimumPayment),
priority: Number.parseInt(debt.priority || "0", 10),
}));
if (!debts.length) {
setDebtPayoffStatus("Add at least one debt with balance and minimum payment.");
return;
}
setDebtPayoffStatus("Calculating debt payoff plan...");
const res = await apiFetch<DebtPayoffResult>(`/api/households/${selectedId}/debt-payoff`, {
method: "POST",
body: JSON.stringify({
strategy: debtDraft.strategy,
monthlyExtraPayment: Number.parseFloat(debtDraft.monthlyExtraPayment || "0"),
splitMode: debtDraft.splitMode,
mineMonthlyIncome: Number.parseFloat(debtDraft.mineMonthlyIncome || "0"),
yoursMonthlyIncome: Number.parseFloat(debtDraft.yoursMonthlyIncome || "0"),
customMinePercent: debtDraft.splitMode === "custom" ? Number.parseFloat(debtDraft.customMinePercent) : undefined,
debts,
}),
});
if (res.error) {
setDebtPayoffResult(null);
setDebtPayoffStatus(res.error.message ?? "Unable to calculate debt payoff plan.");
return;
}
setDebtPayoffResult(res.data ?? null);
setDebtPayoffStatus("");
};
const updateFutureScenario = (
index: number,
field: "name" | "type" | "startingBalance" | "monthlyContribution" | "monthlyIncome" | "monthlyExpenses" | "targetAmount" | "horizonMonths" | "annualGrowthRate",
value: string,
) => {
setFutureDraft((prev) => ({
...prev,
scenarios: prev.scenarios.map((scenario, i) => i === index ? { ...scenario, [field]: value } : scenario),
}));
};
const addFutureScenario = () => {
setFutureDraft((prev) => ({
...prev,
scenarios: [
...prev.scenarios,
{ name: `Scenario ${prev.scenarios.length + 1}`, type: "goal", startingBalance: "", monthlyContribution: "", monthlyIncome: "", monthlyExpenses: "", targetAmount: "", horizonMonths: "36", annualGrowthRate: "0" },
],
}));
};
const calculateFutureScenarios = async (event: FormEvent) => {
event.preventDefault();
if (!selectedId) return;
const scenarios = futureDraft.scenarios
.filter((scenario) => scenario.name.trim())
.map((scenario) => ({
name: scenario.name,
type: scenario.type,
startingBalance: Number.parseFloat(scenario.startingBalance || "0"),
monthlyContribution: Number.parseFloat(scenario.monthlyContribution || "0"),
monthlyIncome: Number.parseFloat(scenario.monthlyIncome || "0"),
monthlyExpenses: Number.parseFloat(scenario.monthlyExpenses || "0"),
targetAmount: scenario.targetAmount ? Number.parseFloat(scenario.targetAmount) : undefined,
horizonMonths: Number.parseInt(scenario.horizonMonths || "36", 10),
annualGrowthRate: Number.parseFloat(scenario.annualGrowthRate || "0"),
}));
if (!scenarios.length) {
setFutureStatus("Add at least one scenario.");
return;
}
setFutureStatus("Calculating future scenarios...");
const res = await apiFetch<FutureScenarioResponse>(`/api/households/${selectedId}/future-scenarios`, {
method: "POST",
body: JSON.stringify({ scenarios }),
});
if (res.error) {
setFutureResult(null);
setFutureStatus(res.error.message ?? "Unable to calculate future scenarios.");
return;
}
setFutureResult(res.data ?? null);
setFutureStatus("");
};
const updateMemberAccess = async (member: HouseholdMember, payload: { role?: string; status?: string }) => {
if (!selectedId) return;
const res = await apiFetch<HouseholdMember>(`/api/households/${selectedId}/members/${member.id}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
if (res.error) {
setCollaborationStatus(res.error.message ?? "Unable to update collaborator access.");
return;
}
setCollaborationStatus("Collaborator access updated.");
await loadDashboard(selectedId);
await loadInvites(selectedId);
};
const updatePrivacyMode = async (enabled: boolean) => {
if (!selectedId) return;
setPrivacyStatus("Updating privacy mode...");
const res = await apiFetch<{ privacyMode: DashboardData["privacyMode"] }>(`/api/households/${selectedId}/privacy`, {
method: "PATCH",
body: JSON.stringify({
enabled,
hideIndividualBalances: true,
hideIndividualTransactions: true,
}),
});
if (res.error) {
setPrivacyStatus(res.error.message ?? "Unable to update privacy mode.");
return;
}
setPrivacyStatus(enabled ? "Privacy mode enabled." : "Privacy mode disabled.");
await loadDashboard(selectedId);
await loadMoneyDatePrompts(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.">
<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={() => {
if (!selectedId) return;
loadDashboard(selectedId);
loadMoneyDatePrompts(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-5">
<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 className={cardClass}>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Health score</p>
<p className="mt-2 text-3xl font-bold text-foreground">{dashboard.healthScore.score}</p>
<p className="mt-1 text-sm capitalize text-muted-foreground">{healthRatingLabel(dashboard.healthScore.rating)}</p>
</div>
</div>
<div className="glass-panel rounded-2xl p-6 shadow-sm">
<div className="grid gap-6 lg:grid-cols-[260px_1fr_1fr]">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Couples financial health</p>
<p className="mt-2 text-5xl font-bold text-foreground">{dashboard.healthScore.score}</p>
<p className="mt-1 text-sm capitalize text-muted-foreground">{healthRatingLabel(dashboard.healthScore.rating)}</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{[
["Cashflow", dashboard.healthScore.components.cashflow, 35],
["Balance buffer", dashboard.healthScore.components.balanceBuffer, 20],
["Goal progress", dashboard.healthScore.components.goalProgress, 20],
["Collaboration", dashboard.healthScore.components.collaboration, 15],
["Sync health", dashboard.healthScore.components.syncHealth, 10],
].map(([label, value, max]) => (
<div key={label} className="rounded-xl border border-border bg-background/40 p-3">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-semibold text-foreground">{label}</span>
<span className="text-muted-foreground">{value}/{max}</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-secondary">
<div className="h-full bg-primary" style={{ width: `${Math.min((Number(value) / Number(max)) * 100, 100)}%` }} />
</div>
</div>
))}
</div>
<div className="rounded-xl border border-border bg-background/40 p-4">
<p className="text-sm font-semibold text-foreground">Next best moves</p>
<div className="mt-3 space-y-2">
{dashboard.healthScore.recommendations.map((recommendation) => (
<p key={recommendation} className="text-sm text-muted-foreground">{recommendation}</p>
))}
{!dashboard.healthScore.recommendations.length && (
<p className="text-sm text-muted-foreground">Shared cashflow, goals, collaboration, and sync health are all in strong shape.</p>
)}
</div>
</div>
</div>
</div>
<div className="glass-panel rounded-2xl p-6 shadow-sm">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Money date prompts</p>
<h2 className="mt-2 text-xl font-bold text-foreground">Next household conversation</h2>
<p className="mt-2 text-sm text-muted-foreground">
{moneyDatePrompts
? `${moneyDatePrompts.cadenceSuggestion} cadence based on cashflow, goals, sync health, and privacy settings.`
: "Prompts are generated from the shared dashboard."}
</p>
</div>
<button
type="button"
onClick={() => selectedId && loadMoneyDatePrompts(selectedId)}
className="rounded-lg border border-border px-4 py-2 text-sm font-semibold text-foreground hover:bg-secondary"
>
Refresh prompts
</button>
</div>
{moneyDateStatus ? <p className="mt-4 text-sm text-muted-foreground">{moneyDateStatus}</p> : null}
<div className="mt-5 grid gap-3 lg:grid-cols-3">
{(moneyDatePrompts?.prompts ?? []).map((prompt) => (
<div key={prompt.id} className="rounded-xl border border-border bg-background/40 p-4">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-foreground">{prompt.topic}</p>
<span className={`rounded-full px-2 py-1 text-[11px] font-bold uppercase ${
prompt.priority === "high"
? "bg-red-500/10 text-red-500"
: prompt.priority === "medium"
? "bg-amber-500/10 text-amber-500"
: "bg-secondary text-muted-foreground"
}`}>
{prompt.priority}
</span>
</div>
<p className="mt-3 text-sm font-semibold leading-6 text-foreground">{prompt.question}</p>
<p className="mt-2 text-xs leading-5 text-muted-foreground">{prompt.why}</p>
<p className="mt-3 text-xs font-bold uppercase tracking-wider text-primary">{prompt.actionLabel}</p>
</div>
))}
{!moneyDateStatus && !moneyDatePrompts?.prompts.length ? (
<p className="text-sm text-muted-foreground">No prompts generated yet.</p>
) : null}
</div>
</div>
<div className="glass-panel rounded-2xl p-6 shadow-sm">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Financial independence</p>
<h2 className="mt-2 text-xl font-bold text-foreground">Privacy mode</h2>
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">
When enabled, the shared dashboard only shows joint household accounts and joint transactions. Individual mine/theirs balances and activity stay out of the shared view.
</p>
{privacyStatus ? <p className="mt-2 text-sm text-muted-foreground">{privacyStatus}</p> : null}
</div>
<button
type="button"
onClick={() => updatePrivacyMode(!dashboard.privacyMode.enabled)}
className={`rounded-lg px-4 py-2 text-sm font-bold transition-colors ${
dashboard.privacyMode.enabled
? "border border-border bg-background text-foreground hover:bg-secondary"
: "bg-primary text-primary-foreground hover:bg-primary/90"
}`}
>
{dashboard.privacyMode.enabled ? "Disable privacy mode" : "Enable privacy mode"}
</button>
</div>
{dashboard.privacyMode.enabled ? (
<div className="mt-4 grid gap-3 md:grid-cols-2">
<div className="rounded-xl border border-border bg-background/40 p-4">
<p className="text-sm font-semibold text-foreground">Individual balances hidden</p>
<p className="mt-1 text-xs text-muted-foreground">Mine/theirs account balances are excluded from cards and ownership totals.</p>
</div>
<div className="rounded-xl border border-border bg-background/40 p-4">
<p className="text-sm font-semibold text-foreground">Individual transactions hidden</p>
<p className="mt-1 text-xs text-muted-foreground">Recent transactions and cashflow use joint account activity only.</p>
</div>
</div>
) : null}
</div>
<div className="glass-panel rounded-2xl p-6 shadow-sm">
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Fair split calculator</p>
<h2 className="mt-2 text-xl font-bold text-foreground">Split shared expenses by income or custom share</h2>
<p className="mt-2 text-sm text-muted-foreground">
Calculate a household split before applying the same percentages to a shared transaction.
</p>
{fairSplitResult ? (
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-border bg-background/40 p-4">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Mine</p>
<p className="mt-2 text-2xl font-bold text-foreground">{formatMoney(fairSplitResult.split.mineAmount)}</p>
<p className="text-sm text-muted-foreground">{fairSplitResult.split.minePercent}% share</p>
</div>
<div className="rounded-xl border border-border bg-background/40 p-4">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Yours</p>
<p className="mt-2 text-2xl font-bold text-foreground">{formatMoney(fairSplitResult.split.yoursAmount)}</p>
<p className="text-sm text-muted-foreground">{fairSplitResult.split.yoursPercent}% share</p>
</div>
<p className="text-sm text-muted-foreground sm:col-span-2">
{fairSplitResult.rationale} Transaction default: {fairSplitResult.transactionDefaults.splitMode} split with ours attribution.
</p>
</div>
) : null}
</div>
<form onSubmit={calculateFairSplit} className="grid gap-3 md:grid-cols-2">
<input
type="number"
min="0"
step="0.01"
value={fairSplitDraft.expenseAmount}
onChange={(event) => setFairSplitDraft((prev) => ({ ...prev, expenseAmount: event.target.value }))}
placeholder="Expense amount"
className={inputClass}
/>
<select
value={fairSplitDraft.splitMode}
onChange={(event) => setFairSplitDraft((prev) => ({ ...prev, splitMode: event.target.value as "equal" | "income_weighted" | "custom" }))}
className={inputClass}
>
<option value="income_weighted">Income-weighted</option>
<option value="equal">Equal 50/50</option>
<option value="custom">Custom percent</option>
</select>
<input
type="number"
min="0"
step="0.01"
value={fairSplitDraft.mineMonthlyIncome}
onChange={(event) => setFairSplitDraft((prev) => ({ ...prev, mineMonthlyIncome: event.target.value }))}
placeholder="Mine monthly income"
className={inputClass}
/>
<input
type="number"
min="0"
step="0.01"
value={fairSplitDraft.yoursMonthlyIncome}
onChange={(event) => setFairSplitDraft((prev) => ({ ...prev, yoursMonthlyIncome: event.target.value }))}
placeholder="Yours monthly income"
className={inputClass}
/>
{fairSplitDraft.splitMode === "custom" ? (
<input
type="number"
min="0"
max="100"
step="0.01"
value={fairSplitDraft.customMinePercent}
onChange={(event) => setFairSplitDraft((prev) => ({ ...prev, customMinePercent: event.target.value }))}
placeholder="Mine percent"
className={inputClass}
/>
) : null}
<button
type="submit"
className="rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 md:col-span-2"
>
Calculate split
</button>
{fairSplitStatus ? <p className="text-sm text-muted-foreground md:col-span-2">{fairSplitStatus}</p> : null}
</form>
</div>
</div>
<div className="glass-panel rounded-2xl p-6 shadow-sm">
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Debt payoff teamwork</p>
<h2 className="mt-2 text-xl font-bold text-foreground">Plan a shared payoff order and contribution split</h2>
<p className="mt-2 text-sm text-muted-foreground">
Compare avalanche, snowball, or custom priority and agree how much each partner contributes monthly.
</p>
{debtPayoffResult ? (
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-border bg-background/40 p-4">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Payoff time</p>
<p className="mt-2 text-2xl font-bold text-foreground">{debtPayoffResult.payoffMonths} months</p>
<p className="text-sm text-muted-foreground">{debtPayoffResult.payoffYears} years</p>
</div>
<div className="rounded-xl border border-border bg-background/40 p-4">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Interest</p>
<p className="mt-2 text-2xl font-bold text-foreground">{formatMoney(debtPayoffResult.totalInterestPaid)}</p>
<p className="text-sm text-muted-foreground">{formatMoney(debtPayoffResult.monthlyPayment)} monthly plan</p>
</div>
<div className="rounded-xl border border-border bg-background/40 p-4">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Mine</p>
<p className="mt-2 text-2xl font-bold text-foreground">{formatMoney(debtPayoffResult.contributionSplit.mineAmount)}</p>
<p className="text-sm text-muted-foreground">{debtPayoffResult.contributionSplit.minePercent}% monthly share</p>
</div>
<div className="rounded-xl border border-border bg-background/40 p-4">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Yours</p>
<p className="mt-2 text-2xl font-bold text-foreground">{formatMoney(debtPayoffResult.contributionSplit.yoursAmount)}</p>
<p className="text-sm text-muted-foreground">{debtPayoffResult.contributionSplit.yoursPercent}% monthly share</p>
</div>
<div className="sm:col-span-2 rounded-xl border border-border bg-background/40 p-4">
<p className="text-sm font-semibold text-foreground">Payoff order</p>
<div className="mt-3 space-y-2">
{debtPayoffResult.orderedDebts.map((debt) => (
<div key={`${debt.payoffOrder}-${debt.name}`} className="flex items-center justify-between gap-3 text-sm">
<span className="text-foreground">{debt.payoffOrder}. {debt.name}</span>
<span className="text-muted-foreground">{debt.annualPercentageRate}% APR · {formatMoney(debt.startingBalance)}</span>
</div>
))}
</div>
</div>
</div>
) : null}
</div>
<form onSubmit={calculateDebtPayoff} className="space-y-4">
<div className="grid gap-3 md:grid-cols-3">
<select
value={debtDraft.strategy}
onChange={(event) => setDebtDraft((prev) => ({ ...prev, strategy: event.target.value as "avalanche" | "snowball" | "custom" }))}
className={inputClass}
>
<option value="avalanche">Avalanche</option>
<option value="snowball">Snowball</option>
<option value="custom">Custom order</option>
</select>
<input
type="number"
min="0"
step="0.01"
value={debtDraft.monthlyExtraPayment}
onChange={(event) => setDebtDraft((prev) => ({ ...prev, monthlyExtraPayment: event.target.value }))}
placeholder="Extra monthly payment"
className={inputClass}
/>
<select
value={debtDraft.splitMode}
onChange={(event) => setDebtDraft((prev) => ({ ...prev, splitMode: event.target.value as "equal" | "income_weighted" | "custom" }))}
className={inputClass}
>
<option value="income_weighted">Income-weighted</option>
<option value="equal">Equal 50/50</option>
<option value="custom">Custom percent</option>
</select>
<input
type="number"
min="0"
step="0.01"
value={debtDraft.mineMonthlyIncome}
onChange={(event) => setDebtDraft((prev) => ({ ...prev, mineMonthlyIncome: event.target.value }))}
placeholder="Mine monthly income"
className={inputClass}
/>
<input
type="number"
min="0"
step="0.01"
value={debtDraft.yoursMonthlyIncome}
onChange={(event) => setDebtDraft((prev) => ({ ...prev, yoursMonthlyIncome: event.target.value }))}
placeholder="Yours monthly income"
className={inputClass}
/>
{debtDraft.splitMode === "custom" ? (
<input
type="number"
min="0"
max="100"
step="0.01"
value={debtDraft.customMinePercent}
onChange={(event) => setDebtDraft((prev) => ({ ...prev, customMinePercent: event.target.value }))}
placeholder="Mine percent"
className={inputClass}
/>
) : null}
</div>
<div className="space-y-2">
{debtDraft.debts.map((debt, index) => (
<div key={index} className="grid gap-2 md:grid-cols-[1.2fr_1fr_1fr_1fr_80px]">
<input value={debt.name} onChange={(event) => updateDebtDraft(index, "name", event.target.value)} placeholder="Debt name" className={inputClass} />
<input type="number" min="0" step="0.01" value={debt.balance} onChange={(event) => updateDebtDraft(index, "balance", event.target.value)} placeholder="Balance" className={inputClass} />
<input type="number" min="0" step="0.01" value={debt.annualPercentageRate} onChange={(event) => updateDebtDraft(index, "annualPercentageRate", event.target.value)} placeholder="APR %" className={inputClass} />
<input type="number" min="0" step="0.01" value={debt.minimumPayment} onChange={(event) => updateDebtDraft(index, "minimumPayment", event.target.value)} placeholder="Minimum" className={inputClass} />
<input type="number" min="1" step="1" value={debt.priority} onChange={(event) => updateDebtDraft(index, "priority", event.target.value)} placeholder="Order" className={inputClass} />
</div>
))}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<button type="button" onClick={addDebtDraftRow} className="rounded-lg border border-border px-4 py-2 text-sm font-semibold text-foreground hover:bg-secondary">
Add debt
</button>
<button type="submit" className="rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90">
Calculate payoff
</button>
</div>
{debtPayoffStatus ? <p className="text-sm text-muted-foreground">{debtPayoffStatus}</p> : null}
{debtPayoffResult?.recommendations.length ? (
<div className="rounded-xl border border-border bg-background/40 p-4">
<p className="text-sm font-semibold text-foreground">Team next steps</p>
<div className="mt-2 space-y-1">
{debtPayoffResult.recommendations.map((recommendation) => (
<p key={recommendation} className="text-sm text-muted-foreground">{recommendation}</p>
))}
</div>
</div>
) : null}
</form>
</div>
</div>
<div className="glass-panel rounded-2xl p-6 shadow-sm">
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Future planning scenarios</p>
<h2 className="mt-2 text-xl font-bold text-foreground">Compare what-if household plans</h2>
<p className="mt-2 text-sm text-muted-foreground">
Project shared milestones, income changes, expense changes, or net worth plans over time.
</p>
{futureResult ? (
<div className="mt-5 space-y-3">
<div className="rounded-xl border border-border bg-background/40 p-4">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Best final balance</p>
<p className="mt-2 text-2xl font-bold text-foreground">{futureResult.comparison.bestFinalBalance.name}</p>
<p className="text-sm text-muted-foreground">{formatMoney(futureResult.comparison.bestFinalBalance.finalBalance)} after {futureResult.comparison.bestFinalBalance.horizonMonths} months</p>
</div>
{futureResult.comparison.earliestTarget ? (
<div className="rounded-xl border border-border bg-background/40 p-4">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Earliest target</p>
<p className="mt-2 text-2xl font-bold text-foreground">{futureResult.comparison.earliestTarget.name}</p>
<p className="text-sm text-muted-foreground">Month {futureResult.comparison.earliestTarget.targetReachedMonth}</p>
</div>
) : null}
<div className="space-y-3">
{futureResult.scenarios.map((scenario) => (
<div key={scenario.name} className="rounded-xl border border-border bg-background/40 p-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="font-semibold text-foreground">{scenario.name}</p>
<p className="text-sm text-muted-foreground">{scenario.recommendation}</p>
</div>
<p className="text-lg font-bold text-foreground">{formatMoney(scenario.finalBalance)}</p>
</div>
<div className="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
<span>Net monthly: {formatMoney(scenario.monthlyNetChange)}</span>
<span>Growth: {formatMoney(scenario.projectedGrowth)}</span>
<span>Target: {scenario.targetReachedMonth ? `month ${scenario.targetReachedMonth}` : "not reached"}</span>
</div>
</div>
))}
</div>
</div>
) : null}
</div>
<form onSubmit={calculateFutureScenarios} className="space-y-4">
<div className="space-y-3">
{futureDraft.scenarios.map((scenario, index) => (
<div key={index} className="rounded-xl border border-border bg-background/40 p-3">
<div className="grid gap-2 md:grid-cols-2">
<input value={scenario.name} onChange={(event) => updateFutureScenario(index, "name", event.target.value)} placeholder="Scenario name" className={inputClass} />
<select value={scenario.type} onChange={(event) => updateFutureScenario(index, "type", event.target.value)} className={inputClass}>
<option value="goal">Goal</option>
<option value="net_worth">Net worth</option>
<option value="income_change">Income change</option>
<option value="expense_change">Expense change</option>
</select>
<input type="number" step="0.01" value={scenario.startingBalance} onChange={(event) => updateFutureScenario(index, "startingBalance", event.target.value)} placeholder="Starting balance" className={inputClass} />
<input type="number" step="0.01" value={scenario.monthlyContribution} onChange={(event) => updateFutureScenario(index, "monthlyContribution", event.target.value)} placeholder="Monthly contribution" className={inputClass} />
<input type="number" step="0.01" value={scenario.monthlyIncome} onChange={(event) => updateFutureScenario(index, "monthlyIncome", event.target.value)} placeholder="Monthly income change" className={inputClass} />
<input type="number" step="0.01" value={scenario.monthlyExpenses} onChange={(event) => updateFutureScenario(index, "monthlyExpenses", event.target.value)} placeholder="Monthly expense change" className={inputClass} />
<input type="number" min="0" step="0.01" value={scenario.targetAmount} onChange={(event) => updateFutureScenario(index, "targetAmount", event.target.value)} placeholder="Target amount" className={inputClass} />
<div className="grid gap-2 sm:grid-cols-2">
<input type="number" min="1" max="600" step="1" value={scenario.horizonMonths} onChange={(event) => updateFutureScenario(index, "horizonMonths", event.target.value)} placeholder="Months" className={inputClass} />
<input type="number" min="0" step="0.01" value={scenario.annualGrowthRate} onChange={(event) => updateFutureScenario(index, "annualGrowthRate", event.target.value)} placeholder="Annual growth %" className={inputClass} />
</div>
</div>
</div>
))}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<button type="button" onClick={addFutureScenario} className="rounded-lg border border-border px-4 py-2 text-sm font-semibold text-foreground hover:bg-secondary">
Add scenario
</button>
<button type="submit" className="rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90">
Compare scenarios
</button>
</div>
{futureStatus ? <p className="text-sm text-muted-foreground">{futureStatus}</p> : null}
</form>
</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">
<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">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 className="text-lg font-bold text-foreground">Collaborators</h2>
<p className="mt-1 text-sm text-muted-foreground">Invite accountants or advisors with view-only household access.</p>
</div>
<span className="rounded-full border border-border bg-secondary/60 px-3 py-1 text-xs font-semibold text-muted-foreground">
{dashboard.members.length} active
</span>
</div>
<form onSubmit={inviteCollaborator} className="mt-4 grid gap-2 sm:grid-cols-[1fr_130px_auto]">
<input
value={inviteDraft.email}
onChange={(event) => setInviteDraft((prev) => ({ ...prev, email: event.target.value }))}
type="email"
placeholder="advisor@example.com"
className={inputClass}
/>
<select
value={inviteDraft.role}
onChange={(event) => setInviteDraft((prev) => ({ ...prev, role: event.target.value }))}
className={inputClass}
>
<option value="accountant">Accountant</option>
<option value="advisor">Advisor</option>
<option value="viewer">Viewer</option>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
<button
type="submit"
disabled={!inviteDraft.email.trim()}
className="rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Invite
</button>
</form>
{collaborationStatus && <p className="mt-3 text-sm text-muted-foreground">{collaborationStatus}</p>}
<div className="mt-4 space-y-3">
{dashboard.members.map((member) => (
<div key={member.id} className="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>
<div className="mt-3 grid gap-2 sm:grid-cols-[1fr_1fr_auto]">
<select
value={member.role}
onChange={(event) => updateMemberAccess(member, { role: event.target.value })}
className={inputClass}
>
<option value="owner">Owner</option>
{collaboratorRoles.map((role) => (
<option key={role} value={role}>{role}</option>
))}
</select>
<select
value={member.status ?? "active"}
onChange={(event) => updateMemberAccess(member, { status: event.target.value })}
className={inputClass}
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="removed">Removed</option>
</select>
<span className="rounded-lg border border-border bg-secondary/60 px-3 py-2 text-xs font-semibold capitalize text-muted-foreground">
{member.role}
</span>
</div>
</div>
))}
</div>
{!!invites.length && (
<div className="mt-5">
<p className="text-sm font-semibold text-foreground">Pending invites</p>
<div className="mt-3 space-y-2">
{invites.filter((invite) => invite.status === "pending").map((invite) => (
<div key={invite.id} className="flex items-center justify-between gap-3 rounded-xl border border-border bg-background/40 p-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">{invite.email}</p>
<p className="text-xs capitalize text-muted-foreground">{invite.role} · expires {new Date(invite.expiresAt).toLocaleDateString()}</p>
</div>
<span className="rounded-full border border-border bg-secondary/60 px-2 py-1 text-[11px] font-semibold capitalize text-muted-foreground">{invite.status}</span>
</div>
))}
</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>
);
}