586 lines
25 KiB
JavaScript
586 lines
25 KiB
JavaScript
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 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 ──────────────────────────────────────────────────────────────
|
||
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 [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));
|
||
}, []);
|
||
|
||
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 ?? [];
|
||
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');
|
||
}
|
||
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) return `${bankTracking.account_name} · live balance`;
|
||
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(); }}
|
||
/>
|
||
|
||
{/* 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>
|
||
);
|
||
}
|