2026-07-04 22:36:24 -05:00
import { useCallback , useEffect , useMemo , useState , type ReactNode } from 'react' ;
2026-07-05 18:09:12 -05:00
import { AlertCircle , Calculator , HelpCircle , Info , Printer , RefreshCw , RotateCcw , Save , TrendingDown } from 'lucide-react' ;
2026-05-30 15:18:45 -05:00
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' ;
2026-07-05 18:09:12 -05:00
import { Tooltip , TooltipContent , TooltipProvider , TooltipTrigger } from '@/components/ui/tooltip' ;
2026-05-30 15:18:45 -05:00
import {
2026-05-30 21:20:51 -05:00
Select , SelectContent , SelectGroup , SelectItem , SelectLabel ,
SelectSeparator , SelectTrigger , SelectValue ,
2026-05-30 15:18:45 -05:00
} from '@/components/ui/select' ;
2026-07-04 22:36:24 -05:00
import { cn , errMessage } from '@/lib/utils' ;
import { formatUSD , formatUSDWhole , asDollars } from '@/lib/money' ;
2026-05-30 15:18:45 -05:00
import PayoffChart from '@/components/snowball/PayoffChart' ;
2026-07-04 22:36:24 -05:00
import type { Bill } from '@/types' ;
interface ScheduleMonth {
month : number ;
balance : number ;
interest : number ;
}
2026-05-30 15:18:45 -05:00
2026-05-30 21:20:51 -05:00
// ─── 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 ; }
}
` ;
2026-05-30 15:18:45 -05:00
// ─── Helpers ──────────────────────────────────────────────────────────────────
2026-07-04 22:36:24 -05:00
function fmt ( v : number | null | undefined ) : string {
return formatUSD ( asDollars ( Number ( v ) || 0 ) ) ;
2026-05-30 15:18:45 -05:00
}
2026-07-04 22:36:24 -05:00
function fmtShort ( v : number | null | undefined ) : string {
return formatUSDWhole ( asDollars ( Number ( v ) || 0 ) ) ;
2026-05-30 15:18:45 -05:00
}
2026-07-04 22:36:24 -05:00
function buildPayoffSchedule ( balance : number , annualRatePct : number , monthlyPayment : number , oneTimeExtra = 0 ) : ScheduleMonth [ ] {
2026-05-30 15:18:45 -05:00
if ( ! balance || balance <= 0 || ! monthlyPayment || monthlyPayment <= 0 ) return [ ] ;
const rate = ( annualRatePct || 0 ) / 100 / 12 ;
if ( rate > 0 && monthlyPayment <= balance * rate ) return [ ] ;
let bal = balance ;
2026-07-04 22:36:24 -05:00
const months : ScheduleMonth [ ] = [ ] ;
2026-05-30 15:18:45 -05:00
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 ;
}
2026-07-04 22:36:24 -05:00
function payoffLabel ( track : ScheduleMonth [ ] , now = new Date ( ) ) : string | null {
2026-05-30 15:18:45 -05:00
if ( ! track . length ) return null ;
const d = new Date ( now . getFullYear ( ) , now . getMonth ( ) + track . length , 1 ) ;
return d . toLocaleDateString ( undefined , { month : 'short' , year : 'numeric' } ) ;
}
2026-07-04 22:36:24 -05:00
function numMonths ( track : ScheduleMonth [ ] ) : string | null {
2026-05-30 15:18:45 -05:00
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 ` ;
}
2026-05-30 21:20:51 -05:00
// ─── Sub-components ───────────────────────────────────────────────────────────
2026-05-30 15:18:45 -05:00
2026-07-05 18:09:12 -05:00
// 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 } : {
2026-07-04 22:36:24 -05:00
label : ReactNode ;
value : ReactNode ;
sub? : ReactNode ;
color? : string ;
2026-07-05 18:09:12 -05:00
help? : string ;
2026-07-04 22:36:24 -05:00
} ) {
const colors : Record < string , string > = {
2026-05-30 15:18:45 -05:00
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 ] ) } >
2026-07-05 18:09:12 -05:00
< 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 >
2026-05-30 15:18:45 -05:00
< 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 >
) ;
}
2026-07-04 22:36:24 -05:00
function InputRow ( { label , hint , children } : { label : ReactNode ; hint? : ReactNode ; children : ReactNode } ) {
2026-05-30 15:18:45 -05:00
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 (
2026-06-07 15:14:09 -05:00
< div className = "flex flex-col items-center justify-center rounded-xl bg-muted/20 px-6 py-16 text-center" >
2026-05-30 15:18:45 -05:00
< TrendingDown className = "h-10 w-10 text-muted-foreground/40 mb-4" / >
2026-05-30 21:20:51 -05:00
< p className = "text-sm font-medium text-foreground" > No bills with a balance found < / p >
2026-05-30 15:18:45 -05:00
< p className = "text-xs text-muted-foreground mt-1" >
Add a current balance to your bills on the { ' ' }
2026-05-30 21:20:51 -05:00
< a href = "/snowball" className = "underline text-primary hover:opacity-80" > Snowball page < / a > ,
or use the < strong > Custom < / strong > option in the dropdown above .
2026-05-30 15:18:45 -05:00
< / p >
< / div >
) ;
}
function NoSelection() {
return (
2026-06-07 15:14:09 -05:00
< div className = "flex flex-col items-center justify-center rounded-xl bg-muted/20 px-6 py-16 text-center" >
2026-05-30 15:18:45 -05:00
< 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 >
2026-05-30 21:20:51 -05:00
< p className = "text-xs text-muted-foreground mt-1" >
Choose from the dropdown above , or select < strong > Custom < / strong > to simulate any loan .
< / p >
2026-05-30 15:18:45 -05:00
< / div >
) ;
}
// ─── PayoffPage ───────────────────────────────────────────────────────────────
export default function PayoffPage() {
2026-07-04 22:36:24 -05:00
const [ bills , setBills ] = useState < Bill [ ] > ( [ ] ) ;
2026-05-30 15:18:45 -05:00
const [ loading , setLoading ] = useState ( true ) ;
2026-07-04 22:36:24 -05:00
const [ loadError , setLoadError ] = useState < string | null > ( null ) ;
const [ selectedId , setSelectedId ] = useState < number | ' custom ' | null > ( null ) ;
2026-05-30 21:20:51 -05:00
// Custom mode inputs
const [ customName , setCustomName ] = useState ( '' ) ;
const [ customBalance , setCustomBalance ] = useState ( '' ) ;
2026-05-30 15:18:45 -05:00
2026-05-30 21:20:51 -05:00
// Per-simulation inputs (reset when selection changes)
2026-05-30 15:18:45 -05:00
const [ simPayment , setSimPayment ] = useState ( '' ) ;
const [ simRate , setSimRate ] = useState ( '' ) ;
const [ oneTimeExtra , setOneTimeExtra ] = useState ( '' ) ;
const [ applying , setApplying ] = useState ( false ) ;
2026-05-30 21:20:51 -05:00
const isCustom = selectedId === 'custom' ;
2026-07-05 18:09:12 -05:00
// 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 ) ; }
2026-05-30 21:20:51 -05:00
// Use api.bills() so ALL active bills with a balance appear (not just debt categories)
2026-07-05 18:09:12 -05:00
( api . bills ( ) as Promise < Bill [ ] > )
. then ( ( allBills ) = > {
2026-05-30 21:20:51 -05:00
const withBalance = ( allBills || [ ] )
. filter ( b = > ( b . current_balance ? ? 0 ) > 0 && ! b . is_subscription )
. sort ( ( a , b ) = > a . name . localeCompare ( b . name ) ) ;
setBills ( withBalance ) ;
2026-07-05 18:09:12 -05:00
// 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 ) ) ;
2026-05-30 15:18:45 -05:00
}
} )
2026-07-05 18:09:12 -05:00
. 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 ) ; } ) ;
} , [ ] ) ;
2026-05-30 15:18:45 -05:00
2026-07-05 18:09:12 -05:00
useEffect ( ( ) = > { loadData ( true ) ; } , [ loadData ] ) ;
2026-05-30 15:18:45 -05:00
2026-05-30 21:20:51 -05:00
const bill = useMemo (
( ) = > ( isCustom ? null : bills . find ( b = > b . id === selectedId ) ? ? null ) ,
[ bills , selectedId , isCustom ] ,
) ;
// Reset sim inputs whenever selection changes
2026-05-30 15:18:45 -05:00
useEffect ( ( ) = > {
2026-05-30 21:20:51 -05:00
if ( isCustom ) {
setSimPayment ( '' ) ;
setSimRate ( '0' ) ;
setOneTimeExtra ( '' ) ;
return ;
}
2026-05-30 15:18:45 -05:00
if ( ! bill ) return ;
setSimPayment ( String ( Math . max ( bill . minimum_payment ? ? 0 , bill . expected_amount ? ? 0 ) ) ) ;
setSimRate ( String ( bill . interest_rate ? ? 0 ) ) ;
setOneTimeExtra ( '' ) ;
2026-05-30 21:20:51 -05:00
} , [ selectedId ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
2026-05-30 15:18:45 -05:00
2026-05-30 21:20:51 -05:00
// Derived numeric values
2026-05-30 15:18:45 -05:00
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 ;
2026-05-30 21:20:51 -05:00
const activeBalance = isCustom ? ( parseFloat ( customBalance ) || 0 ) : ( bill ? . current_balance ? ? 0 ) ;
const activeName = isCustom ? ( customName . trim ( ) || 'Custom Loan' ) : ( bill ? . name ? ? '' ) ;
2026-05-30 15:18:45 -05:00
2026-07-05 18:09:12 -05:00
// 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 [ ] } ;
2026-05-30 15:18:45 -05:00
return {
2026-07-05 18:09:12 -05:00
minTrack : hasMinimum ? buildPayoffSchedule ( activeBalance , simRateN , minPayment ) : [ ] ,
simTrack : buildPayoffSchedule ( activeBalance , simRateN , simPaymentN , oneTimeExtraN ) ,
2026-05-30 15:18:45 -05:00
} ;
2026-07-05 18:09:12 -05:00
} , [ activeBalance , hasMinimum , minPayment , simRateN , simPaymentN , oneTimeExtraN ] ) ;
2026-05-30 15:18:45 -05:00
2026-05-30 21:20:51 -05:00
const minInterest = useMemo ( ( ) = > minTrack . reduce ( ( s , m ) = > s + m . interest , 0 ) , [ minTrack ] ) ;
const simInterest = useMemo ( ( ) = > simTrack . reduce ( ( s , m ) = > s + m . interest , 0 ) , [ simTrack ] ) ;
2026-07-05 18:09:12 -05:00
const hasBaseline = minTrack . length > 0 ; // a real, amortizing minimum to compare against
2026-05-30 15:18:45 -05:00
const interestSavings = Math . max ( 0 , minInterest - simInterest ) ;
const timeSavings = Math . max ( 0 , minTrack . length - simTrack . length ) ;
2026-05-30 21:20:51 -05:00
const simTotalPaid = simInterest + activeBalance ;
2026-05-30 15:18:45 -05:00
const simPayoffLabel = payoffLabel ( simTrack ) ;
const minPayoffLabel = payoffLabel ( minTrack ) ;
const simDuration = numMonths ( simTrack ) ;
2026-07-05 18:09:12 -05:00
// 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
2026-05-30 21:20:51 -05:00
const paymentBelowMin = ! isCustom && simPaymentN > 0 && simPaymentN < minPayment && minPayment > 0 ;
2026-07-05 18:09:12 -05:00
const paymentTooLow = activeBalance > 0 && simPaymentN > 0 && ( simTrack . length === 0 || simCapped ) ;
2026-05-30 21:20:51 -05:00
const customNeedsBalance = isCustom && ! customBalance ;
2026-05-30 15:18:45 -05:00
const defaultSimPayment = bill
? String ( Math . max ( bill . minimum_payment ? ? 0 , bill . expected_amount ? ? 0 ) )
: '' ;
const defaultRate = bill ? String ( bill . interest_rate ? ? 0 ) : '' ;
2026-05-30 21:20:51 -05:00
const isDirty = ! isCustom && ( simPayment !== defaultSimPayment || simRate !== defaultRate || oneTimeExtra !== '' ) ;
2026-05-30 15:18:45 -05:00
const handleReset = ( ) = > {
if ( ! bill ) return ;
setSimPayment ( defaultSimPayment ) ;
setSimRate ( defaultRate ) ;
setOneTimeExtra ( '' ) ;
} ;
2026-05-30 21:20:51 -05:00
const handlePrint = ( ) = > window . print ( ) ;
2026-07-05 18:09:12 -05:00
// 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 ( ) = > {
2026-05-30 15:18:45 -05:00
if ( ! bill || applying ) return ;
2026-07-05 18:09:12 -05:00
const prevRate = bill . interest_rate ? ? null ;
2026-05-30 15:18:45 -05:00
setApplying ( true ) ;
try {
2026-07-05 18:09:12 -05:00
await api . updateBill ( bill . id , { interest_rate : simRateN } ) ;
toast . success ( ` Saved ${ simRateN } % APR to " ${ bill . name } " — payoff estimates now include interest ` , {
2026-05-30 15:18:45 -05:00
action : {
label : 'Undo' ,
onClick : async ( ) = > {
2026-07-05 18:09:12 -05:00
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" ) ) ;
}
2026-05-30 15:18:45 -05:00
} ,
} ,
} ) ;
loadData ( ) ;
2026-07-05 18:09:12 -05:00
} catch ( err ) {
toast . error ( errMessage ( err , "Couldn't save the interest rate" ) ) ;
2026-05-30 15:18:45 -05:00
} finally {
setApplying ( false ) ;
}
} ;
2026-07-04 22:36:24 -05:00
const handleSelectChange = ( val : string ) = > {
2026-05-30 21:20:51 -05:00
setSelectedId ( val === 'custom' ? 'custom' : Number ( val ) ) ;
} ;
2026-05-30 15:18:45 -05:00
// ── 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" >
2026-05-30 21:20:51 -05:00
{ [ 1 , 2 , 3 , 4 ] . map ( i = > < div key = { i } className = "h-16 rounded-xl bg-muted/40" / > ) }
2026-05-30 15:18:45 -05:00
< / 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 >
2026-07-05 18:09:12 -05:00
< Button size = "sm" variant = "outline" onClick = { ( ) = > loadData ( true ) } className = "mt-4 gap-1.5 text-xs" >
2026-05-30 15:18:45 -05:00
< RefreshCw className = "h-3 w-3" / > Try again
< / Button >
< / div >
) ;
}
2026-07-05 18:09:12 -05:00
// 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 )
) ;
2026-05-30 21:20:51 -05:00
2026-05-30 15:18:45 -05:00
return (
2026-07-05 18:09:12 -05:00
< TooltipProvider delayDuration = { 200 } >
2026-05-30 21:20:51 -05:00
< 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 >
) }
2026-05-30 15:18:45 -05:00
< / div >
2026-05-30 21:20:51 -05:00
{ /* ── 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" >
2026-07-05 18:09:12 -05:00
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 . )
2026-05-30 21:20:51 -05:00
< / 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" >
2026-05-30 15:18:45 -05:00
< SelectValue placeholder = "Select a loan or debt…" / >
< / SelectTrigger >
< SelectContent >
2026-05-30 21:20:51 -05:00
{ 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
2026-05-30 15:18:45 -05:00
< / SelectItem >
2026-05-30 21:20:51 -05:00
< / SelectGroup >
2026-05-30 15:18:45 -05:00
< / SelectContent >
< / Select >
2026-05-30 21:20:51 -05:00
{ 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 >
2026-05-30 15:18:45 -05:00
2026-05-30 21:20:51 -05:00
{ /* ── 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 >
< / >
) }
2026-05-30 15:18:45 -05:00
2026-05-30 21:20:51 -05:00
{ /* Bill mode: Required minimum display */ }
{ ! isCustom && (
< div className = "rounded-lg bg-muted/30 px-4 py-3 flex items-center justify-between" >
2026-07-05 18:09:12 -05:00
< 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." / >
2026-05-30 21:20:51 -05:00
< / 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 >
2026-05-30 15:18:45 -05:00
) }
2026-05-30 21:20:51 -05:00
{ ! 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" / >
2026-07-05 18:09:12 -05:00
No minimum payment on this bill — edit the bill ' s Debt Details to add one and unlock the savings - vs - minimum comparison .
2026-05-30 15:18:45 -05:00
< / p >
) }
2026-05-30 21:20:51 -05:00
{ /* Interest rate */ }
2026-07-05 18:09:12 -05:00
< 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"
>
2026-05-30 21:20:51 -05:00
< 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 >
2026-05-30 15:18:45 -05:00
< / div >
2026-07-05 18:09:12 -05:00
{ 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 >
) }
2026-05-30 21:20:51 -05:00
< / 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 && (
2026-07-05 18:09:12 -05:00
< 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.' }
2026-05-30 21:20:51 -05:00
< / 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 >
2026-05-30 15:18:45 -05:00
< / div >
< / div >
2026-05-30 21:20:51 -05:00
{ 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 >
2026-05-30 15:18:45 -05:00
< / div >
2026-05-30 21:20:51 -05:00
{ /* ── Right panel ── */ }
< div className = "space-y-4" >
2026-05-30 15:18:45 -05:00
2026-05-30 21:20:51 -05:00
{ /* Chart */ }
2026-07-05 18:09:12 -05:00
{ 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 >
< / >
2026-05-30 21:20:51 -05:00
) : (
2026-07-05 18:09:12 -05:00
< div className = "flex items-center justify-center rounded-xl bg-muted/20 h-[300px] text-center text-sm text-muted-foreground px-6" >
2026-05-30 21:20:51 -05:00
{ customNeedsBalance
? 'Enter a balance and payment to see the chart'
: simPaymentN <= 0
? 'Enter a monthly payment to see the chart'
2026-07-05 18:09:12 -05:00
: simCapped
? 'This payment won’ t pay the debt off — raise it to see a payoff curve.'
: 'Payment too low to pay off this debt' }
2026-05-30 15:18:45 -05:00
< / div >
2026-05-30 21:20:51 -05:00
) }
2026-05-30 15:18:45 -05:00
2026-05-30 21:20:51 -05:00
{ /* Stats row */ }
{ showResults && (
< >
2026-07-05 18:09:12 -05:00
< 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' }
/ >
< / >
2026-05-30 21:20:51 -05:00
) }
2026-05-30 15:18:45 -05:00
< / div >
2026-05-30 21:20:51 -05:00
{ /* 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 >
2026-05-30 15:18:45 -05:00
< / div >
2026-05-30 21:20:51 -05:00
< / >
) }
2026-05-30 15:18:45 -05:00
2026-05-30 21:20:51 -05:00
< / div >
2026-05-30 15:18:45 -05:00
< / div >
2026-05-30 21:20:51 -05:00
) }
2026-05-30 15:18:45 -05:00
2026-05-30 21:20:51 -05:00
< / div > { /* /payoff-print-area */ }
2026-07-05 18:09:12 -05:00
< / TooltipProvider >
2026-05-30 15:18:45 -05:00
) ;
}