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
+7 -3
View File
@@ -8,18 +8,22 @@ export async function GET(req: NextRequest) {
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const sp = req.nextUrl.searchParams; 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, { const result = await getTransactions(user.id, {
from: sp.get("from") || undefined, from: sp.get("from") || undefined,
to: sp.get("to") || undefined, to: sp.get("to") || undefined,
category: sp.get("category") || undefined, categories: parseArr("categories"),
bank_name: sp.get("bank_name") || undefined, bank_names: parseArr("bank_names"),
tag_ids: parseArr("tag_ids"),
transaction_types: parseArr("transaction_types"),
search: sp.get("search") || undefined, search: sp.get("search") || undefined,
statement_id: sp.get("statement_id") || undefined, statement_id: sp.get("statement_id") || undefined,
tag_id: sp.get("tag_id") || undefined,
sort_by: sp.get("sort_by") || undefined, sort_by: sp.get("sort_by") || undefined,
sort_dir: sp.get("sort_dir") || undefined, sort_dir: sp.get("sort_dir") || undefined,
limit: sp.get("limit") ? Number(sp.get("limit")) : undefined, limit: sp.get("limit") ? Number(sp.get("limit")) : undefined,
offset: sp.get("offset") ? Number(sp.get("offset")) : 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); return NextResponse.json(result);
+1 -1
View File
@@ -136,7 +136,7 @@ function CategoryPanel({ category, selectedMonth }: { category: string; selected
const [year, month] = selectedMonth.split("-").map(Number); const [year, month] = selectedMonth.split("-").map(Number);
const nextDate = new Date(year, month, 1); const nextDate = new Date(year, month, 1);
const to = `${nextDate.getFullYear()}-${String(nextDate.getMonth() + 1).padStart(2, "0")}-01`; 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 || []; const txns = data?.data || [];
return ( return (
+1 -1
View File
@@ -74,7 +74,7 @@ function DrillDownRow({
from: string; from: string;
to: 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(); const updateTx = useUpdateTransaction();
if (isLoading) { if (isLoading) {
+187 -39
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useCallback, Suspense } from "react"; import { useState, useCallback, useRef, useEffect, Suspense } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement, useCreateRule } from "@/lib/hooks"; import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement, useCreateRule } from "@/lib/hooks";
import { CATEGORIES, formatCategory } from "@/lib/categories"; 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() { export default function TransactionsPage() {
return ( return (
<Suspense fallback={<p className="text-zinc-500 text-sm">Loading...</p>}> <Suspense fallback={<p className="text-zinc-500 text-sm">Loading...</p>}>
@@ -221,16 +321,44 @@ function TransactionsContent() {
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
from: "", from: "",
to: "", to: "",
category: "", categories: [] as string[],
bank_name: "", bank_names: [] as string[],
tag_ids: [] as string[],
transaction_types: [] as string[],
search: "", search: "",
statement_id: initialStatementId, statement_id: initialStatementId,
tag_id: "",
sort_by: "transaction_date", sort_by: "transaction_date",
sort_dir: "desc", sort_dir: "desc",
limit: 50, limit: 50,
offset: 0, 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 [selected, setSelected] = useState<Set<number>>(new Set());
const [bulkCategory, setBulkCategory] = useState(""); const [bulkCategory, setBulkCategory] = useState("");
const [bulkTagId, setBulkTagId] = useState(""); const [bulkTagId, setBulkTagId] = useState("");
@@ -320,14 +448,38 @@ function TransactionsContent() {
)} )}
{/* Filter bar */} {/* Filter bar */}
<div className="flex flex-wrap gap-3 mb-4"> <div className="flex flex-wrap gap-3 mb-2">
{/* Smart query bar */}
<div className="flex flex-col gap-1">
<input <input
type="text" type="text"
placeholder="Search..." value={queryInput}
value={filters.search} onChange={(e) => handleQueryChange(e.target.value)}
onChange={(e) => setFilters((f) => ({ ...f, search: e.target.value, offset: 0 }))} placeholder="Search… or >500 <=1500 200-800"
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm w-48" 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 <input
type="date" type="date"
value={filters.from} value={filters.from}
@@ -340,36 +492,32 @@ function TransactionsContent() {
onChange={(e) => setFilters((f) => ({ ...f, to: e.target.value, offset: 0 }))} 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" className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm"
/> />
<select
value={filters.category} {/* Multi-select dropdowns */}
onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value, offset: 0 }))} <MultiSelect
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm" options={CATEGORIES.map((c) => ({ value: c, label: formatCategory(c) }))}
> value={filters.categories}
<option value="">All Categories</option> onChange={(v) => setFilters((f) => ({ ...f, categories: v, offset: 0 }))}
{CATEGORIES.map((c) => ( placeholder="All Categories"
<option key={c} value={c}>{formatCategory(c)}</option> />
))} <MultiSelect
</select> options={(banks ?? []).map((b) => ({ value: b, label: b }))}
<select value={filters.bank_names}
value={filters.bank_name} onChange={(v) => setFilters((f) => ({ ...f, bank_names: v, offset: 0 }))}
onChange={(e) => setFilters((f) => ({ ...f, bank_name: e.target.value, offset: 0 }))} placeholder="All Banks"
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm" />
> <MultiSelect
<option value="">All Banks</option> options={(tags ?? []).map((t) => ({ value: String(t.id), label: t.name }))}
{banks?.map((b) => ( value={filters.tag_ids}
<option key={b} value={b}>{b}</option> onChange={(v) => setFilters((f) => ({ ...f, tag_ids: v, offset: 0 }))}
))} placeholder="All Tags"
</select> />
<select <MultiSelect
value={filters.tag_id} options={TYPE_OPTIONS}
onChange={(e) => setFilters((f) => ({ ...f, tag_id: e.target.value, offset: 0 }))} value={filters.transaction_types}
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm" onChange={(v) => setFilters((f) => ({ ...f, transaction_types: v, offset: 0 }))}
> placeholder="All Types"
<option value="">All Tags</option> />
{tags?.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div> </div>
{/* Bulk action bar */} {/* Bulk action bar */}
+12 -4
View File
@@ -14,21 +14,29 @@ interface TransactionsResponse {
interface TransactionFilters { interface TransactionFilters {
from?: string; from?: string;
to?: string; to?: string;
category?: string; categories?: string[];
bank_name?: string; bank_names?: string[];
tag_ids?: string[];
transaction_types?: string[];
search?: string; search?: string;
statement_id?: string; statement_id?: string;
tag_id?: string;
sort_by?: string; sort_by?: string;
sort_dir?: string; sort_dir?: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
amount_min?: number;
amount_max?: number;
} }
function buildParams(filters: TransactionFilters): string { function buildParams(filters: TransactionFilters): string {
const params = new URLSearchParams(); const params = new URLSearchParams();
Object.entries(filters).forEach(([key, val]) => { 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(); return params.toString();
} }
+33 -13
View File
@@ -70,15 +70,18 @@ export interface StatementRow {
interface TransactionFilters { interface TransactionFilters {
from?: string; from?: string;
to?: string; to?: string;
category?: string; categories?: string[];
bank_name?: string; bank_names?: string[];
tag_ids?: string[];
transaction_types?: string[];
search?: string; search?: string;
statement_id?: string; statement_id?: string;
tag_id?: string;
sort_by?: string; sort_by?: string;
sort_dir?: string; sort_dir?: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
amount_min?: number;
amount_max?: number;
} }
export async function getTransactions(ownerId: number, filters: TransactionFilters) { 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++}`); conditions.push(`t.transaction_date <= $${paramIdx++}`);
params.push(filters.to); params.push(filters.to);
} }
if (filters.category) { if (filters.categories?.length) {
conditions.push(`COALESCE(o.category_override, t.category) = $${paramIdx++}`); conditions.push(`COALESCE(o.category_override, t.category) = ANY($${paramIdx++}::text[])`);
params.push(filters.category); params.push(filters.categories);
} }
if (filters.bank_name) { if (filters.bank_names?.length) {
if (filters.bank_name === "Manual") { 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`); conditions.push(`t.statement_id IS NULL`);
} else { } else {
conditions.push(`s.bank_name = $${paramIdx++}`); conditions.push(`s.bank_name = ANY($${paramIdx++}::text[])`);
params.push(filters.bank_name); 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) { 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})`); 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}%`); params.push(`%${filters.search}%`);
@@ -115,9 +131,13 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
conditions.push(`t.statement_id = $${paramIdx++}`); conditions.push(`t.statement_id = $${paramIdx++}`);
params.push(Number(filters.statement_id)); params.push(Number(filters.statement_id));
} }
if (filters.tag_id) { if (filters.amount_min !== undefined) {
conditions.push(`EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id AND tt2.tag_id = $${paramIdx++})`); conditions.push(`t.amount >= $${paramIdx++}`);
params.push(Number(filters.tag_id)); 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 ")}`; const where = `WHERE ${conditions.join(" AND ")}`;