BillTracker/client/pages/PayoffPage.tsx

847 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 wont 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 wont 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>
);
}