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 });
|
||||
|
||||
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, {
|
||||
from: sp.get("from") || undefined,
|
||||
to: sp.get("to") || undefined,
|
||||
category: sp.get("category") || undefined,
|
||||
bank_name: sp.get("bank_name") || undefined,
|
||||
categories: parseArr("categories"),
|
||||
bank_names: parseArr("bank_names"),
|
||||
tag_ids: parseArr("tag_ids"),
|
||||
transaction_types: parseArr("transaction_types"),
|
||||
search: sp.get("search") || undefined,
|
||||
statement_id: sp.get("statement_id") || undefined,
|
||||
tag_id: sp.get("tag_id") || undefined,
|
||||
sort_by: sp.get("sort_by") || undefined,
|
||||
sort_dir: sp.get("sort_dir") || undefined,
|
||||
limit: sp.get("limit") ? Number(sp.get("limit")) : 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);
|
||||
|
||||
@@ -136,7 +136,7 @@ function CategoryPanel({ category, selectedMonth }: { category: string; selected
|
||||
const [year, month] = selectedMonth.split("-").map(Number);
|
||||
const nextDate = new Date(year, month, 1);
|
||||
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 || [];
|
||||
|
||||
return (
|
||||
|
||||
@@ -74,7 +74,7 @@ function DrillDownRow({
|
||||
from: 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();
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
+187
-39
@@ -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">
|
||||
<div className="flex flex-wrap gap-3 mb-2">
|
||||
{/* Smart query bar */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<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"
|
||||
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 */}
|
||||
|
||||
+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