import { useState, useEffect, useMemo, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom'; import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, X, RefreshCw } 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 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'; // ── Main page ────────────────────────────────────────────────────────────── 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 [editBillData, setEditBillData] = useState(null); // Edit Starting Amounts modal: true when open, false when closed const [editStartingOpen, setEditStartingOpen] = 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]); 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 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]); const first = filteredRows.filter(r => r.bucket === '1st'); const second = filteredRows.filter(r => r.bucket === '15th'); const reorderEnabled = !hasFilters && !loading && !isError; 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'}

{MONTHS[month - 1]} {year}
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 && }
) : (
{ if (bankTracking?.enabled) return `${bankTracking.account_name} · live balance`; 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={bankTracking?.enabled ? undefined : () => 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(); }} /> {/* PaymentLedgerDialog opened via Overdue Command Center "Pay Now" */} {commandCenterPayRow && ( setCommandCenterPayRow(null)} onSaved={() => { setCommandCenterPayRow(null); refetch(); }} /> )}
); }