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:
@@ -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);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
+190
-42
@@ -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">
|
||||||
<input
|
{/* Smart query bar */}
|
||||||
type="text"
|
<div className="flex flex-col gap-1">
|
||||||
placeholder="Search..."
|
<input
|
||||||
value={filters.search}
|
type="text"
|
||||||
onChange={(e) => setFilters((f) => ({ ...f, search: e.target.value, offset: 0 }))}
|
value={queryInput}
|
||||||
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm w-48"
|
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
|
<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
@@ -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
@@ -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 ")}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user