diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 5c5222a..adf6a77 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -15,7 +15,7 @@ import { } from "lucide-react"; import { apiClient } from "../lib/api"; import AccountsOverview from "./AccountsOverview"; -import TransactionsList from "./TransactionsList"; +import TransactionsTable from "./TransactionsTable"; import Notifications from "./Notifications"; import ErrorBoundary from "./ErrorBoundary"; import { cn } from "../lib/utils"; @@ -177,7 +177,7 @@ export default function Dashboard() {
{activeTab === "overview" && } - {activeTab === "transactions" && } + {activeTab === "transactions" && } {activeTab === "analytics" && (

diff --git a/frontend/src/components/FiltersSkeleton.tsx b/frontend/src/components/FiltersSkeleton.tsx new file mode 100644 index 0000000..9ff3622 --- /dev/null +++ b/frontend/src/components/FiltersSkeleton.tsx @@ -0,0 +1,70 @@ +export default function FiltersSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+ +
+ {/* Quick Date Filters Skeleton */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {/* Filter Fields Skeleton */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {/* Amount Range Filters Skeleton */} +
+
+
+
+
+
+
+
+
+
+
+ + {/* Results Summary Skeleton */} +
+
+
+
+ ); +} diff --git a/frontend/src/components/TransactionSkeleton.tsx b/frontend/src/components/TransactionSkeleton.tsx new file mode 100644 index 0000000..d7c083c --- /dev/null +++ b/frontend/src/components/TransactionSkeleton.tsx @@ -0,0 +1,103 @@ +interface TransactionSkeletonProps { + rows?: number; + view?: "table" | "mobile"; +} + +export default function TransactionSkeleton({ + rows = 5, + view = "table" +}: TransactionSkeletonProps) { + const skeletonRows = Array.from({ length: rows }, (_, index) => index); + + if (view === "mobile") { + return ( +
+ {skeletonRows.map((_, index) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); + } + + return ( +
+
+ + + + + + + + + + + {skeletonRows.map((_, index) => ( + + + + + + + ))} + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/TransactionsList.tsx b/frontend/src/components/TransactionsList.tsx deleted file mode 100644 index 89c59fb..0000000 --- a/frontend/src/components/TransactionsList.tsx +++ /dev/null @@ -1,378 +0,0 @@ -import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { - Filter, - Search, - TrendingUp, - TrendingDown, - Calendar, - RefreshCw, - AlertCircle, - X, - Eye, -} 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, ApiResponse } from "../types/api"; - -export default function TransactionsList() { - const [searchTerm, setSearchTerm] = useState(""); - const [selectedAccount, setSelectedAccount] = useState(""); - const [startDate, setStartDate] = useState(""); - const [endDate, setEndDate] = useState(""); - const [showFilters, setShowFilters] = useState(false); - const [showRawModal, setShowRawModal] = useState(false); - const [selectedTransaction, setSelectedTransaction] = - useState(null); - - const { data: accounts } = useQuery({ - queryKey: ["accounts"], - queryFn: apiClient.getAccounts, - }); - - const { - data: transactionsResponse, - 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, // Always fetch raw transaction data - }), - }); - - const transactions = transactionsResponse?.data || []; - - const filteredTransactions = (transactions || []).filter( - (transaction: Transaction) => { - // Additional validation (API client should have already filtered out invalid ones) - if (!transaction || !transaction.account_id) { - console.warn( - "Invalid transaction found after API filtering:", - transaction, - ); - return false; - } - - const description = transaction.description || ""; - const creditorName = transaction.creditor_name || ""; - const debtorName = transaction.debtor_name || ""; - const reference = transaction.reference || ""; - - const matchesSearch = - searchTerm === "" || - description.toLowerCase().includes(searchTerm.toLowerCase()) || - creditorName.toLowerCase().includes(searchTerm.toLowerCase()) || - debtorName.toLowerCase().includes(searchTerm.toLowerCase()) || - reference.toLowerCase().includes(searchTerm.toLowerCase()); - - return matchesSearch; - }, - ); - - const clearFilters = () => { - setSearchTerm(""); - setSelectedAccount(""); - setStartDate(""); - setEndDate(""); - }; - - const handleViewRaw = (transaction: Transaction) => { - setSelectedTransaction(transaction); - setShowRawModal(true); - }; - - const handleCloseModal = () => { - setShowRawModal(false); - setSelectedTransaction(null); - }; - - const hasActiveFilters = - searchTerm || selectedAccount || startDate || endDate; - - if (transactionsLoading) { - return ( -
- -
- ); - } - - if (transactionsError) { - return ( -
-
-
- -

- Failed to load transactions -

-

- Unable to fetch transactions from the Leggen API. -

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

Transactions

-
- {hasActiveFilters && ( - - )} - -
-
-
- - {showFilters && ( -
-
- {/* 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" - /> -
-
-
-
- )} - - {/* Results Summary */} -
-

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

-
-
- - {/* Transactions List */} - {filteredTransactions.length === 0 ? ( -
-
- -
-

- No transactions found -

-

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

-
- ) : ( -
- {filteredTransactions.map((transaction: Transaction) => { - 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.internal_transaction_id && ( -

ID: {transaction.internal_transaction_id}

- )} -
-
-
-
- -
-
- -
-

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

-

- {transaction.transaction_date - ? formatDate(transaction.transaction_date) - : "No date"} -

- {transaction.booking_date && - transaction.booking_date !== - transaction.transaction_date && ( -

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

- )} -
-
-
- ); - })} -
- )} - - {/* Raw Transaction Modal */} - -
- ); -} diff --git a/frontend/src/components/TransactionsTable.tsx b/frontend/src/components/TransactionsTable.tsx index e495555..37c81c5 100644 --- a/frontend/src/components/TransactionsTable.tsx +++ b/frontend/src/components/TransactionsTable.tsx @@ -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(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({ + 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(); + + // 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[] = [ { @@ -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 ( +
+

+ {formatCurrency(balance, transaction.transaction_currency)} +

+
+ ); + }, + }] : []), { accessorKey: "transaction_date", header: "Date", @@ -324,8 +424,12 @@ export default function TransactionsTable() { if (transactionsLoading) { return ( -
- +
+ + +
+ +
); } @@ -372,6 +476,16 @@ export default function TransactionsTable() { Clear filters )} + - - +
+
+ + + +
+
+ + +
@@ -724,6 +854,14 @@ export default function TransactionsTable() { transaction.transaction_currency, )}

+ {showRunningBalance && ( +

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

+ )}

- + {/* Mobile pagination info */}

diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5b3dea3..96b2d54 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -84,6 +84,8 @@ export const apiClient = { perPage?: number; search?: string; summaryOnly?: boolean; + minAmount?: number; + maxAmount?: number; }): Promise> => { const queryParams = new URLSearchParams(); @@ -97,6 +99,12 @@ export const apiClient = { if (params?.summaryOnly !== undefined) { queryParams.append("summary_only", params.summaryOnly.toString()); } + if (params?.minAmount !== undefined) { + queryParams.append("min_amount", params.minAmount.toString()); + } + if (params?.maxAmount !== undefined) { + queryParams.append("max_amount", params.maxAmount.toString()); + } const response = await api.get>( `/transactions?${queryParams.toString()}`,