feat: improve transactions API pagination and search

- Update backend /transactions endpoint to use PaginatedResponse
- Change from limit/offset to page/per_page parameters for consistency
- Implement server-side pagination with proper metadata
- Add search debouncing to prevent excessive API calls (300ms delay)
- Add First/Last page buttons to pagination controls
- Fix pagination state reset when filters return 0 results
- Reset pagination to page 1 when filters are applied
- Add visual loading indicator during search debouncing
- Update frontend types and API client to handle new response structure
- Fix TypeScript errors and improve type safety
This commit is contained in:
Elisiário Couto
2025-09-11 14:13:58 +01:00
parent 544527f282
commit 2d6800eff8
5 changed files with 272 additions and 146 deletions

View File

@@ -15,7 +15,7 @@ import { apiClient } from "../lib/api";
import { formatCurrency, formatDate } from "../lib/utils";
import LoadingSpinner from "./LoadingSpinner";
import RawTransactionModal from "./RawTransactionModal";
import type { Account, Transaction } from "../types/api";
import type { Account, Transaction, ApiResponse } from "../types/api";
export default function TransactionsList() {
const [searchTerm, setSearchTerm] = useState("");
@@ -33,11 +33,11 @@ export default function TransactionsList() {
});
const {
data: transactions,
data: transactionsResponse,
isLoading: transactionsLoading,
error: transactionsError,
refetch: refetchTransactions,
} = useQuery<Transaction[]>({
} = useQuery<ApiResponse<Transaction[]>>({
queryKey: ["transactions", selectedAccount, startDate, endDate],
queryFn: () =>
apiClient.getTransactions({
@@ -48,30 +48,34 @@ export default function TransactionsList() {
}),
});
const filteredTransactions = (transactions || []).filter((transaction) => {
// Additional validation (API client should have already filtered out invalid ones)
if (!transaction || !transaction.account_id) {
console.warn(
"Invalid transaction found after API filtering:",
transaction,
);
return false;
}
const transactions = transactionsResponse?.data || [];
const description = transaction.description || "";
const creditorName = transaction.creditor_name || "";
const debtorName = transaction.debtor_name || "";
const reference = transaction.reference || "";
const filteredTransactions = (transactions || []).filter(
(transaction: Transaction) => {
// Additional validation (API client should have already filtered out invalid ones)
if (!transaction || !transaction.account_id) {
console.warn(
"Invalid transaction found after API filtering:",
transaction,
);
return false;
}
const matchesSearch =
searchTerm === "" ||
description.toLowerCase().includes(searchTerm.toLowerCase()) ||
creditorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
debtorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
reference.toLowerCase().includes(searchTerm.toLowerCase());
const description = transaction.description || "";
const creditorName = transaction.creditor_name || "";
const debtorName = transaction.debtor_name || "";
const reference = transaction.reference || "";
return matchesSearch;
});
const matchesSearch =
searchTerm === "" ||
description.toLowerCase().includes(searchTerm.toLowerCase()) ||
creditorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
debtorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
reference.toLowerCase().includes(searchTerm.toLowerCase());
return matchesSearch;
},
);
const clearFilters = () => {
setSearchTerm("");
@@ -260,7 +264,7 @@ export default function TransactionsList() {
</div>
) : (
<div className="bg-white rounded-lg shadow divide-y divide-gray-200">
{filteredTransactions.map((transaction) => {
{filteredTransactions.map((transaction: Transaction) => {
const account = accounts?.find(
(acc) => acc.id === transaction.account_id,
);

View File

@@ -1,14 +1,17 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getPaginationRowModel,
getFilteredRowModel,
flexRender,
} from "@tanstack/react-table";
import type { ColumnDef, SortingState, ColumnFiltersState } from "@tanstack/react-table";
import type {
ColumnDef,
SortingState,
ColumnFiltersState,
} from "@tanstack/react-table";
import {
Filter,
Search,
@@ -26,7 +29,7 @@ import { apiClient } from "../lib/api";
import { formatCurrency, formatDate } from "../lib/utils";
import LoadingSpinner from "./LoadingSpinner";
import RawTransactionModal from "./RawTransactionModal";
import type { Account, Transaction } from "../types/api";
import type { Account, Transaction, ApiResponse } from "../types/api";
export default function TransactionsTable() {
const [searchTerm, setSearchTerm] = useState("");
@@ -40,11 +43,32 @@ export default function TransactionsTable() {
const [selectedTransaction, setSelectedTransaction] =
useState<Transaction | null>(null);
// Table state
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [perPage, setPerPage] = useState(50);
// Debounced search state
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm);
// Table state (remove pagination from table)
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
// Debounce search term to prevent excessive API calls
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
}, 300); // 300ms delay
return () => clearTimeout(timer);
}, [searchTerm]);
// Reset pagination when search term changes
useEffect(() => {
if (debouncedSearchTerm !== searchTerm) {
setCurrentPage(1);
}
}, [debouncedSearchTerm, searchTerm]);
const { data: accounts } = useQuery<Account[]>({
queryKey: ["accounts"],
@@ -52,21 +76,45 @@ export default function TransactionsTable() {
});
const {
data: transactions,
data: transactionsResponse,
isLoading: transactionsLoading,
error: transactionsError,
refetch: refetchTransactions,
} = useQuery<Transaction[]>({
queryKey: ["transactions", selectedAccount, startDate, endDate],
} = useQuery<ApiResponse<Transaction[]>>({
queryKey: [
"transactions",
selectedAccount,
startDate,
endDate,
currentPage,
perPage,
debouncedSearchTerm,
],
queryFn: () =>
apiClient.getTransactions({
accountId: selectedAccount || undefined,
startDate: startDate || undefined,
endDate: endDate || undefined,
page: currentPage,
perPage: perPage,
search: debouncedSearchTerm || undefined,
summaryOnly: false,
}),
});
const transactions = transactionsResponse?.data || [];
const pagination = transactionsResponse?.pagination;
// Check if search is currently debouncing
const isSearchLoading = searchTerm !== debouncedSearchTerm;
// Reset pagination when total becomes 0 (no results)
useEffect(() => {
if (pagination && pagination.total === 0 && currentPage > 1) {
setCurrentPage(1);
}
}, [pagination, currentPage]);
const clearFilters = () => {
setSearchTerm("");
setSelectedAccount("");
@@ -75,6 +123,7 @@ export default function TransactionsTable() {
setMinAmount("");
setMaxAmount("");
setColumnFilters([]);
setCurrentPage(1); // Reset to first page when clearing filters
};
const setQuickDateFilter = (days: number) => {
@@ -82,8 +131,9 @@ export default function TransactionsTable() {
const startDate = new Date();
startDate.setDate(endDate.getDate() - days);
setStartDate(startDate.toISOString().split('T')[0]);
setEndDate(endDate.toISOString().split('T')[0]);
setStartDate(startDate.toISOString().split("T")[0]);
setEndDate(endDate.toISOString().split("T")[0]);
setCurrentPage(1); // Reset to first page when changing date filters
};
const setThisMonthFilter = () => {
@@ -91,10 +141,21 @@ export default function TransactionsTable() {
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
setStartDate(startOfMonth.toISOString().split('T')[0]);
setEndDate(endOfMonth.toISOString().split('T')[0]);
setStartDate(startOfMonth.toISOString().split("T")[0]);
setEndDate(endOfMonth.toISOString().split("T")[0]);
setCurrentPage(1); // Reset to first page when changing date filters
};
// Reset pagination when account filter changes
useEffect(() => {
setCurrentPage(1);
}, [selectedAccount]);
// Reset pagination when date filters change
useEffect(() => {
setCurrentPage(1);
}, [startDate, endDate]);
const handleViewRaw = (transaction: Transaction) => {
setSelectedTransaction(transaction);
setShowRawModal(true);
@@ -106,7 +167,12 @@ export default function TransactionsTable() {
};
const hasActiveFilters =
searchTerm || selectedAccount || startDate || endDate || minAmount || maxAmount;
searchTerm ||
selectedAccount ||
startDate ||
endDate ||
minAmount ||
maxAmount;
// Define columns
const columns: ColumnDef<Transaction>[] = [
@@ -144,12 +210,10 @@ export default function TransactionsTable() {
{account.institution_id}
</p>
)}
{(transaction.creditor_name ||
transaction.debtor_name) && (
{(transaction.creditor_name || transaction.debtor_name) && (
<p className="truncate">
{isPositive ? "From: " : "To: "}
{transaction.creditor_name ||
transaction.debtor_name}
{transaction.creditor_name || transaction.debtor_name}
</p>
)}
{transaction.reference && (
@@ -196,8 +260,7 @@ export default function TransactionsTable() {
? formatDate(transaction.transaction_date)
: "No date"}
{transaction.booking_date &&
transaction.booking_date !==
transaction.transaction_date && (
transaction.booking_date !== transaction.transaction_date && (
<p className="text-xs text-gray-400">
Booked: {formatDate(transaction.booking_date)}
</p>
@@ -227,11 +290,10 @@ export default function TransactionsTable() {
];
const table = useReactTable({
data: transactions || [],
data: transactions,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
@@ -365,6 +427,11 @@ export default function TransactionsTable() {
placeholder="Description, name, reference..."
className="pl-10 pr-3 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
{isSearchLoading && (
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
<div className="animate-spin h-4 w-4 border-2 border-gray-300 border-t-blue-500 rounded-full"></div>
</div>
)}
</div>
</div>
@@ -456,8 +523,21 @@ export default function TransactionsTable() {
{/* Results Summary */}
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200">
<p className="text-sm text-gray-600">
Showing {table.getFilteredRowModel().rows.length} transaction
{table.getFilteredRowModel().rows.length !== 1 ? "s" : ""}
Showing {transactions.length} transaction
{transactions.length !== 1 ? "s" : ""} (
{pagination ? (
<>
{(pagination.page - 1) * pagination.per_page + 1}-
{Math.min(
pagination.page * pagination.per_page,
pagination.total,
)}{" "}
of {pagination.total}
</>
) : (
"loading..."
)}
)
{selectedAccount && accounts && (
<span className="ml-1">
for {accounts.find((acc) => acc.id === selectedAccount)?.name}
@@ -552,93 +632,125 @@ export default function TransactionsTable() {
</div>
{/* Pagination */}
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="ml-3 relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div className="flex items-center space-x-2">
<p className="text-sm text-gray-700">
Showing{" "}
<span className="font-medium">
{table.getState().pagination.pageIndex *
table.getState().pagination.pageSize +
1}
</span>{" "}
to{" "}
<span className="font-medium">
{Math.min(
(table.getState().pagination.pageIndex + 1) *
table.getState().pagination.pageSize,
table.getFilteredRowModel().rows.length,
)}
</span>{" "}
of{" "}
<span className="font-medium">
{table.getFilteredRowModel().rows.length}
</span>{" "}
results
</p>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<label className="text-sm text-gray-700">Rows per page:</label>
<select
value={table.getState().pagination.pageSize}
onChange={(e) => {
table.setPageSize(Number(e.target.value));
}}
className="border border-gray-300 rounded px-2 py-1 text-sm"
>
{[10, 25, 50, 100].map((pageSize) => (
<option key={pageSize} value={pageSize}>
{pageSize}
</option>
))}
</select>
</div>
<div className="flex items-center space-x-2">
{pagination && (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="flex-1 flex justify-between sm:hidden">
<div className="flex space-x-2">
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => setCurrentPage(1)}
disabled={pagination.page === 1}
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
First
</button>
<button
onClick={() =>
setCurrentPage((prev) => Math.max(1, prev - 1))
}
disabled={!pagination.has_prev}
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-sm text-gray-700">
Page{" "}
<span className="font-medium">
{table.getState().pagination.pageIndex + 1}
</span>{" "}
of{" "}
<span className="font-medium">
{table.getPageCount()}
</span>
</span>
</div>
<div className="flex space-x-2">
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => setCurrentPage((prev) => prev + 1)}
disabled={!pagination.has_next}
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
<button
onClick={() => setCurrentPage(pagination.total_pages)}
disabled={pagination.page === pagination.total_pages}
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Last
</button>
</div>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div className="flex items-center space-x-2">
<p className="text-sm text-gray-700">
Showing{" "}
<span className="font-medium">
{(pagination.page - 1) * pagination.per_page + 1}
</span>{" "}
to{" "}
<span className="font-medium">
{Math.min(
pagination.page * pagination.per_page,
pagination.total,
)}
</span>{" "}
of <span className="font-medium">{pagination.total}</span>{" "}
results
</p>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<label className="text-sm text-gray-700">
Rows per page:
</label>
<select
value={perPage}
onChange={(e) => {
setPerPage(Number(e.target.value));
setCurrentPage(1); // Reset to first page when changing page size
}}
className="border border-gray-300 rounded px-2 py-1 text-sm"
>
{[10, 25, 50, 100].map((pageSize) => (
<option key={pageSize} value={pageSize}>
{pageSize}
</option>
))}
</select>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => setCurrentPage(1)}
disabled={pagination.page === 1}
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
First
</button>
<button
onClick={() =>
setCurrentPage((prev) => Math.max(1, prev - 1))
}
disabled={!pagination.has_prev}
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-sm text-gray-700">
Page <span className="font-medium">{pagination.page}</span>{" "}
of{" "}
<span className="font-medium">
{pagination.total_pages}
</span>
</span>
<button
onClick={() => setCurrentPage((prev) => prev + 1)}
disabled={!pagination.has_next}
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
<button
onClick={() => setCurrentPage(pagination.total_pages)}
disabled={pagination.page === pagination.total_pages}
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Last
</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
{/* Raw Transaction Modal */}

View File

@@ -70,12 +70,12 @@ export const apiClient = {
perPage?: number;
search?: string;
summaryOnly?: boolean;
}): Promise<Transaction[]> => {
}): Promise<ApiResponse<Transaction[]>> => {
const queryParams = new URLSearchParams();
if (params?.accountId) queryParams.append("account_id", params.accountId);
if (params?.startDate) queryParams.append("start_date", params.startDate);
if (params?.endDate) queryParams.append("end_date", params.endDate);
if (params?.startDate) queryParams.append("date_from", params.startDate);
if (params?.endDate) queryParams.append("date_to", params.endDate);
if (params?.page) queryParams.append("page", params.page.toString());
if (params?.perPage)
queryParams.append("per_page", params.perPage.toString());
@@ -87,7 +87,7 @@ export const apiClient = {
const response = await api.get<ApiResponse<Transaction[]>>(
`/transactions?${queryParams.toString()}`,
);
return response.data.data;
return response.data;
},
// Get transaction by ID

View File

@@ -124,6 +124,14 @@ export interface ApiResponse<T> {
data: T;
message?: string;
success: boolean;
pagination?: {
total: number;
page: number;
per_page: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
};
}
export interface PaginatedResponse<T> {

View File

@@ -3,7 +3,7 @@ from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
from leggend.api.models.common import APIResponse
from leggend.api.models.common import APIResponse, PaginatedResponse
from leggend.api.models.accounts import Transaction, TransactionSummary
from leggend.services.database_service import DatabaseService
@@ -11,10 +11,10 @@ router = APIRouter()
database_service = DatabaseService()
@router.get("/transactions", response_model=APIResponse)
@router.get("/transactions", response_model=PaginatedResponse)
async def get_all_transactions(
limit: Optional[int] = Query(default=100, le=500),
offset: Optional[int] = Query(default=0, ge=0),
page: int = Query(default=1, ge=1, description="Page number (1-based)"),
per_page: int = Query(default=50, le=500, description="Items per page"),
summary_only: bool = Query(
default=True, description="Return transaction summaries only"
),
@@ -34,9 +34,13 @@ async def get_all_transactions(
default=None, description="Search in transaction descriptions"
),
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
) -> APIResponse:
) -> PaginatedResponse:
"""Get all transactions from database with filtering options"""
try:
# Calculate offset from page and per_page
offset = (page - 1) * per_page
limit = per_page
# Get transactions from database instead of GoCardless API
db_transactions = await database_service.get_transactions_from_db(
account_id=account_id,
@@ -59,16 +63,6 @@ async def get_all_transactions(
search=search,
)
# Get total count for pagination info
total_transactions = await database_service.get_transaction_count_from_db(
account_id=account_id,
date_from=date_from,
date_to=date_to,
min_amount=min_amount,
max_amount=max_amount,
search=search,
)
data: Union[List[TransactionSummary], List[Transaction]]
if summary_only:
@@ -105,11 +99,19 @@ async def get_all_transactions(
for txn in db_transactions
]
actual_offset = offset or 0
return APIResponse(
total_pages = (total_transactions + per_page - 1) // per_page
return PaginatedResponse(
success=True,
data=data,
message=f"Retrieved {len(data)} transactions (showing {actual_offset + 1}-{actual_offset + len(data)} of {total_transactions})",
pagination={
"total": total_transactions,
"page": page,
"per_page": per_page,
"total_pages": total_pages,
"has_next": page < total_pages,
"has_prev": page > 1,
},
)
except Exception as e: