BillTracker/client/pages/TrackerPage.jsx

1039 lines
45 KiB
JavaScript
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 } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, EyeOff, Settings2 } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { useTracker, useDriftReport } 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, fmt, localDateString } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
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, 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';
function parseUtc(str) {
if (!str) return null;
// SQLite datetime('now') returns "2026-06-07 23:40:15" — no T, no Z. Treat as UTC.
const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z';
return new Date(normalized);
}
function fmtBalanceAge(isoStr) {
if (!isoStr) return null;
return parseUtc(isoStr).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
function settingEnabled(value, fallback = true) {
if (value === undefined || value === null || value === '') return fallback;
return value === true || value === 'true';
}
function BankProjectionBanner({ bankTracking, onSnooze, onIgnore, busy }) {
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 }) {
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 }) {
if (!attr) return null;
const fmtDate = (d) => 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>
);
}
export default function TrackerPage() {
const [searchParams, setSearchParams] = useSearchParams();
const now = new Date();
// All navigation + filter state lives in the URL so views are bookmarkable/shareable.
const year = Number(searchParams.get('year')) || now.getFullYear();
const month = Number(searchParams.get('month')) || (now.getMonth() + 1);
const search = searchParams.get('q') || '';
const filters = {
category: searchParams.get('fc') || FILTER_ALL,
cycle: searchParams.get('cy') || FILTER_ALL,
autopay: searchParams.get('ap') === '1',
firstBucket: searchParams.get('b1') === '1',
fifteenthBucket: searchParams.get('b2') === '1',
unpaid: searchParams.get('un') === '1',
overdue: searchParams.get('ov') === '1',
debt: searchParams.get('de') === '1',
};
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) => {
setSearchParams(prev => {
const next = new URLSearchParams(prev);
Object.entries(patch).forEach(([k, v]) => {
if (v == null || v === '' || v === false) next.delete(k);
else next.set(k, v === true ? '1' : String(v));
});
return next;
}, { replace: true });
}, [setSearchParams]);
// Edit Bill modal: { bill, categories } when open, null when closed
const [bankSyncStatus, setBankSyncStatus] = useState(null);
const [bankSyncing, setBankSyncing] = useState(false);
const [lateAttributions, setLateAttributions] = useState([]); // pending month-attribution prompts
const [attrBusy, setAttrBusy] = useState(null); // payment_id being resolved
const [pinUpcoming, setPinUpcoming] = useState(() => localStorage.getItem('tracker_pin_upcoming') === 'true');
const [editBillData, setEditBillData] = useState(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(null);
const [movingBillId, setMovingBillId] = useState(null);
const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();
const [trackerSettings, setTrackerSettings] = useState({
tracker_show_bank_projection_banner: 'true',
tracker_bank_projection_banner_snoozed_until: '',
tracker_show_search_sort: 'true',
tracker_show_summary_cards: 'true',
tracker_show_overdue_command_center: 'true',
tracker_show_drift_insights: 'true',
tracker_table_columns: '["due","expected","previous","paid","paid_date","status","action","notes"]',
});
const [savingTrackerSetting, setSavingTrackerSetting] = useState(false);
// Row to open in PaymentLedgerDialog via the overdue command center
const [commandCenterPayRow, setCommandCenterPayRow] = useState(null);
// Use React Query for data fetching
const { data, isLoading: loading, isError, error, refetch, dataUpdatedAt } = useTracker(year, month);
const { data: driftData, refetch: refetchDrift } = useDriftReport();
const driftedIds = useMemo(() => new Set((driftData?.bills ?? []).map(b => b.id)), [driftData]);
useEffect(() => {
setOrderedRows(null);
setMovingBillId(null);
}, [dataUpdatedAt, year, month]);
// Load SimpleFIN status once to decide whether to show the sync button
useEffect(() => {
api.simplefinStatus()
.then(setBankSyncStatus)
.catch(() => setBankSyncStatus(null));
}, []);
useEffect(() => {
api.settings()
.then(settings => setTrackerSettings(prev => ({ ...prev, ...settings })))
.catch(() => {});
}, []);
// Listen for late-attribution events fired by BillModal's single-bill sync
useEffect(() => {
function handler(e) {
const attrs = e.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 navigate(delta) {
let nm = month + delta;
let ny = year;
if (nm > 12) { ny += 1; nm = 1; }
if (nm < 1) { ny -= 1; nm = 12; }
updateParams({ year: ny, month: nm });
}
async function handleBankSync() {
setBankSyncing(true);
try {
const result = await api.syncAllSources();
const matched = result.auto_matched ?? 0;
const newTx = result.transactions_new ?? 0;
const billNames = result.matched_bills ?? [];
const attributions = result.late_attributions ?? [];
if (matched > 0 && billNames.length > 0) {
toast.success(
`Synced — ${billNames.join(', ')}` +
(matched > billNames.length ? ` (+${matched - billNames.length} more)` : ''),
{ duration: 5000 }
);
} else if (matched > 0) {
toast.success(`Synced — ${matched} payment${matched === 1 ? '' : 's'} matched`);
} else if (newTx > 0) {
toast.success(`Synced — ${newTx} new transaction${newTx === 1 ? '' : 's'}, no automatic matches`);
} else {
toast.success('Synced — no new transactions');
}
// Surface late-attribution prompts (payments that just crossed a month boundary)
if (attributions.length > 0) setLateAttributions(attributions);
refetch();
} catch (err) {
toast.error(err.message || 'Bank sync failed');
} 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) {
try {
const [bill, categories] = await Promise.all([
api.bill(row.id),
api.categories(),
]);
setEditBillData({ bill, categories });
} catch (err) {
toast.error(err.message);
}
}
async function handleOpenAddBill() {
try {
const categories = await api.categories();
setEditBillData({ bill: null, categories });
} catch (err) {
toast.error(err.message || 'Failed to open bill editor');
}
}
function goToday() {
const n = new Date();
updateParams({ year: n.getFullYear(), month: n.getMonth() + 1 });
}
const rows = orderedRows || data?.rows || [];
const summary = data?.summary || {};
const bankTracking = data?.bank_tracking;
const cashflow = data?.cashflow;
const 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);
const showBankProjectionBanner = settingEnabled(trackerSettings.tracker_show_bank_projection_banner) &&
(!bannerSnoozedUntil || bannerSnoozedUntil <= today);
const visibleTableColumns = useMemo(
() => parseTrackerTableColumns(trackerSettings.tracker_table_columns),
[trackerSettings.tracker_table_columns]
);
async function saveTrackerSettings(patch, successMessage) {
setSavingTrackerSetting(true);
setTrackerSettings(prev => ({ ...prev, ...patch }));
try {
const next = await api.saveSettings(patch);
setTrackerSettings(prev => ({ ...prev, ...next }));
if (successMessage) toast.success(successMessage);
} catch (err) {
toast.error(err.message || 'Failed to update tracker setting');
api.settings()
.then(settings => setTrackerSettings(prev => ({ ...prev, ...settings })))
.catch(() => {});
} finally {
setSavingTrackerSetting(false);
}
}
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, checked) {
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 showSearchSortPanel() {
saveTrackerSettings({ tracker_show_search_sort: 'true' });
}
const toggleFilter = (key) => {
const paramMap = { autopay: 'ap', firstBucket: 'b1', fifteenthBucket: 'b2', unpaid: 'un', overdue: 'ov', debt: 'de' };
updateParams({ [paramMap[key]]: !filters[key] });
};
const setFilterValue = (key, value) => {
const paramMap = { category: 'fc', cycle: 'cy' };
updateParams({ [paramMap[key]]: value === FILTER_ALL ? null : value });
};
const setSort = (key) => {
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) => {
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'}` : null;
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();
rows.forEach(row => {
if (row.category_id && row.category_name) map.set(String(row.category_id), row.category_name);
});
return Array.from(map, ([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name));
}, [rows]);
const cycleOptions = useMemo(() => (
Array.from(new Set(rows.map(scheduleValue))).sort()
), [rows]);
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase();
return rows.filter(row => {
const effectiveStatus = rowEffectiveStatus(row);
if (filters.category !== FILTER_ALL && String(row.category_id ?? '') !== filters.category) return false;
if (filters.cycle !== FILTER_ALL && scheduleValue(row) !== filters.cycle) return false;
if (filters.autopay && !row.autopay_enabled) return false;
if (filters.debt && !rowIsDebt(row)) return false;
if (filters.unpaid && (row.is_skipped || rowIsPaid(row))) return false;
if (filters.overdue && !(effectiveStatus === 'late' || effectiveStatus === 'missed')) return false;
if (filters.firstBucket && !filters.fifteenthBucket && row.bucket !== '1st') return false;
if (filters.fifteenthBucket && !filters.firstBucket && row.bucket !== '15th') return false;
if (!q) return true;
const haystack = [
row.name,
row.category_name,
row.notes,
row.monthly_notes,
scheduleValue(row),
scheduleLabel(row),
row.bucket,
row.status,
amountSearchText(row.expected_amount, row.actual_amount, row.total_paid, row.balance, row.current_balance, row.minimum_payment),
].filter(Boolean).join(' ').toLowerCase();
return haystack.includes(q);
});
}, [filters, rows, search]);
// 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 = { missed: 0, late: 1, due_soon: 2, upcoming: 3 };
const sortedRows = hasSort
? sortTrackerRows(filteredRows, sortKey, sortDir)
: pinUpcoming
? [...filteredRows].sort((a, b) => {
const ua = URGENCY_ORDER[a.status] ?? 99;
const ub = URGENCY_ORDER[b.status] ?? 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;
async function persistTrackerOrder(nextRows, movedBillId) {
const payload = Object.fromEntries(nextRows.map((row, index) => [row.id, index]));
setOrderedRows(nextRows);
setMovingBillId(movedBillId);
try {
await api.reorderBills(payload);
toast.success('Bill order saved');
refetch();
} catch (err) {
setOrderedRows(null);
toast.error(err.message || 'Failed to save bill order');
} finally {
setMovingBillId(null);
}
}
function handleReorderBucket(bucket, orderedBucketRows) {
const sourceRows = rows;
const nextRows = [...sourceRows];
const replacement = [...orderedBucketRows];
for (let i = 0; i < nextRows.length; i += 1) {
if (nextRows[i].bucket === bucket) nextRows[i] = replacement.shift();
}
const moved = orderedBucketRows.find((row, index) => row.id !== (sourceRows.filter(item => item.bucket === bucket)[index]?.id));
persistTrackerOrder(nextRows, moved?.id || orderedBucketRows[0]?.id);
}
return (
<div className="space-y-5">
{/* ── Header ── */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground mb-0.5">
Monthly Overview
</p>
<h1 className="text-2xl font-bold tracking-tight">
{MONTHS[month - 1]}
<span className="text-muted-foreground font-normal ml-2 text-xl">{year}</span>
</h1>
<p className="text-xs text-muted-foreground mt-0.5">
{rows.length} {rows.length === 1 ? 'bill' : 'bills'}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant={pinUpcoming ? 'default' : 'outline'}
onClick={togglePinUpcoming}
className="h-9 gap-1.5 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 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 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>
<div className="flex items-center gap-1 bg-muted/50 border border-border rounded-lg p-1">
<Button
size="icon" variant="ghost"
onClick={() => navigate(-1)}
className="h-7 w-7 hover:bg-white/5"
aria-label="Previous month"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="min-w-[7.5rem] px-1 text-center text-xs font-semibold tabular-nums select-none">
{MONTHS[month - 1]} {year}
</span>
<Button
size="icon" variant="ghost"
onClick={() => navigate(1)}
className="h-7 w-7 hover:bg-white/5"
aria-label="Next month"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<Button
size="sm" variant="outline"
onClick={goToday}
className="h-9 px-3 text-xs"
>
Today
</Button>
</div>
</div>
{showSearchSort && (
<SearchFilterPanel
title="Search & sort"
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">
<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">
<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">
<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 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-2 gap-3 lg: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" />}
</div>
) : showSummaryCards ? (
<div className="grid grid-cols-2 gap-3 lg:flex">
{bankTracking?.enabled ? (
<button
type="button"
onClick={() => setIncomeModalOpen(true)}
className={cn(
'flex-1 min-w-0 relative overflow-hidden rounded-xl border px-5 py-4 shadow-sm shadow-black/15 transition-all duration-300 text-left',
'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/30 bg-card/95'
: 'border-destructive/30 bg-card/95',
)}
title="Click to see income breakdown"
>
<div className="absolute top-0 left-0 right-0 h-[3px] bg-gradient-to-r from-emerald-500 to-teal-400" />
<div className="flex items-center gap-2 mb-3">
<Landmark className={cn('h-4 w-4', Number(bankTracking.remaining ?? 0) >= 0 ? 'text-emerald-500' : 'text-destructive')} />
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground truncate">
{bankTracking.account_name}
</p>
<span className="ml-auto flex items-center gap-1 text-[10px] text-emerald-600 dark:text-emerald-400 font-medium">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
Live
</span>
</div>
<p className="text-[1.75rem] font-bold tracking-tight font-mono leading-none text-foreground">
{fmt(bankTracking.effective_balance ?? 0)}
</p>
<p className="mt-2 text-[11px] 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] text-muted-foreground/60">
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} />
<SummaryCard type="overdue" value={summary.overdue} />
<SummaryCard type="paid" value={summary.previous_month_total} hint="Previous month"/>
{summary.trend && <TrendCard trend={summary.trend} />}
</div>
) : null}
{/* ── 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={refetch}
onPayNow={(row) => setCommandCenterPayRow(row)}
/>
)}
{/* ── 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={() => { refetch(); refetchDrift(); }}
/>
)}
{/* ── Fetch error state ── */}
{isError && (
<div className="flex flex-col items-center justify-center py-20 text-center rounded-xl border border-destructive/20 bg-destructive/5">
<div className="h-10 w-10 rounded-full bg-destructive/10 flex items-center justify-center mb-3">
<AlertCircle className="h-5 w-5 text-destructive" />
</div>
<p className="text-sm font-medium text-foreground">Failed to load tracker data</p>
<p className="mt-1 text-xs text-muted-foreground">{error?.message || 'An unexpected error occurred.'}</p>
<Button
size="sm"
variant="outline"
onClick={() => refetch()}
className="mt-4 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>
</div>
)}
{/* ── Empty state ── */}
{!isError && rows.length === 0 && data !== null && (
<div className="flex flex-col items-center justify-center py-20 text-center rounded-xl border border-border bg-muted/20">
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center mb-3">
<CheckCircle2 className="h-5 w-5 text-muted-foreground" />
</div>
<p className="text-sm font-medium text-muted-foreground">No bills this month</p>
<a href="/bills" className="mt-1.5 text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground transition-colors">
Add a bill
</a>
</div>
)}
{/* ── 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>
)}
{!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={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('1st', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />}
{second.length > 0 && <Bucket label="15th 31st" rows={second} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('15th', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />}
</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={() => refetch()}
onDuplicate={bill => setEditBillData({
bill: null,
initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }),
categories: editBillData.categories,
})}
/>
)}
{/* Edit Starting Amounts modal */}
<StartingAmountsEditDialog
open={editStartingOpen}
onClose={() => setEditStartingOpen(false)}
year={year}
month={month}
onSave={() => { setEditStartingOpen(false); refetch(); }}
/>
{/* 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 month = new Date(attr.suggested_date + 'T00:00:00').toLocaleDateString('en-US', { month: 'long' });
toast.success(`${attr.bill_name} payment moved to ${month}`);
setLateAttributions(prev => prev.slice(1)); // dismiss only on success
refetch();
} catch (err) {
toast.error(err.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}
defaultPaymentDate={paymentDateForTrackerMonth(year, month, commandCenterPayRow.due_day)}
onClose={() => setCommandCenterPayRow(null)}
onSaved={() => { setCommandCenterPayRow(null); refetch(); }}
/>
)}
</div>
);
}