BillTracker/client/pages/TrackerPage.jsx

486 lines
21 KiB
React
Raw Normal View History

2026-05-31 15:06:10 -05:00
import { useState, useEffect, useMemo, useCallback } from 'react';
2026-05-16 15:38:28 -05:00
import { useSearchParams } from 'react-router-dom';
2026-05-31 15:06:10 -05:00
import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, X, RefreshCw } from 'lucide-react';
2026-05-03 19:51:57 -05:00
import { toast } from 'sonner';
import { api } from '@/api.js';
import { useTracker, useDriftReport } from '@/hooks/useQueries';
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';
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
2026-05-31 15:06:10 -05:00
import { cn, fmt } from '@/lib/utils';
2026-05-03 19:51:57 -05:00
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/Skeleton';
2026-05-03 19:51:57 -05:00
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog';
import OverdueCommandCenter from '@/components/tracker/OverdueCommandCenter';
import DriftInsightPanel from '@/components/tracker/DriftInsightPanel';
2026-05-31 15:06:10 -05:00
import {
MONTHS, FILTER_ALL,
paymentDateForTrackerMonth, amountSearchText, rowEffectiveStatus, rowIsPaid, rowIsDebt,
} from '@/lib/trackerUtils';
import { FilterChip } from '@/components/tracker/FilterChip';
import { SummaryCard, TrendCard } from '@/components/tracker/SummaryCards';
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
import { TrackerBucket as Bucket } from '@/components/tracker/TrackerBucket';
2026-05-03 19:51:57 -05:00
2026-05-31 15:06:10 -05:00
// ── Main page ──────────────────────────────────────────────────────────────
export default function TrackerPage() {
const [searchParams, setSearchParams] = useSearchParams();
2026-05-03 19:51:57 -05:00
const now = new Date();
2026-05-31 15:06:10 -05:00
// All navigation + filter state lives in the URL so views are bookmarkable/shareable.
const year = Number(searchParams.get('year')) || now.getFullYear();
const month = Number(searchParams.get('month')) || (now.getMonth() + 1);
const search = searchParams.get('q') || '';
const filters = {
category: searchParams.get('fc') || FILTER_ALL,
cycle: searchParams.get('cy') || FILTER_ALL,
autopay: searchParams.get('ap') === '1',
firstBucket: searchParams.get('b1') === '1',
fifteenthBucket: searchParams.get('b2') === '1',
unpaid: searchParams.get('un') === '1',
overdue: searchParams.get('ov') === '1',
debt: searchParams.get('de') === '1',
2026-05-16 15:38:28 -05:00
};
2026-05-03 19:51:57 -05:00
2026-05-31 15:06:10 -05:00
// replace: true keeps history clean for rapid navigation (e.g. search keystrokes)
const updateParams = useCallback((patch) => {
setSearchParams(prev => {
const next = new URLSearchParams(prev);
Object.entries(patch).forEach(([k, v]) => {
if (v == null || v === '' || v === false) next.delete(k);
else next.set(k, v === true ? '1' : String(v));
});
2026-05-31 15:06:10 -05:00
return next;
}, { replace: true });
}, [setSearchParams]);
2026-05-03 19:51:57 -05:00
// Edit Bill modal: { bill, categories } when open, null when closed
const [editBillData, setEditBillData] = useState(null);
2026-05-04 20:12:57 -05:00
// Edit Starting Amounts modal: true when open, false when closed
const [editStartingOpen, setEditStartingOpen] = useState(false);
2026-05-30 16:13:37 -05:00
const [orderedRows, setOrderedRows] = useState(null);
const [movingBillId, setMovingBillId] = useState(null);
2026-05-03 19:51:57 -05:00
// Row to open in PaymentLedgerDialog via the overdue command center
const [commandCenterPayRow, setCommandCenterPayRow] = useState(null);
// 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);
const { data: driftData, refetch: refetchDrift } = useDriftReport();
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-05-03 19:51:57 -05:00
function navigate(delta) {
2026-05-31 15:06:10 -05:00
let nm = month + delta;
let ny = year;
if (nm > 12) { ny += 1; nm = 1; }
if (nm < 1) { ny -= 1; nm = 12; }
updateParams({ year: ny, month: nm });
2026-05-03 19:51:57 -05:00
}
async function handleOpenEditBill(row) {
try {
const [bill, categories] = await Promise.all([
api.bill(row.id),
api.categories(),
]);
setEditBillData({ bill, categories });
} catch (err) {
toast.error(err.message);
}
}
2026-05-31 15:06:10 -05:00
async function handleOpenAddBill() {
try {
const categories = await api.categories();
setEditBillData({ bill: null, categories });
} catch (err) {
toast.error(err.message || 'Failed to open bill editor');
}
}
2026-05-03 19:51:57 -05:00
function goToday() {
const n = new Date();
2026-05-31 15:06:10 -05:00
updateParams({ year: n.getFullYear(), month: n.getMonth() + 1 });
2026-05-03 19:51:57 -05:00
}
2026-05-30 16:13:37 -05:00
const rows = orderedRows || data?.rows || [];
2026-05-03 19:51:57 -05:00
const summary = data?.summary || {};
2026-05-31 15:06:10 -05:00
const toggleFilter = (key) => {
const paramMap = { autopay: 'ap', firstBucket: 'b1', fifteenthBucket: 'b2', unpaid: 'un', overdue: 'ov', debt: 'de' };
updateParams({ [paramMap[key]]: !filters[key] });
};
const setFilterValue = (key, value) => {
const paramMap = { category: 'fc', cycle: 'cy' };
updateParams({ [paramMap[key]]: value === FILTER_ALL ? null : value });
};
2026-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
);
const resetFilters = () => {
2026-05-31 15:06:10 -05:00
updateParams({ q: null, fc: null, cy: null, ap: null, b1: null, b2: null, un: null, ov: null, de: null });
2026-05-16 15:38:28 -05:00
};
const categoryOptions = useMemo(() => {
const map = new Map();
rows.forEach(row => {
if (row.category_id && row.category_name) map.set(String(row.category_id), row.category_name);
});
return Array.from(map, ([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name));
}, [rows]);
const cycleOptions = useMemo(() => (
Array.from(new Set(rows.map(scheduleValue))).sort()
2026-05-16 15:38:28 -05:00
), [rows]);
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase();
return rows.filter(row => {
const effectiveStatus = rowEffectiveStatus(row);
if (filters.category !== FILTER_ALL && String(row.category_id ?? '') !== filters.category) return false;
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,
scheduleValue(row),
scheduleLabel(row),
2026-05-16 15:38:28 -05:00
row.bucket,
row.status,
amountSearchText(row.expected_amount, row.actual_amount, row.total_paid, row.balance, row.current_balance, row.minimum_payment),
].filter(Boolean).join(' ').toLowerCase();
return haystack.includes(q);
});
}, [filters, rows, search]);
const first = filteredRows.filter(r => r.bucket === '1st');
const second = filteredRows.filter(r => r.bucket === '15th');
2026-05-30 16:13:37 -05:00
const reorderEnabled = !hasFilters && !loading && !isError;
async function persistTrackerOrder(nextRows, movedBillId) {
const payload = Object.fromEntries(nextRows.map((row, index) => [row.id, index]));
setOrderedRows(nextRows);
setMovingBillId(movedBillId);
try {
await api.reorderBills(payload);
toast.success('Bill order saved');
refetch();
} catch (err) {
setOrderedRows(null);
toast.error(err.message || 'Failed to save bill order');
} finally {
setMovingBillId(null);
}
}
function handleReorderBucket(bucket, orderedBucketRows) {
const sourceRows = rows;
const nextRows = [...sourceRows];
const replacement = [...orderedBucketRows];
for (let i = 0; i < nextRows.length; i += 1) {
if (nextRows[i].bucket === bucket) nextRows[i] = replacement.shift();
}
const moved = orderedBucketRows.find((row, index) => row.id !== (sourceRows.filter(item => item.bucket === bucket)[index]?.id));
persistTrackerOrder(nextRows, moved?.id || orderedBucketRows[0]?.id);
}
2026-05-03 19:51:57 -05:00
return (
<div className="space-y-5">
{/* ── Header ── */}
2026-05-04 13:14:32 -05:00
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
2026-05-03 19:51:57 -05:00
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground mb-0.5">
Monthly Overview
</p>
<h1 className="text-2xl font-bold tracking-tight">
{MONTHS[month - 1]}
<span className="text-muted-foreground font-normal ml-2 text-xl">{year}</span>
</h1>
<p className="text-xs text-muted-foreground mt-0.5">
{rows.length} {rows.length === 1 ? 'bill' : 'bills'}
</p>
</div>
2026-05-31 15:06:10 -05:00
<div className="flex flex-wrap items-center gap-2">
2026-05-03 19:51:57 -05:00
<Button
2026-05-31 15:06:10 -05:00
size="sm"
onClick={handleOpenAddBill}
className="h-9 gap-1.5 px-3 shadow-sm"
aria-label="Add bill"
title="Add bill"
2026-05-03 19:51:57 -05:00
>
2026-05-31 15:06:10 -05:00
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Add Bill</span>
2026-05-03 19:51:57 -05:00
</Button>
2026-05-31 15:06:10 -05:00
<div className="flex items-center gap-1 bg-muted/50 border border-border rounded-lg p-1">
<Button
size="icon" variant="ghost"
onClick={() => navigate(-1)}
className="h-7 w-7 hover:bg-white/5"
aria-label="Previous month"
2026-05-31 15:06:10 -05:00
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="min-w-[7.5rem] px-1 text-center text-xs font-semibold tabular-nums select-none">
{MONTHS[month - 1]} {year}
</span>
2026-05-31 15:06:10 -05:00
<Button
size="icon" variant="ghost"
onClick={() => navigate(1)}
className="h-7 w-7 hover:bg-white/5"
aria-label="Next month"
2026-05-31 15:06:10 -05:00
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<Button
size="sm" variant="outline"
onClick={goToday}
className="h-9 px-3 text-xs"
>
Today
</Button>
2026-05-03 19:51:57 -05:00
</div>
</div>
2026-05-28 19:58:01 -05:00
<div className="rounded-xl border border-border/80 bg-card/95 p-4 shadow-sm shadow-black/15 space-y-3">
2026-05-16 15:38:28 -05:00
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_220px_180px_auto] lg:items-center">
<label className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
2026-05-31 15:06:10 -05:00
onChange={e => updateParams({ q: e.target.value || null })}
2026-05-16 15:38:28 -05:00
placeholder="Search this month by bill, category, notes, or amount"
className="h-10 pl-9"
/>
</label>
<Select value={filters.category} onValueChange={value => setFilterValue('category', value)}>
<SelectTrigger className="h-10">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_ALL}>All categories</SelectItem>
{categoryOptions.map(category => (
<SelectItem key={category.id} value={category.id}>{category.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filters.cycle} onValueChange={value => setFilterValue('cycle', value)}>
<SelectTrigger className="h-10 capitalize">
<SelectValue placeholder="Billing schedule" />
2026-05-16 15:38:28 -05:00
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_ALL}>All cycles</SelectItem>
{cycleOptions.map(cycle => (
<SelectItem key={cycle} value={cycle}>{scheduleLabel(cycle)}</SelectItem>
2026-05-16 15:38:28 -05:00
))}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
disabled={!hasFilters}
onClick={resetFilters}
className="h-10 justify-center gap-2 text-xs"
>
<X className="h-3.5 w-3.5" />
Clear
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<FilterChip active={filters.unpaid} onClick={() => toggleFilter('unpaid')}>Unpaid</FilterChip>
<FilterChip active={filters.overdue} onClick={() => toggleFilter('overdue')}>Overdue</FilterChip>
<FilterChip active={filters.autopay} onClick={() => toggleFilter('autopay')}>Autopay</FilterChip>
<FilterChip active={filters.firstBucket} onClick={() => toggleFilter('firstBucket')}>1st bucket</FilterChip>
<FilterChip active={filters.fifteenthBucket} onClick={() => toggleFilter('fifteenthBucket')}>15th bucket</FilterChip>
<FilterChip active={filters.debt} onClick={() => toggleFilter('debt')}>Debt</FilterChip>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{filteredRows.length} of {rows.length} shown
</span>
</div>
</div>
2026-05-03 19:51:57 -05:00
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
{loading ? (
<div className="grid grid-cols-2 gap-3 lg:flex" aria-busy="true">
<Skeleton variant="card" className="h-32" />
<Skeleton variant="card" className="h-32" />
<Skeleton variant="card" className="h-32" />
<Skeleton variant="card" className="h-32" />
<Skeleton variant="card" className="h-32" />
{summary.trend && <Skeleton variant="card" className="h-32" />}
</div>
) : (
<div className="grid grid-cols-2 gap-3 lg:flex">
<SummaryCard
type="starting"
value={summary.total_starting}
hint={!summary.has_starting_amounts ? 'Set monthly starting cash' : ''}
onEdit={() => setEditStartingOpen(true)}
/>
<SummaryCard type="paid" value={summary.total_paid} />
<SummaryCard type="overdue" value={summary.overdue} />
<SummaryCard type="paid" value={summary.previous_month_total} hint="Previous month"/>
{summary.trend && <TrendCard trend={summary.trend} />}
</div>
)}
2026-05-03 19:51:57 -05:00
{/* ── Overdue Command Center ── */}
{!isError && !loading && (summary?.count_late ?? 0) > 0 && (
<OverdueCommandCenter
rows={rows}
year={year}
month={month}
refresh={refetch}
onPayNow={(row) => setCommandCenterPayRow(row)}
/>
)}
{/* ── Drift / Price-Change Insights ── */}
{!isError && !loading && (driftData?.bills?.length ?? 0) > 0 && (
<DriftInsightPanel
driftBills={driftData.bills}
refresh={() => { refetch(); refetchDrift(); }}
/>
)}
2026-05-28 02:34:24 -05:00
{/* ── Fetch error state ── */}
{isError && (
<div className="flex flex-col items-center justify-center py-20 text-center rounded-xl border border-destructive/20 bg-destructive/5">
<div className="h-10 w-10 rounded-full bg-destructive/10 flex items-center justify-center mb-3">
<AlertCircle className="h-5 w-5 text-destructive" />
</div>
<p className="text-sm font-medium text-foreground">Failed to load tracker data</p>
<p className="mt-1 text-xs text-muted-foreground">{error?.message || 'An unexpected error occurred.'}</p>
<Button
size="sm"
variant="outline"
onClick={() => refetch()}
className="mt-4 gap-1.5 text-xs border-destructive/30 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/50"
>
<RefreshCw className="h-3 w-3" />
Try again
</Button>
</div>
)}
2026-05-03 19:51:57 -05:00
{/* ── Empty state ── */}
2026-05-28 02:34:24 -05:00
{!isError && rows.length === 0 && data !== null && (
2026-05-03 19:51:57 -05:00
<div className="flex flex-col items-center justify-center py-20 text-center rounded-xl border border-border bg-muted/20">
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center mb-3">
<CheckCircle2 className="h-5 w-5 text-muted-foreground" />
</div>
<p className="text-sm font-medium text-muted-foreground">No bills this month</p>
<a href="/bills" className="mt-1.5 text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground transition-colors">
Add a bill
</a>
</div>
)}
{/* ── Buckets — year and month passed so Row can open the correct monthly state dialog ── */}
2026-05-28 02:34:24 -05:00
{!isError && loading && (
<div className="space-y-5" aria-busy="true">
<div className="rounded-xl border border-border overflow-hidden bg-card p-4">
<div className="flex items-center justify-between mb-4">
<div className="h-4 w-32 rounded-md bg-muted" />
<div className="h-4 w-16 rounded-md bg-muted" />
</div>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 animate-pulse">
<div className="h-1.5 w-1.5 rounded-full bg-muted" />
<div className="h-4 w-64 rounded-md bg-muted" />
</div>
))}
</div>
</div>
<div className="rounded-xl border border-border overflow-hidden bg-card p-4">
<div className="flex items-center justify-between mb-4">
<div className="h-4 w-32 rounded-md bg-muted" />
<div className="h-4 w-16 rounded-md bg-muted" />
</div>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 animate-pulse">
<div className="h-1.5 w-1.5 rounded-full bg-muted" />
<div className="h-4 w-64 rounded-md bg-muted" />
</div>
))}
</div>
</div>
</div>
)}
{!isError && (first.length > 0 || second.length > 0) && (
<div className="space-y-5">
2026-05-30 16:13:37 -05:00
{first.length > 0 && <Bucket label="1st 14th" rows={first} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} onReorderRows={(next) => handleReorderBucket('1st', next)} />}
{second.length > 0 && <Bucket label="15th 31st" rows={second} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} onReorderRows={(next) => handleReorderBucket('15th', next)} />}
</div>
)}
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)}
onSave={() => { setEditBillData(null); refetch(); }}
2026-05-16 15:38:28 -05:00
onDuplicate={bill => setEditBillData({
bill: null,
initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }),
categories: editBillData.categories,
})}
2026-05-03 19:51:57 -05:00
/>
)}
2026-05-04 20:12:57 -05:00
{/* Edit Starting Amounts modal */}
<StartingAmountsEditDialog
open={editStartingOpen}
onClose={() => setEditStartingOpen(false)}
year={year}
month={month}
onSave={() => { setEditStartingOpen(false); refetch(); }}
2026-05-04 20:12:57 -05:00
/>
{/* PaymentLedgerDialog opened via Overdue Command Center "Pay Now" */}
{commandCenterPayRow && (
<PaymentLedgerDialog
row={commandCenterPayRow}
year={year}
month={month}
threshold={commandCenterPayRow.actual_amount ?? commandCenterPayRow.expected_amount}
defaultPaymentDate={paymentDateForTrackerMonth(year, month, commandCenterPayRow.due_day)}
onClose={() => setCommandCenterPayRow(null)}
onSaved={() => { setCommandCenterPayRow(null); refetch(); }}
/>
)}
2026-05-03 19:51:57 -05:00
</div>
);
}