mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 16:02:16 +00:00
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
This commit is contained in:
34
frontend/package-lock.json
generated
34
frontend/package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tanstack/react-query": "^5.87.1",
|
"@tanstack/react-query": "^5.87.1",
|
||||||
"@tanstack/react-router": "^1.131.36",
|
"@tanstack/react-router": "^1.131.36",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/router-cli": "^1.131.36",
|
"@tanstack/router-cli": "^1.131.36",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
@@ -1609,6 +1610,26 @@
|
|||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"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": {
|
"node_modules/@tanstack/router-cli": {
|
||||||
"version": "1.131.36",
|
"version": "1.131.36",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/router-cli/-/router-cli-1.131.36.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/router-cli/-/router-cli-1.131.36.tgz",
|
||||||
@@ -1777,6 +1798,19 @@
|
|||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"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": {
|
"node_modules/@tanstack/virtual-file-routes": {
|
||||||
"version": "1.131.2",
|
"version": "1.131.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.131.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.131.2.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tanstack/react-query": "^5.87.1",
|
"@tanstack/react-query": "^5.87.1",
|
||||||
"@tanstack/react-router": "^1.131.36",
|
"@tanstack/react-router": "^1.131.36",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/router-cli": "^1.131.36",
|
"@tanstack/router-cli": "^1.131.36",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
|
|||||||
653
frontend/src/components/TransactionsTable.tsx
Normal file
653
frontend/src/components/TransactionsTable.tsx
Normal file
@@ -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<string>("");
|
||||||
|
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<Transaction | null>(null);
|
||||||
|
|
||||||
|
// Table state
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const { data: accounts } = useQuery<Account[]>({
|
||||||
|
queryKey: ["accounts"],
|
||||||
|
queryFn: apiClient.getAccounts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: transactions,
|
||||||
|
isLoading: transactionsLoading,
|
||||||
|
error: transactionsError,
|
||||||
|
refetch: refetchTransactions,
|
||||||
|
} = useQuery<Transaction[]>({
|
||||||
|
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<Transaction>[] = [
|
||||||
|
{
|
||||||
|
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 (
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div
|
||||||
|
className={`p-2 rounded-full ${
|
||||||
|
isPositive ? "bg-green-100" : "bg-red-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPositive ? (
|
||||||
|
<TrendingUp className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="h-4 w-4 text-red-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{transaction.description}
|
||||||
|
</h4>
|
||||||
|
<div className="text-xs text-gray-500 space-y-1">
|
||||||
|
{account && (
|
||||||
|
<p className="truncate">
|
||||||
|
{account.name || "Unnamed Account"} •{" "}
|
||||||
|
{account.institution_id}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(transaction.creditor_name ||
|
||||||
|
transaction.debtor_name) && (
|
||||||
|
<p className="truncate">
|
||||||
|
{isPositive ? "From: " : "To: "}
|
||||||
|
{transaction.creditor_name ||
|
||||||
|
transaction.debtor_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{transaction.reference && (
|
||||||
|
<p className="truncate">Ref: {transaction.reference}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "transaction_value",
|
||||||
|
header: "Amount",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const transaction = row.original;
|
||||||
|
const isPositive = transaction.transaction_value > 0;
|
||||||
|
return (
|
||||||
|
<div className="text-right">
|
||||||
|
<p
|
||||||
|
className={`text-lg font-semibold ${
|
||||||
|
isPositive ? "text-green-600" : "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPositive ? "+" : ""}
|
||||||
|
{formatCurrency(
|
||||||
|
transaction.transaction_value,
|
||||||
|
transaction.transaction_currency,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
sortingFn: "basic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "transaction_date",
|
||||||
|
header: "Date",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const transaction = row.original;
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-gray-900">
|
||||||
|
{transaction.transaction_date
|
||||||
|
? formatDate(transaction.transaction_date)
|
||||||
|
: "No date"}
|
||||||
|
{transaction.booking_date &&
|
||||||
|
transaction.booking_date !==
|
||||||
|
transaction.transaction_date && (
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Booked: {formatDate(transaction.booking_date)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
sortingFn: "datetime",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const transaction = row.original;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewRaw(transaction)}
|
||||||
|
className="inline-flex items-center px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
|
||||||
|
title="View raw transaction data"
|
||||||
|
>
|
||||||
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
|
Raw
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<LoadingSpinner message="Loading transactions..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transactionsError) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<div className="flex items-center justify-center text-center">
|
||||||
|
<div>
|
||||||
|
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
Failed to load transactions
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
Unable to fetch transactions from the Leggen API.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => refetchTransactions()}
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Transactions</h3>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="inline-flex items-center px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-full hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 mr-1" />
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
className="inline-flex items-center px-3 py-2 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 transition-colors"
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4 mr-2" />
|
||||||
|
Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showFilters && (
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||||
|
{/* Quick Date Filters */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Quick Filters
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setQuickDateFilter(7)}
|
||||||
|
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors"
|
||||||
|
>
|
||||||
|
Last 7 days
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setQuickDateFilter(30)}
|
||||||
|
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors"
|
||||||
|
>
|
||||||
|
Last 30 days
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={setThisMonthFilter}
|
||||||
|
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors"
|
||||||
|
>
|
||||||
|
This month
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Search
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account Filter */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Account
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedAccount}
|
||||||
|
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="">All accounts</option>
|
||||||
|
{accounts?.map((account) => (
|
||||||
|
<option key={account.id} value={account.id}>
|
||||||
|
{account.name || "Unnamed Account"} (
|
||||||
|
{account.institution_id})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Start Date */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Start Date
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* End Date */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
End Date
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amount Range Filters */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Min Amount
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={minAmount}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Max Amount
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={maxAmount}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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" : ""}
|
||||||
|
{selectedAccount && accounts && (
|
||||||
|
<span className="ml-1">
|
||||||
|
for {accounts.find((acc) => acc.id === selectedAccount)?.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th
|
||||||
|
key={header.id}
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||||
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<span>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{header.column.getCanSort() && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<ChevronUp
|
||||||
|
className={`h-3 w-3 ${
|
||||||
|
header.column.getIsSorted() === "asc"
|
||||||
|
? "text-blue-600"
|
||||||
|
: "text-gray-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-3 w-3 -mt-1 ${
|
||||||
|
header.column.getIsSorted() === "desc"
|
||||||
|
? "text-blue-600"
|
||||||
|
: "text-gray-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{table.getRowModel().rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="px-6 py-12 text-center"
|
||||||
|
>
|
||||||
|
<div className="text-gray-400 mb-4">
|
||||||
|
<TrendingUp className="h-12 w-12 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
No transactions found
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{hasActiveFilters
|
||||||
|
? "Try adjusting your filters to see more results."
|
||||||
|
: "No transactions are available for the selected criteria."}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<tr key={row.id} className="hover:bg-gray-50">
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td key={cell.id} className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Raw Transaction Modal */}
|
||||||
|
<RawTransactionModal
|
||||||
|
isOpen={showRawModal}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
rawTransaction={selectedTransaction?.raw_transaction}
|
||||||
|
transactionId={selectedTransaction?.transaction_id || "unknown"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import TransactionsList from "../components/TransactionsList";
|
import TransactionsTable from "../components/TransactionsTable";
|
||||||
|
|
||||||
export const Route = createFileRoute("/transactions")({
|
export const Route = createFileRoute("/transactions")({
|
||||||
component: TransactionsList,
|
component: TransactionsTable,
|
||||||
validateSearch: (search) => ({
|
validateSearch: (search) => ({
|
||||||
accountId: search.accountId as string | undefined,
|
accountId: search.accountId as string | undefined,
|
||||||
startDate: search.startDate as string | undefined,
|
startDate: search.startDate as string | undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user