From c5d2aeda29336e157b275fbccb64178a694e2ce2 Mon Sep 17 00:00:00 2001 From: null Date: Sat, 4 Jul 2026 22:36:24 -0500 Subject: [PATCH] =?UTF-8?q?refactor(ts):=20PayoffPage=20=E2=86=92=20TypeSc?= =?UTF-8?q?ript=20(debt=20payoff=20simulator)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/{PayoffPage.jsx => PayoffPage.tsx} | 58 +++++++++++-------- 1 file changed, 35 insertions(+), 23 deletions(-) rename client/pages/{PayoffPage.jsx => PayoffPage.tsx} (93%) diff --git a/client/pages/PayoffPage.jsx b/client/pages/PayoffPage.tsx similarity index 93% rename from client/pages/PayoffPage.jsx rename to client/pages/PayoffPage.tsx index f762b40..a32c255 100644 --- a/client/pages/PayoffPage.jsx +++ b/client/pages/PayoffPage.tsx @@ -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 { toast } from 'sonner'; import { api } from '@/api'; @@ -9,9 +9,16 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { cn } from '@/lib/utils'; -import { formatUSD, formatUSDWhole } from '@/lib/money'; +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 ────────────────────────────────────────────────────────── @@ -36,20 +43,20 @@ const PRINT_STYLES = ` // ─── Helpers ────────────────────────────────────────────────────────────────── -function fmt(v) { - return formatUSD(v); +function fmt(v: number | null | undefined): string { + return formatUSD(asDollars(Number(v) || 0)); } -function fmtShort(v) { - return formatUSDWhole(v); +function fmtShort(v: number | null | undefined): string { + 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 []; const rate = (annualRatePct || 0) / 100 / 12; if (rate > 0 && monthlyPayment <= balance * rate) return []; let bal = balance; - const months = []; + 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); @@ -61,13 +68,13 @@ function buildPayoffSchedule(balance, annualRatePct, monthlyPayment, oneTimeExtr return months; } -function payoffLabel(track, now = new Date()) { +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) { +function numMonths(track: ScheduleMonth[]): string | null { if (!track.length) return null; const y = Math.floor(track.length / 12); const m = track.length % 12; @@ -78,8 +85,13 @@ function numMonths(track) { // ─── Sub-components ─────────────────────────────────────────────────────────── -function StatCard({ label, value, sub, color = 'amber' }) { - const colors = { +function StatCard({ label, value, sub, color = 'amber' }: { + label: ReactNode; + value: ReactNode; + sub?: ReactNode; + color?: string; +}) { + const colors: Record = { 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', @@ -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 (
@@ -136,11 +148,11 @@ function NoSelection() { // ─── PayoffPage ─────────────────────────────────────────────────────────────── export default function PayoffPage() { - const [bills, setBills] = useState([]); + const [bills, setBills] = useState([]); const [extraPayment, setExtraPayment] = useState(0); const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); - const [selectedId, setSelectedId] = useState(null); // number | 'custom' | null + const [loadError, setLoadError] = useState(null); + const [selectedId, setSelectedId] = useState(null); // Custom mode inputs const [customName, setCustomName] = useState(''); @@ -158,7 +170,7 @@ export default function PayoffPage() { setLoading(true); setLoadError(null); // Use api.bills() so ALL active bills with a balance appear (not just debt categories) - Promise.all([api.bills(), api.snowballSettings()]) + Promise.all([api.bills() as Promise, api.snowballSettings() as Promise<{ extra_payment?: number }>]) .then(([allBills, settings]) => { const withBalance = (allBills || []) .filter(b => (b.current_balance ?? 0) > 0 && !b.is_subscription) @@ -166,10 +178,10 @@ export default function PayoffPage() { setBills(withBalance); setExtraPayment(Number(settings?.extra_payment) || 0); 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)); }, []); // 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 { 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 currentPmt = !isCustom && isAttack ? min + extraPayment : min; return { @@ -267,7 +279,7 @@ export default function PayoffPage() { } }; - const handleSelectChange = (val) => { + const handleSelectChange = (val: string) => { setSelectedId(val === 'custom' ? 'custom' : Number(val)); }; @@ -302,7 +314,7 @@ export default function PayoffPage() { } const showResults = (isCustom && activeBalance > 0 && simTrack.length > 0) || - (!isCustom && bill && simTrack.length > 0); + (!isCustom && !!bill && simTrack.length > 0); return ( <>