refactor(ts): SnowballPage → TypeScript (debt attack order + projections + plans)
Typed Bill[]/Projection/SnowballSettings; reused SnowballPlan and exported ActivePlan from the plan components; React Query cache setters typed.
This commit is contained in:
parent
d559f4b00b
commit
18eaec04e2
|
|
@ -24,7 +24,7 @@ interface SnapshotDebt {
|
||||||
projected_payoff_month?: number;
|
projected_payoff_month?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActivePlan {
|
export interface ActivePlan {
|
||||||
name: string;
|
name: string;
|
||||||
status: string;
|
status: string;
|
||||||
started_at?: string | null;
|
started_at?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState, type DragEvent, type DragEventHandler, type ReactNode } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
useSnowball, useSnowballSettings, useSnowballActivePlan, useSnowballPlans, useCategories,
|
useSnowball, useSnowballSettings, useSnowballActivePlan, useSnowballPlans, useCategories,
|
||||||
|
|
@ -11,24 +11,25 @@ import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Skeleton } from '@/components/ui/Skeleton';
|
import { Skeleton } from '@/components/ui/Skeleton';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn, errMessage } from '@/lib/utils';
|
||||||
import { moveInArray } from '@/lib/reorder';
|
import { moveInArray } from '@/lib/reorder';
|
||||||
import BillModal from '@/components/BillModal';
|
import BillModal from '@/components/BillModal';
|
||||||
import { makeBillDraft } from '@/lib/billDrafts';
|
import { makeBillDraft } from '@/lib/billDrafts';
|
||||||
import PlanStatusBanner from '@/components/snowball/PlanStatusBanner';
|
import PlanStatusBanner, { type ActivePlan } from '@/components/snowball/PlanStatusBanner';
|
||||||
import PlanHistoryPanel from '@/components/snowball/PlanHistoryPanel';
|
import PlanHistoryPanel, { type SnowballPlan } from '@/components/snowball/PlanHistoryPanel';
|
||||||
import * as AlertDialog from '@radix-ui/react-alert-dialog';
|
import * as AlertDialog from '@radix-ui/react-alert-dialog';
|
||||||
import { formatUSD, formatUSDWhole } from '@/lib/money';
|
import { formatUSD, formatUSDWhole, asDollars } from '@/lib/money';
|
||||||
|
import type { Bill, Category } from '@/types';
|
||||||
|
|
||||||
// ── formatters ────────────────────────────────────────────────────────────────
|
// ── formatters ────────────────────────────────────────────────────────────────
|
||||||
function fmt(val) {
|
function fmt(val: number | null | undefined): string {
|
||||||
return formatUSD(val, { dash: true });
|
return formatUSD(val == null ? undefined : asDollars(val), { dash: true });
|
||||||
}
|
}
|
||||||
function fmtCompact(val) {
|
function fmtCompact(val: number | null | undefined): string {
|
||||||
if (val == null || val === 0) return '—';
|
if (val == null || val === 0) return '—';
|
||||||
return formatUSDWhole(val);
|
return formatUSDWhole(asDollars(val));
|
||||||
}
|
}
|
||||||
function ordinal(n) {
|
function ordinal(n: number | null | undefined): string {
|
||||||
const d = Number(n);
|
const d = Number(n);
|
||||||
if (!d) return '—';
|
if (!d) return '—';
|
||||||
if (d > 3 && d < 21) return `${d}th`;
|
if (d > 3 && d < 21) return `${d}th`;
|
||||||
|
|
@ -36,7 +37,7 @@ function ordinal(n) {
|
||||||
case 1: return `${d}st`; case 2: return `${d}nd`; case 3: return `${d}rd`; default: return `${d}th`;
|
case 1: return `${d}st`; case 2: return `${d}nd`; case 3: return `${d}rd`; default: return `${d}th`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function sortRamseyDebts(debts) {
|
function sortRamseyDebts(debts: Bill[]): Bill[] {
|
||||||
return [...debts].sort((a, b) => {
|
return [...debts].sort((a, b) => {
|
||||||
if (a.current_balance == null && b.current_balance == null) return a.name.localeCompare(b.name);
|
if (a.current_balance == null && b.current_balance == null) return a.name.localeCompare(b.name);
|
||||||
if (a.current_balance == null) return 1;
|
if (a.current_balance == null) return 1;
|
||||||
|
|
@ -45,14 +46,70 @@ function sortRamseyDebts(debts) {
|
||||||
return diff || a.name.localeCompare(b.name);
|
return diff || a.name.localeCompare(b.name);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function isRamseyOrdered(debts) {
|
function isRamseyOrdered(debts: Bill[]): boolean {
|
||||||
const sorted = sortRamseyDebts(debts);
|
const sorted = sortRamseyDebts(debts);
|
||||||
return debts.every((debt, index) => debt.id === sorted[index]?.id);
|
return debts.every((debt, index) => debt.id === sorted[index]?.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DebtProj {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
payoff_display?: string;
|
||||||
|
months?: number;
|
||||||
|
total_interest?: number;
|
||||||
|
current_balance?: number;
|
||||||
|
}
|
||||||
|
interface SnowballProj {
|
||||||
|
debts: DebtProj[];
|
||||||
|
skipped: { name: string }[];
|
||||||
|
payoff_display?: string;
|
||||||
|
months_to_freedom?: number;
|
||||||
|
total_interest_paid?: number;
|
||||||
|
capped?: boolean;
|
||||||
|
}
|
||||||
|
interface AvalancheProj {
|
||||||
|
months_to_freedom?: number;
|
||||||
|
total_interest_paid?: number;
|
||||||
|
payoff_display?: string;
|
||||||
|
}
|
||||||
|
interface Projection {
|
||||||
|
snowball?: SnowballProj;
|
||||||
|
avalanche?: AvalancheProj;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnowballSettings {
|
||||||
|
extra_payment?: number;
|
||||||
|
ramsey_mode?: boolean;
|
||||||
|
ready_current_on_bills?: boolean;
|
||||||
|
ready_emergency_fund?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReadinessItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
ready: boolean;
|
||||||
|
manual?: boolean;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DebtDragProps {
|
||||||
|
draggable: boolean;
|
||||||
|
isDragging?: boolean;
|
||||||
|
isDropTarget?: boolean;
|
||||||
|
onDragStart?: DragEventHandler<HTMLDivElement>;
|
||||||
|
onDragEnter?: DragEventHandler<HTMLDivElement>;
|
||||||
|
onDragOver?: DragEventHandler<HTMLDivElement>;
|
||||||
|
onDragEnd?: DragEventHandler<HTMLDivElement>;
|
||||||
|
onDrop?: DragEventHandler<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditBillState {
|
||||||
|
bill: Bill | null;
|
||||||
|
initialBill?: Partial<Bill>;
|
||||||
|
}
|
||||||
|
|
||||||
// ── SectionDivider ────────────────────────────────────────────────────────────
|
// ── SectionDivider ────────────────────────────────────────────────────────────
|
||||||
function SectionDivider({ label }) {
|
function SectionDivider({ label }: { label: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="shrink-0 text-[10px] font-bold uppercase tracking-[0.12em] text-muted-foreground/50">
|
<span className="shrink-0 text-[10px] font-bold uppercase tracking-[0.12em] text-muted-foreground/50">
|
||||||
|
|
@ -64,7 +121,12 @@ function SectionDivider({ label }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── StatCard ──────────────────────────────────────────────────────────────────
|
// ── StatCard ──────────────────────────────────────────────────────────────────
|
||||||
function StatCard({ label, value, sub, highlight }) {
|
function StatCard({ label, value, sub, highlight }: {
|
||||||
|
label: ReactNode;
|
||||||
|
value: ReactNode;
|
||||||
|
sub?: ReactNode;
|
||||||
|
highlight?: boolean;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('surface-elevated rounded-xl px-5 py-4 space-y-0.5', highlight && 'border border-emerald-500/30')}>
|
<div className={cn('surface-elevated rounded-xl px-5 py-4 space-y-0.5', highlight && 'border border-emerald-500/30')}>
|
||||||
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{label}</p>
|
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{label}</p>
|
||||||
|
|
@ -75,10 +137,10 @@ function StatCard({ label, value, sub, highlight }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Projection panel ──────────────────────────────────────────────────────────
|
// ── Projection panel ──────────────────────────────────────────────────────────
|
||||||
function AvalancheComparison({ snowball, avalanche }) {
|
function AvalancheComparison({ snowball, avalanche }: { snowball: SnowballProj; avalanche: AvalancheProj }) {
|
||||||
if (!snowball.months_to_freedom || !avalanche.months_to_freedom) return null;
|
if (!snowball.months_to_freedom || !avalanche.months_to_freedom) return null;
|
||||||
const monthDiff = snowball.months_to_freedom - avalanche.months_to_freedom;
|
const monthDiff = snowball.months_to_freedom - avalanche.months_to_freedom;
|
||||||
const interestDiff = snowball.total_interest_paid - avalanche.total_interest_paid;
|
const interestDiff = Number(snowball.total_interest_paid ?? 0) - Number(avalanche.total_interest_paid ?? 0);
|
||||||
const same = Math.abs(monthDiff) < 1 && Math.abs(interestDiff) < 1;
|
const same = Math.abs(monthDiff) < 1 && Math.abs(interestDiff) < 1;
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-border/40 px-5 py-3 space-y-1.5">
|
<div className="border-t border-border/40 px-5 py-3 space-y-1.5">
|
||||||
|
|
@ -106,7 +168,13 @@ function AvalancheComparison({ snowball, avalanche }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectionPanel({ projection, projectionLoading, projectionError, onRetry, billCount }) {
|
function ProjectionPanel({ projection, projectionLoading, projectionError, onRetry, billCount }: {
|
||||||
|
projection: Projection | null;
|
||||||
|
projectionLoading: boolean;
|
||||||
|
projectionError: boolean;
|
||||||
|
onRetry: () => void;
|
||||||
|
billCount: number;
|
||||||
|
}) {
|
||||||
if (projectionLoading && !projection) {
|
if (projectionLoading && !projection) {
|
||||||
return (
|
return (
|
||||||
<div className="surface-elevated rounded-xl p-5 space-y-3">
|
<div className="surface-elevated rounded-xl p-5 space-y-3">
|
||||||
|
|
@ -204,7 +272,14 @@ function ProjectionPanel({ projection, projectionLoading, projectionError, onRet
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Readiness strip ───────────────────────────────────────────────────────────
|
// ── Readiness strip ───────────────────────────────────────────────────────────
|
||||||
function ReadinessStrip({ items, readyCount, totalCount, allReady, onToggle, disabled }) {
|
function ReadinessStrip({ items, readyCount, totalCount, allReady, onToggle, disabled }: {
|
||||||
|
items: ReadinessItem[];
|
||||||
|
readyCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
allReady: boolean;
|
||||||
|
onToggle: (id: string, ready: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'surface-elevated rounded-xl px-4 py-3 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between',
|
'surface-elevated rounded-xl px-4 py-3 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between',
|
||||||
|
|
@ -271,24 +346,29 @@ function ReadinessStrip({ items, readyCount, totalCount, allReady, onToggle, dis
|
||||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
export default function SnowballPage() {
|
export default function SnowballPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: bills = [], isPending: billsPending, error: snowballError } = useSnowball();
|
const { data: billsRaw = [], isPending: billsPending, error: snowballError } = useSnowball();
|
||||||
const { data: categories = [] } = useCategories();
|
const bills = (billsRaw ?? []) as Bill[];
|
||||||
const { data: settings } = useSnowballSettings();
|
const { data: categoriesRaw = [] } = useCategories();
|
||||||
const { data: activePlan = null } = useSnowballActivePlan();
|
const categories = (categoriesRaw ?? []) as Category[];
|
||||||
const { data: allPlans = [] } = useSnowballPlans();
|
const { data: settingsRaw } = useSnowballSettings();
|
||||||
|
const settings = settingsRaw as SnowballSettings | undefined;
|
||||||
|
const { data: activePlanRaw = null } = useSnowballActivePlan();
|
||||||
|
const activePlan = (activePlanRaw ?? null) as SnowballPlan | null;
|
||||||
|
const { data: allPlansRaw = [] } = useSnowballPlans();
|
||||||
|
const allPlans = (allPlansRaw ?? []) as SnowballPlan[];
|
||||||
const loading = billsPending;
|
const loading = billsPending;
|
||||||
const loadError = snowballError ? (snowballError.message || 'Failed to load snowball data') : null;
|
const loadError = snowballError ? (snowballError.message || 'Failed to load snowball data') : null;
|
||||||
// Optimistic list/plan edits write through the query cache so existing call
|
// Optimistic list/plan edits write through the query cache so existing call
|
||||||
// sites (setBills(prev => …), setActivePlan(…), setAllPlans(prev => …)) work.
|
// sites (setBills(prev => …), setActivePlan(…), setAllPlans(prev => …)) work.
|
||||||
const setBills = useCallback((u) => queryClient.setQueryData(['snowball'],
|
const setBills = useCallback((u: Bill[] | ((prev: Bill[]) => Bill[])) =>
|
||||||
prev => (typeof u === 'function' ? u(prev || []) : u)), [queryClient]);
|
queryClient.setQueryData<Bill[]>(['snowball'], prev => (typeof u === 'function' ? u(prev || []) : u)), [queryClient]);
|
||||||
const setActivePlan = useCallback((u) => queryClient.setQueryData(['snowball-active-plan'],
|
const setActivePlan = useCallback((plan: SnowballPlan | null) =>
|
||||||
prev => (typeof u === 'function' ? u(prev ?? null) : u)), [queryClient]);
|
queryClient.setQueryData<SnowballPlan | null>(['snowball-active-plan'], plan), [queryClient]);
|
||||||
const setAllPlans = useCallback((u) => queryClient.setQueryData(['snowball-plans'],
|
const setAllPlans = useCallback((u: (prev: SnowballPlan[]) => SnowballPlan[]) =>
|
||||||
prev => (typeof u === 'function' ? u(prev || []) : u)), [queryClient]);
|
queryClient.setQueryData<SnowballPlan[]>(['snowball-plans'], prev => u(prev || [])), [queryClient]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const [editBill, setEditBill] = useState(null);
|
const [editBill, setEditBill] = useState<EditBillState | null>(null);
|
||||||
|
|
||||||
const [extraPayment, setExtraPayment] = useState('');
|
const [extraPayment, setExtraPayment] = useState('');
|
||||||
const [ramseyMode, setRamseyMode] = useState(true);
|
const [ramseyMode, setRamseyMode] = useState(true);
|
||||||
|
|
@ -297,28 +377,25 @@ export default function SnowballPage() {
|
||||||
const [savingSettings, setSavingSettings] = useState(false);
|
const [savingSettings, setSavingSettings] = useState(false);
|
||||||
const extraPaymentRef = useRef('');
|
const extraPaymentRef = useRef('');
|
||||||
|
|
||||||
const [projection, setProjection] = useState(null);
|
const [projection, setProjection] = useState<Projection | null>(null);
|
||||||
const [projectionLoading, setProjectionLoading] = useState(false);
|
const [projectionLoading, setProjectionLoading] = useState(false);
|
||||||
const [projectionError, setProjectionError] = useState(false);
|
const [projectionError, setProjectionError] = useState(false);
|
||||||
const typedExtraRef = useRef('');
|
const typedExtraRef = useRef('');
|
||||||
|
|
||||||
const [editingBalance, setEditingBalance] = useState({ billId: null, value: '' });
|
const [editingBalance, setEditingBalance] = useState<{ billId: number | null; value: string }>({ billId: null, value: '' });
|
||||||
|
|
||||||
const [startingPlan, setStartingPlan] = useState(false);
|
const [startingPlan, setStartingPlan] = useState(false);
|
||||||
const [readinessWarnOpen, setReadinessWarnOpen] = useState(false);
|
const [readinessWarnOpen, setReadinessWarnOpen] = useState(false);
|
||||||
const [draggingId, setDraggingId] = useState(null);
|
const [draggingId, setDraggingId] = useState<number | null>(null);
|
||||||
const [dropTargetId, setDropTargetId] = useState(null);
|
const [dropTargetId, setDropTargetId] = useState<number | null>(null);
|
||||||
|
|
||||||
// ── loading ───────────────────────────────────────────────────────────────
|
// ── loading ───────────────────────────────────────────────────────────────
|
||||||
// Keep the projection in sync with the amount CURRENTLY typed (not just the
|
|
||||||
// last-saved value), so refreshing after a balance edit doesn't drop an
|
|
||||||
// in-progress extra payment. Mirrors the debounced live-projection effect.
|
|
||||||
const loadProjection = useCallback(async () => {
|
const loadProjection = useCallback(async () => {
|
||||||
setProjectionLoading(true);
|
setProjectionLoading(true);
|
||||||
try {
|
try {
|
||||||
const extra = parseFloat(typedExtraRef.current);
|
const extra = parseFloat(typedExtraRef.current);
|
||||||
const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {};
|
const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {};
|
||||||
setProjection(await api.snowballProjection(params));
|
setProjection(await api.snowballProjection(params) as Projection);
|
||||||
setProjectionError(false);
|
setProjectionError(false);
|
||||||
} catch {
|
} catch {
|
||||||
setProjectionError(true);
|
setProjectionError(true);
|
||||||
|
|
@ -331,12 +408,11 @@ export default function SnowballPage() {
|
||||||
queryClient.invalidateQueries({ queryKey: ['snowball-settings'] }),
|
queryClient.invalidateQueries({ queryKey: ['snowball-settings'] }),
|
||||||
]), [queryClient]);
|
]), [queryClient]);
|
||||||
|
|
||||||
// Seed the editable settings form (extra payment, ramsey mode, ready flags)
|
// Seed the editable settings form from the loaded settings whenever they change.
|
||||||
// from the loaded settings whenever they change — this is what `load` did inline.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settings) return;
|
if (!settings) return;
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
const ep = settings.extra_payment > 0 ? String(settings.extra_payment) : '';
|
const ep = Number(settings.extra_payment) > 0 ? String(settings.extra_payment) : '';
|
||||||
setRamseyMode(settings.ramsey_mode !== false);
|
setRamseyMode(settings.ramsey_mode !== false);
|
||||||
setReadyCurrentOnBills(!!settings.ready_current_on_bills);
|
setReadyCurrentOnBills(!!settings.ready_current_on_bills);
|
||||||
setReadyEmergencyFund(!!settings.ready_emergency_fund);
|
setReadyEmergencyFund(!!settings.ready_emergency_fund);
|
||||||
|
|
@ -354,19 +430,19 @@ export default function SnowballPage() {
|
||||||
toast.success('Arranged smallest-to-largest balance');
|
toast.success('Arranged smallest-to-largest balance');
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveDebt = (fromIndex, toIndex) => {
|
const moveDebt = (fromIndex: number, toIndex: number) => {
|
||||||
if (ramseyMode || saving || fromIndex === toIndex) return;
|
if (ramseyMode || saving || fromIndex === toIndex) return;
|
||||||
setBills(prev => moveInArray(prev, fromIndex, toIndex));
|
setBills(prev => moveInArray(prev, fromIndex, toIndex));
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const dragPropsFor = (bill, index) => {
|
const dragPropsFor = (bill: Bill): DebtDragProps => {
|
||||||
if (ramseyMode || saving) return { draggable: false };
|
if (ramseyMode || saving) return { draggable: false };
|
||||||
return {
|
return {
|
||||||
draggable: true,
|
draggable: true,
|
||||||
isDragging: draggingId === bill.id,
|
isDragging: draggingId === bill.id,
|
||||||
isDropTarget: dropTargetId === bill.id && draggingId !== bill.id,
|
isDropTarget: dropTargetId === bill.id && draggingId !== bill.id,
|
||||||
onDragStart: (event) => {
|
onDragStart: (event: DragEvent<HTMLDivElement>) => {
|
||||||
setDraggingId(bill.id);
|
setDraggingId(bill.id);
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
event.dataTransfer.setData('text/plain', String(bill.id));
|
event.dataTransfer.setData('text/plain', String(bill.id));
|
||||||
|
|
@ -374,16 +450,16 @@ export default function SnowballPage() {
|
||||||
onDragEnter: () => {
|
onDragEnter: () => {
|
||||||
if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id);
|
if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id);
|
||||||
},
|
},
|
||||||
onDragOver: (event) => {
|
onDragOver: (event: DragEvent<HTMLDivElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.dataTransfer.dropEffect = 'move';
|
event.dataTransfer.dropEffect = 'move';
|
||||||
if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id);
|
if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id);
|
||||||
},
|
},
|
||||||
onDrop: (event) => {
|
onDrop: (event: DragEvent<HTMLDivElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId);
|
const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId);
|
||||||
const fromIndex = bills.findIndex(item => item.id === sourceId);
|
const fromIndex = bills.findIndex(item => item.id === sourceId);
|
||||||
if (fromIndex >= 0) moveDebt(fromIndex, index);
|
if (fromIndex >= 0) moveDebt(fromIndex, bills.findIndex(item => item.id === bill.id));
|
||||||
setDraggingId(null);
|
setDraggingId(null);
|
||||||
setDropTargetId(null);
|
setDropTargetId(null);
|
||||||
},
|
},
|
||||||
|
|
@ -402,7 +478,7 @@ export default function SnowballPage() {
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
toast.success('Order saved');
|
toast.success('Order saved');
|
||||||
loadProjection();
|
loadProjection();
|
||||||
} catch (err) { toast.error(err.message || 'Failed to save order'); }
|
} catch (err) { toast.error(errMessage(err, 'Failed to save order')); }
|
||||||
finally { setSaving(false); }
|
finally { setSaving(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -415,20 +491,20 @@ export default function SnowballPage() {
|
||||||
if (val === extraPaymentRef.current) return;
|
if (val === extraPaymentRef.current) return;
|
||||||
setSavingSettings(true);
|
setSavingSettings(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.saveSnowballSettings({ extra_payment: val === '' ? 0 : parseFloat(val) });
|
const result = await api.saveSnowballSettings({ extra_payment: val === '' ? 0 : parseFloat(val) }) as SnowballSettings;
|
||||||
const saved = result.extra_payment > 0 ? String(result.extra_payment) : '';
|
const saved = Number(result.extra_payment) > 0 ? String(result.extra_payment) : '';
|
||||||
extraPaymentRef.current = saved;
|
extraPaymentRef.current = saved;
|
||||||
setExtraPayment(saved);
|
setExtraPayment(saved);
|
||||||
toast.success('Extra payment saved');
|
toast.success('Extra payment saved');
|
||||||
loadProjection();
|
loadProjection();
|
||||||
} catch (err) { toast.error(err.message || 'Failed to save'); }
|
} catch (err) { toast.error(errMessage(err, 'Failed to save')); }
|
||||||
finally { setSavingSettings(false); }
|
finally { setSavingSettings(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRamseyModeChange = async (checked) => {
|
const handleRamseyModeChange = async (checked: boolean) => {
|
||||||
setSavingSettings(true);
|
setSavingSettings(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.saveSnowballSettings({ ramsey_mode: checked });
|
const result = await api.saveSnowballSettings({ ramsey_mode: checked }) as SnowballSettings;
|
||||||
const nextMode = result.ramsey_mode !== false;
|
const nextMode = result.ramsey_mode !== false;
|
||||||
setRamseyMode(nextMode);
|
setRamseyMode(nextMode);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
|
|
@ -437,13 +513,13 @@ export default function SnowballPage() {
|
||||||
loadProjection();
|
loadProjection();
|
||||||
toast.success(nextMode ? 'Ramsey Mode enabled' : 'Custom order enabled');
|
toast.success(nextMode ? 'Ramsey Mode enabled' : 'Custom order enabled');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to save Snowball mode');
|
toast.error(errMessage(err, 'Failed to save Snowball mode'));
|
||||||
} finally {
|
} finally {
|
||||||
setSavingSettings(false);
|
setSavingSettings(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReadinessToggle = async (id, checked) => {
|
const handleReadinessToggle = async (id: string, checked: boolean) => {
|
||||||
const payload = id === 'current_on_bills'
|
const payload = id === 'current_on_bills'
|
||||||
? { ready_current_on_bills: checked }
|
? { ready_current_on_bills: checked }
|
||||||
: { ready_emergency_fund: checked };
|
: { ready_emergency_fund: checked };
|
||||||
|
|
@ -454,40 +530,40 @@ export default function SnowballPage() {
|
||||||
|
|
||||||
setSavingSettings(true);
|
setSavingSettings(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.saveSnowballSettings(payload);
|
const result = await api.saveSnowballSettings(payload) as SnowballSettings;
|
||||||
setReadyCurrentOnBills(!!result.ready_current_on_bills);
|
setReadyCurrentOnBills(!!result.ready_current_on_bills);
|
||||||
setReadyEmergencyFund(!!result.ready_emergency_fund);
|
setReadyEmergencyFund(!!result.ready_emergency_fund);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (id === 'current_on_bills') setReadyCurrentOnBills(previous);
|
if (id === 'current_on_bills') setReadyCurrentOnBills(previous);
|
||||||
else setReadyEmergencyFund(previous);
|
else setReadyEmergencyFund(previous);
|
||||||
toast.error(err.message || 'Failed to save readiness');
|
toast.error(errMessage(err, 'Failed to save readiness'));
|
||||||
} finally {
|
} finally {
|
||||||
setSavingSettings(false);
|
setSavingSettings(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── inline balance edit ───────────────────────────────────────────────────
|
// ── inline balance edit ───────────────────────────────────────────────────
|
||||||
const startEditBalance = (bill) =>
|
const startEditBalance = (bill: Bill) =>
|
||||||
setEditingBalance({ billId: bill.id, value: bill.current_balance != null ? String(bill.current_balance) : '' });
|
setEditingBalance({ billId: bill.id, value: bill.current_balance != null ? String(bill.current_balance) : '' });
|
||||||
|
|
||||||
const commitBalance = async (billId) => {
|
const commitBalance = async (billId: number) => {
|
||||||
const raw = editingBalance.value.trim();
|
const raw = editingBalance.value.trim();
|
||||||
const num = raw === '' ? null : parseFloat(raw);
|
const num = raw === '' ? null : parseFloat(raw);
|
||||||
if (raw !== '' && (isNaN(num) || num < 0)) { toast.error('Balance must be a non-negative number'); return; }
|
if (raw !== '' && (num == null || isNaN(num) || num < 0)) { toast.error('Balance must be a non-negative number'); return; }
|
||||||
const current = bills.find(b => b.id === billId);
|
const current = bills.find(b => b.id === billId);
|
||||||
if (num === current?.current_balance) { setEditingBalance({ billId: null, value: '' }); return; }
|
if (num === current?.current_balance) { setEditingBalance({ billId: null, value: '' }); return; }
|
||||||
try {
|
try {
|
||||||
await api.updateBillBalance(billId, num);
|
await api.updateBillBalance(billId, num);
|
||||||
setBills(prev => {
|
setBills(prev => {
|
||||||
const next = prev.map(b => b.id === billId ? { ...b, current_balance: num } : b);
|
const next = prev.map(b => b.id === billId ? { ...b, current_balance: (num == null ? null : asDollars(num)) } : b);
|
||||||
return ramseyMode ? sortRamseyDebts(next) : next;
|
return ramseyMode ? sortRamseyDebts(next) : next;
|
||||||
});
|
});
|
||||||
setEditingBalance({ billId: null, value: '' });
|
setEditingBalance({ billId: null, value: '' });
|
||||||
loadProjection();
|
loadProjection();
|
||||||
} catch (err) { toast.error(err.message || 'Failed to update balance'); }
|
} catch (err) { toast.error(errMessage(err, 'Failed to update balance')); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeFromSnowball = async (bill) => {
|
const removeFromSnowball = async (bill: Bill) => {
|
||||||
try {
|
try {
|
||||||
await api.updateBillSnowball(bill.id, { snowball_include: false, snowball_exempt: true });
|
await api.updateBillSnowball(bill.id, { snowball_include: false, snowball_exempt: true });
|
||||||
setBills(prev => prev.filter(b => b.id !== bill.id));
|
setBills(prev => prev.filter(b => b.id !== bill.id));
|
||||||
|
|
@ -495,14 +571,11 @@ export default function SnowballPage() {
|
||||||
toast.success(`${bill.name} removed from Snowball`);
|
toast.success(`${bill.name} removed from Snowball`);
|
||||||
loadProjection();
|
loadProjection();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to remove bill from Snowball');
|
toast.error(errMessage(err, 'Failed to remove bill from Snowball'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── live projection (debounced server call as user types extra amount) ──────
|
// ── live projection (debounced server call as user types extra amount) ──────
|
||||||
// Passes ?extra=N to the projection endpoint so the server simulation runs
|
|
||||||
// with the current input — no client-side duplicate of snowballService logic.
|
|
||||||
const liveProjectionRef = useRef(null);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
typedExtraRef.current = extraPayment; // keep loadProjection() in sync with the input
|
typedExtraRef.current = extraPayment; // keep loadProjection() in sync with the input
|
||||||
const t = setTimeout(async () => {
|
const t = setTimeout(async () => {
|
||||||
|
|
@ -510,7 +583,7 @@ export default function SnowballPage() {
|
||||||
const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {};
|
const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {};
|
||||||
try {
|
try {
|
||||||
setProjectionLoading(true);
|
setProjectionLoading(true);
|
||||||
const result = await api.snowballProjection(params);
|
const result = await api.snowballProjection(params) as Projection;
|
||||||
setProjection(result);
|
setProjection(result);
|
||||||
setProjectionError(false);
|
setProjectionError(false);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -519,7 +592,6 @@ export default function SnowballPage() {
|
||||||
setProjectionLoading(false);
|
setProjectionLoading(false);
|
||||||
}
|
}
|
||||||
}, 220);
|
}, 220);
|
||||||
liveProjectionRef.current = t;
|
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [extraPayment]);
|
}, [extraPayment]);
|
||||||
|
|
||||||
|
|
@ -531,52 +603,52 @@ export default function SnowballPage() {
|
||||||
const handleStartPlan = async () => {
|
const handleStartPlan = async () => {
|
||||||
setStartingPlan(true);
|
setStartingPlan(true);
|
||||||
try {
|
try {
|
||||||
const plan = await api.startSnowballPlan({ method: ramseyMode ? 'snowball' : 'custom' });
|
const plan = await api.startSnowballPlan({ method: ramseyMode ? 'snowball' : 'custom' }) as SnowballPlan;
|
||||||
setActivePlan(plan);
|
setActivePlan(plan);
|
||||||
setAllPlans(prev => [plan, ...prev.filter(p => !['active', 'paused'].includes(p.status))]);
|
setAllPlans(prev => [plan, ...prev.filter(p => !['active', 'paused'].includes(p.status))]);
|
||||||
toast.success('Snowball plan started!');
|
toast.success('Snowball plan started!');
|
||||||
} catch (err) { toast.error(err.message || 'Failed to start plan'); }
|
} catch (err) { toast.error(errMessage(err, 'Failed to start plan')); }
|
||||||
finally { setStartingPlan(false); }
|
finally { setStartingPlan(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePausePlan = async () => {
|
const handlePausePlan = async () => {
|
||||||
if (!activePlan) return;
|
if (!activePlan) return;
|
||||||
try {
|
try {
|
||||||
const updated = await api.pauseSnowballPlan(activePlan.id);
|
const updated = await api.pauseSnowballPlan(activePlan.id) as SnowballPlan;
|
||||||
setActivePlan(updated);
|
setActivePlan(updated);
|
||||||
setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p));
|
setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p));
|
||||||
toast.success('Plan paused');
|
toast.success('Plan paused');
|
||||||
} catch (err) { toast.error(err.message || 'Failed to pause'); }
|
} catch (err) { toast.error(errMessage(err, 'Failed to pause')); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResumePlan = async () => {
|
const handleResumePlan = async () => {
|
||||||
if (!activePlan) return;
|
if (!activePlan) return;
|
||||||
try {
|
try {
|
||||||
const updated = await api.resumeSnowballPlan(activePlan.id);
|
const updated = await api.resumeSnowballPlan(activePlan.id) as SnowballPlan;
|
||||||
setActivePlan(updated);
|
setActivePlan(updated);
|
||||||
setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p));
|
setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p));
|
||||||
toast.success('Plan resumed');
|
toast.success('Plan resumed');
|
||||||
} catch (err) { toast.error(err.message || 'Failed to resume'); }
|
} catch (err) { toast.error(errMessage(err, 'Failed to resume')); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCompletePlan = async () => {
|
const handleCompletePlan = async () => {
|
||||||
if (!activePlan) return;
|
if (!activePlan) return;
|
||||||
try {
|
try {
|
||||||
const updated = await api.completeSnowballPlan(activePlan.id);
|
const updated = await api.completeSnowballPlan(activePlan.id) as SnowballPlan;
|
||||||
setActivePlan(null);
|
setActivePlan(null);
|
||||||
setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p));
|
setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p));
|
||||||
toast.success('Plan marked as complete!');
|
toast.success('Plan marked as complete!');
|
||||||
} catch (err) { toast.error(err.message || 'Failed to complete plan'); }
|
} catch (err) { toast.error(errMessage(err, 'Failed to complete plan')); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAbandonPlan = async () => {
|
const handleAbandonPlan = async () => {
|
||||||
if (!activePlan) return;
|
if (!activePlan) return;
|
||||||
try {
|
try {
|
||||||
const updated = await api.abandonSnowballPlan(activePlan.id);
|
const updated = await api.abandonSnowballPlan(activePlan.id) as SnowballPlan;
|
||||||
setActivePlan(null);
|
setActivePlan(null);
|
||||||
setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p));
|
setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p));
|
||||||
toast.success('Plan abandoned');
|
toast.success('Plan abandoned');
|
||||||
} catch (err) { toast.error(err.message || 'Failed to abandon plan'); }
|
} catch (err) { toast.error(errMessage(err, 'Failed to abandon plan')); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartPlanClick = () => {
|
const handleStartPlanClick = () => {
|
||||||
|
|
@ -588,13 +660,13 @@ export default function SnowballPage() {
|
||||||
const totalBalance = bills.reduce((s, b) => s + (b.current_balance || 0), 0);
|
const totalBalance = bills.reduce((s, b) => s + (b.current_balance || 0), 0);
|
||||||
const totalMinPayment = bills.reduce((s, b) => s + (b.minimum_payment || 0), 0);
|
const totalMinPayment = bills.reduce((s, b) => s + (b.minimum_payment || 0), 0);
|
||||||
const unknownCount = bills.filter(b => b.current_balance == null).length;
|
const unknownCount = bills.filter(b => b.current_balance == null).length;
|
||||||
const missingMinCount = bills.filter(b => b.current_balance > 0 && b.minimum_payment == null).length;
|
const missingMinCount = bills.filter(b => Number(b.current_balance) > 0 && b.minimum_payment == null).length;
|
||||||
const extraAmt = parseFloat(extraPayment) || 0;
|
const extraAmt = parseFloat(extraPayment) || 0;
|
||||||
const attackBill = bills[0] ?? null;
|
const attackBill = bills[0] ?? null;
|
||||||
const attackAmount = attackBill ? (attackBill.minimum_payment || 0) + extraAmt : 0;
|
const attackAmount = attackBill ? (attackBill.minimum_payment || 0) + extraAmt : 0;
|
||||||
const customOrderDrift = !ramseyMode && bills.length > 1 && !isRamseyOrdered(bills);
|
const customOrderDrift = !ramseyMode && bills.length > 1 && !isRamseyOrdered(bills);
|
||||||
const mortgageIncluded = bills.some(b => /mortgage|housing/i.test(b.category_name || b.name || ''));
|
const mortgageIncluded = bills.some(b => /mortgage|housing/i.test(b.category_name || b.name || ''));
|
||||||
const readinessItems = [
|
const readinessItems: ReadinessItem[] = [
|
||||||
{
|
{
|
||||||
id: 'current_on_bills',
|
id: 'current_on_bills',
|
||||||
label: 'Current on bills',
|
label: 'Current on bills',
|
||||||
|
|
@ -671,7 +743,7 @@ export default function SnowballPage() {
|
||||||
{/* Active plan banner */}
|
{/* Active plan banner */}
|
||||||
{activePlan && (
|
{activePlan && (
|
||||||
<PlanStatusBanner
|
<PlanStatusBanner
|
||||||
plan={activePlan}
|
plan={activePlan as unknown as ActivePlan}
|
||||||
onPause={handlePausePlan}
|
onPause={handlePausePlan}
|
||||||
onResume={handleResumePlan}
|
onResume={handleResumePlan}
|
||||||
onComplete={handleCompletePlan}
|
onComplete={handleCompletePlan}
|
||||||
|
|
@ -830,7 +902,7 @@ export default function SnowballPage() {
|
||||||
{bills.map((bill, index) => {
|
{bills.map((bill, index) => {
|
||||||
const isAttack = index === 0;
|
const isAttack = index === 0;
|
||||||
const isEditingBal = editingBalance.billId === bill.id;
|
const isEditingBal = editingBalance.billId === bill.id;
|
||||||
const dragProps = dragPropsFor(bill, index);
|
const dragProps = dragPropsFor(bill);
|
||||||
|
|
||||||
// Pull this debt's payoff info from the live projection (attack card only)
|
// Pull this debt's payoff info from the live projection (attack card only)
|
||||||
const attackProjection = isAttack
|
const attackProjection = isAttack
|
||||||
|
|
@ -928,7 +1000,7 @@ export default function SnowballPage() {
|
||||||
onChange={e => setEditingBalance(p => ({ ...p, value: e.target.value }))}
|
onChange={e => setEditingBalance(p => ({ ...p, value: e.target.value }))}
|
||||||
onBlur={() => commitBalance(bill.id)}
|
onBlur={() => commitBalance(bill.id)}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
if (e.key === 'Enter') e.target.blur();
|
if (e.key === 'Enter') e.currentTarget.blur();
|
||||||
if (e.key === 'Escape') setEditingBalance({ billId: null, value: '' });
|
if (e.key === 'Escape') setEditingBalance({ billId: null, value: '' });
|
||||||
}}
|
}}
|
||||||
className={cn(inp, 'h-7 w-28 text-xs py-0 px-2')}
|
className={cn(inp, 'h-7 w-28 text-xs py-0 px-2')}
|
||||||
|
|
@ -956,7 +1028,7 @@ export default function SnowballPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{bill.current_balance > 0 && bill.minimum_payment == null && (
|
{Number(bill.current_balance) > 0 && bill.minimum_payment == null && (
|
||||||
<div className="inline-flex items-center gap-1 text-xs text-amber-400">
|
<div className="inline-flex items-center gap-1 text-xs text-amber-400">
|
||||||
<AlertTriangle className="h-3 w-3" />
|
<AlertTriangle className="h-3 w-3" />
|
||||||
Needs minimum payment
|
Needs minimum payment
|
||||||
|
|
@ -997,11 +1069,11 @@ export default function SnowballPage() {
|
||||||
{isAttack && (liveAttackPayoff || attackProjection?.payoff_display) && (
|
{isAttack && (liveAttackPayoff || attackProjection?.payoff_display) && (
|
||||||
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-emerald-400/80">
|
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-emerald-400/80">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
↳ Clears {liveAttackPayoff ?? attackProjection.payoff_display}
|
↳ Clears {liveAttackPayoff ?? attackProjection?.payoff_display}
|
||||||
</span>
|
</span>
|
||||||
{attackProjection?.total_interest > 0 && (
|
{Number(attackProjection?.total_interest) > 0 && (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
· {fmtCompact(attackProjection.total_interest)} interest
|
· {fmtCompact(attackProjection?.total_interest)} interest
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1110,7 +1182,7 @@ export default function SnowballPage() {
|
||||||
categories={categories}
|
categories={categories}
|
||||||
onClose={() => setEditBill(null)}
|
onClose={() => setEditBill(null)}
|
||||||
onSave={() => { setEditBill(null); load(); loadProjection(); }}
|
onSave={() => { setEditBill(null); load(); loadProjection(); }}
|
||||||
onDuplicate={bill => setEditBill({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories }) })}
|
onDuplicate={bill => setEditBill({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories }) as Partial<Bill> })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
Loading…
Reference in New Issue