refactor(ts): PayoffPage → TypeScript (debt payoff simulator)

This commit is contained in:
null 2026-07-04 22:36:24 -05:00
parent 7a33bd1d56
commit c5d2aeda29
1 changed files with 35 additions and 23 deletions

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import { AlertCircle, ArrowRight, Calculator, Printer, RefreshCw, RotateCcw, TrendingDown } from 'lucide-react'; import { AlertCircle, ArrowRight, Calculator, Printer, RefreshCw, RotateCcw, TrendingDown } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
@ -9,9 +9,16 @@ import {
Select, SelectContent, SelectGroup, SelectItem, SelectLabel, Select, SelectContent, SelectGroup, SelectItem, SelectLabel,
SelectSeparator, SelectTrigger, SelectValue, SelectSeparator, SelectTrigger, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { cn } from '@/lib/utils'; import { cn, errMessage } from '@/lib/utils';
import { formatUSD, formatUSDWhole } from '@/lib/money'; import { formatUSD, formatUSDWhole, asDollars } from '@/lib/money';
import PayoffChart from '@/components/snowball/PayoffChart'; import PayoffChart from '@/components/snowball/PayoffChart';
import type { Bill } from '@/types';
interface ScheduleMonth {
month: number;
balance: number;
interest: number;
}
// ─── Print isolation ────────────────────────────────────────────────────────── // ─── Print isolation ──────────────────────────────────────────────────────────
@ -36,20 +43,20 @@ const PRINT_STYLES = `
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function fmt(v) { function fmt(v: number | null | undefined): string {
return formatUSD(v); return formatUSD(asDollars(Number(v) || 0));
} }
function fmtShort(v) { function fmtShort(v: number | null | undefined): string {
return formatUSDWhole(v); return formatUSDWhole(asDollars(Number(v) || 0));
} }
function buildPayoffSchedule(balance, annualRatePct, monthlyPayment, oneTimeExtra = 0) { function buildPayoffSchedule(balance: number, annualRatePct: number, monthlyPayment: number, oneTimeExtra = 0): ScheduleMonth[] {
if (!balance || balance <= 0 || !monthlyPayment || monthlyPayment <= 0) return []; if (!balance || balance <= 0 || !monthlyPayment || monthlyPayment <= 0) return [];
const rate = (annualRatePct || 0) / 100 / 12; const rate = (annualRatePct || 0) / 100 / 12;
if (rate > 0 && monthlyPayment <= balance * rate) return []; if (rate > 0 && monthlyPayment <= balance * rate) return [];
let bal = balance; let bal = balance;
const months = []; const months: ScheduleMonth[] = [];
for (let i = 0; i < 600; i++) { for (let i = 0; i < 600; i++) {
const interest = Math.round(bal * rate * 100) / 100; const interest = Math.round(bal * rate * 100) / 100;
const pmt = Math.min(bal + interest, i === 0 ? monthlyPayment + oneTimeExtra : monthlyPayment); const pmt = Math.min(bal + interest, i === 0 ? monthlyPayment + oneTimeExtra : monthlyPayment);
@ -61,13 +68,13 @@ function buildPayoffSchedule(balance, annualRatePct, monthlyPayment, oneTimeExtr
return months; return months;
} }
function payoffLabel(track, now = new Date()) { function payoffLabel(track: ScheduleMonth[], now = new Date()): string | null {
if (!track.length) return null; if (!track.length) return null;
const d = new Date(now.getFullYear(), now.getMonth() + track.length, 1); const d = new Date(now.getFullYear(), now.getMonth() + track.length, 1);
return d.toLocaleDateString(undefined, { month: 'short', year: 'numeric' }); return d.toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
} }
function numMonths(track) { function numMonths(track: ScheduleMonth[]): string | null {
if (!track.length) return null; if (!track.length) return null;
const y = Math.floor(track.length / 12); const y = Math.floor(track.length / 12);
const m = track.length % 12; const m = track.length % 12;
@ -78,8 +85,13 @@ function numMonths(track) {
// ─── Sub-components ─────────────────────────────────────────────────────────── // ─── Sub-components ───────────────────────────────────────────────────────────
function StatCard({ label, value, sub, color = 'amber' }) { function StatCard({ label, value, sub, color = 'amber' }: {
const colors = { label: ReactNode;
value: ReactNode;
sub?: ReactNode;
color?: string;
}) {
const colors: Record<string, string> = {
amber: 'bg-amber-500/8 border-amber-400/20 text-amber-500 dark:text-amber-400', 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', 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', slate: 'bg-muted/40 border-border/60 text-foreground',
@ -93,7 +105,7 @@ function StatCard({ label, value, sub, color = 'amber' }) {
); );
} }
function InputRow({ label, hint, children }) { function InputRow({ label, hint, children }: { label: ReactNode; hint?: ReactNode; children: ReactNode }) {
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
@ -136,11 +148,11 @@ function NoSelection() {
// ─── PayoffPage ─────────────────────────────────────────────────────────────── // ─── PayoffPage ───────────────────────────────────────────────────────────────
export default function PayoffPage() { export default function PayoffPage() {
const [bills, setBills] = useState([]); const [bills, setBills] = useState<Bill[]>([]);
const [extraPayment, setExtraPayment] = useState(0); const [extraPayment, setExtraPayment] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(null); const [loadError, setLoadError] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState(null); // number | 'custom' | null const [selectedId, setSelectedId] = useState<number | 'custom' | null>(null);
// Custom mode inputs // Custom mode inputs
const [customName, setCustomName] = useState(''); const [customName, setCustomName] = useState('');
@ -158,7 +170,7 @@ export default function PayoffPage() {
setLoading(true); setLoading(true);
setLoadError(null); setLoadError(null);
// Use api.bills() so ALL active bills with a balance appear (not just debt categories) // Use api.bills() so ALL active bills with a balance appear (not just debt categories)
Promise.all([api.bills(), api.snowballSettings()]) Promise.all([api.bills() as Promise<Bill[]>, api.snowballSettings() as Promise<{ extra_payment?: number }>])
.then(([allBills, settings]) => { .then(([allBills, settings]) => {
const withBalance = (allBills || []) const withBalance = (allBills || [])
.filter(b => (b.current_balance ?? 0) > 0 && !b.is_subscription) .filter(b => (b.current_balance ?? 0) > 0 && !b.is_subscription)
@ -166,10 +178,10 @@ export default function PayoffPage() {
setBills(withBalance); setBills(withBalance);
setExtraPayment(Number(settings?.extra_payment) || 0); setExtraPayment(Number(settings?.extra_payment) || 0);
if (withBalance.length > 0 && !selectedId) { if (withBalance.length > 0 && !selectedId) {
setSelectedId(withBalance[0].id); setSelectedId(withBalance[0]!.id);
} }
}) })
.catch(err => setLoadError(err.message || 'Failed to load data')) .catch(err => setLoadError(errMessage(err, 'Failed to load data')))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
@ -205,7 +217,7 @@ export default function PayoffPage() {
const activeName = isCustom ? (customName.trim() || 'Custom Loan') : (bill?.name ?? ''); const activeName = isCustom ? (customName.trim() || 'Custom Loan') : (bill?.name ?? '');
const { minTrack, currentTrack, simTrack } = useMemo(() => { const { minTrack, currentTrack, simTrack } = useMemo(() => {
if (!activeBalance) return { minTrack: [], currentTrack: [], simTrack: [] }; if (!activeBalance) return { minTrack: [] as ScheduleMonth[], currentTrack: [] as ScheduleMonth[], simTrack: [] as ScheduleMonth[] };
const min = !isCustom && minPayment > 0 ? minPayment : 0.01; const min = !isCustom && minPayment > 0 ? minPayment : 0.01;
const currentPmt = !isCustom && isAttack ? min + extraPayment : min; const currentPmt = !isCustom && isAttack ? min + extraPayment : min;
return { return {
@ -267,7 +279,7 @@ export default function PayoffPage() {
} }
}; };
const handleSelectChange = (val) => { const handleSelectChange = (val: string) => {
setSelectedId(val === 'custom' ? 'custom' : Number(val)); setSelectedId(val === 'custom' ? 'custom' : Number(val));
}; };
@ -302,7 +314,7 @@ export default function PayoffPage() {
} }
const showResults = (isCustom && activeBalance > 0 && simTrack.length > 0) || const showResults = (isCustom && activeBalance > 0 && simTrack.length > 0) ||
(!isCustom && bill && simTrack.length > 0); (!isCustom && !!bill && simTrack.length > 0);
return ( return (
<> <>