1034 lines
44 KiB
JavaScript
1034 lines
44 KiB
JavaScript
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 } 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 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 localDateString(date = new Date()) {
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||
const day = String(date.getDate()).padStart(2, '0');
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
|
||
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();
|
||
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 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}
|
||
|
||
{/* ── 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={() => { setEditBillData(null); 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>
|
||
);
|
||
}
|