import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { AlertCircle, ArrowRight, Calculator, Printer, 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, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { cn } from '@/lib/utils'; import PayoffChart from '@/components/snowball/PayoffChart'; // ─── Print isolation ────────────────────────────────────────────────────────── const PRINT_STYLES = ` @media print { * { visibility: hidden !important; } #payoff-print-area, #payoff-print-area * { visibility: visible !important; } #payoff-print-area { position: absolute !important; top: 0 !important; left: 0 !important; right: 0 !important; width: 100% !important; padding: 24px !important; margin: 0 !important; background: #fff !important; color: #111 !important; } #payoff-print-area .no-print { display: none !important; } #payoff-print-area .print-only { display: block !important; } } `; // ─── 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`; } // ─── Sub-components ─────────────────────────────────────────────────────────── 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 (

{label}

{value}

{sub &&

{sub}

}
); } function InputRow({ label, hint, children }) { return (
{hint && {hint}}
{children}
); } function EmptyDebts() { return (

No bills with a balance found

Add a current balance to your bills on the{' '} Snowball page, or use the Custom option in the dropdown above.

); } function NoSelection() { return (

Select a loan or debt to begin

Choose from the dropdown above, or select Custom to simulate any loan.

); } // ─── 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); // number | 'custom' | null // Custom mode inputs const [customName, setCustomName] = useState(''); const [customBalance, setCustomBalance] = useState(''); // Per-simulation inputs (reset when selection changes) const [simPayment, setSimPayment] = useState(''); const [simRate, setSimRate] = useState(''); const [oneTimeExtra, setOneTimeExtra] = useState(''); const [applying, setApplying] = useState(false); const isCustom = selectedId === 'custom'; const loadData = useCallback(() => { setLoading(true); setLoadError(null); // Use api.bills() so ALL active bills with a balance appear (not just debt categories) Promise.all([api.bills(), api.snowballSettings()]) .then(([allBills, settings]) => { const withBalance = (allBills || []) .filter(b => (b.current_balance ?? 0) > 0 && !b.is_subscription) .sort((a, b) => a.name.localeCompare(b.name)); setBills(withBalance); setExtraPayment(Number(settings?.extra_payment) || 0); if (withBalance.length > 0 && !selectedId) { setSelectedId(withBalance[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( () => (isCustom ? null : bills.find(b => b.id === selectedId) ?? null), [bills, selectedId, isCustom], ); const isAttack = !isCustom && bills[0]?.id === selectedId; // Reset sim inputs whenever selection changes useEffect(() => { if (isCustom) { setSimPayment(''); setSimRate('0'); setOneTimeExtra(''); return; } if (!bill) return; setSimPayment(String(Math.max(bill.minimum_payment ?? 0, bill.expected_amount ?? 0))); setSimRate(String(bill.interest_rate ?? 0)); setOneTimeExtra(''); }, [selectedId]); // eslint-disable-line react-hooks/exhaustive-deps // Derived numeric values 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 activeBalance = isCustom ? (parseFloat(customBalance) || 0) : (bill?.current_balance ?? 0); const activeName = isCustom ? (customName.trim() || 'Custom Loan') : (bill?.name ?? ''); const { minTrack, currentTrack, simTrack } = useMemo(() => { if (!activeBalance) return { minTrack: [], currentTrack: [], simTrack: [] }; const min = !isCustom && minPayment > 0 ? minPayment : 0.01; const currentPmt = !isCustom && isAttack ? min + extraPayment : min; return { minTrack: isCustom ? [] : buildPayoffSchedule(activeBalance, simRateN, min), currentTrack: isCustom ? [] : buildPayoffSchedule(activeBalance, simRateN, currentPmt), simTrack: buildPayoffSchedule(activeBalance, simRateN, simPaymentN, oneTimeExtraN), }; }, [activeBalance, isCustom, 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 + activeBalance; const simPayoffLabel = payoffLabel(simTrack); const minPayoffLabel = payoffLabel(minTrack); const simDuration = numMonths(simTrack); const paymentBelowMin = !isCustom && simPaymentN > 0 && simPaymentN < minPayment && minPayment > 0; const paymentTooLow = activeBalance > 0 && simPaymentN > 0 && simTrack.length === 0; const customNeedsBalance = isCustom && !customBalance; const defaultSimPayment = bill ? String(Math.max(bill.minimum_payment ?? 0, bill.expected_amount ?? 0)) : ''; const defaultRate = bill ? String(bill.interest_rate ?? 0) : ''; const isDirty = !isCustom && (simPayment !== defaultSimPayment || simRate !== defaultRate || oneTimeExtra !== ''); const handleReset = () => { if (!bill) return; setSimPayment(defaultSimPayment); setSimRate(defaultRate); setOneTimeExtra(''); }; const handlePrint = () => window.print(); 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); } }; const handleSelectChange = (val) => { setSelectedId(val === 'custom' ? 'custom' : Number(val)); }; // ── Render ────────────────────────────────────────────────────────────────── if (loading) { return (
{[1, 2, 3, 4].map(i =>
)}
); } if (loadError) { return (

Failed to load data

{loadError}

); } const showResults = (isCustom && activeBalance > 0 && simTrack.length > 0) || (!isCustom && bill && simTrack.length > 0); return ( <>
{/* ── Print-only summary header (hidden on screen) ── */}

Payoff Simulator — {activeName || '—'}

{activeBalance > 0 && (

Balance: {fmt(activeBalance)} {simRateN > 0 && ` · Rate: ${simRateN}%`} {simPaymentN > 0 && ` · Payment: ${fmt(simPaymentN)}/mo`} {oneTimeExtraN > 0 && ` · One-time extra: ${fmt(oneTimeExtraN)}`}

)}
{/* ── Page header ── */}

Payoff Simulator

Explore how extra payments reduce interest and shorten your payoff timeline.

{isDirty && ( )}
{/* ── Bill/debt selector ── */}
{bills.length === 0 && !isCustom && (

No bills with a current balance found.{' '}

)}
{/* ── Empty / no-selection states ── */} {!isCustom && !bill && bills.length === 0 && } {!isCustom && !bill && bills.length > 0 && } {/* ── Main content ── */} {(isCustom || bill) && (
{/* ── Left panel ── */}
{/* Custom mode: Name + Balance inputs */} {isCustom && ( <> setCustomName(e.target.value)} placeholder="e.g. Car Loan, Mortgage…" className="no-print" />

{customName || 'Custom Loan'}

$ setCustomBalance(e.target.value)} className="font-mono" placeholder="0.00" autoFocus />

{fmt(activeBalance)}

{customNeedsBalance && (

Balance is required to run the simulation

)}
)} {/* Bill mode: Required minimum display */} {!isCustom && (
Required Minimum {minPayment > 0 ? fmt(minPayment) : Not set}
)} {!isCustom && minPayment <= 0 && (

Set a minimum payment on the Snowball page for best results.

)} {/* Interest rate */}
setSimRate(e.target.value)} className="font-mono no-print" placeholder="0.00" /> % {simRateN}%
{/* Monthly payment */}
setSimPayment(e.target.value)} className="font-mono" placeholder="0.00" /> {paymentBelowMin && (

Below minimum payment of {fmt(minPayment)}

)} {paymentTooLow && !paymentBelowMin && (

Payment too low to overcome interest

)} {!isCustom && simPaymentN > 0 && simPaymentN !== (bill?.expected_amount ?? 0) && !paymentTooLow && ( )}

{fmt(simPaymentN)}/mo

{/* One-time extra */}
setOneTimeExtra(e.target.value)} className="font-mono" placeholder="0.00" />
{oneTimeExtraN > 0 && (

{fmt(oneTimeExtraN)}

)}
{/* Divider */}
{/* Payoff date summary */}
{simPayoffLabel ? (
Payoff
{simPayoffLabel} {simDuration && (

{simDuration}

)}
) : (

{customNeedsBalance ? 'Enter a balance to see payoff date' : 'Enter a payment to see payoff date'}

)} {!isCustom && minPayoffLabel && simPayoffLabel && minPayoffLabel !== simPayoffLabel && (
Minimum only {minPayoffLabel}
)}
{/* ── Right panel ── */}
{/* Chart */} {simTrack.length > 0 ? ( ) : (
{customNeedsBalance ? 'Enter a balance and payment to see the chart' : simPaymentN <= 0 ? 'Enter a monthly payment to see the chart' : 'Payment too low to pay off this debt'}
)} {/* Stats row */} {showResults && ( <>
= 0 ? 'grid-cols-2' : 'grid-cols-1')}> {!isCustom && ( 0 ? 'teal' : 'slate'} /> )} 0 ? `${timeSavings} mo` : (isCustom ? numMonths(simTrack) ?? '—' : '—')} sub={isCustom ? 'to pay off' : (timeSavings > 0 ? 'months sooner' : 'same timeline')} color={timeSavings > 0 ? 'amber' : (isCustom ? 'amber' : 'slate')} /> {isCustom && ( 0 ? 'teal' : 'slate'} /> )}
{/* Breakdown */}
Balance today {fmt(activeBalance)}
Total interest {fmt(simInterest)}
Total paid {fmt(simTotalPaid)}
)}
)}
{/* /payoff-print-area */} ); }