mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-14 12:02:19 +00:00
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:
@@ -15,7 +15,7 @@ import { apiClient } from "../lib/api";
|
|||||||
import { formatCurrency, formatDate } from "../lib/utils";
|
import { formatCurrency, formatDate } from "../lib/utils";
|
||||||
import LoadingSpinner from "./LoadingSpinner";
|
import LoadingSpinner from "./LoadingSpinner";
|
||||||
import RawTransactionModal from "./RawTransactionModal";
|
import RawTransactionModal from "./RawTransactionModal";
|
||||||
import type { Account, Transaction } from "../types/api";
|
import type { Account, Transaction, ApiResponse } from "../types/api";
|
||||||
|
|
||||||
export default function TransactionsList() {
|
export default function TransactionsList() {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
@@ -33,11 +33,11 @@ export default function TransactionsList() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: transactions,
|
data: transactionsResponse,
|
||||||
isLoading: transactionsLoading,
|
isLoading: transactionsLoading,
|
||||||
error: transactionsError,
|
error: transactionsError,
|
||||||
refetch: refetchTransactions,
|
refetch: refetchTransactions,
|
||||||
} = useQuery<Transaction[]>({
|
} = useQuery<ApiResponse<Transaction[]>>({
|
||||||
queryKey: ["transactions", selectedAccount, startDate, endDate],
|
queryKey: ["transactions", selectedAccount, startDate, endDate],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiClient.getTransactions({
|
apiClient.getTransactions({
|
||||||
@@ -48,7 +48,10 @@ export default function TransactionsList() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredTransactions = (transactions || []).filter((transaction) => {
|
const transactions = transactionsResponse?.data || [];
|
||||||
|
|
||||||
|
const filteredTransactions = (transactions || []).filter(
|
||||||
|
(transaction: Transaction) => {
|
||||||
// Additional validation (API client should have already filtered out invalid ones)
|
// Additional validation (API client should have already filtered out invalid ones)
|
||||||
if (!transaction || !transaction.account_id) {
|
if (!transaction || !transaction.account_id) {
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -71,7 +74,8 @@ export default function TransactionsList() {
|
|||||||
reference.toLowerCase().includes(searchTerm.toLowerCase());
|
reference.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
return matchesSearch;
|
return matchesSearch;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
setSearchTerm("");
|
setSearchTerm("");
|
||||||
@@ -260,7 +264,7 @@ export default function TransactionsList() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white rounded-lg shadow divide-y divide-gray-200">
|
<div className="bg-white rounded-lg shadow divide-y divide-gray-200">
|
||||||
{filteredTransactions.map((transaction) => {
|
{filteredTransactions.map((transaction: Transaction) => {
|
||||||
const account = accounts?.find(
|
const account = accounts?.find(
|
||||||
(acc) => acc.id === transaction.account_id,
|
(acc) => acc.id === transaction.account_id,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
useReactTable,
|
useReactTable,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
getPaginationRowModel,
|
|
||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
flexRender,
|
flexRender,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import type { ColumnDef, SortingState, ColumnFiltersState } from "@tanstack/react-table";
|
import type {
|
||||||
|
ColumnDef,
|
||||||
|
SortingState,
|
||||||
|
ColumnFiltersState,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
import {
|
import {
|
||||||
Filter,
|
Filter,
|
||||||
Search,
|
Search,
|
||||||
@@ -26,7 +29,7 @@ import { apiClient } from "../lib/api";
|
|||||||
import { formatCurrency, formatDate } from "../lib/utils";
|
import { formatCurrency, formatDate } from "../lib/utils";
|
||||||
import LoadingSpinner from "./LoadingSpinner";
|
import LoadingSpinner from "./LoadingSpinner";
|
||||||
import RawTransactionModal from "./RawTransactionModal";
|
import RawTransactionModal from "./RawTransactionModal";
|
||||||
import type { Account, Transaction } from "../types/api";
|
import type { Account, Transaction, ApiResponse } from "../types/api";
|
||||||
|
|
||||||
export default function TransactionsTable() {
|
export default function TransactionsTable() {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
@@ -40,11 +43,32 @@ export default function TransactionsTable() {
|
|||||||
const [selectedTransaction, setSelectedTransaction] =
|
const [selectedTransaction, setSelectedTransaction] =
|
||||||
useState<Transaction | null>(null);
|
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 [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
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[]>({
|
const { data: accounts } = useQuery<Account[]>({
|
||||||
queryKey: ["accounts"],
|
queryKey: ["accounts"],
|
||||||
@@ -52,21 +76,45 @@ export default function TransactionsTable() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: transactions,
|
data: transactionsResponse,
|
||||||
isLoading: transactionsLoading,
|
isLoading: transactionsLoading,
|
||||||
error: transactionsError,
|
error: transactionsError,
|
||||||
refetch: refetchTransactions,
|
refetch: refetchTransactions,
|
||||||
} = useQuery<Transaction[]>({
|
} = useQuery<ApiResponse<Transaction[]>>({
|
||||||
queryKey: ["transactions", selectedAccount, startDate, endDate],
|
queryKey: [
|
||||||
|
"transactions",
|
||||||
|
selectedAccount,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
currentPage,
|
||||||
|
perPage,
|
||||||
|
debouncedSearchTerm,
|
||||||
|
],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiClient.getTransactions({
|
apiClient.getTransactions({
|
||||||
accountId: selectedAccount || undefined,
|
accountId: selectedAccount || undefined,
|
||||||
startDate: startDate || undefined,
|
startDate: startDate || undefined,
|
||||||
endDate: endDate || undefined,
|
endDate: endDate || undefined,
|
||||||
|
page: currentPage,
|
||||||
|
perPage: perPage,
|
||||||
|
search: debouncedSearchTerm || undefined,
|
||||||
summaryOnly: false,
|
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 = () => {
|
const clearFilters = () => {
|
||||||
setSearchTerm("");
|
setSearchTerm("");
|
||||||
setSelectedAccount("");
|
setSelectedAccount("");
|
||||||
@@ -75,6 +123,7 @@ export default function TransactionsTable() {
|
|||||||
setMinAmount("");
|
setMinAmount("");
|
||||||
setMaxAmount("");
|
setMaxAmount("");
|
||||||
setColumnFilters([]);
|
setColumnFilters([]);
|
||||||
|
setCurrentPage(1); // Reset to first page when clearing filters
|
||||||
};
|
};
|
||||||
|
|
||||||
const setQuickDateFilter = (days: number) => {
|
const setQuickDateFilter = (days: number) => {
|
||||||
@@ -82,8 +131,9 @@ export default function TransactionsTable() {
|
|||||||
const startDate = new Date();
|
const startDate = new Date();
|
||||||
startDate.setDate(endDate.getDate() - days);
|
startDate.setDate(endDate.getDate() - days);
|
||||||
|
|
||||||
setStartDate(startDate.toISOString().split('T')[0]);
|
setStartDate(startDate.toISOString().split("T")[0]);
|
||||||
setEndDate(endDate.toISOString().split('T')[0]);
|
setEndDate(endDate.toISOString().split("T")[0]);
|
||||||
|
setCurrentPage(1); // Reset to first page when changing date filters
|
||||||
};
|
};
|
||||||
|
|
||||||
const setThisMonthFilter = () => {
|
const setThisMonthFilter = () => {
|
||||||
@@ -91,10 +141,21 @@ export default function TransactionsTable() {
|
|||||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||||
|
|
||||||
setStartDate(startOfMonth.toISOString().split('T')[0]);
|
setStartDate(startOfMonth.toISOString().split("T")[0]);
|
||||||
setEndDate(endOfMonth.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) => {
|
const handleViewRaw = (transaction: Transaction) => {
|
||||||
setSelectedTransaction(transaction);
|
setSelectedTransaction(transaction);
|
||||||
setShowRawModal(true);
|
setShowRawModal(true);
|
||||||
@@ -106,7 +167,12 @@ export default function TransactionsTable() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hasActiveFilters =
|
const hasActiveFilters =
|
||||||
searchTerm || selectedAccount || startDate || endDate || minAmount || maxAmount;
|
searchTerm ||
|
||||||
|
selectedAccount ||
|
||||||
|
startDate ||
|
||||||
|
endDate ||
|
||||||
|
minAmount ||
|
||||||
|
maxAmount;
|
||||||
|
|
||||||
// Define columns
|
// Define columns
|
||||||
const columns: ColumnDef<Transaction>[] = [
|
const columns: ColumnDef<Transaction>[] = [
|
||||||
@@ -144,12 +210,10 @@ export default function TransactionsTable() {
|
|||||||
{account.institution_id}
|
{account.institution_id}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{(transaction.creditor_name ||
|
{(transaction.creditor_name || transaction.debtor_name) && (
|
||||||
transaction.debtor_name) && (
|
|
||||||
<p className="truncate">
|
<p className="truncate">
|
||||||
{isPositive ? "From: " : "To: "}
|
{isPositive ? "From: " : "To: "}
|
||||||
{transaction.creditor_name ||
|
{transaction.creditor_name || transaction.debtor_name}
|
||||||
transaction.debtor_name}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{transaction.reference && (
|
{transaction.reference && (
|
||||||
@@ -196,8 +260,7 @@ export default function TransactionsTable() {
|
|||||||
? formatDate(transaction.transaction_date)
|
? formatDate(transaction.transaction_date)
|
||||||
: "No date"}
|
: "No date"}
|
||||||
{transaction.booking_date &&
|
{transaction.booking_date &&
|
||||||
transaction.booking_date !==
|
transaction.booking_date !== transaction.transaction_date && (
|
||||||
transaction.transaction_date && (
|
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400">
|
||||||
Booked: {formatDate(transaction.booking_date)}
|
Booked: {formatDate(transaction.booking_date)}
|
||||||
</p>
|
</p>
|
||||||
@@ -227,11 +290,10 @@ export default function TransactionsTable() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: transactions || [],
|
data: transactions,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
@@ -365,6 +427,11 @@ export default function TransactionsTable() {
|
|||||||
placeholder="Description, name, reference..."
|
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"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -456,8 +523,21 @@ export default function TransactionsTable() {
|
|||||||
{/* Results Summary */}
|
{/* Results Summary */}
|
||||||
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200">
|
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Showing {table.getFilteredRowModel().rows.length} transaction
|
Showing {transactions.length} transaction
|
||||||
{table.getFilteredRowModel().rows.length !== 1 ? "s" : ""}
|
{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 && (
|
{selectedAccount && accounts && (
|
||||||
<span className="ml-1">
|
<span className="ml-1">
|
||||||
for {accounts.find((acc) => acc.id === selectedAccount)?.name}
|
for {accounts.find((acc) => acc.id === selectedAccount)?.name}
|
||||||
@@ -552,54 +632,72 @@ export default function TransactionsTable() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
|
{pagination && (
|
||||||
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
<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-1 flex justify-between sm:hidden">
|
||||||
|
<div className="flex space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => table.previousPage()}
|
onClick={() => setCurrentPage(1)}
|
||||||
disabled={!table.getCanPreviousPage()}
|
disabled={pagination.page === 1}
|
||||||
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"
|
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
|
Previous
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => table.nextPage()}
|
onClick={() => setCurrentPage((prev) => prev + 1)}
|
||||||
disabled={!table.getCanNextPage()}
|
disabled={!pagination.has_next}
|
||||||
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"
|
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
|
Next
|
||||||
</button>
|
</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>
|
||||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<p className="text-sm text-gray-700">
|
<p className="text-sm text-gray-700">
|
||||||
Showing{" "}
|
Showing{" "}
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{table.getState().pagination.pageIndex *
|
{(pagination.page - 1) * pagination.per_page + 1}
|
||||||
table.getState().pagination.pageSize +
|
|
||||||
1}
|
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
to{" "}
|
to{" "}
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{Math.min(
|
{Math.min(
|
||||||
(table.getState().pagination.pageIndex + 1) *
|
pagination.page * pagination.per_page,
|
||||||
table.getState().pagination.pageSize,
|
pagination.total,
|
||||||
table.getFilteredRowModel().rows.length,
|
|
||||||
)}
|
)}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
of{" "}
|
of <span className="font-medium">{pagination.total}</span>{" "}
|
||||||
<span className="font-medium">
|
|
||||||
{table.getFilteredRowModel().rows.length}
|
|
||||||
</span>{" "}
|
|
||||||
results
|
results
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<label className="text-sm text-gray-700">Rows per page:</label>
|
<label className="text-sm text-gray-700">
|
||||||
|
Rows per page:
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={table.getState().pagination.pageSize}
|
value={perPage}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
table.setPageSize(Number(e.target.value));
|
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"
|
className="border border-gray-300 rounded px-2 py-1 text-sm"
|
||||||
>
|
>
|
||||||
@@ -612,33 +710,47 @@ export default function TransactionsTable() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => table.previousPage()}
|
onClick={() => setCurrentPage(1)}
|
||||||
disabled={!table.getCanPreviousPage()}
|
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"
|
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
|
Previous
|
||||||
</button>
|
</button>
|
||||||
<span className="text-sm text-gray-700">
|
<span className="text-sm text-gray-700">
|
||||||
Page{" "}
|
Page <span className="font-medium">{pagination.page}</span>{" "}
|
||||||
<span className="font-medium">
|
|
||||||
{table.getState().pagination.pageIndex + 1}
|
|
||||||
</span>{" "}
|
|
||||||
of{" "}
|
of{" "}
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{table.getPageCount()}
|
{pagination.total_pages}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => table.nextPage()}
|
onClick={() => setCurrentPage((prev) => prev + 1)}
|
||||||
disabled={!table.getCanNextPage()}
|
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"
|
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
|
Next
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Raw Transaction Modal */}
|
{/* Raw Transaction Modal */}
|
||||||
|
|||||||
@@ -70,12 +70,12 @@ export const apiClient = {
|
|||||||
perPage?: number;
|
perPage?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
summaryOnly?: boolean;
|
summaryOnly?: boolean;
|
||||||
}): Promise<Transaction[]> => {
|
}): Promise<ApiResponse<Transaction[]>> => {
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
if (params?.accountId) queryParams.append("account_id", params.accountId);
|
if (params?.accountId) queryParams.append("account_id", params.accountId);
|
||||||
if (params?.startDate) queryParams.append("start_date", params.startDate);
|
if (params?.startDate) queryParams.append("date_from", params.startDate);
|
||||||
if (params?.endDate) queryParams.append("end_date", params.endDate);
|
if (params?.endDate) queryParams.append("date_to", params.endDate);
|
||||||
if (params?.page) queryParams.append("page", params.page.toString());
|
if (params?.page) queryParams.append("page", params.page.toString());
|
||||||
if (params?.perPage)
|
if (params?.perPage)
|
||||||
queryParams.append("per_page", params.perPage.toString());
|
queryParams.append("per_page", params.perPage.toString());
|
||||||
@@ -87,7 +87,7 @@ export const apiClient = {
|
|||||||
const response = await api.get<ApiResponse<Transaction[]>>(
|
const response = await api.get<ApiResponse<Transaction[]>>(
|
||||||
`/transactions?${queryParams.toString()}`,
|
`/transactions?${queryParams.toString()}`,
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get transaction by ID
|
// Get transaction by ID
|
||||||
|
|||||||
@@ -124,6 +124,14 @@ export interface ApiResponse<T> {
|
|||||||
data: T;
|
data: T;
|
||||||
message?: string;
|
message?: string;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
pagination?: {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
total_pages: number;
|
||||||
|
has_next: boolean;
|
||||||
|
has_prev: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta
|
|||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from loguru import logger
|
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.api.models.accounts import Transaction, TransactionSummary
|
||||||
from leggend.services.database_service import DatabaseService
|
from leggend.services.database_service import DatabaseService
|
||||||
|
|
||||||
@@ -11,10 +11,10 @@ router = APIRouter()
|
|||||||
database_service = DatabaseService()
|
database_service = DatabaseService()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/transactions", response_model=APIResponse)
|
@router.get("/transactions", response_model=PaginatedResponse)
|
||||||
async def get_all_transactions(
|
async def get_all_transactions(
|
||||||
limit: Optional[int] = Query(default=100, le=500),
|
page: int = Query(default=1, ge=1, description="Page number (1-based)"),
|
||||||
offset: Optional[int] = Query(default=0, ge=0),
|
per_page: int = Query(default=50, le=500, description="Items per page"),
|
||||||
summary_only: bool = Query(
|
summary_only: bool = Query(
|
||||||
default=True, description="Return transaction summaries only"
|
default=True, description="Return transaction summaries only"
|
||||||
),
|
),
|
||||||
@@ -34,9 +34,13 @@ async def get_all_transactions(
|
|||||||
default=None, description="Search in transaction descriptions"
|
default=None, description="Search in transaction descriptions"
|
||||||
),
|
),
|
||||||
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
|
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
|
||||||
) -> APIResponse:
|
) -> PaginatedResponse:
|
||||||
"""Get all transactions from database with filtering options"""
|
"""Get all transactions from database with filtering options"""
|
||||||
try:
|
try:
|
||||||
|
# Calculate offset from page and per_page
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
limit = per_page
|
||||||
|
|
||||||
# Get transactions from database instead of GoCardless API
|
# Get transactions from database instead of GoCardless API
|
||||||
db_transactions = await database_service.get_transactions_from_db(
|
db_transactions = await database_service.get_transactions_from_db(
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
@@ -59,16 +63,6 @@ async def get_all_transactions(
|
|||||||
search=search,
|
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]]
|
data: Union[List[TransactionSummary], List[Transaction]]
|
||||||
|
|
||||||
if summary_only:
|
if summary_only:
|
||||||
@@ -105,11 +99,19 @@ async def get_all_transactions(
|
|||||||
for txn in db_transactions
|
for txn in db_transactions
|
||||||
]
|
]
|
||||||
|
|
||||||
actual_offset = offset or 0
|
total_pages = (total_transactions + per_page - 1) // per_page
|
||||||
return APIResponse(
|
|
||||||
|
return PaginatedResponse(
|
||||||
success=True,
|
success=True,
|
||||||
data=data,
|
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:
|
except Exception as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user