import { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import { useBills, useCategories, useBillTemplates, useDeletedBills } from '@/hooks/useQueries'; import { Plus, ChevronRight, SlidersHorizontal, Search, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import SearchFilterPanel from '@/components/SearchFilterPanel'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, } from '@/components/ui/alert-dialog'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { api } from '@/api'; import { cn, errMessage } from '@/lib/utils'; import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference'; import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder'; import BillsTableInner from '@/components/BillsTableInner'; import BillModal from '@/components/BillModal'; import RecentlyDeletedBillsDialog from '@/components/RecentlyDeletedBillsDialog'; import { makeBillDraft, type SourceBill } from '@/lib/billDrafts'; import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule'; import type { Bill } from '@/types'; import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow'; interface BuiltInTemplate { id: string; name: string; data: SourceBill; categoryKeywords: string[] } interface BillTemplate { id: number | string; name: string; data?: SourceBill; categoryKeywords?: string[] } const VISIBILITY_OPTIONS = [ { value: 'default', label: 'Default', description: 'Use the app default behavior for inactive bill history.' }, { value: 'all', label: 'Show all history', description: 'Show all historical tracker data for this inactive bill.' }, { value: 'none', label: 'Show no history', description: 'Hide historical tracker data for this inactive bill.' }, { value: 'ranges', label: 'Show only selected date ranges', description: 'Show history only for the ranges listed below.' }, ]; const MONTH_OPTIONS: [string, string][] = [ ['1', 'Jan'], ['2', 'Feb'], ['3', 'Mar'], ['4', 'Apr'], ['5', 'May'], ['6', 'Jun'], ['7', 'Jul'], ['8', 'Aug'], ['9', 'Sep'], ['10', 'Oct'], ['11', 'Nov'], ['12', 'Dec'], ]; const FILTER_ALL = 'all'; const BUILT_IN_TEMPLATES: BuiltInTemplate[] = [ { id: 'builtin-utility', name: 'Utility', data: { name: 'Utility Bill', due_day: 15, expected_amount: 0, billing_cycle: 'monthly', cycle_type: 'monthly', cycle_day: '1', autopay_enabled: false, autodraft_status: 'none', has_2fa: false, notes: 'Utility service bill', }, categoryKeywords: ['utility', 'utilities', 'electric', 'water'], }, { id: 'builtin-credit-card', name: 'Credit Card', data: { name: 'Credit Card', due_day: 20, expected_amount: 0, billing_cycle: 'monthly', cycle_type: 'monthly', cycle_day: '1', autopay_enabled: false, autodraft_status: 'none', has_2fa: true, current_balance: 0, minimum_payment: 0, interest_rate: 0, snowball_include: true, notes: 'Credit card minimum payment', }, categoryKeywords: ['credit card', 'credit cards', 'debt'], }, { id: 'builtin-subscription', name: 'Subscription', data: { name: 'Subscription', due_day: 1, expected_amount: 0, billing_cycle: 'monthly', cycle_type: 'monthly', cycle_day: '1', autopay_enabled: true, autodraft_status: 'assumed_paid', has_2fa: false, notes: 'Recurring subscription', }, categoryKeywords: ['subscription', 'subscriptions', 'entertainment'], }, { id: 'builtin-loan', name: 'Loan', data: { name: 'Loan Payment', due_day: 1, expected_amount: 0, billing_cycle: 'monthly', cycle_type: 'monthly', cycle_day: '1', autopay_enabled: false, autodraft_status: 'none', has_2fa: false, current_balance: 0, minimum_payment: 0, interest_rate: 0, snowball_include: true, notes: 'Installment loan payment', }, categoryKeywords: ['loan', 'loans', 'debt'], }, ]; // BillModal is imported from @/components/BillModal interface HistoryRange { id?: number; _key: string; start_year: string; start_month: string; end_year: string; end_month: string; label: string; } interface RawRange { id?: number; _key?: string; start_year?: number | string | null; start_month?: number | string | null; end_year?: number | string | null; end_month?: number | string | null; label?: string | null; } function blankRange(): HistoryRange { const year = String(new Date().getFullYear()); return { _key: `new-${Date.now()}-${Math.random().toString(16).slice(2)}`, start_year: year, start_month: String(new Date().getMonth() + 1), end_year: '', end_month: '', label: '', }; } function normalizeRange(range: RawRange): HistoryRange { return { ...range, _key: range._key || String(range.id), start_year: String(range.start_year ?? ''), start_month: String(range.start_month ?? ''), end_year: range.end_year == null ? '' : String(range.end_year), end_month: range.end_month == null ? '' : String(range.end_month), label: range.label || '', }; } function rangePayload(range: HistoryRange) { return { start_year: parseInt(range.start_year, 10), start_month: parseInt(range.start_month, 10), end_year: range.end_year ? parseInt(range.end_year, 10) : null, end_month: range.end_month ? parseInt(range.end_month, 10) : null, label: range.label?.trim() || null, }; } function validateRange(range: HistoryRange): string | null { const payload = rangePayload(range); if (!Number.isInteger(payload.start_year) || payload.start_year < 2000 || payload.start_year > 2100) { return 'Start year must be between 2000 and 2100.'; } if (!Number.isInteger(payload.start_month) || payload.start_month < 1 || payload.start_month > 12) { return 'Start month must be between 1 and 12.'; } if ((payload.end_year == null) !== (payload.end_month == null)) { return 'End year and end month must both be provided or both left blank.'; } if (payload.end_year != null && payload.end_month != null) { if (payload.end_year < 2000 || payload.end_year > 2100) { return 'End year must be between 2000 and 2100.'; } if (payload.end_month < 1 || payload.end_month > 12) { return 'End month must be between 1 and 12.'; } if ((payload.end_year * 12 + payload.end_month) < (payload.start_year * 12 + payload.start_month)) { return 'Range end must be on or after the start.'; } } return null; } // ── Display preferences ─────────────────────────────────────────────────────── const PREFS_KEY = 'bills-display-prefs-v1'; interface DisplayPrefs { showCategory: boolean; showDueDay: boolean; showAmount: boolean; showCycle: boolean; showApr: boolean; showBalance: boolean; showMinPayment: boolean; showAutopay: boolean; show2fa: boolean; showSubscription: boolean; } const PREFS_DEFAULTS: DisplayPrefs = { showCategory: true, showDueDay: true, showAmount: true, showCycle: true, showApr: true, showBalance: true, showMinPayment: true, showAutopay: true, show2fa: true, showSubscription: true, }; const PREFS_LABELS: [keyof DisplayPrefs, string][] = [ ['showCategory', 'Category'], ['showDueDay', 'Due day'], ['showAmount', 'Amount'], ['showCycle', 'Billing schedule'], ['showApr', 'APR'], ['showBalance', 'Balance'], ['showMinPayment', 'Min payment'], ['showAutopay', 'Autopay badge'], ['show2fa', '2FA badge'], ['showSubscription', 'Subscription badge'], ]; function useDisplayPrefs() { const [prefs, setPrefs] = useState(() => { try { const raw = localStorage.getItem(PREFS_KEY); return raw ? { ...PREFS_DEFAULTS, ...JSON.parse(raw) } : PREFS_DEFAULTS; } catch { return PREFS_DEFAULTS; } }); const toggle = (key: keyof DisplayPrefs) => { setPrefs(prev => { const next = { ...prev, [key]: !prev[key] }; try { localStorage.setItem(PREFS_KEY, JSON.stringify(next)); } catch { /* ignore quota errors */ } return next; }); }; return { prefs, toggle }; } function DisplayPrefsPanel({ prefs, onToggle }: { prefs: DisplayPrefs; onToggle: (key: keyof DisplayPrefs) => void }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [open]); return (
{open && (

Display options

{PREFS_LABELS.map(([key, label]) => ( ))}
)}
); } function amountSearchText(...values: (number | string | null | undefined)[]): string { return values .filter(value => value !== null && value !== undefined && Number.isFinite(Number(value))) .flatMap(value => { const num = Number(value); return [String(num), num.toFixed(2), `$${num.toFixed(2)}`]; }) .join(' '); } function billIsDebt(bill: Bill): boolean { const category = String(bill.category_name || '').toLowerCase(); return Number(bill.current_balance) > 0 || bill.minimum_payment != null || ['credit card', 'credit cards', 'loan', 'loans', 'debt', 'mortgage'].some(token => category.includes(token)); } function FilterChip({ active, children, onClick }: { active: boolean; children: React.ReactNode; onClick: () => void }) { return ( ); } const BILLS_SORT_KEY = 'bills_sort_mode'; const BILLS_CADENCE_ORDER = ['weekly', 'biweekly', 'monthly', 'quarterly', 'annual', 'other']; function normalizedBillCadence(bill: Bill): string { const raw = String(bill?.cycle_type || bill?.billing_cycle || '').toLowerCase(); if (raw.includes('week') && raw.includes('bi')) return 'biweekly'; if (raw === 'biweekly') return 'biweekly'; if (raw.includes('week')) return 'weekly'; if (raw.includes('quarter')) return 'quarterly'; if (raw.includes('annual') || raw.includes('year')) return 'annual'; if (raw.includes('month') || !raw) return 'monthly'; return 'other'; } function billCadenceIndex(bill: Bill): number { const index = BILLS_CADENCE_ORDER.indexOf(normalizedBillCadence(bill)); return index >= 0 ? index : BILLS_CADENCE_ORDER.length - 1; } function sortBillsByCadence(items: Bill[]): Bill[] { return [...items].sort((a, b) => ( billCadenceIndex(a) - billCadenceIndex(b) || (Number(a.due_day) || 0) - (Number(b.due_day) || 0) || String(a.name || '').localeCompare(String(b.name || '')) )); } function SortModeButton({ active, children, onClick }: { active: boolean; children: React.ReactNode; onClick: () => void }) { return ( ); } // ───────────────────────────────────────────────────────────────────────────── function HistoryVisibilityDialog({ bill, onClose, onSaved }: { bill: Bill; onClose: () => void; onSaved: () => void }) { const [visibility, setVisibility] = useState((bill?.history_visibility as string) || 'default'); const [ranges, setRanges] = useState([]); const [deletedIds, setDeletedIds] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); useEffect(() => { let mounted = true; setLoading(true); api.billHistoryRanges(bill.id) .then(raw => { if (!mounted) return; const data = raw as { history_visibility?: string; ranges?: RawRange[] }; setVisibility(data.history_visibility || (bill.history_visibility as string) || 'default'); setRanges((data.ranges || []).map(normalizeRange)); }) .catch(err => { toast.error(errMessage(err, 'Failed to load history visibility.')); onClose(); }) .finally(() => { if (mounted) setLoading(false); }); return () => { mounted = false; }; }, [bill, onClose]); const updateRange = (key: string, patch: Partial) => { setRanges(prev => prev.map(r => r._key === key ? { ...r, ...patch } : r)); }; const deleteRange = (range: HistoryRange) => { if (range.id) setDeletedIds(prev => [...prev, range.id!]); setRanges(prev => prev.filter(r => r._key !== range._key)); }; const save = async () => { if (visibility === 'ranges') { if (ranges.length === 0) { toast.error('Add at least one date range or choose another visibility option.'); return; } const invalid = ranges.map(validateRange).find(Boolean); if (invalid) { toast.error(invalid); return; } } setSaving(true); try { await api.updateBill(bill.id, { history_visibility: visibility }); for (const id of deletedIds) { await api.deleteBillHistoryRange(bill.id, id); } if (visibility === 'ranges') { for (const range of ranges) { const payload = rangePayload(range); if (range.id) { await api.updateBillHistoryRange(bill.id, range.id, payload); } else { await api.createBillHistoryRange(bill.id, payload); } } } toast.success('History visibility saved.'); onSaved(); onClose(); } catch (err) { toast.error(errMessage(err, 'Failed to save history visibility.')); } finally { setSaving(false); } }; return ( { if (!v && !saving) onClose(); }}> History Visibility: {bill.name} {loading ? (
Loading history visibility...
) : (

Inactive bill history

Choose whether this inactive bill should appear in historical tracker views. Active bill behavior is unchanged.

{VISIBILITY_OPTIONS.map(option => ( ))}
{visibility === 'ranges' && (

Selected date ranges

End month/year are optional for open-ended ranges.

{ranges.length === 0 ? (
No ranges yet. Add a range to use selected history.
) : (
{ranges.map(range => (
updateRange(range._key, { start_year: e.target.value })} className="h-8" />
updateRange(range._key, { end_year: e.target.value })} className="h-8" />
updateRange(range._key, { label: e.target.value })} placeholder="Optional" className="h-8" />
))}
)}
)}
)}
); } interface ModalState { bill: Bill | null; initialBill?: Partial } type BoolFilterKey = 'autopay' | 'firstBucket' | 'fifteenthBucket' | 'debt' | 'inactive'; interface BillFilters { category: string; cycle: string; autopay: boolean; firstBucket: boolean; fifteenthBucket: boolean; debt: boolean; inactive: boolean; } // ── Bills Page ───────────────────────────────────────────────────────────── export default function BillsPage() { const [searchParams] = useSearchParams(); const queryClient = useQueryClient(); // React Query is the source of truth (reuses the shared ['bills']/['categories'] // caches, so bill mutations here also refresh the Tracker/badge). Optimistic // list edits below write to the cache via setQueryData. const { data: bills = [], isPending: billsPending } = useBills(); const { data: categories = [] } = useCategories(); const { data: savedTemplatesRaw = [] } = useBillTemplates(); const savedTemplates = savedTemplatesRaw as BillTemplate[]; const { data: deletedBills = [] } = useDeletedBills(); const loading = billsPending; const setBills = useCallback( (updater: Bill[] | ((prev: Bill[]) => Bill[])) => queryClient.setQueryData(['bills'], prev => ( typeof updater === 'function' ? updater(prev || []) : updater )), [queryClient], ); const refresh = useCallback(() => Promise.all([ queryClient.invalidateQueries({ queryKey: ['bills'] }), queryClient.invalidateQueries({ queryKey: ['categories'] }), queryClient.invalidateQueries({ queryKey: ['bill-templates'] }), queryClient.invalidateQueries({ queryKey: ['deleted-bills'] }), ]), [queryClient]); const [showDeleted, setShowDeleted] = useState(false); const [showInactive, setShowInactive] = useState(false); const [search, setSearch] = useState(''); const [draggingId, setDraggingId] = useState(null); const [dropTargetId, setDropTargetId] = useState(null); const [movingBillId, setMovingBillId] = useState(null); const [filters, setFilters] = useState({ category: FILTER_ALL, cycle: FILTER_ALL, autopay: false, firstBucket: false, fifteenthBucket: false, debt: false, inactive: false, }); // modal state: null = closed, { bill: null } = add new, { bill: {...} } = edit const [modal, setModal] = useState(null); // Bill pending deactivation confirmation (AlertDialog replaces window.confirm) const [deactivate, setDeactivate] = useState(null); const [deactivateReason, setDeactivateReason] = useState(''); const [deleteTarget, setDeleteTarget] = useState(null); const [deleteConfirmed, setDeleteConfirmed] = useState(false); const [deleteBusy, setDeleteBusy] = useState(false); const [historyTarget, setHistoryTarget] = useState(null); const { prefs, toggle: togglePref } = useDisplayPrefs(); const [billsSort, setBillsSort] = useState<'custom' | 'cadence'>(() => ( localStorage.getItem(BILLS_SORT_KEY) === 'cadence' ? 'cadence' : 'custom' )); const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference(); useEffect(() => { localStorage.setItem(BILLS_SORT_KEY, billsSort); }, [billsSort]); useEffect(() => { const querySearch = searchParams.get('search') || ''; const includeInactive = searchParams.get('inactive') === '1' || searchParams.get('inactive') === 'true'; if (querySearch) setSearch(querySearch); if (includeInactive) { setShowInactive(true); setFilters(prev => ({ ...prev, inactive: true })); } }, [searchParams]); useEffect(() => { if (filters.inactive) setShowInactive(true); }, [filters.inactive]); const toggleFilter = (key: BoolFilterKey) => setFilters(prev => ({ ...prev, [key]: !prev[key] })); const setFilterValue = (key: 'category' | 'cycle', value: string) => setFilters(prev => ({ ...prev, [key]: value })); const hasFilters = !!( search.trim() || filters.category !== FILTER_ALL || filters.cycle !== FILTER_ALL || filters.autopay || filters.firstBucket || filters.fifteenthBucket || filters.debt || filters.inactive ); const resetFilters = () => { setSearch(''); setFilters({ category: FILTER_ALL, cycle: FILTER_ALL, autopay: false, firstBucket: false, fifteenthBucket: false, debt: false, inactive: false, }); }; async function handleEdit(billId: number) { try { const bill = await api.bill(billId); setModal({ bill }); } catch (err) { toast.error(errMessage(err, 'Failed to load bill')); } } function handleDuplicateBill(bill: Bill) { setModal({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories }) as Partial }); } function handleTemplateSelect(value: string) { if (!value || value === 'placeholder') return; const builtIn = BUILT_IN_TEMPLATES.find(template => template.id === value); if (builtIn) { setModal({ bill: null, initialBill: makeBillDraft(builtIn.data, { template: builtIn, categories }) as Partial }); return; } const saved = savedTemplates.find(template => `saved-${template.id}` === value); if (saved) { setModal({ bill: null, initialBill: makeBillDraft(saved.data, { template: saved, categories }) as Partial }); } } function handleToggle(bill: Bill) { if (bill.active) { // Prompt confirmation before deactivating setDeactivate(bill); } else { doToggle(bill); } } async function doToggle(bill: Bill | null, reason?: string) { if (!bill) return; try { const payload: { active: number; inactive_reason?: string } = { active: bill.active ? 0 : 1 }; if (bill.active && reason) payload.inactive_reason = reason; await api.updateBill(bill.id, payload); toast.success(bill.active ? 'Bill deactivated' : 'Bill activated'); refresh(); } catch (err) { toast.error(errMessage(err, 'Failed to update bill')); } } function handleDeleteRequest(bill: Bill) { setDeleteConfirmed(false); setDeleteTarget(bill); } async function handleDeleteConfirmed() { if (!deleteTarget || !deleteConfirmed) return; setDeleteBusy(true); try { const bill = deleteTarget; await api.deleteBill(bill.id); setBills(prev => prev.filter(b => b.id !== bill.id)); toast.success(`"${bill.name}" moved to recovery`, { description: 'It will be permanently purged after 30 days.', action: { label: 'Undo', onClick: async () => { try { await api.restoreBill(bill.id); toast.success(`"${bill.name}" restored`); refresh(); } catch (err) { toast.error(errMessage(err, 'Failed to restore bill')); } }, }, }); setDeleteTarget(null); setDeleteConfirmed(false); refresh(); } catch (err) { toast.error(errMessage(err, 'Failed to delete bill')); } finally { setDeleteBusy(false); } } async function handleRestoreDeleted(bill: Bill) { try { await api.restoreBill(bill.id); toast.success(`"${bill.name}" restored`); await refresh(); } catch (err) { toast.error(errMessage(err, 'Failed to restore bill')); } } async function handleDeleteAlternative() { if (!deleteTarget) return; const bill = deleteTarget; setDeleteTarget(null); setDeleteConfirmed(false); await doToggle(bill); } const cycleOptions = useMemo(() => ( Array.from(new Set(bills.map(bill => scheduleValue(bill)))).sort() ), [bills]); const filteredBills = useMemo(() => { const q = search.trim().toLowerCase(); return bills.filter(bill => { if (filters.inactive && bill.active) return false; if (filters.category !== FILTER_ALL && String(bill.category_id ?? '') !== filters.category) return false; if (filters.cycle !== FILTER_ALL && scheduleValue(bill) !== filters.cycle) return false; if (filters.autopay && !bill.autopay_enabled) return false; if (filters.debt && !billIsDebt(bill)) return false; if (filters.firstBucket && !filters.fifteenthBucket && bill.bucket !== '1st') return false; if (filters.fifteenthBucket && !filters.firstBucket && bill.bucket !== '15th') return false; if (!q) return true; const haystack = [ bill.name, bill.category_name, bill.notes, scheduleValue(bill), scheduleLabel(bill), bill.bucket, amountSearchText(bill.expected_amount, bill.current_balance, bill.minimum_payment, bill.interest_rate), ].filter(Boolean).join(' ').toLowerCase(); return haystack.includes(q); }); }, [bills, filters, search]); const active = filteredBills.filter(b => b.active); const inactive = filteredBills.filter(b => !b.active); const totalActive = bills.filter(b => b.active).length; const totalInactive = bills.filter(b => !b.active).length; const reorderEnabled = !hasFilters && !loading && billsSort === 'custom'; const sortedActive = billsSort === 'cadence' ? sortBillsByCadence(active) : active; const sortedInactive = billsSort === 'cadence' ? sortBillsByCadence(inactive) : inactive; async function persistBillOrder(nextBills: Bill[], movedId: string | number | null) { setBills(nextBills); setMovingBillId(movedId); try { await api.reorderBills(reorderPayload(nextBills)); toast.success('Bill order saved'); refresh(); } catch (err) { toast.error(errMessage(err, 'Failed to save bill order')); refresh(); } finally { setMovingBillId(null); } } function reorderBillGroup(activeState: boolean, orderedGroup: Bill[]) { const sourceGroup = bills.filter(bill => !!bill.active === activeState); const replacements = [...orderedGroup]; const nextBills = bills.map(bill => ( !!bill.active === activeState ? replacements.shift()! : bill )); persistBillOrder(nextBills, movedItemId(sourceGroup, orderedGroup)); } function moveControlsForGroup(group: Bill[], activeState: boolean): (bill: Bill, index: number) => MoveControls { return (bill, index) => ({ enabled: reorderEnabled, moving: movingBillId === bill.id, canMoveUp: index > 0, canMoveDown: index < group.length - 1, onMoveUp: () => reorderBillGroup(activeState, moveInArray(group, index, index - 1)), onMoveDown: () => reorderBillGroup(activeState, moveInArray(group, index, index + 1)), }); } function dragPropsForGroup(group: Bill[], activeState: boolean): (bill: Bill, index: number) => DragProps { return (bill, index) => { if (!reorderEnabled) return { draggable: false }; return { draggable: true, isDragging: draggingId === bill.id, isDropTarget: dropTargetId === bill.id && draggingId !== bill.id, onDragStart: (event) => { setDraggingId(bill.id); event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', String(bill.id)); }, onDragEnter: () => { if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id); }, onDragOver: (event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id); }, onDrop: (event) => { event.preventDefault(); const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId); const fromIndex = group.findIndex(item => item.id === sourceId); if (fromIndex >= 0) reorderBillGroup(activeState, moveInArray(group, fromIndex, index)); setDraggingId(null); setDropTargetId(null); }, onDragEnd: () => { setDraggingId(null); setDropTargetId(null); }, }; }; } return (
{/* ── Header ── */}

Manage

Bills

{totalActive} active {totalInactive > 0 && ( · {totalInactive} inactive )}

{deletedBills.length > 0 && ( )}
toggleFilter('autopay')}>Autopay toggleFilter('firstBucket')}>1st bucket toggleFilter('fifteenthBucket')}>15th bucket toggleFilter('debt')}>Debt toggleFilter('inactive')}>Inactive {filteredBills.length} of {bills.length} shown
{/* ── Active Bills ── */} {!filters.inactive && (
Active
{!reorderEnabled && sortedActive.length > 1 && ( {billsSort === 'cadence' ? 'Switch to Custom to reorder' : 'Clear filters to reorder'} )}
setBillsSort('custom')} > Custom setBillsSort('cadence')} > Cadence
{loading ? (
{[...Array(4)].map((_, i) => (
))}
) : active.length === 0 ? (
{hasFilters ? ( <>No active bills match your filters. ) : ( <> No active bills.{' '} )}
) : ( )}
)} {/* ── Inactive Bills ── */} {!loading && sortedInactive.length > 0 && (
{(showInactive || filters.inactive) && (
Inactive {sortedInactive.length}
)}
)} {!loading && filters.inactive && inactive.length === 0 && (
No inactive bills match your filters.
)} {/* ── Bill Modal ── */} {modal && ( setModal(null)} onSave={refresh} onDuplicate={handleDuplicateBill} /> )} {historyTarget && ( setHistoryTarget(null)} onSaved={refresh} /> )} {/* ── Deactivate confirmation (replaces window.confirm) ── */} { if (!open) { setDeactivate(null); setDeactivateReason(''); } }}> Deactivate "{deactivate?.name}"? This bill will be hidden from the tracker. You can reactivate it at any time.
{ setDeactivate(null); setDeactivateReason(''); }}>Cancel { const b = deactivate; const r = deactivateReason; setDeactivate(null); setDeactivateReason(''); doToggle(b, r); }} > Deactivate
{/* ── Soft delete confirmation ── */} { if (!open && !deleteBusy) { setDeleteTarget(null); setDeleteConfirmed(false); } }} > Move "{deleteTarget?.name}" to recovery? This hides the bill from normal tracking and keeps its payments, monthly history, notes, and history ranges recoverable for 30 days.
Deactivate is still the best option if you want to retain the bill long-term but hide it from active tracking.
Cancel
); }