diff --git a/backend/controllers/transactionController.js b/backend/controllers/transactionController.js index 1e49568..cad2499 100644 --- a/backend/controllers/transactionController.js +++ b/backend/controllers/transactionController.js @@ -28,10 +28,13 @@ const addTransaction = async (req, res) => { // @access Private const getTransactions = async (req, res) => { try { - const { isIncome, category, startDate, endDate, page = 1, limit = 10 } = req.query; + const { search, isIncome, category, startDate, endDate, page = 1, limit = 10 } = req.query; const filter = { user: req.user.id, isDeleted: false }; - + + if (search) { + filter.name = { $regex: search, $options: 'i' }; + } if (isIncome) filter.isIncome = isIncome; if (category) filter.category = category; if (startDate || endDate) { diff --git a/frontend/src/pages/TransactionsPage.jsx b/frontend/src/pages/TransactionsPage.jsx index 5d703c6..37a1eb9 100644 --- a/frontend/src/pages/TransactionsPage.jsx +++ b/frontend/src/pages/TransactionsPage.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import api from '../api/axios'; import TransactionModal from '../components/TransactionModal'; import ManageCategoriesModal from '../components/ManageCategoriesModal'; @@ -29,36 +29,108 @@ const handleExportCSV = async () => { const TransactionsPage = () => { const [transactions, setTransactions] = useState([]); const [loading, setLoading] = useState(true); + const [isFiltering, setIsFiltering] = useState(false); const [page, setPage] = useState(1); const [totalPages, setTotalPages] = useState(1); const [editingTransaction, setEditingTransaction] = useState(null); const [categories, setCategories] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const [typeFilter, setTypeFilter] = useState('all'); + const [categoryFilter, setCategoryFilter] = useState('all'); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + const debounceTimer = useRef(null); // Changed to useRef const [isTransactionModalOpen, setIsTransactionModalOpen] = useState(false); const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false); const { currency } = useCurrency(); + const isInitialMount = useRef(true); + + const fetchData = useCallback(async (search = searchTerm) => { + if (isInitialMount.current) { + setLoading(true); + } else { + setIsFiltering(true); + } - const fetchData = useCallback(async () => { - setLoading(true); try { - const [transactionsRes, categoriesRes] = await Promise.all([ - api.get(`/transactions?page=${page}&limit=10`), - api.get('/transactions/categories') - ]); + const params = new URLSearchParams({ + page: page.toString(), + limit: '10' + }); + + if (search) { + params.append('search', search); + } + if (typeFilter !== 'all') { + params.append('isIncome', typeFilter === 'income' ? 'true' : 'false'); + } + if (categoryFilter !== 'all') { + params.append('category', categoryFilter); + } + if (dateFrom) { + params.append('startDate', dateFrom); + } + if (dateTo) { + params.append('endDate', dateTo); + } + + const transactionsRes = await api.get(`/transactions?${params.toString()}`); setTransactions(transactionsRes.data.transactions); setTotalPages(transactionsRes.data.totalPages); - setCategories(categoriesRes.data); + } catch (error) { console.error("Failed to fetch transactions data", error); } finally { setLoading(false); + setIsFiltering(false); + isInitialMount.current = false; } - }, [page]); + }, [page, searchTerm, typeFilter, categoryFilter, dateFrom, dateTo]); + + // Fetch categories only on component mount + useEffect(() => { + const fetchCategories = async () => { + try { + const categoriesRes = await api.get('/transactions/categories'); + setCategories(categoriesRes.data); + } catch (error) { + console.error("Failed to fetch categories", error); + } + }; + fetchCategories(); + }, []); + // Fetch transactions when fetchData changes useEffect(() => { fetchData(); }, [fetchData]); + const handleSearchChange = (e) => { + const value = e.target.value; + setSearchTerm(value); + + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + + debounceTimer.current = setTimeout(() => { + setPage(1); + fetchData(value); + }, 300); + }; + + const clearAllFilters = () => { + setSearchTerm(''); + setTypeFilter('all'); + setCategoryFilter('all'); + setDateFrom(''); + setDateTo(''); + setPage(1); + }; + + const hasActiveFilters = searchTerm || typeFilter !== 'all' || categoryFilter !== 'all' || dateFrom || dateTo; + const handleOpenTransactionModal = (transaction = null) => { setEditingTransaction(transaction); setIsTransactionModalOpen(true); @@ -127,48 +199,174 @@ const TransactionsPage = () => { + {/* Search and Filters */} +
+
+ {/* Search Bar */} +
+ +
+ + {/* Type Filter */} +
+ +
+ + {/* Category Filter */} +
+ +
+ + {/* Start Date */} +
+
+ From: +
+ { + setDateFrom(e.target.value); + setPage(1); + }} + className="w-full pl-14 pr-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+ + {/* End Date */} +
+
+ To: +
+ { + setDateTo(e.target.value); + setPage(1); + }} + className="w-full pl-10 pr-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+
+ + {/* Active Filters and Clear Button */} + {hasActiveFilters && ( +
+
+ Active: + {searchTerm && ( + + "{searchTerm}" + + )} + {typeFilter !== 'all' && ( + + {typeFilter === 'income' ? 'Income' : 'Expense'} + + )} + {categoryFilter !== 'all' && ( + + {categoryFilter} + + )} + {dateFrom && ( + + From: {new Date(dateFrom).toLocaleDateString()} + + )} + {dateTo && ( + + To: {new Date(dateTo).toLocaleDateString()} + + )} +
+ +
+ )} +
+ {loading ? ( ) : ( -
- {transactions.length > 0 ? ( - - - - - - - - - - - - {transactions.map((tx) => ( - - - - - - - - ))} - -
NameCategoryAmountDateActions
{tx.name}{tx.category} - {tx.isIncome ? '+' : '-'}{new Intl.NumberFormat('en-US', { - style: 'currency', - currency: currency.code, - }).format(tx.cost)} - {new Date(tx.addedOn).toLocaleDateString()} - - -
- ) : ( -
- -
- )} -
- +
+ {transactions.length > 0 ? ( + + + + + + + + + + + + {transactions.map((tx) => ( + + + + + + + + ))} + +
NameCategoryAmountDateActions
{tx.name}{tx.category} + {tx.isIncome ? '+' : '-'}{new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency.code, + }).format(tx.cost)} + {new Date(tx.addedOn).toLocaleDateString()} + + +
+ ) : ( +
+ +
+ )} +
)}