Commit 5c0c24fc authored by ThinhNC's avatar ThinhNC

feat(reports): add financial reports and analytics dashboard

parent 333f0887
...@@ -18,6 +18,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th ...@@ -18,6 +18,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- **Hiển thị Category**: Trang quản lý danh mục tại `/categories` dùng `GET /categories/tree` làm nguồn hiển thị chính, giữ cấu trúc cha/con và sắp xếp đệ quy ở client vì tree endpoint không nhận tham số sort. Danh mục hệ thống là chỉ đọc và tên hiển thị được ánh xạ theo locale mà không thay đổi payload API; danh mục cá nhân giữ nguyên tên người dùng nhập và hỗ trợ tạo/sửa, archive cả nhánh, restore. Parent picker chỉ cho chọn danh mục đang hoạt động, cùng loại giao dịch và loại trừ chính node cùng toàn bộ hậu duệ để tránh chu trình. - **Hiển thị Category**: Trang quản lý danh mục tại `/categories` dùng `GET /categories/tree` làm nguồn hiển thị chính, giữ cấu trúc cha/con và sắp xếp đệ quy ở client vì tree endpoint không nhận tham số sort. Danh mục hệ thống là chỉ đọc và tên hiển thị được ánh xạ theo locale mà không thay đổi payload API; danh mục cá nhân giữ nguyên tên người dùng nhập và hỗ trợ tạo/sửa, archive cả nhánh, restore. Parent picker chỉ cho chọn danh mục đang hoạt động, cùng loại giao dịch và loại trừ chính node cùng toàn bộ hậu duệ để tránh chu trình.
- **Quản lý Budget**: Trang `/budgets``/budgets/:id` dùng trực tiếp usage do Budget API tính (`spentAmount`, `remainingAmount`, `usagePercentage`, `timeStatus`, `status`) để giữ một nguồn sự thật chung cho UI, báo cáo, thông báo và AI. `DELETE /budgets/:id` là archive/xóa mềm nhằm bảo toàn lịch sử; ngân sách đã archive chỉ đọc đến khi restore. Mọi mutation giao dịch phải invalidate cache `budgets` vì số tiền đã chi phụ thuộc giao dịch EXPENSE. - **Quản lý Budget**: Trang `/budgets``/budgets/:id` dùng trực tiếp usage do Budget API tính (`spentAmount`, `remainingAmount`, `usagePercentage`, `timeStatus`, `status`) để giữ một nguồn sự thật chung cho UI, báo cáo, thông báo và AI. `DELETE /budgets/:id` là archive/xóa mềm nhằm bảo toàn lịch sử; ngân sách đã archive chỉ đọc đến khi restore. Mọi mutation giao dịch phải invalidate cache `budgets` vì số tiền đã chi phụ thuộc giao dịch EXPENSE.
- **Quản lý Saving Goal**: Trang `/saving-goals``/saving-goals/:id` dùng trực tiếp progress do Saving Goal API tính (`savedAmount`, `remainingAmount`, `progressPercentage`, `daysRemaining`, `isOverdue`) để giữ một nguồn sự thật chung cho UI, báo cáo và AI. `DELETE /saving-goals/:id` là archive/xóa mềm nhằm bảo toàn lịch sử; mục tiêu archive chỉ đọc đến khi restore. Contribution được quản lý riêng và mọi mutation contribution phải làm mới cả detail, list goal và lịch sử contribution. - **Quản lý Saving Goal**: Trang `/saving-goals``/saving-goals/:id` dùng trực tiếp progress do Saving Goal API tính (`savedAmount`, `remainingAmount`, `progressPercentage`, `daysRemaining`, `isOverdue`) để giữ một nguồn sự thật chung cho UI, báo cáo và AI. `DELETE /saving-goals/:id` là archive/xóa mềm nhằm bảo toàn lịch sử; mục tiêu archive chỉ đọc đến khi restore. Contribution được quản lý riêng và mọi mutation contribution phải làm mới cả detail, list goal và lịch sử contribution.
- **Báo cáo tài chính**: Trang `/reports` dùng trực tiếp bốn Report API (`overview`, `cash-flow`, `spending-by-category`, `budget-performance`) qua TanStack Query và luôn giữ số liệu tách theo tiền tệ; không cộng gộp các loại tiền tệ khác nhau. Bộ lọc khoảng ngày tùy chọn gửi mốc kết thúc dạng exclusive sang Backend nhưng hiển thị ngày kết thúc dạng inclusive cho người dùng. Biểu đồ dùng SVG/CSS với semantic Clay tokens, không phụ thuộc thư viện chart, và context `period`/`currency`/`walletId` được chuẩn bị tại khối AI Financial Insights cho giai đoạn tích hợp tiếp theo.
- **Tiêu đề trang**: Mỗi route cập nhật `document.title` theo mẫu `<Tên trang> | FinWise` thông qua component dùng chung trong router; route chưa nhận diện dùng tiêu đề mô tả sản phẩm mặc định. - **Tiêu đề trang**: Mỗi route cập nhật `document.title` theo mẫu `<Tên trang> | FinWise` thông qua component dùng chung trong router; route chưa nhận diện dùng tiêu đề mô tả sản phẩm mặc định.
- **Khoảng trống và nút điều khiển trên header hệ thống**: Zalo Mini App mặc định hiển thị `zaui-header` ở phía trên. Mọi page/layout phải chừa đủ khoảng cách phía trên (tính cả safe area khi cần) để nội dung và phần tử tương tác không bị header che khuất. Cụm điều khiển riêng của app (hiện gồm theme và ngôn ngữ) phải nằm trong `.finwise-header-controls`, đặt về bên trái vùng native `right-buttons` rộng 96px; đồng thời `zaui-header` phải dành đủ `padding-right` cho cả `right-buttons` và cụm này. Không đặt từng nút bằng offset `right` rời rạc vì có thể làm switch ngôn ngữ bị che khuất. - **Khoảng trống và nút điều khiển trên header hệ thống**: Zalo Mini App mặc định hiển thị `zaui-header` ở phía trên. Mọi page/layout phải chừa đủ khoảng cách phía trên (tính cả safe area khi cần) để nội dung và phần tử tương tác không bị header che khuất. Cụm điều khiển riêng của app (hiện gồm theme và ngôn ngữ) phải nằm trong `.finwise-header-controls`, đặt về bên trái vùng native `right-buttons` rộng 96px; đồng thời `zaui-header` phải dành đủ `padding-right` cho cả `right-buttons` và cụm này. Không đặt từng nút bằng offset `right` rời rạc vì có thể làm switch ngôn ngữ bị che khuất.
- **Cấu hình API**: Base URL mặc định là `http://localhost:7777/api/v1` (tương tác trực tiếp với port 7777 của Backend). - **Cấu hình API**: Base URL mặc định là `http://localhost:7777/api/v1` (tương tác trực tiếp với port 7777 của Backend).
...@@ -47,7 +48,8 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th ...@@ -47,7 +48,8 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- Module quản lý giao dịch đã có frontend tại `/transactions`, kết nối Transaction API qua TanStack Query, gồm danh sách lịch sử theo ngày, xem/tạo/sửa/xóa giao dịch, lọc nâng cao, sắp xếp, phân trang và đính kèm hóa đơn. - Module quản lý giao dịch đã có frontend tại `/transactions`, kết nối Transaction API qua TanStack Query, gồm danh sách lịch sử theo ngày, xem/tạo/sửa/xóa giao dịch, lọc nâng cao, sắp xếp, phân trang và đính kèm hóa đơn.
- Module quản lý chuyển tiền đã có frontend tại `/transfers`, dùng contract `GET/POST /transfers``DELETE /transfers/:id` qua TanStack Query; gồm form chọn ví nguồn/đích, validation số dư và hai ví khác nhau, xác nhận trước khi chuyển/xóa, lịch sử có tìm kiếm/lọc/sắp xếp/phân trang và đầy đủ loading/error/empty state. Sau thao tác tạo hoặc xóa, frontend làm mới cache transfer, wallet và transaction để lấy lại số dư do Backend tính toán. - Module quản lý chuyển tiền đã có frontend tại `/transfers`, dùng contract `GET/POST /transfers``DELETE /transfers/:id` qua TanStack Query; gồm form chọn ví nguồn/đích, validation số dư và hai ví khác nhau, xác nhận trước khi chuyển/xóa, lịch sử có tìm kiếm/lọc/sắp xếp/phân trang và đầy đủ loading/error/empty state. Sau thao tác tạo hoặc xóa, frontend làm mới cache transfer, wallet và transaction để lấy lại số dư do Backend tính toán.
- Module quản lý ngân sách đã có frontend tại `/budgets``/budgets/:id`, kết nối Budget API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, archive/restore, tìm kiếm, lọc loại/chu kỳ/danh mục/thời điểm, sắp xếp, phân trang và cảnh báo trực quan theo tỷ lệ sử dụng; đầy đủ validation, loading/error/empty/confirmation và bản dịch Việt/Anh. - Module quản lý ngân sách đã có frontend tại `/budgets``/budgets/:id`, kết nối Budget API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, archive/restore, tìm kiếm, lọc loại/chu kỳ/danh mục/thời điểm, sắp xếp, phân trang và cảnh báo trực quan theo tỷ lệ sử dụng; đầy đủ validation, loading/error/empty/confirmation và bản dịch Việt/Anh.
- Module mục tiêu tiết kiệm đã có frontend tại `/saving-goals``/saving-goals/:id`, kết nối Saving Goal API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, pause/resume, archive/restore, tìm kiếm, lọc trạng thái/thời hạn, sắp xếp, phân trang và quản lý đầy đủ tạo/sửa/xóa lịch sử đóng góp; có validation, loading/error/empty/confirmation và bản dịch Việt/Anh. Các module báo cáo và AI Assistant chưa được triển khai trong frontend hiện tại. - Module mục tiêu tiết kiệm đã có frontend tại `/saving-goals``/saving-goals/:id`, kết nối Saving Goal API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, pause/resume, archive/restore, tìm kiếm, lọc trạng thái/thời hạn, sắp xếp, phân trang và quản lý đầy đủ tạo/sửa/xóa lịch sử đóng góp; có validation, loading/error/empty/confirmation và bản dịch Việt/Anh.
- Module báo cáo tài chính đã có frontend tại `/reports`, kết nối đầy đủ Report API qua TanStack Query, gồm lọc ngày/tuần/tháng/năm/khoảng tùy chọn, ví và tiền tệ; KPI tổng quan, dòng tiền, cơ cấu chi tiêu, hiệu suất ngân sách, mục tiêu tiết kiệm, loading/error/empty state, responsive và bản dịch Việt/Anh. AI Assistant chưa được tích hợp; trang báo cáo hiện có khối preview/context làm nền tảng cho AI Financial Insights.
## Khi cập nhật file này ## Khi cập nhật file này
......
...@@ -33,6 +33,7 @@ import BudgetsPage from "@/pages/budgets/index"; ...@@ -33,6 +33,7 @@ import BudgetsPage from "@/pages/budgets/index";
import BudgetDetailPage from "@/pages/budgets/detail"; import BudgetDetailPage from "@/pages/budgets/detail";
import SavingGoalsPage from "@/pages/saving-goals/index"; import SavingGoalsPage from "@/pages/saving-goals/index";
import SavingGoalDetailPage from "@/pages/saving-goals/detail"; import SavingGoalDetailPage from "@/pages/saving-goals/detail";
import ReportsPage from "@/pages/reports/index";
const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => { const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { setAuth, clearAuth, setInitialized } = useAuthStore(); const { setAuth, clearAuth, setInitialized } = useAuthStore();
...@@ -100,6 +101,7 @@ const Layout = () => { ...@@ -100,6 +101,7 @@ const Layout = () => {
<Route path="/budgets/:id" element={<AuthGuard><BudgetDetailPage /></AuthGuard>}></Route> <Route path="/budgets/:id" element={<AuthGuard><BudgetDetailPage /></AuthGuard>}></Route>
<Route path="/saving-goals" element={<AuthGuard><SavingGoalsPage /></AuthGuard>}></Route> <Route path="/saving-goals" element={<AuthGuard><SavingGoalsPage /></AuthGuard>}></Route>
<Route path="/saving-goals/:id" element={<AuthGuard><SavingGoalDetailPage /></AuthGuard>}></Route> <Route path="/saving-goals/:id" element={<AuthGuard><SavingGoalDetailPage /></AuthGuard>}></Route>
<Route path="/reports" element={<AuthGuard><ReportsPage /></AuthGuard>}></Route>
<Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route> <Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route>
</AnimationRoutes> </AnimationRoutes>
</AuthInitializer> </AuthInitializer>
......
...@@ -23,6 +23,7 @@ const PAGE_TITLES: ReadonlyArray<{ ...@@ -23,6 +23,7 @@ const PAGE_TITLES: ReadonlyArray<{
{ matches: (pathname) => pathname.startsWith("/budgets/"), key: "document.budgetDetail" }, { matches: (pathname) => pathname.startsWith("/budgets/"), key: "document.budgetDetail" },
{ matches: (pathname) => pathname === "/saving-goals", key: "document.savingGoals" }, { matches: (pathname) => pathname === "/saving-goals", key: "document.savingGoals" },
{ matches: (pathname) => pathname.startsWith("/saving-goals/"), key: "document.savingGoalDetail" }, { matches: (pathname) => pathname.startsWith("/saving-goals/"), key: "document.savingGoalDetail" },
{ matches: (pathname) => pathname === "/reports", key: "document.reports" },
{ matches: (pathname) => pathname === "/style-guide", key: "document.styleGuide" }, { matches: (pathname) => pathname === "/style-guide", key: "document.styleGuide" },
]; ];
......
import { useQuery } from "@tanstack/react-query";
import { reportService } from "@/services/report.service";
import { ReportQuery } from "@/types/report";
export const reportKeys = {
all: ["reports"] as const,
overview: (query: ReportQuery) => [...reportKeys.all, "overview", query] as const,
cashFlow: (query: ReportQuery) => [...reportKeys.all, "cash-flow", query] as const,
spending: (query: ReportQuery) => [...reportKeys.all, "spending-by-category", query] as const,
budgets: (query: ReportQuery) => [...reportKeys.all, "budget-performance", query] as const,
};
const queryDefaults = {
staleTime: 5 * 60_000,
refetchOnMount: "always" as const,
refetchOnWindowFocus: true,
} as const;
export function useReportOverview(query: ReportQuery, enabled = true) {
return useQuery({
queryKey: reportKeys.overview(query),
queryFn: () => reportService.getOverview(query),
enabled,
...queryDefaults,
});
}
export function useCashFlowReport(query: ReportQuery, enabled = true) {
return useQuery({
queryKey: reportKeys.cashFlow(query),
queryFn: () => reportService.getCashFlow(query),
enabled,
...queryDefaults,
});
}
export function useSpendingReport(query: ReportQuery, enabled = true) {
return useQuery({
queryKey: reportKeys.spending(query),
queryFn: () => reportService.getSpendingByCategory(query),
enabled,
...queryDefaults,
});
}
export function useBudgetPerformanceReport(query: ReportQuery, enabled = true) {
return useQuery({
queryKey: reportKeys.budgets(query),
queryFn: () => reportService.getBudgetPerformance(query),
enabled,
...queryDefaults,
});
}
...@@ -50,7 +50,8 @@ ...@@ -50,7 +50,8 @@
"budgets": "Budget Management", "budgets": "Budget Management",
"budgetDetail": "Budget Details", "budgetDetail": "Budget Details",
"savingGoals": "Saving Goals", "savingGoals": "Saving Goals",
"savingGoalDetail": "Saving Goal Details" "savingGoalDetail": "Saving Goal Details",
"reports": "Financial Reports"
}, },
"validation": { "validation": {
"emailRequired": "Email is required", "emailRequired": "Email is required",
...@@ -197,7 +198,8 @@ ...@@ -197,7 +198,8 @@
"transactions": "Manage transactions", "transactions": "Manage transactions",
"transfers": "Transfer between wallets", "transfers": "Transfer between wallets",
"budgets": "Manage spending budgets", "budgets": "Manage spending budgets",
"savingGoals": "Manage saving goals" "savingGoals": "Manage saving goals",
"reports": "Financial reports & analytics"
}, },
"profile": { "profile": {
"header": "Account", "header": "Account",
...@@ -911,6 +913,128 @@ ...@@ -911,6 +913,128 @@
"today": "Today", "today": "Today",
"yesterday": "Yesterday" "yesterday": "Yesterday"
}, },
"report": {
"header": "Financial reports",
"syncing": "Updating",
"currencyTabs": "Choose a currency to view its report",
"retryAll": "Retry the full report",
"hero": {
"eyebrow": "Smart overview",
"title": "Your finances, made easy to understand"
},
"period": {
"day": "Day",
"week": "Week",
"month": "Month",
"year": "Year",
"custom": "Custom"
},
"filters": {
"title": "Report scope",
"hint": "Filter by time, wallet, or currency",
"reset": "Reset",
"dateFrom": "From date",
"dateTo": "Through date",
"wallet": "Wallet",
"allWallets": "All wallets",
"currency": "Currency",
"allCurrencies": "All currencies",
"requiredRange": "Choose both a start and end date.",
"invalidRange": "The end date must be on or after the start date.",
"waitingTitle": "Choose a date range to view reports",
"waitingHint": "Data will load when the selected range is valid."
},
"overview": {
"title": "Key metrics",
"subtitle": "Figures are kept separate for {{currency}}"
},
"metrics": {
"balance": "Total balance",
"income": "Total income",
"expense": "Total expense",
"saving": "Net savings",
"budgetUsage": "Budget usage",
"budgetCount": "Combined from {{count}} budgets",
"noBudget": "No budgets in this period",
"spent": "Spent",
"limit": "Limit",
"financialHealth": "Financial health",
"healthHint": "Savings as a share of total income",
"savingsRate": "Savings rate",
"transactions": "Transactions"
},
"wallets": {
"title": "Balance by wallet",
"subtitle": "Wallets using {{currency}}",
"default": "Default wallet"
},
"cashFlow": {
"title": "Cash-flow trend",
"subtitle": "Compare income and spending over time",
"income": "Income",
"expense": "Expense",
"net": "Net",
"expenseTrend": "Latest period expense: {{value}}",
"chartAria": "Cash-flow chart in {{currency}}",
"totalIncome": "Total income",
"totalExpense": "Total expense",
"totalNet": "Net cash flow",
"empty": "No cash flow in this period",
"emptyHint": "Record an income or expense transaction to start tracking trends."
},
"categories": {
"title": "Spending by category",
"subtitle": "The categories taking the largest share of spending",
"chartAria": "Spending distribution chart in {{currency}}",
"total": "Total spent",
"transactionCount": "{{count}} transactions",
"itemTransactions": "{{count}} expenses",
"empty": "No category spending yet",
"emptyHint": "Categories appear after expense transactions occur in the selected range."
},
"budgets": {
"title": "Budget performance",
"subtitle": "Compare limits with actual spending",
"count": "{{count}} budgets",
"status": {
"on_track": "On track",
"near_limit": "Near limit",
"exceeded": "Exceeded"
},
"empty": "No matching budgets",
"emptyHint": "Create a budget to track usage directly in reports."
},
"goals": {
"title": "Saving goals",
"subtitle": "Progress toward the plans that matter to you",
"active": "{{count}} active",
"saved": "Saved so far",
"periodContribution": "Added this period",
"target": "Target",
"activeLabel": "Active",
"pausedLabel": "Paused",
"completedLabel": "Completed",
"empty": "No saving goals yet",
"emptyHint": "Create a goal to track saving progress here."
},
"ai": {
"title": "AI Financial Insights",
"comingSoon": "Coming soon",
"subtitle": "The data foundation is ready for personalized analysis in the next phase.",
"spending": "Spending signals",
"spendingHint": "Spot unusual category-level changes.",
"cashFlow": "Cash-flow outlook",
"cashFlowHint": "Analyze income, expense, and balance trends.",
"recommendation": "Suggested actions",
"recommendationHint": "Recommendations aligned with budgets and goals."
},
"states": {
"errorTitle": "This report section could not load",
"errorHint": "The data connection ran into a problem. Please try again.",
"emptyTitle": "No report data yet",
"emptyHint": "Try another range or add transactions to see analytics."
}
},
"transfer": { "transfer": {
"header": "Transfer Management", "header": "Transfer Management",
"create": "New transfer", "create": "New transfer",
......
...@@ -50,7 +50,8 @@ ...@@ -50,7 +50,8 @@
"budgets": "Quản lý ngân sách", "budgets": "Quản lý ngân sách",
"budgetDetail": "Chi tiết ngân sách", "budgetDetail": "Chi tiết ngân sách",
"savingGoals": "Mục tiêu tiết kiệm", "savingGoals": "Mục tiêu tiết kiệm",
"savingGoalDetail": "Chi tiết mục tiêu tiết kiệm" "savingGoalDetail": "Chi tiết mục tiêu tiết kiệm",
"reports": "Báo cáo tài chính"
}, },
"validation": { "validation": {
"emailRequired": "Email không được để trống", "emailRequired": "Email không được để trống",
...@@ -197,7 +198,8 @@ ...@@ -197,7 +198,8 @@
"transactions": "Quản lý giao dịch thu chi", "transactions": "Quản lý giao dịch thu chi",
"transfers": "Chuyển tiền giữa các ví", "transfers": "Chuyển tiền giữa các ví",
"budgets": "Quản lý ngân sách chi tiêu", "budgets": "Quản lý ngân sách chi tiêu",
"savingGoals": "Quản lý mục tiêu tiết kiệm" "savingGoals": "Quản lý mục tiêu tiết kiệm",
"reports": "Báo cáo & phân tích tài chính"
}, },
"profile": { "profile": {
"header": "Tài Khoản", "header": "Tài Khoản",
...@@ -920,6 +922,128 @@ ...@@ -920,6 +922,128 @@
"today": "Hôm nay", "today": "Hôm nay",
"yesterday": "Hôm qua" "yesterday": "Hôm qua"
}, },
"report": {
"header": "Báo cáo tài chính",
"syncing": "Đang cập nhật",
"currencyTabs": "Chọn loại tiền tệ để xem báo cáo",
"retryAll": "Thử tải lại toàn bộ báo cáo",
"hero": {
"eyebrow": "Tổng quan thông minh",
"title": "Tài chính của bạn, thật dễ hiểu"
},
"period": {
"day": "Ngày",
"week": "Tuần",
"month": "Tháng",
"year": "Năm",
"custom": "Tùy chọn"
},
"filters": {
"title": "Phạm vi báo cáo",
"hint": "Lọc theo thời gian, ví hoặc loại tiền tệ",
"reset": "Đặt lại",
"dateFrom": "Từ ngày",
"dateTo": "Đến hết ngày",
"wallet": "Ví",
"allWallets": "Tất cả ví",
"currency": "Tiền tệ",
"allCurrencies": "Tất cả tiền tệ",
"requiredRange": "Vui lòng chọn đủ ngày bắt đầu và kết thúc.",
"invalidRange": "Ngày kết thúc phải cùng hoặc sau ngày bắt đầu.",
"waitingTitle": "Chọn khoảng thời gian để xem báo cáo",
"waitingHint": "Dữ liệu sẽ được tải khi khoảng ngày hợp lệ."
},
"overview": {
"title": "Chỉ số tổng quan",
"subtitle": "Các số liệu được tách riêng theo {{currency}}"
},
"metrics": {
"balance": "Tổng số dư",
"income": "Tổng thu",
"expense": "Tổng chi",
"saving": "Tiết kiệm ròng",
"budgetUsage": "Sử dụng ngân sách",
"budgetCount": "Tổng hợp từ {{count}} ngân sách",
"noBudget": "Chưa có ngân sách trong kỳ",
"spent": "Đã chi",
"limit": "Hạn mức",
"financialHealth": "Sức khỏe tài chính",
"healthHint": "Tỷ lệ tiết kiệm trên tổng thu",
"savingsRate": "Tỷ lệ tiết kiệm",
"transactions": "Giao dịch"
},
"wallets": {
"title": "Số dư theo ví",
"subtitle": "Các ví sử dụng {{currency}}",
"default": "Ví mặc định"
},
"cashFlow": {
"title": "Xu hướng dòng tiền",
"subtitle": "So sánh thu nhập và chi tiêu theo thời gian",
"income": "Thu nhập",
"expense": "Chi tiêu",
"net": "Ròng",
"expenseTrend": "Chi tiêu kỳ gần nhất: {{value}}",
"chartAria": "Biểu đồ dòng tiền theo {{currency}}",
"totalIncome": "Tổng thu",
"totalExpense": "Tổng chi",
"totalNet": "Dòng tiền ròng",
"empty": "Chưa có dòng tiền trong kỳ",
"emptyHint": "Hãy ghi nhận giao dịch thu hoặc chi để bắt đầu theo dõi xu hướng."
},
"categories": {
"title": "Chi tiêu theo danh mục",
"subtitle": "Những nhóm đang chiếm phần lớn chi tiêu",
"chartAria": "Biểu đồ phân bổ chi tiêu theo {{currency}}",
"total": "Tổng chi",
"transactionCount": "{{count}} giao dịch",
"itemTransactions": "{{count}} lần chi",
"empty": "Chưa có chi tiêu theo danh mục",
"emptyHint": "Các danh mục sẽ xuất hiện khi có giao dịch chi trong phạm vi đã chọn."
},
"budgets": {
"title": "Hiệu suất ngân sách",
"subtitle": "So sánh hạn mức và chi tiêu thực tế",
"count": "{{count}} ngân sách",
"status": {
"on_track": "Đúng kế hoạch",
"near_limit": "Gần hạn mức",
"exceeded": "Vượt hạn mức"
},
"empty": "Chưa có ngân sách phù hợp",
"emptyHint": "Tạo ngân sách để theo dõi mức sử dụng ngay trong báo cáo."
},
"goals": {
"title": "Mục tiêu tiết kiệm",
"subtitle": "Tiến độ tích lũy cho các kế hoạch của bạn",
"active": "{{count}} đang chạy",
"saved": "Đã tích lũy",
"periodContribution": "Đóng góp trong kỳ",
"target": "Mục tiêu",
"activeLabel": "Đang chạy",
"pausedLabel": "Tạm dừng",
"completedLabel": "Hoàn thành",
"empty": "Chưa có mục tiêu tiết kiệm",
"emptyHint": "Tạo mục tiêu để theo dõi tiến độ tích lũy tại đây."
},
"ai": {
"title": "AI Financial Insights",
"comingSoon": "Sắp ra mắt",
"subtitle": "Nền tảng dữ liệu đã sẵn sàng cho các phân tích cá nhân hóa ở giai đoạn tiếp theo.",
"spending": "Phát hiện chi tiêu",
"spendingHint": "Nhận diện thay đổi bất thường theo danh mục.",
"cashFlow": "Dự báo dòng tiền",
"cashFlowHint": "Phân tích xu hướng thu, chi và số dư.",
"recommendation": "Gợi ý hành động",
"recommendationHint": "Đề xuất bám sát ngân sách và mục tiêu."
},
"states": {
"errorTitle": "Không thể tải phần báo cáo này",
"errorHint": "Kết nối dữ liệu đang gặp sự cố. Vui lòng thử lại.",
"emptyTitle": "Chưa có dữ liệu báo cáo",
"emptyHint": "Thử chọn phạm vi khác hoặc thêm giao dịch để xem phân tích."
}
},
"transfer": { "transfer": {
"header": "Quản lý chuyển tiền", "header": "Quản lý chuyển tiền",
"create": "Tạo lệnh chuyển tiền", "create": "Tạo lệnh chuyển tiền",
......
...@@ -95,6 +95,13 @@ function HomePage() { ...@@ -95,6 +95,13 @@ function HomePage() {
> >
{t("home.categories")} {t("home.categories")}
</Button> </Button>
<Button
variant="secondary"
fullWidth
onClick={() => navigate("/reports")}
>
{t("home.reports")}
</Button>
<Button <Button
variant="secondary" variant="secondary"
fullWidth fullWidth
......
import React from "react";
import { Card } from "@/components/ui/Card";
import { AIAssistantIcon } from "@/components/ui/icons";
import { useI18n } from "@/i18n";
import { ReportPeriodPreset } from "@/types/report";
export interface AIInsightContext {
period: ReportPeriodPreset;
currency: string;
walletId?: string;
}
interface AIInsightsPreviewProps {
context: AIInsightContext;
}
export const AIInsightsPreview: React.FC<AIInsightsPreviewProps> = ({ context }) => {
const { t } = useI18n();
return (
<Card className="relative overflow-hidden p-5" data-period={context.period} data-currency={context.currency} data-wallet-id={context.walletId}>
<div className="absolute -right-10 -top-10 h-32 w-32 rounded-full bg-clay-primary/10" />
<div className="relative flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-clay bg-clay-primary/15 shadow-clay-pressed">
<AIAssistantIcon size={28} />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="clay-title-h2">{t("report.ai.title")}</h2>
<span className="rounded-full bg-clay-primary/15 px-3 py-1 font-nunito text-[10px] font-bold uppercase tracking-wide text-clay-primary">{t("report.ai.comingSoon")}</span>
</div>
<p className="clay-caption mt-1">{t("report.ai.subtitle")}</p>
</div>
</div>
<div className="relative mt-5 grid grid-cols-1 gap-2 sm:grid-cols-3">
{["spending", "cashFlow", "recommendation"].map((key) => (
<div key={key} className="rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
<div className="mb-2 h-2 w-10 rounded-full bg-clay-primary/25" />
<p className="font-nunito text-xs font-bold text-clay-text">{t(`report.ai.${key}`)}</p>
<p className="mt-1 text-[10px] font-semibold leading-relaxed text-clay-text-muted">{t(`report.ai.${key}Hint`)}</p>
</div>
))}
</div>
</Card>
);
};
import React from "react";
import { Badge } from "@/components/ui/Badge";
import { Card } from "@/components/ui/Card";
import { ProgressBar } from "@/components/ui/ProgressBar";
import { useI18n } from "@/i18n";
import { BudgetPerformanceItem, BudgetPerformanceStatus } from "@/types/report";
interface BudgetAnalyticsProps {
budgets: BudgetPerformanceItem[];
currency: string;
onOpenBudget: (id: string) => void;
}
const statusType: Record<BudgetPerformanceStatus, "income" | "warning" | "expense"> = {
ON_TRACK: "income",
NEAR_LIMIT: "warning",
EXCEEDED: "expense",
};
export const BudgetAnalytics: React.FC<BudgetAnalyticsProps> = ({ budgets, currency, onOpenBudget }) => {
const { t, formatCurrency, formatNumber } = useI18n();
const sortedBudgets = [...budgets]
.filter((budget) => budget.currency === currency)
.sort((left, right) => Number(right.usagePercentage) - Number(left.usagePercentage));
return (
<Card className="p-5">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="clay-title-h2">{t("report.budgets.title")}</h2>
<p className="clay-caption">{t("report.budgets.subtitle")}</p>
</div>
<span className="rounded-full bg-clay-warning/15 px-3 py-1 font-nunito text-xs font-bold text-clay-text">
{t("report.budgets.count", { count: sortedBudgets.length })}
</span>
</div>
<div className="mt-5 flex flex-col gap-3">
{sortedBudgets.slice(0, 5).map((budget) => {
const usage = Number(budget.usagePercentage) || 0;
return (
<button
key={budget.id}
type="button"
onClick={() => onOpenBudget(budget.id)}
className="w-full rounded-clay bg-clay-bg p-4 text-left shadow-clay-pressed transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-clay-raised focus:outline-none focus:ring-2 focus:ring-clay-primary/30"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate font-nunito text-sm font-bold text-clay-text">{budget.name}</p>
<p className="clay-caption">{formatCurrency(Number(budget.spentAmount), currency)} / {formatCurrency(Number(budget.budgetAmount), currency)}</p>
</div>
<Badge type={statusType[budget.status]} className="shrink-0">
{t(`report.budgets.status.${budget.status.toLowerCase()}`)}
</Badge>
</div>
<div className="mt-3 flex items-center gap-3">
<ProgressBar value={usage} type={statusType[budget.status]} className="flex-1" />
<span className="w-12 text-right font-baloo text-sm font-bold text-clay-text">{formatNumber(usage, { maximumFractionDigits: 0 })}%</span>
</div>
</button>
);
})}
</div>
</Card>
);
};
import React from "react";
import { Card } from "@/components/ui/Card";
import { useI18n } from "@/i18n";
import { CashFlowReport } from "@/types/report";
interface CashFlowChartProps {
report: CashFlowReport;
currency: string;
}
const safeNumber = (value?: string): number => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
};
export const CashFlowChart: React.FC<CashFlowChartProps> = ({ report, currency }) => {
const { t, formatCurrency, formatDate, formatNumber } = useI18n();
const width = 360;
const height = 190;
const padding = { left: 12, right: 12, top: 18, bottom: 28 };
const plotWidth = width - padding.left - padding.right;
const plotHeight = height - padding.top - padding.bottom;
const points = report.series.map((bucket) => {
const metric = bucket.metricsByCurrency.find((item) => item.currency === currency);
return {
from: bucket.from,
income: safeNumber(metric?.income),
expense: safeNumber(metric?.expense),
net: safeNumber(metric?.netCashFlow),
};
});
const chartValues = points.reduce<number[]>((values, point) => [...values, point.income, point.expense], []);
const maxValue = Math.max(1, ...chartValues);
const xAt = (index: number) => padding.left + (points.length <= 1 ? plotWidth / 2 : (index / (points.length - 1)) * plotWidth);
const yAt = (value: number) => padding.top + plotHeight - (value / maxValue) * plotHeight;
const toPath = (key: "income" | "expense") => points.map((point, index) => `${index === 0 ? "M" : "L"}${xAt(index).toFixed(2)},${yAt(point[key]).toFixed(2)}`).join(" ");
const total = report.totalsByCurrency.find((item) => item.currency === currency);
const latest = points.length > 0 ? points[points.length - 1].expense : 0;
const previous = points.length > 1 ? points[points.length - 2].expense : 0;
const expenseTrend = previous > 0 ? ((latest - previous) / previous) * 100 : null;
const labelIndexes = Array.from(new Set([0, Math.floor((points.length - 1) / 2), Math.max(0, points.length - 1)]));
const compactMoney = (value: number) => formatCurrency(value, currency);
return (
<Card className="overflow-hidden p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="clay-title-h2">{t("report.cashFlow.title")}</h2>
<p className="clay-caption">{t("report.cashFlow.subtitle")}</p>
</div>
{expenseTrend !== null && (
<span className={`rounded-full px-3 py-1 font-nunito text-xs font-bold ${expenseTrend > 0 ? "bg-clay-expense/15 text-clay-expense" : "bg-clay-income/15 text-clay-income"}`}>
{t("report.cashFlow.expenseTrend", { value: `${expenseTrend > 0 ? "+" : ""}${formatNumber(expenseTrend, { maximumFractionDigits: 1 })}%` })}
</span>
)}
</div>
<div className="mt-4 flex flex-wrap gap-4 text-xs font-bold">
<span className="flex items-center gap-2 text-clay-text-muted"><span className="h-2.5 w-2.5 rounded-full bg-clay-income" />{t("report.cashFlow.income")}</span>
<span className="flex items-center gap-2 text-clay-text-muted"><span className="h-2.5 w-2.5 rounded-full bg-clay-expense" />{t("report.cashFlow.expense")}</span>
<span className="ml-auto text-clay-text">{t("report.cashFlow.net")}: {compactMoney(safeNumber(total?.netCashFlow))}</span>
</div>
<div className="mt-3 rounded-clay bg-clay-bg p-2 shadow-clay-pressed">
<svg viewBox={`0 0 ${width} ${height}`} className="h-auto w-full" role="img" aria-label={t("report.cashFlow.chartAria", { currency })}>
{[0, 0.5, 1].map((ratio) => {
const y = padding.top + plotHeight * ratio;
return <line key={ratio} x1={padding.left} x2={width - padding.right} y1={y} y2={y} stroke="rgb(var(--color-clay-border))" strokeWidth="1" strokeDasharray="4 5" />;
})}
{points.length > 0 && (
<>
<path d={toPath("income")} fill="none" stroke="rgb(var(--color-clay-income))" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
<path d={toPath("expense")} fill="none" stroke="rgb(var(--color-clay-expense))" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
{points.map((point, index) => (
<g key={point.from}>
<circle cx={xAt(index)} cy={yAt(point.income)} r="3.5" fill="rgb(var(--color-clay-income))" stroke="rgb(var(--color-clay-highlight))" strokeWidth="2" />
<circle cx={xAt(index)} cy={yAt(point.expense)} r="3.5" fill="rgb(var(--color-clay-expense))" stroke="rgb(var(--color-clay-highlight))" strokeWidth="2" />
</g>
))}
{labelIndexes.map((index) => (
<text key={index} x={xAt(index)} y={height - 7} textAnchor={index === 0 ? "start" : index === points.length - 1 ? "end" : "middle"} fill="rgb(var(--color-clay-text-muted))" fontSize="10" fontFamily="Nunito">
{formatDate(points[index].from, report.granularity === "HOUR" ? { hour: "2-digit", minute: "2-digit" } : { day: "2-digit", month: "short" })}
</text>
))}
</>
)}
</svg>
</div>
<div className="mt-4 grid grid-cols-3 gap-2">
<div className="rounded-clay-sm bg-clay-income/10 p-3 text-center">
<p className="clay-caption">{t("report.cashFlow.totalIncome")}</p>
<p className="mt-1 break-words font-baloo text-sm font-bold text-clay-text">{compactMoney(safeNumber(total?.income))}</p>
</div>
<div className="rounded-clay-sm bg-clay-expense/10 p-3 text-center">
<p className="clay-caption">{t("report.cashFlow.totalExpense")}</p>
<p className="mt-1 break-words font-baloo text-sm font-bold text-clay-text">{compactMoney(safeNumber(total?.expense))}</p>
</div>
<div className="rounded-clay-sm bg-clay-primary/10 p-3 text-center">
<p className="clay-caption">{t("report.cashFlow.totalNet")}</p>
<p className="mt-1 break-words font-baloo text-sm font-bold text-clay-text">{compactMoney(safeNumber(total?.netCashFlow))}</p>
</div>
</div>
</Card>
);
};
import React from "react";
import { Card } from "@/components/ui/Card";
import { useI18n } from "@/i18n";
import { getCategoryDisplayName } from "@/lib/category-format";
import { SpendingCategoryCurrency } from "@/types/report";
interface CategoryDistributionProps {
data: SpendingCategoryCurrency;
}
const chartColors = [
"rgb(var(--color-clay-expense))",
"rgb(var(--color-clay-warning))",
"rgb(var(--color-clay-primary))",
"rgb(var(--color-clay-info))",
"rgb(var(--color-clay-income))",
"rgb(var(--color-clay-text-muted))",
];
export const CategoryDistribution: React.FC<CategoryDistributionProps> = ({ data }) => {
const { t, formatCurrency, formatNumber } = useI18n();
const radius = 46;
const circumference = 2 * Math.PI * radius;
let cumulativePercentage = 0;
return (
<Card className="p-5">
<div>
<h2 className="clay-title-h2">{t("report.categories.title")}</h2>
<p className="clay-caption">{t("report.categories.subtitle")}</p>
</div>
<div className="mt-5 grid grid-cols-1 items-center gap-5 sm:grid-cols-[160px_1fr]">
<div className="relative mx-auto h-40 w-40">
<svg viewBox="0 0 120 120" className="h-full w-full -rotate-90" role="img" aria-label={t("report.categories.chartAria", { currency: data.currency })}>
<circle cx="60" cy="60" r={radius} fill="none" stroke="rgb(var(--color-clay-bg))" strokeWidth="18" />
{data.categories.slice(0, chartColors.length).map((item, index) => {
const percentage = Math.max(0, Number(item.percentage) || 0);
const offset = -(cumulativePercentage / 100) * circumference;
cumulativePercentage += percentage;
return (
<circle
key={item.category.id}
cx="60"
cy="60"
r={radius}
fill="none"
stroke={chartColors[index]}
strokeWidth="18"
strokeDasharray={`${(percentage / 100) * circumference} ${circumference}`}
strokeDashoffset={offset}
strokeLinecap="round"
/>
);
})}
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">
<span className="clay-caption">{t("report.categories.total")}</span>
<span className="max-w-[110px] break-words font-baloo text-sm font-bold text-clay-text">{formatCurrency(Number(data.totalExpense), data.currency)}</span>
<span className="text-[10px] font-semibold text-clay-text-muted">{t("report.categories.transactionCount", { count: data.transactionCount })}</span>
</div>
</div>
<div className="flex flex-col gap-2">
{data.categories.slice(0, chartColors.length).map((item, index) => (
<div key={item.category.id} className="flex items-center gap-3 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: chartColors[index] }} />
<div className="min-w-0 flex-1">
<p className="truncate font-nunito text-sm font-bold text-clay-text">{getCategoryDisplayName(item.category, t)}</p>
<p className="clay-caption">{t("report.categories.itemTransactions", { count: item.transactionCount })}</p>
</div>
<div className="shrink-0 text-right">
<p className="font-baloo text-sm font-bold text-clay-text">{formatNumber(Number(item.percentage), { maximumFractionDigits: 1 })}%</p>
<p className="text-[10px] font-semibold text-clay-text-muted">{formatCurrency(Number(item.amount), data.currency)}</p>
</div>
</div>
))}
</div>
</div>
</Card>
);
};
import React from "react";
import { Card } from "@/components/ui/Card";
import { ProgressBar } from "@/components/ui/ProgressBar";
import { useI18n } from "@/i18n";
import { BudgetTypeSummary, FinancialMetric, ReportWallet } from "@/types/report";
interface OverviewMetricsProps {
metric: FinancialMetric;
wallets: ReportWallet[];
budgetSummaries: BudgetTypeSummary[];
}
const safeNumber = (value: string | null | undefined): number => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
};
export const OverviewMetrics: React.FC<OverviewMetricsProps> = ({ metric, wallets, budgetSummaries }) => {
const { t, formatCurrency, formatNumber } = useI18n();
const money = (value: string) => formatCurrency(safeNumber(value), metric.currency);
const budget = budgetSummaries.find((summary) => summary.currency === metric.currency && summary.type === "OVERALL")
|| budgetSummaries.find((summary) => summary.currency === metric.currency);
const usage = safeNumber(budget?.usagePercentage);
const budgetType = usage > 100 ? "expense" : usage >= 80 ? "warning" : "income";
const metricCards = [
{ key: "balance", label: t("report.metrics.balance"), value: money(metric.currentBalance), tone: "bg-clay-info/15 text-clay-info", symbol: "=" },
{ key: "income", label: t("report.metrics.income"), value: money(metric.income), tone: "bg-clay-income/15 text-clay-income", symbol: "↑" },
{ key: "expense", label: t("report.metrics.expense"), value: money(metric.expense), tone: "bg-clay-expense/15 text-clay-expense", symbol: "↓" },
{ key: "saving", label: t("report.metrics.saving"), value: money(metric.netCashFlow), tone: safeNumber(metric.netCashFlow) >= 0 ? "bg-clay-primary/15 text-clay-primary" : "bg-clay-expense/15 text-clay-expense", symbol: "◆" },
];
return (
<section aria-labelledby="report-overview-heading" className="flex flex-col gap-3">
<div className="px-1">
<h2 id="report-overview-heading" className="clay-title-h2">{t("report.overview.title")}</h2>
<p className="clay-caption">{t("report.overview.subtitle", { currency: metric.currency })}</p>
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{metricCards.map((item) => (
<Card key={item.key} className="min-w-0 p-4">
<div className={`mb-3 flex h-9 w-9 items-center justify-center rounded-clay-sm font-baloo text-lg font-bold shadow-clay-pressed ${item.tone}`} aria-hidden="true">
{item.symbol}
</div>
<p className="font-nunito text-xs font-bold text-clay-text-muted">{item.label}</p>
<p className="mt-1 break-words font-baloo text-lg font-bold leading-tight text-clay-text">{item.value}</p>
</Card>
))}
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<Card className="p-5">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="clay-title-h3">{t("report.metrics.budgetUsage")}</h3>
<p className="clay-caption">{budget ? t("report.metrics.budgetCount", { count: budget.budgetCount }) : t("report.metrics.noBudget")}</p>
</div>
<span className="rounded-full bg-clay-warning/15 px-3 py-1 font-baloo text-sm font-bold text-clay-text">
{formatNumber(usage, { maximumFractionDigits: 1 })}%
</span>
</div>
<ProgressBar value={usage} type={budgetType} className="mt-4" />
{budget && (
<div className="mt-3 flex justify-between gap-3 text-xs font-semibold text-clay-text-muted">
<span>{t("report.metrics.spent")}: {money(budget.spentAmount)}</span>
<span className="text-right">{t("report.metrics.limit")}: {money(budget.budgetAmount)}</span>
</div>
)}
</Card>
<Card className="p-5">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="clay-title-h3">{t("report.metrics.financialHealth")}</h3>
<p className="clay-caption">{t("report.metrics.healthHint")}</p>
</div>
<span className="rounded-full bg-clay-primary/15 px-3 py-1 font-baloo text-sm font-bold text-clay-primary">
{metric.savingsRate === null ? "—" : `${formatNumber(safeNumber(metric.savingsRate), { maximumFractionDigits: 1 })}%`}
</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
<p className="clay-caption">{t("report.metrics.savingsRate")}</p>
<p className="mt-1 font-baloo text-lg font-bold text-clay-text">{metric.savingsRate === null ? "—" : `${formatNumber(safeNumber(metric.savingsRate), { maximumFractionDigits: 1 })}%`}</p>
</div>
<div className="rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
<p className="clay-caption">{t("report.metrics.transactions")}</p>
<p className="mt-1 font-baloo text-lg font-bold text-clay-text">{formatNumber(metric.transactionCount)}</p>
</div>
</div>
</Card>
</div>
{wallets.length > 1 && (
<Card className="p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h3 className="clay-title-h3">{t("report.wallets.title")}</h3>
<p className="clay-caption">{t("report.wallets.subtitle", { currency: metric.currency })}</p>
</div>
<span className="rounded-full bg-clay-info/15 px-3 py-1 font-nunito text-xs font-bold text-clay-info">{wallets.length}</span>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{wallets.map((wallet) => (
<div key={wallet.id} className="flex items-center justify-between gap-3 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
<div className="min-w-0">
<p className="truncate font-nunito text-sm font-bold text-clay-text">{wallet.name}</p>
<p className="clay-caption">{wallet.isDefault ? t("report.wallets.default") : wallet.currency}</p>
</div>
<p className="shrink-0 font-baloo text-sm font-bold text-clay-text">{money(wallet.balance)}</p>
</div>
))}
</div>
</Card>
)}
</section>
);
};
import React from "react";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { Select } from "@/components/ui/Select";
import { Tabs } from "@/components/ui/Tabs";
import { useI18n } from "@/i18n";
import { ReportPeriodPreset } from "@/types/report";
import { Wallet } from "@/types/wallet";
interface ReportFiltersProps {
period: ReportPeriodPreset;
dateFrom: string;
dateTo: string;
walletId: string;
currency: string;
wallets: Wallet[];
isLoadingWallets: boolean;
dateError?: string;
onPeriodChange: (period: ReportPeriodPreset) => void;
onDateFromChange: (value: string) => void;
onDateToChange: (value: string) => void;
onWalletChange: (value: string) => void;
onCurrencyChange: (value: string) => void;
onReset: () => void;
}
export const ReportFilters: React.FC<ReportFiltersProps> = ({
period,
dateFrom,
dateTo,
walletId,
currency,
wallets,
isLoadingWallets,
dateError,
onPeriodChange,
onDateFromChange,
onDateToChange,
onWalletChange,
onCurrencyChange,
onReset,
}) => {
const { t } = useI18n();
const currencies = Array.from(new Set(wallets.map((wallet) => wallet.currency))).sort();
return (
<Card className="flex flex-col gap-4 p-4">
<div className="flex items-center justify-between gap-3 px-1">
<div>
<h2 className="clay-title-h3">{t("report.filters.title")}</h2>
<p className="clay-caption">{t("report.filters.hint")}</p>
</div>
{(period !== "MONTH" || walletId || currency) && (
<Button variant="ghost" className="shrink-0 px-3 text-sm" onClick={onReset}>
{t("report.filters.reset")}
</Button>
)}
</div>
<Tabs
activeTab={period}
onChange={(key) => onPeriodChange(key as ReportPeriodPreset)}
tabs={(["DAY", "WEEK", "MONTH", "YEAR", "CUSTOM"] as const).map((key) => ({
key,
label: t(`report.period.${key.toLowerCase()}`),
}))}
/>
{period === "CUSTOM" && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Input
type="date"
label={t("report.filters.dateFrom")}
value={dateFrom}
max={dateTo || undefined}
error={dateError}
onChange={(event) => onDateFromChange(event.target.value)}
/>
<Input
type="date"
label={t("report.filters.dateTo")}
value={dateTo}
min={dateFrom || undefined}
onChange={(event) => onDateToChange(event.target.value)}
/>
</div>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Select
label={t("report.filters.wallet")}
value={walletId}
disabled={isLoadingWallets}
onChange={(event) => onWalletChange(event.target.value)}
options={[
{ value: "", label: t("report.filters.allWallets") },
...wallets.map((wallet) => ({ value: wallet.id, label: `${wallet.name} · ${wallet.currency}` })),
]}
/>
<Select
label={t("report.filters.currency")}
value={currency}
disabled={Boolean(walletId) || isLoadingWallets}
onChange={(event) => onCurrencyChange(event.target.value)}
options={[
{ value: "", label: t("report.filters.allCurrencies") },
...currencies.map((code) => ({ value: code, label: code })),
]}
/>
</div>
</Card>
);
};
import React from "react";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { ReportIcon } from "@/components/ui/icons";
import { useI18n } from "@/i18n";
export const ReportSectionSkeleton: React.FC<{ rows?: number }> = ({ rows = 3 }) => (
<Card className="animate-pulse p-5" aria-hidden="true">
<div className="h-6 w-1/2 rounded-full bg-clay-text-muted/15" />
<div className="mt-2 h-3 w-2/3 rounded-full bg-clay-text-muted/10" />
<div className="mt-6 grid grid-cols-2 gap-3">
{Array.from({ length: rows }, (_, index) => (
<div key={index} className="h-20 rounded-clay bg-clay-bg shadow-clay-pressed" />
))}
</div>
</Card>
);
interface ReportErrorStateProps {
onRetry: () => void;
}
export const ReportErrorState: React.FC<ReportErrorStateProps> = ({ onRetry }) => {
const { t } = useI18n();
return (
<Card className="flex flex-col items-center gap-3 py-8 text-center" role="alert">
<div className="flex h-14 w-14 items-center justify-center rounded-clay bg-clay-expense/15 font-baloo text-2xl font-bold text-clay-expense shadow-clay-pressed">!</div>
<div>
<h3 className="clay-title-h3">{t("report.states.errorTitle")}</h3>
<p className="clay-caption mt-1 max-w-xs">{t("report.states.errorHint")}</p>
</div>
<Button variant="secondary" className="text-sm" onClick={onRetry}>{t("common.retry")}</Button>
</Card>
);
};
interface ReportEmptyStateProps {
title?: string;
hint?: string;
}
export const ReportEmptyState: React.FC<ReportEmptyStateProps> = ({ title, hint }) => {
const { t } = useI18n();
return (
<Card className="flex flex-col items-center gap-3 py-8 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-clay bg-clay-info/15 shadow-clay-pressed">
<ReportIcon size={30} />
</div>
<div>
<h3 className="clay-title-h3">{title || t("report.states.emptyTitle")}</h3>
<p className="clay-caption mt-1 max-w-xs">{hint || t("report.states.emptyHint")}</p>
</div>
</Card>
);
};
import React from "react";
import { Card } from "@/components/ui/Card";
import { ProgressBar } from "@/components/ui/ProgressBar";
import { useI18n } from "@/i18n";
import { SavingGoalSummary } from "@/types/report";
interface SavingGoalsAnalyticsProps {
summary: SavingGoalSummary;
currency: string;
onOpenGoals: () => void;
}
export const SavingGoalsAnalytics: React.FC<SavingGoalsAnalyticsProps> = ({ summary, currency, onOpenGoals }) => {
const { t, formatCurrency, formatNumber } = useI18n();
const totals = summary.byCurrency.find((item) => item.currency === currency);
const progress = Number(totals?.progressPercentage) || 0;
return (
<Card hoverable className="p-5" role="button" tabIndex={0} onClick={onOpenGoals} onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") onOpenGoals();
}}>
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="clay-title-h2">{t("report.goals.title")}</h2>
<p className="clay-caption">{t("report.goals.subtitle")}</p>
</div>
<span className="rounded-full bg-clay-income/15 px-3 py-1 font-nunito text-xs font-bold text-clay-income">
{t("report.goals.active", { count: summary.activeCount })}
</span>
</div>
<div className="mt-5 rounded-clay bg-clay-bg p-4 shadow-clay-pressed">
<div className="flex items-end justify-between gap-3">
<div>
<p className="clay-caption">{t("report.goals.saved")}</p>
<p className="mt-1 font-baloo text-xl font-bold text-clay-text">{formatCurrency(Number(totals?.savedAmount || 0), currency)}</p>
</div>
<span className="font-baloo text-lg font-bold text-clay-primary">{formatNumber(progress, { maximumFractionDigits: 1 })}%</span>
</div>
<ProgressBar value={progress} type="primary" className="mt-3" />
<div className="mt-3 flex justify-between gap-3 text-xs font-semibold text-clay-text-muted">
<span>{t("report.goals.periodContribution")}: {formatCurrency(Number(totals?.contributedInPeriod || 0), currency)}</span>
<span className="text-right">{t("report.goals.target")}: {formatCurrency(Number(totals?.targetAmount || 0), currency)}</span>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-2 text-center">
<div><p className="font-baloo text-lg font-bold text-clay-text">{summary.activeCount}</p><p className="clay-caption">{t("report.goals.activeLabel")}</p></div>
<div><p className="font-baloo text-lg font-bold text-clay-text">{summary.pausedCount}</p><p className="clay-caption">{t("report.goals.pausedLabel")}</p></div>
<div><p className="font-baloo text-lg font-bold text-clay-text">{summary.completedCount}</p><p className="clay-caption">{t("report.goals.completedLabel")}</p></div>
</div>
</Card>
);
};
This diff is collapsed.
import { apiClient } from "@/lib/api-client";
import {
BudgetPerformanceReport,
CashFlowReport,
FinancialOverview,
ReportQuery,
ReportResponse,
SpendingCategoryReport,
} from "@/types/report";
function ensureSuccess<T>(response: ReportResponse<T>): ReportResponse<T> {
if (!response.success) {
throw new Error(response.message || "Report request failed");
}
return response;
}
export const reportService = {
async getOverview(query: ReportQuery): Promise<ReportResponse<FinancialOverview>> {
const response = await apiClient.get<ReportResponse<FinancialOverview>>("/reports/overview", { params: query });
return ensureSuccess(response.data);
},
async getCashFlow(query: ReportQuery): Promise<ReportResponse<CashFlowReport>> {
const response = await apiClient.get<ReportResponse<CashFlowReport>>("/reports/cash-flow", { params: query });
return ensureSuccess(response.data);
},
async getSpendingByCategory(query: ReportQuery): Promise<ReportResponse<SpendingCategoryReport>> {
const response = await apiClient.get<ReportResponse<SpendingCategoryReport>>("/reports/spending-by-category", { params: query });
return ensureSuccess(response.data);
},
async getBudgetPerformance(query: ReportQuery): Promise<ReportResponse<BudgetPerformanceReport>> {
const response = await apiClient.get<ReportResponse<BudgetPerformanceReport>>("/reports/budget-performance", { params: query });
return ensureSuccess(response.data);
},
};
export type ReportPeriodPreset = "DAY" | "WEEK" | "MONTH" | "YEAR" | "CUSTOM";
export type ReportGranularity = "AUTO" | "HOUR" | "DAY" | "WEEK" | "MONTH" | "YEAR";
export type ResolvedReportGranularity = Exclude<ReportGranularity, "AUTO">;
export type BudgetPerformanceStatus = "ON_TRACK" | "NEAR_LIMIT" | "EXCEEDED";
export interface ReportQuery {
period: ReportPeriodPreset;
dateFrom?: string;
dateTo?: string;
timezoneOffsetMinutes: number;
walletId?: string;
currency?: string;
granularity: ReportGranularity;
}
export interface ReportPeriod {
preset: ReportPeriodPreset;
from: string;
to: string;
timezoneOffsetMinutes: number;
generatedAt: string;
}
export interface MoneyFlow {
currency: string;
income: string;
expense: string;
netCashFlow: string;
transactionCount: number;
}
export interface FinancialMetric extends MoneyFlow {
currentBalance: string;
savingsRate: string | null;
expenseToIncomeRatio: string | null;
}
export interface ReportWallet {
id: string;
name: string;
currency: string;
balance: string;
isDefault: boolean;
isArchived: boolean;
}
export interface BudgetTypeSummary {
currency: string;
type: "OVERALL" | "CATEGORY";
budgetCount: number;
budgetAmount: string;
spentAmount: string;
remainingAmount: string;
usagePercentage: string;
onTrackCount: number;
nearLimitCount: number;
exceededCount: number;
}
export interface SavingGoalCurrencySummary {
currency: string;
targetAmount: string;
savedAmount: string;
remainingAmount: string;
contributedInPeriod: string;
progressPercentage: string;
}
export interface SavingGoalSummary {
totalGoals: number;
activeCount: number;
pausedCount: number;
completedCount: number;
byCurrency: SavingGoalCurrencySummary[];
}
export interface FinancialOverview {
period: ReportPeriod;
metricsByCurrency: FinancialMetric[];
wallets: {
totalWallets: number;
archivedWallets: number;
items: ReportWallet[];
};
budgets: {
totalBudgets: number;
byType: BudgetTypeSummary[];
};
savingGoals: SavingGoalSummary;
}
export interface CashFlowBucket {
from: string;
to: string;
metricsByCurrency: MoneyFlow[];
}
export interface CashFlowReport {
period: ReportPeriod;
granularity: ResolvedReportGranularity;
totalsByCurrency: MoneyFlow[];
series: CashFlowBucket[];
}
export interface SpendingCategory {
category: {
id: string;
name: string;
icon: string | null;
color: string | null;
};
amount: string;
percentage: string;
transactionCount: number;
}
export interface SpendingCategoryCurrency {
currency: string;
totalExpense: string;
transactionCount: number;
categories: SpendingCategory[];
}
export interface SpendingCategoryReport {
period: ReportPeriod;
currencies: SpendingCategoryCurrency[];
}
export interface BudgetPerformanceItem {
id: string;
name: string;
type: "OVERALL" | "CATEGORY";
currency: string;
period: string;
category: SpendingCategory["category"] | null;
budgetAmount: string;
spentAmount: string;
remainingAmount: string;
usagePercentage: string;
transactionCount: number;
status: BudgetPerformanceStatus;
reportFrom: string;
reportTo: string;
budgetFrom: string;
budgetTo: string;
isArchived: boolean;
}
export interface BudgetPerformanceReport {
period: ReportPeriod;
summary: {
totalBudgets: number;
byType: BudgetTypeSummary[];
};
budgets: BudgetPerformanceItem[];
}
export interface ReportResponse<T> {
success: boolean;
message?: string;
data: T;
errors?: unknown[] | null;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment