2026-05-14 02:11:54 -05:00
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
2026-05-14 03:00:01 -05:00
|
|
|
import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, X } 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';
|
|
|
|
|
import { Skeleton } from '@/components/ui/Skeleton';
|
|
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
import BillModal from '@/components/BillModal';
|
|
|
|
|
|
|
|
|
|
// ── formatters ────────────────────────────────────────────────────────────────
|
|
|
|
|
function fmt(val) {
|
|
|
|
|
if (val == null) return '—';
|
|
|
|
|
return Number(val).toLocaleString(undefined, {
|
|
|
|
|
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
function fmtCompact(val) {
|
|
|
|
|
if (val == null || val === 0) return '—';
|
|
|
|
|
return Number(val).toLocaleString(undefined, {
|
|
|
|
|
style: 'currency', currency: 'USD', maximumFractionDigits: 0,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
function ordinal(n) {
|
|
|
|
|
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`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── StatCard ──────────────────────────────────────────────────────────────────
|
|
|
|
|
function StatCard({ label, value, sub, highlight }) {
|
|
|
|
|
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>
|
|
|
|
|
{sub && <p className="text-xs text-muted-foreground">{sub}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Projection panel ──────────────────────────────────────────────────────────
|
|
|
|
|
function AvalancheComparison({ snowball, avalanche }) {
|
|
|
|
|
if (!snowball.months_to_freedom || !avalanche.months_to_freedom) return null;
|
|
|
|
|
const monthDiff = snowball.months_to_freedom - avalanche.months_to_freedom;
|
|
|
|
|
const interestDiff = snowball.total_interest_paid - avalanche.total_interest_paid;
|
|
|
|
|
const same = Math.abs(monthDiff) < 1 && Math.abs(interestDiff) < 1;
|
|
|
|
|
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>
|
|
|
|
|
</div>
|
|
|
|
|
{same ? (
|
|
|
|
|
<p className="text-xs text-muted-foreground/70">Same result — your debts have similar rates.</p>
|
|
|
|
|
) : 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, billCount }) {
|
|
|
|
|
if (projectionLoading) {
|
|
|
|
|
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>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
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>
|
|
|
|
|
<p className="text-base font-semibold text-emerald-400">{sb.payoff_display}</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{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" />
|
|
|
|
|
Payoff exceeds 50 years. Add extra monthly budget or increase minimum payments.
|
|
|
|
|
</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>
|
|
|
|
|
<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(', ')}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Pointer-based drag-and-drop hook (works on touch + mouse) ─────────────────
|
|
|
|
|
function useSortable(items, setItems, setDirty) {
|
|
|
|
|
const [draggingIdx, setDraggingIdx] = useState(null);
|
|
|
|
|
|
|
|
|
|
// Refs that live through the entire drag gesture
|
|
|
|
|
const state = useRef({
|
|
|
|
|
fromIdx: null, // card index where the drag started
|
|
|
|
|
currentIdx: null, // card index currently under the pointer
|
|
|
|
|
startY: 0,
|
|
|
|
|
itemHeight: 0,
|
|
|
|
|
containerEl: null,
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-14 03:00:01 -05:00
|
|
|
const indexFromPointer = useCallback((clientX, clientY) => {
|
|
|
|
|
const direct = document.elementFromPoint(clientX, clientY)?.closest?.('[data-card-index]');
|
|
|
|
|
if (direct?.dataset?.cardIndex != null) {
|
|
|
|
|
const idx = Number(direct.dataset.cardIndex);
|
|
|
|
|
if (Number.isInteger(idx)) return idx;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cards = [...(state.current.containerEl?.querySelectorAll('[data-card-index]') || [])];
|
|
|
|
|
if (cards.length === 0) return state.current.currentIdx;
|
|
|
|
|
|
|
|
|
|
let nearestIdx = state.current.currentIdx;
|
|
|
|
|
let nearestDistance = Infinity;
|
|
|
|
|
for (const card of cards) {
|
|
|
|
|
const rect = card.getBoundingClientRect();
|
|
|
|
|
const centerY = rect.top + rect.height / 2;
|
|
|
|
|
const distance = Math.abs(clientY - centerY);
|
|
|
|
|
if (distance < nearestDistance) {
|
|
|
|
|
nearestDistance = distance;
|
|
|
|
|
nearestIdx = Number(card.dataset.cardIndex);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return Number.isInteger(nearestIdx) ? nearestIdx : state.current.currentIdx;
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-05-14 02:11:54 -05:00
|
|
|
const onPointerDown = useCallback((e, index) => {
|
|
|
|
|
// Only trigger on the grip handle (data-grip attr)
|
2026-05-14 03:23:52 -05:00
|
|
|
if (!e.currentTarget.hasAttribute('data-grip')) return;
|
2026-05-14 02:11:54 -05:00
|
|
|
// Ignore right-click
|
|
|
|
|
if (e.button !== undefined && e.button !== 0) return;
|
|
|
|
|
|
|
|
|
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
|
|
|
|
|
|
|
|
const card = e.currentTarget.closest('[data-card]');
|
|
|
|
|
const list = card?.parentElement;
|
|
|
|
|
const rect = card?.getBoundingClientRect();
|
|
|
|
|
|
|
|
|
|
state.current = {
|
|
|
|
|
fromIdx: index,
|
|
|
|
|
currentIdx: index,
|
|
|
|
|
startY: e.clientY,
|
|
|
|
|
itemHeight: rect?.height ?? 80,
|
|
|
|
|
containerEl: list ?? null,
|
|
|
|
|
};
|
|
|
|
|
setDraggingIdx(index);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const onPointerMove = useCallback((e) => {
|
|
|
|
|
if (state.current.fromIdx === null) return;
|
2026-05-14 03:00:01 -05:00
|
|
|
const { containerEl, currentIdx } = state.current;
|
2026-05-14 02:11:54 -05:00
|
|
|
if (!containerEl) return;
|
|
|
|
|
|
2026-05-14 03:00:01 -05:00
|
|
|
const newIdx = Math.max(0, Math.min(items.length - 1, indexFromPointer(e.clientX, e.clientY)));
|
2026-05-14 02:11:54 -05:00
|
|
|
|
|
|
|
|
if (newIdx !== currentIdx) {
|
|
|
|
|
state.current.currentIdx = newIdx;
|
|
|
|
|
setDraggingIdx(newIdx); // visual feedback on where card will land
|
|
|
|
|
}
|
2026-05-14 03:00:01 -05:00
|
|
|
}, [indexFromPointer, items.length]);
|
2026-05-14 02:11:54 -05:00
|
|
|
|
|
|
|
|
const onPointerUp = useCallback((e) => {
|
|
|
|
|
const { fromIdx, currentIdx } = state.current;
|
|
|
|
|
state.current.fromIdx = null;
|
|
|
|
|
state.current.currentIdx = null;
|
|
|
|
|
setDraggingIdx(null);
|
|
|
|
|
|
|
|
|
|
if (fromIdx === null || currentIdx === null || fromIdx === currentIdx) return;
|
|
|
|
|
setItems(prev => {
|
|
|
|
|
const next = [...prev];
|
|
|
|
|
const [moved] = next.splice(fromIdx, 1);
|
|
|
|
|
next.splice(currentIdx, 0, moved);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
setDirty(true);
|
|
|
|
|
}, [setItems, setDirty]);
|
|
|
|
|
|
|
|
|
|
return { draggingIdx, onPointerDown, onPointerMove, onPointerUp };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
export default function SnowballPage() {
|
|
|
|
|
const [bills, setBills] = useState([]);
|
|
|
|
|
const [categories, setCategories] = useState([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
const [dirty, setDirty] = useState(false);
|
|
|
|
|
const [editBill, setEditBill] = useState(null);
|
|
|
|
|
|
|
|
|
|
const [extraPayment, setExtraPayment] = useState('');
|
|
|
|
|
const [savingSettings, setSavingSettings] = useState(false);
|
|
|
|
|
const extraPaymentRef = useRef('');
|
|
|
|
|
|
|
|
|
|
const [projection, setProjection] = useState(null);
|
|
|
|
|
const [projectionLoading, setProjectionLoading] = useState(false);
|
|
|
|
|
|
|
|
|
|
const [editingBalance, setEditingBalance] = useState({ billId: null, value: '' });
|
|
|
|
|
|
|
|
|
|
const { draggingIdx, onPointerDown, onPointerMove, onPointerUp } =
|
|
|
|
|
useSortable(bills, setBills, setDirty);
|
|
|
|
|
|
|
|
|
|
// ── loading ───────────────────────────────────────────────────────────────
|
|
|
|
|
const loadProjection = useCallback(async () => {
|
|
|
|
|
setProjectionLoading(true);
|
|
|
|
|
try { setProjection(await api.snowballProjection()); }
|
|
|
|
|
catch { /* non-fatal */ }
|
|
|
|
|
finally { setProjectionLoading(false); }
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const load = useCallback(async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const [billsArr, catsArr, settings] = await Promise.all([
|
|
|
|
|
api.snowball(), api.categories(), api.snowballSettings(),
|
|
|
|
|
]);
|
|
|
|
|
setCategories(catsArr);
|
|
|
|
|
setBills(billsArr);
|
|
|
|
|
setDirty(false);
|
|
|
|
|
const ep = settings.extra_payment > 0 ? String(settings.extra_payment) : '';
|
|
|
|
|
setExtraPayment(ep);
|
|
|
|
|
extraPaymentRef.current = ep;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(err.message || 'Failed to load snowball data');
|
|
|
|
|
} finally { setLoading(false); }
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => { Promise.all([load(), loadProjection()]); }, [load, loadProjection]);
|
|
|
|
|
|
|
|
|
|
// ── auto-arrange ──────────────────────────────────────────────────────────
|
|
|
|
|
const handleAutoArrange = () => {
|
|
|
|
|
setBills(prev => [...prev].sort((a, b) => {
|
|
|
|
|
if (a.current_balance == null && b.current_balance == null) return 0;
|
|
|
|
|
if (a.current_balance == null) return 1;
|
|
|
|
|
if (b.current_balance == null) return -1;
|
|
|
|
|
return a.current_balance - b.current_balance;
|
|
|
|
|
}));
|
|
|
|
|
setDirty(true);
|
|
|
|
|
toast.success('Arranged smallest-to-largest balance');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ── 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(err.message || 'Failed to save order'); }
|
|
|
|
|
finally { setSaving(false); }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ── extra payment ─────────────────────────────────────────────────────────
|
|
|
|
|
const handleSaveExtraPayment = async () => {
|
|
|
|
|
const val = extraPayment.trim();
|
|
|
|
|
if (val !== '' && (isNaN(parseFloat(val)) || parseFloat(val) < 0)) {
|
|
|
|
|
toast.error('Extra payment must be a positive number'); return;
|
|
|
|
|
}
|
|
|
|
|
if (val === extraPaymentRef.current) return;
|
|
|
|
|
setSavingSettings(true);
|
|
|
|
|
try {
|
|
|
|
|
const result = await api.saveSnowballSettings({ extra_payment: val === '' ? 0 : parseFloat(val) });
|
|
|
|
|
const saved = result.extra_payment > 0 ? String(result.extra_payment) : '';
|
|
|
|
|
extraPaymentRef.current = saved;
|
|
|
|
|
setExtraPayment(saved);
|
|
|
|
|
toast.success('Extra payment saved');
|
|
|
|
|
loadProjection();
|
|
|
|
|
} catch (err) { toast.error(err.message || 'Failed to save'); }
|
|
|
|
|
finally { setSavingSettings(false); }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ── inline balance edit ───────────────────────────────────────────────────
|
|
|
|
|
const startEditBalance = (bill) =>
|
|
|
|
|
setEditingBalance({ billId: bill.id, value: bill.current_balance != null ? String(bill.current_balance) : '' });
|
|
|
|
|
|
|
|
|
|
const commitBalance = async (billId) => {
|
|
|
|
|
const raw = editingBalance.value.trim();
|
|
|
|
|
const num = raw === '' ? null : parseFloat(raw);
|
|
|
|
|
if (raw !== '' && (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; }
|
|
|
|
|
try {
|
|
|
|
|
await api.updateBillBalance(billId, num);
|
|
|
|
|
setBills(prev => prev.map(b => b.id === billId ? { ...b, current_balance: num } : b));
|
|
|
|
|
setEditingBalance({ billId: null, value: '' });
|
|
|
|
|
loadProjection();
|
|
|
|
|
} catch (err) { toast.error(err.message || 'Failed to update balance'); }
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-14 03:00:01 -05:00
|
|
|
const removeFromSnowball = async (bill) => {
|
|
|
|
|
try {
|
|
|
|
|
await api.updateBillSnowball(bill.id, { snowball_include: false, snowball_exempt: true });
|
|
|
|
|
setBills(prev => prev.filter(b => b.id !== bill.id));
|
|
|
|
|
setDirty(true);
|
|
|
|
|
toast.success(`${bill.name} removed from Snowball`);
|
|
|
|
|
loadProjection();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(err.message || 'Failed to remove bill from Snowball');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-14 02:11:54 -05:00
|
|
|
// ── stats ─────────────────────────────────────────────────────────────────
|
|
|
|
|
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 unknownCount = bills.filter(b => b.current_balance == null).length;
|
|
|
|
|
const extraAmt = parseFloat(extraPayment) || 0;
|
|
|
|
|
|
|
|
|
|
// ── 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" />)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 rounded-xl" />)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const inp = 'bg-background/50 border-border/60 h-9 text-sm font-mono';
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
|
|
|
|
|
{/* 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 && (
|
|
|
|
|
<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="Total Attack" value={fmt(totalMinPayment + extraAmt)}
|
|
|
|
|
sub="toward #1 target" highlight={extraAmt > 0} />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Toolbar */}
|
|
|
|
|
{bills.length > 0 && (
|
|
|
|
|
<div className="flex flex-wrap items-end gap-3">
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<Label className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
|
|
|
|
Extra monthly budget ($)
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
type="number" min="0" step="1" placeholder="0.00"
|
|
|
|
|
value={extraPayment}
|
|
|
|
|
onChange={e => setExtraPayment(e.target.value)}
|
|
|
|
|
onBlur={handleSaveExtraPayment}
|
|
|
|
|
className={cn(inp, 'w-32')}
|
|
|
|
|
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">
|
|
|
|
|
<Zap className="h-3.5 w-3.5" /> Auto-arrange
|
|
|
|
|
</Button>
|
|
|
|
|
<Button type="button" size="sm" disabled={!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>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Empty state */}
|
|
|
|
|
{bills.length === 0 && (
|
|
|
|
|
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 py-20 text-center gap-3">
|
|
|
|
|
<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 Mortgage categories appear here automatically.
|
|
|
|
|
You can also enable "Include in Snowball" when editing any bill.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Cards + projection */}
|
|
|
|
|
{bills.length > 0 && (
|
|
|
|
|
<div className="grid gap-6 lg:grid-cols-[1fr_340px]">
|
|
|
|
|
|
|
|
|
|
{/* Cards list — pointer events on the whole list so moves are tracked even outside a card */}
|
|
|
|
|
<div
|
|
|
|
|
className="space-y-2"
|
|
|
|
|
onPointerMove={onPointerMove}
|
|
|
|
|
onPointerUp={onPointerUp}
|
|
|
|
|
onPointerCancel={onPointerUp}
|
|
|
|
|
>
|
|
|
|
|
{bills.map((bill, index) => {
|
|
|
|
|
const isAttack = index === 0;
|
|
|
|
|
const isEditingBal = editingBalance.billId === bill.id;
|
|
|
|
|
const isDragging = draggingIdx !== null;
|
|
|
|
|
const isTarget = draggingIdx === index; // where it will land
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={bill.id}
|
|
|
|
|
data-card
|
2026-05-14 03:00:01 -05:00
|
|
|
data-card-index={index}
|
2026-05-14 02:11:54 -05:00
|
|
|
className={cn(
|
|
|
|
|
'surface-elevated rounded-xl border transition-all duration-100 select-none touch-none',
|
|
|
|
|
isAttack ? 'border-emerald-500/40' : 'border-border/40',
|
|
|
|
|
isTarget && isDragging && 'ring-2 ring-primary/50 scale-[0.99]',
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-stretch">
|
|
|
|
|
|
|
|
|
|
{/* Grip handle — pointer-capture trigger */}
|
|
|
|
|
<div
|
|
|
|
|
data-grip
|
|
|
|
|
onPointerDown={e => onPointerDown(e, index)}
|
|
|
|
|
className="flex items-center px-3 text-muted-foreground/30 hover:text-muted-foreground/70 cursor-grab active:cursor-grabbing transition-colors touch-none"
|
|
|
|
|
aria-label="Drag to reorder"
|
|
|
|
|
>
|
|
|
|
|
<GripVertical className="h-5 w-5" />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Body */}
|
|
|
|
|
<div className="flex-1 py-3.5 pr-4 min-w-0">
|
|
|
|
|
{/* Top row */}
|
|
|
|
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
|
|
|
<span className="text-xs font-bold text-muted-foreground tabular-nums w-5 shrink-0">
|
|
|
|
|
#{index + 1}
|
|
|
|
|
</span>
|
|
|
|
|
{isAttack && (
|
|
|
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-400 shrink-0">
|
|
|
|
|
<Zap className="h-2.5 w-2.5" /> Attack
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<span className="font-semibold truncate">{bill.name}</span>
|
|
|
|
|
{bill.category_name && (
|
|
|
|
|
<span className="text-[10px] text-muted-foreground border border-border/50 rounded px-1.5 py-0.5 shrink-0">
|
|
|
|
|
{bill.category_name}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{bill.snowball_include === 1 && !bill.category_name && (
|
|
|
|
|
<span className="text-[10px] text-violet-400 border border-violet-500/30 rounded px-1.5 py-0.5 shrink-0">
|
|
|
|
|
manual
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setEditBill(bill)}
|
|
|
|
|
className="ml-auto text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
|
|
|
|
>
|
|
|
|
|
Edit
|
|
|
|
|
</button>
|
2026-05-14 03:00:01 -05:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => removeFromSnowball(bill)}
|
|
|
|
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-amber-400 transition-colors shrink-0"
|
|
|
|
|
title="Remove from Snowball"
|
|
|
|
|
>
|
|
|
|
|
<X className="h-3.5 w-3.5" />
|
|
|
|
|
Remove
|
|
|
|
|
</button>
|
2026-05-14 02:11:54 -05:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Stats row */}
|
|
|
|
|
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1.5 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.target.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)}
|
|
|
|
|
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/60 italic text-xs',
|
|
|
|
|
)}
|
|
|
|
|
title="Click to update balance"
|
|
|
|
|
>
|
|
|
|
|
{bill.current_balance != null ? fmt(bill.current_balance) : 'enter balance'}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-xs text-muted-foreground">Min/mo </span>
|
|
|
|
|
<span className="font-medium tabular-nums">
|
|
|
|
|
{bill.minimum_payment != null ? fmt(bill.minimum_payment) : '—'}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{isAttack && extraAmt > 0 && (
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-xs text-muted-foreground">Attack </span>
|
|
|
|
|
<span className="font-medium tabular-nums text-emerald-400">
|
|
|
|
|
{fmt((bill.minimum_payment || 0) + extraAmt)}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{bill.interest_rate != null && (
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-xs text-muted-foreground">APR </span>
|
|
|
|
|
<span className="font-medium tabular-nums">{bill.interest_rate}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-xs text-muted-foreground">Due </span>
|
|
|
|
|
<span className="font-medium">{ordinal(bill.due_day)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
<p className="text-xs text-muted-foreground/50 text-center pt-1">
|
|
|
|
|
Drag the grip handle to reorder · Click a balance to update it · Save Order to persist
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Projection (sticky sidebar on large screens) */}
|
|
|
|
|
<div className="lg:sticky lg:top-24 lg:self-start">
|
|
|
|
|
<ProjectionPanel
|
|
|
|
|
projection={projection}
|
|
|
|
|
projectionLoading={projectionLoading}
|
|
|
|
|
billCount={bills.length}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Edit modal */}
|
|
|
|
|
{editBill && (
|
|
|
|
|
<BillModal
|
|
|
|
|
bill={editBill}
|
|
|
|
|
categories={categories}
|
|
|
|
|
onClose={() => setEditBill(null)}
|
|
|
|
|
onSave={() => { setEditBill(null); load(); loadProjection(); }}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|