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>
|
||||
{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>
|
||||
{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,6 +452,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
|||
</TableCell>
|
||||
|
||||
{/* Due */}
|
||||
{showColumn('due') && (
|
||||
<TableCell className="tracker-number w-[10%] py-2.5 text-[13px] font-medium text-foreground/75">
|
||||
{editingDue ? (
|
||||
<input
|
||||
|
|
@ -476,8 +480,10 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
|||
</button>
|
||||
)}
|
||||
</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
|
||||
|
|
@ -548,13 +554,17 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
|||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* Previous month paid */}
|
||||
{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 */}
|
||||
{showColumn('paid') && (
|
||||
<TableCell className="w-[10%] py-2.5 text-center">
|
||||
<PaymentProgress
|
||||
row={row}
|
||||
|
|
@ -564,8 +574,10 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
|||
onMarkFullAmount={!isSkipped ? handleMarkFullAmount : undefined}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* Paid date */}
|
||||
{showColumn('paid_date') && (
|
||||
<TableCell className="w-[10%] py-2.5 text-[13px] text-foreground/75">
|
||||
<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>}
|
||||
</button>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* Status — uses effectiveStatus (accounts for skipped + threshold) */}
|
||||
{showColumn('status') && (
|
||||
<TableCell className="w-[9%] py-2.5">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<StatusBadge
|
||||
|
|
@ -600,8 +614,10 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
|||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{showColumn('action') && (
|
||||
<TableCell className="w-[10%] py-2.5 text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{showUpdateNudge ? (
|
||||
|
|
@ -656,11 +672,14 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
|||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* Notes cell (monthly state notes) */}
|
||||
{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,41 +611,7 @@ 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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{Number(bankTracking.remaining ?? 0) < 0 ? '−' : ''}{fmt(Math.abs(Number(bankTracking.remaining ?? 0)))} projected
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSearchSort && (
|
||||
<SearchFilterPanel
|
||||
title="Search & sort"
|
||||
collapsed={searchPanelCollapsed}
|
||||
|
|
@ -580,9 +692,10 @@ export default function TrackerPage() {
|
|||
</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