fix(tracker): table columns and settings improvements
This commit is contained in:
parent
955fb96aec
commit
f7ad1c1ebb
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 (1–31)"
|
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 (1–31)"
|
||||||
) : (
|
/>
|
||||||
<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 && (
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue