import { useState, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import { useReactTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, flexRender, } from "@tanstack/react-table"; import type { ColumnDef, SortingState, ColumnFiltersState, } from "@tanstack/react-table"; import { TrendingUp, TrendingDown, RefreshCw, AlertCircle, Eye, ChevronUp, ChevronDown, } from "lucide-react"; import { apiClient } from "../lib/api"; import { formatCurrency, formatDate } from "../lib/utils"; import TransactionSkeleton from "./TransactionSkeleton"; import FiltersSkeleton from "./FiltersSkeleton"; import RawTransactionModal from "./RawTransactionModal"; import { FilterBar, type FilterState } from "./filters"; import type { Account, Transaction, ApiResponse, Balance } from "../types/api"; export default function TransactionsTable() { // Filter state consolidated into a single object const [filterState, setFilterState] = useState({ searchTerm: "", selectedAccount: "", startDate: "", endDate: "", minAmount: "", maxAmount: "", }); const [showRawModal, setShowRawModal] = useState(false); const [selectedTransaction, setSelectedTransaction] = useState(null); const [showRunningBalance, setShowRunningBalance] = useState(true); // Pagination state const [currentPage, setCurrentPage] = useState(1); const [perPage, setPerPage] = useState(50); // Debounced search state const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(filterState.searchTerm); // Table state (remove pagination from table) const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); // Helper function to update filter state const handleFilterChange = (key: keyof FilterState, value: string) => { setFilterState((prev) => ({ ...prev, [key]: value })); }; // Helper function to clear all filters const handleClearFilters = () => { setFilterState({ searchTerm: "", selectedAccount: "", startDate: "", endDate: "", minAmount: "", maxAmount: "", }); setColumnFilters([]); setCurrentPage(1); }; // Debounce search term to prevent excessive API calls useEffect(() => { const timer = setTimeout(() => { setDebouncedSearchTerm(filterState.searchTerm); }, 300); // 300ms delay return () => clearTimeout(timer); }, [filterState.searchTerm]); // Reset pagination when search term changes useEffect(() => { if (debouncedSearchTerm !== filterState.searchTerm) { setCurrentPage(1); } }, [debouncedSearchTerm, filterState.searchTerm]); const { data: accounts } = useQuery({ queryKey: ["accounts"], queryFn: apiClient.getAccounts, }); const { data: balances } = useQuery({ queryKey: ["balances"], queryFn: apiClient.getBalances, enabled: showRunningBalance, }); const { data: transactionsResponse, isLoading: transactionsLoading, error: transactionsError, refetch: refetchTransactions, } = useQuery>({ queryKey: [ "transactions", filterState.selectedAccount, filterState.startDate, filterState.endDate, currentPage, perPage, debouncedSearchTerm, filterState.minAmount, filterState.maxAmount, ], queryFn: () => apiClient.getTransactions({ accountId: filterState.selectedAccount || undefined, startDate: filterState.startDate || undefined, endDate: filterState.endDate || undefined, page: currentPage, perPage: perPage, search: debouncedSearchTerm || undefined, summaryOnly: false, minAmount: filterState.minAmount ? parseFloat(filterState.minAmount) : undefined, maxAmount: filterState.maxAmount ? parseFloat(filterState.maxAmount) : undefined, }), }); const transactions = transactionsResponse?.data || []; const pagination = transactionsResponse?.pagination; // Check if search is currently debouncing const isSearchLoading = filterState.searchTerm !== debouncedSearchTerm; // Reset pagination when total becomes 0 (no results) useEffect(() => { if (pagination && pagination.total === 0 && currentPage > 1) { setCurrentPage(1); } }, [pagination, currentPage]); // Reset pagination when filters change useEffect(() => { setCurrentPage(1); }, [filterState.selectedAccount, filterState.startDate, filterState.endDate, filterState.minAmount, filterState.maxAmount]); const handleViewRaw = (transaction: Transaction) => { setSelectedTransaction(transaction); setShowRawModal(true); }; const handleCloseModal = () => { setShowRawModal(false); setSelectedTransaction(null); }; const hasActiveFilters = filterState.searchTerm || filterState.selectedAccount || filterState.startDate || filterState.endDate || filterState.minAmount || filterState.maxAmount; // Calculate running balances const calculateRunningBalances = (transactions: Transaction[]) => { if (!balances || !showRunningBalance) return {}; const runningBalances: { [key: string]: number } = {}; const accountBalanceMap = new Map(); // Create a map of account current balances balances.forEach(balance => { if (balance.balance_type === 'expected') { accountBalanceMap.set(balance.account_id, balance.balance_amount); } }); // Group transactions by account const transactionsByAccount = new Map(); transactions.forEach(txn => { if (!transactionsByAccount.has(txn.account_id)) { transactionsByAccount.set(txn.account_id, []); } transactionsByAccount.get(txn.account_id)!.push(txn); }); // Calculate running balance for each account transactionsByAccount.forEach((accountTransactions, accountId) => { const currentBalance = accountBalanceMap.get(accountId) || 0; let runningBalance = currentBalance; // Sort transactions by date (newest first) to work backwards const sortedTransactions = [...accountTransactions].sort((a, b) => new Date(b.transaction_date).getTime() - new Date(a.transaction_date).getTime() ); // Calculate running balance by working backwards from current balance sortedTransactions.forEach((txn) => { runningBalances[`${txn.account_id}-${txn.transaction_id}`] = runningBalance; runningBalance -= txn.transaction_value; }); }); return runningBalances; }; const runningBalances = calculateRunningBalances(transactions); // 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", }, ...(showRunningBalance ? [{ id: "running_balance", header: "Running Balance", cell: ({ row }: { row: { original: Transaction } }) => { const transaction = row.original; const balanceKey = `${transaction.account_id}-${transaction.transaction_id}`; const balance = runningBalances[balanceKey]; if (balance === undefined) return null; return (

{formatCurrency(balance, transaction.transaction_currency)}

); }, }] : []), { 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(), getFilteredRowModel: getFilteredRowModel(), onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, state: { sorting, columnFilters, globalFilter: filterState.searchTerm, }, onGlobalFilterChange: (value: string) => handleFilterChange("searchTerm", value), 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 (
{/* New FilterBar */} setShowRunningBalance(!showRunningBalance)} /> {/* Results Summary */}

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..." )} ) {filterState.selectedAccount && accounts && ( for {accounts.find((acc) => acc.id === filterState.selectedAccount)?.name} )}

{/* Responsive Table/Cards */}
{/* Desktop Table View (hidden on mobile) */}
{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(), )}
{/* Mobile Card View (visible only on mobile) */}
{table.getRowModel().rows.length === 0 ? (

No transactions found

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

) : (
{table.getRowModel().rows.map((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}

)}

{transaction.transaction_date ? formatDate(transaction.transaction_date) : "No date"} {transaction.booking_date && transaction.booking_date !== transaction.transaction_date && ( (Booked: {formatDate(transaction.booking_date)}) )}

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

{showRunningBalance && (

Balance: {formatCurrency( runningBalances[`${transaction.account_id}-${transaction.transaction_id}`] || 0, transaction.transaction_currency, )}

)}
); })}
)}
{/* Pagination */} {pagination && (
{/* Mobile pagination controls */}
{/* Mobile pagination info */}

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

{/* Desktop pagination */}

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 */}
); }