import { useState, useEffect, useMemo, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom'; import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api.js'; import { useTracker, useDriftReport } from '@/hooks/useQueries'; import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference'; 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 SearchFilterPanel from '@/components/SearchFilterPanel'; 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, TRACKER_SORT_DEFAULT, TRACKER_SORT_ASC, TRACKER_SORT_DESC, TRACKER_SORT_OPTIONS, TRACKER_SORT_DEFAULT_DIRS, TRACKER_SORT_LABELS, normalizeTrackerSortKey, normalizeTrackerSortDir, sortTrackerRows, } 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 ( ); } 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', }; const sortKey = normalizeTrackerSortKey(searchParams.get('sort') || TRACKER_SORT_DEFAULT); const hasSort = sortKey !== TRACKER_SORT_DEFAULT; const sortDir = hasSort ? normalizeTrackerSortDir(searchParams.get('dir') || TRACKER_SORT_DEFAULT_DIRS[sortKey] || TRACKER_SORT_ASC) : TRACKER_SORT_ASC; // 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); const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference(); // 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(); const driftedIds = useMemo(() => new Set((driftData?.bills ?? []).map(b => b.id)), [driftData]); 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 setSort = (key) => { const normalizedKey = normalizeTrackerSortKey(key); if (normalizedKey === TRACKER_SORT_DEFAULT) { updateParams({ sort: null, dir: null }); return; } updateParams({ sort: normalizedKey, dir: TRACKER_SORT_DEFAULT_DIRS[normalizedKey] || TRACKER_SORT_ASC, }); }; const toggleSortDirection = () => { if (!hasSort) return; updateParams({ dir: sortDir === TRACKER_SORT_ASC ? TRACKER_SORT_DESC : TRACKER_SORT_ASC }); }; const handleSortHeader = (key) => { const normalizedKey = normalizeTrackerSortKey(key); if (normalizedKey === TRACKER_SORT_DEFAULT) return; if (sortKey === normalizedKey) { updateParams({ dir: sortDir === TRACKER_SORT_ASC ? TRACKER_SORT_DESC : TRACKER_SORT_ASC }); return; } updateParams({ sort: normalizedKey, dir: TRACKER_SORT_DEFAULT_DIRS[normalizedKey] || TRACKER_SORT_ASC, }); }; const hasFilters = !!( search.trim() || filters.category !== FILTER_ALL || filters.cycle !== FILTER_ALL || filters.autopay || filters.firstBucket || filters.fifteenthBucket || filters.unpaid || filters.overdue || filters.debt || hasSort ); const resetFilters = () => { updateParams({ q: null, fc: null, cy: null, ap: null, b1: null, b2: null, un: null, ov: null, de: null, sort: null, dir: 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 = hasSort ? sortTrackerRows(filteredRows, sortKey, sortDir) : 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 (
Monthly Overview
{rows.length} {rows.length === 1 ? 'bill' : 'bills'}
Failed to load tracker data
{error?.message || 'An unexpected error occurred.'}