BillTracker/client/pages/SnowballPage.tsx

1403 lines
55 KiB
TypeScript
Raw Permalink Normal View History

import {
useCallback,
useEffect,
useRef,
useState,
type DragEvent,
type DragEventHandler,
type ReactNode,
} from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
useSnowball,
useSnowballSettings,
useSnowballActivePlan,
useSnowballPlans,
useCategories,
} from '@/hooks/useQueries';
import {
ArrowDown,
ArrowUp,
GripVertical,
TrendingDown,
Zap,
Save,
CalendarCheck,
AlertTriangle,
PenLine,
EyeOff,
CheckCircle2,
Circle,
AlertCircle,
RefreshCw,
} from 'lucide-react';
2026-05-14 02:11:54 -05:00
import { toast } from 'sonner';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
2026-05-16 10:17:24 -05:00
import { Switch } from '@/components/ui/switch';
2026-05-14 02:11:54 -05:00
import { Skeleton } from '@/components/ui/Skeleton';
import { cn, errMessage } from '@/lib/utils';
import { moveInArray } from '@/lib/reorder';
2026-05-14 02:11:54 -05:00
import BillModal from '@/components/BillModal';
2026-05-16 15:38:28 -05:00
import { makeBillDraft } from '@/lib/billDrafts';
import PlanStatusBanner, { type ActivePlan } from '@/components/snowball/PlanStatusBanner';
import PlanHistoryPanel, { type SnowballPlan } from '@/components/snowball/PlanHistoryPanel';
import * as AlertDialog from '@radix-ui/react-alert-dialog';
import { formatUSD, formatUSDWhole, asDollars } from '@/lib/money';
import type {
Bill,
Category,
SnowballProjection as Projection,
SnowballProjectionDetail as SnowballProj,
AvalancheProjectionDetail as AvalancheProj,
SnowballSettings,
} from '@/types';
2026-05-14 02:11:54 -05:00
// ── formatters ────────────────────────────────────────────────────────────────
function fmt(val: number | null | undefined): string {
return formatUSD(val == null ? undefined : asDollars(val), { dash: true });
2026-05-14 02:11:54 -05:00
}
function fmtCompact(val: number | null | undefined): string {
2026-05-14 02:11:54 -05:00
if (val == null || val === 0) return '—';
return formatUSDWhole(asDollars(val));
2026-05-14 02:11:54 -05:00
}
function ordinal(n: number | null | undefined): string {
2026-05-14 02:11:54 -05:00
const d = Number(n);
if (!d) return '—';
if (d > 3 && d < 21) return `${d}th`;
switch (d % 10) {
case 1:
return `${d}st`;
case 2:
return `${d}nd`;
case 3:
return `${d}rd`;
default:
return `${d}th`;
2026-05-14 02:11:54 -05:00
}
}
function sortRamseyDebts(debts: Bill[]): Bill[] {
2026-05-16 10:17:24 -05:00
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) return 1;
if (b.current_balance == null) return -1;
const diff = Number(a.current_balance) - Number(b.current_balance);
return diff || a.name.localeCompare(b.name);
});
}
function isRamseyOrdered(debts: Bill[]): boolean {
2026-05-16 10:17:24 -05:00
const sorted = sortRamseyDebts(debts);
return debts.every((debt, index) => debt.id === sorted[index]?.id);
}
2026-05-14 02:11:54 -05:00
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>;
}
2026-05-14 21:00:07 -05:00
// ── SectionDivider ────────────────────────────────────────────────────────────
function SectionDivider({ label }: { label: ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="shrink-0 text-[10px] font-bold uppercase tracking-[0.12em] text-muted-foreground/50">
{label}
</span>
<div className="h-px flex-1 bg-border/50" />
</div>
);
}
2026-05-14 02:11:54 -05:00
// ── StatCard ──────────────────────────────────────────────────────────────────
function StatCard({
label,
value,
sub,
highlight,
}: {
label: ReactNode;
value: ReactNode;
sub?: ReactNode;
highlight?: boolean;
}) {
2026-05-14 02:11:54 -05:00
return (
<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={cn('text-2xl font-semibold tabular-nums', highlight && 'text-emerald-400')}>
{value}
</p>
2026-05-14 02:11:54 -05:00
{sub && <p className="text-xs text-muted-foreground">{sub}</p>}
</div>
);
}
// ── Projection panel ──────────────────────────────────────────────────────────
function AvalancheComparison({
snowball,
avalanche,
}: {
snowball: SnowballProj;
avalanche: AvalancheProj;
}) {
2026-05-14 02:11:54 -05:00
if (!snowball.months_to_freedom || !avalanche.months_to_freedom) return null;
const monthDiff = snowball.months_to_freedom - avalanche.months_to_freedom;
const interestDiff =
Number(snowball.total_interest_paid ?? 0) - Number(avalanche.total_interest_paid ?? 0);
const same = Math.abs(monthDiff) < 1 && Math.abs(interestDiff) < 1;
2026-05-14 02:11:54 -05:00
return (
<div className="border-t border-border/40 px-5 py-3 space-y-1.5">
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
vs. Avalanche (highest rate first)
</p>
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm text-muted-foreground">{avalanche.payoff_display}</span>
<span className="text-xs tabular-nums text-muted-foreground">
{fmt(avalanche.total_interest_paid)} interest
</span>
2026-05-14 02:11:54 -05:00
</div>
{same ? (
<p className="text-xs text-muted-foreground/70">
Same result your debts have similar rates.
</p>
2026-05-14 02:11:54 -05:00
) : interestDiff > 0 ? (
<p className="text-xs text-emerald-400">
Avalanche saves {fmt(interestDiff)} interest
{monthDiff > 0 ? ` · ${monthDiff} month${monthDiff > 1 ? 's' : ''} faster` : ''}
</p>
) : (
<p className="text-xs text-violet-400">
Snowball finishes {Math.abs(monthDiff)} month{Math.abs(monthDiff) > 1 ? 's' : ''} faster ·
Avalanche costs {fmt(Math.abs(interestDiff))} more
</p>
)}
</div>
);
}
function ProjectionPanel({
projection,
projectionLoading,
projectionError,
onRetry,
billCount,
}: {
projection: Projection | null;
projectionLoading: boolean;
projectionError: boolean;
onRetry: () => void;
billCount: number;
}) {
if (projectionLoading && !projection) {
2026-05-14 02:11:54 -05:00
return (
<div className="surface-elevated rounded-xl p-5 space-y-3">
<Skeleton className="h-5 w-36" />
<div className="space-y-2">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-10" />
))}
</div>
2026-05-14 02:11:54 -05:00
</div>
);
}
if (projectionError && !projection) {
return (
<div className="surface-elevated rounded-xl p-5 text-center">
<AlertCircle className="mx-auto h-6 w-6 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">Couldnt load the payoff projection.</p>
<Button size="sm" variant="outline" className="mt-3 gap-1.5" onClick={onRetry}>
<RefreshCw className="h-3.5 w-3.5" /> Try again
</Button>
</div>
);
}
2026-05-14 02:11:54 -05:00
if (!projection) return null;
const sb = projection.snowball;
const av = projection.avalanche;
if (!sb) return null;
const hasProjection = sb.debts.length > 0;
const needsBalances = billCount > 0 && !hasProjection && sb.skipped.length > 0;
return (
<div className="surface-elevated rounded-xl overflow-hidden">
<div className="flex items-start justify-between gap-4 px-5 py-4 border-b border-border/40">
<div className="flex items-center gap-2">
<CalendarCheck className="h-4 w-4 text-primary shrink-0" />
<span className="text-sm font-semibold">Payoff Projection</span>
</div>
{sb.payoff_display && (
<div className="text-right shrink-0">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
Snowball · Debt-Free
</p>
2026-05-14 02:11:54 -05:00
<p className="text-base font-semibold text-emerald-400">{sb.payoff_display}</p>
</div>
)}
</div>
{projectionError && (
<div className="flex items-center gap-2 px-5 py-2 bg-muted/40 border-b border-border/40 text-xs text-muted-foreground">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
Couldnt refresh showing the last result.
<button
type="button"
onClick={onRetry}
className="ml-auto underline hover:text-foreground"
>
Retry
</button>
</div>
)}
2026-05-14 02:11:54 -05:00
{sb.capped && (
<div className="flex items-start gap-2 px-5 py-3 bg-amber-500/10 border-b border-amber-500/20 text-xs text-amber-400">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
One or more debts wont pay off at this rate. Add extra monthly budget or increase minimum
payments.
2026-05-14 02:11:54 -05:00
</div>
)}
{needsBalances && (
<div className="px-5 py-8 text-center text-sm text-muted-foreground">
Click any balance to enter it and see your payoff timeline.
</div>
)}
{hasProjection && (
<div className="divide-y divide-border/30">
{sb.debts.map((d, i) => (
<div key={d.id} className="flex items-center gap-3 px-5 py-3">
<span className="text-xs font-bold text-muted-foreground w-5 shrink-0 tabular-nums">
#{i + 1}
</span>
2026-05-14 02:11:54 -05:00
<span className="flex-1 text-sm font-medium truncate min-w-0">{d.name}</span>
<div className="text-right shrink-0 space-y-0.5">
{d.payoff_display ? (
<>
<p className="text-sm font-semibold">{d.payoff_display}</p>
<p className="text-[10px] text-muted-foreground">
{d.months} mo · {fmtCompact(d.total_interest)} interest
</p>
</>
) : (
<p className="text-xs text-muted-foreground">unknown balance</p>
)}
</div>
</div>
))}
</div>
)}
{hasProjection && (
<div className="flex items-center justify-between px-5 py-3 border-t border-border/40 bg-muted/20">
<span className="text-xs text-muted-foreground">Total interest paid</span>
<span className="text-sm font-semibold tabular-nums">{fmt(sb.total_interest_paid)}</span>
</div>
)}
{hasProjection && av && <AvalancheComparison snowball={sb} avalanche={av} />}
{sb.skipped.length > 0 && hasProjection && (
<div className="px-5 pb-3 text-[10px] text-muted-foreground/60">
{sb.skipped.length} bill{sb.skipped.length > 1 ? 's' : ''} excluded (no balance):{' '}
{sb.skipped.map((s) => s.name).join(', ')}
2026-05-14 02:11:54 -05:00
</div>
)}
</div>
);
}
2026-05-16 10:17:24 -05:00
// ── Readiness strip ───────────────────────────────────────────────────────────
function ReadinessStrip({
items,
readyCount,
totalCount,
allReady,
onToggle,
disabled,
}: {
items: ReadinessItem[];
readyCount: number;
totalCount: number;
allReady: boolean;
onToggle: (id: string, ready: boolean) => void;
disabled?: boolean;
}) {
2026-05-16 10:17:24 -05:00
return (
<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',
allReady && 'border border-emerald-500/30',
)}
>
2026-05-16 10:17:24 -05:00
<div className="flex items-center gap-3 shrink-0">
<div
className={cn(
'flex h-9 w-9 items-center justify-center rounded-lg border',
allReady
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400'
: 'border-border/60 bg-muted/30 text-muted-foreground',
)}
>
2026-05-16 10:17:24 -05:00
{allReady ? <CheckCircle2 className="h-4 w-4" /> : <Circle className="h-4 w-4" />}
</div>
<div>
<p className="text-sm font-semibold">Snowball Readiness</p>
<p className={cn('text-xs', allReady ? 'text-emerald-400' : 'text-muted-foreground')}>
{allReady
? 'Ready to roll: attack the smallest balance first.'
: `${readyCount} of ${totalCount} ready`}
2026-05-16 10:17:24 -05:00
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{items.map((item) => {
2026-05-16 10:17:24 -05:00
const Icon = item.ready ? CheckCircle2 : Circle;
const content = (
<>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{item.label}</span>
</>
);
const className = cn(
'inline-flex h-8 max-w-full items-center gap-1.5 rounded-full border px-3 text-xs font-medium transition-colors',
item.ready
? 'border-emerald-500/25 bg-emerald-500/10 text-emerald-400'
: 'border-border/60 bg-muted/30 text-muted-foreground',
item.manual && !disabled && 'hover:bg-muted/60 cursor-pointer',
item.manual && disabled && 'opacity-60 cursor-not-allowed',
);
if (item.manual) {
return (
<button
key={item.id}
type="button"
className={className}
onClick={() => onToggle(item.id, !item.ready)}
disabled={disabled}
title={item.hint}
>
{content}
</button>
);
}
return (
<span key={item.id} className={className} title={item.hint}>
{content}
</span>
);
})}
</div>
</div>
);
}
2026-05-14 02:11:54 -05:00
// ── Page ──────────────────────────────────────────────────────────────────────
export default function SnowballPage() {
const queryClient = useQueryClient();
const { data: billsRaw = [], isPending: billsPending, error: snowballError } = useSnowball();
const bills = billsRaw ?? [];
const { data: categoriesRaw = [] } = useCategories();
const categories = (categoriesRaw ?? []) as Category[];
const { data: settingsRaw } = useSnowballSettings();
const settings = settingsRaw;
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 loadError = snowballError ? snowballError.message || 'Failed to load snowball data' : null;
// Optimistic list/plan edits write through the query cache so existing call
// sites (setBills(prev => …), setActivePlan(…), setAllPlans(prev => …)) work.
const setBills = useCallback(
(u: Bill[] | ((prev: Bill[]) => Bill[])) =>
queryClient.setQueryData<Bill[]>(['snowball'], (prev) =>
typeof u === 'function' ? u(prev || []) : u,
),
[queryClient],
);
const setActivePlan = useCallback(
(plan: SnowballPlan | null) =>
queryClient.setQueryData<SnowballPlan | null>(['snowball-active-plan'], plan),
[queryClient],
);
const setAllPlans = useCallback(
(u: (prev: SnowballPlan[]) => SnowballPlan[]) =>
queryClient.setQueryData<SnowballPlan[]>(['snowball-plans'], (prev) => u(prev || [])),
[queryClient],
);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [editBill, setEditBill] = useState<EditBillState | null>(null);
const [extraPayment, setExtraPayment] = useState('');
const [ramseyMode, setRamseyMode] = useState(true);
2026-05-16 10:17:24 -05:00
const [readyCurrentOnBills, setReadyCurrentOnBills] = useState(false);
const [readyEmergencyFund, setReadyEmergencyFund] = useState(false);
2026-05-14 02:11:54 -05:00
const [savingSettings, setSavingSettings] = useState(false);
const extraPaymentRef = useRef('');
const [projection, setProjection] = useState<Projection | null>(null);
2026-05-14 02:11:54 -05:00
const [projectionLoading, setProjectionLoading] = useState(false);
const [projectionError, setProjectionError] = useState(false);
const typedExtraRef = useRef('');
2026-05-14 02:11:54 -05:00
const [editingBalance, setEditingBalance] = useState<{ billId: number | null; value: string }>({
billId: null,
value: '',
});
2026-05-14 02:11:54 -05:00
const [startingPlan, setStartingPlan] = useState(false);
const [readinessWarnOpen, setReadinessWarnOpen] = useState(false);
const [draggingId, setDraggingId] = useState<number | null>(null);
const [dropTargetId, setDropTargetId] = useState<number | null>(null);
2026-05-14 02:11:54 -05:00
// ── loading ───────────────────────────────────────────────────────────────
const loadProjection = useCallback(async () => {
setProjectionLoading(true);
try {
const extra = parseFloat(typedExtraRef.current);
const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {};
setProjection(await api.snowballProjection(params));
setProjectionError(false);
} catch {
setProjectionError(true);
} finally {
setProjectionLoading(false);
}
2026-05-14 02:11:54 -05:00
}, []);
const load = useCallback(
() =>
Promise.all([
queryClient.invalidateQueries({ queryKey: ['snowball'] }),
queryClient.invalidateQueries({ queryKey: ['categories'] }),
queryClient.invalidateQueries({ queryKey: ['snowball-settings'] }),
]),
[queryClient],
);
2026-05-14 02:11:54 -05:00
// Seed the editable settings form from the loaded settings whenever they change.
useEffect(() => {
if (!settings) return;
setDirty(false);
const ep = Number(settings.extra_payment) > 0 ? String(settings.extra_payment) : '';
setRamseyMode(settings.ramsey_mode !== false);
setReadyCurrentOnBills(!!settings.ready_current_on_bills);
setReadyEmergencyFund(!!settings.ready_emergency_fund);
setExtraPayment(ep);
extraPaymentRef.current = ep;
}, [settings]);
2026-05-30 17:27:15 -05:00
// The live projection stays a debounced client computation (not page data).
useEffect(() => {
loadProjection();
}, [loadProjection]);
2026-05-14 02:11:54 -05:00
// ── auto-arrange ──────────────────────────────────────────────────────────
const handleAutoArrange = () => {
setBills((prev) => sortRamseyDebts(prev));
2026-05-14 02:11:54 -05:00
setDirty(true);
toast.success('Arranged smallest-to-largest balance');
};
const moveDebt = (fromIndex: number, toIndex: number) => {
if (ramseyMode || saving || fromIndex === toIndex) return;
setBills((prev) => moveInArray(prev, fromIndex, toIndex));
setDirty(true);
};
const dragPropsFor = (bill: Bill): DebtDragProps => {
if (ramseyMode || saving) return { draggable: false };
return {
draggable: true,
isDragging: draggingId === bill.id,
isDropTarget: dropTargetId === bill.id && draggingId !== bill.id,
onDragStart: (event: DragEvent<HTMLDivElement>) => {
setDraggingId(bill.id);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(bill.id));
},
onDragEnter: () => {
if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id);
},
onDragOver: (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id);
},
onDrop: (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId);
const fromIndex = bills.findIndex((item) => item.id === sourceId);
if (fromIndex >= 0)
moveDebt(
fromIndex,
bills.findIndex((item) => item.id === bill.id),
);
setDraggingId(null);
setDropTargetId(null);
},
onDragEnd: () => {
setDraggingId(null);
setDropTargetId(null);
},
};
};
2026-05-14 02:11:54 -05:00
// ── save order ────────────────────────────────────────────────────────────
const handleSaveOrder = async () => {
setSaving(true);
try {
await api.saveSnowballOrder(bills.map((b, i) => ({ id: b.id, snowball_order: i })));
setDirty(false);
toast.success('Order saved');
loadProjection();
} catch (err) {
toast.error(errMessage(err, 'Failed to save order'));
} finally {
setSaving(false);
}
2026-05-14 02:11:54 -05:00
};
// ── extra payment ─────────────────────────────────────────────────────────
const handleSaveExtraPayment = async () => {
const val = extraPayment.trim();
if (val !== '' && (isNaN(parseFloat(val)) || parseFloat(val) < 0)) {
toast.error('Extra payment must be a non-negative number');
return;
2026-05-14 02:11:54 -05:00
}
if (val === extraPaymentRef.current) return;
setSavingSettings(true);
try {
const result = await api.saveSnowballSettings({
extra_payment: val === '' ? 0 : parseFloat(val),
});
const saved = Number(result.extra_payment) > 0 ? String(result.extra_payment) : '';
2026-05-14 02:11:54 -05:00
extraPaymentRef.current = saved;
setExtraPayment(saved);
toast.success('Extra payment saved');
loadProjection();
} catch (err) {
toast.error(errMessage(err, 'Failed to save'));
} finally {
setSavingSettings(false);
}
2026-05-14 02:11:54 -05:00
};
const handleRamseyModeChange = async (checked: boolean) => {
2026-05-16 10:17:24 -05:00
setSavingSettings(true);
try {
const result = await api.saveSnowballSettings({ ramsey_mode: checked });
2026-05-16 10:17:24 -05:00
const nextMode = result.ramsey_mode !== false;
setRamseyMode(nextMode);
setDirty(false);
if (nextMode) setBills((prev) => sortRamseyDebts(prev));
2026-05-16 10:17:24 -05:00
else load();
loadProjection();
toast.success(nextMode ? 'Ramsey Mode enabled' : 'Custom order enabled');
} catch (err) {
toast.error(errMessage(err, 'Failed to save Snowball mode'));
2026-05-16 10:17:24 -05:00
} finally {
setSavingSettings(false);
}
};
const handleReadinessToggle = async (id: string, checked: boolean) => {
const payload =
id === 'current_on_bills'
? { ready_current_on_bills: checked }
: { ready_emergency_fund: checked };
2026-05-16 10:17:24 -05:00
const previous = id === 'current_on_bills' ? readyCurrentOnBills : readyEmergencyFund;
if (id === 'current_on_bills') setReadyCurrentOnBills(checked);
else setReadyEmergencyFund(checked);
setSavingSettings(true);
try {
const result = await api.saveSnowballSettings(payload);
2026-05-16 10:17:24 -05:00
setReadyCurrentOnBills(!!result.ready_current_on_bills);
setReadyEmergencyFund(!!result.ready_emergency_fund);
} catch (err) {
if (id === 'current_on_bills') setReadyCurrentOnBills(previous);
else setReadyEmergencyFund(previous);
toast.error(errMessage(err, 'Failed to save readiness'));
2026-05-16 10:17:24 -05:00
} finally {
setSavingSettings(false);
}
};
2026-05-14 02:11:54 -05:00
// ── inline balance edit ───────────────────────────────────────────────────
const startEditBalance = (bill: Bill) =>
setEditingBalance({
billId: bill.id,
value: bill.current_balance != null ? String(bill.current_balance) : '',
});
2026-05-14 02:11:54 -05:00
const commitBalance = async (billId: number) => {
2026-05-14 02:11:54 -05:00
const raw = editingBalance.value.trim();
const num = raw === '' ? null : parseFloat(raw);
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);
if (num === current?.current_balance) {
setEditingBalance({ billId: null, value: '' });
return;
}
2026-05-14 02:11:54 -05:00
try {
await api.updateBillBalance(billId, num);
setBills((prev) => {
const next = prev.map((b) =>
b.id === billId ? { ...b, current_balance: num == null ? null : asDollars(num) } : b,
);
2026-05-16 10:17:24 -05:00
return ramseyMode ? sortRamseyDebts(next) : next;
});
2026-05-14 02:11:54 -05:00
setEditingBalance({ billId: null, value: '' });
loadProjection();
} catch (err) {
toast.error(errMessage(err, 'Failed to update balance'));
}
2026-05-14 02:11:54 -05:00
};
const removeFromSnowball = async (bill: Bill) => {
2026-05-14 03:00:01 -05:00
try {
await api.updateBillSnowball(bill.id, { snowball_include: false, snowball_exempt: true });
setBills((prev) => prev.filter((b) => b.id !== bill.id));
2026-05-14 03:00:01 -05:00
setDirty(true);
toast.success(`${bill.name} removed from Snowball`);
loadProjection();
} catch (err) {
toast.error(errMessage(err, 'Failed to remove bill from Snowball'));
2026-05-14 03:00:01 -05:00
}
};
// ── live projection (debounced server call as user types extra amount) ──────
useEffect(() => {
typedExtraRef.current = extraPayment; // keep loadProjection() in sync with the input
const t = setTimeout(async () => {
const extra = parseFloat(extraPayment);
const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {};
try {
setProjectionLoading(true);
const result = await api.snowballProjection(params);
setProjection(result);
setProjectionError(false);
} catch {
setProjectionError(true); // keep last projection, but surface the failure
} finally {
setProjectionLoading(false);
}
}, 220);
return () => clearTimeout(t);
}, [extraPayment]);
// Attack card payoff date and display projection come directly from server result
const liveAttackPayoff = projection?.snowball?.debts?.[0]?.payoff_display ?? null;
const displayProjection = projection;
2026-05-15 00:03:32 -05:00
2026-05-30 17:27:15 -05:00
// ── plan lifecycle ────────────────────────────────────────────────────────
const handleStartPlan = async () => {
setStartingPlan(true);
try {
const plan = (await api.startSnowballPlan({
method: ramseyMode ? 'snowball' : 'custom',
})) as SnowballPlan;
2026-05-30 17:27:15 -05:00
setActivePlan(plan);
setAllPlans((prev) => [
plan,
...prev.filter((p) => !['active', 'paused'].includes(p.status)),
]);
2026-05-30 17:27:15 -05:00
toast.success('Snowball plan started!');
} catch (err) {
toast.error(errMessage(err, 'Failed to start plan'));
} finally {
setStartingPlan(false);
}
2026-05-30 17:27:15 -05:00
};
const handlePausePlan = async () => {
if (!activePlan) return;
try {
const updated = (await api.pauseSnowballPlan(activePlan.id)) as SnowballPlan;
2026-05-30 17:27:15 -05:00
setActivePlan(updated);
setAllPlans((prev) => prev.map((p) => (p.id === updated.id ? updated : p)));
2026-05-30 17:27:15 -05:00
toast.success('Plan paused');
} catch (err) {
toast.error(errMessage(err, 'Failed to pause'));
}
2026-05-30 17:27:15 -05:00
};
const handleResumePlan = async () => {
if (!activePlan) return;
try {
const updated = (await api.resumeSnowballPlan(activePlan.id)) as SnowballPlan;
2026-05-30 17:27:15 -05:00
setActivePlan(updated);
setAllPlans((prev) => prev.map((p) => (p.id === updated.id ? updated : p)));
2026-05-30 17:27:15 -05:00
toast.success('Plan resumed');
} catch (err) {
toast.error(errMessage(err, 'Failed to resume'));
}
2026-05-30 17:27:15 -05:00
};
const handleCompletePlan = async () => {
if (!activePlan) return;
try {
const updated = (await api.completeSnowballPlan(activePlan.id)) as SnowballPlan;
2026-05-30 17:27:15 -05:00
setActivePlan(null);
setAllPlans((prev) => prev.map((p) => (p.id === updated.id ? updated : p)));
2026-05-30 17:27:15 -05:00
toast.success('Plan marked as complete!');
} catch (err) {
toast.error(errMessage(err, 'Failed to complete plan'));
}
2026-05-30 17:27:15 -05:00
};
const handleAbandonPlan = async () => {
if (!activePlan) return;
try {
const updated = (await api.abandonSnowballPlan(activePlan.id)) as SnowballPlan;
2026-05-30 17:27:15 -05:00
setActivePlan(null);
setAllPlans((prev) => prev.map((p) => (p.id === updated.id ? updated : p)));
2026-05-30 17:27:15 -05:00
toast.success('Plan abandoned');
} catch (err) {
toast.error(errMessage(err, 'Failed to abandon plan'));
}
2026-05-30 17:27:15 -05:00
};
const handleStartPlanClick = () => {
if (readinessAllReady) {
handleStartPlan();
} else {
setReadinessWarnOpen(true);
}
};
2026-05-14 02:11:54 -05:00
// ── stats ─────────────────────────────────────────────────────────────────
const totalBalance = bills.reduce((s, b) => s + (b.current_balance || 0), 0);
2026-05-14 02:11:54 -05:00
const totalMinPayment = bills.reduce((s, b) => s + (b.minimum_payment || 0), 0);
const unknownCount = bills.filter((b) => b.current_balance == null).length;
const missingMinCount = bills.filter(
(b) => Number(b.current_balance) > 0 && b.minimum_payment == null,
).length;
const extraAmt = parseFloat(extraPayment) || 0;
const attackBill = bills[0] ?? null;
const attackAmount = attackBill ? (attackBill.minimum_payment || 0) + extraAmt : 0;
2026-05-16 10:17:24 -05:00
const customOrderDrift = !ramseyMode && bills.length > 1 && !isRamseyOrdered(bills);
const mortgageIncluded = bills.some((b) =>
/mortgage|housing/i.test(b.category_name || b.name || ''),
);
const readinessItems: ReadinessItem[] = [
2026-05-16 10:17:24 -05:00
{
id: 'current_on_bills',
label: 'Current on bills',
ready: readyCurrentOnBills,
manual: true,
hint: 'Confirm all bills are current before starting the snowball.',
},
{
id: 'emergency_fund',
label: '$1,000 emergency fund',
ready: readyEmergencyFund,
manual: true,
hint: 'Confirm the starter emergency fund is set aside.',
},
{
id: 'debts_entered',
label: 'Debts entered',
ready: bills.length > 0 && !mortgageIncluded,
hint: mortgageIncluded
? 'Mortgage or housing debt is included; Ramsey Baby Step 2 excludes the house.'
: 'Enter consumer debts and leave the mortgage out of this snowball.',
},
{
id: 'minimums_entered',
label: 'Minimums entered',
ready: bills.length > 0 && missingMinCount === 0,
hint: 'Every debt with a balance needs a minimum payment.',
},
{
id: 'extra_set',
label: 'Extra amount set',
ready: extraAmt > 0,
hint: 'Set the extra monthly budget you can throw at the current target.',
},
];
const readinessReadyCount = readinessItems.filter((item) => item.ready).length;
2026-05-16 10:17:24 -05:00
const readinessAllReady = readinessReadyCount === readinessItems.length;
2026-05-14 02:11:54 -05:00
// ── loading skeleton ──────────────────────────────────────────────────────
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
2026-05-14 02:11:54 -05:00
</div>
<div className="space-y-2">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
2026-05-14 02:11:54 -05:00
</div>
</div>
);
}
2026-05-28 02:34:24 -05:00
if (loadError) {
return (
<div className="flex flex-col items-center justify-center py-24 text-center rounded-xl border border-destructive/20 bg-destructive/5">
<AlertCircle className="h-10 w-10 text-destructive mb-3" />
<p className="text-sm font-medium text-foreground">Failed to load snowball data</p>
<p className="mt-1 text-xs text-muted-foreground">{loadError}</p>
<Button
size="sm"
variant="outline"
onClick={load}
className="mt-4 gap-1.5 text-xs border-destructive/30 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/50"
>
2026-05-28 02:34:24 -05:00
<RefreshCw className="h-3 w-3" />
Try again
</Button>
</div>
);
}
2026-05-14 02:11:54 -05:00
const inp = 'bg-background/50 border-border/60 h-9 text-sm font-mono';
return (
<div className="space-y-6">
2026-05-30 17:27:15 -05:00
{/* Active plan banner */}
{activePlan && (
<PlanStatusBanner
plan={activePlan as unknown as ActivePlan}
2026-05-30 17:27:15 -05:00
onPause={handlePausePlan}
onResume={handleResumePlan}
onComplete={handleCompletePlan}
onAbandon={handleAbandonPlan}
onNewPlan={handleStartPlan}
/>
)}
2026-05-14 02:11:54 -05:00
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<TrendingDown className="h-6 w-6 text-primary" />
Debt Snowball
</h1>
<p className="text-sm text-muted-foreground mt-1">
Dave Ramsey method attack the smallest balance first, roll payments as each debt clears.
Marking a payment automatically reduces the outstanding balance.
</p>
</div>
{/* Stats */}
{bills.length > 0 && (
<>
<SectionDivider label="Overview" />
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatCard
label="Total Debt"
value={fmt(totalBalance)}
sub={unknownCount > 0 ? `+ ${unknownCount} unknown` : undefined}
/>
<StatCard label="Monthly Minimums" value={fmt(totalMinPayment)} />
<StatCard
label="Extra / Month"
value={extraAmt > 0 ? fmt(extraAmt) : '—'}
sub="snowball accelerator"
/>
<StatCard
label="Next Win"
value={liveAttackPayoff || 'Add details'}
sub={
attackBill ? `${attackBill.name} · attacking ${fmt(attackAmount)}/mo` : undefined
}
highlight={!!liveAttackPayoff}
/>
</div>
</>
2026-05-14 02:11:54 -05:00
)}
{/* Settings + Readiness */}
2026-05-14 02:11:54 -05:00
{bills.length > 0 && (
<>
<SectionDivider label="Configuration" />
<div className="space-y-3">
<div className="flex flex-wrap items-end gap-3">
<div className="surface-elevated rounded-xl px-4 py-3 flex items-center gap-3 min-h-[58px]">
<Switch
id="ramsey-mode"
checked={ramseyMode}
onCheckedChange={handleRamseyModeChange}
disabled={savingSettings}
/>
<div>
<Label htmlFor="ramsey-mode" className="text-xs font-semibold cursor-pointer">
Ramsey Mode
</Label>
<p className="text-[10px] text-muted-foreground">
{ramseyMode ? 'Smallest balance first' : 'Custom drag order'}
</p>
</div>
</div>
<div className="surface-elevated min-h-[58px] rounded-xl border border-primary/25 bg-primary/4 px-4 py-3 shadow-sm shadow-primary/5">
<div className="flex items-center justify-between gap-4">
<div>
<Label
htmlFor="extra-payment"
className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-primary"
>
<Zap className="h-3.5 w-3.5" />
Extra applied monthly
</Label>
<p className="mt-0.5 text-[10px] text-muted-foreground">
Added to the current target debt.
</p>
</div>
<div className="text-right">
<p className="tracker-number text-base font-bold text-primary">
{extraAmt > 0 ? fmt(extraAmt) : '$0'}
</p>
<p className="text-[10px] text-muted-foreground">per month</p>
</div>
</div>
<Input
id="extra-payment"
type="number"
min="0"
step="1"
placeholder="0.00"
value={extraPayment}
onChange={(e) => setExtraPayment(e.target.value)}
onBlur={handleSaveExtraPayment}
className={cn(inp, 'mt-2 w-full border-primary/25 bg-background/75')}
disabled={savingSettings}
/>
</div>
<div className="flex items-center gap-2 pb-0.5">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAutoArrange}
className="gap-2"
disabled={ramseyMode}
>
<Zap className="h-3.5 w-3.5" /> Restore Ramsey Order
</Button>
<Button
type="button"
size="sm"
disabled={ramseyMode || !dirty || saving}
onClick={handleSaveOrder}
className="gap-2"
>
<Save className="h-3.5 w-3.5" /> {saving ? 'Saving…' : 'Save Order'}
</Button>
{dirty && <span className="text-xs text-amber-400">Unsaved changes</span>}
</div>
</div>
<ReadinessStrip
items={readinessItems}
readyCount={readinessReadyCount}
totalCount={readinessItems.length}
allReady={readinessAllReady}
onToggle={handleReadinessToggle}
2026-05-14 02:11:54 -05:00
disabled={savingSettings}
/>
{!activePlan && readinessReadyCount >= 3 && (
<div className="flex justify-end">
<Button
size="sm"
onClick={handleStartPlanClick}
disabled={startingPlan}
className="gap-1.5"
>
<Zap className="h-3.5 w-3.5" />
{startingPlan ? 'Starting…' : 'Start Snowball Plan'}
</Button>
</div>
)}
2026-05-14 02:11:54 -05:00
</div>
</>
2026-05-16 10:17:24 -05:00
)}
{bills.length > 0 && (customOrderDrift || missingMinCount > 0) && (
<div className="surface-elevated rounded-xl border border-amber-500/25 bg-amber-500/10 px-4 py-3 space-y-2">
{customOrderDrift && (
<div className="flex items-start gap-2 text-xs text-amber-300">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
Custom order is active, so the payoff list no longer strictly follows
smallest-balance-first.
2026-05-16 10:17:24 -05:00
</div>
)}
{missingMinCount > 0 && (
<div className="flex items-start gap-2 text-xs text-amber-300">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
{missingMinCount} debt{missingMinCount > 1 ? 's need' : ' needs'} a minimum payment
before the projection can model the snowball cleanly.
2026-05-16 10:17:24 -05:00
</div>
)}
</div>
)}
2026-05-14 02:11:54 -05:00
{/* Empty state */}
{bills.length === 0 && (
<div className="flex flex-col items-center justify-center rounded-xl bg-muted/20 py-20 text-center gap-3">
2026-05-14 02:11:54 -05:00
<TrendingDown className="h-10 w-10 text-muted-foreground/30" />
<p className="text-sm font-medium text-muted-foreground">No debt bills found</p>
<p className="text-xs text-muted-foreground/70 max-w-sm">
Bills in Credit Cards, Loans, or Debt categories appear here automatically. You can also
enable "Include in Snowball" when editing any bill.
2026-05-14 02:11:54 -05:00
</p>
</div>
)}
{/* Cards + projection */}
{bills.length > 0 && (
<>
<SectionDivider label="Attack Order" />
<div className="grid gap-6 lg:grid-cols-[1fr_340px]">
{/* Cards list */}
<div className="space-y-2">
{bills.map((bill, index) => {
const isAttack = index === 0;
const isEditingBal = editingBalance.billId === bill.id;
const dragProps = dragPropsFor(bill);
// Pull this debt's payoff info from the live projection (attack card only)
const attackProjection = isAttack ? displayProjection?.snowball?.debts?.[0] : null;
return (
<div
key={bill.id}
draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart}
onDragEnter={dragProps?.onDragEnter}
onDragOver={dragProps?.onDragOver}
onDragEnd={dragProps?.onDragEnd}
onDrop={dragProps?.onDrop}
className={cn(
'surface-elevated rounded-xl border select-none transition-colors duration-150',
isAttack ? 'border-emerald-500/30 bg-emerald-950/5' : 'border-border/40',
dragProps?.isDragging && 'opacity-45',
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
)}
>
<div className="flex items-stretch">
{/* Grip */}
<div className="flex items-center gap-0.5 px-3 transition-colors touch-none shrink-0">
<div
className={cn(
'transition-colors',
ramseyMode
? 'text-muted-foreground/10 cursor-not-allowed'
: 'text-muted-foreground/55 hover:text-muted-foreground/80 cursor-grab active:cursor-grabbing',
)}
title={ramseyMode ? 'Ramsey Mode controls order' : 'Drag to reorder'}
>
<GripVertical className="h-5 w-5" />
</div>
<div className="flex flex-col">
<button
type="button"
onClick={() => moveDebt(index, index - 1)}
disabled={ramseyMode || saving || index === 0}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move debt up"
aria-label={`Move ${bill.name} up`}
>
<ArrowUp className="h-3 w-3" />
</button>
<button
type="button"
onClick={() => moveDebt(index, index + 1)}
disabled={ramseyMode || saving || index === bills.length - 1}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move debt down"
aria-label={`Move ${bill.name} down`}
>
<ArrowDown className="h-3 w-3" />
</button>
</div>
2026-05-14 02:11:54 -05:00
</div>
{/* Main content */}
<div className="flex-1 py-3 min-w-0">
{/* Name row */}
<div className="flex items-center gap-2 min-w-0">
{isAttack ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-400 shrink-0">
<Zap className="h-2.5 w-2.5" /> Now
</span>
2026-05-14 02:11:54 -05:00
) : (
<span className="text-xs font-semibold text-muted-foreground/50 tabular-nums shrink-0 w-5">
#{index + 1}
</span>
)}
<span className="font-semibold text-sm truncate">{bill.name}</span>
{bill.category_name && (
<span className="text-[10px] text-muted-foreground/60 shrink-0 hidden sm:inline">
{bill.category_name}
</span>
2026-05-14 02:11:54 -05:00
)}
</div>
{/* Stats row */}
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-sm items-center">
{/* Balance — inline editable */}
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">Balance</span>
{isEditingBal ? (
<Input
autoFocus
type="number"
min="0"
step="0.01"
value={editingBalance.value}
onChange={(e) =>
setEditingBalance((p) => ({ ...p, value: e.target.value }))
}
onBlur={() => commitBalance(bill.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
if (e.key === 'Escape')
setEditingBalance({ billId: null, value: '' });
}}
className={cn(inp, 'h-7 w-28 text-xs py-0 px-2')}
/>
) : (
<button
type="button"
onClick={() => startEditBalance(bill)}
title="Click to update balance"
className={cn(
'font-semibold tabular-nums rounded px-1 -mx-1 hover:bg-muted/60 transition-colors',
isAttack && bill.current_balance != null && 'text-emerald-400',
bill.current_balance == null &&
'text-muted-foreground/50 italic text-xs',
)}
>
{bill.current_balance != null
? fmt(bill.current_balance)
: 'add balance'}
</button>
)}
2026-05-14 19:33:23 -05:00
</div>
2026-05-14 02:11:54 -05:00
{bill.minimum_payment != null && (
<div>
<span className="text-xs text-muted-foreground">Min </span>
<span className="font-medium tabular-nums">
{fmt(bill.minimum_payment)}
</span>
</div>
)}
{Number(bill.current_balance) > 0 && bill.minimum_payment == null && (
<div className="inline-flex items-center gap-1 text-xs text-amber-400">
<AlertTriangle className="h-3 w-3" />
Needs minimum payment
</div>
)}
{isAttack && extraAmt > 0 && (
<div>
<span className="text-xs text-muted-foreground">Throwing </span>
<span className="font-semibold tabular-nums text-emerald-400">
{fmt((bill.minimum_payment || 0) + extraAmt)}
</span>
<span className="text-xs text-muted-foreground"> /mo</span>
</div>
)}
{bill.interest_rate != null && (
<div>
<span className="text-xs text-muted-foreground">APR </span>
<span
className={cn(
'font-medium tabular-nums',
bill.interest_rate >= 25
? 'text-rose-400'
: bill.interest_rate >= 15
? 'text-amber-400'
: 'text-muted-foreground',
)}
>
{bill.interest_rate}%
</span>
</div>
)}
2026-05-16 10:17:24 -05:00
2026-05-14 02:11:54 -05:00
<div>
<span className="text-xs text-muted-foreground">Due </span>
<span className="font-medium">{ordinal(bill.due_day)}</span>
2026-05-14 02:11:54 -05:00
</div>
</div>
2026-05-14 02:11:54 -05:00
{/* Attack payoff line — date is live (updates while typing), interest from server */}
{isAttack && (liveAttackPayoff || attackProjection?.payoff_display) && (
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-emerald-400/80">
<span className="font-medium">
Clears {liveAttackPayoff ?? attackProjection?.payoff_display}
2026-05-14 19:33:23 -05:00
</span>
{Number(attackProjection?.total_interest) > 0 && (
<span className="text-muted-foreground">
· {fmtCompact(attackProjection?.total_interest)} interest
</span>
)}
2026-05-14 02:11:54 -05:00
</div>
)}
</div>
2026-05-14 19:33:23 -05:00
{/* Action icons — fixed right column */}
<div className="flex flex-col items-center justify-center gap-1 px-3 shrink-0">
<button
type="button"
onClick={() => setEditBill({ bill })}
title="Edit bill"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground"
>
<PenLine className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => removeFromSnowball(bill)}
title="Hide from Snowball"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-amber-500/10 hover:text-amber-400"
>
<EyeOff className="h-3.5 w-3.5" />
</button>
</div>
2026-05-14 19:33:23 -05:00
</div>
2026-05-14 02:11:54 -05:00
</div>
);
})}
<p className="pt-1 text-center text-xs font-medium text-muted-foreground">
{ramseyMode
? 'Ramsey Mode keeps debts sorted by smallest balance · Click a balance to update it'
: 'Drag the grip handle to reorder · Click a balance to update it · Save Order to persist'}
</p>
</div>
2026-05-14 02:11:54 -05:00
{/* Projection (sticky sidebar on large screens) */}
<div className="lg:sticky lg:top-24 lg:self-start">
<ProjectionPanel
projection={displayProjection}
projectionLoading={projectionLoading}
projectionError={projectionError}
onRetry={loadProjection}
billCount={bills.length}
/>
</div>
2026-05-14 02:11:54 -05:00
</div>
</>
2026-05-14 02:11:54 -05:00
)}
2026-05-30 17:27:15 -05:00
{/* Plan history */}
<PlanHistoryPanel plans={allPlans} />
{/* Readiness warning dialog */}
<AlertDialog.Root open={readinessWarnOpen} onOpenChange={setReadinessWarnOpen}>
<AlertDialog.Portal>
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm" />
<AlertDialog.Content className="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 w-full max-w-md rounded-xl border border-border bg-popover p-6 shadow-2xl space-y-4 focus:outline-none">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-400 shrink-0" />
<AlertDialog.Title className="text-base font-semibold">
Checklist not complete
</AlertDialog.Title>
</div>
<AlertDialog.Description asChild>
<div className="text-sm text-muted-foreground space-y-3">
<p>The following readiness items are still pending:</p>
<ul className="space-y-1.5">
{readinessItems
.filter((i) => !i.ready)
.map((item) => (
<li key={item.id} className="flex items-center gap-2 text-amber-400 text-xs">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
{item.label}
</li>
))}
</ul>
<p>Starting now may affect your plan&apos;s accuracy. You can still proceed.</p>
</div>
</AlertDialog.Description>
<div className="flex gap-2 justify-end pt-1">
<AlertDialog.Cancel asChild>
<Button variant="outline" size="sm">
Go back
</Button>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<Button
size="sm"
className="gap-1.5"
onClick={() => {
setReadinessWarnOpen(false);
handleStartPlan();
}}
>
<Zap className="h-3.5 w-3.5" />
Start anyway
</Button>
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
2026-05-14 02:11:54 -05:00
{/* Edit modal */}
{editBill && (
<BillModal
key={
editBill.bill?.id
? `edit-${editBill.bill.id}`
: `new-${editBill.initialBill?.name || 'blank'}`
}
2026-05-16 15:38:28 -05:00
bill={editBill.bill}
initialBill={editBill.initialBill}
2026-05-14 02:11:54 -05:00
categories={categories}
onClose={() => setEditBill(null)}
onSave={() => {
setEditBill(null);
load();
loadProjection();
}}
onDuplicate={(bill) =>
setEditBill({
bill: null,
initialBill: makeBillDraft(bill, { copy: true, categories }) as Partial<Bill>,
})
}
2026-05-14 02:11:54 -05:00
/>
)}
</div>
);
}