mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-14 06:12:19 +00:00
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:
@@ -15,7 +15,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiClient } from "../lib/api";
|
import { apiClient } from "../lib/api";
|
||||||
import AccountsOverview from "./AccountsOverview";
|
import AccountsOverview from "./AccountsOverview";
|
||||||
import TransactionsList from "./TransactionsList";
|
import TransactionsTable from "./TransactionsTable";
|
||||||
import Notifications from "./Notifications";
|
import Notifications from "./Notifications";
|
||||||
import ErrorBoundary from "./ErrorBoundary";
|
import ErrorBoundary from "./ErrorBoundary";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
@@ -177,7 +177,7 @@ export default function Dashboard() {
|
|||||||
<main className="flex-1 overflow-y-auto p-6">
|
<main className="flex-1 overflow-y-auto p-6">
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
{activeTab === "overview" && <AccountsOverview />}
|
{activeTab === "overview" && <AccountsOverview />}
|
||||||
{activeTab === "transactions" && <TransactionsList />}
|
{activeTab === "transactions" && <TransactionsTable />}
|
||||||
{activeTab === "analytics" && (
|
{activeTab === "analytics" && (
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||||
|
|||||||
70
frontend/src/components/FiltersSkeleton.tsx
Normal file
70
frontend/src/components/FiltersSkeleton.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
export default function FiltersSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow animate-pulse">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-32"></div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-24"></div>
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-20"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||||
|
{/* Quick Date Filters Skeleton */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-32 mb-3"></div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<div className="h-10 bg-gray-200 rounded-lg w-24"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded-lg w-20"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded-lg w-28"></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<div className="h-10 bg-gray-200 rounded-lg w-24"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded-lg w-20"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Fields Skeleton */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="sm:col-span-2 lg:col-span-1">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amount Range Filters Skeleton */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results Summary Skeleton */}
|
||||||
|
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-48"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
frontend/src/components/TransactionSkeleton.tsx
Normal file
103
frontend/src/components/TransactionSkeleton.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="bg-white rounded-lg shadow divide-y divide-gray-200">
|
||||||
|
{skeletonRows.map((_, index) => (
|
||||||
|
<div key={index} className="p-4 animate-pulse">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="p-2 rounded-full bg-gray-200 flex-shrink-0">
|
||||||
|
<div className="h-4 w-4 bg-gray-300 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-2/3"></div>
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-1/3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right ml-3 flex-shrink-0 space-y-2">
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-20"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-16 ml-auto"></div>
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-12 ml-auto"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-20 animate-pulse"></div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-16 animate-pulse"></div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-12 animate-pulse"></div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-8 animate-pulse"></div>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{skeletonRows.map((_, index) => (
|
||||||
|
<tr key={index} className="animate-pulse">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="p-2 rounded-full bg-gray-200 flex-shrink-0">
|
||||||
|
<div className="h-4 w-4 bg-gray-300 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-2/3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-24 ml-auto mb-1"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-20"></div>
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-16"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-12"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string>("");
|
|
||||||
const [startDate, setStartDate] = useState("");
|
|
||||||
const [endDate, setEndDate] = useState("");
|
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
|
||||||
const [showRawModal, setShowRawModal] = useState(false);
|
|
||||||
const [selectedTransaction, setSelectedTransaction] =
|
|
||||||
useState<Transaction | null>(null);
|
|
||||||
|
|
||||||
const { data: accounts } = useQuery<Account[]>({
|
|
||||||
queryKey: ["accounts"],
|
|
||||||
queryFn: apiClient.getAccounts,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: transactionsResponse,
|
|
||||||
isLoading: transactionsLoading,
|
|
||||||
error: transactionsError,
|
|
||||||
refetch: refetchTransactions,
|
|
||||||
} = useQuery<ApiResponse<Transaction[]>>({
|
|
||||||
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 (
|
|
||||||
<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">
|
|
||||||
<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>
|
|
||||||
</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 {filteredTransactions.length} transaction
|
|
||||||
{filteredTransactions.length !== 1 ? "s" : ""}
|
|
||||||
{selectedAccount && accounts && (
|
|
||||||
<span className="ml-1">
|
|
||||||
for {accounts.find((acc) => acc.id === selectedAccount)?.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Transactions List */}
|
|
||||||
{filteredTransactions.length === 0 ? (
|
|
||||||
<div className="bg-white rounded-lg shadow p-6 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>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-white rounded-lg shadow divide-y divide-gray-200">
|
|
||||||
{filteredTransactions.map((transaction: Transaction) => {
|
|
||||||
const account = accounts?.find(
|
|
||||||
(acc) => acc.id === transaction.account_id,
|
|
||||||
);
|
|
||||||
const isPositive = transaction.transaction_value > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`${transaction.account_id}-${transaction.transaction_id}`}
|
|
||||||
className="p-6 hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<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">
|
|
||||||
<h4 className="text-sm font-medium text-gray-900 mb-1">
|
|
||||||
{transaction.description}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<div className="text-xs text-gray-500 space-y-1">
|
|
||||||
{account && (
|
|
||||||
<p>
|
|
||||||
{account.name || "Unnamed Account"} •{" "}
|
|
||||||
{account.institution_id}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(transaction.creditor_name ||
|
|
||||||
transaction.debtor_name) && (
|
|
||||||
<p>
|
|
||||||
{isPositive ? "From: " : "To: "}
|
|
||||||
{transaction.creditor_name ||
|
|
||||||
transaction.debtor_name}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{transaction.reference && (
|
|
||||||
<p>Ref: {transaction.reference}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{transaction.internal_transaction_id && (
|
|
||||||
<p>ID: {transaction.internal_transaction_id}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-right ml-4">
|
|
||||||
<div className="flex items-center justify-end space-x-2 mb-2">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
className={`text-lg font-semibold ${
|
|
||||||
isPositive ? "text-green-600" : "text-red-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isPositive ? "+" : ""}
|
|
||||||
{formatCurrency(
|
|
||||||
transaction.transaction_value,
|
|
||||||
transaction.transaction_currency,
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
{transaction.transaction_date
|
|
||||||
? formatDate(transaction.transaction_date)
|
|
||||||
: "No date"}
|
|
||||||
</p>
|
|
||||||
{transaction.booking_date &&
|
|
||||||
transaction.booking_date !==
|
|
||||||
transaction.transaction_date && (
|
|
||||||
<p className="text-xs text-gray-400">
|
|
||||||
Booked: {formatDate(transaction.booking_date)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Raw Transaction Modal */}
|
|
||||||
<RawTransactionModal
|
|
||||||
isOpen={showRawModal}
|
|
||||||
onClose={handleCloseModal}
|
|
||||||
rawTransaction={selectedTransaction?.raw_transaction}
|
|
||||||
transactionId={selectedTransaction?.transaction_id || "unknown"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -27,9 +27,10 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiClient } from "../lib/api";
|
import { apiClient } from "../lib/api";
|
||||||
import { formatCurrency, formatDate } from "../lib/utils";
|
import { formatCurrency, formatDate } from "../lib/utils";
|
||||||
import LoadingSpinner from "./LoadingSpinner";
|
import TransactionSkeleton from "./TransactionSkeleton";
|
||||||
|
import FiltersSkeleton from "./FiltersSkeleton";
|
||||||
import RawTransactionModal from "./RawTransactionModal";
|
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() {
|
export default function TransactionsTable() {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
@@ -42,6 +43,7 @@ export default function TransactionsTable() {
|
|||||||
const [showRawModal, setShowRawModal] = useState(false);
|
const [showRawModal, setShowRawModal] = useState(false);
|
||||||
const [selectedTransaction, setSelectedTransaction] =
|
const [selectedTransaction, setSelectedTransaction] =
|
||||||
useState<Transaction | null>(null);
|
useState<Transaction | null>(null);
|
||||||
|
const [showRunningBalance, setShowRunningBalance] = useState(true);
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
@@ -75,6 +77,12 @@ export default function TransactionsTable() {
|
|||||||
queryFn: apiClient.getAccounts,
|
queryFn: apiClient.getAccounts,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: balances } = useQuery<Balance[]>({
|
||||||
|
queryKey: ["balances"],
|
||||||
|
queryFn: apiClient.getBalances,
|
||||||
|
enabled: showRunningBalance,
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: transactionsResponse,
|
data: transactionsResponse,
|
||||||
isLoading: transactionsLoading,
|
isLoading: transactionsLoading,
|
||||||
@@ -89,6 +97,8 @@ export default function TransactionsTable() {
|
|||||||
currentPage,
|
currentPage,
|
||||||
perPage,
|
perPage,
|
||||||
debouncedSearchTerm,
|
debouncedSearchTerm,
|
||||||
|
minAmount,
|
||||||
|
maxAmount,
|
||||||
],
|
],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiClient.getTransactions({
|
apiClient.getTransactions({
|
||||||
@@ -99,6 +109,8 @@ export default function TransactionsTable() {
|
|||||||
perPage: perPage,
|
perPage: perPage,
|
||||||
search: debouncedSearchTerm || undefined,
|
search: debouncedSearchTerm || undefined,
|
||||||
summaryOnly: false,
|
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
|
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 setThisMonthFilter = () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
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
|
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
|
// Reset pagination when account filter changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
@@ -174,6 +210,51 @@ export default function TransactionsTable() {
|
|||||||
minAmount ||
|
minAmount ||
|
||||||
maxAmount;
|
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
|
// Define columns
|
||||||
const columns: ColumnDef<Transaction>[] = [
|
const columns: ColumnDef<Transaction>[] = [
|
||||||
{
|
{
|
||||||
@@ -249,6 +330,25 @@ export default function TransactionsTable() {
|
|||||||
},
|
},
|
||||||
sortingFn: "basic",
|
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",
|
accessorKey: "transaction_date",
|
||||||
header: "Date",
|
header: "Date",
|
||||||
@@ -324,8 +424,12 @@ export default function TransactionsTable() {
|
|||||||
|
|
||||||
if (transactionsLoading) {
|
if (transactionsLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow">
|
<div className="space-y-6">
|
||||||
<LoadingSpinner message="Loading transactions..." />
|
<FiltersSkeleton />
|
||||||
|
<TransactionSkeleton rows={10} view="table" />
|
||||||
|
<div className="md:hidden">
|
||||||
|
<TransactionSkeleton rows={10} view="mobile" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -372,6 +476,16 @@ export default function TransactionsTable() {
|
|||||||
Clear filters
|
Clear filters
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => setShowFilters(!showFilters)}
|
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"
|
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 && (
|
{showFilters && (
|
||||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||||
{/* Quick Date Filters */}
|
{/* Quick Date Filters */}
|
||||||
<div className="mb-4">
|
<div className="mb-6">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||||
Quick Filters
|
Quick Date Filters
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="space-y-2">
|
||||||
<button
|
<div className="flex flex-wrap gap-2">
|
||||||
onClick={() => setQuickDateFilter(7)}
|
<button
|
||||||
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors"
|
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>
|
Last 7 days
|
||||||
<button
|
</button>
|
||||||
onClick={() => setQuickDateFilter(30)}
|
<button
|
||||||
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors"
|
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"
|
||||||
Last 30 days
|
>
|
||||||
</button>
|
This week
|
||||||
<button
|
</button>
|
||||||
onClick={setThisMonthFilter}
|
<button
|
||||||
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors"
|
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"
|
||||||
This month
|
>
|
||||||
</button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -724,6 +854,14 @@ export default function TransactionsTable() {
|
|||||||
transaction.transaction_currency,
|
transaction.transaction_currency,
|
||||||
)}
|
)}
|
||||||
</p>
|
</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
|
<button
|
||||||
onClick={() => handleViewRaw(transaction)}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile pagination info */}
|
{/* Mobile pagination info */}
|
||||||
<div className="text-center w-full sm:hidden">
|
<div className="text-center w-full sm:hidden">
|
||||||
<p className="text-sm text-gray-700">
|
<p className="text-sm text-gray-700">
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ export const apiClient = {
|
|||||||
perPage?: number;
|
perPage?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
summaryOnly?: boolean;
|
summaryOnly?: boolean;
|
||||||
|
minAmount?: number;
|
||||||
|
maxAmount?: number;
|
||||||
}): Promise<ApiResponse<Transaction[]>> => {
|
}): Promise<ApiResponse<Transaction[]>> => {
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
@@ -97,6 +99,12 @@ export const apiClient = {
|
|||||||
if (params?.summaryOnly !== undefined) {
|
if (params?.summaryOnly !== undefined) {
|
||||||
queryParams.append("summary_only", params.summaryOnly.toString());
|
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<ApiResponse<Transaction[]>>(
|
const response = await api.get<ApiResponse<Transaction[]>>(
|
||||||
`/transactions?${queryParams.toString()}`,
|
`/transactions?${queryParams.toString()}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user