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
+12 -4
View File
@@ -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();
}
+33 -13
View File
@@ -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 ")}`;