BillTracker/client/pages/TrackerPage.jsx

673 lines
29 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, X, RefreshCw, Landmark, ArrowUpToLine } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { useTracker, useDriftReport } from '@/hooks/useQueries';
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 { 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 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,
} from '@/lib/trackerUtils';
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';
// ── 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',
};
// 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 [orderedRows, setOrderedRows] = useState(null);
const [movingBillId, setMovingBillId] = useState(null);
// 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();
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));
}, []);
// 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 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 hasFilters = !!(
search.trim()
|| filters.category !== FILTER_ALL
|| filters.cycle !== FILTER_ALL
|| filters.autopay
|| filters.firstBucket
|| filters.fifteenthBucket
|| filters.unpaid
|| filters.overdue
|| filters.debt
);
const resetFilters = () => {
updateParams({ q: null, fc: null, cy: null, ap: null, b1: null, b2: null, un: null, ov: null, de: 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 = 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 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>
<div className="rounded-xl border border-border/80 bg-card/95 p-4 shadow-sm shadow-black/15 space-y-3">
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_220px_180px_auto] lg: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>
<Button
type="button"
variant="ghost"
disabled={!hasFilters}
onClick={resetFilters}
className="h-10 justify-center gap-2 text-xs"
>
<X className="h-3.5 w-3.5" />
Clear
</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
</span>
</div>
</div>
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
{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>
) : (
<div className="grid grid-cols-2 gap-3 lg:flex">
<SummaryCard
type="starting"
value={summary.total_starting}
hint={(() => {
if (bankTracking?.enabled) {
const proj = Number(bankTracking.remaining ?? 0);
const sign = proj < 0 ? '' : '';
return `${bankTracking.account_name} · projected ${sign}${fmt(Math.abs(proj))} after bills`;
}
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={bankTracking?.enabled ? undefined : () => 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>
)}
{/* ── Overdue Command Center ── */}
{!isError && !loading && (summary?.count_late ?? 0) > 0 && (
<OverdueCommandCenter
rows={rows}
year={year}
month={month}
refresh={refetch}
onPayNow={(row) => setCommandCenterPayRow(row)}
/>
)}
{/* ── Drift / Price-Change Insights ── */}
{!isError && !loading && (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} onReorderRows={(next) => handleReorderBucket('1st', next)} />}
{second.length > 0 && <Bucket label="15th 31st" rows={second} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} onReorderRows={(next) => handleReorderBucket('15th', next)} />}
</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(); }}
/>
{/* 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>
);
}