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>
{showColumn('due') && (
<SortableHead sortKey="due" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%]">Due</SortableHead> <SortableHead sortKey="due" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%]">Due</SortableHead>
)}
{showColumn('expected') && (
<SortableHead sortKey="expected" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-center">Expected</SortableHead> <SortableHead sortKey="expected" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-center">Expected</SortableHead>
)}
{showColumn('previous') && (
<SortableHead sortKey="previous" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-center">Last Month</SortableHead> <SortableHead sortKey="previous" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-center">Last Month</SortableHead>
)}
{showColumn('paid') && (
<SortableHead sortKey="paid" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-center">Paid</SortableHead> <SortableHead sortKey="paid" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-center">Paid</SortableHead>
)}
{showColumn('paid_date') && (
<SortableHead sortKey="paid_date" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%]">Paid Date</SortableHead> <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> <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"> <TableHead className="h-11 w-[10%] py-2.5 text-center text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
Action Action
</TableHead> </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"> <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 Notes
</TableHead> </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>}
{showColumn('action') && (
<TableCell className="w-[10%] py-3 text-center"> <TableCell className="w-[10%] py-3 text-center">
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
<div className="h-7 w-20 rounded-md bg-muted" /> <div className="h-7 w-20 rounded-md bg-muted" />
<div className="h-7 w-7 rounded-md bg-muted" /> <div className="h-7 w-7 rounded-md bg-muted" />
</div> </div>
</TableCell> </TableCell>
)}
{showColumn('notes') && (
<TableCell className="w-[23%] py-3 border-l border-border pl-4"> <TableCell className="w-[23%] py-3 border-l border-border pl-4">
<div className="h-4 w-full rounded-md bg-muted" /> <div className="h-4 w-full rounded-md bg-muted" />
</TableCell> </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,6 +452,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
</TableCell> </TableCell>
{/* Due */} {/* Due */}
{showColumn('due') && (
<TableCell className="tracker-number w-[10%] py-2.5 text-[13px] font-medium text-foreground/75"> <TableCell className="tracker-number w-[10%] py-2.5 text-[13px] font-medium text-foreground/75">
{editingDue ? ( {editingDue ? (
<input <input
@ -476,8 +480,10 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
</button> </button>
)} )}
</TableCell> </TableCell>
)}
{/* Expected / Actual — shows actual_amount in amber when it overrides the template */} {/* Expected / Actual — shows actual_amount in amber when it overrides the template */}
{showColumn('expected') && (
<TableCell className="tracker-number w-[10%] py-2.5 text-center text-[13px] font-semibold"> <TableCell className="tracker-number w-[10%] py-2.5 text-center text-[13px] font-semibold">
{editingExpected ? ( {editingExpected ? (
<input <input
@ -548,13 +554,17 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
</div> </div>
)} )}
</TableCell> </TableCell>
)}
{/* Previous month paid */} {/* Previous month paid */}
{showColumn('previous') && (
<TableCell className="tracker-number w-[10%] py-2.5 text-center text-[13px] font-medium text-muted-foreground/80"> <TableCell className="tracker-number w-[10%] py-2.5 text-center text-[13px] font-medium text-muted-foreground/80">
{row.previous_month_paid > 0 ? fmt(row.previous_month_paid) : '—'} {row.previous_month_paid > 0 ? fmt(row.previous_month_paid) : '—'}
</TableCell> </TableCell>
)}
{/* Amount paid — mismatch now compares against threshold */} {/* Amount paid — mismatch now compares against threshold */}
{showColumn('paid') && (
<TableCell className="w-[10%] py-2.5 text-center"> <TableCell className="w-[10%] py-2.5 text-center">
<PaymentProgress <PaymentProgress
row={row} row={row}
@ -564,8 +574,10 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
onMarkFullAmount={!isSkipped ? handleMarkFullAmount : undefined} onMarkFullAmount={!isSkipped ? handleMarkFullAmount : undefined}
/> />
</TableCell> </TableCell>
)}
{/* Paid date */} {/* Paid date */}
{showColumn('paid_date') && (
<TableCell className="w-[10%] py-2.5 text-[13px] text-foreground/75"> <TableCell className="w-[10%] py-2.5 text-[13px] text-foreground/75">
<button <button
type="button" type="button"
@ -577,8 +589,10 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
{summary.count > 1 && <span className="ml-1 text-[10px] text-muted-foreground">({summary.count})</span>} {summary.count > 1 && <span className="ml-1 text-[10px] text-muted-foreground">({summary.count})</span>}
</button> </button>
</TableCell> </TableCell>
)}
{/* Status — uses effectiveStatus (accounts for skipped + threshold) */} {/* Status — uses effectiveStatus (accounts for skipped + threshold) */}
{showColumn('status') && (
<TableCell className="w-[9%] py-2.5"> <TableCell className="w-[9%] py-2.5">
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
<StatusBadge <StatusBadge
@ -600,8 +614,10 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
)} )}
</div> </div>
</TableCell> </TableCell>
)}
{/* Actions */} {/* Actions */}
{showColumn('action') && (
<TableCell className="w-[10%] py-2.5 text-center"> <TableCell className="w-[10%] py-2.5 text-center">
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
{showUpdateNudge ? ( {showUpdateNudge ? (
@ -656,11 +672,14 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
)} )}
</div> </div>
</TableCell> </TableCell>
)}
{/* Notes cell (monthly state notes) */} {/* Notes cell (monthly state notes) */}
{showColumn('notes') && (
<TableCell className="w-[23%] py-2.5 border-l border-border pl-4"> <TableCell className="w-[23%] py-2.5 border-l border-border pl-4">
<NotesCell row={{ ...row, year, month }} refresh={refresh} /> <NotesCell row={{ ...row, year, month }} refresh={refresh} />
</TableCell> </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,41 +611,7 @@ export default function TrackerPage() {
</div> </div>
</div> </div>
{/* ── B: Bank status bar ── */} {showSearchSort && (
{bankTracking?.enabled && (
<div className={cn(
'flex flex-wrap items-center justify-between gap-x-4 gap-y-1 rounded-xl border px-4 py-2.5 text-xs',
Number(bankTracking.remaining ?? 0) >= 0
? '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 items-center gap-2">
<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" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-current" />
</span>
<span className="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>
<span className="font-semibold tabular-nums">
{Number(bankTracking.remaining ?? 0) < 0 ? '' : ''}{fmt(Math.abs(Number(bankTracking.remaining ?? 0)))} projected
</span>
</div>
)}
<SearchFilterPanel <SearchFilterPanel
title="Search & sort" title="Search & sort"
collapsed={searchPanelCollapsed} collapsed={searchPanelCollapsed}
@ -580,9 +692,10 @@ export default function TrackerPage() {
</span> </span>
</div> </div>
</SearchFilterPanel> </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() {