diff --git a/frontend/src/components/TransactionsList.tsx b/frontend/src/components/TransactionsList.tsx index 68aebcd..89c59fb 100644 --- a/frontend/src/components/TransactionsList.tsx +++ b/frontend/src/components/TransactionsList.tsx @@ -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({ + } = useQuery>({ 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() { ) : (
- {filteredTransactions.map((transaction) => { + {filteredTransactions.map((transaction: Transaction) => { const account = accounts?.find( (acc) => acc.id === transaction.account_id, ); diff --git a/frontend/src/components/TransactionsTable.tsx b/frontend/src/components/TransactionsTable.tsx index 6ee6020..e60fd73 100644 --- a/frontend/src/components/TransactionsTable.tsx +++ b/frontend/src/components/TransactionsTable.tsx @@ -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(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([]); const [columnFilters, setColumnFilters] = useState([]); + // 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({ queryKey: ["accounts"], @@ -52,21 +76,45 @@ export default function TransactionsTable() { }); const { - data: transactions, + data: transactionsResponse, isLoading: transactionsLoading, error: transactionsError, refetch: refetchTransactions, - } = useQuery({ - queryKey: ["transactions", selectedAccount, startDate, endDate], + } = useQuery>({ + 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[] = [ @@ -144,12 +210,10 @@ export default function TransactionsTable() { {account.institution_id}

)} - {(transaction.creditor_name || - transaction.debtor_name) && ( + {(transaction.creditor_name || transaction.debtor_name) && (

{isPositive ? "From: " : "To: "} - {transaction.creditor_name || - transaction.debtor_name} + {transaction.creditor_name || transaction.debtor_name}

)} {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 && (

Booked: {formatDate(transaction.booking_date)}

@@ -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 && ( +
+
+
+ )}
@@ -456,8 +523,21 @@ export default function TransactionsTable() { {/* Results Summary */}

- 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 && ( for {accounts.find((acc) => acc.id === selectedAccount)?.name} @@ -552,93 +632,125 @@ export default function TransactionsTable() {

{/* Pagination */} -
-
- - -
-
-
-

- Showing{" "} - - {table.getState().pagination.pageIndex * - table.getState().pagination.pageSize + - 1} - {" "} - to{" "} - - {Math.min( - (table.getState().pagination.pageIndex + 1) * - table.getState().pagination.pageSize, - table.getFilteredRowModel().rows.length, - )} - {" "} - of{" "} - - {table.getFilteredRowModel().rows.length} - {" "} - results -

-
-
-
- - -
-
+ {pagination && ( +
+
+
+ - - Page{" "} - - {table.getState().pagination.pageIndex + 1} - {" "} - of{" "} - - {table.getPageCount()} - - +
+
+ +
+
+
+
+

+ Showing{" "} + + {(pagination.page - 1) * pagination.per_page + 1} + {" "} + to{" "} + + {Math.min( + pagination.page * pagination.per_page, + pagination.total, + )} + {" "} + of {pagination.total}{" "} + results +

+
+
+
+ + +
+
+ + + + Page {pagination.page}{" "} + of{" "} + + {pagination.total_pages} + + + + +
-
+ )}
{/* Raw Transaction Modal */} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index af0741e..6ff39ad 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -70,12 +70,12 @@ export const apiClient = { perPage?: number; search?: string; summaryOnly?: boolean; - }): Promise => { + }): Promise> => { 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>( `/transactions?${queryParams.toString()}`, ); - return response.data.data; + return response.data; }, // Get transaction by ID diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index bf7ac9c..d614f0f 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -124,6 +124,14 @@ export interface ApiResponse { 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 { diff --git a/leggend/api/routes/transactions.py b/leggend/api/routes/transactions.py index 3f91d2e..778d775 100644 --- a/leggend/api/routes/transactions.py +++ b/leggend/api/routes/transactions.py @@ -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: