fix(tracker): table columns and settings improvements

This commit is contained in:
null 2026-06-07 17:23:14 -05:00
parent 955fb96aec
commit f7ad1c1ebb
6 changed files with 687 additions and 344 deletions

View File

@ -1,11 +1,17 @@
import { useState } from 'react'; import { useState } from 'react';
import { LayoutGroup } from 'framer-motion'; import { LayoutGroup } from 'framer-motion';
import { ArrowDown, ArrowUp } from 'lucide-react'; import { ArrowDown, ArrowUp, Settings2 } from 'lucide-react';
import { cn, fmt } from '@/lib/utils'; import { cn, fmt } from '@/lib/utils';
import { import {
Table, TableHeader, TableBody, TableHead, TableRow, TableCell, Table, TableHeader, TableBody, TableHead, TableRow, TableCell,
} from '@/components/ui/table'; } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import {
DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { TRACKER_SORT_ASC, TRACKER_SORT_DEFAULT, moveInArray } from '@/lib/trackerUtils'; import { TRACKER_SORT_ASC, TRACKER_SORT_DEFAULT, moveInArray } from '@/lib/trackerUtils';
import { TRACKER_TABLE_COLUMNS, DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns';
import { TrackerRow as Row } from '@/components/tracker/TrackerRow'; import { TrackerRow as Row } from '@/components/tracker/TrackerRow';
import { MobileTrackerRow } from '@/components/tracker/MobileTrackerRow'; import { MobileTrackerRow } from '@/components/tracker/MobileTrackerRow';
@ -39,9 +45,30 @@ function SortableHead({ sortKey, activeSortKey, sortDir, onSort, children, class
); );
} }
export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, loading, onReorderRows, reorderEnabled, movingBillId, sortKey = TRACKER_SORT_DEFAULT, sortDir = TRACKER_SORT_ASC, onSort, driftedIds = new Set() }) { export function TrackerBucket({
label,
rows,
year,
month,
refresh,
onEditBill,
loading,
onReorderRows,
reorderEnabled,
movingBillId,
sortKey = TRACKER_SORT_DEFAULT,
sortDir = TRACKER_SORT_ASC,
onSort,
driftedIds = new Set(),
visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS,
onColumnToggle,
columnsSaving,
}) {
const [draggingId, setDraggingId] = useState(null); const [draggingId, setDraggingId] = useState(null);
const [dropTargetId, setDropTargetId] = useState(null); const [dropTargetId, setDropTargetId] = useState(null);
const visibleColumnSet = new Set(visibleColumns);
const showColumn = key => visibleColumnSet.has(key);
const tableColSpan = 1 + visibleColumns.length;
// Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals // Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals
const activeRows = rows.filter(r => !r.is_skipped); const activeRows = rows.filter(r => !r.is_skipped);
const totalThreshold = activeRows.reduce((s, r) => s + (r.actual_amount ?? r.expected_amount ?? 0), 0); const totalThreshold = activeRows.reduce((s, r) => s + (r.actual_amount ?? r.expected_amount ?? 0), 0);
@ -158,6 +185,37 @@ export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, l
{!reorderEnabled && rows.length > 1 && ( {!reorderEnabled && rows.length > 1 && (
<span className="hidden text-[11px] text-muted-foreground/60 xl:inline">Clear filters to reorder</span> <span className="hidden text-[11px] text-muted-foreground/60 xl:inline">Clear filters to reorder</span>
)} )}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="sm"
variant="outline"
className="hidden h-8 gap-1.5 px-2 text-[11px] lg:inline-flex"
disabled={columnsSaving}
>
<Settings2 className="h-3.5 w-3.5" />
Columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Visible columns</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem checked disabled>
Bill
</DropdownMenuCheckboxItem>
{TRACKER_TABLE_COLUMNS.map(column => (
<DropdownMenuCheckboxItem
key={column.key}
checked={showColumn(column.key)}
onSelect={event => event.preventDefault()}
onCheckedChange={checked => onColumnToggle?.(column.key, checked)}
>
{column.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
@ -212,22 +270,38 @@ export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, l
<div className="hidden lg:block" aria-busy={loading ? 'true' : 'false'}> <div className="hidden lg:block" aria-busy={loading ? 'true' : 'false'}>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="min-w-[1120px]"> <Table className={cn(visibleColumns.length <= 4 ? 'min-w-[720px]' : 'min-w-[1120px]')}>
<TableHeader> <TableHeader>
<TableRow className="border-border/80 bg-background/30 hover:bg-background/30"> <TableRow className="border-border/80 bg-background/30 hover:bg-background/30">
<SortableHead sortKey="name" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[18%]">Bill</SortableHead> <SortableHead sortKey="name" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[18%]">Bill</SortableHead>
<SortableHead sortKey="due" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%]">Due</SortableHead> {showColumn('due') && (
<SortableHead sortKey="expected" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-center">Expected</SortableHead> <SortableHead sortKey="due" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%]">Due</SortableHead>
<SortableHead sortKey="previous" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-center">Last Month</SortableHead> )}
<SortableHead sortKey="paid" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-center">Paid</SortableHead> {showColumn('expected') && (
<SortableHead sortKey="paid_date" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%]">Paid Date</SortableHead> <SortableHead sortKey="expected" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-center">Expected</SortableHead>
<SortableHead sortKey="status" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[9%] text-center">Status</SortableHead> )}
<TableHead className="h-11 w-[10%] py-2.5 text-center text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground"> {showColumn('previous') && (
Action <SortableHead sortKey="previous" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-center">Last Month</SortableHead>
</TableHead> )}
<TableHead className="h-11 w-[23%] py-2.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground border-l border-border/80 pl-4"> {showColumn('paid') && (
Notes <SortableHead sortKey="paid" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-center">Paid</SortableHead>
</TableHead> )}
{showColumn('paid_date') && (
<SortableHead sortKey="paid_date" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%]">Paid Date</SortableHead>
)}
{showColumn('status') && (
<SortableHead sortKey="status" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[9%] text-center">Status</SortableHead>
)}
{showColumn('action') && (
<TableHead className="h-11 w-[10%] py-2.5 text-center text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
Action
</TableHead>
)}
{showColumn('notes') && (
<TableHead className="h-11 w-[23%] py-2.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground border-l border-border/80 pl-4">
Notes
</TableHead>
)}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -240,26 +314,30 @@ export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, l
<div className="h-4 w-48 rounded-md bg-muted" /> <div className="h-4 w-48 rounded-md bg-muted" />
</div> </div>
</TableCell> </TableCell>
<TableCell className="w-[10%] py-3"><div className="h-3 w-20 rounded-md bg-muted" /></TableCell> {showColumn('due') && <TableCell className="w-[10%] py-3"><div className="h-3 w-20 rounded-md bg-muted" /></TableCell>}
<TableCell className="w-[10%] py-3 text-center"><div className="mx-auto h-3 w-20 rounded-md bg-muted" /></TableCell> {showColumn('expected') && <TableCell className="w-[10%] py-3 text-center"><div className="mx-auto h-3 w-20 rounded-md bg-muted" /></TableCell>}
<TableCell className="w-[10%] py-3 text-center"><div className="mx-auto h-3 w-20 rounded-md bg-muted" /></TableCell> {showColumn('previous') && <TableCell className="w-[10%] py-3 text-center"><div className="mx-auto h-3 w-20 rounded-md bg-muted" /></TableCell>}
<TableCell className="w-[10%] py-3"><div className="mx-auto h-7 w-24 rounded-md bg-muted" /></TableCell> {showColumn('paid') && <TableCell className="w-[10%] py-3"><div className="mx-auto h-7 w-24 rounded-md bg-muted" /></TableCell>}
<TableCell className="w-[10%] py-3"><div className="h-7 w-24 rounded-md bg-muted" /></TableCell> {showColumn('paid_date') && <TableCell className="w-[10%] py-3"><div className="h-7 w-24 rounded-md bg-muted" /></TableCell>}
<TableCell className="w-[9%] py-3"><div className="mx-auto h-5 w-20 rounded-md bg-muted" /></TableCell> {showColumn('status') && <TableCell className="w-[9%] py-3"><div className="mx-auto h-5 w-20 rounded-md bg-muted" /></TableCell>}
<TableCell className="w-[10%] py-3 text-center"> {showColumn('action') && (
<div className="flex items-center justify-center gap-1"> <TableCell className="w-[10%] py-3 text-center">
<div className="h-7 w-20 rounded-md bg-muted" /> <div className="flex items-center justify-center gap-1">
<div className="h-7 w-7 rounded-md bg-muted" /> <div className="h-7 w-20 rounded-md bg-muted" />
</div> <div className="h-7 w-7 rounded-md bg-muted" />
</TableCell> </div>
<TableCell className="w-[23%] py-3 border-l border-border pl-4"> </TableCell>
<div className="h-4 w-full rounded-md bg-muted" /> )}
</TableCell> {showColumn('notes') && (
<TableCell className="w-[23%] py-3 border-l border-border pl-4">
<div className="h-4 w-full rounded-md bg-muted" />
</TableCell>
)}
</TableRow> </TableRow>
)) ))
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<TableRow className="border-border/50"> <TableRow className="border-border/50">
<TableCell colSpan={9} className="py-10 text-center text-sm text-muted-foreground"> <TableCell colSpan={tableColSpan} className="py-10 text-center text-sm text-muted-foreground">
No bills match try adjusting your filters. No bills match try adjusting your filters.
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -277,6 +355,7 @@ export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, l
moveControls={moveControlsFor(r, i)} moveControls={moveControlsFor(r, i)}
dragProps={dragPropsFor(r, i)} dragProps={dragPropsFor(r, i)}
isDrifted={driftedIds.has(r.id)} isDrifted={driftedIds.has(r.id)}
visibleColumns={visibleColumns}
/> />
))} ))}
</LayoutGroup> </LayoutGroup>

View File

@ -16,13 +16,14 @@ import {
import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog'; import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
import PaymentModal from '@/components/tracker/PaymentModal'; import PaymentModal from '@/components/tracker/PaymentModal';
import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils'; import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils';
import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns';
import { StatusBadge } from '@/components/tracker/StatusBadge'; import { StatusBadge } from '@/components/tracker/StatusBadge';
import { PaymentProgress } from '@/components/tracker/PaymentProgress'; import { PaymentProgress } from '@/components/tracker/PaymentProgress';
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog'; import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
import { NotesCell } from '@/components/tracker/NotesCell'; import { NotesCell } from '@/components/tracker/NotesCell';
import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions'; import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions';
export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted }) { export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted, visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS }) {
const amountRef = useRef(null); const amountRef = useRef(null);
const [editPayment, setEditPayment] = useState(null); const [editPayment, setEditPayment] = useState(null);
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false); const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
@ -34,6 +35,8 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
const [showUpdateNudge, setShowUpdateNudge] = useState(false); const [showUpdateNudge, setShowUpdateNudge] = useState(false);
const [nudgeAmount, setNudgeAmount] = useState(null); const [nudgeAmount, setNudgeAmount] = useState(null);
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const visibleColumnSet = new Set(visibleColumns);
const showColumn = key => visibleColumnSet.has(key);
const [editingExpected, setEditingExpected] = useState(false); const [editingExpected, setEditingExpected] = useState(false);
const [expectedDraft, setExpectedDraft] = useState(''); const [expectedDraft, setExpectedDraft] = useState('');
@ -449,218 +452,234 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
</TableCell> </TableCell>
{/* Due */} {/* Due */}
<TableCell className="tracker-number w-[10%] py-2.5 text-[13px] font-medium text-foreground/75"> {showColumn('due') && (
{editingDue ? ( <TableCell className="tracker-number w-[10%] py-2.5 text-[13px] font-medium text-foreground/75">
<input {editingDue ? (
type="number" <input
min="1" max="31" type="number"
value={dueDraft} min="1" max="31"
autoFocus value={dueDraft}
onChange={e => setDueDraft(e.target.value)} autoFocus
onBlur={handleSaveDue} onChange={e => setDueDraft(e.target.value)}
onKeyDown={e => { onBlur={handleSaveDue}
if (e.key === 'Enter') e.currentTarget.blur(); onKeyDown={e => {
if (e.key === 'Escape') { setEditingDue(false); } if (e.key === 'Enter') e.currentTarget.blur();
}} if (e.key === 'Escape') { setEditingDue(false); }
className="tracker-number w-12 rounded border border-border bg-transparent px-1 py-0.5 text-sm font-medium text-foreground outline-none focus:ring-[2px] focus:ring-ring/50" }}
title="Day of month (131)" className="tracker-number w-12 rounded border border-border bg-transparent px-1 py-0.5 text-sm font-medium text-foreground outline-none focus:ring-[2px] focus:ring-ring/50"
/> title="Day of month (131)"
) : ( />
<button ) : (
type="button"
onClick={() => { setDueDraft(String(row.due_day)); setEditingDue(true); }}
className="rounded px-1 py-0.5 transition-colors hover:bg-accent hover:text-foreground"
title="Click to edit due day"
>
{fmtDate(row.due_date)}
</button>
)}
</TableCell>
{/* Expected / Actual — shows actual_amount in amber when it overrides the template */}
<TableCell className="tracker-number w-[10%] py-2.5 text-center text-[13px] font-semibold">
{editingExpected ? (
<input
type="number"
min="0" step="0.01"
value={expectedDraft}
autoFocus
onChange={e => setExpectedDraft(e.target.value)}
onBlur={handleSaveExpected}
onKeyDown={e => {
if (e.key === 'Enter') e.currentTarget.blur();
if (e.key === 'Escape') { setEditingExpected(false); }
}}
className="tracker-number mx-auto w-24 rounded border border-border bg-transparent px-1 py-0.5 text-center text-sm font-semibold text-foreground outline-none focus:ring-[2px] focus:ring-ring/50"
/>
) : effectiveActual != null ? (
<button
type="button"
onClick={() => { setExpectedDraft(String(effectiveActual)); setEditingExpected(true); }}
className="mx-auto rounded px-1 py-0.5 text-amber-300 transition-colors hover:bg-accent"
title={`Monthly override — click to edit. Template default: ${fmt(row.expected_amount)}`}
>
{fmt(effectiveActual)}
</button>
) : (
<div className="mx-auto flex w-[68px] flex-col items-center gap-0.5">
<button <button
type="button" type="button"
onClick={() => { setExpectedDraft(String(row.expected_amount)); setEditingExpected(true); }} onClick={() => { setDueDraft(String(row.due_day)); setEditingDue(true); }}
className="rounded px-1 py-0.5 text-center text-foreground/85 transition-colors hover:bg-accent hover:text-foreground" className="rounded px-1 py-0.5 transition-colors hover:bg-accent hover:text-foreground"
title="Click to edit expected amount" title="Click to edit due day"
> >
{fmt(row.expected_amount)} {fmtDate(row.due_date)}
</button> </button>
{isDrifted && ( )}
<span className="inline-flex items-center gap-0.5 text-[10px] font-semibold text-amber-500"> </TableCell>
<TrendingUp className="h-2.5 w-2.5" />Changed )}
</span>
)} {/* Expected / Actual — shows actual_amount in amber when it overrides the template */}
{row.sparkline && row.sparkline.length >= 2 && (() => { {showColumn('expected') && (
const vals = row.sparkline; <TableCell className="tracker-number w-[10%] py-2.5 text-center text-[13px] font-semibold">
const min = Math.min(...vals); {editingExpected ? (
const max = Math.max(...vals); <input
const range = max - min || 1; type="number"
const W = 40, H = 12; min="0" step="0.01"
const pts = vals.map((v, i) => { value={expectedDraft}
const x = (i / (vals.length - 1)) * W; autoFocus
const y = H - ((v - min) / range) * H; onChange={e => setExpectedDraft(e.target.value)}
return `${x.toFixed(1)},${y.toFixed(1)}`; onBlur={handleSaveExpected}
}).join(' '); onKeyDown={e => {
return ( if (e.key === 'Enter') e.currentTarget.blur();
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="opacity-50"> if (e.key === 'Escape') { setEditingExpected(false); }
<polyline points={pts} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" /> }}
</svg> className="tracker-number mx-auto w-24 rounded border border-border bg-transparent px-1 py-0.5 text-center text-sm font-semibold text-foreground outline-none focus:ring-[2px] focus:ring-ring/50"
); />
})()} ) : effectiveActual != null ? (
{row.amount_suggestion?.suggestion != null && <button
Math.abs(row.amount_suggestion.suggestion - row.expected_amount) / row.expected_amount > 0.05 && ( type="button"
onClick={() => { setExpectedDraft(String(effectiveActual)); setEditingExpected(true); }}
className="mx-auto rounded px-1 py-0.5 text-amber-300 transition-colors hover:bg-accent"
title={`Monthly override — click to edit. Template default: ${fmt(row.expected_amount)}`}
>
{fmt(effectiveActual)}
</button>
) : (
<div className="mx-auto flex w-[68px] flex-col items-center gap-0.5">
<button <button
type="button" type="button"
onClick={() => handleApplySuggestion(row.amount_suggestion.suggestion)} onClick={() => { setExpectedDraft(String(row.expected_amount)); setEditingExpected(true); }}
className="text-[10px] text-muted-foreground/50 transition-colors hover:text-muted-foreground" className="rounded px-1 py-0.5 text-center text-foreground/85 transition-colors hover:bg-accent hover:text-foreground"
title={`Based on last ${row.amount_suggestion.months_used} months`} title="Click to edit expected amount"
> >
~{fmt(row.amount_suggestion.suggestion)} {fmt(row.expected_amount)}
</button> </button>
)} {isDrifted && (
</div> <span className="inline-flex items-center gap-0.5 text-[10px] font-semibold text-amber-500">
)} <TrendingUp className="h-2.5 w-2.5" />Changed
</TableCell> </span>
)}
{row.sparkline && row.sparkline.length >= 2 && (() => {
const vals = row.sparkline;
const min = Math.min(...vals);
const max = Math.max(...vals);
const range = max - min || 1;
const W = 40, H = 12;
const pts = vals.map((v, i) => {
const x = (i / (vals.length - 1)) * W;
const y = H - ((v - min) / range) * H;
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
return (
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="opacity-50">
<polyline points={pts} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
</svg>
);
})()}
{row.amount_suggestion?.suggestion != null &&
Math.abs(row.amount_suggestion.suggestion - row.expected_amount) / row.expected_amount > 0.05 && (
<button
type="button"
onClick={() => handleApplySuggestion(row.amount_suggestion.suggestion)}
className="text-[10px] text-muted-foreground/50 transition-colors hover:text-muted-foreground"
title={`Based on last ${row.amount_suggestion.months_used} months`}
>
~{fmt(row.amount_suggestion.suggestion)}
</button>
)}
</div>
)}
</TableCell>
)}
{/* Previous month paid */} {/* Previous month paid */}
<TableCell className="tracker-number w-[10%] py-2.5 text-center text-[13px] font-medium text-muted-foreground/80"> {showColumn('previous') && (
{row.previous_month_paid > 0 ? fmt(row.previous_month_paid) : '—'} <TableCell className="tracker-number w-[10%] py-2.5 text-center text-[13px] font-medium text-muted-foreground/80">
</TableCell> {row.previous_month_paid > 0 ? fmt(row.previous_month_paid) : '—'}
</TableCell>
)}
{/* Amount paid — mismatch now compares against threshold */} {/* Amount paid — mismatch now compares against threshold */}
<TableCell className="w-[10%] py-2.5 text-center"> {showColumn('paid') && (
<PaymentProgress <TableCell className="w-[10%] py-2.5 text-center">
row={row} <PaymentProgress
threshold={threshold} row={row}
className="mx-auto max-w-[7.5rem]" threshold={threshold}
onOpen={() => setPaymentLedgerOpen(true)} className="mx-auto max-w-[7.5rem]"
onMarkFullAmount={!isSkipped ? handleMarkFullAmount : undefined} onOpen={() => setPaymentLedgerOpen(true)}
/> onMarkFullAmount={!isSkipped ? handleMarkFullAmount : undefined}
</TableCell> />
</TableCell>
)}
{/* Paid date */} {/* Paid date */}
<TableCell className="w-[10%] py-2.5 text-[13px] text-foreground/75"> {showColumn('paid_date') && (
<button <TableCell className="w-[10%] py-2.5 text-[13px] text-foreground/75">
type="button" <button
onClick={() => setPaymentLedgerOpen(true)} type="button"
className="tracker-number rounded-md px-1.5 py-0.5 font-medium transition-colors hover:bg-accent hover:text-foreground" onClick={() => setPaymentLedgerOpen(true)}
title="View payment history" className="tracker-number rounded-md px-1.5 py-0.5 font-medium transition-colors hover:bg-accent hover:text-foreground"
> title="View payment history"
{row.last_paid_date ? fmtDate(row.last_paid_date) : '—'} >
{summary.count > 1 && <span className="ml-1 text-[10px] text-muted-foreground">({summary.count})</span>} {row.last_paid_date ? fmtDate(row.last_paid_date) : '—'}
</button> {summary.count > 1 && <span className="ml-1 text-[10px] text-muted-foreground">({summary.count})</span>}
</TableCell> </button>
</TableCell>
)}
{/* Status — uses effectiveStatus (accounts for skipped + threshold) */} {/* Status — uses effectiveStatus (accounts for skipped + threshold) */}
<TableCell className="w-[9%] py-2.5"> {showColumn('status') && (
<div className="flex flex-col items-center gap-1"> <TableCell className="w-[9%] py-2.5">
<StatusBadge <div className="flex flex-col items-center gap-1">
status={effectiveStatus} <StatusBadge
clickable status={effectiveStatus}
onClick={() => { clickable
if (effectiveStatus === 'skipped') return; onClick={() => {
handleTogglePaid(); if (effectiveStatus === 'skipped') return;
}} handleTogglePaid();
loading={loading} }}
/> loading={loading}
{row.pending_cleared && ( />
<span {row.pending_cleared && (
className="inline-flex items-center rounded-full border border-amber-400/40 bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400" <span
title="Paid in tracker but may not have cleared your bank account yet" className="inline-flex items-center rounded-full border border-amber-400/40 bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400"
> title="Paid in tracker but may not have cleared your bank account yet"
Pending >
</span> Pending
)} </span>
</div> )}
</TableCell> </div>
</TableCell>
)}
{/* Actions */} {/* Actions */}
<TableCell className="w-[10%] py-2.5 text-center"> {showColumn('action') && (
<div className="flex items-center justify-center gap-1"> <TableCell className="w-[10%] py-2.5 text-center">
{showUpdateNudge ? ( <div className="flex items-center justify-center gap-1">
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-1 duration-200"> {showUpdateNudge ? (
<span className="text-[10px] text-muted-foreground">Update default?</span> <div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-1 duration-200">
<Button <span className="text-[10px] text-muted-foreground">Update default?</span>
size="sm" variant="ghost" <Button
onClick={handleUpdateTemplate} size="sm" variant="ghost"
className="h-6 px-2 text-[10px] font-semibold text-emerald-600 hover:bg-emerald-500/10 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300" onClick={handleUpdateTemplate}
> className="h-6 px-2 text-[10px] font-semibold text-emerald-600 hover:bg-emerald-500/10 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300"
{fmt(nudgeAmount)} >
</Button> {fmt(nudgeAmount)}
<button </Button>
type="button" <button
onClick={() => setShowUpdateNudge(false)} type="button"
className="text-muted-foreground transition-colors hover:text-foreground" onClick={() => setShowUpdateNudge(false)}
title="Dismiss" className="text-muted-foreground transition-colors hover:text-foreground"
> title="Dismiss"
<X className="h-3 w-3" /> >
</button> <X className="h-3 w-3" />
</div> </button>
) : ( </div>
<> ) : (
{hasAutopaySuggestion && ( <>
<AutopaySuggestionActions {hasAutopaySuggestion && (
row={row} <AutopaySuggestionActions
loading={suggestionLoading} row={row}
onConfirm={handleConfirmSuggestion} loading={suggestionLoading}
onDismiss={handleDismissSuggestion} onConfirm={handleConfirmSuggestion}
/> onDismiss={handleDismissSuggestion}
)}
{/* Quick pay — hidden for skipped/paid bills */}
{!isPaid && !isSkipped && !hasAutopaySuggestion && (
<div className="flex items-center gap-1">
<Input
ref={amountRef}
type="number" min="0" step="0.01"
defaultValue={summary.remaining || threshold}
className="tracker-number h-7 w-20 text-right text-sm font-medium bg-background/50 border-border/50"
title="Payment amount"
/> />
<Button )}
size="sm" variant="ghost" {/* Quick pay — hidden for skipped/paid bills */}
onClick={handleQuickPay} {!isPaid && !isSkipped && !hasAutopaySuggestion && (
className="h-7 px-2.5 text-xs font-semibold text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:text-emerald-300 dark:hover:bg-emerald-500/10" <div className="flex items-center gap-1">
> <Input
Add ref={amountRef}
</Button> type="number" min="0" step="0.01"
</div> defaultValue={summary.remaining || threshold}
)} className="tracker-number h-7 w-20 text-right text-sm font-medium bg-background/50 border-border/50"
</> title="Payment amount"
)} />
</div> <Button
</TableCell> size="sm" variant="ghost"
onClick={handleQuickPay}
className="h-7 px-2.5 text-xs font-semibold text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:text-emerald-300 dark:hover:bg-emerald-500/10"
>
Add
</Button>
</div>
)}
</>
)}
</div>
</TableCell>
)}
{/* Notes cell (monthly state notes) */} {/* Notes cell (monthly state notes) */}
<TableCell className="w-[23%] py-2.5 border-l border-border pl-4"> {showColumn('notes') && (
<NotesCell row={{ ...row, year, month }} refresh={refresh} /> <TableCell className="w-[23%] py-2.5 border-l border-border pl-4">
</TableCell> <NotesCell row={{ ...row, year, month }} refresh={refresh} />
</TableCell>
)}
</TableRow> </TableRow>
{editPayment && ( {editPayment && (

View File

@ -0,0 +1,37 @@
export const TRACKER_TABLE_COLUMNS = [
{ key: 'due', label: 'Due' },
{ key: 'expected', label: 'Expected' },
{ key: 'previous', label: 'Last Month' },
{ key: 'paid', label: 'Paid' },
{ key: 'paid_date', label: 'Paid Date' },
{ key: 'status', label: 'Status' },
{ key: 'action', label: 'Action' },
{ key: 'notes', label: 'Notes' },
];
export const TRACKER_TABLE_COLUMN_KEYS = TRACKER_TABLE_COLUMNS.map(column => column.key);
export const DEFAULT_TRACKER_TABLE_COLUMNS = [...TRACKER_TABLE_COLUMN_KEYS];
export function parseTrackerTableColumns(value) {
if (Array.isArray(value)) return normalizeTrackerTableColumns(value);
if (!value) return DEFAULT_TRACKER_TABLE_COLUMNS;
try {
const parsed = JSON.parse(value);
return normalizeTrackerTableColumns(parsed);
} catch {
return DEFAULT_TRACKER_TABLE_COLUMNS;
}
}
export function normalizeTrackerTableColumns(columns) {
const valid = new Set(TRACKER_TABLE_COLUMN_KEYS);
return Array.isArray(columns)
? columns.filter(column => valid.has(column))
.filter((column, index, all) => all.indexOf(column) === index)
: DEFAULT_TRACKER_TABLE_COLUMNS;
}
export function trackerTableColumnsToSetting(columns) {
return JSON.stringify(normalizeTrackerTableColumns(columns));
}

View File

@ -238,6 +238,11 @@ function LinkImportToggle() {
); );
} }
function settingsBool(value, fallback = true) {
if (value === undefined || value === null || value === '') return fallback;
return value === true || value === 'true';
}
// SettingsPage // SettingsPage
export default function SettingsPage() { export default function SettingsPage() {
@ -246,6 +251,12 @@ export default function SettingsPage() {
date_format: 'MM/DD/YYYY', date_format: 'MM/DD/YYYY',
grace_period_days: 3, grace_period_days: 3,
drift_threshold_pct: '5', drift_threshold_pct: '5',
tracker_show_bank_projection_banner: 'true',
tracker_bank_projection_banner_snoozed_until: '',
tracker_show_search_sort: 'true',
tracker_show_summary_cards: 'true',
tracker_show_overdue_command_center: 'true',
tracker_show_drift_insights: 'true',
}; };
const [settings, setSettings] = useState(DEFAULTS); const [settings, setSettings] = useState(DEFAULTS);
@ -274,6 +285,12 @@ export default function SettingsPage() {
date_format: settings.date_format, date_format: settings.date_format,
grace_period_days: settings.grace_period_days, grace_period_days: settings.grace_period_days,
drift_threshold_pct: settings.drift_threshold_pct, drift_threshold_pct: settings.drift_threshold_pct,
tracker_show_bank_projection_banner: settings.tracker_show_bank_projection_banner,
tracker_bank_projection_banner_snoozed_until: settings.tracker_bank_projection_banner_snoozed_until || '',
tracker_show_search_sort: settings.tracker_show_search_sort,
tracker_show_summary_cards: settings.tracker_show_summary_cards,
tracker_show_overdue_command_center: settings.tracker_show_overdue_command_center,
tracker_show_drift_insights: settings.tracker_show_drift_insights,
}); });
toast.success('Settings saved.'); toast.success('Settings saved.');
} catch (err) { } catch (err) {
@ -351,6 +368,60 @@ export default function SettingsPage() {
</SettingRow> </SettingRow>
</SectionCard> </SectionCard>
{/* Tracker Layout */}
<SectionCard title="Tracker Layout">
<SettingRow
label="Bank Projection Banner"
description="Show the account balance and projected-after-bills banner on the tracker."
>
<Switch
checked={settingsBool(settings.tracker_show_bank_projection_banner)}
onCheckedChange={(checked) => set('tracker_show_bank_projection_banner', String(checked))}
aria-label="Show Bank Projection Banner"
/>
</SettingRow>
<SettingRow
label="Search & sort"
description="Show the tracker search, filters, and sort controls."
>
<Switch
checked={settingsBool(settings.tracker_show_search_sort)}
onCheckedChange={(checked) => set('tracker_show_search_sort', String(checked))}
aria-label="Show Search and sort"
/>
</SettingRow>
<SettingRow
label="Summary cards"
description="Show the bank balance, paid, overdue, previous month, and trend cards."
>
<Switch
checked={settingsBool(settings.tracker_show_summary_cards)}
onCheckedChange={(checked) => set('tracker_show_summary_cards', String(checked))}
aria-label="Show Summary cards"
/>
</SettingRow>
<SettingRow
label="Overdue Command Center"
description="Show the quick-action panel for overdue bills."
>
<Switch
checked={settingsBool(settings.tracker_show_overdue_command_center)}
onCheckedChange={(checked) => set('tracker_show_overdue_command_center', String(checked))}
aria-label="Show Overdue Command Center"
/>
</SettingRow>
<SettingRow
label="Drift insights"
description="Show price-change and bill-drift insights on the tracker."
>
<Switch
checked={settingsBool(settings.tracker_show_drift_insights)}
onCheckedChange={(checked) => set('tracker_show_drift_insights', String(checked))}
aria-label="Show Drift insights"
/>
</SettingRow>
</SectionCard>
{/* Billing Behavior */} {/* Billing Behavior */}
<SectionCard title="Billing Behavior"> <SectionCard title="Billing Behavior">
<LinkImportToggle /> <LinkImportToggle />

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } 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 } from 'lucide-react'; import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, EyeOff } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api.js'; import { api } from '@/api.js';
import { useTracker, useDriftReport } from '@/hooks/useQueries'; import { useTracker, useDriftReport } from '@/hooks/useQueries';
@ -30,6 +30,7 @@ import {
TRACKER_SORT_DEFAULT_DIRS, TRACKER_SORT_LABELS, TRACKER_SORT_DEFAULT_DIRS, TRACKER_SORT_LABELS,
normalizeTrackerSortKey, normalizeTrackerSortDir, sortTrackerRows, normalizeTrackerSortKey, normalizeTrackerSortDir, sortTrackerRows,
} from '@/lib/trackerUtils'; } from '@/lib/trackerUtils';
import { parseTrackerTableColumns, trackerTableColumnsToSetting } from '@/lib/trackerTableColumns';
import { FilterChip } from '@/components/tracker/FilterChip'; import { FilterChip } from '@/components/tracker/FilterChip';
import { SummaryCard, TrendCard } from '@/components/tracker/SummaryCards'; import { SummaryCard, TrendCard } from '@/components/tracker/SummaryCards';
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog'; import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
@ -42,6 +43,81 @@ function fmtBalanceAge(isoStr) {
return new Date(isoStr).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); return new Date(isoStr).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
} }
function localDateString(date = new Date()) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function settingEnabled(value, fallback = true) {
if (value === undefined || value === null || value === '') return fallback;
return value === true || value === 'true';
}
function BankProjectionBanner({ bankTracking, onSnooze, onIgnore, busy }) {
if (!bankTracking?.enabled) return null;
const isPositive = Number(bankTracking.remaining ?? 0) >= 0;
return (
<div className={cn(
'flex flex-col gap-3 rounded-xl border px-4 py-3 text-xs shadow-sm sm:flex-row sm:items-center sm:justify-between',
isPositive
? 'border-emerald-500/20 bg-emerald-500/5 text-emerald-700 dark:text-emerald-400'
: 'border-destructive/20 bg-destructive/5 text-destructive',
)}>
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="relative flex h-2 w-2 shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-50" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-current" />
</span>
<span className="truncate font-semibold">{bankTracking.account_name}</span>
<span className="text-muted-foreground">·</span>
<span>{fmt(bankTracking.balance ?? 0)} balance</span>
{bankTracking.last_updated && (
<>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">as of {fmtBalanceAge(bankTracking.last_updated)}</span>
</>
)}
{Number(bankTracking.pending_payments ?? 0) > 0 && (
<>
<span className="text-muted-foreground">·</span>
<span className="text-amber-600 dark:text-amber-400">{fmt(bankTracking.pending_payments)} pending</span>
</>
)}
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2 sm:justify-end">
<span className="font-semibold tabular-nums">
{Number(bankTracking.remaining ?? 0) < 0 ? '' : ''}{fmt(Math.abs(Number(bankTracking.remaining ?? 0)))} projected
</span>
<Button
type="button"
size="sm"
variant="ghost"
disabled={busy}
onClick={onSnooze}
className="h-7 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
>
<BellOff className="h-3 w-3" />
Snooze
</Button>
<Button
type="button"
size="sm"
variant="ghost"
disabled={busy}
onClick={onIgnore}
className="h-7 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
>
<EyeOff className="h-3 w-3" />
Ignore
</Button>
</div>
</div>
);
}
// Main page // Main page
function LateAttributionDialog({ attr, remaining, busy, onAccept, onDismiss }) { function LateAttributionDialog({ attr, remaining, busy, onAccept, onDismiss }) {
if (!attr) return null; if (!attr) return null;
@ -127,6 +203,16 @@ export default function TrackerPage() {
const [orderedRows, setOrderedRows] = useState(null); const [orderedRows, setOrderedRows] = useState(null);
const [movingBillId, setMovingBillId] = useState(null); const [movingBillId, setMovingBillId] = useState(null);
const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference(); const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();
const [trackerSettings, setTrackerSettings] = useState({
tracker_show_bank_projection_banner: 'true',
tracker_bank_projection_banner_snoozed_until: '',
tracker_show_search_sort: 'true',
tracker_show_summary_cards: 'true',
tracker_show_overdue_command_center: 'true',
tracker_show_drift_insights: 'true',
tracker_table_columns: '["due","expected","previous","paid","paid_date","status","action","notes"]',
});
const [savingTrackerSetting, setSavingTrackerSetting] = useState(false);
// 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(null);
@ -148,6 +234,12 @@ export default function TrackerPage() {
.catch(() => setBankSyncStatus(null)); .catch(() => setBankSyncStatus(null));
}, []); }, []);
useEffect(() => {
api.settings()
.then(settings => setTrackerSettings(prev => ({ ...prev, ...settings })))
.catch(() => {});
}, []);
// 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) {
@ -245,6 +337,60 @@ export default function TrackerPage() {
const summary = data?.summary || {}; const summary = data?.summary || {};
const bankTracking = data?.bank_tracking; const bankTracking = data?.bank_tracking;
const cashflow = data?.cashflow; const cashflow = data?.cashflow;
const today = localDateString();
const bannerSnoozedUntil = trackerSettings.tracker_bank_projection_banner_snoozed_until || '';
const showSearchSort = settingEnabled(trackerSettings.tracker_show_search_sort);
const showSummaryCards = settingEnabled(trackerSettings.tracker_show_summary_cards);
const showOverdueCommandCenter = settingEnabled(trackerSettings.tracker_show_overdue_command_center);
const showDriftInsights = settingEnabled(trackerSettings.tracker_show_drift_insights);
const showBankProjectionBanner = settingEnabled(trackerSettings.tracker_show_bank_projection_banner) &&
(!bannerSnoozedUntil || bannerSnoozedUntil <= today);
const visibleTableColumns = useMemo(
() => parseTrackerTableColumns(trackerSettings.tracker_table_columns),
[trackerSettings.tracker_table_columns]
);
async function saveTrackerSettings(patch, successMessage) {
setSavingTrackerSetting(true);
setTrackerSettings(prev => ({ ...prev, ...patch }));
try {
const next = await api.saveSettings(patch);
setTrackerSettings(prev => ({ ...prev, ...next }));
if (successMessage) toast.success(successMessage);
} catch (err) {
toast.error(err.message || 'Failed to update tracker setting');
api.settings()
.then(settings => setTrackerSettings(prev => ({ ...prev, ...settings })))
.catch(() => {});
} finally {
setSavingTrackerSetting(false);
}
}
function snoozeBankProjectionBanner() {
const until = new Date();
until.setDate(until.getDate() + 1);
saveTrackerSettings({
tracker_bank_projection_banner_snoozed_until: localDateString(until),
}, 'Bank projection banner snoozed until tomorrow.');
}
function ignoreBankProjectionBanner() {
saveTrackerSettings({
tracker_show_bank_projection_banner: 'false',
tracker_bank_projection_banner_snoozed_until: '',
}, 'Bank projection banner hidden. You can turn it back on in Settings.');
}
function handleTableColumnToggle(columnKey, checked) {
const nextColumns = checked
? [...visibleTableColumns, columnKey]
: visibleTableColumns.filter(key => key !== columnKey);
saveTrackerSettings({
tracker_table_columns: trackerTableColumnsToSetting(nextColumns),
});
}
const toggleFilter = (key) => { const toggleFilter = (key) => {
const paramMap = { autopay: 'ap', firstBucket: 'b1', fifteenthBucket: 'b2', unpaid: 'un', overdue: 'ov', debt: 'de' }; const paramMap = { autopay: 'ap', firstBucket: 'b1', fifteenthBucket: 'b2', unpaid: 'un', overdue: 'ov', debt: 'de' };
updateParams({ [paramMap[key]]: !filters[key] }); updateParams({ [paramMap[key]]: !filters[key] });
@ -465,124 +611,91 @@ export default function TrackerPage() {
</div> </div>
</div> </div>
{/* ── B: Bank status bar ── */} {showSearchSort && (
{bankTracking?.enabled && ( <SearchFilterPanel
<div className={cn( title="Search & sort"
'flex flex-wrap items-center justify-between gap-x-4 gap-y-1 rounded-xl border px-4 py-2.5 text-xs', collapsed={searchPanelCollapsed}
Number(bankTracking.remaining ?? 0) >= 0 onCollapsedChange={setSearchPanelCollapsed}
? 'border-emerald-500/20 bg-emerald-500/5 text-emerald-700 dark:text-emerald-400' hasFilters={hasFilters}
: 'border-destructive/20 bg-destructive/5 text-destructive', resultLabel={`${filteredRows.length} of ${rows.length} shown`}
)}> sortLabel={hasSort ? `sorted by ${TRACKER_SORT_LABELS[sortKey]} ${sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}` : null}
<div className="flex items-center gap-2"> onClear={resetFilters}
<span className="relative flex h-2 w-2"> >
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-50" /> <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-[minmax(220px,1fr)_220px_180px_220px_auto] xl:items-center">
<span className="relative inline-flex h-2 w-2 rounded-full bg-current" /> <label className="relative">
</span> <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<span className="font-semibold">{bankTracking.account_name}</span> <Input
<span className="text-muted-foreground">·</span> value={search}
<span>{fmt(bankTracking.balance ?? 0)} balance</span> onChange={e => updateParams({ q: e.target.value || null })}
{bankTracking.last_updated && ( placeholder="Search this month by bill, category, notes, or amount"
<> className="h-10 pl-9"
<span className="text-muted-foreground">·</span> />
<span className="text-muted-foreground">as of {fmtBalanceAge(bankTracking.last_updated)}</span> </label>
</> <Select value={filters.category} onValueChange={value => setFilterValue('category', value)}>
)} <SelectTrigger className="h-10">
{Number(bankTracking.pending_payments ?? 0) > 0 && ( <SelectValue placeholder="Category" />
<> </SelectTrigger>
<span className="text-muted-foreground">·</span> <SelectContent>
<span className="text-amber-600 dark:text-amber-400">{fmt(bankTracking.pending_payments)} pending</span> <SelectItem value={FILTER_ALL}>All categories</SelectItem>
</> {categoryOptions.map(category => (
)} <SelectItem key={category.id} value={category.id}>{category.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filters.cycle} onValueChange={value => setFilterValue('cycle', value)}>
<SelectTrigger className="h-10 capitalize">
<SelectValue placeholder="Billing schedule" />
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_ALL}>All cycles</SelectItem>
{cycleOptions.map(cycle => (
<SelectItem key={cycle} value={cycle}>{scheduleLabel(cycle)}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={sortKey} onValueChange={setSort}>
<SelectTrigger className="h-10">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
{TRACKER_SORT_OPTIONS.map(option => (
<SelectItem key={option.key} value={option.key}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
disabled={!hasSort}
onClick={toggleSortDirection}
className="h-10 justify-center gap-2 text-xs"
title={hasSort ? `Sort ${sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}` : 'Choose a sort first'}
>
{sortDir === TRACKER_SORT_ASC ? <ArrowUp className="h-3.5 w-3.5" /> : <ArrowDown className="h-3.5 w-3.5" />}
<span>{sortDir === TRACKER_SORT_ASC ? 'Asc' : 'Desc'}</span>
</Button>
</div> </div>
<span className="font-semibold tabular-nums"> <div className="flex flex-wrap items-center gap-2">
{Number(bankTracking.remaining ?? 0) < 0 ? '' : ''}{fmt(Math.abs(Number(bankTracking.remaining ?? 0)))} projected <FilterChip active={filters.unpaid} onClick={() => toggleFilter('unpaid')}>Unpaid</FilterChip>
</span> <FilterChip active={filters.overdue} onClick={() => toggleFilter('overdue')}>Overdue</FilterChip>
</div> <FilterChip active={filters.autopay} onClick={() => toggleFilter('autopay')}>Autopay</FilterChip>
<FilterChip active={filters.firstBucket} onClick={() => toggleFilter('firstBucket')}>1st bucket</FilterChip>
<FilterChip active={filters.fifteenthBucket} onClick={() => toggleFilter('fifteenthBucket')}>15th bucket</FilterChip>
<FilterChip active={filters.debt} onClick={() => toggleFilter('debt')}>Debt</FilterChip>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{filteredRows.length} of {rows.length} shown
{hasSort && (
<span className="ml-2 hidden sm:inline">
· sorted by {TRACKER_SORT_LABELS[sortKey]} {sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}
</span>
)}
</span>
</div>
</SearchFilterPanel>
)} )}
<SearchFilterPanel
title="Search & sort"
collapsed={searchPanelCollapsed}
onCollapsedChange={setSearchPanelCollapsed}
hasFilters={hasFilters}
resultLabel={`${filteredRows.length} of ${rows.length} shown`}
sortLabel={hasSort ? `sorted by ${TRACKER_SORT_LABELS[sortKey]} ${sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}` : null}
onClear={resetFilters}
>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-[minmax(220px,1fr)_220px_180px_220px_auto] xl:items-center">
<label className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={e => updateParams({ q: e.target.value || null })}
placeholder="Search this month by bill, category, notes, or amount"
className="h-10 pl-9"
/>
</label>
<Select value={filters.category} onValueChange={value => setFilterValue('category', value)}>
<SelectTrigger className="h-10">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_ALL}>All categories</SelectItem>
{categoryOptions.map(category => (
<SelectItem key={category.id} value={category.id}>{category.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filters.cycle} onValueChange={value => setFilterValue('cycle', value)}>
<SelectTrigger className="h-10 capitalize">
<SelectValue placeholder="Billing schedule" />
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_ALL}>All cycles</SelectItem>
{cycleOptions.map(cycle => (
<SelectItem key={cycle} value={cycle}>{scheduleLabel(cycle)}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={sortKey} onValueChange={setSort}>
<SelectTrigger className="h-10">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
{TRACKER_SORT_OPTIONS.map(option => (
<SelectItem key={option.key} value={option.key}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
disabled={!hasSort}
onClick={toggleSortDirection}
className="h-10 justify-center gap-2 text-xs"
title={hasSort ? `Sort ${sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}` : 'Choose a sort first'}
>
{sortDir === TRACKER_SORT_ASC ? <ArrowUp className="h-3.5 w-3.5" /> : <ArrowDown className="h-3.5 w-3.5" />}
<span>{sortDir === TRACKER_SORT_ASC ? 'Asc' : 'Desc'}</span>
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<FilterChip active={filters.unpaid} onClick={() => toggleFilter('unpaid')}>Unpaid</FilterChip>
<FilterChip active={filters.overdue} onClick={() => toggleFilter('overdue')}>Overdue</FilterChip>
<FilterChip active={filters.autopay} onClick={() => toggleFilter('autopay')}>Autopay</FilterChip>
<FilterChip active={filters.firstBucket} onClick={() => toggleFilter('firstBucket')}>1st bucket</FilterChip>
<FilterChip active={filters.fifteenthBucket} onClick={() => toggleFilter('fifteenthBucket')}>15th bucket</FilterChip>
<FilterChip active={filters.debt} onClick={() => toggleFilter('debt')}>Debt</FilterChip>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{filteredRows.length} of {rows.length} shown
{hasSort && (
<span className="ml-2 hidden sm:inline">
· sorted by {TRACKER_SORT_LABELS[sortKey]} {sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}
</span>
)}
</span>
</div>
</SearchFilterPanel>
{/* ── Summary cards (backend already excludes skipped from totals) ── */} {/* ── Summary cards (backend already excludes skipped from totals) ── */}
{loading ? ( {showSummaryCards && loading ? (
<div className="grid grid-cols-2 gap-3 lg:flex" aria-busy="true"> <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" />
@ -591,7 +704,7 @@ export default function TrackerPage() {
<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" />}
</div> </div>
) : ( ) : showSummaryCards ? (
<div className="grid grid-cols-2 gap-3 lg:flex"> <div className="grid grid-cols-2 gap-3 lg:flex">
{bankTracking?.enabled ? ( {bankTracking?.enabled ? (
<button <button
@ -653,10 +766,10 @@ export default function TrackerPage() {
<SummaryCard type="paid" value={summary.previous_month_total} hint="Previous month"/> <SummaryCard type="paid" value={summary.previous_month_total} hint="Previous month"/>
{summary.trend && <TrendCard trend={summary.trend} />} {summary.trend && <TrendCard trend={summary.trend} />}
</div> </div>
)} ) : null}
{/* ── Overdue Command Center ── */} {/* ── Overdue Command Center ── */}
{!isError && !loading && (summary?.count_late ?? 0) > 0 && ( {!isError && !loading && showOverdueCommandCenter && (summary?.count_late ?? 0) > 0 && (
<OverdueCommandCenter <OverdueCommandCenter
rows={rows} rows={rows}
year={year} year={year}
@ -666,8 +779,18 @@ export default function TrackerPage() {
/> />
)} )}
{/* ── Bank Projection Banner ── */}
{!isError && !loading && showBankProjectionBanner && (
<BankProjectionBanner
bankTracking={bankTracking}
busy={savingTrackerSetting}
onSnooze={snoozeBankProjectionBanner}
onIgnore={ignoreBankProjectionBanner}
/>
)}
{/* ── Drift / Price-Change Insights ── */} {/* ── Drift / Price-Change Insights ── */}
{!isError && !loading && (driftData?.bills?.length ?? 0) > 0 && ( {!isError && !loading && showDriftInsights && (driftData?.bills?.length ?? 0) > 0 && (
<DriftInsightPanel <DriftInsightPanel
driftBills={driftData.bills} driftBills={driftData.bills}
refresh={() => { refetch(); refetchDrift(); }} refresh={() => { refetch(); refetchDrift(); }}
@ -742,8 +865,8 @@ export default function TrackerPage() {
)} )}
{!isError && (first.length > 0 || second.length > 0) && ( {!isError && (first.length > 0 || second.length > 0) && (
<div className="space-y-5"> <div className="space-y-5">
{first.length > 0 && <Bucket label="1st 14th" rows={first} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('1st', next)} driftedIds={driftedIds} />} {first.length > 0 && <Bucket label="1st 14th" rows={first} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('1st', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} onColumnToggle={handleTableColumnToggle} columnsSaving={savingTrackerSetting} />}
{second.length > 0 && <Bucket label="15th 31st" rows={second} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('15th', next)} driftedIds={driftedIds} />} {second.length > 0 && <Bucket label="15th 31st" rows={second} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('15th', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} onColumnToggle={handleTableColumnToggle} columnsSaving={savingTrackerSetting} />}
</div> </div>
)} )}

View File

@ -13,10 +13,24 @@ const USER_SETTING_KEYS = [
'bank_tracking_pending_days', 'bank_tracking_pending_days',
'bank_late_attribution_days', 'bank_late_attribution_days',
'search_bars_collapsed', 'search_bars_collapsed',
'tracker_show_bank_projection_banner',
'tracker_bank_projection_banner_snoozed_until',
'tracker_show_search_sort',
'tracker_show_summary_cards',
'tracker_show_overdue_command_center',
'tracker_show_drift_insights',
'tracker_table_columns',
]; ];
const USER_SETTING_DEFAULTS = { const USER_SETTING_DEFAULTS = {
search_bars_collapsed: 'false', search_bars_collapsed: 'false',
tracker_show_bank_projection_banner: 'true',
tracker_bank_projection_banner_snoozed_until: '',
tracker_show_search_sort: 'true',
tracker_show_summary_cards: 'true',
tracker_show_overdue_command_center: 'true',
tracker_show_drift_insights: 'true',
tracker_table_columns: '["due","expected","previous","paid","paid_date","status","action","notes"]',
}; };
function defaultUserSettings() { function defaultUserSettings() {