BillTracker/client/pages/TrackerPage.tsx

1944 lines
70 KiB
TypeScript
Raw Normal View History

import {
useState,
useEffect,
useMemo,
useCallback,
useDeferredValue,
type ComponentType,
type ReactNode,
} from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import {
ChevronLeft,
ChevronRight,
AlertCircle,
CheckCircle2,
Plus,
Search,
RefreshCw,
Landmark,
ArrowUpToLine,
ArrowUp,
ArrowDown,
BellOff,
EyeOff,
Settings2,
Download,
Printer,
FileSpreadsheet,
CalendarDays,
Keyboard,
} from 'lucide-react';
2026-05-03 19:51:57 -05:00
import { toast } from 'sonner';
import { api } from '@/api';
import {
useTracker,
useDriftReport,
useInvalidateTrackerData,
usePrefetchTracker,
useSettings,
useSimplefinStatus,
useSaveSettings,
} from '@/hooks/useQueries';
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
2026-05-03 19:51:57 -05:00
import BillModal from '@/components/BillModal';
2026-05-16 15:38:28 -05:00
import { makeBillDraft } from '@/lib/billDrafts';
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
import { cn, localDateString, parseUtc, settingEnabled } from '@/lib/utils';
import { downloadFile } from '@/lib/download';
import { formatUSD, asDollars, type Dollars } from '@/lib/money';
2026-05-03 19:51:57 -05:00
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { EmptyState, PageHeader, StatusDot } from '@/components/ui/app-primitives';
import SearchFilterPanel from '@/components/SearchFilterPanel';
import { Skeleton } from '@/components/ui/Skeleton';
2026-05-03 19:51:57 -05:00
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
2026-05-03 19:51:57 -05:00
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
2026-05-03 19:51:57 -05:00
import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog';
import CashFlowCard from '@/components/tracker/CashFlowCard';
import OverdueCommandCenter from '@/components/tracker/OverdueCommandCenter';
import DriftInsightPanel from '@/components/tracker/DriftInsightPanel';
2026-05-31 15:06:10 -05:00
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,
2026-05-31 15:06:10 -05:00
} from '@/lib/trackerUtils';
import {
TRACKER_TABLE_COLUMNS,
parseTrackerTableColumns,
trackerTableColumnsToSetting,
} from '@/lib/trackerTableColumns';
2026-05-31 15:06:10 -05:00
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';
import type { TrackerRow, TrackerSummary, BankTracking, DriftBill, Bill, Category } from '@/types';
2026-05-03 19:51:57 -05:00
function fmt(v: number | null | undefined): string {
return formatUSD(asDollars(Number(v) || 0));
}
interface LateAttr {
payment_id: number;
bill_name?: string;
amount?: number;
original_date: string;
suggested_date: string;
}
interface SyncResult {
auto_matched?: number;
transactions_new?: number;
matched_bills?: string[];
late_attributions?: LateAttr[];
}
interface BankSyncStatus {
enabled?: boolean;
has_connections?: boolean;
has_merchant_rules?: boolean;
}
2026-05-03 19:51:57 -05:00
interface EditBillData {
bill: Bill | null;
initialBill?: Partial<Bill>;
categories: Category[];
}
interface TrackerFilters {
category: string;
cycle: string;
autopay: boolean;
firstBucket: boolean;
fifteenthBucket: boolean;
unpaid: boolean;
overdue: boolean;
debt: boolean;
}
type BoolFilterKey = 'autopay' | 'firstBucket' | 'fifteenthBucket' | 'unpaid' | 'overdue' | 'debt';
type Patch = Record<string, string | number | boolean | null | undefined>;
2026-07-06 12:54:11 -05:00
function HeaderStat({
icon: Icon,
label,
value,
tone = 'neutral',
}: {
icon: ComponentType<{ className?: string }>;
label: string;
value: ReactNode;
tone?: 'neutral' | 'good' | 'warn' | 'danger' | 'info';
}) {
const toneClass = {
neutral: 'border-border/70 bg-card/65 text-muted-foreground',
good: 'border-emerald-500/25 bg-emerald-500/8 text-emerald-700 dark:text-emerald-300',
warn: 'border-amber-500/30 bg-amber-500/8 text-amber-700 dark:text-amber-300',
danger: 'border-rose-500/30 bg-rose-500/8 text-rose-700 dark:text-rose-300',
info: 'border-sky-500/25 bg-sky-500/8 text-sky-700 dark:text-sky-300',
2026-07-06 12:54:11 -05:00
}[tone];
return (
<span
className={cn('inline-flex items-center gap-2 rounded-full border px-3 py-1.5', toneClass)}
>
2026-07-06 12:54:11 -05:00
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{label}
</span>
2026-07-06 12:54:11 -05:00
<span className="tracker-number text-xs font-semibold text-foreground">{value}</span>
</span>
);
}
function fmtBalanceAge(isoStr: string | null | undefined): string | null {
const d = parseUtc(isoStr);
if (!d) return null;
return d.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
function BankProjectionBanner({
bankTracking,
onSnooze,
onIgnore,
busy,
}: {
bankTracking?: BankTracking;
onSnooze: () => void;
onIgnore: () => void;
busy?: boolean;
}) {
if (!bankTracking?.enabled) return null;
const isPositive = Number(bankTracking.remaining ?? 0) >= 0;
return (
<div
className={cn(
'flex flex-col gap-3 rounded-xl border px-4 py-3 text-xs shadow-sm sm:flex-row sm:items-center sm:justify-between',
isPositive
? 'border-emerald-500/20 bg-emerald-500/5 text-emerald-700 dark:text-emerald-400'
: 'border-destructive/20 bg-destructive/5 text-destructive',
)}
>
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="relative flex h-2 w-2 shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-50" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-current" />
</span>
<span className="truncate font-semibold">{bankTracking.account_name}</span>
<span className="text-muted-foreground">·</span>
<span>{fmt(bankTracking.balance ?? 0)} balance</span>
{bankTracking.last_updated && (
<>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">
as of {fmtBalanceAge(bankTracking.last_updated)}
</span>
</>
)}
{Number(bankTracking.pending_payments ?? 0) > 0 && (
<>
<span className="text-muted-foreground">·</span>
<span className="text-amber-600 dark:text-amber-400">
{fmt(bankTracking.pending_payments)} pending
</span>
</>
)}
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2 sm:justify-end">
<span className="font-semibold tabular-nums">
{Number(bankTracking.remaining ?? 0) < 0 ? '' : ''}
{fmt(Math.abs(Number(bankTracking.remaining ?? 0)))} projected
</span>
<Button
type="button"
size="sm"
variant="ghost"
disabled={busy}
onClick={onSnooze}
className="h-7 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
>
<BellOff className="h-3 w-3" />
Snooze
</Button>
<Button
type="button"
size="sm"
variant="ghost"
disabled={busy}
onClick={onIgnore}
className="h-7 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
>
<EyeOff className="h-3 w-3" />
Ignore
</Button>
</div>
</div>
);
}
function TrackerColumnMenu({
visibleColumns,
onColumnToggle,
saving,
}: {
visibleColumns: string[];
onColumnToggle?: (key: string, checked: boolean) => void;
saving?: boolean;
}) {
const visibleSet = new Set(visibleColumns);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="sm"
variant="outline"
className="h-8 gap-1.5 px-2.5 text-xs"
disabled={saving}
>
<Settings2 className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Columns</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Table columns</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem checked disabled>
Bill
</DropdownMenuCheckboxItem>
{TRACKER_TABLE_COLUMNS.map((column) => (
<DropdownMenuCheckboxItem
key={column.key}
checked={visibleSet.has(column.key)}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(checked) => onColumnToggle?.(column.key, checked)}
>
{column.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
2026-05-31 15:06:10 -05:00
// ── Main page ──────────────────────────────────────────────────────────────
function LateAttributionDialog({
attr,
remaining,
busy,
onAccept,
onDismiss,
}: {
attr?: LateAttr | null;
remaining: number;
busy?: boolean;
onAccept: (attr: LateAttr) => void;
onDismiss: () => void;
}) {
if (!attr) return null;
const fmtDate = (d: string) =>
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 (
<Dialog open onOpenChange={onDismiss}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Payment posted after month end</DialogTitle>
<DialogDescription>
A <strong>{attr.bill_name}</strong> payment of <strong>{fmt(attr.amount)}</strong>{' '}
posted on <strong>{fmtDate(attr.original_date)}</strong> after the previous month
closed. Should it count for {priorMonth}?
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
What this does
</p>
<p className="text-sm">
Moves the paid date to <strong>{fmtDate(attr.suggested_date)}</strong> so it appears in
the prior month's tracker. Amount and bank link are unchanged.
</p>
</div>
{remaining > 0 && (
<p className="text-xs text-muted-foreground">
{remaining} more similar payment{remaining > 1 ? 's' : ''} to review after this.
</p>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={onDismiss} disabled={busy}>
Keep as{' '}
{fmtDate(attr.original_date)
.replace(/,?\s*\d{4}/, '')
.trim()}
</Button>
<Button onClick={() => onAccept(attr)} disabled={busy}>
{busy ? 'Moving…' : `Count for ${priorMonth}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Client-side fallbacks for the tracker display settings, merged under whatever
// the server returns (an absent key falls back to "shown"). Mirrors the list in
// SettingsPage's "Tracker Layout".
const TRACKER_SETTING_DEFAULTS: Record<string, string> = {
tracker_show_bank_projection_banner: 'true',
tracker_bank_projection_banner_snoozed_until: '',
tracker_show_search_sort: 'true',
tracker_show_summary_cards: 'true',
tracker_show_safe_to_spend: 'true',
tracker_show_overdue_command_center: 'true',
tracker_show_drift_insights: 'true',
tracker_overdue_collapsed: 'false',
tracker_drift_collapsed: 'false',
tracker_table_columns:
'["due","expected","previous","paid","paid_date","status","action","notes"]',
};
2026-05-31 15:06:10 -05:00
export default function TrackerPage() {
const [searchParams, setSearchParams] = useSearchParams();
const navigateTo = useNavigate();
2026-05-03 19:51:57 -05:00
const now = new Date();
2026-05-31 15:06:10 -05:00
// 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;
2026-05-31 15:06:10 -05:00
const search = searchParams.get('q') || '';
// Stable identity so the memo that filters rows on `filters` doesn't recompute
// every render (a new object literal each render would defeat it).
const filters = useMemo<TrackerFilters>(
() => ({
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',
}),
[searchParams],
);
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;
2026-05-03 19:51:57 -05:00
2026-05-31 15:06:10 -05:00
// replace: true keeps history clean for rapid navigation (e.g. search keystrokes)
const updateParams = useCallback(
(patch: 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],
);
const [bankSyncing, setBankSyncing] = useState(false);
const [lateAttributions, setLateAttributions] = useState<LateAttr[]>([]); // pending month-attribution prompts
const [attrBusy, setAttrBusy] = useState<number | null>(null); // payment_id being resolved
const [pinUpcoming, setPinUpcoming] = useState(
() => localStorage.getItem('tracker_pin_upcoming') === 'true',
);
const [editBillData, setEditBillData] = useState<EditBillData | null>(null);
2026-05-04 20:12:57 -05:00
// Edit Starting Amounts modal: true when open, false when closed
const [editStartingOpen, setEditStartingOpen] = useState(false);
const [incomeModalOpen, setIncomeModalOpen] = useState(false);
const [orderedRows, setOrderedRows] = useState<TrackerRow[] | null>(null);
const [movingBillId, setMovingBillId] = useState<number | null>(null);
const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();
2026-05-03 19:51:57 -05:00
// Row to open in PaymentLedgerDialog via the overdue command center
const [commandCenterPayRow, setCommandCenterPayRow] = useState<TrackerRow | null>(null);
// Multi-select: bill ids checked for a bulk pay/skip action.
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false);
// Screen-reader announcement for drag/keyboard reorder (visually hidden).
const [reorderAnnouncement, setReorderAnnouncement] = useState('');
// Use React Query for data fetching
const {
data,
isLoading: loading,
isError,
error,
refetch,
dataUpdatedAt,
} = useTracker(year, month);
const { data: driftDataRaw } = useDriftReport();
const driftData = driftDataRaw as { bills?: DriftBill[] } | undefined;
// Display settings + SimpleFIN status via React Query (cached/deduped like the
// rest of the app), with a save mutation that handles optimistic + rollback.
const settingsQuery = useSettings();
const simplefinStatusQuery = useSimplefinStatus();
const saveSettings = useSaveSettings();
const trackerSettings = useMemo<Record<string, string>>(
() => ({
...TRACKER_SETTING_DEFAULTS,
...((settingsQuery.data as Record<string, string> | undefined) ?? {}),
}),
[settingsQuery.data],
);
const bankSyncStatus = (simplefinStatusQuery.data as BankSyncStatus | undefined) ?? null;
const savingTrackerSetting = saveSettings.isPending;
// Live cross-query refresh: a bill mutation invalidates the tracker AND the
// overdue badge / drift report / bills list so the whole shell updates, not
// just the one tracker query. Use this everywhere a row/payment mutation lands.
const invalidateData = useInvalidateTrackerData();
const driftedIds = useMemo(() => new Set((driftData?.bills ?? []).map((b) => b.id)), [driftData]);
2026-05-03 19:51:57 -05:00
2026-05-30 16:13:37 -05:00
useEffect(() => {
setOrderedRows(null);
setMovingBillId(null);
}, [dataUpdatedAt, year, month]);
// Clear a stale multi-selection when navigating months (survives background
// refetches within the same month).
useEffect(() => {
setSelectedIds(new Set());
}, [year, month]);
// Surface a settings-load failure (React Query swallows it into isError).
// The page still renders with TRACKER_SETTING_DEFAULTS.
useEffect(() => {
if (settingsQuery.isError)
toast.error("Couldn't load tracker display settings — showing defaults.");
}, [settingsQuery.isError]);
// Listen for late-attribution events fired by BillModal's single-bill sync
useEffect(() => {
function handler(e: Event) {
const attrs = (e as CustomEvent<{ attributions?: LateAttr[] }>).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 adjacentMonth(delta: number) {
2026-05-31 15:06:10 -05:00
let nm = month + delta;
let ny = year;
if (nm > 12) {
ny += 1;
nm = 1;
}
if (nm < 1) {
ny -= 1;
nm = 12;
}
return { year: ny, month: nm };
}
function navigate(delta: number) {
updateParams(adjacentMonth(delta));
}
// Prefetch the adjacent month on hover/focus so clicking prev/next is instant.
const prefetchTracker = usePrefetchTracker();
function prefetchAdjacent(delta: number) {
const { year: ny, month: nm } = adjacentMonth(delta);
prefetchTracker(ny, nm);
2026-05-03 19:51:57 -05:00
}
function bankSyncMessage(result: SyncResult): string {
const matched = result.auto_matched ?? 0;
const newTx = result.transactions_new ?? 0;
const billNames = result.matched_bills ?? [];
if (matched > 0 && billNames.length > 0) {
return (
`Synced — ${billNames.join(', ')}` +
(matched > billNames.length ? ` (+${matched - billNames.length} more)` : '')
);
}
if (matched > 0) return `Synced — ${matched} payment${matched === 1 ? '' : 's'} matched`;
if (newTx > 0)
return `Synced — ${newTx} new transaction${newTx === 1 ? '' : 's'}, no automatic matches`;
return 'Synced — no new transactions';
}
async function handleBankSync() {
setBankSyncing(true);
// One toast that transitions loading → done, instead of a manual spinner +
// a separate success/error toast.
const promise = api.syncAllSources();
toast.promise(promise, {
loading: 'Syncing bank…',
success: (result: unknown) => bankSyncMessage(result as SyncResult),
error: (err: unknown) => (err as { message?: string })?.message || 'Bank sync failed',
});
try {
const result = (await promise) as SyncResult;
// Surface late-attribution prompts (payments that just crossed a month boundary)
if ((result.late_attributions ?? []).length > 0)
setLateAttributions(result.late_attributions ?? []);
invalidateData();
} catch {
// toast.promise already surfaced the error
} 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: TrackerRow) {
2026-05-03 19:51:57 -05:00
try {
const [bill, categories] = await Promise.all([
api.bill(row.id) as Promise<Bill>,
api.categories() as Promise<Category[]>,
2026-05-03 19:51:57 -05:00
]);
setEditBillData({ bill, categories });
} catch (err) {
toast.error((err as { message?: string })?.message || 'Failed to open bill');
2026-05-03 19:51:57 -05:00
}
}
2026-05-31 15:06:10 -05:00
async function handleOpenAddBill() {
try {
const categories = (await api.categories()) as Category[];
2026-05-31 15:06:10 -05:00
setEditBillData({ bill: null, categories });
} catch (err) {
toast.error((err as { message?: string })?.message || 'Failed to open bill editor');
2026-05-31 15:06:10 -05:00
}
}
2026-05-03 19:51:57 -05:00
function goToday() {
const n = new Date();
2026-05-31 15:06:10 -05:00
updateParams({ year: n.getFullYear(), month: n.getMonth() + 1 });
2026-05-03 19:51:57 -05:00
}
// Export the viewed month's activity as CSV via the existing /api/export
// date-range endpoint (both bounds inclusive).
async function handleExportMonthCsv() {
const mm = String(month).padStart(2, '0');
const lastDay = new Date(year, month, 0).getDate();
const from = `${year}-${mm}-01`;
const to = `${year}-${mm}-${String(lastDay).padStart(2, '0')}`;
try {
await downloadFile(api.exportRangeUrl(from, to, 'csv'), `bills-${year}-${mm}.csv`);
toast.success(`Exported ${from} ${to} to CSV.`);
} catch (err) {
toast.error((err as { message?: string })?.message || 'Export failed');
}
}
// Jump to the calendar view for the month currently in view.
function goToCalendar() {
navigateTo(`/calendar?year=${year}&month=${month}`);
}
const handleToggleSelect = useCallback((id: number, next: boolean) => {
setSelectedIds((prev) => {
const s = new Set(prev);
if (next) s.add(id);
else s.delete(id);
return s;
});
}, []);
const clearSelection = useCallback(() => setSelectedIds(new Set()), []);
// Pay every selected bill that still has a balance, in one bulk call (reuses
// the same item shape + Undo as the per-bucket "Pay all due").
async function handlePaySelected() {
const items = selectedRows
.filter((r) => !r.is_skipped && !rowIsPaid(r))
.map((r) => {
const threshold = Number(r.actual_amount ?? r.expected_amount ?? 0) || 0;
const remaining = Math.max(threshold - (Number(r.total_paid) || 0), 0);
return {
bill_id: r.id,
amount: remaining > 0 ? remaining : threshold,
paid_date: paymentDateForTrackerMonth(year, month, r.due_day),
};
})
.filter((item) => item.amount > 0);
if (items.length === 0) {
toast.info('No unpaid bills selected.');
return;
}
const total = items.reduce((s, i) => s + i.amount, 0);
setBulkBusy(true);
try {
const result = (await api.bulkPay(items)) as { created?: Array<{ id: number }> };
const created = result.created || [];
if (created.length === 0) {
toast.info('Those bills were already paid.');
} else {
toast.success(
`Paid ${created.length} bill${created.length === 1 ? '' : 's'}${fmt(total)}`,
{
action: {
label: 'Undo',
onClick: async () => {
try {
await Promise.all(created.map((p) => api.deletePayment(p.id)));
toast.success('Payments removed');
invalidateData();
} catch (err) {
toast.error((err as { message?: string })?.message || 'Failed to undo payments.');
}
},
},
},
);
}
clearSelection();
invalidateData();
} catch (err) {
toast.error((err as { message?: string })?.message || 'Failed to pay bills.');
} finally {
setBulkBusy(false);
}
}
// Skip every selected bill for this month. No bulk endpoint exists (skip has no
// money effect), so fan out per-bill and aggregate the result; Undo un-skips.
async function handleSkipSelected() {
const toSkip = selectedRows.filter((r) => !r.is_skipped);
if (toSkip.length === 0) {
toast.info('No skippable bills selected.');
return;
}
setBulkBusy(true);
try {
const results = await Promise.allSettled(
toSkip.map((r) =>
api.saveBillMonthlyState(r.id, {
year,
month,
is_skipped: true,
actual_amount: r.actual_amount,
notes: r.monthly_notes,
}),
),
);
const skipped = toSkip.filter((_, i) => results[i]?.status === 'fulfilled');
const failed = results.length - skipped.length;
if (skipped.length > 0) {
toast.success(
`Skipped ${skipped.length} bill${skipped.length === 1 ? '' : 's'}${failed ? `${failed} failed` : ''}`,
{
action: {
label: 'Undo',
onClick: async () => {
try {
await Promise.all(
skipped.map((r) =>
api.saveBillMonthlyState(r.id, {
year,
month,
is_skipped: false,
actual_amount: r.actual_amount,
notes: r.monthly_notes,
}),
),
);
toast.success('Restored');
invalidateData();
} catch (err) {
toast.error((err as { message?: string })?.message || 'Failed to undo.');
}
},
},
},
);
} else {
toast.error('Failed to skip the selected bills.');
}
clearSelection();
invalidateData();
} finally {
setBulkBusy(false);
}
}
const rows = useMemo(() => orderedRows || data?.rows || [], [orderedRows, data]);
const selectedRows = useMemo(
() => rows.filter((r) => selectedIds.has(r.id)),
[rows, selectedIds],
);
const summary = (data?.summary ?? {}) as TrackerSummary;
const bankTracking = data?.bank_tracking;
const cashflow = data?.cashflow;
const today = localDateString();
// Safe-to-spend projects from "now", so it only makes sense on the current month.
const isCurrentMonth = today.startsWith(`${year}-${String(month).padStart(2, '0')}`);
const bannerSnoozedUntil = trackerSettings.tracker_bank_projection_banner_snoozed_until || '';
const showSearchSort = settingEnabled(trackerSettings.tracker_show_search_sort);
const showSummaryCards = settingEnabled(trackerSettings.tracker_show_summary_cards);
const showOverdueCommandCenter = settingEnabled(
trackerSettings.tracker_show_overdue_command_center,
);
const showSafeToSpend = settingEnabled(trackerSettings.tracker_show_safe_to_spend);
const showDriftInsights = settingEnabled(trackerSettings.tracker_show_drift_insights);
// Collapse state for the Overdue / Drift panels is remembered per-user (default expanded).
const overdueCollapsed = settingEnabled(trackerSettings.tracker_overdue_collapsed, false);
const driftCollapsed = settingEnabled(trackerSettings.tracker_drift_collapsed, false);
const showBankProjectionBanner =
settingEnabled(trackerSettings.tracker_show_bank_projection_banner) &&
(!bannerSnoozedUntil || bannerSnoozedUntil <= today);
const visibleTableColumns = useMemo(
() => parseTrackerTableColumns(trackerSettings.tracker_table_columns),
[trackerSettings.tracker_table_columns],
);
// Optimistic write + rollback now live in the useSaveSettings mutation; this
// wrapper just fires it and owns the success/error toasts.
function saveTrackerSettings(patch: Record<string, string>, successMessage?: string) {
saveSettings.mutate(patch, {
onSuccess: () => {
if (successMessage) toast.success(successMessage);
},
onError: (err) =>
toast.error((err as { message?: string })?.message || 'Failed to update tracker setting'),
});
}
function snoozeBankProjectionBanner() {
const until = new Date();
until.setDate(until.getDate() + 1);
saveTrackerSettings(
{
tracker_bank_projection_banner_snoozed_until: localDateString(until),
},
'Bank projection banner snoozed until tomorrow.',
);
}
function ignoreBankProjectionBanner() {
saveTrackerSettings(
{
tracker_show_bank_projection_banner: 'false',
tracker_bank_projection_banner_snoozed_until: '',
},
'Bank projection banner hidden. You can turn it back on in Settings.',
);
}
function handleTableColumnToggle(columnKey: string, checked: boolean) {
const nextColumns = checked
? [...visibleTableColumns, columnKey]
: visibleTableColumns.filter((key) => key !== columnKey);
saveTrackerSettings({
tracker_table_columns: trackerTableColumnsToSetting(nextColumns),
});
}
function hideSearchSortPanel() {
2026-06-07 18:38:05 -05:00
saveTrackerSettings({ tracker_show_search_sort: 'false' });
toast('Search & sort hidden.', {
action: {
label: 'Undo',
onClick: () => saveTrackerSettings({ tracker_show_search_sort: 'true' }),
},
2026-06-07 18:38:05 -05:00
});
}
function hideOverduePanel() {
saveTrackerSettings({ tracker_show_overdue_command_center: 'false' });
toast('Overdue panel hidden.', {
action: {
label: 'Undo',
onClick: () => saveTrackerSettings({ tracker_show_overdue_command_center: 'true' }),
},
});
}
function hideDriftPanel() {
saveTrackerSettings({ tracker_show_drift_insights: 'false' });
toast('Price-change insights hidden.', {
action: {
label: 'Undo',
onClick: () => saveTrackerSettings({ tracker_show_drift_insights: 'true' }),
},
});
}
const toggleFilter = (key: BoolFilterKey) => {
const paramMap: Record<BoolFilterKey, string> = {
autopay: 'ap',
firstBucket: 'b1',
fifteenthBucket: 'b2',
unpaid: 'un',
overdue: 'ov',
debt: 'de',
};
2026-05-31 15:06:10 -05:00
updateParams({ [paramMap[key]]: !filters[key] });
};
const setFilterValue = (key: 'category' | 'cycle', value: string) => {
const paramMap: Record<'category' | 'cycle', string> = { category: 'fc', cycle: 'cy' };
2026-05-31 15:06:10 -05:00
updateParams({ [paramMap[key]]: value === FILTER_ALL ? null : value });
};
const setSort = (key: string) => {
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: string) => {
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,
});
};
2026-05-16 15:38:28 -05:00
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
2026-05-16 15:38:28 -05:00
);
const searchSortLabel = hasSort
? `sorted by ${TRACKER_SORT_LABELS[sortKey]} ${sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}`
: undefined;
2026-05-16 15:38:28 -05:00
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,
});
2026-05-16 15:38:28 -05:00
};
const categoryOptions = useMemo(() => {
const map = new Map<string, string>();
rows.forEach((row) => {
2026-05-16 15:38:28 -05:00
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),
);
2026-05-16 15:38:28 -05:00
}, [rows]);
const cycleOptions = useMemo(() => Array.from(new Set(rows.map(scheduleValue))).sort(), [rows]);
// Defer the search term so the whole-list filter below (which re-scans every
// row's haystack) runs off the urgent keystroke path — the input stays snappy
// on big months while filtering catches up (React 19).
const deferredSearch = useDeferredValue(search);
2026-05-16 15:38:28 -05:00
const filteredRows = useMemo(() => {
const q = deferredSearch.trim().toLowerCase();
return rows.filter((row) => {
2026-05-16 15:38:28 -05:00
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;
2026-05-16 15:38:28 -05:00
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;
2026-05-16 15:38:28 -05:00
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),
2026-05-16 15:38:28 -05:00
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();
2026-05-16 15:38:28 -05:00
return haystack.includes(q);
});
}, [filters, rows, deferredSearch]);
// 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: Record<string, number> = { missed: 0, late: 1, due_soon: 2, upcoming: 3 };
const sortedRows = hasSort
? sortTrackerRows(filteredRows, sortKey, sortDir)
: pinUpcoming
? [...filteredRows].sort((a, b) => {
// Rank on the reconciled status (same source the overdue filter uses),
// so a bill that's paid-by-threshold but still carries a stale raw
// 'late'/'missed' status sorts to the bottom instead of being pinned up.
const ua = URGENCY_ORDER[rowEffectiveStatus(a)] ?? 99;
const ub = URGENCY_ORDER[rowEffectiveStatus(b)] ?? 99;
if (ua !== ub) return ua - ub;
return (a.due_day ?? 99) - (b.due_day ?? 99);
})
: filteredRows;
2026-06-07 17:50:17 -05:00
const searchResultLabel = `${filteredRows.length} of ${rows.length} shown`;
const first = sortedRows.filter((r) => r.bucket === '1st');
const second = sortedRows.filter((r) => r.bucket === '15th');
const reorderEnabled = !hasFilters && !loading && !isError && !pinUpcoming;
2026-07-06 12:54:11 -05:00
const activeMonthLabel = `${MONTHS[month - 1]} ${year}`;
const monthStartLabel = new Date(year, month - 1, 1).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
const monthEndLabel = new Date(year, month, 0).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
2026-07-06 12:54:11 -05:00
const paidCount = Number(summary.count_paid ?? 0);
const overdueCount = Number(summary.count_late ?? 0);
const unpaidCount = rows.filter((row) => !row.is_skipped && !rowIsPaid(row)).length;
const headerStatusTone = isError
? 'danger'
: loading
? 'info'
: overdueCount > 0
? 'danger'
: unpaidCount > 0
? 'warn'
: 'good';
2026-07-06 12:54:11 -05:00
const headerStatusText = isError
? 'Needs attention'
: loading
? 'Loading month'
: overdueCount > 0
? `${overdueCount} overdue`
: unpaidCount > 0
? `${unpaidCount} unpaid`
: 'All settled';
2026-05-30 16:13:37 -05:00
async function persistTrackerOrder(nextRows: TrackerRow[], movedBillId: number | null) {
2026-05-30 16:13:37 -05:00
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');
invalidateData();
2026-05-30 16:13:37 -05:00
} catch (err) {
setOrderedRows(null);
toast.error((err as { message?: string })?.message || 'Failed to save bill order');
2026-05-30 16:13:37 -05:00
} finally {
setMovingBillId(null);
}
}
function handleReorderBucket(bucket: string, orderedBucketRows: TrackerRow[]) {
2026-05-30 16:13:37 -05:00
const sourceRows = rows;
const nextRows = [...sourceRows];
const replacement = [...orderedBucketRows];
for (let i = 0; i < nextRows.length; i += 1) {
const cur = nextRows[i];
if (cur && cur.bucket === bucket) nextRows[i] = replacement.shift()!;
2026-05-30 16:13:37 -05:00
}
const moved = orderedBucketRows.find(
(row, index) => row.id !== sourceRows.filter((item) => item.bucket === bucket)[index]?.id,
);
if (moved) {
const pos = orderedBucketRows.findIndex((r) => r.id === moved.id) + 1;
setReorderAnnouncement(
`Moved ${moved.name} to position ${pos} of ${orderedBucketRows.length}.`,
);
}
persistTrackerOrder(nextRows, moved?.id || orderedBucketRows[0]?.id || null);
2026-05-30 16:13:37 -05:00
}
2026-05-03 19:51:57 -05:00
return (
<div className="space-y-5">
{/* Visually-hidden live region: announces drag/keyboard reorder to screen readers */}
<div role="status" aria-live="polite" className="sr-only">
{reorderAnnouncement}
</div>
2026-05-03 19:51:57 -05:00
{/* ── Header ── */}
<PageHeader
2026-07-06 12:54:11 -05:00
eyebrow="Monthly command center"
title="Tracker"
description={
<>
2026-07-06 12:54:11 -05:00
{activeMonthLabel}
<span className="mx-2 text-muted-foreground/60">·</span>
{monthStartLabel} to {monthEndLabel}
{isCurrentMonth && (
<>
<span className="mx-2 text-muted-foreground/60">·</span>
Current month
</>
)}
</>
}
glyph="tracker"
meta={
2026-07-06 12:54:11 -05:00
<div className="flex flex-wrap items-center gap-2">
<HeaderStat
icon={CheckCircle2}
label="Paid"
value={loading ? '...' : paidCount}
tone={paidCount > 0 ? 'good' : 'neutral'}
/>
<HeaderStat
icon={AlertCircle}
label="Needs action"
value={loading ? '...' : headerStatusText}
tone={headerStatusTone}
/>
<HeaderStat
icon={Search}
label="Showing"
value={`${filteredRows.length}/${rows.length}`}
tone={hasFilters ? 'info' : 'neutral'}
/>
<HeaderStat
icon={RefreshCw}
label="Remaining"
value={loading ? '...' : fmt(summary.remaining)}
tone={Number(summary.remaining ?? 0) <= 0 ? 'good' : 'neutral'}
/>
2026-07-06 12:54:11 -05:00
</div>
}
actions={
<div className="flex flex-wrap items-center gap-2 tracker-print-hide">
<div className="flex items-center gap-1 rounded-full border border-border/75 bg-card/70 p-1 shadow-sm shadow-black/5">
<Button
size="icon"
variant="ghost"
onClick={() => navigate(-1)}
onMouseEnter={() => prefetchAdjacent(-1)}
onFocus={() => prefetchAdjacent(-1)}
className="h-8 w-8 rounded-full"
aria-label="Previous month"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="min-w-32 px-1 text-center text-xs font-semibold tabular-nums select-none">
{activeMonthLabel}
</span>
<Button
size="icon"
variant="ghost"
onClick={() => navigate(1)}
onMouseEnter={() => prefetchAdjacent(1)}
onFocus={() => prefetchAdjacent(1)}
className="h-8 w-8 rounded-full"
aria-label="Next month"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
2026-07-06 12:54:11 -05:00
<Button
size="sm"
variant={isCurrentMonth ? 'secondary' : 'outline'}
onClick={goToday}
className="h-9 rounded-full px-3 text-xs"
2026-07-06 12:54:11 -05:00
>
Today
2026-07-06 12:54:11 -05:00
</Button>
<Button
size="sm"
variant={pinUpcoming ? 'default' : 'outline'}
onClick={togglePinUpcoming}
2026-07-06 12:54:11 -05:00
className="h-9 gap-1.5 rounded-full px-3"
title={
pinUpcoming
? 'Showing urgent bills first — click to restore normal order'
: 'Pin overdue and due-soon bills to the top'
}
>
<ArrowUpToLine className="h-4 w-4" />
<span className="hidden sm:inline">{pinUpcoming ? 'Pinned' : 'Pin Due'}</span>
</Button>
2026-05-31 15:06:10 -05:00
{showBankSync && (
<Button
size="sm"
variant="outline"
onClick={handleBankSync}
disabled={bankSyncing}
className="h-9 gap-1.5 rounded-full px-3"
title="Scan bank transactions and match payments"
>
{bankSyncing ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Landmark className="h-4 w-4" />
)}
<span className="hidden sm:inline">{bankSyncing ? 'Syncing…' : 'Sync Bank'}</span>
</Button>
)}
<Button
size="sm"
onClick={handleOpenAddBill}
className="h-9 gap-1.5 rounded-full px-3 shadow-sm"
aria-label="Add bill"
title="Add bill"
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Add Bill</span>
</Button>
<Button
size="sm"
variant="outline"
onClick={goToCalendar}
className="h-9 gap-1.5 rounded-full px-3 tracker-print-hide"
aria-label="Open calendar for this month"
title="View this month on the calendar"
>
<CalendarDays className="h-4 w-4" />
<span className="hidden sm:inline">Calendar</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5 rounded-full px-3 tracker-print-hide"
aria-label="Export or print this month"
title="Export or print this month"
>
<Download className="h-4 w-4" />
<span className="hidden sm:inline">Export</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
{new Date(year, month - 1, 1).toLocaleString('en-US', { month: 'long' })} {year}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleExportMonthCsv}>
<FileSpreadsheet className="mr-2 h-4 w-4" />
Export CSV
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => window.print()}>
<Printer className="mr-2 h-4 w-4" />
Print
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-9 w-9 rounded-full tracker-print-hide"
aria-label="Keyboard shortcuts"
title="Keyboard shortcuts"
>
<Keyboard className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel>Keyboard shortcuts</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="space-y-1.5 px-2 py-1.5 text-xs text-muted-foreground">
{[
['Move between bills', 'j / k · ↑ / ↓'],
['Edit focused bill', 'Enter'],
['Toggle paid', 'p'],
['Blur / deselect row', 'Esc'],
['Command palette', '⌘K / Ctrl+K'],
].map(([label, keys]) => (
<div key={label} className="flex items-center justify-between gap-4">
<span>{label}</span>
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] text-foreground">
{keys}
</kbd>
</div>
))}
</div>
<DropdownMenuSeparator />
<p className="px-2 py-1 text-[11px] text-muted-foreground">
Focus any bill row (Tab or click), then use the keys above.
</p>
</DropdownMenuContent>
</DropdownMenu>
</div>
}
/>
2026-05-03 19:51:57 -05:00
{showSearchSort && (
<SearchFilterPanel
title="Search & sort"
className="tracker-print-hide"
collapsed={searchPanelCollapsed}
onCollapsedChange={setSearchPanelCollapsed}
hasFilters={hasFilters}
resultLabel={searchResultLabel}
sortLabel={searchSortLabel}
onClear={resetFilters}
headerActions={
<div className="flex items-center gap-2">
<TrackerColumnMenu
visibleColumns={visibleTableColumns}
onColumnToggle={handleTableColumnToggle}
saving={savingTrackerSetting}
/>
<Button
type="button"
size="sm"
variant="ghost"
onClick={hideSearchSortPanel}
disabled={savingTrackerSetting}
className="h-8 gap-1.5 px-2.5 text-xs text-muted-foreground hover:text-foreground"
>
<EyeOff className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Hide</span>
</Button>
</div>
}
>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-[minmax(220px,1fr)_220px_180px_220px_auto] xl:items-center">
<label className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => updateParams({ q: e.target.value || null })}
placeholder="Search this month by bill, category, notes, or amount"
className="h-10 pl-9"
/>
</label>
<Select
value={filters.category}
onValueChange={(value) => setFilterValue('category', value)}
>
<SelectTrigger className="h-10" aria-label="Filter by category">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_ALL}>All categories</SelectItem>
{categoryOptions.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filters.cycle} onValueChange={(value) => setFilterValue('cycle', value)}>
<SelectTrigger className="h-10 capitalize" aria-label="Filter by billing schedule">
<SelectValue placeholder="Billing schedule" />
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_ALL}>All cycles</SelectItem>
{cycleOptions.map((cycle) => (
<SelectItem key={cycle} value={cycle}>
{scheduleLabel(cycle)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={sortKey} onValueChange={setSort}>
<SelectTrigger className="h-10" aria-label="Sort by">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
{TRACKER_SORT_OPTIONS.map((option) => (
<SelectItem key={option.key} value={option.key}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
disabled={!hasSort}
onClick={toggleSortDirection}
className="h-10 justify-center gap-2 text-xs"
title={
hasSort
? `Sort ${sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}`
: 'Choose a sort first'
}
>
{sortDir === TRACKER_SORT_ASC ? (
<ArrowUp className="h-3.5 w-3.5" />
) : (
<ArrowDown className="h-3.5 w-3.5" />
)}
<span>{sortDir === TRACKER_SORT_ASC ? 'Asc' : 'Desc'}</span>
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<FilterChip active={filters.unpaid} onClick={() => toggleFilter('unpaid')}>
Unpaid
</FilterChip>
<FilterChip active={filters.overdue} onClick={() => toggleFilter('overdue')}>
Overdue
</FilterChip>
<FilterChip active={filters.autopay} onClick={() => toggleFilter('autopay')}>
Autopay
</FilterChip>
<FilterChip active={filters.firstBucket} onClick={() => toggleFilter('firstBucket')}>
1st bucket
</FilterChip>
<FilterChip
active={filters.fifteenthBucket}
onClick={() => toggleFilter('fifteenthBucket')}
>
15th bucket
</FilterChip>
<FilterChip active={filters.debt} onClick={() => toggleFilter('debt')}>
Debt
</FilterChip>
<span
role="status"
aria-live="polite"
className="ml-auto text-xs text-muted-foreground tabular-nums"
>
{filteredRows.length} of {rows.length} shown
{hasSort && (
<span className="ml-2 hidden sm:inline">
· sorted by {TRACKER_SORT_LABELS[sortKey]}{' '}
{sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}
</span>
)}
</span>
</div>
</SearchFilterPanel>
)}
2026-05-03 19:51:57 -05:00
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
{showSummaryCards && loading ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:flex" aria-busy="true">
<Skeleton variant="card" className="h-32" />
<Skeleton variant="card" className="h-32" />
<Skeleton variant="card" className="h-32" />
<Skeleton variant="card" className="h-32" />
<Skeleton variant="card" className="h-32" />
{summary.trend ? <Skeleton variant="card" className="h-32" /> : null}
</div>
) : showSummaryCards ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:flex">
{bankTracking?.enabled ? (
<button
type="button"
onClick={() => setIncomeModalOpen(true)}
className={cn(
'metric-card min-w-0 flex-1 text-left transition-all duration-200',
'hover:ring-2 hover:ring-primary/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
Number(bankTracking.remaining ?? 0) >= 0
? 'border-emerald-500/25 bg-emerald-500/[0.07]'
: 'border-destructive/30 bg-destructive/[0.07]',
)}
title="Click to see income breakdown"
>
<div className="mb-3 flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
'grid h-7 w-7 shrink-0 place-items-center rounded-lg border border-current/15 bg-current/8',
Number(bankTracking.remaining ?? 0) >= 0
? 'text-emerald-600 dark:text-emerald-300'
: 'text-destructive',
)}
>
<Landmark className="h-3.5 w-3.5" />
</span>
<p className="truncate text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
{bankTracking.account_name}
</p>
</div>
<span className="ml-auto flex items-center gap-1 text-[10px] text-emerald-600 dark:text-emerald-400 font-medium">
<StatusDot
tone="good"
pulse
className="h-1.5 w-1.5 [&_span]:h-1.5 [&_span]:w-1.5"
/>
Live
</span>
</div>
<p className="metric-value mt-4 text-2xl leading-none sm:text-[1.72rem]">
{fmt(bankTracking.effective_balance ?? 0)}
</p>
<p className="mt-2 text-xs leading-5 text-muted-foreground">
{Number(bankTracking.remaining ?? 0) < 0 ? '' : ''}
{fmt(Math.abs(Number(bankTracking.remaining ?? 0)))} projected after bills
</p>
{bankTracking.last_updated && (
<p className="mt-1 text-[10px] leading-4 text-muted-foreground/70">
as of {fmtBalanceAge(bankTracking.last_updated)}
</p>
)}
</button>
) : (
<SummaryCard
type="starting"
value={summary.total_starting}
hint={(() => {
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)}
/>
)}
<SummaryCard
type="paid"
value={summary.total_paid}
hint={
Number(summary.previous_month_total ?? 0) > 0
? `Last month: ${fmt(summary.previous_month_total)}`
: undefined
}
/>
{/* In bank mode the hero card already surfaces the projected remaining,
so only show the Remaining card when there's no bank hero. */}
{!bankTracking?.enabled && (
<SummaryCard
type="remaining"
value={summary.remaining}
hint={summary.remaining_label || undefined}
/>
)}
<SummaryCard type="overdue" value={summary.overdue} />
{summary.trend ? (
<TrendCard trend={summary.trend as Parameters<typeof TrendCard>[0]['trend']} />
) : null}
</div>
) : null}
2026-05-03 19:51:57 -05:00
{/* Compact month-progress line — quick "how far through the month am I?" */}
{showSummaryCards &&
!loading &&
!isError &&
Number(summary?.total_expected ?? 0) > 0 &&
(() => {
const billsLeft = (summary.count_upcoming ?? 0) + (summary.count_late ?? 0);
const progress = Math.min(
100,
Math.max(
0,
(Number(summary.paid_toward_due || 0) / Number(summary.total_expected || 1)) * 100,
),
);
return (
<div className="-mt-1 rounded-xl border border-border/65 bg-card/55 px-3 py-2 shadow-sm shadow-black/5">
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] text-muted-foreground">
<p>
<span className="tracker-number font-semibold text-foreground">
{fmt(summary.paid_toward_due)}
</span>
{' of '}
<span className="tracker-number">{fmt(summary.total_expected)}</span>
{' paid'}
{billsLeft > 0 && (
<>
{' '}
· {billsLeft} bill{billsLeft === 1 ? '' : 's'} left
</>
)}
</p>
<span className="tracker-number font-semibold text-foreground">
{Math.round(progress)}%
</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary/75"
style={{ width: `${progress}%` }}
/>
</div>
</div>
);
})()}
{/* ── Safe to Spend ── */}
{!isError && !loading && showSafeToSpend && isCurrentMonth && cashflow && (
<CashFlowCard cashflow={cashflow} onSetStartingAmounts={() => setEditStartingOpen(true)} />
)}
{/* ── Overdue Command Center ── */}
{!isError && !loading && showOverdueCommandCenter && (summary?.count_late ?? 0) > 0 && (
<OverdueCommandCenter
rows={rows}
year={year}
month={month}
refresh={invalidateData}
onPayNow={(row) => setCommandCenterPayRow(row)}
collapsed={overdueCollapsed}
onCollapsedChange={(c) =>
saveTrackerSettings({ tracker_overdue_collapsed: c ? 'true' : 'false' })
}
onHide={hideOverduePanel}
/>
)}
{/* ── Bank Projection Banner ── */}
{!isError && !loading && showBankProjectionBanner && (
<BankProjectionBanner
bankTracking={bankTracking}
busy={savingTrackerSetting}
onSnooze={snoozeBankProjectionBanner}
onIgnore={ignoreBankProjectionBanner}
/>
)}
{/* ── Drift / Price-Change Insights ── */}
{!isError && !loading && showDriftInsights && (driftData?.bills?.length ?? 0) > 0 && (
<DriftInsightPanel
driftBills={driftData?.bills || []}
refresh={invalidateData}
collapsed={driftCollapsed}
onCollapsedChange={(c) =>
saveTrackerSettings({ tracker_drift_collapsed: c ? 'true' : 'false' })
}
onHide={hideDriftPanel}
/>
)}
2026-05-28 02:34:24 -05:00
{/* ── Fetch error state ── */}
{isError && (
<EmptyState
icon={AlertCircle}
title="Failed to load tracker data"
description={error?.message || 'An unexpected error occurred.'}
action={
<Button
size="sm"
variant="outline"
onClick={() => refetch()}
className="gap-1.5 text-xs border-destructive/30 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/50"
>
<RefreshCw className="h-3 w-3" />
Try again
</Button>
}
/>
2026-05-28 02:34:24 -05:00
)}
2026-05-03 19:51:57 -05:00
{/* ── Empty state ── */}
{!isError && rows.length === 0 && data != null && (
<EmptyState
icon={CheckCircle2}
title="No bills this month"
action={
<a
href="/bills"
className="text-xs text-muted-foreground underline underline-offset-4 transition-colors hover:text-foreground"
>
Add a bill
</a>
}
/>
2026-05-03 19:51:57 -05:00
)}
{/* ── Buckets — year and month passed so Row can open the correct monthly state dialog ── */}
2026-05-28 02:34:24 -05:00
{!isError && loading && (
<div className="space-y-5" aria-busy="true">
<div className="rounded-xl border border-border overflow-hidden bg-card p-4">
<div className="flex items-center justify-between mb-4">
<div className="h-4 w-32 rounded-md bg-muted" />
<div className="h-4 w-16 rounded-md bg-muted" />
</div>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 animate-pulse">
<div className="h-1.5 w-1.5 rounded-full bg-muted" />
<div className="h-4 w-64 rounded-md bg-muted" />
</div>
))}
</div>
</div>
<div className="rounded-xl border border-border overflow-hidden bg-card p-4">
<div className="flex items-center justify-between mb-4">
<div className="h-4 w-32 rounded-md bg-muted" />
<div className="h-4 w-16 rounded-md bg-muted" />
</div>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 animate-pulse">
<div className="h-1.5 w-1.5 rounded-full bg-muted" />
<div className="h-4 w-64 rounded-md bg-muted" />
</div>
))}
</div>
</div>
</div>
)}
{/* ── Filtered-to-empty state — bills exist this month but none match the active filters/search ── */}
{!isError && !loading && rows.length > 0 && first.length === 0 && second.length === 0 && (
<EmptyState
icon={Search}
title="No bills match your filters"
description={`${rows.length} bill${rows.length === 1 ? '' : 's'} this month are hidden by the current search or filters.`}
action={
hasFilters ? (
<Button
size="sm"
variant="outline"
onClick={resetFilters}
className="gap-1.5 text-xs"
>
<RefreshCw className="h-3 w-3" />
Clear filters
</Button>
) : undefined
}
/>
)}
{!isError && (first.length > 0 || second.length > 0) && (
<div className="space-y-5">
{first.length > 0 && (
<Bucket
label="1st 14th"
rows={first}
year={year}
month={month}
refresh={invalidateData}
onEditBill={handleOpenEditBill}
loading={loading}
reorderEnabled={reorderEnabled}
movingBillId={movingBillId}
sortKey={sortKey}
sortDir={sortDir}
onSort={handleSortHeader}
onReorderRows={(next) => handleReorderBucket('1st', next)}
driftedIds={driftedIds}
visibleColumns={visibleTableColumns}
selectedIds={selectedIds}
onToggleSelect={handleToggleSelect}
/>
)}
{second.length > 0 && (
<Bucket
label="15th 31st"
rows={second}
year={year}
month={month}
refresh={invalidateData}
onEditBill={handleOpenEditBill}
loading={loading}
reorderEnabled={reorderEnabled}
movingBillId={movingBillId}
sortKey={sortKey}
sortDir={sortDir}
onSort={handleSortHeader}
onReorderRows={(next) => handleReorderBucket('15th', next)}
driftedIds={driftedIds}
visibleColumns={visibleTableColumns}
selectedIds={selectedIds}
onToggleSelect={handleToggleSelect}
/>
)}
</div>
)}
2026-05-03 19:51:57 -05:00
{/* Edit Bill modal — opened by clicking a bill name in any tracker row */}
{editBillData && (
<BillModal
key={
editBillData.bill?.id
? `edit-${editBillData.bill.id}`
: `new-${editBillData.initialBill?.name || 'blank'}`
}
2026-05-03 19:51:57 -05:00
bill={editBillData.bill}
2026-05-16 15:38:28 -05:00
initialBill={editBillData.initialBill}
2026-05-03 19:51:57 -05:00
categories={editBillData.categories}
onClose={() => setEditBillData(null)}
onSave={() => invalidateData()}
onDuplicate={(bill) =>
setEditBillData({
bill: null,
initialBill: makeBillDraft(bill, {
copy: true,
categories: editBillData.categories,
}) as Partial<Bill>,
categories: editBillData.categories,
})
}
2026-05-03 19:51:57 -05:00
/>
)}
2026-05-04 20:12:57 -05:00
{/* Edit Starting Amounts modal */}
<StartingAmountsEditDialog
open={editStartingOpen}
onClose={() => setEditStartingOpen(false)}
year={year}
month={month}
onSave={() => {
setEditStartingOpen(false);
invalidateData();
}}
2026-05-04 20:12:57 -05:00
/>
{/* Income breakdown modal — opens when clicking the bank balance card */}
{bankTracking?.enabled && (
<IncomeBreakdownModal
open={incomeModalOpen}
onClose={() => 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 && (
<LateAttributionDialog
attr={lateAttributions[0]}
remaining={lateAttributions.length - 1}
busy={attrBusy === lateAttributions[0]?.payment_id}
onAccept={async (attr) => {
setAttrBusy(attr.payment_id);
try {
await api.attributePaymentToMonth(attr.payment_id, attr.suggested_date);
const monthName = new Date(attr.suggested_date + 'T00:00:00').toLocaleDateString(
'en-US',
{ month: 'long' },
);
toast.success(`${attr.bill_name} payment moved to ${monthName}`);
setLateAttributions((prev) => prev.slice(1)); // dismiss only on success
invalidateData();
} catch (err) {
toast.error(
(err as { message?: string })?.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 && (
<PaymentLedgerDialog
row={commandCenterPayRow}
year={year}
month={month}
threshold={
(commandCenterPayRow.actual_amount ?? commandCenterPayRow.expected_amount) as Dollars
}
defaultPaymentDate={paymentDateForTrackerMonth(year, month, commandCenterPayRow.due_day)}
onClose={() => setCommandCenterPayRow(null)}
onSaved={() => {
setCommandCenterPayRow(null);
invalidateData();
}}
/>
)}
{/* Multi-select action bar — sticky at the bottom while any bills are checked */}
{selectedRows.length > 0 && (
<div className="tracker-print-hide fixed inset-x-0 bottom-4 z-40 flex justify-center px-4">
<div
role="toolbar"
aria-label="Bulk actions for selected bills"
className="flex items-center gap-2 rounded-full border border-border bg-card/95 px-3 py-2 shadow-lg backdrop-blur supports-backdrop-filter:bg-card/80"
>
<span className="px-2 text-sm font-medium tabular-nums">
{selectedRows.length} selected
</span>
<Button
size="sm"
onClick={handlePaySelected}
disabled={bulkBusy || selectedRows.every((r) => r.is_skipped || rowIsPaid(r))}
className="h-8 gap-1.5"
>
<CheckCircle2 className="h-4 w-4" />
Pay
</Button>
<Button
size="sm"
variant="outline"
onClick={handleSkipSelected}
disabled={bulkBusy || selectedRows.every((r) => r.is_skipped)}
className="h-8 gap-1.5"
>
<EyeOff className="h-4 w-4" />
Skip
</Button>
<Button
size="sm"
variant="ghost"
onClick={clearSelection}
disabled={bulkBusy}
className="h-8 gap-1.5 text-muted-foreground"
aria-label="Clear selection"
>
Clear
</Button>
</div>
</div>
)}
2026-05-03 19:51:57 -05:00
</div>
);
}