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 { 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 { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import SearchFilterPanel from '@/components/SearchFilterPanel';
|
||||
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
|
|
@ -18,6 +19,7 @@ import {
|
|||
} from '@/components/ui/select';
|
||||
import { api } from '@/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
|
||||
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
|
||||
import BillsTableInner from '@/components/BillsTableInner';
|
||||
import BillModal from '@/components/BillModal';
|
||||
|
|
@ -629,6 +631,7 @@ export default function BillsPage() {
|
|||
const [billsSort, setBillsSort] = useState(() => (
|
||||
localStorage.getItem(BILLS_SORT_KEY) === 'cadence' ? 'cadence' : 'custom'
|
||||
));
|
||||
const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(BILLS_SORT_KEY, billsSort);
|
||||
|
|
@ -944,8 +947,16 @@ export default function BillsPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface-elevated rounded-xl p-4 space-y-3">
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_220px_180px_auto] lg:items-center">
|
||||
<SearchFilterPanel
|
||||
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">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
|
|
@ -977,16 +988,6 @@ export default function BillsPage() {
|
|||
))}
|
||||
</SelectContent>
|
||||
</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 className="flex flex-wrap items-center gap-2">
|
||||
<FilterChip active={filters.autopay} onClick={() => toggleFilter('autopay')}>Autopay</FilterChip>
|
||||
|
|
@ -998,7 +999,7 @@ export default function BillsPage() {
|
|||
{filteredBills.length} of {bills.length} shown
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SearchFilterPanel>
|
||||
|
||||
{/* ── Active Bills ── */}
|
||||
{!filters.inactive && (
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { Button } from '@/components/ui/button';
|
|||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import SearchFilterPanel from '@/components/SearchFilterPanel';
|
||||
import {
|
||||
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
|
@ -45,6 +46,7 @@ import {
|
|||
import BillModal from '@/components/BillModal';
|
||||
import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog';
|
||||
import { getLinkImportPref } from '@/pages/SettingsPage';
|
||||
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
|
||||
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
|
||||
|
||||
const TYPE_LABELS = {
|
||||
|
|
@ -967,6 +969,7 @@ export default function SubscriptionsPage() {
|
|||
const [draggingId, setDraggingId] = useState(null);
|
||||
const [dropTargetId, setDropTargetId] = useState(null);
|
||||
const [movingBillId, setMovingBillId] = useState(null);
|
||||
const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();
|
||||
|
||||
const [txQuery, setTxQuery] = useState('');
|
||||
const [txResults, setTxResults] = useState([]);
|
||||
|
|
@ -1415,25 +1418,35 @@ export default function SubscriptionsPage() {
|
|||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<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" />
|
||||
<Input
|
||||
value={subSearch}
|
||||
onChange={e => setSubSearch(e.target.value)}
|
||||
placeholder="Search subscriptions…"
|
||||
className="h-8 pl-8 pr-8 text-sm"
|
||||
/>
|
||||
{subSearch && (
|
||||
<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
|
||||
title="Search subscriptions"
|
||||
collapsed={searchPanelCollapsed}
|
||||
onCollapsedChange={setSearchPanelCollapsed}
|
||||
hasFilters={!!subSearch.trim()}
|
||||
resultLabel={`${filteredSubscriptions.length} of ${subscriptions.length} shown`}
|
||||
onClear={() => setSubSearch('')}
|
||||
variant="embedded"
|
||||
>
|
||||
<div className="relative">
|
||||
<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" />
|
||||
<Input
|
||||
value={subSearch}
|
||||
onChange={e => setSubSearch(e.target.value)}
|
||||
placeholder="Search subscriptions…"
|
||||
className="h-8 pl-8 pr-8 text-sm"
|
||||
/>
|
||||
{subSearch && (
|
||||
<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>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
|
|
@ -1470,16 +1483,26 @@ export default function SubscriptionsPage() {
|
|||
</div>
|
||||
<CardDescription>Known subscription services found in your bank transactions with 90%+ confidence.</CardDescription>
|
||||
{!recommendationsLoading && highConfidenceRecs.length > 0 && (
|
||||
<div className="relative mt-2">
|
||||
<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
|
||||
title="Search recommendations"
|
||||
collapsed={searchPanelCollapsed}
|
||||
onCollapsedChange={setSearchPanelCollapsed}
|
||||
hasFilters={!!recSearch.trim()}
|
||||
resultLabel={`${filteredRecs.length} of ${highConfidenceRecs.length} shown`}
|
||||
onClear={() => setRecSearch('')}
|
||||
variant="embedded"
|
||||
>
|
||||
<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>
|
||||
<CardContent className="space-y-3">
|
||||
|
|
@ -1538,17 +1561,27 @@ export default function SubscriptionsPage() {
|
|||
<CardDescription>
|
||||
Search all account charges — matched and unmatched — to find subscriptions the algorithm may have missed.
|
||||
</CardDescription>
|
||||
<div className="relative mt-2">
|
||||
<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
|
||||
title="Search transactions"
|
||||
collapsed={searchPanelCollapsed}
|
||||
onCollapsedChange={setSearchPanelCollapsed}
|
||||
hasFilters={!!txQuery.trim()}
|
||||
resultLabel={txQuery.trim() ? `${txResults.length} result${txResults.length === 1 ? '' : 's'}` : 'all bank transactions'}
|
||||
onClear={() => setTxQuery('')}
|
||||
variant="embedded"
|
||||
>
|
||||
<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 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>
|
||||
|
||||
{(txQuery.trim() || txSearching) && (
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
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 { api } from '@/api.js';
|
||||
import { useTracker, useDriftReport } from '@/hooks/useQueries';
|
||||
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
|
||||
import BillModal from '@/components/BillModal';
|
||||
import { makeBillDraft } from '@/lib/billDrafts';
|
||||
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
|
||||
import { cn, fmt } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import SearchFilterPanel from '@/components/SearchFilterPanel';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
|
|
@ -124,6 +126,7 @@ export default function TrackerPage() {
|
|||
const [incomeModalOpen, setIncomeModalOpen] = useState(false);
|
||||
const [orderedRows, setOrderedRows] = useState(null);
|
||||
const [movingBillId, setMovingBillId] = useState(null);
|
||||
const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();
|
||||
|
||||
// Row to open in PaymentLedgerDialog via the overdue command center
|
||||
const [commandCenterPayRow, setCommandCenterPayRow] = useState(null);
|
||||
|
|
@ -496,8 +499,16 @@ export default function TrackerPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-border/80 bg-card/95 p-4 shadow-sm shadow-black/15 space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-[minmax(220px,1fr)_220px_180px_220px_auto_auto] xl:items-center">
|
||||
<SearchFilterPanel
|
||||
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">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<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" />}
|
||||
<span>{sortDir === TRACKER_SORT_ASC ? 'Asc' : 'Desc'}</span>
|
||||
</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 className="flex flex-wrap items-center gap-2">
|
||||
<FilterChip active={filters.unpaid} onClick={() => toggleFilter('unpaid')}>Unpaid</FilterChip>
|
||||
|
|
@ -577,7 +578,7 @@ export default function TrackerPage() {
|
|||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SearchFilterPanel>
|
||||
|
||||
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
|
||||
{loading ? (
|
||||
|
|
|
|||
|
|
@ -12,12 +12,17 @@ const USER_SETTING_KEYS = [
|
|||
'bank_tracking_account_id',
|
||||
'bank_tracking_pending_days',
|
||||
'bank_late_attribution_days',
|
||||
'search_bars_collapsed',
|
||||
];
|
||||
|
||||
const USER_SETTING_DEFAULTS = {
|
||||
search_bars_collapsed: 'false',
|
||||
};
|
||||
|
||||
function defaultUserSettings() {
|
||||
const defaults = {};
|
||||
for (const key of USER_SETTING_KEYS) {
|
||||
defaults[key] = getSetting(key);
|
||||
defaults[key] = USER_SETTING_DEFAULTS[key] ?? getSetting(key);
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue