refactor(ts): TrackerPage → TypeScript (the home page)
The tracker: URL-driven month/filters/sort, React Query data (TrackerResponse), bank-projection banner, summary cards, safe-to-spend, overdue center, drift insights, drag-reorder buckets, and all row/modal orchestration. TrackerSummary.trend is typed unknown in @/types so it's cast at the TrendCard boundary.
This commit is contained in:
parent
dbf3c42d62
commit
d559f4b00b
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback, type ReactNode } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, EyeOff, Settings2 } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, EyeOff, Settings2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
@ -8,7 +8,8 @@ import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
|
||||||
import BillModal from '@/components/BillModal';
|
import BillModal from '@/components/BillModal';
|
||||||
import { makeBillDraft } from '@/lib/billDrafts';
|
import { makeBillDraft } from '@/lib/billDrafts';
|
||||||
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
|
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
|
||||||
import { cn, fmt, localDateString } from '@/lib/utils';
|
import { cn, localDateString } from '@/lib/utils';
|
||||||
|
import { formatUSD, asDollars, type Dollars } from '@/lib/money';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import SearchFilterPanel from '@/components/SearchFilterPanel';
|
import SearchFilterPanel from '@/components/SearchFilterPanel';
|
||||||
|
|
@ -41,26 +42,77 @@ import { SummaryCard, TrendCard } from '@/components/tracker/SummaryCards';
|
||||||
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
|
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
|
||||||
import { TrackerBucket as Bucket } from '@/components/tracker/TrackerBucket';
|
import { TrackerBucket as Bucket } from '@/components/tracker/TrackerBucket';
|
||||||
import IncomeBreakdownModal from '@/components/IncomeBreakdownModal';
|
import IncomeBreakdownModal from '@/components/IncomeBreakdownModal';
|
||||||
|
import type { TrackerRow, TrackerSummary, BankTracking, DriftBill, Bill, Category } from '@/types';
|
||||||
|
|
||||||
|
function fmt(v: number | null | undefined): string {
|
||||||
|
return formatUSD(asDollars(Number(v) || 0));
|
||||||
|
}
|
||||||
|
|
||||||
function parseUtc(str) {
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>;
|
||||||
|
|
||||||
|
function parseUtc(str: string | null | undefined): Date | null {
|
||||||
if (!str) return null;
|
if (!str) return null;
|
||||||
// SQLite datetime('now') returns "2026-06-07 23:40:15" — no T, no Z. Treat as UTC.
|
// SQLite datetime('now') returns "2026-06-07 23:40:15" — no T, no Z. Treat as UTC.
|
||||||
const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z';
|
const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z';
|
||||||
return new Date(normalized);
|
return new Date(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtBalanceAge(isoStr) {
|
function fmtBalanceAge(isoStr: string | null | undefined): string | null {
|
||||||
if (!isoStr) return null;
|
const d = parseUtc(isoStr);
|
||||||
return parseUtc(isoStr).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
if (!d) return null;
|
||||||
|
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function settingEnabled(value, fallback = true) {
|
function settingEnabled(value: unknown, fallback = true): boolean {
|
||||||
if (value === undefined || value === null || value === '') return fallback;
|
if (value === undefined || value === null || value === '') return fallback;
|
||||||
return value === true || value === 'true';
|
return value === true || value === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
function BankProjectionBanner({ bankTracking, onSnooze, onIgnore, busy }) {
|
function BankProjectionBanner({ bankTracking, onSnooze, onIgnore, busy }: {
|
||||||
|
bankTracking?: BankTracking;
|
||||||
|
onSnooze: () => void;
|
||||||
|
onIgnore: () => void;
|
||||||
|
busy?: boolean;
|
||||||
|
}) {
|
||||||
if (!bankTracking?.enabled) return null;
|
if (!bankTracking?.enabled) return null;
|
||||||
const isPositive = Number(bankTracking.remaining ?? 0) >= 0;
|
const isPositive = Number(bankTracking.remaining ?? 0) >= 0;
|
||||||
|
|
||||||
|
|
@ -123,7 +175,11 @@ function BankProjectionBanner({ bankTracking, onSnooze, onIgnore, busy }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TrackerColumnMenu({ visibleColumns, onColumnToggle, saving }) {
|
function TrackerColumnMenu({ visibleColumns, onColumnToggle, saving }: {
|
||||||
|
visibleColumns: string[];
|
||||||
|
onColumnToggle?: (key: string, checked: boolean) => void;
|
||||||
|
saving?: boolean;
|
||||||
|
}) {
|
||||||
const visibleSet = new Set(visibleColumns);
|
const visibleSet = new Set(visibleColumns);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -162,9 +218,15 @@ function TrackerColumnMenu({ visibleColumns, onColumnToggle, saving }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main page ──────────────────────────────────────────────────────────────
|
// ── Main page ──────────────────────────────────────────────────────────────
|
||||||
function LateAttributionDialog({ attr, remaining, busy, onAccept, onDismiss }) {
|
function LateAttributionDialog({ attr, remaining, busy, onAccept, onDismiss }: {
|
||||||
|
attr?: LateAttr | null;
|
||||||
|
remaining: number;
|
||||||
|
busy?: boolean;
|
||||||
|
onAccept: (attr: LateAttr) => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}) {
|
||||||
if (!attr) return null;
|
if (!attr) return null;
|
||||||
const fmtDate = (d) => new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
const fmtDate = (d: string) => 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' });
|
const priorMonth = new Date(attr.suggested_date + 'T00:00:00').toLocaleDateString('en-US', { month: 'long' });
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={onDismiss}>
|
<Dialog open onOpenChange={onDismiss}>
|
||||||
|
|
@ -207,7 +269,7 @@ export default function TrackerPage() {
|
||||||
const search = searchParams.get('q') || '';
|
const search = searchParams.get('q') || '';
|
||||||
// Stable identity so the memo that filters rows on `filters` doesn't recompute
|
// Stable identity so the memo that filters rows on `filters` doesn't recompute
|
||||||
// every render (a new object literal each render would defeat it).
|
// every render (a new object literal each render would defeat it).
|
||||||
const filters = useMemo(() => ({
|
const filters = useMemo<TrackerFilters>(() => ({
|
||||||
category: searchParams.get('fc') || FILTER_ALL,
|
category: searchParams.get('fc') || FILTER_ALL,
|
||||||
cycle: searchParams.get('cy') || FILTER_ALL,
|
cycle: searchParams.get('cy') || FILTER_ALL,
|
||||||
autopay: searchParams.get('ap') === '1',
|
autopay: searchParams.get('ap') === '1',
|
||||||
|
|
@ -224,7 +286,7 @@ export default function TrackerPage() {
|
||||||
: TRACKER_SORT_ASC;
|
: TRACKER_SORT_ASC;
|
||||||
|
|
||||||
// replace: true keeps history clean for rapid navigation (e.g. search keystrokes)
|
// replace: true keeps history clean for rapid navigation (e.g. search keystrokes)
|
||||||
const updateParams = useCallback((patch) => {
|
const updateParams = useCallback((patch: Patch) => {
|
||||||
setSearchParams(prev => {
|
setSearchParams(prev => {
|
||||||
const next = new URLSearchParams(prev);
|
const next = new URLSearchParams(prev);
|
||||||
Object.entries(patch).forEach(([k, v]) => {
|
Object.entries(patch).forEach(([k, v]) => {
|
||||||
|
|
@ -236,19 +298,19 @@ export default function TrackerPage() {
|
||||||
}, [setSearchParams]);
|
}, [setSearchParams]);
|
||||||
|
|
||||||
// Edit Bill modal: { bill, categories } when open, null when closed
|
// Edit Bill modal: { bill, categories } when open, null when closed
|
||||||
const [bankSyncStatus, setBankSyncStatus] = useState(null);
|
const [bankSyncStatus, setBankSyncStatus] = useState<BankSyncStatus | null>(null);
|
||||||
const [bankSyncing, setBankSyncing] = useState(false);
|
const [bankSyncing, setBankSyncing] = useState(false);
|
||||||
const [lateAttributions, setLateAttributions] = useState([]); // pending month-attribution prompts
|
const [lateAttributions, setLateAttributions] = useState<LateAttr[]>([]); // pending month-attribution prompts
|
||||||
const [attrBusy, setAttrBusy] = useState(null); // payment_id being resolved
|
const [attrBusy, setAttrBusy] = useState<number | null>(null); // payment_id being resolved
|
||||||
const [pinUpcoming, setPinUpcoming] = useState(() => localStorage.getItem('tracker_pin_upcoming') === 'true');
|
const [pinUpcoming, setPinUpcoming] = useState(() => localStorage.getItem('tracker_pin_upcoming') === 'true');
|
||||||
const [editBillData, setEditBillData] = useState(null);
|
const [editBillData, setEditBillData] = useState<EditBillData | null>(null);
|
||||||
// Edit Starting Amounts modal: true when open, false when closed
|
// Edit Starting Amounts modal: true when open, false when closed
|
||||||
const [editStartingOpen, setEditStartingOpen] = useState(false);
|
const [editStartingOpen, setEditStartingOpen] = useState(false);
|
||||||
const [incomeModalOpen, setIncomeModalOpen] = useState(false);
|
const [incomeModalOpen, setIncomeModalOpen] = useState(false);
|
||||||
const [orderedRows, setOrderedRows] = useState(null);
|
const [orderedRows, setOrderedRows] = useState<TrackerRow[] | null>(null);
|
||||||
const [movingBillId, setMovingBillId] = useState(null);
|
const [movingBillId, setMovingBillId] = useState<number | null>(null);
|
||||||
const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();
|
const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();
|
||||||
const [trackerSettings, setTrackerSettings] = useState({
|
const [trackerSettings, setTrackerSettings] = useState<Record<string, string>>({
|
||||||
tracker_show_bank_projection_banner: 'true',
|
tracker_show_bank_projection_banner: 'true',
|
||||||
tracker_bank_projection_banner_snoozed_until: '',
|
tracker_bank_projection_banner_snoozed_until: '',
|
||||||
tracker_show_search_sort: 'true',
|
tracker_show_search_sort: 'true',
|
||||||
|
|
@ -260,11 +322,12 @@ export default function TrackerPage() {
|
||||||
const [savingTrackerSetting, setSavingTrackerSetting] = useState(false);
|
const [savingTrackerSetting, setSavingTrackerSetting] = useState(false);
|
||||||
|
|
||||||
// Row to open in PaymentLedgerDialog via the overdue command center
|
// Row to open in PaymentLedgerDialog via the overdue command center
|
||||||
const [commandCenterPayRow, setCommandCenterPayRow] = useState(null);
|
const [commandCenterPayRow, setCommandCenterPayRow] = useState<TrackerRow | null>(null);
|
||||||
|
|
||||||
// Use React Query for data fetching
|
// Use React Query for data fetching
|
||||||
const { data, isLoading: loading, isError, error, refetch, dataUpdatedAt } = useTracker(year, month);
|
const { data, isLoading: loading, isError, error, refetch, dataUpdatedAt } = useTracker(year, month);
|
||||||
const { data: driftData } = useDriftReport();
|
const { data: driftDataRaw } = useDriftReport();
|
||||||
|
const driftData = driftDataRaw as { bills?: DriftBill[] } | undefined;
|
||||||
// Live cross-query refresh: a bill mutation invalidates the tracker AND the
|
// Live cross-query refresh: a bill mutation invalidates the tracker AND the
|
||||||
// overdue badge / drift report / bills list so the whole shell updates, not
|
// 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.
|
// just the one tracker query. Use this everywhere a row/payment mutation lands.
|
||||||
|
|
@ -279,20 +342,20 @@ export default function TrackerPage() {
|
||||||
// Load SimpleFIN status once to decide whether to show the sync button
|
// Load SimpleFIN status once to decide whether to show the sync button
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.simplefinStatus()
|
api.simplefinStatus()
|
||||||
.then(setBankSyncStatus)
|
.then(s => setBankSyncStatus(s as BankSyncStatus))
|
||||||
.catch(() => setBankSyncStatus(null));
|
.catch(() => setBankSyncStatus(null));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.settings()
|
api.settings()
|
||||||
.then(settings => setTrackerSettings(prev => ({ ...prev, ...settings })))
|
.then(settings => setTrackerSettings(prev => ({ ...prev, ...(settings as Record<string, string>) })))
|
||||||
.catch(() => toast.error("Couldn't load tracker display settings — showing defaults."));
|
.catch(() => toast.error("Couldn't load tracker display settings — showing defaults."));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Listen for late-attribution events fired by BillModal's single-bill sync
|
// Listen for late-attribution events fired by BillModal's single-bill sync
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handler(e) {
|
function handler(e: Event) {
|
||||||
const attrs = e.detail?.attributions;
|
const attrs = (e as CustomEvent<{ attributions?: LateAttr[] }>).detail?.attributions;
|
||||||
if (Array.isArray(attrs) && attrs.length > 0) {
|
if (Array.isArray(attrs) && attrs.length > 0) {
|
||||||
setLateAttributions(prev => [...prev, ...attrs]);
|
setLateAttributions(prev => [...prev, ...attrs]);
|
||||||
}
|
}
|
||||||
|
|
@ -301,7 +364,7 @@ export default function TrackerPage() {
|
||||||
return () => window.removeEventListener('tracker:late-attributions', handler);
|
return () => window.removeEventListener('tracker:late-attributions', handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function adjacentMonth(delta) {
|
function adjacentMonth(delta: number) {
|
||||||
let nm = month + delta;
|
let nm = month + delta;
|
||||||
let ny = year;
|
let ny = year;
|
||||||
if (nm > 12) { ny += 1; nm = 1; }
|
if (nm > 12) { ny += 1; nm = 1; }
|
||||||
|
|
@ -309,18 +372,18 @@ export default function TrackerPage() {
|
||||||
return { year: ny, month: nm };
|
return { year: ny, month: nm };
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigate(delta) {
|
function navigate(delta: number) {
|
||||||
updateParams(adjacentMonth(delta));
|
updateParams(adjacentMonth(delta));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefetch the adjacent month on hover/focus so clicking prev/next is instant.
|
// Prefetch the adjacent month on hover/focus so clicking prev/next is instant.
|
||||||
const prefetchTracker = usePrefetchTracker();
|
const prefetchTracker = usePrefetchTracker();
|
||||||
function prefetchAdjacent(delta) {
|
function prefetchAdjacent(delta: number) {
|
||||||
const { year: ny, month: nm } = adjacentMonth(delta);
|
const { year: ny, month: nm } = adjacentMonth(delta);
|
||||||
prefetchTracker(ny, nm);
|
prefetchTracker(ny, nm);
|
||||||
}
|
}
|
||||||
|
|
||||||
function bankSyncMessage(result) {
|
function bankSyncMessage(result: SyncResult): string {
|
||||||
const matched = result.auto_matched ?? 0;
|
const matched = result.auto_matched ?? 0;
|
||||||
const newTx = result.transactions_new ?? 0;
|
const newTx = result.transactions_new ?? 0;
|
||||||
const billNames = result.matched_bills ?? [];
|
const billNames = result.matched_bills ?? [];
|
||||||
|
|
@ -340,13 +403,13 @@ export default function TrackerPage() {
|
||||||
const promise = api.syncAllSources();
|
const promise = api.syncAllSources();
|
||||||
toast.promise(promise, {
|
toast.promise(promise, {
|
||||||
loading: 'Syncing bank…',
|
loading: 'Syncing bank…',
|
||||||
success: bankSyncMessage,
|
success: (result: unknown) => bankSyncMessage(result as SyncResult),
|
||||||
error: (err) => err.message || 'Bank sync failed',
|
error: (err: unknown) => (err as { message?: string })?.message || 'Bank sync failed',
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const result = await promise;
|
const result = await promise as SyncResult;
|
||||||
// Surface late-attribution prompts (payments that just crossed a month boundary)
|
// Surface late-attribution prompts (payments that just crossed a month boundary)
|
||||||
if ((result.late_attributions ?? []).length > 0) setLateAttributions(result.late_attributions);
|
if ((result.late_attributions ?? []).length > 0) setLateAttributions(result.late_attributions ?? []);
|
||||||
invalidateData();
|
invalidateData();
|
||||||
} catch {
|
} catch {
|
||||||
// toast.promise already surfaced the error
|
// toast.promise already surfaced the error
|
||||||
|
|
@ -368,24 +431,24 @@ export default function TrackerPage() {
|
||||||
bankSyncStatus?.has_connections &&
|
bankSyncStatus?.has_connections &&
|
||||||
bankSyncStatus?.has_merchant_rules;
|
bankSyncStatus?.has_merchant_rules;
|
||||||
|
|
||||||
async function handleOpenEditBill(row) {
|
async function handleOpenEditBill(row: TrackerRow) {
|
||||||
try {
|
try {
|
||||||
const [bill, categories] = await Promise.all([
|
const [bill, categories] = await Promise.all([
|
||||||
api.bill(row.id),
|
api.bill(row.id) as Promise<Bill>,
|
||||||
api.categories(),
|
api.categories() as Promise<Category[]>,
|
||||||
]);
|
]);
|
||||||
setEditBillData({ bill, categories });
|
setEditBillData({ bill, categories });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message);
|
toast.error((err as { message?: string })?.message || 'Failed to open bill');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleOpenAddBill() {
|
async function handleOpenAddBill() {
|
||||||
try {
|
try {
|
||||||
const categories = await api.categories();
|
const categories = await api.categories() as Category[];
|
||||||
setEditBillData({ bill: null, categories });
|
setEditBillData({ bill: null, categories });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to open bill editor');
|
toast.error((err as { message?: string })?.message || 'Failed to open bill editor');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -395,7 +458,7 @@ export default function TrackerPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = useMemo(() => orderedRows || data?.rows || [], [orderedRows, data]);
|
const rows = useMemo(() => orderedRows || data?.rows || [], [orderedRows, data]);
|
||||||
const summary = data?.summary || {};
|
const summary = (data?.summary ?? {}) as TrackerSummary;
|
||||||
const bankTracking = data?.bank_tracking;
|
const bankTracking = data?.bank_tracking;
|
||||||
const cashflow = data?.cashflow;
|
const cashflow = data?.cashflow;
|
||||||
const today = localDateString();
|
const today = localDateString();
|
||||||
|
|
@ -414,17 +477,17 @@ export default function TrackerPage() {
|
||||||
[trackerSettings.tracker_table_columns]
|
[trackerSettings.tracker_table_columns]
|
||||||
);
|
);
|
||||||
|
|
||||||
async function saveTrackerSettings(patch, successMessage) {
|
async function saveTrackerSettings(patch: Record<string, string>, successMessage?: string) {
|
||||||
setSavingTrackerSetting(true);
|
setSavingTrackerSetting(true);
|
||||||
setTrackerSettings(prev => ({ ...prev, ...patch }));
|
setTrackerSettings(prev => ({ ...prev, ...patch }));
|
||||||
try {
|
try {
|
||||||
const next = await api.saveSettings(patch);
|
const next = await api.saveSettings(patch) as Record<string, string>;
|
||||||
setTrackerSettings(prev => ({ ...prev, ...next }));
|
setTrackerSettings(prev => ({ ...prev, ...next }));
|
||||||
if (successMessage) toast.success(successMessage);
|
if (successMessage) toast.success(successMessage);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to update tracker setting');
|
toast.error((err as { message?: string })?.message || 'Failed to update tracker setting');
|
||||||
api.settings()
|
api.settings()
|
||||||
.then(settings => setTrackerSettings(prev => ({ ...prev, ...settings })))
|
.then(settings => setTrackerSettings(prev => ({ ...prev, ...(settings as Record<string, string>) })))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
} finally {
|
} finally {
|
||||||
setSavingTrackerSetting(false);
|
setSavingTrackerSetting(false);
|
||||||
|
|
@ -446,7 +509,7 @@ export default function TrackerPage() {
|
||||||
}, 'Bank projection banner hidden. You can turn it back on in Settings.');
|
}, 'Bank projection banner hidden. You can turn it back on in Settings.');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTableColumnToggle(columnKey, checked) {
|
function handleTableColumnToggle(columnKey: string, checked: boolean) {
|
||||||
const nextColumns = checked
|
const nextColumns = checked
|
||||||
? [...visibleTableColumns, columnKey]
|
? [...visibleTableColumns, columnKey]
|
||||||
: visibleTableColumns.filter(key => key !== columnKey);
|
: visibleTableColumns.filter(key => key !== columnKey);
|
||||||
|
|
@ -463,18 +526,15 @@ export default function TrackerPage() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSearchSortPanel() {
|
const toggleFilter = (key: BoolFilterKey) => {
|
||||||
saveTrackerSettings({ tracker_show_search_sort: 'true' });
|
const paramMap: Record<BoolFilterKey, string> = { autopay: 'ap', firstBucket: 'b1', fifteenthBucket: 'b2', unpaid: 'un', overdue: 'ov', debt: 'de' };
|
||||||
}
|
|
||||||
const toggleFilter = (key) => {
|
|
||||||
const paramMap = { autopay: 'ap', firstBucket: 'b1', fifteenthBucket: 'b2', unpaid: 'un', overdue: 'ov', debt: 'de' };
|
|
||||||
updateParams({ [paramMap[key]]: !filters[key] });
|
updateParams({ [paramMap[key]]: !filters[key] });
|
||||||
};
|
};
|
||||||
const setFilterValue = (key, value) => {
|
const setFilterValue = (key: 'category' | 'cycle', value: string) => {
|
||||||
const paramMap = { category: 'fc', cycle: 'cy' };
|
const paramMap: Record<'category' | 'cycle', string> = { category: 'fc', cycle: 'cy' };
|
||||||
updateParams({ [paramMap[key]]: value === FILTER_ALL ? null : value });
|
updateParams({ [paramMap[key]]: value === FILTER_ALL ? null : value });
|
||||||
};
|
};
|
||||||
const setSort = (key) => {
|
const setSort = (key: string) => {
|
||||||
const normalizedKey = normalizeTrackerSortKey(key);
|
const normalizedKey = normalizeTrackerSortKey(key);
|
||||||
if (normalizedKey === TRACKER_SORT_DEFAULT) {
|
if (normalizedKey === TRACKER_SORT_DEFAULT) {
|
||||||
updateParams({ sort: null, dir: null });
|
updateParams({ sort: null, dir: null });
|
||||||
|
|
@ -489,7 +549,7 @@ export default function TrackerPage() {
|
||||||
if (!hasSort) return;
|
if (!hasSort) return;
|
||||||
updateParams({ dir: sortDir === TRACKER_SORT_ASC ? TRACKER_SORT_DESC : TRACKER_SORT_ASC });
|
updateParams({ dir: sortDir === TRACKER_SORT_ASC ? TRACKER_SORT_DESC : TRACKER_SORT_ASC });
|
||||||
};
|
};
|
||||||
const handleSortHeader = (key) => {
|
const handleSortHeader = (key: string) => {
|
||||||
const normalizedKey = normalizeTrackerSortKey(key);
|
const normalizedKey = normalizeTrackerSortKey(key);
|
||||||
if (normalizedKey === TRACKER_SORT_DEFAULT) return;
|
if (normalizedKey === TRACKER_SORT_DEFAULT) return;
|
||||||
if (sortKey === normalizedKey) {
|
if (sortKey === normalizedKey) {
|
||||||
|
|
@ -513,12 +573,12 @@ export default function TrackerPage() {
|
||||||
|| filters.debt
|
|| filters.debt
|
||||||
|| hasSort
|
|| hasSort
|
||||||
);
|
);
|
||||||
const searchSortLabel = hasSort ? `sorted by ${TRACKER_SORT_LABELS[sortKey]} ${sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}` : null;
|
const searchSortLabel = hasSort ? `sorted by ${TRACKER_SORT_LABELS[sortKey]} ${sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}` : undefined;
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
updateParams({ q: null, fc: null, cy: null, ap: null, b1: null, b2: null, un: null, ov: null, de: null, sort: null, dir: null });
|
updateParams({ q: null, fc: null, cy: null, ap: null, b1: null, b2: null, un: null, ov: null, de: null, sort: null, dir: null });
|
||||||
};
|
};
|
||||||
const categoryOptions = useMemo(() => {
|
const categoryOptions = useMemo(() => {
|
||||||
const map = new Map();
|
const map = new Map<string, string>();
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
if (row.category_id && row.category_name) map.set(String(row.category_id), row.category_name);
|
if (row.category_id && row.category_name) map.set(String(row.category_id), row.category_name);
|
||||||
});
|
});
|
||||||
|
|
@ -557,7 +617,7 @@ export default function TrackerPage() {
|
||||||
}, [filters, rows, search]);
|
}, [filters, rows, search]);
|
||||||
// When pin-upcoming is on, sort by urgency so overdue/due-soon bills surface
|
// 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.
|
// at the top of each bucket. Bucket split runs after so each bucket is sorted independently.
|
||||||
const URGENCY_ORDER = { missed: 0, late: 1, due_soon: 2, upcoming: 3 };
|
const URGENCY_ORDER: Record<string, number> = { missed: 0, late: 1, due_soon: 2, upcoming: 3 };
|
||||||
const sortedRows = hasSort
|
const sortedRows = hasSort
|
||||||
? sortTrackerRows(filteredRows, sortKey, sortDir)
|
? sortTrackerRows(filteredRows, sortKey, sortDir)
|
||||||
: pinUpcoming
|
: pinUpcoming
|
||||||
|
|
@ -575,7 +635,7 @@ export default function TrackerPage() {
|
||||||
const second = sortedRows.filter(r => r.bucket === '15th');
|
const second = sortedRows.filter(r => r.bucket === '15th');
|
||||||
const reorderEnabled = !hasFilters && !loading && !isError && !pinUpcoming;
|
const reorderEnabled = !hasFilters && !loading && !isError && !pinUpcoming;
|
||||||
|
|
||||||
async function persistTrackerOrder(nextRows, movedBillId) {
|
async function persistTrackerOrder(nextRows: TrackerRow[], movedBillId: number | null) {
|
||||||
const payload = Object.fromEntries(nextRows.map((row, index) => [row.id, index]));
|
const payload = Object.fromEntries(nextRows.map((row, index) => [row.id, index]));
|
||||||
setOrderedRows(nextRows);
|
setOrderedRows(nextRows);
|
||||||
setMovingBillId(movedBillId);
|
setMovingBillId(movedBillId);
|
||||||
|
|
@ -585,21 +645,22 @@ export default function TrackerPage() {
|
||||||
invalidateData();
|
invalidateData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setOrderedRows(null);
|
setOrderedRows(null);
|
||||||
toast.error(err.message || 'Failed to save bill order');
|
toast.error((err as { message?: string })?.message || 'Failed to save bill order');
|
||||||
} finally {
|
} finally {
|
||||||
setMovingBillId(null);
|
setMovingBillId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleReorderBucket(bucket, orderedBucketRows) {
|
function handleReorderBucket(bucket: string, orderedBucketRows: TrackerRow[]) {
|
||||||
const sourceRows = rows;
|
const sourceRows = rows;
|
||||||
const nextRows = [...sourceRows];
|
const nextRows = [...sourceRows];
|
||||||
const replacement = [...orderedBucketRows];
|
const replacement = [...orderedBucketRows];
|
||||||
for (let i = 0; i < nextRows.length; i += 1) {
|
for (let i = 0; i < nextRows.length; i += 1) {
|
||||||
if (nextRows[i].bucket === bucket) nextRows[i] = replacement.shift();
|
const cur = nextRows[i];
|
||||||
|
if (cur && cur.bucket === bucket) nextRows[i] = replacement.shift()!;
|
||||||
}
|
}
|
||||||
const moved = orderedBucketRows.find((row, index) => row.id !== (sourceRows.filter(item => item.bucket === bucket)[index]?.id));
|
const moved = orderedBucketRows.find((row, index) => row.id !== (sourceRows.filter(item => item.bucket === bucket)[index]?.id));
|
||||||
persistTrackerOrder(nextRows, moved?.id || orderedBucketRows[0]?.id);
|
persistTrackerOrder(nextRows, moved?.id || orderedBucketRows[0]?.id || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -804,7 +865,7 @@ export default function TrackerPage() {
|
||||||
<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" />
|
<Skeleton variant="card" className="h-32" />
|
||||||
{summary.trend && <Skeleton variant="card" className="h-32" />}
|
{summary.trend ? <Skeleton variant="card" className="h-32" /> : null}
|
||||||
</div>
|
</div>
|
||||||
) : showSummaryCards ? (
|
) : showSummaryCards ? (
|
||||||
<div className="grid grid-cols-2 gap-3 lg:flex">
|
<div className="grid grid-cols-2 gap-3 lg:flex">
|
||||||
|
|
@ -866,7 +927,7 @@ export default function TrackerPage() {
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
type="paid"
|
type="paid"
|
||||||
value={summary.total_paid}
|
value={summary.total_paid}
|
||||||
hint={summary.previous_month_total > 0 ? `Last month: ${fmt(summary.previous_month_total)}` : undefined}
|
hint={Number(summary.previous_month_total ?? 0) > 0 ? `Last month: ${fmt(summary.previous_month_total)}` : undefined}
|
||||||
/>
|
/>
|
||||||
{/* In bank mode the hero card already surfaces the projected remaining,
|
{/* In bank mode the hero card already surfaces the projected remaining,
|
||||||
so only show the Remaining card when there's no bank hero. */}
|
so only show the Remaining card when there's no bank hero. */}
|
||||||
|
|
@ -878,12 +939,12 @@ export default function TrackerPage() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<SummaryCard type="overdue" value={summary.overdue} />
|
<SummaryCard type="overdue" value={summary.overdue} />
|
||||||
{summary.trend && <TrendCard trend={summary.trend} />}
|
{summary.trend ? <TrendCard trend={summary.trend as Parameters<typeof TrendCard>[0]['trend']} /> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Compact month-progress line — quick "how far through the month am I?" */}
|
{/* Compact month-progress line — quick "how far through the month am I?" */}
|
||||||
{showSummaryCards && !loading && !isError && summary?.total_expected > 0 && (() => {
|
{showSummaryCards && !loading && !isError && Number(summary?.total_expected ?? 0) > 0 && (() => {
|
||||||
const billsLeft = (summary.count_upcoming ?? 0) + (summary.count_late ?? 0);
|
const billsLeft = (summary.count_upcoming ?? 0) + (summary.count_late ?? 0);
|
||||||
return (
|
return (
|
||||||
<p className="px-1 -mt-1 text-[11px] text-muted-foreground">
|
<p className="px-1 -mt-1 text-[11px] text-muted-foreground">
|
||||||
|
|
@ -928,7 +989,7 @@ export default function TrackerPage() {
|
||||||
{/* ── Drift / Price-Change Insights ── */}
|
{/* ── Drift / Price-Change Insights ── */}
|
||||||
{!isError && !loading && showDriftInsights && (driftData?.bills?.length ?? 0) > 0 && (
|
{!isError && !loading && showDriftInsights && (driftData?.bills?.length ?? 0) > 0 && (
|
||||||
<DriftInsightPanel
|
<DriftInsightPanel
|
||||||
driftBills={driftData.bills}
|
driftBills={driftData?.bills || []}
|
||||||
refresh={invalidateData}
|
refresh={invalidateData}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -954,7 +1015,7 @@ export default function TrackerPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Empty state ── */}
|
{/* ── Empty state ── */}
|
||||||
{!isError && rows.length === 0 && data !== null && (
|
{!isError && rows.length === 0 && data != null && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-center rounded-xl border border-border bg-muted/20">
|
<div className="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">
|
<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" />
|
<CheckCircle2 className="h-5 w-5 text-muted-foreground" />
|
||||||
|
|
@ -1017,7 +1078,7 @@ export default function TrackerPage() {
|
||||||
onSave={() => invalidateData()}
|
onSave={() => invalidateData()}
|
||||||
onDuplicate={bill => setEditBillData({
|
onDuplicate={bill => setEditBillData({
|
||||||
bill: null,
|
bill: null,
|
||||||
initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }),
|
initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }) as Partial<Bill>,
|
||||||
categories: editBillData.categories,
|
categories: editBillData.categories,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1053,12 +1114,12 @@ export default function TrackerPage() {
|
||||||
setAttrBusy(attr.payment_id);
|
setAttrBusy(attr.payment_id);
|
||||||
try {
|
try {
|
||||||
await api.attributePaymentToMonth(attr.payment_id, attr.suggested_date);
|
await api.attributePaymentToMonth(attr.payment_id, attr.suggested_date);
|
||||||
const month = new Date(attr.suggested_date + 'T00:00:00').toLocaleDateString('en-US', { month: 'long' });
|
const monthName = new Date(attr.suggested_date + 'T00:00:00').toLocaleDateString('en-US', { month: 'long' });
|
||||||
toast.success(`${attr.bill_name} payment moved to ${month}`);
|
toast.success(`${attr.bill_name} payment moved to ${monthName}`);
|
||||||
setLateAttributions(prev => prev.slice(1)); // dismiss only on success
|
setLateAttributions(prev => prev.slice(1)); // dismiss only on success
|
||||||
invalidateData();
|
invalidateData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to reclassify payment — try again');
|
toast.error((err as { message?: string })?.message || 'Failed to reclassify payment — try again');
|
||||||
// keep the attribution in queue so user can retry
|
// keep the attribution in queue so user can retry
|
||||||
} finally {
|
} finally {
|
||||||
setAttrBusy(null);
|
setAttrBusy(null);
|
||||||
|
|
@ -1074,7 +1135,7 @@ export default function TrackerPage() {
|
||||||
row={commandCenterPayRow}
|
row={commandCenterPayRow}
|
||||||
year={year}
|
year={year}
|
||||||
month={month}
|
month={month}
|
||||||
threshold={commandCenterPayRow.actual_amount ?? commandCenterPayRow.expected_amount}
|
threshold={(commandCenterPayRow.actual_amount ?? commandCenterPayRow.expected_amount) as Dollars}
|
||||||
defaultPaymentDate={paymentDateForTrackerMonth(year, month, commandCenterPayRow.due_day)}
|
defaultPaymentDate={paymentDateForTrackerMonth(year, month, commandCenterPayRow.due_day)}
|
||||||
onClose={() => setCommandCenterPayRow(null)}
|
onClose={() => setCommandCenterPayRow(null)}
|
||||||
onSaved={() => { setCommandCenterPayRow(null); invalidateData(); }}
|
onSaved={() => { setCommandCenterPayRow(null); invalidateData(); }}
|
||||||
Loading…
Reference in New Issue