748 lines
33 KiB
TypeScript
748 lines
33 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||
import { AlertCircle, Calculator, HelpCircle, Info, Printer, RefreshCw, RotateCcw, Save, 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||
import {
|
||
Select, SelectContent, SelectGroup, SelectItem, SelectLabel,
|
||
SelectSeparator, SelectTrigger, SelectValue,
|
||
} from '@/components/ui/select';
|
||
import { cn, errMessage } from '@/lib/utils';
|
||
import { formatUSD, formatUSDWhole, asDollars } from '@/lib/money';
|
||
import PayoffChart from '@/components/snowball/PayoffChart';
|
||
import type { Bill } from '@/types';
|
||
|
||
interface ScheduleMonth {
|
||
month: number;
|
||
balance: number;
|
||
interest: number;
|
||
}
|
||
|
||
// ─── 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: number | null | undefined): string {
|
||
return formatUSD(asDollars(Number(v) || 0));
|
||
}
|
||
|
||
function fmtShort(v: number | null | undefined): string {
|
||
return formatUSDWhole(asDollars(Number(v) || 0));
|
||
}
|
||
|
||
function buildPayoffSchedule(balance: number, annualRatePct: number, monthlyPayment: number, oneTimeExtra = 0): ScheduleMonth[] {
|
||
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: ScheduleMonth[] = [];
|
||
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: ScheduleMonth[], now = new Date()): string | null {
|
||
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: ScheduleMonth[]): string | null {
|
||
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 ───────────────────────────────────────────────────────────
|
||
|
||
// Focusable inline-help tooltip for a non-obvious figure (keyboard + SR accessible).
|
||
function Help({ text, label }: { text: string; label: string }) {
|
||
return (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
aria-label={`${label}: help`}
|
||
className="ml-1 inline-flex h-3.5 w-3.5 items-center justify-center rounded-full text-muted-foreground/70 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring no-print"
|
||
>
|
||
<HelpCircle className="h-3 w-3" />
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent className="max-w-[240px] text-xs leading-relaxed">{text}</TooltipContent>
|
||
</Tooltip>
|
||
);
|
||
}
|
||
|
||
function StatCard({ label, value, sub, color = 'amber', help }: {
|
||
label: ReactNode;
|
||
value: ReactNode;
|
||
sub?: ReactNode;
|
||
color?: string;
|
||
help?: string;
|
||
}) {
|
||
const colors: Record<string, string> = {
|
||
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 inline-flex items-center justify-center">
|
||
{label}{help && typeof label === 'string' && <Help text={help} label={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 }: { label: ReactNode; hint?: ReactNode; children: ReactNode }) {
|
||
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<Bill[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [loadError, setLoadError] = useState<string | null>(null);
|
||
const [selectedId, setSelectedId] = useState<number | 'custom' | null>(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';
|
||
|
||
// isInitial=true drives the full-page loading/error state (first load + retry).
|
||
// A background refresh (after a save/undo) is "silent": it updates the data
|
||
// without flashing the skeleton or replacing the page on a transient failure.
|
||
const loadData = useCallback((isInitial = false) => {
|
||
if (isInitial) { setLoading(true); setLoadError(null); }
|
||
// Use api.bills() so ALL active bills with a balance appear (not just debt categories)
|
||
(api.bills() as Promise<Bill[]>)
|
||
.then((allBills) => {
|
||
const withBalance = (allBills || [])
|
||
.filter(b => (b.current_balance ?? 0) > 0 && !b.is_subscription)
|
||
.sort((a, b) => a.name.localeCompare(b.name));
|
||
setBills(withBalance);
|
||
// Only auto-pick a bill on the initial load — never yank the user's
|
||
// current selection on a background refresh (functional update reads the
|
||
// live value, avoiding the stale-closure snap-back).
|
||
if (withBalance.length > 0) {
|
||
setSelectedId(prev => (prev == null ? withBalance[0]!.id : prev));
|
||
}
|
||
})
|
||
.catch(err => {
|
||
if (isInitial) setLoadError(errMessage(err, 'Failed to load data'));
|
||
else toast.error(errMessage(err, "Couldn't refresh the debt list"));
|
||
})
|
||
.finally(() => { if (isInitial) setLoading(false); });
|
||
}, []);
|
||
|
||
useEffect(() => { loadData(true); }, [loadData]);
|
||
|
||
const bill = useMemo(
|
||
() => (isCustom ? null : bills.find(b => b.id === selectedId) ?? null),
|
||
[bills, selectedId, isCustom],
|
||
);
|
||
|
||
// 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 ?? '');
|
||
|
||
// The "vs minimum" baseline only makes sense when the bill actually has a
|
||
// minimum payment on record; without one we show absolute payoff figures.
|
||
const hasMinimum = !isCustom && minPayment > 0;
|
||
const { minTrack, simTrack } = useMemo(() => {
|
||
if (!activeBalance) return { minTrack: [] as ScheduleMonth[], simTrack: [] as ScheduleMonth[] };
|
||
return {
|
||
minTrack: hasMinimum ? buildPayoffSchedule(activeBalance, simRateN, minPayment) : [],
|
||
simTrack: buildPayoffSchedule(activeBalance, simRateN, simPaymentN, oneTimeExtraN),
|
||
};
|
||
}, [activeBalance, hasMinimum, minPayment, simRateN, simPaymentN, oneTimeExtraN]);
|
||
|
||
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 hasBaseline = minTrack.length > 0; // a real, amortizing minimum to compare against
|
||
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);
|
||
|
||
// No APR on record → the schedule is running interest-free (distinct from a
|
||
// genuine 0%). `interest_rate == null` means the field was never set.
|
||
const aprMissing = !isCustom && !!bill && bill.interest_rate == null;
|
||
const simCapped = simTrack.length >= 600; // hit the 600-month cap → won't realistically pay off
|
||
const paymentBelowMin = !isCustom && simPaymentN > 0 && simPaymentN < minPayment && minPayment > 0;
|
||
const paymentTooLow = activeBalance > 0 && simPaymentN > 0 && (simTrack.length === 0 || simCapped);
|
||
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();
|
||
|
||
// Persist the tuned APR back to the bill (the one durable fact worth keeping —
|
||
// it makes every future estimate interest-aware). Deliberately does NOT touch
|
||
// expected_amount / the budget. Rate differs from what's stored ⇒ offer it.
|
||
const rateChanged = !isCustom && !!bill && simRateN !== (bill.interest_rate ?? 0);
|
||
|
||
const handleSaveApr = async () => {
|
||
if (!bill || applying) return;
|
||
const prevRate = bill.interest_rate ?? null;
|
||
setApplying(true);
|
||
try {
|
||
await api.updateBill(bill.id, { interest_rate: simRateN });
|
||
toast.success(`Saved ${simRateN}% APR to "${bill.name}" — payoff estimates now include interest`, {
|
||
action: {
|
||
label: 'Undo',
|
||
onClick: async () => {
|
||
try {
|
||
await api.updateBill(bill.id, { interest_rate: prevRate });
|
||
toast.success('Interest rate reverted');
|
||
loadData();
|
||
} catch (err) {
|
||
toast.error(errMessage(err, "Couldn't revert the interest rate"));
|
||
}
|
||
},
|
||
},
|
||
});
|
||
loadData();
|
||
} catch (err) {
|
||
toast.error(errMessage(err, "Couldn't save the interest rate"));
|
||
} finally {
|
||
setApplying(false);
|
||
}
|
||
};
|
||
|
||
const handleSelectChange = (val: string) => {
|
||
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(true)} className="mt-4 gap-1.5 text-xs">
|
||
<RefreshCw className="h-3 w-3" /> Try again
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Capped (hit the 600-month wall) counts as "won't pay off" — don't present a
|
||
// bogus payoff date / results for it.
|
||
const showResults = !simCapped && (
|
||
(isCustom && activeBalance > 0 && simTrack.length > 0) ||
|
||
(!isCustom && !!bill && simTrack.length > 0)
|
||
);
|
||
|
||
return (
|
||
<TooltipProvider delayDuration={200}>
|
||
<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">
|
||
A what-if calculator for one debt — see how a bigger monthly payment and your APR change the payoff date and total interest. (For a whole-plan strategy across debts, use the Snowball page.)
|
||
</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 inline-flex items-center">
|
||
Required Minimum<Help label="Required Minimum" text="The smallest payment your lender requires each month." />
|
||
</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" />
|
||
No minimum payment on this bill — edit the bill's Debt Details to add one and unlock the savings-vs-minimum comparison.
|
||
</p>
|
||
)}
|
||
|
||
{/* Interest rate */}
|
||
<InputRow
|
||
label={<span className="inline-flex items-center">Interest Rate<Help label="Interest Rate" text="Your annual rate (APR). Save it to the bill so every future estimate includes interest — otherwise payoff is calculated interest-free." /></span>}
|
||
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>
|
||
{aprMissing && simRateN <= 0 && (
|
||
<p className="text-[11px] text-amber-600 dark:text-amber-400 flex items-start gap-1 mt-1.5 no-print">
|
||
<Info className="h-3 w-3 shrink-0 mt-0.5" />
|
||
No interest rate on this bill — this estimate assumes 0% (interest-free). Enter your APR, then Save it.
|
||
</p>
|
||
)}
|
||
{rateChanged && (
|
||
<button
|
||
type="button"
|
||
onClick={handleSaveApr}
|
||
disabled={applying}
|
||
className="mt-1.5 inline-flex items-center gap-1 text-[11px] text-primary hover:underline disabled:opacity-50 no-print"
|
||
>
|
||
<Save className="h-3 w-3 shrink-0" />
|
||
{applying ? 'Saving…' : `Save ${simRateN}% APR to this bill`}
|
||
<Help label="Save APR" text="Saves this rate to the bill. Doesn't change the bill's amount or your budget." />
|
||
</button>
|
||
)}
|
||
</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-start gap-1 mt-1">
|
||
<AlertCircle className="h-3 w-3 shrink-0 mt-0.5" />
|
||
{simCapped && simRateN <= 0
|
||
? 'This debt won’t pay off in a reasonable time at this payment.'
|
||
: 'Payment too low to overcome the interest — the balance never shrinks.'}
|
||
</p>
|
||
)}
|
||
</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 */}
|
||
{showResults ? (
|
||
<>
|
||
<PayoffChart
|
||
minTrack={minTrack}
|
||
simTrack={simTrack}
|
||
startBalance={activeBalance}
|
||
/>
|
||
<p className="text-[11px] text-muted-foreground text-center -mt-2 no-print">
|
||
Projected balance over time{hasBaseline ? ' — dashed grey is paying only the minimum' : ''}.
|
||
</p>
|
||
</>
|
||
) : (
|
||
<div className="flex items-center justify-center rounded-xl bg-muted/20 h-[300px] text-center text-sm text-muted-foreground px-6">
|
||
{customNeedsBalance
|
||
? 'Enter a balance and payment to see the chart'
|
||
: simPaymentN <= 0
|
||
? 'Enter a monthly payment to see the chart'
|
||
: simCapped
|
||
? 'This payment won’t pay the debt off — raise it to see a payoff curve.'
|
||
: 'Payment too low to pay off this debt'}
|
||
</div>
|
||
)}
|
||
|
||
{/* Stats row */}
|
||
{showResults && (
|
||
<>
|
||
<div className="grid gap-3 grid-cols-2">
|
||
{hasBaseline ? (
|
||
<>
|
||
<StatCard
|
||
label="Interest Savings"
|
||
help="Interest you avoid at this payment vs paying only the minimum."
|
||
value={fmtShort(interestSavings)}
|
||
sub="vs minimum only"
|
||
color={interestSavings > 0 ? 'teal' : 'slate'}
|
||
/>
|
||
<StatCard
|
||
label="Time Savings"
|
||
help="How many months sooner you're debt-free vs paying only the minimum."
|
||
value={timeSavings > 0 ? `${timeSavings} mo` : '—'}
|
||
sub={timeSavings > 0 ? 'months sooner' : 'same timeline'}
|
||
color={timeSavings > 0 ? 'amber' : 'slate'}
|
||
/>
|
||
</>
|
||
) : (
|
||
<>
|
||
<StatCard
|
||
label="Time to Payoff"
|
||
value={numMonths(simTrack) ?? '—'}
|
||
sub={simPayoffLabel ? `by ${simPayoffLabel}` : 'to pay off'}
|
||
color="amber"
|
||
/>
|
||
<StatCard
|
||
label="Total Interest"
|
||
help="Total interest over the life of the debt at this payment and APR."
|
||
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 */}
|
||
</TooltipProvider>
|
||
);
|
||
}
|