2026-05-31 15:06:10 -05:00
|
|
|
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
2026-05-16 15:38:28 -05:00
|
|
|
|
import { useSearchParams } from 'react-router-dom';
|
2026-06-07 17:33:31 -05:00
|
|
|
|
import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, Eye, EyeOff, Settings2 } from 'lucide-react';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
import { toast } from 'sonner';
|
|
|
|
|
|
import { api } from '@/api.js';
|
2026-05-30 14:33:55 -05:00
|
|
|
|
import { useTracker, useDriftReport } from '@/hooks/useQueries';
|
2026-06-07 01:28:35 -05:00
|
|
|
|
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
import BillModal from '@/components/BillModal';
|
2026-05-16 15:38:28 -05:00
|
|
|
|
import { makeBillDraft } from '@/lib/billDrafts';
|
2026-05-30 21:20:51 -05:00
|
|
|
|
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
|
2026-05-31 15:06:10 -05:00
|
|
|
|
import { cn, fmt } from '@/lib/utils';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
|
import { Input } from '@/components/ui/input';
|
2026-06-07 01:28:35 -05:00
|
|
|
|
import SearchFilterPanel from '@/components/SearchFilterPanel';
|
2026-05-10 01:35:41 -05:00
|
|
|
|
import { Skeleton } from '@/components/ui/Skeleton';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
import {
|
|
|
|
|
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
|
|
|
|
|
} from '@/components/ui/select';
|
2026-06-04 00:06:16 -05:00
|
|
|
|
import {
|
|
|
|
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
|
|
|
|
|
} from '@/components/ui/dialog';
|
2026-06-07 17:33:31 -05:00
|
|
|
|
import {
|
|
|
|
|
|
DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel,
|
|
|
|
|
|
DropdownMenuSeparator, DropdownMenuTrigger,
|
|
|
|
|
|
} from '@/components/ui/dropdown-menu';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
refactor: component splits, PWA support, CommandPalette
Component Splits:
- AdminPage.jsx: 1,906 -> 82 lines (logic moved to client/components/admin/ — 9 files)
- DataPage.jsx: 3,132 -> 60 lines (logic moved to client/components/data/ — 8 files)
- TrackerPage.jsx: 2,566 -> 2,132 lines (MonthlyStateDialog, StartingAmountsEditDialog, PaymentModal)
PWA:
- vite-plugin-pwa installed with NetworkFirst caching for API routes
- Square PWA icons (192x192, 512x512, apple-touch-icon)
- theme-color, apple meta tags, touch icon in index.html
- Build generates dist/sw.js + Workbox runtime
CommandPalette:
- Navigation commands, Add bill action, month jumps
- Grouped results with empty/filtered states
2026-05-28 20:53:22 -05:00
|
|
|
|
import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog';
|
2026-05-30 13:19:09 -05:00
|
|
|
|
import OverdueCommandCenter from '@/components/tracker/OverdueCommandCenter';
|
2026-05-30 14:33:55 -05:00
|
|
|
|
import DriftInsightPanel from '@/components/tracker/DriftInsightPanel';
|
2026-05-31 15:06:10 -05:00
|
|
|
|
import {
|
|
|
|
|
|
MONTHS, FILTER_ALL,
|
|
|
|
|
|
paymentDateForTrackerMonth, amountSearchText, rowEffectiveStatus, rowIsPaid, rowIsDebt,
|
2026-06-07 00:41:07 -05:00
|
|
|
|
TRACKER_SORT_DEFAULT, TRACKER_SORT_ASC, TRACKER_SORT_DESC, TRACKER_SORT_OPTIONS,
|
|
|
|
|
|
TRACKER_SORT_DEFAULT_DIRS, TRACKER_SORT_LABELS,
|
|
|
|
|
|
normalizeTrackerSortKey, normalizeTrackerSortDir, sortTrackerRows,
|
2026-05-31 15:06:10 -05:00
|
|
|
|
} from '@/lib/trackerUtils';
|
2026-06-07 17:33:31 -05:00
|
|
|
|
import { TRACKER_TABLE_COLUMNS, parseTrackerTableColumns, trackerTableColumnsToSetting } from '@/lib/trackerTableColumns';
|
2026-05-31 15:06:10 -05:00
|
|
|
|
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';
|
2026-06-04 21:22:20 -05:00
|
|
|
|
import IncomeBreakdownModal from '@/components/IncomeBreakdownModal';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-06 15:17:27 -05:00
|
|
|
|
function fmtBalanceAge(isoStr) {
|
|
|
|
|
|
if (!isoStr) return null;
|
|
|
|
|
|
return new Date(isoStr).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 17:23:14 -05:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 17:33:31 -05:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-31 15:06:10 -05:00
|
|
|
|
// ── Main page ──────────────────────────────────────────────────────────────
|
2026-06-04 00:06:16 -05:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-31 15:06:10 -05:00
|
|
|
|
export default function TrackerPage() {
|
|
|
|
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
2026-05-03 19:51:57 -05:00
|
|
|
|
const now = new Date();
|
|
|
|
|
|
|
2026-05-31 15:06:10 -05:00
|
|
|
|
// 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',
|
2026-05-16 15:38:28 -05:00
|
|
|
|
};
|
2026-06-07 00:41:07 -05:00
|
|
|
|
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;
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
2026-05-31 15:06:10 -05:00
|
|
|
|
// 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));
|
2026-05-11 11:56:49 -05:00
|
|
|
|
});
|
2026-05-31 15:06:10 -05:00
|
|
|
|
return next;
|
|
|
|
|
|
}, { replace: true });
|
|
|
|
|
|
}, [setSearchParams]);
|
2026-05-11 11:56:49 -05:00
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
// Edit Bill modal: { bill, categories } when open, null when closed
|
2026-06-04 00:06:16 -05:00
|
|
|
|
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
|
2026-06-03 22:55:27 -05:00
|
|
|
|
const [pinUpcoming, setPinUpcoming] = useState(() => localStorage.getItem('tracker_pin_upcoming') === 'true');
|
2026-05-03 19:51:57 -05:00
|
|
|
|
const [editBillData, setEditBillData] = useState(null);
|
2026-05-04 20:12:57 -05:00
|
|
|
|
// Edit Starting Amounts modal: true when open, false when closed
|
|
|
|
|
|
const [editStartingOpen, setEditStartingOpen] = useState(false);
|
2026-06-04 21:22:20 -05:00
|
|
|
|
const [incomeModalOpen, setIncomeModalOpen] = useState(false);
|
2026-05-30 16:13:37 -05:00
|
|
|
|
const [orderedRows, setOrderedRows] = useState(null);
|
|
|
|
|
|
const [movingBillId, setMovingBillId] = useState(null);
|
2026-06-07 01:28:35 -05:00
|
|
|
|
const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();
|
2026-06-07 17:23:14 -05:00
|
|
|
|
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);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
2026-05-30 13:19:09 -05:00
|
|
|
|
// Row to open in PaymentLedgerDialog via the overdue command center
|
|
|
|
|
|
const [commandCenterPayRow, setCommandCenterPayRow] = useState(null);
|
|
|
|
|
|
|
2026-05-10 03:10:43 -05:00
|
|
|
|
// Use React Query for data fetching
|
2026-05-30 16:13:37 -05:00
|
|
|
|
const { data, isLoading: loading, isError, error, refetch, dataUpdatedAt } = useTracker(year, month);
|
2026-05-30 14:33:55 -05:00
|
|
|
|
const { data: driftData, refetch: refetchDrift } = useDriftReport();
|
2026-06-07 14:49:39 -05:00
|
|
|
|
const driftedIds = useMemo(() => new Set((driftData?.bills ?? []).map(b => b.id)), [driftData]);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
2026-05-30 16:13:37 -05:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setOrderedRows(null);
|
|
|
|
|
|
setMovingBillId(null);
|
|
|
|
|
|
}, [dataUpdatedAt, year, month]);
|
|
|
|
|
|
|
2026-06-03 21:59:50 -05:00
|
|
|
|
// Load SimpleFIN status once to decide whether to show the sync button
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
api.simplefinStatus()
|
|
|
|
|
|
.then(setBankSyncStatus)
|
|
|
|
|
|
.catch(() => setBankSyncStatus(null));
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-06-07 17:23:14 -05:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
api.settings()
|
|
|
|
|
|
.then(settings => setTrackerSettings(prev => ({ ...prev, ...settings })))
|
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-06-04 00:06:16 -05:00
|
|
|
|
// 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);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
function navigate(delta) {
|
2026-05-31 15:06:10 -05:00
|
|
|
|
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 });
|
2026-05-03 19:51:57 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 21:59:50 -05:00
|
|
|
|
async function handleBankSync() {
|
|
|
|
|
|
setBankSyncing(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await api.syncAllSources();
|
2026-06-03 23:29:30 -05:00
|
|
|
|
const matched = result.auto_matched ?? 0;
|
|
|
|
|
|
const newTx = result.transactions_new ?? 0;
|
|
|
|
|
|
const billNames = result.matched_bills ?? [];
|
2026-06-04 00:06:16 -05:00
|
|
|
|
const attributions = result.late_attributions ?? [];
|
|
|
|
|
|
|
2026-06-03 23:29:30 -05:00
|
|
|
|
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`);
|
2026-06-03 21:59:50 -05:00
|
|
|
|
} else if (newTx > 0) {
|
|
|
|
|
|
toast.success(`Synced — ${newTx} new transaction${newTx === 1 ? '' : 's'}, no automatic matches`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast.success('Synced — no new transactions');
|
|
|
|
|
|
}
|
2026-06-04 00:06:16 -05:00
|
|
|
|
|
|
|
|
|
|
// Surface late-attribution prompts (payments that just crossed a month boundary)
|
|
|
|
|
|
if (attributions.length > 0) setLateAttributions(attributions);
|
|
|
|
|
|
|
2026-06-03 21:59:50 -05:00
|
|
|
|
refetch();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Bank sync failed');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBankSyncing(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 22:55:27 -05:00
|
|
|
|
function togglePinUpcoming() {
|
|
|
|
|
|
setPinUpcoming(prev => {
|
|
|
|
|
|
const next = !prev;
|
|
|
|
|
|
localStorage.setItem('tracker_pin_upcoming', String(next));
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 21:59:50 -05:00
|
|
|
|
// Show sync button when SimpleFIN is enabled, connected, and user has matching rules
|
|
|
|
|
|
const showBankSync = bankSyncStatus?.enabled &&
|
|
|
|
|
|
bankSyncStatus?.has_connections &&
|
|
|
|
|
|
bankSyncStatus?.has_merchant_rules;
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-31 15:06:10 -05:00
|
|
|
|
async function handleOpenAddBill() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const categories = await api.categories();
|
|
|
|
|
|
setEditBillData({ bill: null, categories });
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Failed to open bill editor');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
function goToday() {
|
|
|
|
|
|
const n = new Date();
|
2026-05-31 15:06:10 -05:00
|
|
|
|
updateParams({ year: n.getFullYear(), month: n.getMonth() + 1 });
|
2026-05-03 19:51:57 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 21:30:02 -05:00
|
|
|
|
const rows = orderedRows || data?.rows || [];
|
|
|
|
|
|
const summary = data?.summary || {};
|
2026-06-03 21:09:26 -05:00
|
|
|
|
const bankTracking = data?.bank_tracking;
|
2026-06-03 21:30:02 -05:00
|
|
|
|
const cashflow = data?.cashflow;
|
2026-06-07 17:23:14 -05:00
|
|
|
|
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),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-06-07 17:33:31 -05:00
|
|
|
|
|
|
|
|
|
|
function hideSearchSortPanel() {
|
|
|
|
|
|
saveTrackerSettings({
|
|
|
|
|
|
tracker_show_search_sort: 'false',
|
|
|
|
|
|
}, 'Search & sort hidden.');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function showSearchSortPanel() {
|
|
|
|
|
|
saveTrackerSettings({
|
|
|
|
|
|
tracker_show_search_sort: 'true',
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-05-31 15:06:10 -05:00
|
|
|
|
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 });
|
|
|
|
|
|
};
|
2026-06-07 00:41:07 -05:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const hasFilters = !!(
|
|
|
|
|
|
search.trim()
|
|
|
|
|
|
|| filters.category !== FILTER_ALL
|
|
|
|
|
|
|| filters.cycle !== FILTER_ALL
|
|
|
|
|
|
|| filters.autopay
|
|
|
|
|
|
|| filters.firstBucket
|
|
|
|
|
|
|| filters.fifteenthBucket
|
|
|
|
|
|
|| filters.unpaid
|
|
|
|
|
|
|| filters.overdue
|
|
|
|
|
|
|| filters.debt
|
2026-06-07 00:41:07 -05:00
|
|
|
|
|| hasSort
|
2026-05-16 15:38:28 -05:00
|
|
|
|
);
|
2026-06-07 17:33:31 -05:00
|
|
|
|
const searchSortLabel = hasSort ? `sorted by ${TRACKER_SORT_LABELS[sortKey]} ${sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}` : null;
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const resetFilters = () => {
|
2026-06-07 00:41:07 -05:00
|
|
|
|
updateParams({ q: null, fc: null, cy: null, ap: null, b1: null, b2: null, un: null, ov: null, de: null, sort: null, dir: null });
|
2026-05-16 15:38:28 -05:00
|
|
|
|
};
|
|
|
|
|
|
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(() => (
|
2026-05-30 21:20:51 -05:00
|
|
|
|
Array.from(new Set(rows.map(scheduleValue))).sort()
|
2026-05-16 15:38:28 -05:00
|
|
|
|
), [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;
|
2026-05-30 21:20:51 -05:00
|
|
|
|
if (filters.cycle !== FILTER_ALL && scheduleValue(row) !== filters.cycle) return false;
|
2026-05-16 15:38:28 -05:00
|
|
|
|
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,
|
2026-05-30 21:20:51 -05:00
|
|
|
|
scheduleValue(row),
|
|
|
|
|
|
scheduleLabel(row),
|
2026-05-16 15:38:28 -05:00
|
|
|
|
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]);
|
2026-06-03 22:55:27 -05:00
|
|
|
|
// 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 };
|
2026-06-07 00:41:07 -05:00
|
|
|
|
const sortedRows = hasSort
|
|
|
|
|
|
? sortTrackerRows(filteredRows, sortKey, sortDir)
|
|
|
|
|
|
: pinUpcoming
|
|
|
|
|
|
? [...filteredRows].sort((a, b) => {
|
2026-06-03 22:55:27 -05:00
|
|
|
|
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);
|
|
|
|
|
|
})
|
2026-06-07 00:41:07 -05:00
|
|
|
|
: filteredRows;
|
2026-06-03 22:55:27 -05:00
|
|
|
|
|
2026-06-07 17:50:17 -05:00
|
|
|
|
const searchResultLabel = `${filteredRows.length} of ${rows.length} shown`;
|
|
|
|
|
|
|
2026-06-03 22:55:27 -05:00
|
|
|
|
const first = sortedRows.filter(r => r.bucket === '1st');
|
|
|
|
|
|
const second = sortedRows.filter(r => r.bucket === '15th');
|
|
|
|
|
|
const reorderEnabled = !hasFilters && !loading && !isError && !pinUpcoming;
|
2026-05-30 16:13:37 -05:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-5">
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── Header ── */}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
2026-05-03 19:51:57 -05:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-05-31 15:06:10 -05:00
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
2026-06-03 22:55:27 -05:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-06-03 21:59:50 -05:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
<Button
|
2026-05-31 15:06:10 -05:00
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={handleOpenAddBill}
|
|
|
|
|
|
className="h-9 gap-1.5 px-3 shadow-sm"
|
|
|
|
|
|
aria-label="Add bill"
|
|
|
|
|
|
title="Add bill"
|
2026-05-03 19:51:57 -05:00
|
|
|
|
>
|
2026-05-31 15:06:10 -05:00
|
|
|
|
<Plus className="h-4 w-4" />
|
|
|
|
|
|
<span className="hidden sm:inline">Add Bill</span>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
</Button>
|
2026-05-31 15:06:10 -05:00
|
|
|
|
|
|
|
|
|
|
<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"
|
2026-06-03 20:28:37 -05:00
|
|
|
|
aria-label="Previous month"
|
2026-05-31 15:06:10 -05:00
|
|
|
|
>
|
|
|
|
|
|
<ChevronLeft className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
2026-06-03 20:28:37 -05:00
|
|
|
|
<span className="min-w-[7.5rem] px-1 text-center text-xs font-semibold tabular-nums select-none">
|
|
|
|
|
|
{MONTHS[month - 1]} {year}
|
|
|
|
|
|
</span>
|
2026-05-31 15:06:10 -05:00
|
|
|
|
<Button
|
|
|
|
|
|
size="icon" variant="ghost"
|
|
|
|
|
|
onClick={() => navigate(1)}
|
|
|
|
|
|
className="h-7 w-7 hover:bg-white/5"
|
2026-06-03 20:28:37 -05:00
|
|
|
|
aria-label="Next month"
|
2026-05-31 15:06:10 -05:00
|
|
|
|
>
|
|
|
|
|
|
<ChevronRight className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-06-03 20:28:37 -05:00
|
|
|
|
<Button
|
|
|
|
|
|
size="sm" variant="outline"
|
|
|
|
|
|
onClick={goToday}
|
|
|
|
|
|
className="h-9 px-3 text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
Today
|
|
|
|
|
|
</Button>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-06-07 17:23:14 -05:00
|
|
|
|
{showSearchSort && (
|
|
|
|
|
|
<SearchFilterPanel
|
|
|
|
|
|
title="Search & sort"
|
|
|
|
|
|
collapsed={searchPanelCollapsed}
|
|
|
|
|
|
onCollapsedChange={setSearchPanelCollapsed}
|
|
|
|
|
|
hasFilters={hasFilters}
|
2026-06-07 17:33:31 -05:00
|
|
|
|
resultLabel={searchResultLabel}
|
|
|
|
|
|
sortLabel={searchSortLabel}
|
2026-06-07 17:23:14 -05:00
|
|
|
|
onClear={resetFilters}
|
2026-06-07 17:33:31 -05:00
|
|
|
|
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>
|
|
|
|
|
|
)}
|
2026-06-07 17:23:14 -05:00
|
|
|
|
>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2026-06-04 02:24:10 -05:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-06-07 17:23:14 -05:00
|
|
|
|
</SearchFilterPanel>
|
2026-06-04 02:24:10 -05:00
|
|
|
|
)}
|
2026-06-07 17:33:31 -05:00
|
|
|
|
{!showSearchSort && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={showSearchSortPanel}
|
|
|
|
|
|
disabled={savingTrackerSetting}
|
|
|
|
|
|
className="group flex w-full items-center justify-between gap-3 rounded-xl border border-dashed border-border/80 bg-card/55 px-4 py-3 text-left shadow-sm transition-colors hover:border-primary/40 hover:bg-card/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="flex min-w-0 items-center gap-3">
|
|
|
|
|
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
|
|
|
|
|
<Search className="h-4 w-4" />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="min-w-0">
|
|
|
|
|
|
<span className="block text-sm font-semibold text-foreground">Show Search & sort</span>
|
|
|
|
|
|
<span className="block truncate text-xs text-muted-foreground">
|
|
|
|
|
|
{[searchResultLabel, hasFilters ? 'filters active' : 'no filters', searchSortLabel].filter(Boolean).join(' · ')}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground transition-colors group-hover:bg-accent group-hover:text-foreground">
|
|
|
|
|
|
<Eye className="h-3.5 w-3.5" />
|
|
|
|
|
|
Restore
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2026-06-04 02:24:10 -05:00
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
|
2026-06-07 17:23:14 -05:00
|
|
|
|
{showSummaryCards && loading ? (
|
2026-05-10 01:35:41 -05:00
|
|
|
|
<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>
|
2026-06-07 17:23:14 -05:00
|
|
|
|
) : showSummaryCards ? (
|
2026-05-10 01:35:41 -05:00
|
|
|
|
<div className="grid grid-cols-2 gap-3 lg:flex">
|
2026-06-04 02:24:10 -05:00
|
|
|
|
{bankTracking?.enabled ? (
|
2026-06-04 21:22:20 -05:00
|
|
|
|
<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"
|
|
|
|
|
|
>
|
2026-06-04 02:24:10 -05:00
|
|
|
|
<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>
|
2026-06-06 15:17:27 -05:00
|
|
|
|
Live Sync
|
2026-06-04 02:24:10 -05:00
|
|
|
|
</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>
|
2026-06-06 15:17:27 -05:00
|
|
|
|
{bankTracking.last_updated && (
|
|
|
|
|
|
<p className="mt-1 text-[10px] text-muted-foreground/60">
|
|
|
|
|
|
as of {fmtBalanceAge(bankTracking.last_updated)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
2026-06-04 21:22:20 -05:00
|
|
|
|
</button>
|
2026-06-04 02:24:10 -05:00
|
|
|
|
) : (
|
|
|
|
|
|
<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)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-05-10 01:35:41 -05:00
|
|
|
|
<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>
|
2026-06-07 17:23:14 -05:00
|
|
|
|
) : null}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
2026-05-30 13:19:09 -05:00
|
|
|
|
{/* ── Overdue Command Center ── */}
|
2026-06-07 17:23:14 -05:00
|
|
|
|
{!isError && !loading && showOverdueCommandCenter && (summary?.count_late ?? 0) > 0 && (
|
2026-05-30 13:19:09 -05:00
|
|
|
|
<OverdueCommandCenter
|
|
|
|
|
|
rows={rows}
|
|
|
|
|
|
year={year}
|
|
|
|
|
|
month={month}
|
|
|
|
|
|
refresh={refetch}
|
|
|
|
|
|
onPayNow={(row) => setCommandCenterPayRow(row)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-06-07 17:23:14 -05:00
|
|
|
|
{/* ── Bank Projection Banner ── */}
|
|
|
|
|
|
{!isError && !loading && showBankProjectionBanner && (
|
|
|
|
|
|
<BankProjectionBanner
|
|
|
|
|
|
bankTracking={bankTracking}
|
|
|
|
|
|
busy={savingTrackerSetting}
|
|
|
|
|
|
onSnooze={snoozeBankProjectionBanner}
|
|
|
|
|
|
onIgnore={ignoreBankProjectionBanner}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-30 14:33:55 -05:00
|
|
|
|
{/* ── Drift / Price-Change Insights ── */}
|
2026-06-07 17:23:14 -05:00
|
|
|
|
{!isError && !loading && showDriftInsights && (driftData?.bills?.length ?? 0) > 0 && (
|
2026-05-30 14:33:55 -05:00
|
|
|
|
<DriftInsightPanel
|
|
|
|
|
|
driftBills={driftData.bills}
|
|
|
|
|
|
refresh={() => { refetch(); refetchDrift(); }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-28 02:34:24 -05:00
|
|
|
|
{/* ── 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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
{/* ── Empty state ── */}
|
2026-05-28 02:34:24 -05:00
|
|
|
|
{!isError && rows.length === 0 && data !== null && (
|
2026-05-03 19:51:57 -05:00
|
|
|
|
<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 ── */}
|
2026-05-28 02:34:24 -05:00
|
|
|
|
{!isError && loading && (
|
2026-05-10 01:35:41 -05:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2026-05-29 20:33:01 -05:00
|
|
|
|
{!isError && (first.length > 0 || second.length > 0) && (
|
2026-05-29 21:16:13 -05:00
|
|
|
|
<div className="space-y-5">
|
2026-06-07 17:33:31 -05:00
|
|
|
|
{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} />}
|
2026-05-29 20:33:01 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
|
|
{/* Edit Bill modal — opened by clicking a bill name in any tracker row */}
|
|
|
|
|
|
{editBillData && (
|
|
|
|
|
|
<BillModal
|
2026-05-16 15:38:28 -05:00
|
|
|
|
key={editBillData.bill?.id ? `edit-${editBillData.bill.id}` : `new-${editBillData.initialBill?.name || 'blank'}`}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
bill={editBillData.bill}
|
2026-05-16 15:38:28 -05:00
|
|
|
|
initialBill={editBillData.initialBill}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
categories={editBillData.categories}
|
|
|
|
|
|
onClose={() => setEditBillData(null)}
|
2026-05-10 03:10:43 -05:00
|
|
|
|
onSave={() => { setEditBillData(null); refetch(); }}
|
2026-05-16 15:38:28 -05:00
|
|
|
|
onDuplicate={bill => setEditBillData({
|
|
|
|
|
|
bill: null,
|
|
|
|
|
|
initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }),
|
|
|
|
|
|
categories: editBillData.categories,
|
|
|
|
|
|
})}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-04 20:12:57 -05:00
|
|
|
|
{/* Edit Starting Amounts modal */}
|
|
|
|
|
|
<StartingAmountsEditDialog
|
|
|
|
|
|
open={editStartingOpen}
|
|
|
|
|
|
onClose={() => setEditStartingOpen(false)}
|
|
|
|
|
|
year={year}
|
|
|
|
|
|
month={month}
|
2026-05-10 03:10:43 -05:00
|
|
|
|
onSave={() => { setEditStartingOpen(false); refetch(); }}
|
2026-05-04 20:12:57 -05:00
|
|
|
|
/>
|
|
|
|
|
|
|
2026-06-04 21:22:20 -05:00
|
|
|
|
{/* Income breakdown modal — opens when clicking the bank balance card */}
|
|
|
|
|
|
{bankTracking?.enabled && (
|
|
|
|
|
|
<IncomeBreakdownModal
|
|
|
|
|
|
open={incomeModalOpen}
|
|
|
|
|
|
onClose={() => setIncomeModalOpen(false)}
|
|
|
|
|
|
year={year}
|
|
|
|
|
|
month={month}
|
|
|
|
|
|
bankTracking={bankTracking}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-06-04 00:06:16 -05:00
|
|
|
|
{/* 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))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-30 13:19:09 -05:00
|
|
|
|
{/* 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(); }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|