import React, { useState, useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
import { Doughnut } from 'react-chartjs-2';
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js';
import numberToWords from '../utils/numberToWords';
ChartJS.register(ArcElement, Tooltip, Legend);
// Professional, tranquil, and reassuring SVG icons for each category
const CategoryIcons = {
essentials: (
),
savings: (
),
selfInvestment: (
),
charity: (
),
emergency: (
),
};
const categories = [
{ value: 'essentials', label: 'Tiêu dùng thiết yếu (50%)', color: '#10B981', icon: CategoryIcons.essentials },
{ value: 'savings', label: 'Tiết kiệm bắt buộc (20%)', color: '#3B82F6', icon: CategoryIcons.savings },
{ value: 'selfInvestment', label: 'Đầu tư bản thân (15%)', color: '#F59E0B', icon: CategoryIcons.selfInvestment },
{ value: 'charity', label: 'Từ thiện (5%)', color: '#8B5CF6', icon: CategoryIcons.charity },
{ value: 'emergency', label: 'Dự phòng linh hoạt (10%)', color: '#F97316', icon: CategoryIcons.emergency },
];
function Home() {
// State
const [allocations, setAllocations] = useState({
essentials: 0,
savings: 0,
selfInvestment: 0,
charity: 0,
emergency: 0,
});
const [transactionHistory, setTransactionHistory] = useState([]);
const [isDarkMode, setIsDarkMode] = useState(() => {
// Auto-detect system preference
if (typeof window !== 'undefined') {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return false;
});
const [currentPage, setCurrentPage] = useState(1);
const [minAmount, setMinAmount] = useState('');
const [maxAmount, setMaxAmount] = useState('');
const [searchDetails, setSearchDetails] = useState('');
const [searchLocation, setSearchLocation] = useState('');
const [selectedCategory, setSelectedCategory] = useState('');
const [selectedMonthYear, setSelectedMonthYear] = useState('');
const [isSearchMenuOpen, setIsSearchMenuOpen] = useState(false);
const [expandedMonths, setExpandedMonths] = useState({});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const [successMessage, setSuccessMessage] = useState('');
const [showScrollTop, setShowScrollTop] = useState(false);
const transactionsPerPage = 6;
const navigate = useNavigate();
const token = localStorage.getItem('token');
const mainRef = useRef(null);
// Responsive: scroll to top on page change
useEffect(() => {
if (mainRef.current) {
mainRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [currentPage]);
// Scroll-to-top button
useEffect(() => {
const handleScroll = () => {
setShowScrollTop(window.scrollY > 200);
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// Fetch data
useEffect(() => {
const fetchData = async () => {
if (!token) {
navigate('/login');
return;
}
setIsLoading(true);
try {
const [allocRes, expenseRes] = await Promise.all([
axios.get('https://backend-rockefeller-finance.onrender.com/api/allocations', {
headers: { Authorization: `Bearer ${token}` },
}),
axios.get('https://backend-rockefeller-finance.onrender.com/api/expenses', {
headers: { Authorization: `Bearer ${token}` },
}),
]);
setAllocations(allocRes.data);
setTransactionHistory(
expenseRes.data.map((tx) => ({
...tx,
category: {
'Tiêu dùng thiết yếu': 'essentials',
'Tiết kiệm bắt buộc': 'savings',
'Đầu tư bản thân': 'selfInvestment',
'Từ thiện': 'charity',
'Dự phòng linh hoạt': 'emergency',
}[tx.category],
details: tx.purpose,
timestamp: tx.date,
}))
);
setError('');
} catch (err) {
if (err.response?.status === 401) {
localStorage.removeItem('token');
navigate('/login');
setError('Phiên đăng nhập hết hạn. Vui lòng đăng nhập lại.');
} else {
setError(err.response?.data?.error || 'Lỗi tải dữ liệu');
}
} finally {
setIsLoading(false);
}
};
fetchData();
// eslint-disable-next-line
}, [token, navigate]);
// Theme toggle
useEffect(() => {
if (isDarkMode) {
document.documentElement.classList.add('dark');
document.body.style.background = '#111827';
} else {
document.documentElement.classList.remove('dark');
document.body.style.background = '#F3F4F6';
}
}, [isDarkMode]);
const toggleDarkMode = () => setIsDarkMode((prev) => !prev);
// Format currency
const formatVND = (value) => {
const num = parseFloat(value) || 0;
return new Intl.NumberFormat('vi-VN', { style: 'currency', currency: 'VND' }).format(num);
};
// Total
const totalAmount = useMemo(() =>
categories.reduce((sum, cat) => sum + parseFloat(allocations[cat.value] || 0), 0)
, [allocations]);
// Chart data
const recentTransactionsForChart = transactionHistory.slice(0, 5);
const categoryTotals = categories.reduce((acc, cat) => {
acc[cat.value] = recentTransactionsForChart
.filter((tx) => tx?.category === cat.value)
.reduce((sum, tx) => sum + (parseFloat(tx?.amount) || 0), 0);
return acc;
}, { essentials: 0, savings: 0, selfInvestment: 0, charity: 0, emergency: 0 });
const chartData = {
labels: categories.map((cat) => cat.label),
datasets: [
{
data: categories.map((cat) => categoryTotals[cat.value]),
backgroundColor: categories.map((cat) => cat.color),
hoverBackgroundColor: categories.map((cat) => cat.color + 'CC'),
borderWidth: 2,
borderColor: isDarkMode ? '#1F2937' : '#FFFFFF',
},
],
};
const chartOptions = {
plugins: {
legend: {
position: 'bottom',
labels: {
color: isDarkMode ? '#F3F4F6' : '#1F2937',
font: { size: 15, family: 'Inter, sans-serif', weight: 'bold' },
padding: 18,
},
},
tooltip: {
callbacks: {
label: function (context) {
return `${context.label}: ${formatVND(context.raw)}`;
},
},
backgroundColor: isDarkMode ? '#22223b' : '#fff',
titleColor: isDarkMode ? '#fff' : '#22223b',
bodyColor: isDarkMode ? '#fff' : '#22223b',
borderColor: isDarkMode ? '#fff' : '#22223b',
borderWidth: 1,
},
},
maintainAspectRatio: false,
responsive: true,
cutout: '70%',
};
// Month filter
const availableMonths = useMemo(() => {
const months = new Set();
transactionHistory.forEach((transaction) => {
if (!transaction?.timestamp) return;
const date = new Date(transaction.timestamp.split('/').reverse().join('-'));
const monthYear = date.toLocaleString('vi-VN', { month: 'long', year: 'numeric' });
months.add(monthYear);
});
return [...months].sort((a, b) => {
const [monthA, yearA] = a.split(' ');
const [monthB, yearB] = b.split(' ');
const dateA = new Date(yearA, new Date(Date.parse(monthA + " 1, 2000")).getMonth());
const dateB = new Date(yearB, new Date(Date.parse(monthB + " 1, 2000")).getMonth());
return dateB - dateA;
});
}, [transactionHistory]);
// Filtered transactions
const filteredTransactions = useMemo(() => {
return transactionHistory.filter((tx) => {
if (!tx) return false;
const amount = parseFloat(tx.amount || 0);
const min = parseFloat(minAmount) || 0;
const max = parseFloat(maxAmount) || Infinity;
const amountMatch = amount >= min && amount <= max;
const detailsMatch = searchDetails ? (tx.details || '').toLowerCase().includes(searchDetails.toLowerCase()) : true;
const locationMatch = searchLocation ? (tx.location || '').toLowerCase().includes(searchLocation.toLowerCase()) : true;
const categoryMatch = selectedCategory ? tx.category === selectedCategory : true;
const monthMatch = selectedMonthYear
? new Date(tx.timestamp.split('/').reverse().join('-')).toLocaleString('vi-VN', { month: 'long', year: 'numeric' }) === selectedMonthYear
: true;
return amountMatch && detailsMatch && locationMatch && categoryMatch && monthMatch;
});
}, [transactionHistory, minAmount, maxAmount, searchDetails, searchLocation, selectedCategory, selectedMonthYear]);
// Pagination
const totalPages = Math.ceil(filteredTransactions.length / transactionsPerPage);
const paginatedTransactions = filteredTransactions.slice(
(currentPage - 1) * transactionsPerPage,
currentPage * transactionsPerPage
);
// Group by month
const paginatedGrouped = useMemo(() => {
const grouped = {};
paginatedTransactions.forEach((transaction) => {
if (!transaction?.timestamp) return;
const date = new Date(transaction.timestamp.split('/').reverse().join('-'));
const monthYear = date.toLocaleString('vi-VN', { month: 'long', year: 'numeric' });
if (!grouped[monthYear]) {
grouped[monthYear] = [];
}
grouped[monthYear].push(transaction);
});
return grouped;
}, [paginatedTransactions]);
// UI Handlers
const toggleMonth = (monthYear) => {
setExpandedMonths((prev) => ({
...prev,
[monthYear]: !prev[monthYear],
}));
};
const toggleSearchMenu = () => setIsSearchMenuOpen((prev) => !prev);
const handleResetSearch = () => {
setMinAmount('');
setMaxAmount('');
setSearchDetails('');
setSearchLocation('');
setSelectedCategory('');
setSelectedMonthYear('');
setCurrentPage(1);
};
// Keyboard accessibility for search menu
useEffect(() => {
const handleKeyDown = (e) => {
if (isSearchMenuOpen && e.key === 'Escape') {
setIsSearchMenuOpen(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isSearchMenuOpen]);
// Main UI
return (
{/* Mobile Topbar */}
{/* Notification */}
{isLoading && (
)}
{error && (
{/* Overview Cards */}
{categories.map((cat) => (
{/* Chart */}
{/* Filters */}
{/* Advanced Filters */}
{isSearchMenuOpen && (
<>
{/* Overlay chỉ làm mờ nền, không làm mờ advanced filter */}
Bảng điều khiển
{error}
)}
{successMessage && (
{successMessage}
)}
{!isLoading && (
<>
{/* Financial Quote */}
*32 Lá Thư*: "Kỷ luật tài chính bắt đầu từ việc theo dõi chi tiêu hàng ngày. Hãy kiểm tra ngân sách của bạn thường xuyên!"
Tổng số dư
{formatVND(totalAmount)}
{numberToWords(totalAmount)}
{cat.icon}
))}
{cat.label}
{formatVND(allocations[cat.value] || 0)}
Phân tích chi tiêu gần đây
{recentTransactionsForChart.length > 0 ? (
) : (
Tổng
{formatVND(
recentTransactionsForChart.reduce((sum, tx) => sum + (parseFloat(tx.amount) || 0), 0)
)}
Chưa có giao dịch để phân tích.
)}Bộ lọc giao dịch
setMinAmount(e.target.value)}
className={`w-full p-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition ${isDarkMode ? 'bg-gray-800 text-white border-gray-600' : 'bg-white text-gray-800 border-gray-300'}`}
placeholder="Tối thiểu"
min={0}
/>
setMaxAmount(e.target.value)}
className={`w-full p-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition ${isDarkMode ? 'bg-gray-800 text-white border-gray-600' : 'bg-white text-gray-800 border-gray-300'}`}
placeholder="Tối đa"
min={0}
/>
setSearchDetails(e.target.value)}
className={`w-full p-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition ${isDarkMode ? 'bg-gray-800 text-white border-gray-600' : 'bg-white text-gray-800 border-gray-300'}`}
placeholder="Ví dụ: Mua thực phẩm"
/>
setSearchLocation(e.target.value)}
className={`w-full p-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition ${isDarkMode ? 'bg-gray-800 text-white border-gray-600' : 'bg-white text-gray-800 border-gray-300'}`}
placeholder="Ví dụ: VinMart"
/>
setIsSearchMenuOpen(false)}
aria-label="Đóng bộ lọc nâng cao"
/>
>
)}
{/* Scroll to top button */}
{showScrollTop && (
)}
{/*
Đã chuyển overlay và advanced filter thành fixed,
overlay không làm mờ advanced filter nữa.
*/}
);
}
export default Home;
e.stopPropagation()}
>
>
)}
{/* Transactions */}
Giao dịch gần đây
{Object.keys(paginatedGrouped).length > 0 ? ( <> {Object.keys(paginatedGrouped).sort((a, b) => { const [monthA, yearA] = a.split(' '); const [monthB, yearB] = b.split(' '); const dateA = new Date(yearA, new Date(Date.parse(monthA + " 1, 2000")).getMonth()); const dateB = new Date(yearB, new Date(Date.parse(monthB + " 1, 2000")).getMonth()); return dateB - dateA; }).map((monthYear) => (
{expandedMonths[monthYear] && (
))}
{/* Pagination */}
{paginatedGrouped[monthYear].map((transaction, index) => (
))}
)}
{categories.find(cat => cat.value === transaction.category)?.icon}
{categories.find(cat => cat.value === transaction.category)?.label || 'Không xác định'}
Số tiền:
{formatVND(transaction.amount)}
Chi tiết:
{transaction.details || Không có}
Vị trí:
{transaction.location || Không có}
Thời gian:
{transaction.timestamp || Không có}
{`Trang ${currentPage} / ${totalPages || 1}`}
>
) : (
Không tìm thấy giao dịch phù hợp với bộ lọc.
)}