feat: search filter panel component, search preference persistence, page integration
This commit is contained in:
parent
ab5e3fbf1f
commit
d9cf499dba
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { ChevronDown, ChevronUp, Search, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export default function SearchFilterPanel({
|
||||||
|
title = 'Search & filters',
|
||||||
|
collapsed,
|
||||||
|
onCollapsedChange,
|
||||||
|
hasFilters,
|
||||||
|
resultLabel,
|
||||||
|
sortLabel,
|
||||||
|
onClear,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
}) {
|
||||||
|
const embedded = variant === 'embedded';
|
||||||
|
const ToggleIcon = collapsed ? ChevronUp : ChevronDown;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={cn(
|
||||||
|
embedded
|
||||||
|
? 'rounded-lg'
|
||||||
|
: 'rounded-xl border border-border/80 bg-card/95 shadow-sm shadow-black/15',
|
||||||
|
className,
|
||||||
|
)}>
|
||||||
|
<div className={cn('flex flex-wrap items-center gap-3', embedded ? 'py-1' : 'px-4 py-3')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onCollapsedChange?.(!collapsed)}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
className="group inline-flex min-w-0 flex-1 items-center gap-3 rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<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">{title}</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{[resultLabel, hasFilters ? 'filters active' : 'no filters', sortLabel].filter(Boolean).join(' · ')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ToggleIcon className="ml-auto h-4 w-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{hasFilters && onClear && (
|
||||||
|
<Button type="button" variant="ghost" onClick={onClear} className="h-8 gap-2 px-2.5 text-xs">
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<div className={cn(
|
||||||
|
'space-y-3',
|
||||||
|
embedded ? 'pt-3' : 'border-t border-border/60 px-4 py-4',
|
||||||
|
)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { api } from '@/api';
|
||||||
|
|
||||||
|
const SETTING_KEY = 'search_bars_collapsed';
|
||||||
|
|
||||||
|
function settingToBool(value) {
|
||||||
|
return value === true || value === 'true' || value === '1' || value === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSearchPanelPreference() {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
api.settings()
|
||||||
|
.then(settings => {
|
||||||
|
if (mounted && settings?.[SETTING_KEY] !== undefined && settings?.[SETTING_KEY] !== null) {
|
||||||
|
setCollapsed(settingToBool(settings[SETTING_KEY]));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
return () => { mounted = false; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveCollapsed = useCallback((nextCollapsed) => {
|
||||||
|
setCollapsed(nextCollapsed);
|
||||||
|
api.saveSettings({ [SETTING_KEY]: nextCollapsed ? 'true' : 'false' }).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [collapsed, saveCollapsed];
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Plus, ChevronRight, SlidersHorizontal, Search, Trash2, X } from 'lucide-react';
|
import { Plus, ChevronRight, SlidersHorizontal, Search, Trash2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import SearchFilterPanel from '@/components/SearchFilterPanel';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||||
|
|
@ -18,6 +19,7 @@ import {
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
|
||||||
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
|
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
|
||||||
import BillsTableInner from '@/components/BillsTableInner';
|
import BillsTableInner from '@/components/BillsTableInner';
|
||||||
import BillModal from '@/components/BillModal';
|
import BillModal from '@/components/BillModal';
|
||||||
|
|
@ -629,6 +631,7 @@ export default function BillsPage() {
|
||||||
const [billsSort, setBillsSort] = useState(() => (
|
const [billsSort, setBillsSort] = useState(() => (
|
||||||
localStorage.getItem(BILLS_SORT_KEY) === 'cadence' ? 'cadence' : 'custom'
|
localStorage.getItem(BILLS_SORT_KEY) === 'cadence' ? 'cadence' : 'custom'
|
||||||
));
|
));
|
||||||
|
const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(BILLS_SORT_KEY, billsSort);
|
localStorage.setItem(BILLS_SORT_KEY, billsSort);
|
||||||
|
|
@ -944,8 +947,16 @@ export default function BillsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="surface-elevated rounded-xl p-4 space-y-3">
|
<SearchFilterPanel
|
||||||
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_220px_180px_auto] lg:items-center">
|
title="Search & filters"
|
||||||
|
collapsed={searchPanelCollapsed}
|
||||||
|
onCollapsedChange={setSearchPanelCollapsed}
|
||||||
|
hasFilters={hasFilters}
|
||||||
|
resultLabel={`${filteredBills.length} of ${bills.length} shown`}
|
||||||
|
onClear={resetFilters}
|
||||||
|
className="surface-elevated"
|
||||||
|
>
|
||||||
|
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_220px_180px] lg:items-center">
|
||||||
<label className="relative">
|
<label className="relative">
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -977,16 +988,6 @@ export default function BillsPage() {
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={!hasFilters}
|
|
||||||
onClick={resetFilters}
|
|
||||||
className="h-10 justify-center gap-2 text-xs"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<FilterChip active={filters.autopay} onClick={() => toggleFilter('autopay')}>Autopay</FilterChip>
|
<FilterChip active={filters.autopay} onClick={() => toggleFilter('autopay')}>Autopay</FilterChip>
|
||||||
|
|
@ -998,7 +999,7 @@ export default function BillsPage() {
|
||||||
{filteredBills.length} of {bills.length} shown
|
{filteredBills.length} of {bills.length} shown
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SearchFilterPanel>
|
||||||
|
|
||||||
{/* ── Active Bills ── */}
|
{/* ── Active Bills ── */}
|
||||||
{!filters.inactive && (
|
{!filters.inactive && (
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import SearchFilterPanel from '@/components/SearchFilterPanel';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
|
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
|
@ -45,6 +46,7 @@ import {
|
||||||
import BillModal from '@/components/BillModal';
|
import BillModal from '@/components/BillModal';
|
||||||
import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog';
|
import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog';
|
||||||
import { getLinkImportPref } from '@/pages/SettingsPage';
|
import { getLinkImportPref } from '@/pages/SettingsPage';
|
||||||
|
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
|
||||||
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
|
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
|
||||||
|
|
||||||
const TYPE_LABELS = {
|
const TYPE_LABELS = {
|
||||||
|
|
@ -967,6 +969,7 @@ export default function SubscriptionsPage() {
|
||||||
const [draggingId, setDraggingId] = useState(null);
|
const [draggingId, setDraggingId] = useState(null);
|
||||||
const [dropTargetId, setDropTargetId] = useState(null);
|
const [dropTargetId, setDropTargetId] = useState(null);
|
||||||
const [movingBillId, setMovingBillId] = useState(null);
|
const [movingBillId, setMovingBillId] = useState(null);
|
||||||
|
const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();
|
||||||
|
|
||||||
const [txQuery, setTxQuery] = useState('');
|
const [txQuery, setTxQuery] = useState('');
|
||||||
const [txResults, setTxResults] = useState([]);
|
const [txResults, setTxResults] = useState([]);
|
||||||
|
|
@ -1415,25 +1418,35 @@ export default function SubscriptionsPage() {
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<SearchFilterPanel
|
||||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
title="Search subscriptions"
|
||||||
<Input
|
collapsed={searchPanelCollapsed}
|
||||||
value={subSearch}
|
onCollapsedChange={setSearchPanelCollapsed}
|
||||||
onChange={e => setSubSearch(e.target.value)}
|
hasFilters={!!subSearch.trim()}
|
||||||
placeholder="Search subscriptions…"
|
resultLabel={`${filteredSubscriptions.length} of ${subscriptions.length} shown`}
|
||||||
className="h-8 pl-8 pr-8 text-sm"
|
onClear={() => setSubSearch('')}
|
||||||
/>
|
variant="embedded"
|
||||||
{subSearch && (
|
>
|
||||||
<button
|
<div className="relative">
|
||||||
type="button"
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
onClick={() => setSubSearch('')}
|
<Input
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
value={subSearch}
|
||||||
aria-label="Clear search"
|
onChange={e => setSubSearch(e.target.value)}
|
||||||
>
|
placeholder="Search subscriptions…"
|
||||||
<X className="h-3.5 w-3.5" />
|
className="h-8 pl-8 pr-8 text-sm"
|
||||||
</button>
|
/>
|
||||||
)}
|
{subSearch && (
|
||||||
</div>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSubSearch('')}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SearchFilterPanel>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|
@ -1470,16 +1483,26 @@ export default function SubscriptionsPage() {
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>Known subscription services found in your bank transactions with 90%+ confidence.</CardDescription>
|
<CardDescription>Known subscription services found in your bank transactions with 90%+ confidence.</CardDescription>
|
||||||
{!recommendationsLoading && highConfidenceRecs.length > 0 && (
|
{!recommendationsLoading && highConfidenceRecs.length > 0 && (
|
||||||
<div className="relative mt-2">
|
<SearchFilterPanel
|
||||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
title="Search recommendations"
|
||||||
<Input
|
collapsed={searchPanelCollapsed}
|
||||||
type="search"
|
onCollapsedChange={setSearchPanelCollapsed}
|
||||||
placeholder="Search recommendations…"
|
hasFilters={!!recSearch.trim()}
|
||||||
value={recSearch}
|
resultLabel={`${filteredRecs.length} of ${highConfidenceRecs.length} shown`}
|
||||||
onChange={e => setRecSearch(e.target.value)}
|
onClear={() => setRecSearch('')}
|
||||||
className="pl-8 h-8 text-sm"
|
variant="embedded"
|
||||||
/>
|
>
|
||||||
</div>
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search recommendations…"
|
||||||
|
value={recSearch}
|
||||||
|
onChange={e => setRecSearch(e.target.value)}
|
||||||
|
className="pl-8 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SearchFilterPanel>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
|
|
@ -1538,17 +1561,27 @@ export default function SubscriptionsPage() {
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Search all account charges — matched and unmatched — to find subscriptions the algorithm may have missed.
|
Search all account charges — matched and unmatched — to find subscriptions the algorithm may have missed.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
<div className="relative mt-2">
|
<SearchFilterPanel
|
||||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
title="Search transactions"
|
||||||
<Input
|
collapsed={searchPanelCollapsed}
|
||||||
type="search"
|
onCollapsedChange={setSearchPanelCollapsed}
|
||||||
placeholder="Search by merchant, description, or payee…"
|
hasFilters={!!txQuery.trim()}
|
||||||
value={txQuery}
|
resultLabel={txQuery.trim() ? `${txResults.length} result${txResults.length === 1 ? '' : 's'}` : 'all bank transactions'}
|
||||||
onChange={e => setTxQuery(e.target.value)}
|
onClear={() => setTxQuery('')}
|
||||||
className="pl-8 h-9 text-sm"
|
variant="embedded"
|
||||||
autoComplete="off"
|
>
|
||||||
/>
|
<div className="relative">
|
||||||
</div>
|
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search by merchant, description, or payee…"
|
||||||
|
value={txQuery}
|
||||||
|
onChange={e => setTxQuery(e.target.value)}
|
||||||
|
className="pl-8 h-9 text-sm"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SearchFilterPanel>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{(txQuery.trim() || txSearching) && (
|
{(txQuery.trim() || txSearching) && (
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
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, X, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown } 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';
|
||||||
|
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
|
||||||
import BillModal from '@/components/BillModal';
|
import BillModal from '@/components/BillModal';
|
||||||
import { makeBillDraft } from '@/lib/billDrafts';
|
import { makeBillDraft } from '@/lib/billDrafts';
|
||||||
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
|
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
|
||||||
import { cn, fmt } from '@/lib/utils';
|
import { cn, fmt } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import SearchFilterPanel from '@/components/SearchFilterPanel';
|
||||||
import { Skeleton } from '@/components/ui/Skeleton';
|
import { Skeleton } from '@/components/ui/Skeleton';
|
||||||
import {
|
import {
|
||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
|
|
@ -124,6 +126,7 @@ export default function TrackerPage() {
|
||||||
const [incomeModalOpen, setIncomeModalOpen] = useState(false);
|
const [incomeModalOpen, setIncomeModalOpen] = useState(false);
|
||||||
const [orderedRows, setOrderedRows] = useState(null);
|
const [orderedRows, setOrderedRows] = useState(null);
|
||||||
const [movingBillId, setMovingBillId] = useState(null);
|
const [movingBillId, setMovingBillId] = useState(null);
|
||||||
|
const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();
|
||||||
|
|
||||||
// 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);
|
||||||
|
|
@ -496,8 +499,16 @@ export default function TrackerPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="rounded-xl border border-border/80 bg-card/95 p-4 shadow-sm shadow-black/15 space-y-3">
|
<SearchFilterPanel
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-[minmax(220px,1fr)_220px_180px_220px_auto_auto] xl:items-center">
|
title="Search & sort"
|
||||||
|
collapsed={searchPanelCollapsed}
|
||||||
|
onCollapsedChange={setSearchPanelCollapsed}
|
||||||
|
hasFilters={hasFilters}
|
||||||
|
resultLabel={`${filteredRows.length} of ${rows.length} shown`}
|
||||||
|
sortLabel={hasSort ? `sorted by ${TRACKER_SORT_LABELS[sortKey]} ${sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}` : null}
|
||||||
|
onClear={resetFilters}
|
||||||
|
>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-[minmax(220px,1fr)_220px_180px_220px_auto] xl:items-center">
|
||||||
<label className="relative">
|
<label className="relative">
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -550,16 +561,6 @@ export default function TrackerPage() {
|
||||||
{sortDir === TRACKER_SORT_ASC ? <ArrowUp className="h-3.5 w-3.5" /> : <ArrowDown className="h-3.5 w-3.5" />}
|
{sortDir === TRACKER_SORT_ASC ? <ArrowUp className="h-3.5 w-3.5" /> : <ArrowDown className="h-3.5 w-3.5" />}
|
||||||
<span>{sortDir === TRACKER_SORT_ASC ? 'Asc' : 'Desc'}</span>
|
<span>{sortDir === TRACKER_SORT_ASC ? 'Asc' : 'Desc'}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={!hasFilters}
|
|
||||||
onClick={resetFilters}
|
|
||||||
className="h-10 justify-center gap-2 text-xs"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<FilterChip active={filters.unpaid} onClick={() => toggleFilter('unpaid')}>Unpaid</FilterChip>
|
<FilterChip active={filters.unpaid} onClick={() => toggleFilter('unpaid')}>Unpaid</FilterChip>
|
||||||
|
|
@ -577,7 +578,7 @@ export default function TrackerPage() {
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SearchFilterPanel>
|
||||||
|
|
||||||
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
|
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,17 @@ const USER_SETTING_KEYS = [
|
||||||
'bank_tracking_account_id',
|
'bank_tracking_account_id',
|
||||||
'bank_tracking_pending_days',
|
'bank_tracking_pending_days',
|
||||||
'bank_late_attribution_days',
|
'bank_late_attribution_days',
|
||||||
|
'search_bars_collapsed',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const USER_SETTING_DEFAULTS = {
|
||||||
|
search_bars_collapsed: 'false',
|
||||||
|
};
|
||||||
|
|
||||||
function defaultUserSettings() {
|
function defaultUserSettings() {
|
||||||
const defaults = {};
|
const defaults = {};
|
||||||
for (const key of USER_SETTING_KEYS) {
|
for (const key of USER_SETTING_KEYS) {
|
||||||
defaults[key] = getSetting(key);
|
defaults[key] = USER_SETTING_DEFAULTS[key] ?? getSetting(key);
|
||||||
}
|
}
|
||||||
return defaults;
|
return defaults;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue