mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 11:06:40 +07:00
feat: add search to Transactions tab, spanning all history
Lets the user find a past purchase by keyword instead of paging through months manually — search hits description/merchant/notes and ignores the month filter, with a "jump to month" action on each result to view it in its normal monthly context. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ from datetime import date as date_
|
|||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
@@ -51,6 +51,7 @@ async def list_transactions(
|
|||||||
account_id: int | None = None,
|
account_id: int | None = None,
|
||||||
category_id: int | None = None,
|
category_id: int | None = None,
|
||||||
type: TransactionType | None = None,
|
type: TransactionType | None = None,
|
||||||
|
search: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
sort: Literal["date_desc", "amount_desc", "amount_asc"] = Query(default="date_desc"),
|
sort: Literal["date_desc", "amount_desc", "amount_asc"] = Query(default="date_desc"),
|
||||||
page: int = Query(default=1, ge=1),
|
page: int = Query(default=1, ge=1),
|
||||||
page_size: int = Query(default=20, ge=1, le=200),
|
page_size: int = Query(default=20, ge=1, le=200),
|
||||||
@@ -80,6 +81,18 @@ async def list_transactions(
|
|||||||
if type is not None:
|
if type is not None:
|
||||||
stmt = stmt.where(Transaction.type == type)
|
stmt = stmt.where(Transaction.type == type)
|
||||||
count_stmt = count_stmt.where(Transaction.type == type)
|
count_stmt = count_stmt.where(Transaction.type == type)
|
||||||
|
if search is not None:
|
||||||
|
# Lets the user find a transaction from any period by keyword (e.g. an
|
||||||
|
# item bought months ago) without knowing which month to look in first —
|
||||||
|
# matches description, merchant, and notes so any of those fields can surface it.
|
||||||
|
pattern = f"%{search.strip()}%"
|
||||||
|
search_clause = or_(
|
||||||
|
Transaction.description.ilike(pattern),
|
||||||
|
Transaction.merchant.ilike(pattern),
|
||||||
|
Transaction.notes.ilike(pattern),
|
||||||
|
)
|
||||||
|
stmt = stmt.where(search_clause)
|
||||||
|
count_stmt = count_stmt.where(search_clause)
|
||||||
|
|
||||||
total = (await session.execute(count_stmt)).scalar_one()
|
total = (await session.execute(count_stmt)).scalar_one()
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,57 @@ async def test_list_filters_by_explicit_date_range(client: AsyncClient, account_
|
|||||||
assert body["items"][0]["date"] == "2024-06-15"
|
assert body["items"][0]["date"] == "2024-06-15"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_matches_description_across_any_period_ignoring_month_filter(
|
||||||
|
client: AsyncClient, account_id, categories
|
||||||
|
):
|
||||||
|
"""The point of search is finding a purchase from an unknown month without
|
||||||
|
already knowing which month to filter by, so it must not be scoped by the
|
||||||
|
year/month params even when the caller happens to also pass them."""
|
||||||
|
category_id = categories["Groceries"]["id"]
|
||||||
|
for txn_date, description in [
|
||||||
|
("2021-03-12", "PlayStation 5 console"),
|
||||||
|
("2024-11-02", "grocery run"),
|
||||||
|
("2026-08-16", "current month coffee"),
|
||||||
|
]:
|
||||||
|
resp = await client.post(
|
||||||
|
"/transactions", json=_txn(account_id, category_id=category_id, date=txn_date, description=description)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
resp = await client.get("/transactions", params={"search": "playstation"})
|
||||||
|
body = resp.json()
|
||||||
|
assert body["total"] == 1
|
||||||
|
assert body["items"][0]["description"] == "PlayStation 5 console"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_matches_merchant_and_notes(client: AsyncClient, account_id, categories):
|
||||||
|
category_id = categories["Groceries"]["id"]
|
||||||
|
await client.post(
|
||||||
|
"/transactions",
|
||||||
|
json=_txn(account_id, category_id=category_id, description="misc", merchant="Best Buy"),
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
"/transactions",
|
||||||
|
json=_txn(account_id, category_id=category_id, description="misc", notes="bought for a birthday gift"),
|
||||||
|
)
|
||||||
|
|
||||||
|
merchant_resp = await client.get("/transactions", params={"search": "best buy"})
|
||||||
|
assert merchant_resp.json()["total"] == 1
|
||||||
|
|
||||||
|
notes_resp = await client.get("/transactions", params={"search": "birthday"})
|
||||||
|
assert notes_resp.json()["total"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_with_no_matches_returns_empty(client: AsyncClient, account_id, categories):
|
||||||
|
category_id = categories["Groceries"]["id"]
|
||||||
|
await client.post("/transactions", json=_txn(account_id, category_id=category_id, description="groceries"))
|
||||||
|
|
||||||
|
resp = await client.get("/transactions", params={"search": "nonexistent item xyz"})
|
||||||
|
body = resp.json()
|
||||||
|
assert body["total"] == 0
|
||||||
|
assert body["items"] == []
|
||||||
|
|
||||||
|
|
||||||
async def test_pagination_covers_every_item_with_no_overlap(client: AsyncClient, account_id, categories):
|
async def test_pagination_covers_every_item_with_no_overlap(client: AsyncClient, account_id, categories):
|
||||||
category_id = categories["Groceries"]["id"]
|
category_id = categories["Groceries"]["id"]
|
||||||
for day in range(1, 26):
|
for day in range(1, 26):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "aurum-frontend",
|
"name": "aurum-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.20.2",
|
"version": "0.21.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface TransactionFilters {
|
|||||||
account_id?: number;
|
account_id?: number;
|
||||||
category_id?: number;
|
category_id?: number;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
search?: string;
|
||||||
sort?: TransactionSort;
|
sort?: TransactionSort;
|
||||||
page?: number;
|
page?: number;
|
||||||
page_size?: number;
|
page_size?: number;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ArrowLeftRight, Pencil, StickyNote, Trash2 } from "lucide-react";
|
import { ArrowLeftRight, CalendarSearch, Pencil, StickyNote, Trash2 } from "lucide-react";
|
||||||
import { Dialog } from "@/components/ui/Dialog";
|
import { Dialog } from "@/components/ui/Dialog";
|
||||||
import { getCategoryIcon } from "@/lib/icons";
|
import { getCategoryIcon } from "@/lib/icons";
|
||||||
import { formatCurrency, formatTransactionDate } from "@/lib/format";
|
import { formatCurrency, formatTransactionDate } from "@/lib/format";
|
||||||
@@ -11,14 +11,22 @@ interface TransactionsTableProps {
|
|||||||
items: Transaction[];
|
items: Transaction[];
|
||||||
onEdit: (transaction: Transaction) => void;
|
onEdit: (transaction: Transaction) => void;
|
||||||
onDelete: (transaction: Transaction) => void;
|
onDelete: (transaction: Transaction) => void;
|
||||||
|
/** Present only while showing search results, which span every month —
|
||||||
|
* lets a row's date carry the year and offers a way to jump back to
|
||||||
|
* browsing that transaction's month instead of just listing it flat. */
|
||||||
|
onJumpToMonth?: (transaction: Transaction) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TransactionsTable({ items, onEdit, onDelete }: TransactionsTableProps) {
|
export function TransactionsTable({ items, onEdit, onDelete, onJumpToMonth }: TransactionsTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [noteTransaction, setNoteTransaction] = useState<Transaction | null>(null);
|
const [noteTransaction, setNoteTransaction] = useState<Transaction | null>(null);
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return <p className="py-12 text-center text-sm text-text-muted">{t("transactions.noneFound")}</p>;
|
return (
|
||||||
|
<p className="py-12 text-center text-sm text-text-muted">
|
||||||
|
{onJumpToMonth ? t("transactions.searchNoneFound") : t("transactions.noneFound")}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -42,7 +50,7 @@ export function TransactionsTable({ items, onEdit, onDelete }: TransactionsTable
|
|||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate text-sm font-medium text-text-primary">{tx.description}</span>
|
<span className="block truncate text-sm font-medium text-text-primary">{tx.description}</span>
|
||||||
<span className="block truncate text-xs text-text-muted">
|
<span className="block truncate text-xs text-text-muted">
|
||||||
{formatTransactionDate(tx.date)} · {tx.account.name}
|
{formatTransactionDate(tx.date, Boolean(onJumpToMonth))} · {tx.account.name}
|
||||||
{isTransfer && tx.transfer_account_id ? ` ${t("transactions.transferSuffix")}` : ""}
|
{isTransfer && tx.transfer_account_id ? ` ${t("transactions.transferSuffix")}` : ""}
|
||||||
{tx.category ? ` · ${translateCategoryName(tx.category.name)}` : ""}
|
{tx.category ? ` · ${translateCategoryName(tx.category.name)}` : ""}
|
||||||
</span>
|
</span>
|
||||||
@@ -58,6 +66,16 @@ export function TransactionsTable({ items, onEdit, onDelete }: TransactionsTable
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="flex shrink-0 gap-1">
|
<span className="flex shrink-0 gap-1">
|
||||||
|
{onJumpToMonth && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t("transactions.jumpToMonth")}
|
||||||
|
onClick={() => onJumpToMonth(tx)}
|
||||||
|
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
|
||||||
|
>
|
||||||
|
<CalendarSearch size={15} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{tx.notes && (
|
{tx.notes && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -30,9 +30,15 @@ export function getMonthLabels(language: Language): readonly string[] {
|
|||||||
return language === "ru" ? MONTH_LABELS_RU : MONTH_LABELS_EN;
|
return language === "ru" ? MONTH_LABELS_RU : MONTH_LABELS_EN;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTransactionDate(isoDate: string): string {
|
/** `includeYear` is for contexts spanning multiple years (e.g. an all-time
|
||||||
|
* search) where "Aug 16" alone wouldn't say which year. */
|
||||||
|
export function formatTransactionDate(isoDate: string, includeYear = false): string {
|
||||||
const date = new Date(`${isoDate}T00:00:00`);
|
const date = new Date(`${isoDate}T00:00:00`);
|
||||||
return new Intl.DateTimeFormat(getIntlLocale(), { month: "short", day: "numeric" }).format(date);
|
return new Intl.DateTimeFormat(getIntlLocale(), {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: includeYear ? "numeric" : undefined,
|
||||||
|
}).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Russian noun pluralization: pick the right form for 1/2-4/5+ (with the
|
/** Russian noun pluralization: pick the right form for 1/2-4/5+ (with the
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const ru = {
|
|||||||
"common.loading": "Загрузка…",
|
"common.loading": "Загрузка…",
|
||||||
"common.back": "Назад",
|
"common.back": "Назад",
|
||||||
"common.next": "Далее",
|
"common.next": "Далее",
|
||||||
|
"common.clear": "Очистить",
|
||||||
"common.cancel": "Отмена",
|
"common.cancel": "Отмена",
|
||||||
"common.save": "Сохранить",
|
"common.save": "Сохранить",
|
||||||
"common.saving": "Сохранение…",
|
"common.saving": "Сохранение…",
|
||||||
@@ -138,6 +139,10 @@ const ru = {
|
|||||||
"transactions.allCategories": "Все категории",
|
"transactions.allCategories": "Все категории",
|
||||||
"transactions.failedToLoad": "Не удалось загрузить транзакции.",
|
"transactions.failedToLoad": "Не удалось загрузить транзакции.",
|
||||||
"transactions.noneFound": "Транзакций не найдено.",
|
"transactions.noneFound": "Транзакций не найдено.",
|
||||||
|
"transactions.searchPlaceholder": "Поиск по описанию, продавцу или заметке…",
|
||||||
|
"transactions.searchAcrossAllTime": "Поиск ведётся по всем периодам, а не только за выбранный месяц.",
|
||||||
|
"transactions.searchNoneFound": "Ничего не найдено. Попробуйте другой запрос.",
|
||||||
|
"transactions.jumpToMonth": "Перейти к этому месяцу",
|
||||||
"transactions.confirmDelete": "Удалить транзакцию «{{description}}»?",
|
"transactions.confirmDelete": "Удалить транзакцию «{{description}}»?",
|
||||||
"transactions.transferSuffix": "→ перевод",
|
"transactions.transferSuffix": "→ перевод",
|
||||||
"transactions.sortDateDesc": "Сначала новые",
|
"transactions.sortDateDesc": "Сначала новые",
|
||||||
@@ -358,6 +363,7 @@ const en: Record<keyof typeof ru, string> = {
|
|||||||
"common.loading": "Loading…",
|
"common.loading": "Loading…",
|
||||||
"common.back": "Back",
|
"common.back": "Back",
|
||||||
"common.next": "Next",
|
"common.next": "Next",
|
||||||
|
"common.clear": "Clear",
|
||||||
"common.cancel": "Cancel",
|
"common.cancel": "Cancel",
|
||||||
"common.save": "Save",
|
"common.save": "Save",
|
||||||
"common.saving": "Saving…",
|
"common.saving": "Saving…",
|
||||||
@@ -467,6 +473,10 @@ const en: Record<keyof typeof ru, string> = {
|
|||||||
"transactions.allCategories": "All categories",
|
"transactions.allCategories": "All categories",
|
||||||
"transactions.failedToLoad": "Failed to load transactions.",
|
"transactions.failedToLoad": "Failed to load transactions.",
|
||||||
"transactions.noneFound": "No transactions found.",
|
"transactions.noneFound": "No transactions found.",
|
||||||
|
"transactions.searchPlaceholder": "Search by description, merchant, or note…",
|
||||||
|
"transactions.searchAcrossAllTime": "Searching across every period, not just the selected month.",
|
||||||
|
"transactions.searchNoneFound": "Nothing found. Try a different search.",
|
||||||
|
"transactions.jumpToMonth": "Jump to this month",
|
||||||
"transactions.confirmDelete": "Delete transaction “{{description}}”?",
|
"transactions.confirmDelete": "Delete transaction “{{description}}”?",
|
||||||
"transactions.transferSuffix": "→ transfer",
|
"transactions.transferSuffix": "→ transfer",
|
||||||
"transactions.sortDateDesc": "Newest first",
|
"transactions.sortDateDesc": "Newest first",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus, Search, X } from "lucide-react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Select } from "@/components/ui/Input";
|
import { Input, Select } from "@/components/ui/Input";
|
||||||
import { MonthSelector } from "@/components/layout/MonthSelector";
|
import { MonthSelector } from "@/components/layout/MonthSelector";
|
||||||
import { YearSelector } from "@/components/layout/YearSelector";
|
import { YearSelector } from "@/components/layout/YearSelector";
|
||||||
import { TransactionsTable } from "@/components/transactions/TransactionsTable";
|
import { TransactionsTable } from "@/components/transactions/TransactionsTable";
|
||||||
@@ -47,14 +47,30 @@ export function TransactionsPage() {
|
|||||||
const [sort, setSort] = useState<TransactionSort>("date_desc");
|
const [sort, setSort] = useState<TransactionSort>("date_desc");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
// Raw text follows every keystroke; the debounced value is what actually
|
||||||
|
// drives the query, so we're not refetching on every character typed.
|
||||||
|
const [searchInput, setSearchInput] = useState("");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setSearch(searchInput.trim());
|
||||||
|
setPage(1);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [searchInput]);
|
||||||
|
const isSearching = search.length > 0;
|
||||||
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
|
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
|
||||||
|
|
||||||
const { data: categories } = useCategories();
|
const { data: categories } = useCategories();
|
||||||
const { data: years } = useTransactionYears();
|
const { data: years } = useTransactionYears();
|
||||||
const { data, isLoading, isError } = useTransactions({
|
const { data, isLoading, isError } = useTransactions({
|
||||||
year,
|
// A search looks for a purchase from an unknown month, so it must span
|
||||||
month,
|
// every period instead of being boxed into the currently selected one.
|
||||||
|
year: isSearching ? undefined : year,
|
||||||
|
month: isSearching ? undefined : month,
|
||||||
|
search: isSearching ? search : undefined,
|
||||||
type: type || undefined,
|
type: type || undefined,
|
||||||
category_id: categoryId ? Number(categoryId) : undefined,
|
category_id: categoryId ? Number(categoryId) : undefined,
|
||||||
sort,
|
sort,
|
||||||
@@ -81,11 +97,23 @@ export function TransactionsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Leaves search mode and switches the month/year selectors to whichever
|
||||||
|
* month the picked transaction is in, so the user lands back in the normal
|
||||||
|
* browsing view with it in context instead of a flat result list. */
|
||||||
|
function handleJumpToMonth(transaction: Transaction) {
|
||||||
|
const date = new Date(`${transaction.date}T00:00:00`);
|
||||||
|
setYear(date.getFullYear());
|
||||||
|
setMonth(date.getMonth() + 1);
|
||||||
|
setSearchInput("");
|
||||||
|
setSearch("");
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className={`min-w-0 flex-1 ${isSearching ? "pointer-events-none opacity-50" : ""}`}>
|
||||||
<MonthSelector
|
<MonthSelector
|
||||||
month={month}
|
month={month}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@@ -94,14 +122,16 @@ export function TransactionsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<YearSelector
|
<div className={isSearching ? "pointer-events-none opacity-50" : ""}>
|
||||||
years={years ?? [now.getFullYear()]}
|
<YearSelector
|
||||||
year={year}
|
years={years ?? [now.getFullYear()]}
|
||||||
onChange={(value) => {
|
year={year}
|
||||||
setYear(value);
|
onChange={(value) => {
|
||||||
setPage(1);
|
setYear(value);
|
||||||
}}
|
setPage(1);
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={openCreateModal} className="w-full sm:w-auto">
|
<Button onClick={openCreateModal} className="w-full sm:w-auto">
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
@@ -109,6 +139,31 @@ export function TransactionsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||||
|
<Input
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(event) => setSearchInput(event.target.value)}
|
||||||
|
placeholder={t("transactions.searchPlaceholder")}
|
||||||
|
className="pl-9 pr-9"
|
||||||
|
/>
|
||||||
|
{searchInput && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t("common.clear")}
|
||||||
|
onClick={() => {
|
||||||
|
setSearchInput("");
|
||||||
|
setSearch("");
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-text-muted hover:bg-surface-2 hover:text-text-primary"
|
||||||
|
>
|
||||||
|
<X size={15} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isSearching && <p className="text-xs text-text-muted">{t("transactions.searchAcrossAllTime")}</p>}
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row">
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
<Select
|
<Select
|
||||||
value={type}
|
value={type}
|
||||||
@@ -162,7 +217,12 @@ export function TransactionsPage() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="py-12 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
<p className="py-12 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||||
) : (
|
) : (
|
||||||
<TransactionsTable items={data?.items ?? []} onEdit={openEditModal} onDelete={handleDelete} />
|
<TransactionsTable
|
||||||
|
items={data?.items ?? []}
|
||||||
|
onEdit={openEditModal}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onJumpToMonth={isSearching ? handleJumpToMonth : undefined}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user