BillTracker/client/pages/PayoffPage.jsx

663 lines
28 KiB
React
Raw Normal View History

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 (
<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>
);
}
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>
);
}
function EmptyDebts() {
return (
<div className="flex flex-col items-center justify-center rounded-xl bg-muted/20 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 bills 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>,
or use the <strong>Custom</strong> option in the dropdown above.
</p>
</div>
);
}
function NoSelection() {
return (
<div className="flex flex-col items-center justify-center rounded-xl bg-muted/20 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, or select <strong>Custom</strong> to simulate any loan.
</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); // 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 (
<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>
);
}
const showResults = (isCustom && activeBalance > 0 && simTrack.length > 0) ||
(!isCustom && bill && simTrack.length > 0);
return (
<>
<style>{PRINT_STYLES}</style>
<div id="payoff-print-area">
{/* ── Print-only summary header (hidden on screen) ── */}
<div className="print-only" style={{ display: 'none' }}>
<h2 style={{ fontSize: '18px', fontWeight: 700, marginBottom: '4px' }}>
Payoff Simulator {activeName || '—'}
</h2>
{activeBalance > 0 && (
<p style={{ fontSize: '12px', color: '#555', marginBottom: '12px' }}>
Balance: {fmt(activeBalance)}
{simRateN > 0 && ` · Rate: ${simRateN}%`}
{simPaymentN > 0 && ` · Payment: ${fmt(simPaymentN)}/mo`}
{oneTimeExtraN > 0 && ` · One-time extra: ${fmt(oneTimeExtraN)}`}
</p>
)}
</div>
{/* ── Page header ── */}
<div className="mb-6 flex items-start justify-between gap-4 no-print">
<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>
<div className="flex items-center gap-2 shrink-0 mt-1">
{isDirty && (
<Button size="sm" variant="ghost" onClick={handleReset} className="gap-1.5 text-xs">
<RotateCcw className="h-3 w-3" /> Reset
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={handlePrint}
className="gap-1.5 text-xs"
title="Print-friendly view"
>
<Printer className="h-3.5 w-3.5" /> Print
</Button>
</div>
</div>
{/* ── Bill/debt selector ── */}
<div className="mb-6 no-print">
<Select
value={selectedId != null ? String(selectedId) : ''}
onValueChange={handleSelectChange}
>
<SelectTrigger className="w-80">
<SelectValue placeholder="Select a loan or debt…" />
</SelectTrigger>
<SelectContent>
{bills.length > 0 && (
<SelectGroup>
<SelectLabel>Your Bills</SelectLabel>
{bills.map(b => (
<SelectItem key={b.id} value={String(b.id)}>
<span className="font-medium">{b.name}</span>
<span className="ml-2 text-muted-foreground font-mono text-xs">
{fmt(b.current_balance)}
</span>
</SelectItem>
))}
</SelectGroup>
)}
{bills.length > 0 && <SelectSeparator />}
<SelectGroup>
<SelectLabel>Manual Entry</SelectLabel>
<SelectItem value="custom">
Custom not in Bill Tracker
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
{bills.length === 0 && !isCustom && (
<p className="mt-3 text-xs text-muted-foreground">
No bills with a current balance found.{' '}
<button
type="button"
className="underline text-primary hover:opacity-80"
onClick={() => setSelectedId('custom')}
>
Use Custom instead
</button>
</p>
)}
</div>
{/* ── Empty / no-selection states ── */}
{!isCustom && !bill && bills.length === 0 && <EmptyDebts />}
{!isCustom && !bill && bills.length > 0 && <NoSelection />}
{/* ── Main content ── */}
{(isCustom || bill) && (
<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">
{/* Custom mode: Name + Balance inputs */}
{isCustom && (
<>
<InputRow label="Loan / Debt Name" hint="Optional">
<Input
value={customName}
onChange={e => setCustomName(e.target.value)}
placeholder="e.g. Car Loan, Mortgage…"
className="no-print"
/>
<p className="print-only hidden text-sm font-semibold">{customName || 'Custom Loan'}</p>
</InputRow>
<InputRow label="Current Balance" hint="Required">
<div className="flex items-center gap-2 no-print">
<span className="text-sm text-muted-foreground shrink-0">$</span>
<Input
type="number" min="0" step="100"
value={customBalance}
onChange={e => setCustomBalance(e.target.value)}
className="font-mono"
placeholder="0.00"
autoFocus
/>
</div>
<p className="print-only hidden font-mono text-sm font-semibold">{fmt(activeBalance)}</p>
{customNeedsBalance && (
<p className="text-[11px] text-amber-500 mt-1 flex items-center gap-1 no-print">
<AlertCircle className="h-3 w-3 shrink-0" />
Balance is required to run the simulation
</p>
)}
</InputRow>
</>
)}
{/* Bill mode: Required minimum display */}
{!isCustom && (
<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>
)}
{!isCustom && minPayment <= 0 && (
<p className="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1.5 no-print">
<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 no-print"
placeholder="0.00"
/>
<span className="text-sm text-muted-foreground shrink-0">%</span>
<span className="print-only hidden font-mono text-sm">{simRateN}%</span>
</div>
</InputRow>
{/* Monthly payment */}
<InputRow label="Monthly Payment">
<div className="no-print">
<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>
)}
{!isCustom && 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>
)}
</div>
<p className="print-only hidden font-mono text-sm font-semibold">{fmt(simPaymentN)}/mo</p>
</InputRow>
{/* One-time extra */}
<InputRow label="One-time Extra This Month" hint="Optional lump sum">
<div className="flex items-center gap-1 no-print">
<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>
{oneTimeExtraN > 0 && (
<p className="print-only hidden text-sm">{fmt(oneTimeExtraN)}</p>
)}
</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">
{customNeedsBalance
? 'Enter a balance to see payoff date'
: 'Enter a payment to see payoff date'}
</p>
)}
{!isCustom && 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={activeBalance}
/>
) : (
<div className="flex items-center justify-center rounded-xl bg-muted/20 h-[300px] text-sm text-muted-foreground">
{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'}
</div>
)}
{/* Stats row */}
{showResults && (
<>
<div className={cn('grid gap-3', !isCustom && interestSavings >= 0 ? 'grid-cols-2' : 'grid-cols-1')}>
{!isCustom && (
<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` : (isCustom ? numMonths(simTrack) ?? '—' : '—')}
sub={isCustom ? 'to pay off' : (timeSavings > 0 ? 'months sooner' : 'same timeline')}
color={timeSavings > 0 ? 'amber' : (isCustom ? 'amber' : 'slate')}
/>
{isCustom && (
<StatCard
label="Total Interest"
value={fmtShort(simInterest)}
sub="at this payment"
color={simInterest > 0 ? 'teal' : '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(activeBalance)}</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>{/* /payoff-print-area */}
</>
);
}