From 544527f28284fb9644bec6e721fa5da8ce10739f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elisi=C3=A1rio=20Couto?= Date: Thu, 11 Sep 2025 12:39:42 +0100 Subject: [PATCH] feat(frontend): implement TanStack Table for transactions view - Add @tanstack/react-table package for advanced table functionality - Create new TransactionsTable component with sorting, pagination, and filtering - Implement column sorting for description, amount, and date - Add pagination with configurable page sizes (10, 25, 50, 100) - Implement global search across multiple fields (description, creditor, debtor, reference) - Add quick date filters (Last 7 days, Last 30 days, This month) - Add amount range filtering (min/max) - Ensure mobile responsiveness with proper table layout - Integrate RawTransactionModal with table actions - Replace TransactionsList with TransactionsTable in routes - Fix table freezing issue by removing conflicting filtering logic - Optimize performance with TanStack Table's built-in state management --- frontend/package-lock.json | 34 + frontend/package.json | 1 + frontend/src/components/TransactionsTable.tsx | 653 ++++++++++++++++++ frontend/src/routes/transactions.tsx | 4 +- 4 files changed, 690 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/TransactionsTable.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0c4edc3..22efbec 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@tailwindcss/forms": "^0.5.10", "@tanstack/react-query": "^5.87.1", "@tanstack/react-router": "^1.131.36", + "@tanstack/react-table": "^8.21.3", "@tanstack/router-cli": "^1.131.36", "autoprefixer": "^10.4.21", "axios": "^1.11.0", @@ -1609,6 +1610,26 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/@tanstack/router-cli": { "version": "1.131.36", "resolved": "https://registry.npmjs.org/@tanstack/router-cli/-/router-cli-1.131.36.tgz", @@ -1777,6 +1798,19 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/virtual-file-routes": { "version": "1.131.2", "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.131.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 95166d8..4580fa8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "@tailwindcss/forms": "^0.5.10", "@tanstack/react-query": "^5.87.1", "@tanstack/react-router": "^1.131.36", + "@tanstack/react-table": "^8.21.3", "@tanstack/router-cli": "^1.131.36", "autoprefixer": "^10.4.21", "axios": "^1.11.0", diff --git a/frontend/src/components/TransactionsTable.tsx b/frontend/src/components/TransactionsTable.tsx new file mode 100644 index 0000000..6ee6020 --- /dev/null +++ b/frontend/src/components/TransactionsTable.tsx @@ -0,0 +1,653 @@ +import { useState } 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 { + Filter, + Search, + TrendingUp, + TrendingDown, + Calendar, + RefreshCw, + AlertCircle, + X, + Eye, + ChevronUp, + ChevronDown, +} from "lucide-react"; +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"; + +export default function TransactionsTable() { + const [searchTerm, setSearchTerm] = useState(""); + const [selectedAccount, setSelectedAccount] = useState(""); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [minAmount, setMinAmount] = useState(""); + const [maxAmount, setMaxAmount] = useState(""); + const [showFilters, setShowFilters] = useState(false); + const [showRawModal, setShowRawModal] = useState(false); + const [selectedTransaction, setSelectedTransaction] = + useState(null); + + // Table state + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = useState([]); + + + + const { data: accounts } = useQuery({ + queryKey: ["accounts"], + queryFn: apiClient.getAccounts, + }); + + const { + data: transactions, + isLoading: transactionsLoading, + error: transactionsError, + refetch: refetchTransactions, + } = useQuery({ + queryKey: ["transactions", selectedAccount, startDate, endDate], + queryFn: () => + apiClient.getTransactions({ + accountId: selectedAccount || undefined, + startDate: startDate || undefined, + endDate: endDate || undefined, + summaryOnly: false, + }), + }); + + const clearFilters = () => { + setSearchTerm(""); + setSelectedAccount(""); + setStartDate(""); + setEndDate(""); + setMinAmount(""); + setMaxAmount(""); + setColumnFilters([]); + }; + + const setQuickDateFilter = (days: number) => { + const endDate = new Date(); + const startDate = new Date(); + startDate.setDate(endDate.getDate() - days); + + setStartDate(startDate.toISOString().split('T')[0]); + setEndDate(endDate.toISOString().split('T')[0]); + }; + + const setThisMonthFilter = () => { + const now = new Date(); + 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]); + }; + + const handleViewRaw = (transaction: Transaction) => { + setSelectedTransaction(transaction); + setShowRawModal(true); + }; + + const handleCloseModal = () => { + setShowRawModal(false); + setSelectedTransaction(null); + }; + + const hasActiveFilters = + searchTerm || selectedAccount || startDate || endDate || minAmount || maxAmount; + + // Define columns + const columns: ColumnDef[] = [ + { + accessorKey: "description", + header: "Description", + cell: ({ row }) => { + const transaction = row.original; + const account = accounts?.find( + (acc) => acc.id === transaction.account_id, + ); + const isPositive = transaction.transaction_value > 0; + + return ( +
+
+ {isPositive ? ( + + ) : ( + + )} +
+
+

+ {transaction.description} +

+
+ {account && ( +

+ {account.name || "Unnamed Account"} •{" "} + {account.institution_id} +

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

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

+ )} + {transaction.reference && ( +

Ref: {transaction.reference}

+ )} +
+
+
+ ); + }, + }, + { + accessorKey: "transaction_value", + header: "Amount", + cell: ({ row }) => { + const transaction = row.original; + const isPositive = transaction.transaction_value > 0; + return ( +
+

+ {isPositive ? "+" : ""} + {formatCurrency( + transaction.transaction_value, + transaction.transaction_currency, + )} +

+
+ ); + }, + sortingFn: "basic", + }, + { + accessorKey: "transaction_date", + header: "Date", + cell: ({ row }) => { + const transaction = row.original; + return ( +
+ {transaction.transaction_date + ? formatDate(transaction.transaction_date) + : "No date"} + {transaction.booking_date && + transaction.booking_date !== + transaction.transaction_date && ( +

+ Booked: {formatDate(transaction.booking_date)} +

+ )} +
+ ); + }, + sortingFn: "datetime", + }, + { + id: "actions", + header: "", + cell: ({ row }) => { + const transaction = row.original; + return ( + + ); + }, + }, + ]; + + const table = useReactTable({ + data: transactions || [], + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + state: { + sorting, + columnFilters, + globalFilter: searchTerm, + }, + onGlobalFilterChange: setSearchTerm, + globalFilterFn: (row, _columnId, filterValue) => { + // Custom global filter that searches multiple fields + const transaction = row.original; + const searchLower = filterValue.toLowerCase(); + + const description = transaction.description || ""; + const creditorName = transaction.creditor_name || ""; + const debtorName = transaction.debtor_name || ""; + const reference = transaction.reference || ""; + + return ( + description.toLowerCase().includes(searchLower) || + creditorName.toLowerCase().includes(searchLower) || + debtorName.toLowerCase().includes(searchLower) || + reference.toLowerCase().includes(searchLower) + ); + }, + }); + + if (transactionsLoading) { + return ( +
+ +
+ ); + } + + if (transactionsError) { + return ( +
+
+
+ +

+ Failed to load transactions +

+

+ Unable to fetch transactions from the Leggen API. +

+ +
+
+
+ ); + } + + return ( +
+ {/* Filters */} +
+
+
+

Transactions

+
+ {hasActiveFilters && ( + + )} + +
+
+
+ + {showFilters && ( +
+ {/* Quick Date Filters */} +
+ +
+ + + +
+
+ +
+ {/* Search */} +
+ +
+ + setSearchTerm(e.target.value)} + 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" + /> +
+
+ + {/* Account Filter */} +
+ + +
+ + {/* Start Date */} +
+ +
+ + setStartDate(e.target.value)} + 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" + /> +
+
+ + {/* End Date */} +
+ +
+ + setEndDate(e.target.value)} + 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" + /> +
+
+
+ + {/* Amount Range Filters */} +
+
+ + setMinAmount(e.target.value)} + placeholder="0.00" + step="0.01" + className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+
+ + setMaxAmount(e.target.value)} + placeholder="1000.00" + step="0.01" + className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+
+
+ )} + + {/* Results Summary */} +
+

+ Showing {table.getFilteredRowModel().rows.length} transaction + {table.getFilteredRowModel().rows.length !== 1 ? "s" : ""} + {selectedAccount && accounts && ( + + for {accounts.find((acc) => acc.id === selectedAccount)?.name} + + )} +

+
+
+ + {/* Table */} +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.length === 0 ? ( + + + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + )) + )} + +
+
+ + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + {header.column.getCanSort() && ( +
+ + +
+ )} +
+
+
+ +
+

+ No transactions found +

+

+ {hasActiveFilters + ? "Try adjusting your filters to see more results." + : "No transactions are available for the selected criteria."} +

+
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} +
+
+ + {/* 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 +

+
+
+
+ + +
+
+ + + Page{" "} + + {table.getState().pagination.pageIndex + 1} + {" "} + of{" "} + + {table.getPageCount()} + + + +
+
+
+
+
+ + {/* Raw Transaction Modal */} + +
+ ); +} diff --git a/frontend/src/routes/transactions.tsx b/frontend/src/routes/transactions.tsx index c5c86b4..506d1db 100644 --- a/frontend/src/routes/transactions.tsx +++ b/frontend/src/routes/transactions.tsx @@ -1,8 +1,8 @@ import { createFileRoute } from "@tanstack/react-router"; -import TransactionsList from "../components/TransactionsList"; +import TransactionsTable from "../components/TransactionsTable"; export const Route = createFileRoute("/transactions")({ - component: TransactionsList, + component: TransactionsTable, validateSearch: (search) => ({ accountId: search.accountId as string | undefined, startDate: search.startDate as string | undefined,