refactor(ts): PayoffPage → TypeScript (debt payoff simulator)
This commit is contained in:
parent
7a33bd1d56
commit
c5d2aeda29
|
|
@ -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<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',
|
||||
|
|
@ -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 (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-baseline justify-between">
|
||||
|
|
@ -136,11 +148,11 @@ function NoSelection() {
|
|||
// ─── PayoffPage ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PayoffPage() {
|
||||
const [bills, setBills] = useState([]);
|
||||
const [bills, setBills] = useState<Bill[]>([]);
|
||||
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<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<number | 'custom' | null>(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<Bill[]>, 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 (
|
||||
<>
|
||||
Loading…
Reference in New Issue