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
This commit is contained in:
2026-03-14 20:39:28 +11:00
parent 8076d1a949
commit 5206388958
6 changed files with 244 additions and 64 deletions
+190 -42
View File
@@ -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<HTMLDivElement>(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 (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={`bg-zinc-900 border rounded px-3 py-1.5 text-sm flex items-center gap-2 min-w-[140px] whitespace-nowrap ${
value.length > 0 ? "border-indigo-500 text-white" : "border-zinc-700 text-zinc-400"
}`}
>
<span className="flex-1 text-left truncate">{label}</span>
<span className="text-zinc-500 text-xs"></span>
</button>
{open && (
<div className="absolute top-full mt-1 z-20 bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl min-w-[160px] max-h-64 overflow-y-auto">
{options.map((o) => (
<label
key={o.value}
className="flex items-center gap-2 px-3 py-1.5 hover:bg-zinc-800 cursor-pointer text-sm"
>
<input
type="checkbox"
checked={value.includes(o.value)}
onChange={() => toggle(o.value)}
className="accent-indigo-500 flex-shrink-0"
/>
{o.label}
</label>
))}
</div>
)}
</div>
);
}
export default function TransactionsPage() {
return (
<Suspense fallback={<p className="text-zinc-500 text-sm">Loading...</p>}>
@@ -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<QueryToken[]>([]);
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<Set<number>>(new Set());
const [bulkCategory, setBulkCategory] = useState("");
const [bulkTagId, setBulkTagId] = useState("");
@@ -320,14 +448,38 @@ function TransactionsContent() {
)}
{/* Filter bar */}
<div className="flex flex-wrap gap-3 mb-4">
<input
type="text"
placeholder="Search..."
value={filters.search}
onChange={(e) => 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"
/>
<div className="flex flex-wrap gap-3 mb-2">
{/* Smart query bar */}
<div className="flex flex-col gap-1">
<input
type="text"
value={queryInput}
onChange={(e) => 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 && (
<div className="flex flex-wrap gap-1">
{queryTokens.map((tok) => (
<span
key={tok.key}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-indigo-900/50 border border-indigo-700/50 text-indigo-300"
>
{tok.label}
<button
type="button"
onClick={() => clearQueryToken(tok.key)}
className="text-indigo-400 hover:text-white leading-none"
>
×
</button>
</span>
))}
</div>
)}
</div>
{/* Date range */}
<input
type="date"
value={filters.from}
@@ -340,36 +492,32 @@ function TransactionsContent() {
onChange={(e) => 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"
/>
<select
value={filters.category}
onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value, offset: 0 }))}
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm"
>
<option value="">All Categories</option>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{formatCategory(c)}</option>
))}
</select>
<select
value={filters.bank_name}
onChange={(e) => setFilters((f) => ({ ...f, bank_name: e.target.value, offset: 0 }))}
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm"
>
<option value="">All Banks</option>
{banks?.map((b) => (
<option key={b} value={b}>{b}</option>
))}
</select>
<select
value={filters.tag_id}
onChange={(e) => setFilters((f) => ({ ...f, tag_id: e.target.value, offset: 0 }))}
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm"
>
<option value="">All Tags</option>
{tags?.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
{/* Multi-select dropdowns */}
<MultiSelect
options={CATEGORIES.map((c) => ({ value: c, label: formatCategory(c) }))}
value={filters.categories}
onChange={(v) => setFilters((f) => ({ ...f, categories: v, offset: 0 }))}
placeholder="All Categories"
/>
<MultiSelect
options={(banks ?? []).map((b) => ({ value: b, label: b }))}
value={filters.bank_names}
onChange={(v) => setFilters((f) => ({ ...f, bank_names: v, offset: 0 }))}
placeholder="All Banks"
/>
<MultiSelect
options={(tags ?? []).map((t) => ({ value: String(t.id), label: t.name }))}
value={filters.tag_ids}
onChange={(v) => setFilters((f) => ({ ...f, tag_ids: v, offset: 0 }))}
placeholder="All Tags"
/>
<MultiSelect
options={TYPE_OPTIONS}
value={filters.transaction_types}
onChange={(v) => setFilters((f) => ({ ...f, transaction_types: v, offset: 0 }))}
placeholder="All Types"
/>
</div>
{/* Bulk action bar */}