Add debt payoff teamwork UI

This commit is contained in:
MOHAN 2026-07-16 23:50:04 +05:30
parent 1dbca1bcd4
commit f154fd5656
2 changed files with 252 additions and 0 deletions

View File

@ -0,0 +1,9 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
return proxyRequest(req, `households/${params.id}/debt-payoff`);
}

View File

@ -171,6 +171,40 @@ type MoneyDatePromptResponse = {
prompts: MoneyDatePrompt[]; 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[];
};
const money = new Intl.NumberFormat("en-US", { const money = new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: "USD", currency: "USD",
@ -228,6 +262,20 @@ export default function HouseholdSettingsPage() {
}); });
const [fairSplitResult, setFairSplitResult] = useState<FairSplitResult | null>(null); const [fairSplitResult, setFairSplitResult] = useState<FairSplitResult | null>(null);
const [fairSplitStatus, setFairSplitStatus] = useState(""); 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 [privacyStatus, setPrivacyStatus] = useState(""); const [privacyStatus, setPrivacyStatus] = useState("");
const [moneyDatePrompts, setMoneyDatePrompts] = useState<MoneyDatePromptResponse | null>(null); const [moneyDatePrompts, setMoneyDatePrompts] = useState<MoneyDatePromptResponse | null>(null);
const [moneyDateStatus, setMoneyDateStatus] = useState(""); const [moneyDateStatus, setMoneyDateStatus] = useState("");
@ -451,6 +499,62 @@ export default function HouseholdSettingsPage() {
setFairSplitStatus(""); 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 updateMemberAccess = async (member: HouseholdMember, payload: { role?: string; status?: string }) => { const updateMemberAccess = async (member: HouseholdMember, payload: { role?: string; status?: string }) => {
if (!selectedId) return; if (!selectedId) return;
const res = await apiFetch<HouseholdMember>(`/api/households/${selectedId}/members/${member.id}`, { const res = await apiFetch<HouseholdMember>(`/api/households/${selectedId}/members/${member.id}`, {
@ -794,6 +898,145 @@ export default function HouseholdSettingsPage() {
</div> </div>
</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="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]"> <div className="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
<div className="glass-panel rounded-2xl p-6 shadow-sm"> <div className="glass-panel rounded-2xl p-6 shadow-sm">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">