feat: search filter panel component, search preference persistence, page integration

This commit is contained in:
null 2026-06-07 01:28:35 -05:00
parent ab5e3fbf1f
commit d9cf499dba
6 changed files with 205 additions and 69 deletions

View File

@ -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>
);
}

View File

@ -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];
}

View File

@ -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 && (

View File

@ -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) && (

View File

@ -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 ? (

View File

@ -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;
} }