fix(tracker): search filter and bucket improvements
This commit is contained in:
parent
f7ad1c1ebb
commit
f2f9ad83ac
|
|
@ -13,6 +13,7 @@ export default function SearchFilterPanel({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
variant = 'default',
|
variant = 'default',
|
||||||
|
headerActions,
|
||||||
}) {
|
}) {
|
||||||
const embedded = variant === 'embedded';
|
const embedded = variant === 'embedded';
|
||||||
const ToggleIcon = collapsed ? ChevronUp : ChevronDown;
|
const ToggleIcon = collapsed ? ChevronUp : ChevronDown;
|
||||||
|
|
@ -49,6 +50,7 @@ export default function SearchFilterPanel({
|
||||||
Clear
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{headerActions}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,12 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { LayoutGroup } from 'framer-motion';
|
import { LayoutGroup } from 'framer-motion';
|
||||||
import { ArrowDown, ArrowUp, Settings2 } from 'lucide-react';
|
import { ArrowDown, ArrowUp } 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 { 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';
|
||||||
|
|
||||||
|
|
@ -61,8 +56,6 @@ export function TrackerBucket({
|
||||||
onSort,
|
onSort,
|
||||||
driftedIds = new Set(),
|
driftedIds = new Set(),
|
||||||
visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS,
|
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);
|
||||||
|
|
@ -185,37 +178,6 @@ export function TrackerBucket({
|
||||||
{!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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, BellOff, EyeOff } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, Eye, EyeOff, Settings2 } 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';
|
||||||
|
|
@ -19,6 +19,10 @@ import {
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog';
|
import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog';
|
||||||
import OverdueCommandCenter from '@/components/tracker/OverdueCommandCenter';
|
import OverdueCommandCenter from '@/components/tracker/OverdueCommandCenter';
|
||||||
|
|
@ -30,7 +34,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 { TRACKER_TABLE_COLUMNS, 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';
|
||||||
|
|
@ -118,6 +122,44 @@ function BankProjectionBanner({ bankTracking, onSnooze, onIgnore, busy }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TrackerColumnMenu({ visibleColumns, onColumnToggle, saving }) {
|
||||||
|
const visibleSet = new Set(visibleColumns);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 gap-1.5 px-2.5 text-xs"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<Settings2 className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">Columns</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
<DropdownMenuLabel>Table columns</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuCheckboxItem checked disabled>
|
||||||
|
Bill
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
{TRACKER_TABLE_COLUMNS.map(column => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={column.key}
|
||||||
|
checked={visibleSet.has(column.key)}
|
||||||
|
onSelect={event => event.preventDefault()}
|
||||||
|
onCheckedChange={checked => onColumnToggle?.(column.key, checked)}
|
||||||
|
>
|
||||||
|
{column.label}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── 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;
|
||||||
|
|
@ -391,6 +433,18 @@ export default function TrackerPage() {
|
||||||
tracker_table_columns: trackerTableColumnsToSetting(nextColumns),
|
tracker_table_columns: trackerTableColumnsToSetting(nextColumns),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hideSearchSortPanel() {
|
||||||
|
saveTrackerSettings({
|
||||||
|
tracker_show_search_sort: 'false',
|
||||||
|
}, 'Search & sort hidden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSearchSortPanel() {
|
||||||
|
saveTrackerSettings({
|
||||||
|
tracker_show_search_sort: 'true',
|
||||||
|
});
|
||||||
|
}
|
||||||
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] });
|
||||||
|
|
@ -438,6 +492,8 @@ export default function TrackerPage() {
|
||||||
|| filters.debt
|
|| filters.debt
|
||||||
|| hasSort
|
|| hasSort
|
||||||
);
|
);
|
||||||
|
const searchResultLabel = `${filteredRows.length} of ${rows.length} shown`;
|
||||||
|
const searchSortLabel = hasSort ? `sorted by ${TRACKER_SORT_LABELS[sortKey]} ${sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}` : null;
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
updateParams({ q: null, fc: null, cy: null, ap: null, b1: null, b2: null, un: null, ov: null, de: null, sort: null, dir: null });
|
updateParams({ q: null, fc: null, cy: null, ap: null, b1: null, b2: null, un: null, ov: null, de: null, sort: null, dir: null });
|
||||||
};
|
};
|
||||||
|
|
@ -617,9 +673,29 @@ export default function TrackerPage() {
|
||||||
collapsed={searchPanelCollapsed}
|
collapsed={searchPanelCollapsed}
|
||||||
onCollapsedChange={setSearchPanelCollapsed}
|
onCollapsedChange={setSearchPanelCollapsed}
|
||||||
hasFilters={hasFilters}
|
hasFilters={hasFilters}
|
||||||
resultLabel={`${filteredRows.length} of ${rows.length} shown`}
|
resultLabel={searchResultLabel}
|
||||||
sortLabel={hasSort ? `sorted by ${TRACKER_SORT_LABELS[sortKey]} ${sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}` : null}
|
sortLabel={searchSortLabel}
|
||||||
onClear={resetFilters}
|
onClear={resetFilters}
|
||||||
|
headerActions={(
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrackerColumnMenu
|
||||||
|
visibleColumns={visibleTableColumns}
|
||||||
|
onColumnToggle={handleTableColumnToggle}
|
||||||
|
saving={savingTrackerSetting}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={hideSearchSortPanel}
|
||||||
|
disabled={savingTrackerSetting}
|
||||||
|
className="h-8 gap-1.5 px-2.5 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<EyeOff className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">Hide</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-[minmax(220px,1fr)_220px_180px_220px_auto] xl:items-center">
|
<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">
|
<label className="relative">
|
||||||
|
|
@ -693,6 +769,30 @@ export default function TrackerPage() {
|
||||||
</div>
|
</div>
|
||||||
</SearchFilterPanel>
|
</SearchFilterPanel>
|
||||||
)}
|
)}
|
||||||
|
{!showSearchSort && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={showSearchSortPanel}
|
||||||
|
disabled={savingTrackerSetting}
|
||||||
|
className="group flex w-full items-center justify-between gap-3 rounded-xl border border-dashed border-border/80 bg-card/55 px-4 py-3 text-left shadow-sm transition-colors hover:border-primary/40 hover:bg-card/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-3">
|
||||||
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-semibold text-foreground">Show Search & sort</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{[searchResultLabel, hasFilters ? 'filters active' : 'no filters', searchSortLabel].filter(Boolean).join(' · ')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground transition-colors group-hover:bg-accent group-hover:text-foreground">
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
Restore
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
|
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
|
||||||
{showSummaryCards && loading ? (
|
{showSummaryCards && loading ? (
|
||||||
|
|
@ -865,8 +965,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} visibleColumns={visibleTableColumns} onColumnToggle={handleTableColumnToggle} columnsSaving={savingTrackerSetting} />}
|
{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} />}
|
||||||
{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} />}
|
{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} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue