diff --git a/src/app/api/transactions/route.ts b/src/app/api/transactions/route.ts
index 9c75486..15401a2 100644
--- a/src/app/api/transactions/route.ts
+++ b/src/app/api/transactions/route.ts
@@ -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);
diff --git a/src/app/budget/page.tsx b/src/app/budget/page.tsx
index 2b0886c..430cdc2 100644
--- a/src/app/budget/page.tsx
+++ b/src/app/budget/page.tsx
@@ -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 (
diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx
index d79b32f..8c4b798 100644
--- a/src/app/insights/page.tsx
+++ b/src/app/insights/page.tsx
@@ -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) {
diff --git a/src/app/transactions/page.tsx b/src/app/transactions/page.tsx
index a37d9a2..cff0c05 100644
--- a/src/app/transactions/page.tsx
+++ b/src/app/transactions/page.tsx
@@ -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(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 (
+
+
+ {open && (
+
+ {options.map((o) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
export default function TransactionsPage() {
return (
Loading...
}>
@@ -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([]);
+
+ 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>(new Set());
const [bulkCategory, setBulkCategory] = useState("");
const [bulkTagId, setBulkTagId] = useState("");
@@ -320,14 +448,38 @@ function TransactionsContent() {
)}
{/* Filter bar */}
-
-
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"
- />
+
+ {/* Smart query bar */}
+
+
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 && (
+
+ {queryTokens.map((tok) => (
+
+ {tok.label}
+
+
+ ))}
+
+ )}
+
+
+ {/* Date range */}
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"
/>
-
-
-
+
+ {/* Multi-select dropdowns */}
+
({ value: c, label: formatCategory(c) }))}
+ value={filters.categories}
+ onChange={(v) => setFilters((f) => ({ ...f, categories: v, offset: 0 }))}
+ placeholder="All Categories"
+ />
+ ({ value: b, label: b }))}
+ value={filters.bank_names}
+ onChange={(v) => setFilters((f) => ({ ...f, bank_names: v, offset: 0 }))}
+ placeholder="All Banks"
+ />
+ ({ value: String(t.id), label: t.name }))}
+ value={filters.tag_ids}
+ onChange={(v) => setFilters((f) => ({ ...f, tag_ids: v, offset: 0 }))}
+ placeholder="All Tags"
+ />
+ setFilters((f) => ({ ...f, transaction_types: v, offset: 0 }))}
+ placeholder="All Types"
+ />
{/* Bulk action bar */}
diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts
index 6cb1f3c..639a621 100644
--- a/src/lib/hooks.ts
+++ b/src/lib/hooks.ts
@@ -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();
}
diff --git a/src/lib/queries.ts b/src/lib/queries.ts
index 3cab4b5..7557c7f 100644
--- a/src/lib/queries.ts
+++ b/src/lib/queries.ts
@@ -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 ")}`;