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:
+12
-4
@@ -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
@@ -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 ")}`;
|
||||
|
||||
Reference in New Issue
Block a user