493 lines
21 KiB
JavaScript
493 lines
21 KiB
JavaScript
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { AlertCircle, ArrowRight, Calculator, RefreshCw, RotateCcw, TrendingDown } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { api } from '@/api';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import {
|
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { cn } from '@/lib/utils';
|
|
import PayoffChart from '@/components/snowball/PayoffChart';
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function fmt(v) {
|
|
return (Number(v) || 0).toLocaleString(undefined, {
|
|
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2,
|
|
});
|
|
}
|
|
|
|
function fmtShort(v) {
|
|
const n = Number(v) || 0;
|
|
return n.toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
|
}
|
|
|
|
function buildPayoffSchedule(balance, annualRatePct, monthlyPayment, oneTimeExtra = 0) {
|
|
if (!balance || balance <= 0 || !monthlyPayment || monthlyPayment <= 0) return [];
|
|
const rate = (annualRatePct || 0) / 100 / 12;
|
|
if (rate > 0 && monthlyPayment <= balance * rate) return [];
|
|
let bal = balance;
|
|
const months = [];
|
|
for (let i = 0; i < 600; i++) {
|
|
const interest = Math.round(bal * rate * 100) / 100;
|
|
const pmt = Math.min(bal + interest, i === 0 ? monthlyPayment + oneTimeExtra : monthlyPayment);
|
|
const principal = Math.max(0, pmt - interest);
|
|
bal = Math.round(Math.max(0, bal - principal) * 100) / 100;
|
|
months.push({ month: i + 1, balance: bal, interest });
|
|
if (bal < 0.01) break;
|
|
}
|
|
return months;
|
|
}
|
|
|
|
function payoffLabel(track, now = new Date()) {
|
|
if (!track.length) return null;
|
|
const d = new Date(now.getFullYear(), now.getMonth() + track.length, 1);
|
|
return d.toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
|
|
}
|
|
|
|
function numMonths(track) {
|
|
if (!track.length) return null;
|
|
const y = Math.floor(track.length / 12);
|
|
const m = track.length % 12;
|
|
if (y === 0) return `${m} mo`;
|
|
if (m === 0) return `${y} yr`;
|
|
return `${y} yr ${m} mo`;
|
|
}
|
|
|
|
// ─── Stat card ────────────────────────────────────────────────────────────────
|
|
|
|
function StatCard({ label, value, sub, color = 'amber' }) {
|
|
const colors = {
|
|
amber: 'bg-amber-500/8 border-amber-400/20 text-amber-500 dark:text-amber-400',
|
|
teal: 'bg-teal-500/8 border-teal-400/20 text-teal-500 dark:text-teal-400',
|
|
slate: 'bg-muted/40 border-border/60 text-foreground',
|
|
};
|
|
return (
|
|
<div className={cn('rounded-xl border p-4 text-center', colors[color])}>
|
|
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground mb-1">{label}</p>
|
|
<p className={cn('text-2xl font-bold font-mono tabular-nums', colors[color])}>{value}</p>
|
|
{sub && <p className="text-[11px] text-muted-foreground mt-0.5">{sub}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Input row ────────────────────────────────────────────────────────────────
|
|
|
|
function InputRow({ label, hint, children }) {
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-baseline justify-between">
|
|
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
{label}
|
|
</Label>
|
|
{hint && <span className="text-[11px] text-muted-foreground">{hint}</span>}
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Empty states ─────────────────────────────────────────────────────────────
|
|
|
|
function EmptyDebts() {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/10 px-6 py-16 text-center">
|
|
<TrendingDown className="h-10 w-10 text-muted-foreground/40 mb-4" />
|
|
<p className="text-sm font-medium text-foreground">No debts with a balance found</p>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Add a current balance to your bills on the{' '}
|
|
<a href="/snowball" className="underline text-primary hover:opacity-80">Snowball page</a>.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NoSelection() {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/10 px-6 py-16 text-center">
|
|
<Calculator className="h-10 w-10 text-muted-foreground/40 mb-4" />
|
|
<p className="text-sm font-medium text-foreground">Select a loan or debt to begin</p>
|
|
<p className="text-xs text-muted-foreground mt-1">Choose from the dropdown above to run your simulation.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── PayoffPage ───────────────────────────────────────────────────────────────
|
|
|
|
export default function PayoffPage() {
|
|
const [bills, setBills] = useState([]);
|
|
const [extraPayment, setExtraPayment] = useState(0);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadError, setLoadError] = useState(null);
|
|
const [selectedId, setSelectedId] = useState(null);
|
|
|
|
// Per-simulation state (reset when bill changes)
|
|
const [simPayment, setSimPayment] = useState('');
|
|
const [simRate, setSimRate] = useState('');
|
|
const [oneTimeExtra, setOneTimeExtra] = useState('');
|
|
const [applying, setApplying] = useState(false);
|
|
|
|
const loadData = useCallback(() => {
|
|
setLoading(true);
|
|
setLoadError(null);
|
|
Promise.all([api.snowball(), api.snowballSettings()])
|
|
.then(([billData, settings]) => {
|
|
const debtBills = (billData || []).filter(b => (b.current_balance ?? 0) > 0);
|
|
setBills(debtBills);
|
|
setExtraPayment(Number(settings?.extra_payment) || 0);
|
|
if (debtBills.length > 0 && !selectedId) {
|
|
setSelectedId(debtBills[0].id);
|
|
}
|
|
})
|
|
.catch(err => setLoadError(err.message || 'Failed to load data'))
|
|
.finally(() => setLoading(false));
|
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
useEffect(() => { loadData(); }, [loadData]);
|
|
|
|
const bill = useMemo(() => bills.find(b => b.id === selectedId) ?? null, [bills, selectedId]);
|
|
const isAttack = bills[0]?.id === selectedId;
|
|
|
|
// Reset sim inputs whenever the selected bill changes
|
|
useEffect(() => {
|
|
if (!bill) return;
|
|
setSimPayment(String(Math.max(bill.minimum_payment ?? 0, bill.expected_amount ?? 0)));
|
|
setSimRate(String(bill.interest_rate ?? 0));
|
|
setOneTimeExtra('');
|
|
}, [bill?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Derived simulation tracks
|
|
const simPaymentN = Math.max(0, Number(simPayment) || 0);
|
|
const simRateN = Math.max(0, Number(simRate) || 0);
|
|
const oneTimeExtraN = Math.max(0, Number(oneTimeExtra) || 0);
|
|
const minPayment = bill?.minimum_payment ?? 0;
|
|
|
|
const { minTrack, currentTrack, simTrack } = useMemo(() => {
|
|
if (!bill) return { minTrack: [], currentTrack: [], simTrack: [] };
|
|
const b = bill.current_balance;
|
|
const min = minPayment > 0 ? minPayment : 0.01;
|
|
const currentPmt = isAttack ? min + extraPayment : min;
|
|
return {
|
|
minTrack: buildPayoffSchedule(b, simRateN, min),
|
|
currentTrack: buildPayoffSchedule(b, simRateN, currentPmt),
|
|
simTrack: buildPayoffSchedule(b, simRateN, simPaymentN, oneTimeExtraN),
|
|
};
|
|
}, [bill, simRateN, simPaymentN, oneTimeExtraN, minPayment, isAttack, extraPayment]);
|
|
|
|
const minInterest = useMemo(() => minTrack.reduce((s, m) => s + m.interest, 0), [minTrack]);
|
|
const simInterest = useMemo(() => simTrack.reduce((s, m) => s + m.interest, 0), [simTrack]);
|
|
const interestSavings = Math.max(0, minInterest - simInterest);
|
|
const timeSavings = Math.max(0, minTrack.length - simTrack.length);
|
|
const simTotalPaid = simInterest + (bill?.current_balance ?? 0);
|
|
|
|
const simPayoffLabel = payoffLabel(simTrack);
|
|
const minPayoffLabel = payoffLabel(minTrack);
|
|
const simDuration = numMonths(simTrack);
|
|
|
|
const paymentBelowMin = simPaymentN > 0 && simPaymentN < minPayment && minPayment > 0;
|
|
const paymentTooLow = bill && simPaymentN > 0 && simTrack.length === 0;
|
|
|
|
const defaultSimPayment = bill
|
|
? String(Math.max(bill.minimum_payment ?? 0, bill.expected_amount ?? 0))
|
|
: '';
|
|
const defaultRate = bill ? String(bill.interest_rate ?? 0) : '';
|
|
const isDirty = simPayment !== defaultSimPayment || simRate !== defaultRate || oneTimeExtra !== '';
|
|
|
|
const handleReset = () => {
|
|
if (!bill) return;
|
|
setSimPayment(defaultSimPayment);
|
|
setSimRate(defaultRate);
|
|
setOneTimeExtra('');
|
|
};
|
|
|
|
const handleApply = async () => {
|
|
if (!bill || applying) return;
|
|
setApplying(true);
|
|
try {
|
|
await api.updateBill(bill.id, { expected_amount: simPaymentN });
|
|
toast.success(`"${bill.name}" updated to ${fmt(simPaymentN)}/mo`, {
|
|
action: {
|
|
label: 'Undo',
|
|
onClick: async () => {
|
|
await api.updateBill(bill.id, { expected_amount: bill.expected_amount });
|
|
toast.success('Reverted');
|
|
loadData();
|
|
},
|
|
},
|
|
});
|
|
loadData();
|
|
} catch {
|
|
toast.error('Failed to update bill');
|
|
} finally {
|
|
setApplying(false);
|
|
}
|
|
};
|
|
|
|
// ── Render ──────────────────────────────────────────────────────────────────
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="space-y-6 animate-pulse">
|
|
<div className="h-8 w-64 rounded-lg bg-muted/50" />
|
|
<div className="h-4 w-96 rounded bg-muted/50" />
|
|
<div className="grid grid-cols-1 lg:grid-cols-[360px_1fr] gap-6">
|
|
<div className="space-y-4">
|
|
{[1, 2, 3, 4].map(i => (
|
|
<div key={i} className="h-16 rounded-xl bg-muted/40" />
|
|
))}
|
|
</div>
|
|
<div className="h-96 rounded-xl bg-muted/40" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (loadError) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-24 text-center rounded-xl border border-destructive/20 bg-destructive/5">
|
|
<AlertCircle className="h-10 w-10 text-destructive mb-3" />
|
|
<p className="text-sm font-medium">Failed to load data</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">{loadError}</p>
|
|
<Button size="sm" variant="outline" onClick={loadData} className="mt-4 gap-1.5 text-xs">
|
|
<RefreshCw className="h-3 w-3" /> Try again
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
|
|
{/* Page header */}
|
|
<div className="mb-6 flex items-start justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight">Payoff Simulator</h1>
|
|
<p className="text-sm text-muted-foreground mt-0.5">
|
|
Explore how extra payments reduce interest and shorten your payoff timeline.
|
|
</p>
|
|
</div>
|
|
{isDirty && (
|
|
<Button size="sm" variant="ghost" onClick={handleReset} className="gap-1.5 text-xs shrink-0 mt-1">
|
|
<RotateCcw className="h-3 w-3" /> Reset
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bill selector */}
|
|
<div className="mb-6">
|
|
{bills.length === 0 ? (
|
|
<EmptyDebts />
|
|
) : (
|
|
<Select value={selectedId ? String(selectedId) : ''} onValueChange={v => setSelectedId(Number(v))}>
|
|
<SelectTrigger className="w-72">
|
|
<SelectValue placeholder="Select a loan or debt…" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{bills.map(b => (
|
|
<SelectItem key={b.id} value={String(b.id)}>
|
|
<span className="font-medium">{b.name}</span>
|
|
{b.current_balance ? (
|
|
<span className="ml-2 text-muted-foreground font-mono text-xs">
|
|
{fmt(b.current_balance)}
|
|
</span>
|
|
) : null}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
|
|
{/* Main content: left panel + right panel */}
|
|
{!bill ? (
|
|
bills.length > 0 ? <NoSelection /> : null
|
|
) : (
|
|
<div className="grid grid-cols-1 lg:grid-cols-[340px_1fr] gap-6 items-start">
|
|
|
|
{/* ── Left panel ── */}
|
|
<div className="table-surface p-5 space-y-5">
|
|
|
|
{/* Required minimum */}
|
|
<div className="rounded-lg bg-muted/30 px-4 py-3 flex items-center justify-between">
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
Required Minimum
|
|
</span>
|
|
<span className="font-mono text-lg font-bold tabular-nums">
|
|
{minPayment > 0 ? fmt(minPayment) : <span className="text-muted-foreground text-sm">Not set</span>}
|
|
</span>
|
|
</div>
|
|
|
|
{minPayment <= 0 && (
|
|
<p className="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1.5">
|
|
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
|
Set a minimum payment on the Snowball page for best results.
|
|
</p>
|
|
)}
|
|
|
|
{/* Interest rate */}
|
|
<InputRow label="Interest Rate" hint="Override to test scenarios">
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
type="number" min="0" max="99" step="0.01"
|
|
value={simRate}
|
|
onChange={e => setSimRate(e.target.value)}
|
|
className="font-mono"
|
|
placeholder="0.00"
|
|
/>
|
|
<span className="text-sm text-muted-foreground shrink-0">%</span>
|
|
</div>
|
|
</InputRow>
|
|
|
|
{/* Monthly payment */}
|
|
<InputRow label="Monthly Payment">
|
|
<Input
|
|
type="number" min="0" step="1"
|
|
value={simPayment}
|
|
onChange={e => setSimPayment(e.target.value)}
|
|
className="font-mono"
|
|
placeholder="0.00"
|
|
/>
|
|
{paymentBelowMin && (
|
|
<p className="text-[11px] text-amber-600 dark:text-amber-400 flex items-center gap-1 mt-1">
|
|
<AlertCircle className="h-3 w-3 shrink-0" />
|
|
Below minimum payment of {fmt(minPayment)}
|
|
</p>
|
|
)}
|
|
{paymentTooLow && !paymentBelowMin && (
|
|
<p className="text-[11px] text-destructive flex items-center gap-1 mt-1">
|
|
<AlertCircle className="h-3 w-3 shrink-0" />
|
|
Payment too low to overcome interest
|
|
</p>
|
|
)}
|
|
{simPaymentN > 0 && simPaymentN !== (bill?.expected_amount ?? 0) && !paymentTooLow && (
|
|
<button
|
|
type="button"
|
|
onClick={handleApply}
|
|
disabled={applying}
|
|
className="mt-1.5 text-[11px] text-primary hover:underline flex items-center gap-1 disabled:opacity-50"
|
|
>
|
|
<ArrowRight className="h-3 w-3" />
|
|
{applying ? 'Applying…' : `Apply ${fmt(simPaymentN)}/mo to my budget`}
|
|
</button>
|
|
)}
|
|
</InputRow>
|
|
|
|
{/* One-time extra */}
|
|
<InputRow label="One-time Extra This Month" hint="Optional lump sum">
|
|
<div className="flex items-center gap-1">
|
|
<Input
|
|
type="number" min="0" step="100"
|
|
value={oneTimeExtra}
|
|
onChange={e => setOneTimeExtra(e.target.value)}
|
|
className="font-mono"
|
|
placeholder="0.00"
|
|
/>
|
|
<div className="flex flex-col gap-0.5">
|
|
<button
|
|
type="button"
|
|
className="rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-muted transition-colors"
|
|
onClick={() => setOneTimeExtra(String(Math.max(0, oneTimeExtraN + 100)))}
|
|
>▲</button>
|
|
<button
|
|
type="button"
|
|
className="rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-muted transition-colors"
|
|
onClick={() => setOneTimeExtra(String(Math.max(0, oneTimeExtraN - 100)))}
|
|
>▼</button>
|
|
</div>
|
|
</div>
|
|
</InputRow>
|
|
|
|
{/* Divider */}
|
|
<div className="border-t border-border/50" />
|
|
|
|
{/* Payoff date summary */}
|
|
<div className="space-y-2">
|
|
{simPayoffLabel ? (
|
|
<div className="flex items-baseline justify-between">
|
|
<span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">Payoff</span>
|
|
<div className="text-right">
|
|
<span className="text-xl font-bold font-mono text-amber-500 dark:text-amber-400">
|
|
{simPayoffLabel}
|
|
</span>
|
|
{simDuration && (
|
|
<p className="text-[11px] text-muted-foreground">{simDuration}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground text-center">Enter a payment to see payoff date</p>
|
|
)}
|
|
|
|
{minPayoffLabel && simPayoffLabel && minPayoffLabel !== simPayoffLabel && (
|
|
<div className="flex items-baseline justify-between">
|
|
<span className="text-[11px] text-muted-foreground">Minimum only</span>
|
|
<span className="text-[11px] text-muted-foreground font-mono line-through">{minPayoffLabel}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/* ── Right panel ── */}
|
|
<div className="space-y-4">
|
|
|
|
{/* Chart */}
|
|
{simTrack.length > 0 ? (
|
|
<PayoffChart
|
|
minTrack={minTrack}
|
|
currentTrack={currentTrack}
|
|
simTrack={simTrack}
|
|
startBalance={bill.current_balance}
|
|
/>
|
|
) : (
|
|
<div className="flex items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/10 h-[300px] text-sm text-muted-foreground">
|
|
{simPaymentN <= 0 ? 'Enter a monthly payment to see the chart' : 'Payment too low to pay off this debt'}
|
|
</div>
|
|
)}
|
|
|
|
{/* Stats row */}
|
|
{simTrack.length > 0 && (
|
|
<>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<StatCard
|
|
label="Interest Savings"
|
|
value={fmtShort(interestSavings)}
|
|
sub="vs minimum only"
|
|
color={interestSavings > 0 ? 'teal' : 'slate'}
|
|
/>
|
|
<StatCard
|
|
label="Time Savings"
|
|
value={timeSavings > 0 ? `${timeSavings} mo` : '—'}
|
|
sub={timeSavings > 0 ? 'months sooner' : 'same timeline'}
|
|
color={timeSavings > 0 ? 'amber' : 'slate'}
|
|
/>
|
|
</div>
|
|
|
|
{/* Breakdown */}
|
|
<div className="table-surface divide-y divide-border/50">
|
|
<div className="px-5 py-3 flex items-center justify-between">
|
|
<span className="text-sm text-muted-foreground">Balance today</span>
|
|
<span className="font-mono text-sm font-semibold">{fmt(bill.current_balance)}</span>
|
|
</div>
|
|
<div className="px-5 py-3 flex items-center justify-between">
|
|
<span className="text-sm text-muted-foreground">Total interest</span>
|
|
<span className="font-mono text-sm font-semibold text-rose-500">{fmt(simInterest)}</span>
|
|
</div>
|
|
<div className="px-5 py-3 flex items-center justify-between">
|
|
<span className="text-sm font-medium">Total paid</span>
|
|
<span className="font-mono text-sm font-bold">{fmt(simTotalPaid)}</span>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
);
|
|
}
|