diff --git a/client/components/SearchFilterPanel.jsx b/client/components/SearchFilterPanel.jsx
new file mode 100644
index 0000000..ab2f6d2
--- /dev/null
+++ b/client/components/SearchFilterPanel.jsx
@@ -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 (
+
+
+ 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"
+ >
+
+
+
+
+ {title}
+
+ {[resultLabel, hasFilters ? 'filters active' : 'no filters', sortLabel].filter(Boolean).join(' · ')}
+
+
+
+
+
+ {hasFilters && onClear && (
+
+
+ Clear
+
+ )}
+
+
+ {!collapsed && (
+
+ {children}
+
+ )}
+
+ );
+}
diff --git a/client/hooks/useSearchPanelPreference.js b/client/hooks/useSearchPanelPreference.js
new file mode 100644
index 0000000..6438841
--- /dev/null
+++ b/client/hooks/useSearchPanelPreference.js
@@ -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];
+}
diff --git a/client/pages/BillsPage.jsx b/client/pages/BillsPage.jsx
index a4edbec..87b8e1b 100644
--- a/client/pages/BillsPage.jsx
+++ b/client/pages/BillsPage.jsx
@@ -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() {
-
-
+
+
-
-
- Clear
-
toggleFilter('autopay')}>Autopay
@@ -998,7 +999,7 @@ export default function BillsPage() {
{filteredBills.length} of {bills.length} shown
-
+
{/* ── Active Bills ── */}
{!filters.inactive && (
diff --git a/client/pages/SubscriptionsPage.jsx b/client/pages/SubscriptionsPage.jsx
index 8b67f29..caeb2e2 100644
--- a/client/pages/SubscriptionsPage.jsx
+++ b/client/pages/SubscriptionsPage.jsx
@@ -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() {
-
-
- setSubSearch(e.target.value)}
- placeholder="Search subscriptions…"
- className="h-8 pl-8 pr-8 text-sm"
- />
- {subSearch && (
- 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"
- >
-
-
- )}
-
+ setSubSearch('')}
+ variant="embedded"
+ >
+
+
+ setSubSearch(e.target.value)}
+ placeholder="Search subscriptions…"
+ className="h-8 pl-8 pr-8 text-sm"
+ />
+ {subSearch && (
+ 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"
+ >
+
+
+ )}
+
+
{loading ? (
@@ -1470,16 +1483,26 @@ export default function SubscriptionsPage() {
Known subscription services found in your bank transactions with 90%+ confidence.
{!recommendationsLoading && highConfidenceRecs.length > 0 && (
-
-
- setRecSearch(e.target.value)}
- className="pl-8 h-8 text-sm"
- />
-
+ setRecSearch('')}
+ variant="embedded"
+ >
+
+
+ setRecSearch(e.target.value)}
+ className="pl-8 h-8 text-sm"
+ />
+
+
)}
@@ -1538,17 +1561,27 @@ export default function SubscriptionsPage() {
Search all account charges — matched and unmatched — to find subscriptions the algorithm may have missed.
-
-
- setTxQuery(e.target.value)}
- className="pl-8 h-9 text-sm"
- autoComplete="off"
- />
-
+ setTxQuery('')}
+ variant="embedded"
+ >
+
+
+ setTxQuery(e.target.value)}
+ className="pl-8 h-9 text-sm"
+ autoComplete="off"
+ />
+
+
{(txQuery.trim() || txSearching) && (
diff --git a/client/pages/TrackerPage.jsx b/client/pages/TrackerPage.jsx
index f4100a0..62c2890 100644
--- a/client/pages/TrackerPage.jsx
+++ b/client/pages/TrackerPage.jsx
@@ -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() {
)}
-
-
+
+
toggleFilter('unpaid')}>Unpaid
@@ -577,7 +578,7 @@ export default function TrackerPage() {
)}
-
+
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
{loading ? (
diff --git a/services/userSettings.js b/services/userSettings.js
index 56b4128..c0a1c94 100644
--- a/services/userSettings.js
+++ b/services/userSettings.js
@@ -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;
}