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 { LayoutGroup } from 'framer-motion';
|
||||
import { ArrowDown, ArrowUp } from 'lucide-react';
|
||||
import { ArrowDown, ArrowUp, Settings2 } from 'lucide-react';
|
||||
import { cn, fmt } from '@/lib/utils';
|
||||
import {
|
||||
Table, TableHeader, TableBody, TableHead, TableRow, TableCell,
|
||||
} 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_TABLE_COLUMNS, DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns';
|
||||
import { TrackerRow as Row } from '@/components/tracker/TrackerRow';
|
||||
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 [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
|
||||
const activeRows = rows.filter(r => !r.is_skipped);
|
||||
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 && (
|
||||
<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>
|
||||
|
||||
|
|
@ -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="overflow-x-auto">
|
||||
<Table className="min-w-[1120px]">
|
||||
<Table className={cn(visibleColumns.length <= 4 ? 'min-w-[720px]' : 'min-w-[1120px]')}>
|
||||
<TableHeader>
|
||||
<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="due" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%]">Due</SortableHead>
|
||||
<SortableHead sortKey="expected" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-center">Expected</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>
|
||||
<SortableHead sortKey="paid_date" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%]">Paid Date</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">
|
||||
Action
|
||||
</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">
|
||||
Notes
|
||||
</TableHead>
|
||||
{showColumn('due') && (
|
||||
<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>
|
||||
)}
|
||||
{showColumn('previous') && (
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
</TableHeader>
|
||||
<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>
|
||||
</TableCell>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<TableCell className="w-[10%] py-3 text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<div className="h-7 w-20 rounded-md bg-muted" />
|
||||
<div className="h-7 w-7 rounded-md bg-muted" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-[23%] py-3 border-l border-border pl-4">
|
||||
<div className="h-4 w-full rounded-md bg-muted" />
|
||||
</TableCell>
|
||||
{showColumn('due') && <TableCell className="w-[10%] py-3"><div className="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>}
|
||||
{showColumn('previous') && <TableCell className="w-[10%] py-3 text-center"><div className="mx-auto h-3 w-20 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>}
|
||||
{showColumn('paid_date') && <TableCell className="w-[10%] py-3"><div className="h-7 w-24 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">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<div className="h-7 w-20 rounded-md bg-muted" />
|
||||
<div className="h-7 w-7 rounded-md bg-muted" />
|
||||
</div>
|
||||
</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>
|
||||
))
|
||||
) : rows.length === 0 ? (
|
||||
<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.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -277,6 +355,7 @@ export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, l
|
|||
moveControls={moveControlsFor(r, i)}
|
||||
dragProps={dragPropsFor(r, i)}
|
||||
isDrifted={driftedIds.has(r.id)}
|
||||
visibleColumns={visibleColumns}
|
||||
/>
|
||||
))}
|
||||
</LayoutGroup>
|
||||
|
|
|
|||
|
|
@ -16,13 +16,14 @@ import {
|
|||
import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
|
||||
import PaymentModal from '@/components/tracker/PaymentModal';
|
||||
import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils';
|
||||
import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns';
|
||||
import { StatusBadge } from '@/components/tracker/StatusBadge';
|
||||
import { PaymentProgress } from '@/components/tracker/PaymentProgress';
|
||||
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
|
||||
import { NotesCell } from '@/components/tracker/NotesCell';
|
||||
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 [editPayment, setEditPayment] = useState(null);
|
||||
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 [nudgeAmount, setNudgeAmount] = useState(null);
|
||||
const [, startTransition] = useTransition();
|
||||
const visibleColumnSet = new Set(visibleColumns);
|
||||
const showColumn = key => visibleColumnSet.has(key);
|
||||
|
||||
const [editingExpected, setEditingExpected] = useState(false);
|
||||
const [expectedDraft, setExpectedDraft] = useState('');
|
||||
|
|
@ -449,218 +452,234 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
|||
</TableCell>
|
||||
|
||||
{/* Due */}
|
||||
<TableCell className="tracker-number w-[10%] py-2.5 text-[13px] font-medium text-foreground/75">
|
||||
{editingDue ? (
|
||||
<input
|
||||
type="number"
|
||||
min="1" max="31"
|
||||
value={dueDraft}
|
||||
autoFocus
|
||||
onChange={e => setDueDraft(e.target.value)}
|
||||
onBlur={handleSaveDue}
|
||||
onKeyDown={e => {
|
||||
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)"
|
||||
/>
|
||||
) : (
|
||||
<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">
|
||||
{showColumn('due') && (
|
||||
<TableCell className="tracker-number w-[10%] py-2.5 text-[13px] font-medium text-foreground/75">
|
||||
{editingDue ? (
|
||||
<input
|
||||
type="number"
|
||||
min="1" max="31"
|
||||
value={dueDraft}
|
||||
autoFocus
|
||||
onChange={e => setDueDraft(e.target.value)}
|
||||
onBlur={handleSaveDue}
|
||||
onKeyDown={e => {
|
||||
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)"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setExpectedDraft(String(row.expected_amount)); setEditingExpected(true); }}
|
||||
className="rounded px-1 py-0.5 text-center text-foreground/85 transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Click to edit expected amount"
|
||||
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"
|
||||
>
|
||||
{fmt(row.expected_amount)}
|
||||
{fmtDate(row.due_date)}
|
||||
</button>
|
||||
{isDrifted && (
|
||||
<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
|
||||
</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 && (
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
{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
|
||||
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`}
|
||||
onClick={() => { setExpectedDraft(String(row.expected_amount)); setEditingExpected(true); }}
|
||||
className="rounded px-1 py-0.5 text-center text-foreground/85 transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Click to edit expected amount"
|
||||
>
|
||||
~{fmt(row.amount_suggestion.suggestion)}
|
||||
{fmt(row.expected_amount)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
{isDrifted && (
|
||||
<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
|
||||
</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 */}
|
||||
<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) : '—'}
|
||||
</TableCell>
|
||||
{showColumn('previous') && (
|
||||
<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) : '—'}
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* Amount paid — mismatch now compares against threshold */}
|
||||
<TableCell className="w-[10%] py-2.5 text-center">
|
||||
<PaymentProgress
|
||||
row={row}
|
||||
threshold={threshold}
|
||||
className="mx-auto max-w-[7.5rem]"
|
||||
onOpen={() => setPaymentLedgerOpen(true)}
|
||||
onMarkFullAmount={!isSkipped ? handleMarkFullAmount : undefined}
|
||||
/>
|
||||
</TableCell>
|
||||
{showColumn('paid') && (
|
||||
<TableCell className="w-[10%] py-2.5 text-center">
|
||||
<PaymentProgress
|
||||
row={row}
|
||||
threshold={threshold}
|
||||
className="mx-auto max-w-[7.5rem]"
|
||||
onOpen={() => setPaymentLedgerOpen(true)}
|
||||
onMarkFullAmount={!isSkipped ? handleMarkFullAmount : undefined}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* Paid date */}
|
||||
<TableCell className="w-[10%] py-2.5 text-[13px] text-foreground/75">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentLedgerOpen(true)}
|
||||
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>}
|
||||
</button>
|
||||
</TableCell>
|
||||
{showColumn('paid_date') && (
|
||||
<TableCell className="w-[10%] py-2.5 text-[13px] text-foreground/75">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentLedgerOpen(true)}
|
||||
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>}
|
||||
</button>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* Status — uses effectiveStatus (accounts for skipped + threshold) */}
|
||||
<TableCell className="w-[9%] py-2.5">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<StatusBadge
|
||||
status={effectiveStatus}
|
||||
clickable
|
||||
onClick={() => {
|
||||
if (effectiveStatus === 'skipped') return;
|
||||
handleTogglePaid();
|
||||
}}
|
||||
loading={loading}
|
||||
/>
|
||||
{row.pending_cleared && (
|
||||
<span
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
{showColumn('status') && (
|
||||
<TableCell className="w-[9%] py-2.5">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<StatusBadge
|
||||
status={effectiveStatus}
|
||||
clickable
|
||||
onClick={() => {
|
||||
if (effectiveStatus === 'skipped') return;
|
||||
handleTogglePaid();
|
||||
}}
|
||||
loading={loading}
|
||||
/>
|
||||
{row.pending_cleared && (
|
||||
<span
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<TableCell className="w-[10%] py-2.5 text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{showUpdateNudge ? (
|
||||
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-1 duration-200">
|
||||
<span className="text-[10px] text-muted-foreground">Update default?</span>
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUpdateNudge(false)}
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Dismiss"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{hasAutopaySuggestion && (
|
||||
<AutopaySuggestionActions
|
||||
row={row}
|
||||
loading={suggestionLoading}
|
||||
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"
|
||||
{showColumn('action') && (
|
||||
<TableCell className="w-[10%] py-2.5 text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{showUpdateNudge ? (
|
||||
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-1 duration-200">
|
||||
<span className="text-[10px] text-muted-foreground">Update default?</span>
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUpdateNudge(false)}
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Dismiss"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{hasAutopaySuggestion && (
|
||||
<AutopaySuggestionActions
|
||||
row={row}
|
||||
loading={suggestionLoading}
|
||||
onConfirm={handleConfirmSuggestion}
|
||||
onDismiss={handleDismissSuggestion}
|
||||
/>
|
||||
<Button
|
||||
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>
|
||||
)}
|
||||
{/* 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"
|
||||
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) */}
|
||||
<TableCell className="w-[23%] py-2.5 border-l border-border pl-4">
|
||||
<NotesCell row={{ ...row, year, month }} refresh={refresh} />
|
||||
</TableCell>
|
||||
{showColumn('notes') && (
|
||||
<TableCell className="w-[23%] py-2.5 border-l border-border pl-4">
|
||||
<NotesCell row={{ ...row, year, month }} refresh={refresh} />
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
|
||||
{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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsPage() {
|
||||
|
|
@ -246,6 +251,12 @@ export default function SettingsPage() {
|
|||
date_format: 'MM/DD/YYYY',
|
||||
grace_period_days: 3,
|
||||
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);
|
||||
|
|
@ -274,6 +285,12 @@ export default function SettingsPage() {
|
|||
date_format: settings.date_format,
|
||||
grace_period_days: settings.grace_period_days,
|
||||
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.');
|
||||
} catch (err) {
|
||||
|
|
@ -351,6 +368,60 @@ export default function SettingsPage() {
|
|||
</SettingRow>
|
||||
</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 */}
|
||||
<SectionCard title="Billing Behavior">
|
||||
<LinkImportToggle />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
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 { api } from '@/api.js';
|
||||
import { useTracker, useDriftReport } from '@/hooks/useQueries';
|
||||
|
|
@ -30,6 +30,7 @@ import {
|
|||
TRACKER_SORT_DEFAULT_DIRS, TRACKER_SORT_LABELS,
|
||||
normalizeTrackerSortKey, normalizeTrackerSortDir, sortTrackerRows,
|
||||
} from '@/lib/trackerUtils';
|
||||
import { parseTrackerTableColumns, trackerTableColumnsToSetting } from '@/lib/trackerTableColumns';
|
||||
import { FilterChip } from '@/components/tracker/FilterChip';
|
||||
import { SummaryCard, TrendCard } from '@/components/tracker/SummaryCards';
|
||||
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' });
|
||||
}
|
||||
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
function LateAttributionDialog({ attr, remaining, busy, onAccept, onDismiss }) {
|
||||
if (!attr) return null;
|
||||
|
|
@ -127,6 +203,16 @@ export default function TrackerPage() {
|
|||
const [orderedRows, setOrderedRows] = useState(null);
|
||||
const [movingBillId, setMovingBillId] = useState(null);
|
||||
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
|
||||
const [commandCenterPayRow, setCommandCenterPayRow] = useState(null);
|
||||
|
|
@ -148,6 +234,12 @@ export default function TrackerPage() {
|
|||
.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
|
||||
useEffect(() => {
|
||||
function handler(e) {
|
||||
|
|
@ -245,6 +337,60 @@ export default function TrackerPage() {
|
|||
const summary = data?.summary || {};
|
||||
const bankTracking = data?.bank_tracking;
|
||||
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 paramMap = { autopay: 'ap', firstBucket: 'b1', fifteenthBucket: 'b2', unpaid: 'un', overdue: 'ov', debt: 'de' };
|
||||
updateParams({ [paramMap[key]]: !filters[key] });
|
||||
|
|
@ -465,124 +611,91 @@ export default function TrackerPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── B: Bank status bar ── */}
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
{showSearchSort && (
|
||||
<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>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{Number(bankTracking.remaining ?? 0) < 0 ? '−' : ''}{fmt(Math.abs(Number(bankTracking.remaining ?? 0)))} projected
|
||||
</span>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<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) ── */}
|
||||
{loading ? (
|
||||
{showSummaryCards && loading ? (
|
||||
<div className="grid grid-cols-2 gap-3 lg:flex" aria-busy="true">
|
||||
<Skeleton variant="card" className="h-32" />
|
||||
<Skeleton variant="card" className="h-32" />
|
||||
|
|
@ -591,7 +704,7 @@ export default function TrackerPage() {
|
|||
<Skeleton variant="card" className="h-32" />
|
||||
{summary.trend && <Skeleton variant="card" className="h-32" />}
|
||||
</div>
|
||||
) : (
|
||||
) : showSummaryCards ? (
|
||||
<div className="grid grid-cols-2 gap-3 lg:flex">
|
||||
{bankTracking?.enabled ? (
|
||||
<button
|
||||
|
|
@ -653,10 +766,10 @@ export default function TrackerPage() {
|
|||
<SummaryCard type="paid" value={summary.previous_month_total} hint="Previous month"/>
|
||||
{summary.trend && <TrendCard trend={summary.trend} />}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* ── Overdue Command Center ── */}
|
||||
{!isError && !loading && (summary?.count_late ?? 0) > 0 && (
|
||||
{!isError && !loading && showOverdueCommandCenter && (summary?.count_late ?? 0) > 0 && (
|
||||
<OverdueCommandCenter
|
||||
rows={rows}
|
||||
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 ── */}
|
||||
{!isError && !loading && (driftData?.bills?.length ?? 0) > 0 && (
|
||||
{!isError && !loading && showDriftInsights && (driftData?.bills?.length ?? 0) > 0 && (
|
||||
<DriftInsightPanel
|
||||
driftBills={driftData.bills}
|
||||
refresh={() => { refetch(); refetchDrift(); }}
|
||||
|
|
@ -742,8 +865,8 @@ export default function TrackerPage() {
|
|||
)}
|
||||
{!isError && (first.length > 0 || second.length > 0) && (
|
||||
<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} />}
|
||||
{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} />}
|
||||
{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} visibleColumns={visibleTableColumns} onColumnToggle={handleTableColumnToggle} columnsSaving={savingTrackerSetting} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,10 +13,24 @@ const USER_SETTING_KEYS = [
|
|||
'bank_tracking_pending_days',
|
||||
'bank_late_attribution_days',
|
||||
'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 = {
|
||||
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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue