import { useState, useEffect, useMemo, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom'; import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, X, RefreshCw, Landmark, ArrowUpToLine } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api.js'; import { useTracker, useDriftReport } from '@/hooks/useQueries'; import BillModal from '@/components/BillModal'; import { makeBillDraft } from '@/lib/billDrafts'; import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule'; import { cn, fmt } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Skeleton } from '@/components/ui/Skeleton'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, } from '@/components/ui/dialog'; import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog'; import OverdueCommandCenter from '@/components/tracker/OverdueCommandCenter'; import DriftInsightPanel from '@/components/tracker/DriftInsightPanel'; import { MONTHS, FILTER_ALL, paymentDateForTrackerMonth, amountSearchText, rowEffectiveStatus, rowIsPaid, rowIsDebt, } from '@/lib/trackerUtils'; import { FilterChip } from '@/components/tracker/FilterChip'; import { SummaryCard, TrendCard } from '@/components/tracker/SummaryCards'; import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog'; import { TrackerBucket as Bucket } from '@/components/tracker/TrackerBucket'; import IncomeBreakdownModal from '@/components/IncomeBreakdownModal'; function fmtBalanceAge(isoStr) { if (!isoStr) return null; return new Date(isoStr).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); } // ── Main page ────────────────────────────────────────────────────────────── function LateAttributionDialog({ attr, remaining, busy, onAccept, onDismiss }) { if (!attr) return null; const fmtDate = (d) => new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); const priorMonth = new Date(attr.suggested_date + 'T00:00:00').toLocaleDateString('en-US', { month: 'long' }); return ( Payment posted after month end A {attr.bill_name} payment of {fmt(attr.amount)} posted on{' '} {fmtDate(attr.original_date)} — after the previous month closed. Should it count for {priorMonth}?

What this does

Moves the paid date to {fmtDate(attr.suggested_date)} so it appears in the prior month's tracker. Amount and bank link are unchanged.

{remaining > 0 && (

{remaining} more similar payment{remaining > 1 ? 's' : ''} to review after this.

)}
); } export default function TrackerPage() { const [searchParams, setSearchParams] = useSearchParams(); const now = new Date(); // All navigation + filter state lives in the URL so views are bookmarkable/shareable. const year = Number(searchParams.get('year')) || now.getFullYear(); const month = Number(searchParams.get('month')) || (now.getMonth() + 1); const search = searchParams.get('q') || ''; const filters = { category: searchParams.get('fc') || FILTER_ALL, cycle: searchParams.get('cy') || FILTER_ALL, autopay: searchParams.get('ap') === '1', firstBucket: searchParams.get('b1') === '1', fifteenthBucket: searchParams.get('b2') === '1', unpaid: searchParams.get('un') === '1', overdue: searchParams.get('ov') === '1', debt: searchParams.get('de') === '1', }; // replace: true keeps history clean for rapid navigation (e.g. search keystrokes) const updateParams = useCallback((patch) => { setSearchParams(prev => { const next = new URLSearchParams(prev); Object.entries(patch).forEach(([k, v]) => { if (v == null || v === '' || v === false) next.delete(k); else next.set(k, v === true ? '1' : String(v)); }); return next; }, { replace: true }); }, [setSearchParams]); // Edit Bill modal: { bill, categories } when open, null when closed const [bankSyncStatus, setBankSyncStatus] = useState(null); const [bankSyncing, setBankSyncing] = useState(false); const [lateAttributions, setLateAttributions] = useState([]); // pending month-attribution prompts const [attrBusy, setAttrBusy] = useState(null); // payment_id being resolved const [pinUpcoming, setPinUpcoming] = useState(() => localStorage.getItem('tracker_pin_upcoming') === 'true'); const [editBillData, setEditBillData] = useState(null); // Edit Starting Amounts modal: true when open, false when closed const [editStartingOpen, setEditStartingOpen] = useState(false); const [incomeModalOpen, setIncomeModalOpen] = useState(false); const [orderedRows, setOrderedRows] = useState(null); const [movingBillId, setMovingBillId] = useState(null); // Row to open in PaymentLedgerDialog via the overdue command center const [commandCenterPayRow, setCommandCenterPayRow] = useState(null); // Use React Query for data fetching const { data, isLoading: loading, isError, error, refetch, dataUpdatedAt } = useTracker(year, month); const { data: driftData, refetch: refetchDrift } = useDriftReport(); useEffect(() => { setOrderedRows(null); setMovingBillId(null); }, [dataUpdatedAt, year, month]); // Load SimpleFIN status once to decide whether to show the sync button useEffect(() => { api.simplefinStatus() .then(setBankSyncStatus) .catch(() => setBankSyncStatus(null)); }, []); // Listen for late-attribution events fired by BillModal's single-bill sync useEffect(() => { function handler(e) { const attrs = e.detail?.attributions; if (Array.isArray(attrs) && attrs.length > 0) { setLateAttributions(prev => [...prev, ...attrs]); } } window.addEventListener('tracker:late-attributions', handler); return () => window.removeEventListener('tracker:late-attributions', handler); }, []); function navigate(delta) { let nm = month + delta; let ny = year; if (nm > 12) { ny += 1; nm = 1; } if (nm < 1) { ny -= 1; nm = 12; } updateParams({ year: ny, month: nm }); } async function handleBankSync() { setBankSyncing(true); try { const result = await api.syncAllSources(); const matched = result.auto_matched ?? 0; const newTx = result.transactions_new ?? 0; const billNames = result.matched_bills ?? []; const attributions = result.late_attributions ?? []; if (matched > 0 && billNames.length > 0) { toast.success( `Synced — ${billNames.join(', ')} ✓` + (matched > billNames.length ? ` (+${matched - billNames.length} more)` : ''), { duration: 5000 } ); } else if (matched > 0) { toast.success(`Synced — ${matched} payment${matched === 1 ? '' : 's'} matched`); } else if (newTx > 0) { toast.success(`Synced — ${newTx} new transaction${newTx === 1 ? '' : 's'}, no automatic matches`); } else { toast.success('Synced — no new transactions'); } // Surface late-attribution prompts (payments that just crossed a month boundary) if (attributions.length > 0) setLateAttributions(attributions); refetch(); } catch (err) { toast.error(err.message || 'Bank sync failed'); } finally { setBankSyncing(false); } } function togglePinUpcoming() { setPinUpcoming(prev => { const next = !prev; localStorage.setItem('tracker_pin_upcoming', String(next)); return next; }); } // Show sync button when SimpleFIN is enabled, connected, and user has matching rules const showBankSync = bankSyncStatus?.enabled && bankSyncStatus?.has_connections && bankSyncStatus?.has_merchant_rules; async function handleOpenEditBill(row) { try { const [bill, categories] = await Promise.all([ api.bill(row.id), api.categories(), ]); setEditBillData({ bill, categories }); } catch (err) { toast.error(err.message); } } async function handleOpenAddBill() { try { const categories = await api.categories(); setEditBillData({ bill: null, categories }); } catch (err) { toast.error(err.message || 'Failed to open bill editor'); } } function goToday() { const n = new Date(); updateParams({ year: n.getFullYear(), month: n.getMonth() + 1 }); } const rows = orderedRows || data?.rows || []; const summary = data?.summary || {}; const bankTracking = data?.bank_tracking; const cashflow = data?.cashflow; const toggleFilter = (key) => { const paramMap = { autopay: 'ap', firstBucket: 'b1', fifteenthBucket: 'b2', unpaid: 'un', overdue: 'ov', debt: 'de' }; updateParams({ [paramMap[key]]: !filters[key] }); }; const setFilterValue = (key, value) => { const paramMap = { category: 'fc', cycle: 'cy' }; updateParams({ [paramMap[key]]: value === FILTER_ALL ? null : value }); }; const hasFilters = !!( search.trim() || filters.category !== FILTER_ALL || filters.cycle !== FILTER_ALL || filters.autopay || filters.firstBucket || filters.fifteenthBucket || filters.unpaid || filters.overdue || filters.debt ); const resetFilters = () => { updateParams({ q: null, fc: null, cy: null, ap: null, b1: null, b2: null, un: null, ov: null, de: null }); }; const categoryOptions = useMemo(() => { const map = new Map(); rows.forEach(row => { if (row.category_id && row.category_name) map.set(String(row.category_id), row.category_name); }); return Array.from(map, ([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name)); }, [rows]); const cycleOptions = useMemo(() => ( Array.from(new Set(rows.map(scheduleValue))).sort() ), [rows]); const filteredRows = useMemo(() => { const q = search.trim().toLowerCase(); return rows.filter(row => { const effectiveStatus = rowEffectiveStatus(row); if (filters.category !== FILTER_ALL && String(row.category_id ?? '') !== filters.category) return false; if (filters.cycle !== FILTER_ALL && scheduleValue(row) !== filters.cycle) return false; if (filters.autopay && !row.autopay_enabled) return false; if (filters.debt && !rowIsDebt(row)) return false; if (filters.unpaid && (row.is_skipped || rowIsPaid(row))) return false; if (filters.overdue && !(effectiveStatus === 'late' || effectiveStatus === 'missed')) return false; if (filters.firstBucket && !filters.fifteenthBucket && row.bucket !== '1st') return false; if (filters.fifteenthBucket && !filters.firstBucket && row.bucket !== '15th') return false; if (!q) return true; const haystack = [ row.name, row.category_name, row.notes, row.monthly_notes, scheduleValue(row), scheduleLabel(row), row.bucket, row.status, amountSearchText(row.expected_amount, row.actual_amount, row.total_paid, row.balance, row.current_balance, row.minimum_payment), ].filter(Boolean).join(' ').toLowerCase(); return haystack.includes(q); }); }, [filters, rows, search]); // When pin-upcoming is on, sort by urgency so overdue/due-soon bills surface // at the top of each bucket. Bucket split runs after so each bucket is sorted independently. const URGENCY_ORDER = { missed: 0, late: 1, due_soon: 2, upcoming: 3 }; const sortedRows = pinUpcoming ? [...filteredRows].sort((a, b) => { const ua = URGENCY_ORDER[a.status] ?? 99; const ub = URGENCY_ORDER[b.status] ?? 99; if (ua !== ub) return ua - ub; return (a.due_day ?? 99) - (b.due_day ?? 99); }) : filteredRows; const first = sortedRows.filter(r => r.bucket === '1st'); const second = sortedRows.filter(r => r.bucket === '15th'); const reorderEnabled = !hasFilters && !loading && !isError && !pinUpcoming; async function persistTrackerOrder(nextRows, movedBillId) { const payload = Object.fromEntries(nextRows.map((row, index) => [row.id, index])); setOrderedRows(nextRows); setMovingBillId(movedBillId); try { await api.reorderBills(payload); toast.success('Bill order saved'); refetch(); } catch (err) { setOrderedRows(null); toast.error(err.message || 'Failed to save bill order'); } finally { setMovingBillId(null); } } function handleReorderBucket(bucket, orderedBucketRows) { const sourceRows = rows; const nextRows = [...sourceRows]; const replacement = [...orderedBucketRows]; for (let i = 0; i < nextRows.length; i += 1) { if (nextRows[i].bucket === bucket) nextRows[i] = replacement.shift(); } const moved = orderedBucketRows.find((row, index) => row.id !== (sourceRows.filter(item => item.bucket === bucket)[index]?.id)); persistTrackerOrder(nextRows, moved?.id || orderedBucketRows[0]?.id); } return (
{/* ── Header ── */}

Monthly Overview

{MONTHS[month - 1]} {year}

{rows.length} {rows.length === 1 ? 'bill' : 'bills'}

{showBankSync && ( )}
{MONTHS[month - 1]} {year}
{/* ── B: Bank status bar ── */} {bankTracking?.enabled && (
= 0 ? 'border-emerald-500/20 bg-emerald-500/5 text-emerald-700 dark:text-emerald-400' : 'border-destructive/20 bg-destructive/5 text-destructive', )}>
{bankTracking.account_name} · {fmt(bankTracking.balance ?? 0)} balance {bankTracking.last_updated && ( <> · as of {fmtBalanceAge(bankTracking.last_updated)} )} {Number(bankTracking.pending_payments ?? 0) > 0 && ( <> · {fmt(bankTracking.pending_payments)} pending )}
{Number(bankTracking.remaining ?? 0) < 0 ? '−' : ''}{fmt(Math.abs(Number(bankTracking.remaining ?? 0)))} projected
)}
toggleFilter('unpaid')}>Unpaid toggleFilter('overdue')}>Overdue toggleFilter('autopay')}>Autopay toggleFilter('firstBucket')}>1st bucket toggleFilter('fifteenthBucket')}>15th bucket toggleFilter('debt')}>Debt {filteredRows.length} of {rows.length} shown
{/* ── Summary cards (backend already excludes skipped from totals) ── */} {loading ? (
{summary.trend && }
) : (
{bankTracking?.enabled ? ( ) : ( { if (!summary.has_starting_amounts) return 'Set monthly starting cash'; if (cashflow?.has_data && cashflow.period_projected !== undefined) { const proj = Number(cashflow.period_projected); const sign = proj < 0 ? '−' : ''; return `→ ${sign}${fmt(Math.abs(proj))} projected by ${cashflow.period_end_label}`; } return ''; })()} onEdit={() => setEditStartingOpen(true)} /> )} {summary.trend && }
)} {/* ── Overdue Command Center ── */} {!isError && !loading && (summary?.count_late ?? 0) > 0 && ( setCommandCenterPayRow(row)} /> )} {/* ── Drift / Price-Change Insights ── */} {!isError && !loading && (driftData?.bills?.length ?? 0) > 0 && ( { refetch(); refetchDrift(); }} /> )} {/* ── Fetch error state ── */} {isError && (

Failed to load tracker data

{error?.message || 'An unexpected error occurred.'}

)} {/* ── Empty state ── */} {!isError && rows.length === 0 && data !== null && (

No bills this month

Add a bill
)} {/* ── Buckets — year and month passed so Row can open the correct monthly state dialog ── */} {!isError && loading && (
{Array.from({ length: 3 }).map((_, i) => (
))}
{Array.from({ length: 3 }).map((_, i) => (
))}
)} {!isError && (first.length > 0 || second.length > 0) && (
{first.length > 0 && handleReorderBucket('1st', next)} />} {second.length > 0 && handleReorderBucket('15th', next)} />}
)} {/* Edit Bill modal — opened by clicking a bill name in any tracker row */} {editBillData && ( setEditBillData(null)} onSave={() => { setEditBillData(null); refetch(); }} onDuplicate={bill => setEditBillData({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }), categories: editBillData.categories, })} /> )} {/* Edit Starting Amounts modal */} setEditStartingOpen(false)} year={year} month={month} onSave={() => { setEditStartingOpen(false); refetch(); }} /> {/* Income breakdown modal — opens when clicking the bank balance card */} {bankTracking?.enabled && ( setIncomeModalOpen(false)} year={year} month={month} bankTracking={bankTracking} /> )} {/* Late-attribution dialog — fires after sync when a payment just crossed a month boundary */} {lateAttributions.length > 0 && ( { setAttrBusy(attr.payment_id); try { await api.attributePaymentToMonth(attr.payment_id, attr.suggested_date); const month = new Date(attr.suggested_date + 'T00:00:00').toLocaleDateString('en-US', { month: 'long' }); toast.success(`${attr.bill_name} payment moved to ${month}`); setLateAttributions(prev => prev.slice(1)); // dismiss only on success refetch(); } catch (err) { toast.error(err.message || 'Failed to reclassify payment — try again'); // keep the attribution in queue so user can retry } finally { setAttrBusy(null); } }} onDismiss={() => setLateAttributions(prev => prev.slice(1))} /> )} {/* PaymentLedgerDialog opened via Overdue Command Center "Pay Now" */} {commandCenterPayRow && ( setCommandCenterPayRow(null)} onSaved={() => { setCommandCenterPayRow(null); refetch(); }} /> )}
); }