feat(frontend): Enhance transactions page with advanced filtering and UI improvements.

- Fix amount range filters (min/max) connection to API parameters
- Add enhanced quick date filters: "This week", "This year" alongside existing options
- Create skeleton loading components (TransactionSkeleton, FiltersSkeleton) to replace simple spinner
- Add running balance column with toggle functionality for both desktop and mobile views
- Consolidate TransactionsList.tsx and TransactionsTable.tsx into single comprehensive component
- Improve UI design with better visual hierarchy, spacing, and responsive layout
- Add proper TypeScript types and fix linting issues

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Elisiário Couto
2025-09-14 23:57:23 +01:00
parent 077e2bb1ad
commit 969776fb53
6 changed files with 348 additions and 407 deletions

View File

@@ -27,9 +27,10 @@ import {
} from "lucide-react";
import { apiClient } from "../lib/api";
import { formatCurrency, formatDate } from "../lib/utils";
import LoadingSpinner from "./LoadingSpinner";
import TransactionSkeleton from "./TransactionSkeleton";
import FiltersSkeleton from "./FiltersSkeleton";
import RawTransactionModal from "./RawTransactionModal";
import type { Account, Transaction, ApiResponse } from "../types/api";
import type { Account, Transaction, ApiResponse, Balance } from "../types/api";
export default function TransactionsTable() {
const [searchTerm, setSearchTerm] = useState("");
@@ -42,6 +43,7 @@ export default function TransactionsTable() {
const [showRawModal, setShowRawModal] = useState(false);
const [selectedTransaction, setSelectedTransaction] =
useState<Transaction | null>(null);
const [showRunningBalance, setShowRunningBalance] = useState(true);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
@@ -75,6 +77,12 @@ export default function TransactionsTable() {
queryFn: apiClient.getAccounts,
});
const { data: balances } = useQuery<Balance[]>({
queryKey: ["balances"],
queryFn: apiClient.getBalances,
enabled: showRunningBalance,
});
const {
data: transactionsResponse,
isLoading: transactionsLoading,
@@ -89,6 +97,8 @@ export default function TransactionsTable() {
currentPage,
perPage,
debouncedSearchTerm,
minAmount,
maxAmount,
],
queryFn: () =>
apiClient.getTransactions({
@@ -99,6 +109,8 @@ export default function TransactionsTable() {
perPage: perPage,
search: debouncedSearchTerm || undefined,
summaryOnly: false,
minAmount: minAmount ? parseFloat(minAmount) : undefined,
maxAmount: maxAmount ? parseFloat(maxAmount) : undefined,
}),
});
@@ -136,6 +148,20 @@ export default function TransactionsTable() {
setCurrentPage(1); // Reset to first page when changing date filters
};
const setThisWeekFilter = () => {
const now = new Date();
const dayOfWeek = now.getDay();
const startOfWeek = new Date(now);
startOfWeek.setDate(now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1)); // Monday as start
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(startOfWeek.getDate() + 6);
setStartDate(startOfWeek.toISOString().split("T")[0]);
setEndDate(endOfWeek.toISOString().split("T")[0]);
setCurrentPage(1);
};
const setThisMonthFilter = () => {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
@@ -146,6 +172,16 @@ export default function TransactionsTable() {
setCurrentPage(1); // Reset to first page when changing date filters
};
const setThisYearFilter = () => {
const now = new Date();
const startOfYear = new Date(now.getFullYear(), 0, 1);
const endOfYear = new Date(now.getFullYear(), 11, 31);
setStartDate(startOfYear.toISOString().split("T")[0]);
setEndDate(endOfYear.toISOString().split("T")[0]);
setCurrentPage(1);
};
// Reset pagination when account filter changes
useEffect(() => {
setCurrentPage(1);
@@ -174,6 +210,51 @@ export default function TransactionsTable() {
minAmount ||
maxAmount;
// Calculate running balances
const calculateRunningBalances = (transactions: Transaction[]) => {
if (!balances || !showRunningBalance) return {};
const runningBalances: { [key: string]: number } = {};
const accountBalanceMap = new Map<string, number>();
// 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<string, Transaction[]>();
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<Transaction>[] = [
{
@@ -249,6 +330,25 @@ export default function TransactionsTable() {
},
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 (
<div className="text-right">
<p className="text-sm font-medium text-gray-900">
{formatCurrency(balance, transaction.transaction_currency)}
</p>
</div>
);
},
}] : []),
{
accessorKey: "transaction_date",
header: "Date",
@@ -324,8 +424,12 @@ export default function TransactionsTable() {
if (transactionsLoading) {
return (
<div className="bg-white rounded-lg shadow">
<LoadingSpinner message="Loading transactions..." />
<div className="space-y-6">
<FiltersSkeleton />
<TransactionSkeleton rows={10} view="table" />
<div className="md:hidden">
<TransactionSkeleton rows={10} view="mobile" />
</div>
</div>
);
}
@@ -372,6 +476,16 @@ export default function TransactionsTable() {
Clear filters
</button>
)}
<button
onClick={() => setShowRunningBalance(!showRunningBalance)}
className={`inline-flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
showRunningBalance
? "bg-green-100 text-green-700 hover:bg-green-200"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
Balance
</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"
@@ -386,29 +500,45 @@ export default function TransactionsTable() {
{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
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-3">
Quick Date 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 className="space-y-2">
<div className="flex flex-wrap gap-2">
<button
onClick={() => setQuickDateFilter(7)}
className="px-4 py-2 text-sm bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 transition-colors border border-blue-200"
>
Last 7 days
</button>
<button
onClick={setThisWeekFilter}
className="px-4 py-2 text-sm bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 transition-colors border border-blue-200"
>
This week
</button>
<button
onClick={() => setQuickDateFilter(30)}
className="px-4 py-2 text-sm bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 transition-colors border border-blue-200"
>
Last 30 days
</button>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={setThisMonthFilter}
className="px-4 py-2 text-sm bg-green-50 text-green-700 rounded-lg hover:bg-green-100 transition-colors border border-green-200"
>
This month
</button>
<button
onClick={setThisYearFilter}
className="px-4 py-2 text-sm bg-green-50 text-green-700 rounded-lg hover:bg-green-100 transition-colors border border-green-200"
>
This year
</button>
</div>
</div>
</div>
@@ -724,6 +854,14 @@ export default function TransactionsTable() {
transaction.transaction_currency,
)}
</p>
{showRunningBalance && (
<p className="text-xs text-gray-500 mb-1">
Balance: {formatCurrency(
runningBalances[`${transaction.account_id}-${transaction.transaction_id}`] || 0,
transaction.transaction_currency,
)}
</p>
)}
<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"
@@ -781,7 +919,7 @@ export default function TransactionsTable() {
</button>
</div>
</div>
{/* Mobile pagination info */}
<div className="text-center w-full sm:hidden">
<p className="text-sm text-gray-700">