From 5206388958e3738c4b23c68c003377300510114e Mon Sep 17 00:00:00 2001 From: siddharthd Date: Sat, 14 Mar 2026 20:39:28 +1100 Subject: [PATCH] feat(filters): smart query bar with amount operators and multi-select dropdowns - Query bar parses >500, >=500, <500, <=500, 500-1500 into amount_min/max filters - Parsed tokens shown as dismissable chips below the query bar - Category, Bank, Tag, Type filters upgraded from single-select to multi-select - MultiSelect dropdown component with checkbox list and active-state border - Backend: TransactionFilters uses string[] for categories/bank_names/tag_ids/transaction_types - SQL: ANY($n::text[]) / ANY($n::int[]) for array filters --- src/app/api/transactions/route.ts | 10 +- src/app/budget/page.tsx | 2 +- src/app/insights/page.tsx | 2 +- src/app/transactions/page.tsx | 232 ++++++++++++++++++++++++------ src/lib/hooks.ts | 16 ++- src/lib/queries.ts | 46 ++++-- 6 files changed, 244 insertions(+), 64 deletions(-) diff --git a/src/app/api/transactions/route.ts b/src/app/api/transactions/route.ts index 9c75486..15401a2 100644 --- a/src/app/api/transactions/route.ts +++ b/src/app/api/transactions/route.ts @@ -8,18 +8,22 @@ export async function GET(req: NextRequest) { if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); const sp = req.nextUrl.searchParams; + const parseArr = (key: string) => { const v = sp.get(key); return v ? v.split(",").filter(Boolean) : undefined; }; const result = await getTransactions(user.id, { from: sp.get("from") || undefined, to: sp.get("to") || undefined, - category: sp.get("category") || undefined, - bank_name: sp.get("bank_name") || undefined, + categories: parseArr("categories"), + bank_names: parseArr("bank_names"), + tag_ids: parseArr("tag_ids"), + transaction_types: parseArr("transaction_types"), search: sp.get("search") || undefined, statement_id: sp.get("statement_id") || undefined, - tag_id: sp.get("tag_id") || undefined, sort_by: sp.get("sort_by") || undefined, sort_dir: sp.get("sort_dir") || undefined, limit: sp.get("limit") ? Number(sp.get("limit")) : undefined, offset: sp.get("offset") ? Number(sp.get("offset")) : undefined, + amount_min: sp.get("amount_min") ? Number(sp.get("amount_min")) : undefined, + amount_max: sp.get("amount_max") ? Number(sp.get("amount_max")) : undefined, }); return NextResponse.json(result); diff --git a/src/app/budget/page.tsx b/src/app/budget/page.tsx index 2b0886c..430cdc2 100644 --- a/src/app/budget/page.tsx +++ b/src/app/budget/page.tsx @@ -136,7 +136,7 @@ function CategoryPanel({ category, selectedMonth }: { category: string; selected const [year, month] = selectedMonth.split("-").map(Number); const nextDate = new Date(year, month, 1); const to = `${nextDate.getFullYear()}-${String(nextDate.getMonth() + 1).padStart(2, "0")}-01`; - const { data, isLoading } = useTransactions({ category, from, to, limit: 200 }); + const { data, isLoading } = useTransactions({ categories: category ? [category] : [], from, to, limit: 200 }); const txns = data?.data || []; return ( diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index d79b32f..8c4b798 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -74,7 +74,7 @@ function DrillDownRow({ from: string; to: string; }) { - const { data, isLoading } = useTransactions({ category, from, to, limit: 200 }); + const { data, isLoading } = useTransactions({ categories: category ? [category] : [], from, to, limit: 200 }); const updateTx = useUpdateTransaction(); if (isLoading) { diff --git a/src/app/transactions/page.tsx b/src/app/transactions/page.tsx index a37d9a2..cff0c05 100644 --- a/src/app/transactions/page.tsx +++ b/src/app/transactions/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, Suspense } from "react"; +import { useState, useCallback, useRef, useEffect, Suspense } from "react"; import { useSearchParams } from "next/navigation"; import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement, useCreateRule } from "@/lib/hooks"; import { CATEGORIES, formatCategory } from "@/lib/categories"; @@ -206,6 +206,106 @@ function InlineEdit({ ); } +// ── Query bar parser ────────────────────────────────────────────────────────── +interface QueryToken { key: string; label: string } +interface ParsedQuery { text: string; amountMin?: number; amountMax?: number; tokens: QueryToken[] } + +function parseQuery(input: string): ParsedQuery { + let text = input; + let amountMin: number | undefined; + let amountMax: number | undefined; + const tokens: QueryToken[] = []; + + // Range shorthand: 500-1500 + text = text.replace(/\b(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\b/g, (_, a, b) => { + amountMin = parseFloat(a); + amountMax = parseFloat(b); + tokens.push({ key: "range", label: `$${a}–$${b}` }); + return ""; + }); + + // Operators: >=500 <=500 >500 <500 + text = text.replace(/(>=|<=|>|<)\s*(\d+(?:\.\d+)?)/g, (_, op, num) => { + const val = parseFloat(num); + if (op === ">") amountMin = val + 0.005; + else if (op === ">=") amountMin = val; + else if (op === "<") amountMax = Math.max(0, val - 0.005); + else if (op === "<=") amountMax = val; + const display = op === ">=" ? "≥" : op === "<=" ? "≤" : op; + tokens.push({ key: `amt_${op}`, label: `${display} $${num}` }); + return ""; + }); + + return { text: text.trim(), amountMin, amountMax, tokens }; +} + +// ── MultiSelect dropdown ────────────────────────────────────────────────────── +function MultiSelect({ + options, + value, + onChange, + placeholder, +}: { + options: { value: string; label: string }[]; + value: string[]; + onChange: (v: string[]) => void; + placeholder: string; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + function handler(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + } + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + const toggle = (v: string) => + onChange(value.includes(v) ? value.filter((x) => x !== v) : [...value, v]); + + const label = + value.length === 0 + ? placeholder + : value.length === 1 + ? (options.find((o) => o.value === value[0])?.label ?? value[0]) + : `${value.length} selected`; + + return ( +
+ + {open && ( +
+ {options.map((o) => ( + + ))} +
+ )} +
+ ); +} + export default function TransactionsPage() { return ( Loading...

}> @@ -221,16 +321,44 @@ function TransactionsContent() { const [filters, setFilters] = useState({ from: "", to: "", - category: "", - bank_name: "", + categories: [] as string[], + bank_names: [] as string[], + tag_ids: [] as string[], + transaction_types: [] as string[], search: "", statement_id: initialStatementId, - tag_id: "", sort_by: "transaction_date", sort_dir: "desc", limit: 50, offset: 0, + amount_min: undefined as number | undefined, + amount_max: undefined as number | undefined, }); + const [queryInput, setQueryInput] = useState(""); + const [queryTokens, setQueryTokens] = useState([]); + + function handleQueryChange(val: string) { + setQueryInput(val); + const parsed = parseQuery(val); + setQueryTokens(parsed.tokens); + setFilters((f) => ({ + ...f, + search: parsed.text, + amount_min: parsed.amountMin, + amount_max: parsed.amountMax, + offset: 0, + })); + } + + function clearQueryToken(key: string) { + // Rebuild input without the token's contribution by re-running parse on cleared input + // Simplest: just clear the whole query bar + const next = queryInput + .replace(/(>=|<=|>|<)\s*\d+(?:\.\d+)?/g, "") + .replace(/\b\d+(?:\.\d+)?\s*-\s*\d+(?:\.\d+)?\b/g, "") + .trim(); + handleQueryChange(next); + } const [selected, setSelected] = useState>(new Set()); const [bulkCategory, setBulkCategory] = useState(""); const [bulkTagId, setBulkTagId] = useState(""); @@ -320,14 +448,38 @@ function TransactionsContent() { )} {/* Filter bar */} -
- setFilters((f) => ({ ...f, search: e.target.value, offset: 0 }))} - className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm w-48" - /> +
+ {/* Smart query bar */} +
+ handleQueryChange(e.target.value)} + placeholder="Search… or >500 <=1500 200-800" + className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm w-64 font-mono placeholder:font-sans placeholder:text-zinc-600" + /> + {queryTokens.length > 0 && ( +
+ {queryTokens.map((tok) => ( + + {tok.label} + + + ))} +
+ )} +
+ + {/* Date range */} setFilters((f) => ({ ...f, to: e.target.value, offset: 0 }))} className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm" /> - - - + + {/* Multi-select dropdowns */} + ({ value: c, label: formatCategory(c) }))} + value={filters.categories} + onChange={(v) => setFilters((f) => ({ ...f, categories: v, offset: 0 }))} + placeholder="All Categories" + /> + ({ value: b, label: b }))} + value={filters.bank_names} + onChange={(v) => setFilters((f) => ({ ...f, bank_names: v, offset: 0 }))} + placeholder="All Banks" + /> + ({ value: String(t.id), label: t.name }))} + value={filters.tag_ids} + onChange={(v) => setFilters((f) => ({ ...f, tag_ids: v, offset: 0 }))} + placeholder="All Tags" + /> + setFilters((f) => ({ ...f, transaction_types: v, offset: 0 }))} + placeholder="All Types" + />
{/* Bulk action bar */} diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 6cb1f3c..639a621 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -14,21 +14,29 @@ interface TransactionsResponse { interface TransactionFilters { from?: string; to?: string; - category?: string; - bank_name?: string; + categories?: string[]; + bank_names?: string[]; + tag_ids?: string[]; + transaction_types?: string[]; search?: string; statement_id?: string; - tag_id?: string; sort_by?: string; sort_dir?: string; limit?: number; offset?: number; + amount_min?: number; + amount_max?: number; } function buildParams(filters: TransactionFilters): string { const params = new URLSearchParams(); Object.entries(filters).forEach(([key, val]) => { - if (val !== undefined && val !== "") params.set(key, String(val)); + if (val === undefined || val === "") return; + if (Array.isArray(val)) { + if (val.length > 0) params.set(key, val.join(",")); + } else { + params.set(key, String(val)); + } }); return params.toString(); } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 3cab4b5..7557c7f 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -70,15 +70,18 @@ export interface StatementRow { interface TransactionFilters { from?: string; to?: string; - category?: string; - bank_name?: string; + categories?: string[]; + bank_names?: string[]; + tag_ids?: string[]; + transaction_types?: string[]; search?: string; statement_id?: string; - tag_id?: string; sort_by?: string; sort_dir?: string; limit?: number; offset?: number; + amount_min?: number; + amount_max?: number; } export async function getTransactions(ownerId: number, filters: TransactionFilters) { @@ -94,18 +97,31 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte conditions.push(`t.transaction_date <= $${paramIdx++}`); params.push(filters.to); } - if (filters.category) { - conditions.push(`COALESCE(o.category_override, t.category) = $${paramIdx++}`); - params.push(filters.category); + if (filters.categories?.length) { + conditions.push(`COALESCE(o.category_override, t.category) = ANY($${paramIdx++}::text[])`); + params.push(filters.categories); } - if (filters.bank_name) { - if (filters.bank_name === "Manual") { + if (filters.bank_names?.length) { + const hasManual = filters.bank_names.includes("Manual"); + const bankList = filters.bank_names.filter((b) => b !== "Manual"); + if (hasManual && bankList.length > 0) { + conditions.push(`(t.statement_id IS NULL OR s.bank_name = ANY($${paramIdx++}::text[]))`); + params.push(bankList); + } else if (hasManual) { conditions.push(`t.statement_id IS NULL`); } else { - conditions.push(`s.bank_name = $${paramIdx++}`); - params.push(filters.bank_name); + conditions.push(`s.bank_name = ANY($${paramIdx++}::text[])`); + params.push(bankList); } } + if (filters.tag_ids?.length) { + conditions.push(`EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id AND tt2.tag_id = ANY($${paramIdx++}::int[]))`); + params.push(filters.tag_ids.map(Number)); + } + if (filters.transaction_types?.length) { + conditions.push(`t.transaction_type = ANY($${paramIdx++}::text[])`); + params.push(filters.transaction_types); + } if (filters.search) { conditions.push(`(t.description ILIKE $${paramIdx} OR t.merchant_name ILIKE $${paramIdx} OR COALESCE(o.merchant_normalized, t.merchant_normalized) ILIKE $${paramIdx})`); params.push(`%${filters.search}%`); @@ -115,9 +131,13 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte conditions.push(`t.statement_id = $${paramIdx++}`); params.push(Number(filters.statement_id)); } - if (filters.tag_id) { - conditions.push(`EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id AND tt2.tag_id = $${paramIdx++})`); - params.push(Number(filters.tag_id)); + if (filters.amount_min !== undefined) { + conditions.push(`t.amount >= $${paramIdx++}`); + params.push(filters.amount_min); + } + if (filters.amount_max !== undefined) { + conditions.push(`t.amount <= $${paramIdx++}`); + params.push(filters.amount_max); } const where = `WHERE ${conditions.join(" AND ")}`;