2026-07-06 12:54:11 -05:00
|
|
|
|
import { useState, useEffect, useMemo, useCallback, useDeferredValue, type ComponentType, type ReactNode } from 'react';
|
2026-07-05 14:49:09 -05:00
|
|
|
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
2026-07-05 15:00:16 -05:00
|
|
|
|
import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, EyeOff, Settings2, Download, Printer, FileSpreadsheet, CalendarDays, Keyboard } from 'lucide-react';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
import { toast } from 'sonner';
|
refactor(ts): convert the API client to TypeScript (TS9)
client/api.js -> api.ts. Types the fetch infrastructure: generic _fetch<T>
and get/post/put/patch/del<T> helpers, an ApiError interface (status/code/
details/data), a QueryParams type for the query-string builder, and File-typed
upload helpers. Endpoint response shapes are typed incrementally — most methods
default to Promise<unknown>; the ones consumed by typed .ts files get real
shapes (quickPay -> PaymentRecord, togglePaid -> TogglePaidResult, settings ->
settings map).
Typing togglePaid's result surfaced a latent narrowing bug in usePaymentActions:
the un-pay Undo closure read result.paymentId (number|undefined) inside a
deferred async callback where TS re-widens the if-narrowed value — fixed by
capturing the id in a const before the closure.
The 16 files importing '@/api.js' with an explicit extension were normalized to
'@/api' so Vite/TS resolve the .ts.
Verified: typecheck 0, lint 0 errors (47 warns), build green, 48 client tests,
and 17/17 e2e probe (every page renders, all API paths respond) — the central
fetch-module rename is runtime-safe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:09:04 -05:00
|
|
|
|
import { api } from '@/api';
|
2026-07-05 14:43:13 -05:00
|
|
|
|
import { useTracker, useDriftReport, useInvalidateTrackerData, usePrefetchTracker, useSettings, useSimplefinStatus, useSaveSettings } 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-07-05 14:38:12 -05:00
|
|
|
|
import { cn, localDateString, parseUtc, settingEnabled } from '@/lib/utils';
|
2026-07-05 14:49:09 -05:00
|
|
|
|
import { downloadFile } from '@/lib/download';
|
2026-07-04 23:06:46 -05:00
|
|
|
|
import { formatUSD, asDollars, type Dollars } from '@/lib/money';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
|
import { Input } from '@/components/ui/input';
|
2026-07-05 17:18:56 -05:00
|
|
|
|
import { EmptyState, PageHeader, StatusDot } from '@/components/ui/app-primitives';
|
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 {
|
2026-07-05 14:49:09 -05:00
|
|
|
|
DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel,
|
2026-06-07 17:33:31 -05:00
|
|
|
|
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-06-12 01:32:28 -05:00
|
|
|
|
import CashFlowCard from '@/components/tracker/CashFlowCard';
|
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-07-04 23:06:46 -05:00
|
|
|
|
import type { TrackerRow, TrackerSummary, BankTracking, DriftBill, Bill, Category } from '@/types';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
2026-07-04 23:06:46 -05:00
|
|
|
|
function fmt(v: number | null | undefined): string {
|
|
|
|
|
|
return formatUSD(asDollars(Number(v) || 0));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface LateAttr {
|
|
|
|
|
|
payment_id: number;
|
|
|
|
|
|
bill_name?: string;
|
|
|
|
|
|
amount?: number;
|
|
|
|
|
|
original_date: string;
|
|
|
|
|
|
suggested_date: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface SyncResult {
|
|
|
|
|
|
auto_matched?: number;
|
|
|
|
|
|
transactions_new?: number;
|
|
|
|
|
|
matched_bills?: string[];
|
|
|
|
|
|
late_attributions?: LateAttr[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface BankSyncStatus {
|
|
|
|
|
|
enabled?: boolean;
|
|
|
|
|
|
has_connections?: boolean;
|
|
|
|
|
|
has_merchant_rules?: boolean;
|
|
|
|
|
|
}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
2026-07-04 23:06:46 -05:00
|
|
|
|
interface EditBillData {
|
|
|
|
|
|
bill: Bill | null;
|
|
|
|
|
|
initialBill?: Partial<Bill>;
|
|
|
|
|
|
categories: Category[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface TrackerFilters {
|
|
|
|
|
|
category: string;
|
|
|
|
|
|
cycle: string;
|
|
|
|
|
|
autopay: boolean;
|
|
|
|
|
|
firstBucket: boolean;
|
|
|
|
|
|
fifteenthBucket: boolean;
|
|
|
|
|
|
unpaid: boolean;
|
|
|
|
|
|
overdue: boolean;
|
|
|
|
|
|
debt: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type BoolFilterKey = 'autopay' | 'firstBucket' | 'fifteenthBucket' | 'unpaid' | 'overdue' | 'debt';
|
|
|
|
|
|
type Patch = Record<string, string | number | boolean | null | undefined>;
|
|
|
|
|
|
|
2026-07-06 12:54:11 -05:00
|
|
|
|
function HeaderStat({
|
|
|
|
|
|
icon: Icon,
|
|
|
|
|
|
label,
|
|
|
|
|
|
value,
|
|
|
|
|
|
tone = 'neutral',
|
|
|
|
|
|
}: {
|
|
|
|
|
|
icon: ComponentType<{ className?: string }>;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
value: ReactNode;
|
|
|
|
|
|
tone?: 'neutral' | 'good' | 'warn' | 'danger' | 'info';
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const toneClass = {
|
|
|
|
|
|
neutral: 'border-border/70 bg-card/65 text-muted-foreground',
|
|
|
|
|
|
good: 'border-emerald-500/25 bg-emerald-500/[0.08] text-emerald-700 dark:text-emerald-300',
|
|
|
|
|
|
warn: 'border-amber-500/30 bg-amber-500/[0.08] text-amber-700 dark:text-amber-300',
|
|
|
|
|
|
danger: 'border-rose-500/30 bg-rose-500/[0.08] text-rose-700 dark:text-rose-300',
|
|
|
|
|
|
info: 'border-sky-500/25 bg-sky-500/[0.08] text-sky-700 dark:text-sky-300',
|
|
|
|
|
|
}[tone];
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span className={cn('inline-flex items-center gap-2 rounded-full border px-3 py-1.5', toneClass)}>
|
|
|
|
|
|
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
|
|
|
|
|
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">{label}</span>
|
|
|
|
|
|
<span className="tracker-number text-xs font-semibold text-foreground">{value}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:06:46 -05:00
|
|
|
|
function fmtBalanceAge(isoStr: string | null | undefined): string | null {
|
|
|
|
|
|
const d = parseUtc(isoStr);
|
|
|
|
|
|
if (!d) return null;
|
|
|
|
|
|
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
2026-06-06 15:17:27 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:06:46 -05:00
|
|
|
|
function BankProjectionBanner({ bankTracking, onSnooze, onIgnore, busy }: {
|
|
|
|
|
|
bankTracking?: BankTracking;
|
|
|
|
|
|
onSnooze: () => void;
|
|
|
|
|
|
onIgnore: () => void;
|
|
|
|
|
|
busy?: boolean;
|
|
|
|
|
|
}) {
|
2026-06-07 17:23:14 -05:00
|
|
|
|
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-07-04 23:06:46 -05:00
|
|
|
|
function TrackerColumnMenu({ visibleColumns, onColumnToggle, saving }: {
|
|
|
|
|
|
visibleColumns: string[];
|
|
|
|
|
|
onColumnToggle?: (key: string, checked: boolean) => void;
|
|
|
|
|
|
saving?: boolean;
|
|
|
|
|
|
}) {
|
2026-06-07 17:33:31 -05:00
|
|
|
|
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-07-04 23:06:46 -05:00
|
|
|
|
function LateAttributionDialog({ attr, remaining, busy, onAccept, onDismiss }: {
|
|
|
|
|
|
attr?: LateAttr | null;
|
|
|
|
|
|
remaining: number;
|
|
|
|
|
|
busy?: boolean;
|
|
|
|
|
|
onAccept: (attr: LateAttr) => void;
|
|
|
|
|
|
onDismiss: () => void;
|
|
|
|
|
|
}) {
|
2026-06-04 00:06:16 -05:00
|
|
|
|
if (!attr) return null;
|
2026-07-04 23:06:46 -05:00
|
|
|
|
const fmtDate = (d: string) => new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
2026-06-04 00:06:16 -05:00
|
|
|
|
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-07-05 14:43:13 -05:00
|
|
|
|
// Client-side fallbacks for the tracker display settings, merged under whatever
|
|
|
|
|
|
// the server returns (an absent key falls back to "shown"). Mirrors the list in
|
|
|
|
|
|
// SettingsPage's "Tracker Layout".
|
|
|
|
|
|
const TRACKER_SETTING_DEFAULTS: Record<string, string> = {
|
|
|
|
|
|
tracker_show_bank_projection_banner: 'true',
|
|
|
|
|
|
tracker_bank_projection_banner_snoozed_until: '',
|
|
|
|
|
|
tracker_show_search_sort: 'true',
|
|
|
|
|
|
tracker_show_summary_cards: 'true',
|
|
|
|
|
|
tracker_show_safe_to_spend: 'true',
|
|
|
|
|
|
tracker_show_overdue_command_center: 'true',
|
|
|
|
|
|
tracker_show_drift_insights: 'true',
|
feat(tracker): persist Overdue/Drift panel collapse + inline hide
The Overdue Command Center and Price-Change (drift) panels reset to
expanded on every load — their collapse arrows used local useState. Now
the collapsed state is remembered per-user via two new settings
(tracker_overdue_collapsed / tracker_drift_collapsed), mirroring the
search panel's useSearchPanelPreference pattern. Each panel header also
gains an inline "Hide" control (EyeOff, like the search panel) that writes
the existing tracker_show_* setting and fires an Undo toast; hidden
sections come back from Settings → Tracker Layout.
- services/userSettings.js: allowlist + defaults for the two collapse keys
- TrackerPage: read collapse state, hide handlers with Undo, wire props
- OverdueCommandCenter/DriftInsightPanel: controlled collapse + header hide
Verified: typecheck 0, lint 0 errors, build, and a live settings round-trip
(collapse persists, hide writes, Undo restores, allowlist rejects junk keys).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:34:39 -05:00
|
|
|
|
tracker_overdue_collapsed: 'false',
|
|
|
|
|
|
tracker_drift_collapsed: 'false',
|
2026-07-05 14:43:13 -05:00
|
|
|
|
tracker_table_columns: '["due","expected","previous","paid","paid_date","status","action","notes"]',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-31 15:06:10 -05:00
|
|
|
|
export default function TrackerPage() {
|
|
|
|
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
2026-07-05 14:49:09 -05:00
|
|
|
|
const navigateTo = useNavigate();
|
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') || '';
|
2026-07-03 19:47:14 -05:00
|
|
|
|
// Stable identity so the memo that filters rows on `filters` doesn't recompute
|
|
|
|
|
|
// every render (a new object literal each render would defeat it).
|
2026-07-04 23:06:46 -05:00
|
|
|
|
const filters = useMemo<TrackerFilters>(() => ({
|
2026-05-31 15:06:10 -05:00
|
|
|
|
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-07-03 19:47:14 -05:00
|
|
|
|
}), [searchParams]);
|
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)
|
2026-07-04 23:06:46 -05:00
|
|
|
|
const updateParams = useCallback((patch: Patch) => {
|
2026-05-31 15:06:10 -05:00
|
|
|
|
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-06-04 00:06:16 -05:00
|
|
|
|
const [bankSyncing, setBankSyncing] = useState(false);
|
2026-07-04 23:06:46 -05:00
|
|
|
|
const [lateAttributions, setLateAttributions] = useState<LateAttr[]>([]); // pending month-attribution prompts
|
|
|
|
|
|
const [attrBusy, setAttrBusy] = useState<number | null>(null); // payment_id being resolved
|
2026-06-03 22:55:27 -05:00
|
|
|
|
const [pinUpcoming, setPinUpcoming] = useState(() => localStorage.getItem('tracker_pin_upcoming') === 'true');
|
2026-07-04 23:06:46 -05:00
|
|
|
|
const [editBillData, setEditBillData] = useState<EditBillData | null>(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-07-04 23:06:46 -05:00
|
|
|
|
const [orderedRows, setOrderedRows] = useState<TrackerRow[] | null>(null);
|
|
|
|
|
|
const [movingBillId, setMovingBillId] = useState<number | null>(null);
|
2026-06-07 01:28:35 -05:00
|
|
|
|
const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();
|
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
|
2026-07-04 23:06:46 -05:00
|
|
|
|
const [commandCenterPayRow, setCommandCenterPayRow] = useState<TrackerRow | null>(null);
|
2026-05-30 13:19:09 -05:00
|
|
|
|
|
2026-07-05 14:56:34 -05:00
|
|
|
|
// Multi-select: bill ids checked for a bulk pay/skip action.
|
|
|
|
|
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
|
|
|
|
|
const [bulkBusy, setBulkBusy] = useState(false);
|
2026-07-05 15:00:16 -05:00
|
|
|
|
// Screen-reader announcement for drag/keyboard reorder (visually hidden).
|
|
|
|
|
|
const [reorderAnnouncement, setReorderAnnouncement] = useState('');
|
2026-07-05 14:56:34 -05:00
|
|
|
|
|
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-07-04 23:06:46 -05:00
|
|
|
|
const { data: driftDataRaw } = useDriftReport();
|
|
|
|
|
|
const driftData = driftDataRaw as { bills?: DriftBill[] } | undefined;
|
2026-07-05 14:43:13 -05:00
|
|
|
|
|
|
|
|
|
|
// Display settings + SimpleFIN status via React Query (cached/deduped like the
|
|
|
|
|
|
// rest of the app), with a save mutation that handles optimistic + rollback.
|
|
|
|
|
|
const settingsQuery = useSettings();
|
|
|
|
|
|
const simplefinStatusQuery = useSimplefinStatus();
|
|
|
|
|
|
const saveSettings = useSaveSettings();
|
|
|
|
|
|
const trackerSettings = useMemo<Record<string, string>>(
|
|
|
|
|
|
() => ({ ...TRACKER_SETTING_DEFAULTS, ...((settingsQuery.data as Record<string, string> | undefined) ?? {}) }),
|
|
|
|
|
|
[settingsQuery.data],
|
|
|
|
|
|
);
|
|
|
|
|
|
const bankSyncStatus = (simplefinStatusQuery.data as BankSyncStatus | undefined) ?? null;
|
|
|
|
|
|
const savingTrackerSetting = saveSettings.isPending;
|
2026-07-03 18:19:30 -05:00
|
|
|
|
// Live cross-query refresh: a bill mutation invalidates the tracker AND the
|
|
|
|
|
|
// overdue badge / drift report / bills list so the whole shell updates, not
|
|
|
|
|
|
// just the one tracker query. Use this everywhere a row/payment mutation lands.
|
|
|
|
|
|
const invalidateData = useInvalidateTrackerData();
|
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-07-05 14:56:34 -05:00
|
|
|
|
// Clear a stale multi-selection when navigating months (survives background
|
|
|
|
|
|
// refetches within the same month).
|
|
|
|
|
|
useEffect(() => { setSelectedIds(new Set()); }, [year, month]);
|
|
|
|
|
|
|
2026-07-05 14:43:13 -05:00
|
|
|
|
// Surface a settings-load failure (React Query swallows it into isError).
|
|
|
|
|
|
// The page still renders with TRACKER_SETTING_DEFAULTS.
|
2026-06-07 17:23:14 -05:00
|
|
|
|
useEffect(() => {
|
2026-07-05 14:43:13 -05:00
|
|
|
|
if (settingsQuery.isError) toast.error("Couldn't load tracker display settings — showing defaults.");
|
|
|
|
|
|
}, [settingsQuery.isError]);
|
2026-06-07 17:23:14 -05:00
|
|
|
|
|
2026-06-04 00:06:16 -05:00
|
|
|
|
// Listen for late-attribution events fired by BillModal's single-bill sync
|
|
|
|
|
|
useEffect(() => {
|
2026-07-04 23:06:46 -05:00
|
|
|
|
function handler(e: Event) {
|
|
|
|
|
|
const attrs = (e as CustomEvent<{ attributions?: LateAttr[] }>).detail?.attributions;
|
2026-06-04 00:06:16 -05:00
|
|
|
|
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-07-04 23:06:46 -05:00
|
|
|
|
function adjacentMonth(delta: number) {
|
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; }
|
2026-07-03 20:58:23 -05:00
|
|
|
|
return { year: ny, month: nm };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:06:46 -05:00
|
|
|
|
function navigate(delta: number) {
|
2026-07-03 20:58:23 -05:00
|
|
|
|
updateParams(adjacentMonth(delta));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Prefetch the adjacent month on hover/focus so clicking prev/next is instant.
|
|
|
|
|
|
const prefetchTracker = usePrefetchTracker();
|
2026-07-04 23:06:46 -05:00
|
|
|
|
function prefetchAdjacent(delta: number) {
|
2026-07-03 20:58:23 -05:00
|
|
|
|
const { year: ny, month: nm } = adjacentMonth(delta);
|
|
|
|
|
|
prefetchTracker(ny, nm);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:06:46 -05:00
|
|
|
|
function bankSyncMessage(result: SyncResult): string {
|
2026-07-03 18:47:32 -05:00
|
|
|
|
const matched = result.auto_matched ?? 0;
|
|
|
|
|
|
const newTx = result.transactions_new ?? 0;
|
|
|
|
|
|
const billNames = result.matched_bills ?? [];
|
|
|
|
|
|
if (matched > 0 && billNames.length > 0) {
|
|
|
|
|
|
return `Synced — ${billNames.join(', ')} ✓` +
|
|
|
|
|
|
(matched > billNames.length ? ` (+${matched - billNames.length} more)` : '');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (matched > 0) return `Synced — ${matched} payment${matched === 1 ? '' : 's'} matched`;
|
|
|
|
|
|
if (newTx > 0) return `Synced — ${newTx} new transaction${newTx === 1 ? '' : 's'}, no automatic matches`;
|
|
|
|
|
|
return 'Synced — no new transactions';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 21:59:50 -05:00
|
|
|
|
async function handleBankSync() {
|
|
|
|
|
|
setBankSyncing(true);
|
2026-07-03 18:47:32 -05:00
|
|
|
|
// One toast that transitions loading → done, instead of a manual spinner +
|
|
|
|
|
|
// a separate success/error toast.
|
|
|
|
|
|
const promise = api.syncAllSources();
|
|
|
|
|
|
toast.promise(promise, {
|
|
|
|
|
|
loading: 'Syncing bank…',
|
2026-07-04 23:06:46 -05:00
|
|
|
|
success: (result: unknown) => bankSyncMessage(result as SyncResult),
|
|
|
|
|
|
error: (err: unknown) => (err as { message?: string })?.message || 'Bank sync failed',
|
2026-07-03 18:47:32 -05:00
|
|
|
|
});
|
2026-06-03 21:59:50 -05:00
|
|
|
|
try {
|
2026-07-04 23:06:46 -05:00
|
|
|
|
const result = await promise as SyncResult;
|
2026-06-04 00:06:16 -05:00
|
|
|
|
// Surface late-attribution prompts (payments that just crossed a month boundary)
|
2026-07-04 23:06:46 -05:00
|
|
|
|
if ((result.late_attributions ?? []).length > 0) setLateAttributions(result.late_attributions ?? []);
|
2026-07-03 18:19:30 -05:00
|
|
|
|
invalidateData();
|
2026-07-03 18:47:32 -05:00
|
|
|
|
} catch {
|
|
|
|
|
|
// toast.promise already surfaced the error
|
2026-06-03 21:59:50 -05:00
|
|
|
|
} 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-07-04 23:06:46 -05:00
|
|
|
|
async function handleOpenEditBill(row: TrackerRow) {
|
2026-05-03 19:51:57 -05:00
|
|
|
|
try {
|
|
|
|
|
|
const [bill, categories] = await Promise.all([
|
2026-07-04 23:06:46 -05:00
|
|
|
|
api.bill(row.id) as Promise<Bill>,
|
|
|
|
|
|
api.categories() as Promise<Category[]>,
|
2026-05-03 19:51:57 -05:00
|
|
|
|
]);
|
|
|
|
|
|
setEditBillData({ bill, categories });
|
|
|
|
|
|
} catch (err) {
|
2026-07-04 23:06:46 -05:00
|
|
|
|
toast.error((err as { message?: string })?.message || 'Failed to open bill');
|
2026-05-03 19:51:57 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-31 15:06:10 -05:00
|
|
|
|
async function handleOpenAddBill() {
|
|
|
|
|
|
try {
|
2026-07-04 23:06:46 -05:00
|
|
|
|
const categories = await api.categories() as Category[];
|
2026-05-31 15:06:10 -05:00
|
|
|
|
setEditBillData({ bill: null, categories });
|
|
|
|
|
|
} catch (err) {
|
2026-07-04 23:06:46 -05:00
|
|
|
|
toast.error((err as { message?: string })?.message || 'Failed to open bill editor');
|
2026-05-31 15:06:10 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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-07-05 14:49:09 -05:00
|
|
|
|
// Export the viewed month's activity as CSV via the existing /api/export
|
|
|
|
|
|
// date-range endpoint (both bounds inclusive).
|
|
|
|
|
|
async function handleExportMonthCsv() {
|
|
|
|
|
|
const mm = String(month).padStart(2, '0');
|
|
|
|
|
|
const lastDay = new Date(year, month, 0).getDate();
|
|
|
|
|
|
const from = `${year}-${mm}-01`;
|
|
|
|
|
|
const to = `${year}-${mm}-${String(lastDay).padStart(2, '0')}`;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await downloadFile(api.exportRangeUrl(from, to, 'csv'), `bills-${year}-${mm}.csv`);
|
|
|
|
|
|
toast.success(`Exported ${from} – ${to} to CSV.`);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error((err as { message?: string })?.message || 'Export failed');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Jump to the calendar view for the month currently in view.
|
|
|
|
|
|
function goToCalendar() {
|
|
|
|
|
|
navigateTo(`/calendar?year=${year}&month=${month}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 14:56:34 -05:00
|
|
|
|
const handleToggleSelect = useCallback((id: number, next: boolean) => {
|
|
|
|
|
|
setSelectedIds(prev => {
|
|
|
|
|
|
const s = new Set(prev);
|
|
|
|
|
|
if (next) s.add(id); else s.delete(id);
|
|
|
|
|
|
return s;
|
|
|
|
|
|
});
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
const clearSelection = useCallback(() => setSelectedIds(new Set()), []);
|
|
|
|
|
|
|
|
|
|
|
|
// Pay every selected bill that still has a balance, in one bulk call (reuses
|
|
|
|
|
|
// the same item shape + Undo as the per-bucket "Pay all due").
|
|
|
|
|
|
async function handlePaySelected() {
|
|
|
|
|
|
const items = selectedRows
|
|
|
|
|
|
.filter(r => !r.is_skipped && !rowIsPaid(r))
|
|
|
|
|
|
.map(r => {
|
|
|
|
|
|
const threshold = Number(r.actual_amount ?? r.expected_amount ?? 0) || 0;
|
|
|
|
|
|
const remaining = Math.max(threshold - (Number(r.total_paid) || 0), 0);
|
|
|
|
|
|
return { bill_id: r.id, amount: remaining > 0 ? remaining : threshold, paid_date: paymentDateForTrackerMonth(year, month, r.due_day) };
|
|
|
|
|
|
})
|
|
|
|
|
|
.filter(item => item.amount > 0);
|
|
|
|
|
|
if (items.length === 0) { toast.info('No unpaid bills selected.'); return; }
|
|
|
|
|
|
const total = items.reduce((s, i) => s + i.amount, 0);
|
|
|
|
|
|
setBulkBusy(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await api.bulkPay(items) as { created?: Array<{ id: number }> };
|
|
|
|
|
|
const created = result.created || [];
|
|
|
|
|
|
if (created.length === 0) {
|
|
|
|
|
|
toast.info('Those bills were already paid.');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast.success(`Paid ${created.length} bill${created.length === 1 ? '' : 's'} — ${fmt(total)}`, {
|
|
|
|
|
|
action: {
|
|
|
|
|
|
label: 'Undo',
|
|
|
|
|
|
onClick: async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await Promise.all(created.map(p => api.deletePayment(p.id)));
|
|
|
|
|
|
toast.success('Payments removed');
|
|
|
|
|
|
invalidateData();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error((err as { message?: string })?.message || 'Failed to undo payments.');
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
clearSelection();
|
|
|
|
|
|
invalidateData();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error((err as { message?: string })?.message || 'Failed to pay bills.');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBulkBusy(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Skip every selected bill for this month. No bulk endpoint exists (skip has no
|
|
|
|
|
|
// money effect), so fan out per-bill and aggregate the result; Undo un-skips.
|
|
|
|
|
|
async function handleSkipSelected() {
|
|
|
|
|
|
const toSkip = selectedRows.filter(r => !r.is_skipped);
|
|
|
|
|
|
if (toSkip.length === 0) { toast.info('No skippable bills selected.'); return; }
|
|
|
|
|
|
setBulkBusy(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const results = await Promise.allSettled(
|
|
|
|
|
|
toSkip.map(r => api.saveBillMonthlyState(r.id, { year, month, is_skipped: true, actual_amount: r.actual_amount, notes: r.monthly_notes })),
|
|
|
|
|
|
);
|
|
|
|
|
|
const skipped = toSkip.filter((_, i) => results[i]?.status === 'fulfilled');
|
|
|
|
|
|
const failed = results.length - skipped.length;
|
|
|
|
|
|
if (skipped.length > 0) {
|
|
|
|
|
|
toast.success(`Skipped ${skipped.length} bill${skipped.length === 1 ? '' : 's'}${failed ? ` — ${failed} failed` : ''}`, {
|
|
|
|
|
|
action: {
|
|
|
|
|
|
label: 'Undo',
|
|
|
|
|
|
onClick: async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await Promise.all(skipped.map(r => api.saveBillMonthlyState(r.id, { year, month, is_skipped: false, actual_amount: r.actual_amount, notes: r.monthly_notes })));
|
|
|
|
|
|
toast.success('Restored');
|
|
|
|
|
|
invalidateData();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error((err as { message?: string })?.message || 'Failed to undo.');
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast.error('Failed to skip the selected bills.');
|
|
|
|
|
|
}
|
|
|
|
|
|
clearSelection();
|
|
|
|
|
|
invalidateData();
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBulkBusy(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 19:47:14 -05:00
|
|
|
|
const rows = useMemo(() => orderedRows || data?.rows || [], [orderedRows, data]);
|
2026-07-05 14:56:34 -05:00
|
|
|
|
const selectedRows = useMemo(() => rows.filter(r => selectedIds.has(r.id)), [rows, selectedIds]);
|
2026-07-04 23:06:46 -05:00
|
|
|
|
const summary = (data?.summary ?? {}) as TrackerSummary;
|
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();
|
2026-06-12 01:32:28 -05:00
|
|
|
|
// Safe-to-spend projects from "now", so it only makes sense on the current month.
|
|
|
|
|
|
const isCurrentMonth = today.startsWith(`${year}-${String(month).padStart(2, '0')}`);
|
2026-06-07 17:23:14 -05:00
|
|
|
|
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);
|
2026-06-12 01:52:48 -05:00
|
|
|
|
const showSafeToSpend = settingEnabled(trackerSettings.tracker_show_safe_to_spend);
|
2026-06-07 17:23:14 -05:00
|
|
|
|
const showDriftInsights = settingEnabled(trackerSettings.tracker_show_drift_insights);
|
feat(tracker): persist Overdue/Drift panel collapse + inline hide
The Overdue Command Center and Price-Change (drift) panels reset to
expanded on every load — their collapse arrows used local useState. Now
the collapsed state is remembered per-user via two new settings
(tracker_overdue_collapsed / tracker_drift_collapsed), mirroring the
search panel's useSearchPanelPreference pattern. Each panel header also
gains an inline "Hide" control (EyeOff, like the search panel) that writes
the existing tracker_show_* setting and fires an Undo toast; hidden
sections come back from Settings → Tracker Layout.
- services/userSettings.js: allowlist + defaults for the two collapse keys
- TrackerPage: read collapse state, hide handlers with Undo, wire props
- OverdueCommandCenter/DriftInsightPanel: controlled collapse + header hide
Verified: typecheck 0, lint 0 errors, build, and a live settings round-trip
(collapse persists, hide writes, Undo restores, allowlist rejects junk keys).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:34:39 -05:00
|
|
|
|
// Collapse state for the Overdue / Drift panels is remembered per-user (default expanded).
|
|
|
|
|
|
const overdueCollapsed = settingEnabled(trackerSettings.tracker_overdue_collapsed, false);
|
|
|
|
|
|
const driftCollapsed = settingEnabled(trackerSettings.tracker_drift_collapsed, false);
|
2026-06-07 17:23:14 -05:00
|
|
|
|
const showBankProjectionBanner = settingEnabled(trackerSettings.tracker_show_bank_projection_banner) &&
|
|
|
|
|
|
(!bannerSnoozedUntil || bannerSnoozedUntil <= today);
|
|
|
|
|
|
const visibleTableColumns = useMemo(
|
|
|
|
|
|
() => parseTrackerTableColumns(trackerSettings.tracker_table_columns),
|
|
|
|
|
|
[trackerSettings.tracker_table_columns]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-07-05 14:43:13 -05:00
|
|
|
|
// Optimistic write + rollback now live in the useSaveSettings mutation; this
|
|
|
|
|
|
// wrapper just fires it and owns the success/error toasts.
|
|
|
|
|
|
function saveTrackerSettings(patch: Record<string, string>, successMessage?: string) {
|
|
|
|
|
|
saveSettings.mutate(patch, {
|
|
|
|
|
|
onSuccess: () => { if (successMessage) toast.success(successMessage); },
|
|
|
|
|
|
onError: (err) => toast.error((err as { message?: string })?.message || 'Failed to update tracker setting'),
|
|
|
|
|
|
});
|
2026-06-07 17:23:14 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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.');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:06:46 -05:00
|
|
|
|
function handleTableColumnToggle(columnKey: string, checked: boolean) {
|
2026-06-07 17:23:14 -05:00
|
|
|
|
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() {
|
2026-06-07 18:38:05 -05:00
|
|
|
|
saveTrackerSettings({ tracker_show_search_sort: 'false' });
|
|
|
|
|
|
toast('Search & sort hidden.', {
|
|
|
|
|
|
action: { label: 'Undo', onClick: () => saveTrackerSettings({ tracker_show_search_sort: 'true' }) },
|
|
|
|
|
|
});
|
2026-06-07 17:33:31 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
feat(tracker): persist Overdue/Drift panel collapse + inline hide
The Overdue Command Center and Price-Change (drift) panels reset to
expanded on every load — their collapse arrows used local useState. Now
the collapsed state is remembered per-user via two new settings
(tracker_overdue_collapsed / tracker_drift_collapsed), mirroring the
search panel's useSearchPanelPreference pattern. Each panel header also
gains an inline "Hide" control (EyeOff, like the search panel) that writes
the existing tracker_show_* setting and fires an Undo toast; hidden
sections come back from Settings → Tracker Layout.
- services/userSettings.js: allowlist + defaults for the two collapse keys
- TrackerPage: read collapse state, hide handlers with Undo, wire props
- OverdueCommandCenter/DriftInsightPanel: controlled collapse + header hide
Verified: typecheck 0, lint 0 errors, build, and a live settings round-trip
(collapse persists, hide writes, Undo restores, allowlist rejects junk keys).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:34:39 -05:00
|
|
|
|
function hideOverduePanel() {
|
|
|
|
|
|
saveTrackerSettings({ tracker_show_overdue_command_center: 'false' });
|
|
|
|
|
|
toast('Overdue panel hidden.', {
|
|
|
|
|
|
action: { label: 'Undo', onClick: () => saveTrackerSettings({ tracker_show_overdue_command_center: 'true' }) },
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hideDriftPanel() {
|
|
|
|
|
|
saveTrackerSettings({ tracker_show_drift_insights: 'false' });
|
|
|
|
|
|
toast('Price-change insights hidden.', {
|
|
|
|
|
|
action: { label: 'Undo', onClick: () => saveTrackerSettings({ tracker_show_drift_insights: 'true' }) },
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:06:46 -05:00
|
|
|
|
const toggleFilter = (key: BoolFilterKey) => {
|
|
|
|
|
|
const paramMap: Record<BoolFilterKey, string> = { autopay: 'ap', firstBucket: 'b1', fifteenthBucket: 'b2', unpaid: 'un', overdue: 'ov', debt: 'de' };
|
2026-05-31 15:06:10 -05:00
|
|
|
|
updateParams({ [paramMap[key]]: !filters[key] });
|
|
|
|
|
|
};
|
2026-07-04 23:06:46 -05:00
|
|
|
|
const setFilterValue = (key: 'category' | 'cycle', value: string) => {
|
|
|
|
|
|
const paramMap: Record<'category' | 'cycle', string> = { category: 'fc', cycle: 'cy' };
|
2026-05-31 15:06:10 -05:00
|
|
|
|
updateParams({ [paramMap[key]]: value === FILTER_ALL ? null : value });
|
|
|
|
|
|
};
|
2026-07-04 23:06:46 -05:00
|
|
|
|
const setSort = (key: string) => {
|
2026-06-07 00:41:07 -05:00
|
|
|
|
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 });
|
|
|
|
|
|
};
|
2026-07-04 23:06:46 -05:00
|
|
|
|
const handleSortHeader = (key: string) => {
|
2026-06-07 00:41:07 -05:00
|
|
|
|
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-07-04 23:06:46 -05:00
|
|
|
|
const searchSortLabel = hasSort ? `sorted by ${TRACKER_SORT_LABELS[sortKey]} ${sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}` : undefined;
|
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(() => {
|
2026-07-04 23:06:46 -05:00
|
|
|
|
const map = new Map<string, string>();
|
2026-05-16 15:38:28 -05:00
|
|
|
|
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]);
|
2026-07-05 14:43:13 -05:00
|
|
|
|
// Defer the search term so the whole-list filter below (which re-scans every
|
|
|
|
|
|
// row's haystack) runs off the urgent keystroke path — the input stays snappy
|
|
|
|
|
|
// on big months while filtering catches up (React 19).
|
|
|
|
|
|
const deferredSearch = useDeferredValue(search);
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const filteredRows = useMemo(() => {
|
2026-07-05 14:43:13 -05:00
|
|
|
|
const q = deferredSearch.trim().toLowerCase();
|
2026-05-16 15:38:28 -05:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-07-05 14:43:13 -05:00
|
|
|
|
}, [filters, rows, deferredSearch]);
|
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.
|
2026-07-04 23:06:46 -05:00
|
|
|
|
const URGENCY_ORDER: Record<string, number> = { 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-07-05 14:35:03 -05:00
|
|
|
|
// Rank on the reconciled status (same source the overdue filter uses),
|
|
|
|
|
|
// so a bill that's paid-by-threshold but still carries a stale raw
|
|
|
|
|
|
// 'late'/'missed' status sorts to the bottom instead of being pinned up.
|
|
|
|
|
|
const ua = URGENCY_ORDER[rowEffectiveStatus(a)] ?? 99;
|
|
|
|
|
|
const ub = URGENCY_ORDER[rowEffectiveStatus(b)] ?? 99;
|
2026-06-03 22:55:27 -05:00
|
|
|
|
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-07-06 12:54:11 -05:00
|
|
|
|
const activeMonthLabel = `${MONTHS[month - 1]} ${year}`;
|
|
|
|
|
|
const monthStartLabel = new Date(year, month - 1, 1).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
|
|
|
|
const monthEndLabel = new Date(year, month, 0).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
|
|
|
|
const paidCount = Number(summary.count_paid ?? 0);
|
|
|
|
|
|
const overdueCount = Number(summary.count_late ?? 0);
|
|
|
|
|
|
const unpaidCount = rows.filter(row => !row.is_skipped && !rowIsPaid(row)).length;
|
|
|
|
|
|
const headerStatusTone = isError ? 'danger' : loading ? 'info' : overdueCount > 0 ? 'danger' : unpaidCount > 0 ? 'warn' : 'good';
|
|
|
|
|
|
const headerStatusText = isError
|
|
|
|
|
|
? 'Needs attention'
|
|
|
|
|
|
: loading
|
|
|
|
|
|
? 'Loading month'
|
|
|
|
|
|
: overdueCount > 0
|
|
|
|
|
|
? `${overdueCount} overdue`
|
|
|
|
|
|
: unpaidCount > 0
|
|
|
|
|
|
? `${unpaidCount} unpaid`
|
|
|
|
|
|
: 'All settled';
|
2026-05-30 16:13:37 -05:00
|
|
|
|
|
2026-07-04 23:06:46 -05:00
|
|
|
|
async function persistTrackerOrder(nextRows: TrackerRow[], movedBillId: number | null) {
|
2026-05-30 16:13:37 -05:00
|
|
|
|
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');
|
2026-07-03 18:19:30 -05:00
|
|
|
|
invalidateData();
|
2026-05-30 16:13:37 -05:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setOrderedRows(null);
|
2026-07-04 23:06:46 -05:00
|
|
|
|
toast.error((err as { message?: string })?.message || 'Failed to save bill order');
|
2026-05-30 16:13:37 -05:00
|
|
|
|
} finally {
|
|
|
|
|
|
setMovingBillId(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:06:46 -05:00
|
|
|
|
function handleReorderBucket(bucket: string, orderedBucketRows: TrackerRow[]) {
|
2026-05-30 16:13:37 -05:00
|
|
|
|
const sourceRows = rows;
|
|
|
|
|
|
const nextRows = [...sourceRows];
|
|
|
|
|
|
const replacement = [...orderedBucketRows];
|
|
|
|
|
|
for (let i = 0; i < nextRows.length; i += 1) {
|
2026-07-04 23:06:46 -05:00
|
|
|
|
const cur = nextRows[i];
|
|
|
|
|
|
if (cur && cur.bucket === bucket) nextRows[i] = replacement.shift()!;
|
2026-05-30 16:13:37 -05:00
|
|
|
|
}
|
|
|
|
|
|
const moved = orderedBucketRows.find((row, index) => row.id !== (sourceRows.filter(item => item.bucket === bucket)[index]?.id));
|
2026-07-05 15:00:16 -05:00
|
|
|
|
if (moved) {
|
|
|
|
|
|
const pos = orderedBucketRows.findIndex(r => r.id === moved.id) + 1;
|
|
|
|
|
|
setReorderAnnouncement(`Moved ${moved.name} to position ${pos} of ${orderedBucketRows.length}.`);
|
|
|
|
|
|
}
|
2026-07-04 23:06:46 -05:00
|
|
|
|
persistTrackerOrder(nextRows, moved?.id || orderedBucketRows[0]?.id || null);
|
2026-05-30 16:13:37 -05:00
|
|
|
|
}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-5">
|
|
|
|
|
|
|
2026-07-05 15:00:16 -05:00
|
|
|
|
{/* Visually-hidden live region: announces drag/keyboard reorder to screen readers */}
|
|
|
|
|
|
<div role="status" aria-live="polite" className="sr-only">{reorderAnnouncement}</div>
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
{/* ── Header ── */}
|
2026-07-05 17:18:56 -05:00
|
|
|
|
<PageHeader
|
2026-07-06 12:54:11 -05:00
|
|
|
|
eyebrow="Monthly command center"
|
|
|
|
|
|
title="Tracker"
|
|
|
|
|
|
description={(
|
2026-07-05 17:18:56 -05:00
|
|
|
|
<>
|
2026-07-06 12:54:11 -05:00
|
|
|
|
{activeMonthLabel}
|
|
|
|
|
|
<span className="mx-2 text-muted-foreground/60">·</span>
|
|
|
|
|
|
{monthStartLabel} to {monthEndLabel}
|
|
|
|
|
|
{isCurrentMonth && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<span className="mx-2 text-muted-foreground/60">·</span>
|
|
|
|
|
|
Current month
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-07-05 17:18:56 -05:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
glyph="tracker"
|
2026-07-06 12:54:11 -05:00
|
|
|
|
meta={(
|
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
|
<HeaderStat icon={CheckCircle2} label="Paid" value={loading ? '...' : paidCount} tone={paidCount > 0 ? 'good' : 'neutral'} />
|
|
|
|
|
|
<HeaderStat icon={AlertCircle} label="Needs action" value={loading ? '...' : headerStatusText} tone={headerStatusTone} />
|
|
|
|
|
|
<HeaderStat icon={Search} label="Showing" value={`${filteredRows.length}/${rows.length}`} tone={hasFilters ? 'info' : 'neutral'} />
|
|
|
|
|
|
<HeaderStat icon={RefreshCw} label="Remaining" value={loading ? '...' : fmt(summary.remaining)} tone={Number(summary.remaining ?? 0) <= 0 ? 'good' : 'neutral'} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-07-05 17:18:56 -05:00
|
|
|
|
actions={(
|
2026-07-05 14:49:09 -05:00
|
|
|
|
<div className="flex flex-wrap items-center gap-2 tracker-print-hide">
|
2026-07-06 12:54:11 -05:00
|
|
|
|
<div className="flex items-center gap-1 rounded-full border border-border/75 bg-card/70 p-1 shadow-sm shadow-black/5">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="icon" variant="ghost"
|
|
|
|
|
|
onClick={() => navigate(-1)}
|
|
|
|
|
|
onMouseEnter={() => prefetchAdjacent(-1)}
|
|
|
|
|
|
onFocus={() => prefetchAdjacent(-1)}
|
|
|
|
|
|
className="h-8 w-8 rounded-full"
|
|
|
|
|
|
aria-label="Previous month"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ChevronLeft className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<span className="min-w-[8rem] px-1 text-center text-xs font-semibold tabular-nums select-none">
|
|
|
|
|
|
{activeMonthLabel}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="icon" variant="ghost"
|
|
|
|
|
|
onClick={() => navigate(1)}
|
|
|
|
|
|
onMouseEnter={() => prefetchAdjacent(1)}
|
|
|
|
|
|
onFocus={() => prefetchAdjacent(1)}
|
|
|
|
|
|
className="h-8 w-8 rounded-full"
|
|
|
|
|
|
aria-label="Next month"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ChevronRight className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm" variant={isCurrentMonth ? 'secondary' : 'outline'}
|
|
|
|
|
|
onClick={goToday}
|
|
|
|
|
|
className="h-9 rounded-full px-3 text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
Today
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
2026-06-03 22:55:27 -05:00
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant={pinUpcoming ? 'default' : 'outline'}
|
|
|
|
|
|
onClick={togglePinUpcoming}
|
2026-07-06 12:54:11 -05:00
|
|
|
|
className="h-9 gap-1.5 rounded-full px-3"
|
2026-06-03 22:55:27 -05:00
|
|
|
|
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}
|
2026-07-06 12:54:11 -05:00
|
|
|
|
className="h-9 gap-1.5 rounded-full px-3"
|
2026-06-03 21:59:50 -05:00
|
|
|
|
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}
|
2026-07-06 12:54:11 -05:00
|
|
|
|
className="h-9 gap-1.5 rounded-full px-3 shadow-sm"
|
2026-05-31 15:06:10 -05:00
|
|
|
|
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
|
|
|
|
|
2026-07-05 14:49:09 -05:00
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
onClick={goToCalendar}
|
2026-07-06 12:54:11 -05:00
|
|
|
|
className="h-9 gap-1.5 rounded-full px-3 tracker-print-hide"
|
2026-07-05 14:49:09 -05:00
|
|
|
|
aria-label="Open calendar for this month"
|
|
|
|
|
|
title="View this month on the calendar"
|
|
|
|
|
|
>
|
|
|
|
|
|
<CalendarDays className="h-4 w-4" />
|
|
|
|
|
|
<span className="hidden sm:inline">Calendar</span>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="outline"
|
2026-07-06 12:54:11 -05:00
|
|
|
|
className="h-9 gap-1.5 rounded-full px-3 tracker-print-hide"
|
2026-07-05 14:49:09 -05:00
|
|
|
|
aria-label="Export or print this month"
|
|
|
|
|
|
title="Export or print this month"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Download className="h-4 w-4" />
|
|
|
|
|
|
<span className="hidden sm:inline">Export</span>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
|
<DropdownMenuContent align="end">
|
|
|
|
|
|
<DropdownMenuLabel>{new Date(year, month - 1, 1).toLocaleString('en-US', { month: 'long' })} {year}</DropdownMenuLabel>
|
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
|
<DropdownMenuItem onSelect={handleExportMonthCsv}>
|
|
|
|
|
|
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
|
|
|
|
|
Export CSV
|
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
<DropdownMenuItem onSelect={() => window.print()}>
|
|
|
|
|
|
<Printer className="mr-2 h-4 w-4" />
|
|
|
|
|
|
Print
|
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
|
|
2026-07-05 15:00:16 -05:00
|
|
|
|
<DropdownMenu>
|
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
variant="ghost"
|
2026-07-06 12:54:11 -05:00
|
|
|
|
className="h-9 w-9 rounded-full tracker-print-hide"
|
2026-07-05 15:00:16 -05:00
|
|
|
|
aria-label="Keyboard shortcuts"
|
|
|
|
|
|
title="Keyboard shortcuts"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Keyboard className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
|
<DropdownMenuContent align="end" className="w-64">
|
|
|
|
|
|
<DropdownMenuLabel>Keyboard shortcuts</DropdownMenuLabel>
|
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
|
<div className="space-y-1.5 px-2 py-1.5 text-xs text-muted-foreground">
|
|
|
|
|
|
{[
|
|
|
|
|
|
['Move between bills', 'j / k · ↑ / ↓'],
|
|
|
|
|
|
['Edit focused bill', 'Enter'],
|
|
|
|
|
|
['Toggle paid', 'p'],
|
|
|
|
|
|
['Blur / deselect row', 'Esc'],
|
|
|
|
|
|
['Command palette', '⌘K / Ctrl+K'],
|
|
|
|
|
|
].map(([label, keys]) => (
|
|
|
|
|
|
<div key={label} className="flex items-center justify-between gap-4">
|
|
|
|
|
|
<span>{label}</span>
|
|
|
|
|
|
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] text-foreground">{keys}</kbd>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
|
<p className="px-2 py-1 text-[11px] text-muted-foreground">Focus any bill row (Tab or click), then use the keys above.</p>
|
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
</div>
|
2026-07-05 17:18:56 -05:00
|
|
|
|
)}
|
|
|
|
|
|
/>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
2026-06-07 17:23:14 -05:00
|
|
|
|
{showSearchSort && (
|
|
|
|
|
|
<SearchFilterPanel
|
|
|
|
|
|
title="Search & sort"
|
2026-07-05 14:49:09 -05:00
|
|
|
|
className="tracker-print-hide"
|
2026-06-07 17:23:14 -05:00
|
|
|
|
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)}>
|
2026-07-02 20:36:09 -05:00
|
|
|
|
<SelectTrigger className="h-10" aria-label="Filter by category">
|
2026-06-07 17:23:14 -05:00
|
|
|
|
<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)}>
|
2026-07-02 20:36:09 -05:00
|
|
|
|
<SelectTrigger className="h-10 capitalize" aria-label="Filter by billing schedule">
|
2026-06-07 17:23:14 -05:00
|
|
|
|
<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}>
|
2026-07-02 20:36:09 -05:00
|
|
|
|
<SelectTrigger className="h-10" aria-label="Sort by">
|
2026-06-07 17:23:14 -05:00
|
|
|
|
<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>
|
2026-07-05 15:00:16 -05:00
|
|
|
|
<span role="status" aria-live="polite" className="ml-auto text-xs text-muted-foreground tabular-nums">
|
2026-06-07 17:23:14 -05:00
|
|
|
|
{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-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" />
|
2026-07-04 23:06:46 -05:00
|
|
|
|
{summary.trend ? <Skeleton variant="card" className="h-32" /> : null}
|
2026-05-10 01:35:41 -05:00
|
|
|
|
</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(
|
2026-07-05 17:18:56 -05:00
|
|
|
|
'metric-card min-w-0 flex-1 text-left transition-all duration-200',
|
2026-06-04 21:22:20 -05:00
|
|
|
|
'hover:ring-2 hover:ring-primary/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
|
|
|
|
Number(bankTracking.remaining ?? 0) >= 0
|
2026-07-05 17:18:56 -05:00
|
|
|
|
? 'border-emerald-500/25 bg-emerald-500/[0.07]'
|
|
|
|
|
|
: 'border-destructive/30 bg-destructive/[0.07]',
|
2026-06-04 21:22:20 -05:00
|
|
|
|
)}
|
|
|
|
|
|
title="Click to see income breakdown"
|
|
|
|
|
|
>
|
2026-06-04 02:24:10 -05:00
|
|
|
|
<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')} />
|
2026-07-05 17:18:56 -05:00
|
|
|
|
<p className="truncate text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
2026-06-04 02:24:10 -05:00
|
|
|
|
{bankTracking.account_name}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<span className="ml-auto flex items-center gap-1 text-[10px] text-emerald-600 dark:text-emerald-400 font-medium">
|
2026-07-05 17:18:56 -05:00
|
|
|
|
<StatusDot tone="good" pulse className="h-1.5 w-1.5 [&_span]:h-1.5 [&_span]:w-1.5" />
|
2026-06-08 12:24:51 -05:00
|
|
|
|
Live
|
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-07-03 18:49:35 -05:00
|
|
|
|
<SummaryCard
|
|
|
|
|
|
type="paid"
|
|
|
|
|
|
value={summary.total_paid}
|
2026-07-04 23:06:46 -05:00
|
|
|
|
hint={Number(summary.previous_month_total ?? 0) > 0 ? `Last month: ${fmt(summary.previous_month_total)}` : undefined}
|
2026-07-03 18:49:35 -05:00
|
|
|
|
/>
|
|
|
|
|
|
{/* In bank mode the hero card already surfaces the projected remaining,
|
|
|
|
|
|
so only show the Remaining card when there's no bank hero. */}
|
|
|
|
|
|
{!bankTracking?.enabled && (
|
|
|
|
|
|
<SummaryCard
|
|
|
|
|
|
type="remaining"
|
|
|
|
|
|
value={summary.remaining}
|
|
|
|
|
|
hint={summary.remaining_label || undefined}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-05-10 01:35:41 -05:00
|
|
|
|
<SummaryCard type="overdue" value={summary.overdue} />
|
2026-07-04 23:06:46 -05:00
|
|
|
|
{summary.trend ? <TrendCard trend={summary.trend as Parameters<typeof TrendCard>[0]['trend']} /> : null}
|
2026-05-10 01:35:41 -05:00
|
|
|
|
</div>
|
2026-06-07 17:23:14 -05:00
|
|
|
|
) : null}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
2026-07-03 18:49:35 -05:00
|
|
|
|
{/* Compact month-progress line — quick "how far through the month am I?" */}
|
2026-07-04 23:06:46 -05:00
|
|
|
|
{showSummaryCards && !loading && !isError && Number(summary?.total_expected ?? 0) > 0 && (() => {
|
2026-07-03 18:49:35 -05:00
|
|
|
|
const billsLeft = (summary.count_upcoming ?? 0) + (summary.count_late ?? 0);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<p className="px-1 -mt-1 text-[11px] text-muted-foreground">
|
|
|
|
|
|
<span className="font-mono font-semibold text-foreground">{fmt(summary.paid_toward_due)}</span>
|
|
|
|
|
|
{' of '}
|
|
|
|
|
|
<span className="font-mono">{fmt(summary.total_expected)}</span>
|
|
|
|
|
|
{' paid'}
|
|
|
|
|
|
{billsLeft > 0 && <> · {billsLeft} bill{billsLeft === 1 ? '' : 's'} left</>}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
);
|
|
|
|
|
|
})()}
|
|
|
|
|
|
|
2026-06-12 01:32:28 -05:00
|
|
|
|
{/* ── Safe to Spend ── */}
|
2026-06-12 01:52:48 -05:00
|
|
|
|
{!isError && !loading && showSafeToSpend && isCurrentMonth && cashflow && (
|
2026-06-12 01:32:28 -05:00
|
|
|
|
<CashFlowCard
|
|
|
|
|
|
cashflow={cashflow}
|
|
|
|
|
|
onSetStartingAmounts={() => setEditStartingOpen(true)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
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}
|
2026-07-03 18:19:30 -05:00
|
|
|
|
refresh={invalidateData}
|
2026-05-30 13:19:09 -05:00
|
|
|
|
onPayNow={(row) => setCommandCenterPayRow(row)}
|
feat(tracker): persist Overdue/Drift panel collapse + inline hide
The Overdue Command Center and Price-Change (drift) panels reset to
expanded on every load — their collapse arrows used local useState. Now
the collapsed state is remembered per-user via two new settings
(tracker_overdue_collapsed / tracker_drift_collapsed), mirroring the
search panel's useSearchPanelPreference pattern. Each panel header also
gains an inline "Hide" control (EyeOff, like the search panel) that writes
the existing tracker_show_* setting and fires an Undo toast; hidden
sections come back from Settings → Tracker Layout.
- services/userSettings.js: allowlist + defaults for the two collapse keys
- TrackerPage: read collapse state, hide handlers with Undo, wire props
- OverdueCommandCenter/DriftInsightPanel: controlled collapse + header hide
Verified: typecheck 0, lint 0 errors, build, and a live settings round-trip
(collapse persists, hide writes, Undo restores, allowlist rejects junk keys).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:34:39 -05:00
|
|
|
|
collapsed={overdueCollapsed}
|
|
|
|
|
|
onCollapsedChange={(c) => saveTrackerSettings({ tracker_overdue_collapsed: c ? 'true' : 'false' })}
|
|
|
|
|
|
onHide={hideOverduePanel}
|
2026-05-30 13:19:09 -05:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
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
|
2026-07-04 23:06:46 -05:00
|
|
|
|
driftBills={driftData?.bills || []}
|
2026-07-03 18:19:30 -05:00
|
|
|
|
refresh={invalidateData}
|
feat(tracker): persist Overdue/Drift panel collapse + inline hide
The Overdue Command Center and Price-Change (drift) panels reset to
expanded on every load — their collapse arrows used local useState. Now
the collapsed state is remembered per-user via two new settings
(tracker_overdue_collapsed / tracker_drift_collapsed), mirroring the
search panel's useSearchPanelPreference pattern. Each panel header also
gains an inline "Hide" control (EyeOff, like the search panel) that writes
the existing tracker_show_* setting and fires an Undo toast; hidden
sections come back from Settings → Tracker Layout.
- services/userSettings.js: allowlist + defaults for the two collapse keys
- TrackerPage: read collapse state, hide handlers with Undo, wire props
- OverdueCommandCenter/DriftInsightPanel: controlled collapse + header hide
Verified: typecheck 0, lint 0 errors, build, and a live settings round-trip
(collapse persists, hide writes, Undo restores, allowlist rejects junk keys).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:34:39 -05:00
|
|
|
|
collapsed={driftCollapsed}
|
|
|
|
|
|
onCollapsedChange={(c) => saveTrackerSettings({ tracker_drift_collapsed: c ? 'true' : 'false' })}
|
|
|
|
|
|
onHide={hideDriftPanel}
|
2026-05-30 14:33:55 -05:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-28 02:34:24 -05:00
|
|
|
|
{/* ── Fetch error state ── */}
|
|
|
|
|
|
{isError && (
|
2026-07-05 17:18:56 -05:00
|
|
|
|
<EmptyState
|
|
|
|
|
|
icon={AlertCircle}
|
|
|
|
|
|
title="Failed to load tracker data"
|
|
|
|
|
|
description={error?.message || 'An unexpected error occurred.'}
|
|
|
|
|
|
action={(
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
onClick={() => refetch()}
|
|
|
|
|
|
className="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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
2026-05-28 02:34:24 -05:00
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
{/* ── Empty state ── */}
|
2026-07-04 23:06:46 -05:00
|
|
|
|
{!isError && rows.length === 0 && data != null && (
|
2026-07-05 17:18:56 -05:00
|
|
|
|
<EmptyState
|
|
|
|
|
|
icon={CheckCircle2}
|
|
|
|
|
|
title="No bills this month"
|
|
|
|
|
|
action={(
|
|
|
|
|
|
<a href="/bills" className="text-xs text-muted-foreground underline underline-offset-4 transition-colors hover:text-foreground">
|
|
|
|
|
|
Add a bill
|
|
|
|
|
|
</a>
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── 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-07-05 14:35:03 -05:00
|
|
|
|
{/* ── Filtered-to-empty state — bills exist this month but none match the active filters/search ── */}
|
|
|
|
|
|
{!isError && !loading && rows.length > 0 && first.length === 0 && second.length === 0 && (
|
2026-07-05 17:18:56 -05:00
|
|
|
|
<EmptyState
|
|
|
|
|
|
icon={Search}
|
|
|
|
|
|
title="No bills match your filters"
|
|
|
|
|
|
description={`${rows.length} bill${rows.length === 1 ? '' : 's'} this month are hidden by the current search or filters.`}
|
|
|
|
|
|
action={hasFilters ? (
|
|
|
|
|
|
<Button size="sm" variant="outline" onClick={resetFilters} className="gap-1.5 text-xs">
|
2026-07-05 14:35:03 -05:00
|
|
|
|
<RefreshCw className="h-3 w-3" />
|
|
|
|
|
|
Clear filters
|
|
|
|
|
|
</Button>
|
2026-07-05 17:18:56 -05:00
|
|
|
|
) : undefined}
|
|
|
|
|
|
/>
|
2026-07-05 14:35:03 -05:00
|
|
|
|
)}
|
|
|
|
|
|
|
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-07-05 14:56:34 -05:00
|
|
|
|
{first.length > 0 && <Bucket label="1st – 14th" rows={first} year={year} month={month} refresh={invalidateData} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('1st', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} selectedIds={selectedIds} onToggleSelect={handleToggleSelect} />}
|
|
|
|
|
|
{second.length > 0 && <Bucket label="15th – 31st" rows={second} year={year} month={month} refresh={invalidateData} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('15th', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} selectedIds={selectedIds} onToggleSelect={handleToggleSelect} />}
|
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-07-03 18:19:30 -05:00
|
|
|
|
onSave={() => invalidateData()}
|
2026-05-16 15:38:28 -05:00
|
|
|
|
onDuplicate={bill => setEditBillData({
|
|
|
|
|
|
bill: null,
|
2026-07-04 23:06:46 -05:00
|
|
|
|
initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }) as Partial<Bill>,
|
2026-05-16 15:38:28 -05:00
|
|
|
|
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-07-03 18:19:30 -05:00
|
|
|
|
onSave={() => { setEditStartingOpen(false); invalidateData(); }}
|
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);
|
2026-07-04 23:06:46 -05:00
|
|
|
|
const monthName = new Date(attr.suggested_date + 'T00:00:00').toLocaleDateString('en-US', { month: 'long' });
|
|
|
|
|
|
toast.success(`${attr.bill_name} payment moved to ${monthName}`);
|
2026-06-04 00:06:16 -05:00
|
|
|
|
setLateAttributions(prev => prev.slice(1)); // dismiss only on success
|
2026-07-03 18:19:30 -05:00
|
|
|
|
invalidateData();
|
2026-06-04 00:06:16 -05:00
|
|
|
|
} catch (err) {
|
2026-07-04 23:06:46 -05:00
|
|
|
|
toast.error((err as { message?: string })?.message || 'Failed to reclassify payment — try again');
|
2026-06-04 00:06:16 -05:00
|
|
|
|
// 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}
|
2026-07-04 23:06:46 -05:00
|
|
|
|
threshold={(commandCenterPayRow.actual_amount ?? commandCenterPayRow.expected_amount) as Dollars}
|
2026-05-30 13:19:09 -05:00
|
|
|
|
defaultPaymentDate={paymentDateForTrackerMonth(year, month, commandCenterPayRow.due_day)}
|
|
|
|
|
|
onClose={() => setCommandCenterPayRow(null)}
|
2026-07-03 18:19:30 -05:00
|
|
|
|
onSaved={() => { setCommandCenterPayRow(null); invalidateData(); }}
|
2026-05-30 13:19:09 -05:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-07-05 14:56:34 -05:00
|
|
|
|
{/* Multi-select action bar — sticky at the bottom while any bills are checked */}
|
|
|
|
|
|
{selectedRows.length > 0 && (
|
|
|
|
|
|
<div className="tracker-print-hide fixed inset-x-0 bottom-4 z-40 flex justify-center px-4">
|
|
|
|
|
|
<div
|
|
|
|
|
|
role="toolbar"
|
|
|
|
|
|
aria-label="Bulk actions for selected bills"
|
|
|
|
|
|
className="flex items-center gap-2 rounded-full border border-border bg-card/95 px-3 py-2 shadow-lg backdrop-blur supports-[backdrop-filter]:bg-card/80"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="px-2 text-sm font-medium tabular-nums">{selectedRows.length} selected</span>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={handlePaySelected}
|
|
|
|
|
|
disabled={bulkBusy || selectedRows.every(r => r.is_skipped || rowIsPaid(r))}
|
|
|
|
|
|
className="h-8 gap-1.5"
|
|
|
|
|
|
>
|
|
|
|
|
|
<CheckCircle2 className="h-4 w-4" />
|
|
|
|
|
|
Pay
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
onClick={handleSkipSelected}
|
|
|
|
|
|
disabled={bulkBusy || selectedRows.every(r => r.is_skipped)}
|
|
|
|
|
|
className="h-8 gap-1.5"
|
|
|
|
|
|
>
|
|
|
|
|
|
<EyeOff className="h-4 w-4" />
|
|
|
|
|
|
Skip
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
onClick={clearSelection}
|
|
|
|
|
|
disabled={bulkBusy}
|
|
|
|
|
|
className="h-8 gap-1.5 text-muted-foreground"
|
|
|
|
|
|
aria-label="Clear selection"
|
|
|
|
|
|
>
|
|
|
|
|
|
Clear
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|