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 (
Monthly Overview
{rows.length} {rows.length === 1 ? 'bill' : 'bills'}
Failed to load tracker data
{error?.message || 'An unexpected error occurred.'}