BillTracker/client/pages/TrackerPage.tsx

1944 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
import { toast } from 'sonner';
import { api } from '@/api';
import {
useTracker,
useDriftReport,
useInvalidateTrackerData,
usePrefetchTracker,
useSettings,
useSimplefinStatus,
useSaveSettings,
} from '@/hooks/useQueries';
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
import BillModal from '@/components/BillModal';
import { makeBillDraft } from '@/lib/billDrafts';
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
import { cn, localDateString, parseUtc, settingEnabled } from '@/lib/utils';
import { downloadFile } from '@/lib/download';
import { formatUSD, asDollars, type Dollars } from '@/lib/money';
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';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} 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';
import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog';
import CashFlowCard from '@/components/tracker/CashFlowCard';
import OverdueCommandCenter from '@/components/tracker/OverdueCommandCenter';
import DriftInsightPanel from '@/components/tracker/DriftInsightPanel';
import {
MONTHS,
FILTER_ALL,
paymentDateForTrackerMonth,
amountSearchText,
rowEffectiveStatus,
rowIsPaid,
rowIsDebt,
TRACKER_SORT_DEFAULT,
TRACKER_SORT_ASC,
TRACKER_SORT_DESC,
TRACKER_SORT_OPTIONS,
TRACKER_SORT_DEFAULT_DIRS,
TRACKER_SORT_LABELS,
normalizeTrackerSortKey,
normalizeTrackerSortDir,
sortTrackerRows,
} from '@/lib/trackerUtils';
import {
TRACKER_TABLE_COLUMNS,
parseTrackerTableColumns,
trackerTableColumnsToSetting,
} from '@/lib/trackerTableColumns';
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';
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;
}
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>;
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',
}[tone];
return (
<span
className={cn('inline-flex items-center gap-2 rounded-full border px-3 py-1.5', toneClass)}
>
<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>
<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>
);
}
// ── 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"]',
};
export default function TrackerPage() {
const [searchParams, setSearchParams] = useSearchParams();
const navigateTo = useNavigate();
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') || '';
// 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;
// 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);
// 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();
// 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]);
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) {
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);
}
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) {
try {
const [bill, categories] = await Promise.all([
api.bill(row.id) as Promise<Bill>,
api.categories() as Promise<Category[]>,
]);
setEditBillData({ bill, categories });
} catch (err) {
toast.error((err as { message?: string })?.message || 'Failed to open bill');
}
}
async function handleOpenAddBill() {
try {
const categories = (await api.categories()) as Category[];
setEditBillData({ bill: null, categories });
} catch (err) {
toast.error((err as { message?: string })?.message || 'Failed to open bill editor');
}
}
function goToday() {
const n = new Date();
updateParams({ year: n.getFullYear(), month: n.getMonth() + 1 });
}
// 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() {
saveTrackerSettings({ tracker_show_search_sort: 'false' });
toast('Search & sort hidden.', {
action: {
label: 'Undo',
onClick: () => saveTrackerSettings({ tracker_show_search_sort: 'true' }),
},
});
}
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',
};
updateParams({ [paramMap[key]]: !filters[key] });
};
const setFilterValue = (key: 'category' | 'cycle', value: string) => {
const paramMap: Record<'category' | 'cycle', string> = { category: 'fc', cycle: 'cy' };
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,
});
};
const hasFilters = !!(
search.trim() ||
filters.category !== FILTER_ALL ||
filters.cycle !== FILTER_ALL ||
filters.autopay ||
filters.firstBucket ||
filters.fifteenthBucket ||
filters.unpaid ||
filters.overdue ||
filters.debt ||
hasSort
);
const searchSortLabel = hasSort
? `sorted by ${TRACKER_SORT_LABELS[sortKey]} ${sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}`
: undefined;
const resetFilters = () => {
updateParams({
q: null,
fc: null,
cy: null,
ap: null,
b1: null,
b2: null,
un: null,
ov: null,
de: null,
sort: null,
dir: null,
});
};
const categoryOptions = useMemo(() => {
const map = new Map<string, string>();
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]);
// 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);
const filteredRows = useMemo(() => {
const q = deferredSearch.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, 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;
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;
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',
});
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';
const headerStatusText = isError
? 'Needs attention'
: loading
? 'Loading month'
: overdueCount > 0
? `${overdueCount} overdue`
: unpaidCount > 0
? `${unpaidCount} unpaid`
: 'All settled';
async function persistTrackerOrder(nextRows: TrackerRow[], movedBillId: number | null) {
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();
} catch (err) {
setOrderedRows(null);
toast.error((err as { message?: string })?.message || 'Failed to save bill order');
} finally {
setMovingBillId(null);
}
}
function handleReorderBucket(bucket: string, orderedBucketRows: TrackerRow[]) {
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()!;
}
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);
}
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>
{/* ── Header ── */}
<PageHeader
eyebrow="Monthly command center"
title="Tracker"
description={
<>
{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={
<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'}
/>
</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>
<Button
size="sm"
variant={isCurrentMonth ? 'secondary' : 'outline'}
onClick={goToday}
className="h-9 rounded-full px-3 text-xs"
>
Today
</Button>
<Button
size="sm"
variant={pinUpcoming ? 'default' : 'outline'}
onClick={togglePinUpcoming}
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>
{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>
}
/>
{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>
)}
{/* ── 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}
{/* 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}
/>
)}
{/* ── 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>
}
/>
)}
{/* ── 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>
}
/>
)}
{/* ── Buckets — year and month passed so Row can open the correct monthly state dialog ── */}
{!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>
)}
{/* 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'}`
}
bill={editBillData.bill}
initialBill={editBillData.initialBill}
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,
})
}
/>
)}
{/* Edit Starting Amounts modal */}
<StartingAmountsEditDialog
open={editStartingOpen}
onClose={() => setEditStartingOpen(false)}
year={year}
month={month}
onSave={() => {
setEditStartingOpen(false);
invalidateData();
}}
/>
{/* 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>
)}
</div>
);
}