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>
|
||||||
|
{showColumn('due') && (
|
||||||
<SortableHead sortKey="due" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%]">Due</SortableHead>
|
<SortableHead sortKey="due" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%]">Due</SortableHead>
|
||||||
|
)}
|
||||||
|
{showColumn('expected') && (
|
||||||
<SortableHead sortKey="expected" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-center">Expected</SortableHead>
|
<SortableHead sortKey="expected" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-center">Expected</SortableHead>
|
||||||
|
)}
|
||||||
|
{showColumn('previous') && (
|
||||||
<SortableHead sortKey="previous" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-center">Last Month</SortableHead>
|
<SortableHead sortKey="previous" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-center">Last Month</SortableHead>
|
||||||
|
)}
|
||||||
|
{showColumn('paid') && (
|
||||||
<SortableHead sortKey="paid" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-center">Paid</SortableHead>
|
<SortableHead sortKey="paid" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-center">Paid</SortableHead>
|
||||||
|
)}
|
||||||
|
{showColumn('paid_date') && (
|
||||||
<SortableHead sortKey="paid_date" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%]">Paid Date</SortableHead>
|
<SortableHead sortKey="paid_date" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%]">Paid Date</SortableHead>
|
||||||
|
)}
|
||||||
|
{showColumn('status') && (
|
||||||
<SortableHead sortKey="status" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[9%] text-center">Status</SortableHead>
|
<SortableHead sortKey="status" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[9%] text-center">Status</SortableHead>
|
||||||
|
)}
|
||||||
|
{showColumn('action') && (
|
||||||
<TableHead className="h-11 w-[10%] py-2.5 text-center text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
<TableHead className="h-11 w-[10%] py-2.5 text-center text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||||
Action
|
Action
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
)}
|
||||||
|
{showColumn('notes') && (
|
||||||
<TableHead className="h-11 w-[23%] py-2.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground border-l border-border/80 pl-4">
|
<TableHead className="h-11 w-[23%] py-2.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground border-l border-border/80 pl-4">
|
||||||
Notes
|
Notes
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|
@ -240,26 +314,30 @@ export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, l
|
||||||
<div className="h-4 w-48 rounded-md bg-muted" />
|
<div className="h-4 w-48 rounded-md bg-muted" />
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="w-[10%] py-3"><div className="h-3 w-20 rounded-md bg-muted" /></TableCell>
|
{showColumn('due') && <TableCell className="w-[10%] py-3"><div className="h-3 w-20 rounded-md bg-muted" /></TableCell>}
|
||||||
<TableCell className="w-[10%] py-3 text-center"><div className="mx-auto h-3 w-20 rounded-md bg-muted" /></TableCell>
|
{showColumn('expected') && <TableCell className="w-[10%] py-3 text-center"><div className="mx-auto h-3 w-20 rounded-md bg-muted" /></TableCell>}
|
||||||
<TableCell className="w-[10%] py-3 text-center"><div className="mx-auto h-3 w-20 rounded-md bg-muted" /></TableCell>
|
{showColumn('previous') && <TableCell className="w-[10%] py-3 text-center"><div className="mx-auto h-3 w-20 rounded-md bg-muted" /></TableCell>}
|
||||||
<TableCell className="w-[10%] py-3"><div className="mx-auto h-7 w-24 rounded-md bg-muted" /></TableCell>
|
{showColumn('paid') && <TableCell className="w-[10%] py-3"><div className="mx-auto h-7 w-24 rounded-md bg-muted" /></TableCell>}
|
||||||
<TableCell className="w-[10%] py-3"><div className="h-7 w-24 rounded-md bg-muted" /></TableCell>
|
{showColumn('paid_date') && <TableCell className="w-[10%] py-3"><div className="h-7 w-24 rounded-md bg-muted" /></TableCell>}
|
||||||
<TableCell className="w-[9%] py-3"><div className="mx-auto h-5 w-20 rounded-md bg-muted" /></TableCell>
|
{showColumn('status') && <TableCell className="w-[9%] py-3"><div className="mx-auto h-5 w-20 rounded-md bg-muted" /></TableCell>}
|
||||||
|
{showColumn('action') && (
|
||||||
<TableCell className="w-[10%] py-3 text-center">
|
<TableCell className="w-[10%] py-3 text-center">
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
<div className="h-7 w-20 rounded-md bg-muted" />
|
<div className="h-7 w-20 rounded-md bg-muted" />
|
||||||
<div className="h-7 w-7 rounded-md bg-muted" />
|
<div className="h-7 w-7 rounded-md bg-muted" />
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{showColumn('notes') && (
|
||||||
<TableCell className="w-[23%] py-3 border-l border-border pl-4">
|
<TableCell className="w-[23%] py-3 border-l border-border pl-4">
|
||||||
<div className="h-4 w-full rounded-md bg-muted" />
|
<div className="h-4 w-full rounded-md bg-muted" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<TableRow className="border-border/50">
|
<TableRow className="border-border/50">
|
||||||
<TableCell colSpan={9} className="py-10 text-center text-sm text-muted-foreground">
|
<TableCell colSpan={tableColSpan} className="py-10 text-center text-sm text-muted-foreground">
|
||||||
No bills match — try adjusting your filters.
|
No bills match — try adjusting your filters.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -277,6 +355,7 @@ export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, l
|
||||||
moveControls={moveControlsFor(r, i)}
|
moveControls={moveControlsFor(r, i)}
|
||||||
dragProps={dragPropsFor(r, i)}
|
dragProps={dragPropsFor(r, i)}
|
||||||
isDrifted={driftedIds.has(r.id)}
|
isDrifted={driftedIds.has(r.id)}
|
||||||
|
visibleColumns={visibleColumns}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</LayoutGroup>
|
</LayoutGroup>
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,14 @@ import {
|
||||||
import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
|
import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
|
||||||
import PaymentModal from '@/components/tracker/PaymentModal';
|
import PaymentModal from '@/components/tracker/PaymentModal';
|
||||||
import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils';
|
import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils';
|
||||||
|
import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns';
|
||||||
import { StatusBadge } from '@/components/tracker/StatusBadge';
|
import { StatusBadge } from '@/components/tracker/StatusBadge';
|
||||||
import { PaymentProgress } from '@/components/tracker/PaymentProgress';
|
import { PaymentProgress } from '@/components/tracker/PaymentProgress';
|
||||||
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
|
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
|
||||||
import { NotesCell } from '@/components/tracker/NotesCell';
|
import { NotesCell } from '@/components/tracker/NotesCell';
|
||||||
import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions';
|
import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions';
|
||||||
|
|
||||||
export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted }) {
|
export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted, visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS }) {
|
||||||
const amountRef = useRef(null);
|
const amountRef = useRef(null);
|
||||||
const [editPayment, setEditPayment] = useState(null);
|
const [editPayment, setEditPayment] = useState(null);
|
||||||
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
|
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
|
||||||
|
|
@ -34,6 +35,8 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
const [showUpdateNudge, setShowUpdateNudge] = useState(false);
|
const [showUpdateNudge, setShowUpdateNudge] = useState(false);
|
||||||
const [nudgeAmount, setNudgeAmount] = useState(null);
|
const [nudgeAmount, setNudgeAmount] = useState(null);
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
const visibleColumnSet = new Set(visibleColumns);
|
||||||
|
const showColumn = key => visibleColumnSet.has(key);
|
||||||
|
|
||||||
const [editingExpected, setEditingExpected] = useState(false);
|
const [editingExpected, setEditingExpected] = useState(false);
|
||||||
const [expectedDraft, setExpectedDraft] = useState('');
|
const [expectedDraft, setExpectedDraft] = useState('');
|
||||||
|
|
@ -449,6 +452,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* Due */}
|
{/* Due */}
|
||||||
|
{showColumn('due') && (
|
||||||
<TableCell className="tracker-number w-[10%] py-2.5 text-[13px] font-medium text-foreground/75">
|
<TableCell className="tracker-number w-[10%] py-2.5 text-[13px] font-medium text-foreground/75">
|
||||||
{editingDue ? (
|
{editingDue ? (
|
||||||
<input
|
<input
|
||||||
|
|
@ -476,8 +480,10 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Expected / Actual — shows actual_amount in amber when it overrides the template */}
|
{/* Expected / Actual — shows actual_amount in amber when it overrides the template */}
|
||||||
|
{showColumn('expected') && (
|
||||||
<TableCell className="tracker-number w-[10%] py-2.5 text-center text-[13px] font-semibold">
|
<TableCell className="tracker-number w-[10%] py-2.5 text-center text-[13px] font-semibold">
|
||||||
{editingExpected ? (
|
{editingExpected ? (
|
||||||
<input
|
<input
|
||||||
|
|
@ -548,13 +554,17 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Previous month paid */}
|
{/* Previous month paid */}
|
||||||
|
{showColumn('previous') && (
|
||||||
<TableCell className="tracker-number w-[10%] py-2.5 text-center text-[13px] font-medium text-muted-foreground/80">
|
<TableCell className="tracker-number w-[10%] py-2.5 text-center text-[13px] font-medium text-muted-foreground/80">
|
||||||
{row.previous_month_paid > 0 ? fmt(row.previous_month_paid) : '—'}
|
{row.previous_month_paid > 0 ? fmt(row.previous_month_paid) : '—'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Amount paid — mismatch now compares against threshold */}
|
{/* Amount paid — mismatch now compares against threshold */}
|
||||||
|
{showColumn('paid') && (
|
||||||
<TableCell className="w-[10%] py-2.5 text-center">
|
<TableCell className="w-[10%] py-2.5 text-center">
|
||||||
<PaymentProgress
|
<PaymentProgress
|
||||||
row={row}
|
row={row}
|
||||||
|
|
@ -564,8 +574,10 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
onMarkFullAmount={!isSkipped ? handleMarkFullAmount : undefined}
|
onMarkFullAmount={!isSkipped ? handleMarkFullAmount : undefined}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Paid date */}
|
{/* Paid date */}
|
||||||
|
{showColumn('paid_date') && (
|
||||||
<TableCell className="w-[10%] py-2.5 text-[13px] text-foreground/75">
|
<TableCell className="w-[10%] py-2.5 text-[13px] text-foreground/75">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -577,8 +589,10 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
{summary.count > 1 && <span className="ml-1 text-[10px] text-muted-foreground">({summary.count})</span>}
|
{summary.count > 1 && <span className="ml-1 text-[10px] text-muted-foreground">({summary.count})</span>}
|
||||||
</button>
|
</button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Status — uses effectiveStatus (accounts for skipped + threshold) */}
|
{/* Status — uses effectiveStatus (accounts for skipped + threshold) */}
|
||||||
|
{showColumn('status') && (
|
||||||
<TableCell className="w-[9%] py-2.5">
|
<TableCell className="w-[9%] py-2.5">
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
|
|
@ -600,8 +614,10 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
{showColumn('action') && (
|
||||||
<TableCell className="w-[10%] py-2.5 text-center">
|
<TableCell className="w-[10%] py-2.5 text-center">
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
{showUpdateNudge ? (
|
{showUpdateNudge ? (
|
||||||
|
|
@ -656,11 +672,14 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Notes cell (monthly state notes) */}
|
{/* Notes cell (monthly state notes) */}
|
||||||
|
{showColumn('notes') && (
|
||||||
<TableCell className="w-[23%] py-2.5 border-l border-border pl-4">
|
<TableCell className="w-[23%] py-2.5 border-l border-border pl-4">
|
||||||
<NotesCell row={{ ...row, year, month }} refresh={refresh} />
|
<NotesCell row={{ ...row, year, month }} refresh={refresh} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
||||||
{editPayment && (
|
{editPayment && (
|
||||||
|
|
|
||||||
|
|
@ -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,41 +611,7 @@ export default function TrackerPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── B: Bank status bar ── */}
|
{showSearchSort && (
|
||||||
{bankTracking?.enabled && (
|
|
||||||
<div className={cn(
|
|
||||||
'flex flex-wrap items-center justify-between gap-x-4 gap-y-1 rounded-xl border px-4 py-2.5 text-xs',
|
|
||||||
Number(bankTracking.remaining ?? 0) >= 0
|
|
||||||
? 'border-emerald-500/20 bg-emerald-500/5 text-emerald-700 dark:text-emerald-400'
|
|
||||||
: 'border-destructive/20 bg-destructive/5 text-destructive',
|
|
||||||
)}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="relative flex h-2 w-2">
|
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-50" />
|
|
||||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-current" />
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold">{bankTracking.account_name}</span>
|
|
||||||
<span className="text-muted-foreground">·</span>
|
|
||||||
<span>{fmt(bankTracking.balance ?? 0)} balance</span>
|
|
||||||
{bankTracking.last_updated && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground">·</span>
|
|
||||||
<span className="text-muted-foreground">as of {fmtBalanceAge(bankTracking.last_updated)}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{Number(bankTracking.pending_payments ?? 0) > 0 && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground">·</span>
|
|
||||||
<span className="text-amber-600 dark:text-amber-400">{fmt(bankTracking.pending_payments)} pending</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="font-semibold tabular-nums">
|
|
||||||
{Number(bankTracking.remaining ?? 0) < 0 ? '−' : ''}{fmt(Math.abs(Number(bankTracking.remaining ?? 0)))} projected
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SearchFilterPanel
|
<SearchFilterPanel
|
||||||
title="Search & sort"
|
title="Search & sort"
|
||||||
collapsed={searchPanelCollapsed}
|
collapsed={searchPanelCollapsed}
|
||||||
|
|
@ -580,9 +692,10 @@ export default function TrackerPage() {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</SearchFilterPanel>
|
</SearchFilterPanel>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
|
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
|
||||||
{loading ? (
|
{showSummaryCards && loading ? (
|
||||||
<div className="grid grid-cols-2 gap-3 lg:flex" aria-busy="true">
|
<div className="grid grid-cols-2 gap-3 lg:flex" aria-busy="true">
|
||||||
<Skeleton variant="card" className="h-32" />
|
<Skeleton variant="card" className="h-32" />
|
||||||
<Skeleton variant="card" className="h-32" />
|
<Skeleton variant="card" className="h-32" />
|
||||||
|
|
@ -591,7 +704,7 @@ export default function TrackerPage() {
|
||||||
<Skeleton variant="card" className="h-32" />
|
<Skeleton variant="card" className="h-32" />
|
||||||
{summary.trend && <Skeleton variant="card" className="h-32" />}
|
{summary.trend && <Skeleton variant="card" className="h-32" />}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : showSummaryCards ? (
|
||||||
<div className="grid grid-cols-2 gap-3 lg:flex">
|
<div className="grid grid-cols-2 gap-3 lg:flex">
|
||||||
{bankTracking?.enabled ? (
|
{bankTracking?.enabled ? (
|
||||||
<button
|
<button
|
||||||
|
|
@ -653,10 +766,10 @@ export default function TrackerPage() {
|
||||||
<SummaryCard type="paid" value={summary.previous_month_total} hint="Previous month"/>
|
<SummaryCard type="paid" value={summary.previous_month_total} hint="Previous month"/>
|
||||||
{summary.trend && <TrendCard trend={summary.trend} />}
|
{summary.trend && <TrendCard trend={summary.trend} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{/* ── Overdue Command Center ── */}
|
{/* ── Overdue Command Center ── */}
|
||||||
{!isError && !loading && (summary?.count_late ?? 0) > 0 && (
|
{!isError && !loading && showOverdueCommandCenter && (summary?.count_late ?? 0) > 0 && (
|
||||||
<OverdueCommandCenter
|
<OverdueCommandCenter
|
||||||
rows={rows}
|
rows={rows}
|
||||||
year={year}
|
year={year}
|
||||||
|
|
@ -666,8 +779,18 @@ export default function TrackerPage() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Bank Projection Banner ── */}
|
||||||
|
{!isError && !loading && showBankProjectionBanner && (
|
||||||
|
<BankProjectionBanner
|
||||||
|
bankTracking={bankTracking}
|
||||||
|
busy={savingTrackerSetting}
|
||||||
|
onSnooze={snoozeBankProjectionBanner}
|
||||||
|
onIgnore={ignoreBankProjectionBanner}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Drift / Price-Change Insights ── */}
|
{/* ── Drift / Price-Change Insights ── */}
|
||||||
{!isError && !loading && (driftData?.bills?.length ?? 0) > 0 && (
|
{!isError && !loading && showDriftInsights && (driftData?.bills?.length ?? 0) > 0 && (
|
||||||
<DriftInsightPanel
|
<DriftInsightPanel
|
||||||
driftBills={driftData.bills}
|
driftBills={driftData.bills}
|
||||||
refresh={() => { refetch(); refetchDrift(); }}
|
refresh={() => { refetch(); refetchDrift(); }}
|
||||||
|
|
@ -742,8 +865,8 @@ export default function TrackerPage() {
|
||||||
)}
|
)}
|
||||||
{!isError && (first.length > 0 || second.length > 0) && (
|
{!isError && (first.length > 0 || second.length > 0) && (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{first.length > 0 && <Bucket label="1st – 14th" rows={first} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('1st', next)} driftedIds={driftedIds} />}
|
{first.length > 0 && <Bucket label="1st – 14th" rows={first} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('1st', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} onColumnToggle={handleTableColumnToggle} columnsSaving={savingTrackerSetting} />}
|
||||||
{second.length > 0 && <Bucket label="15th – 31st" rows={second} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('15th', next)} driftedIds={driftedIds} />}
|
{second.length > 0 && <Bucket label="15th – 31st" rows={second} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('15th', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} onColumnToggle={handleTableColumnToggle} columnsSaving={savingTrackerSetting} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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