1499 lines
65 KiB
TypeScript
1499 lines
65 KiB
TypeScript
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/[0.08] text-emerald-700 dark:text-emerald-300',
|
||
warn: 'border-amber-500/30 bg-amber-500/[0.08] text-amber-700 dark:text-amber-300',
|
||
danger: 'border-rose-500/30 bg-rose-500/[0.08] text-rose-700 dark:text-rose-300',
|
||
info: 'border-sky-500/25 bg-sky-500/[0.08] 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-[8rem] 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/[0.08]',
|
||
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>
|
||
);
|
||
}
|