Add debt payoff teamwork UI
This commit is contained in:
parent
1dbca1bcd4
commit
f154fd5656
9
app/api/households/[id]/debt-payoff/route.ts
Normal file
9
app/api/households/[id]/debt-payoff/route.ts
Normal 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`);
|
||||||
|
}
|
||||||
@ -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">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user