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 { 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 (
|
||||||
<>
|
<>
|
||||||
Loading…
Reference in New Issue