Commit a8889304 authored by ThinhNC's avatar ThinhNC

Merge branch 'feature/budget-management-frontend' into 'develop'

feat(budgets): add budget management frontend

See merge request !13
parents ae6fccfd ce2a86b8
...@@ -16,6 +16,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th ...@@ -16,6 +16,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- **Điều khiển tiến độ tương tác**: Các thanh tiến độ cho phép người dùng điều chỉnh phải dùng cùng cấu trúc `−`/slider/`+` từ base component, hiển thị giá trị hiện tại rõ ràng và giữ label liên kết với slider. Hai nút dùng bề mặt, bóng đổ và transition Claymorphism; nút giảm/tăng phải disabled khi giá trị chạm giới hạn tương ứng. Quy tắc này không áp dụng cho `ProgressBar` chỉ dùng để hiển thị trạng thái. - **Điều khiển tiến độ tương tác**: Các thanh tiến độ cho phép người dùng điều chỉnh phải dùng cùng cấu trúc `−`/slider/`+` từ base component, hiển thị giá trị hiện tại rõ ràng và giữ label liên kết với slider. Hai nút dùng bề mặt, bóng đổ và transition Claymorphism; nút giảm/tăng phải disabled khi giá trị chạm giới hạn tương ứng. Quy tắc này không áp dụng cho `ProgressBar` chỉ dùng để hiển thị trạng thái.
- **Quản lý Routing**: Sử dụng cấu hình router của ZMP UI / React Router tích hợp bên trong template để dẫn hướng giữa các màn hình nghiệp vụ và `/style-guide`. - **Quản lý Routing**: Sử dụng cấu hình router của ZMP UI / React Router tích hợp bên trong template để dẫn hướng giữa các màn hình nghiệp vụ và `/style-guide`.
- **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.
- **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).
...@@ -44,7 +45,8 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th ...@@ -44,7 +45,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ý danh mục đã có frontend tại `/categories`, kết nối Category API qua TanStack Query, gồm cây cha/con, tạo/sửa, tìm kiếm, lọc loại/nguồn/trạng thái, sắp xếp, lưu trữ và khôi phục; toàn bộ nội dung có bản dịch Việt/Anh. - Module quản lý danh mục đã có frontend tại `/categories`, kết nối Category API qua TanStack Query, gồm cây cha/con, tạo/sửa, tìm kiếm, lọc loại/nguồn/trạng thái, sắp xếp, lưu trữ và khôi phục; toàn bộ nội dung có bản dịch Việt/Anh.
- 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.
- Các module ngân sách, tiết kiệm, báo cáo và AI Assistant chưa được triển khai trong frontend hiện tại. - 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.
- Các module tiết kiệm, báo cáo và AI Assistant chưa được triển khai trong frontend hiện tại.
## Khi cập nhật file này ## Khi cập nhật file này
......
...@@ -29,6 +29,8 @@ import WalletDetailPage from "@/pages/wallets/detail"; ...@@ -29,6 +29,8 @@ import WalletDetailPage from "@/pages/wallets/detail";
import CategoriesPage from "@/pages/categories/index"; import CategoriesPage from "@/pages/categories/index";
import TransactionsPage from "@/pages/transactions/index"; import TransactionsPage from "@/pages/transactions/index";
import TransfersPage from "@/pages/transfers/index"; import TransfersPage from "@/pages/transfers/index";
import BudgetsPage from "@/pages/budgets/index";
import BudgetDetailPage from "@/pages/budgets/detail";
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();
...@@ -92,6 +94,8 @@ const Layout = () => { ...@@ -92,6 +94,8 @@ const Layout = () => {
<Route path="/categories" element={<AuthGuard><CategoriesPage /></AuthGuard>}></Route> <Route path="/categories" element={<AuthGuard><CategoriesPage /></AuthGuard>}></Route>
<Route path="/transactions" element={<AuthGuard><TransactionsPage /></AuthGuard>}></Route> <Route path="/transactions" element={<AuthGuard><TransactionsPage /></AuthGuard>}></Route>
<Route path="/transfers" element={<AuthGuard><TransfersPage /></AuthGuard>}></Route> <Route path="/transfers" element={<AuthGuard><TransfersPage /></AuthGuard>}></Route>
<Route path="/budgets" element={<AuthGuard><BudgetsPage /></AuthGuard>}></Route>
<Route path="/budgets/:id" element={<AuthGuard><BudgetDetailPage /></AuthGuard>}></Route>
<Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route> <Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route>
</AnimationRoutes> </AnimationRoutes>
</AuthInitializer> </AuthInitializer>
......
...@@ -19,6 +19,8 @@ const PAGE_TITLES: ReadonlyArray<{ ...@@ -19,6 +19,8 @@ const PAGE_TITLES: ReadonlyArray<{
{ matches: (pathname) => pathname === "/categories", key: "document.categories" }, { matches: (pathname) => pathname === "/categories", key: "document.categories" },
{ matches: (pathname) => pathname === "/transactions", key: "document.transactions" }, { matches: (pathname) => pathname === "/transactions", key: "document.transactions" },
{ matches: (pathname) => pathname === "/transfers", key: "document.transfers" }, { matches: (pathname) => pathname === "/transfers", key: "document.transfers" },
{ matches: (pathname) => pathname === "/budgets", key: "document.budgets" },
{ matches: (pathname) => pathname.startsWith("/budgets/"), key: "document.budgetDetail" },
{ matches: (pathname) => pathname === "/style-guide", key: "document.styleGuide" }, { matches: (pathname) => pathname === "/style-guide", key: "document.styleGuide" },
]; ];
......
...@@ -11,11 +11,12 @@ export interface ProgressBarProps { ...@@ -11,11 +11,12 @@ export interface ProgressBarProps {
export const ProgressBar: React.FC<ProgressBarProps> = ({ export const ProgressBar: React.FC<ProgressBarProps> = ({
value, value,
type = "primary", type = "primary",
height = "h-4.5", height = "h-4",
className = "", className = "",
}) => { }) => {
// Cap value between 0 and 100 // Keep malformed API values from producing an invalid inline width.
const percentage = Math.max(0, Math.min(100, value)); const normalizedValue = Number.isFinite(value) ? value : 0;
const percentage = Math.max(0, Math.min(100, normalizedValue));
// Gradient styles corresponding to semantic types // Gradient styles corresponding to semantic types
const gradientStyles = { const gradientStyles = {
...@@ -29,6 +30,10 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({ ...@@ -29,6 +30,10 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({
return ( return (
<div className={`w-full flex flex-col gap-1.5 ${className}`}> <div className={`w-full flex flex-col gap-1.5 ${className}`}>
<div <div
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percentage}
className={` className={`
w-full bg-clay-bg rounded-full shadow-clay-pressed overflow-hidden p-0.5 border border-clay-text/5 w-full bg-clay-bg rounded-full shadow-clay-pressed overflow-hidden p-0.5 border border-clay-text/5
${height} ${height}
......
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { budgetService } from "@/services/budget.service";
import { BudgetQuery, CreateBudgetInput, UpdateBudgetInput } from "@/types/budget";
export const budgetKeys = {
all: ["budgets"] as const,
lists: () => [...budgetKeys.all, "list"] as const,
list: (query: BudgetQuery) => [...budgetKeys.lists(), query] as const,
details: () => [...budgetKeys.all, "detail"] as const,
detail: (id: string) => [...budgetKeys.details(), id] as const,
};
export function useBudgets(query: BudgetQuery) {
return useQuery({
queryKey: budgetKeys.list(query),
queryFn: () => budgetService.getBudgets(query),
placeholderData: keepPreviousData,
staleTime: 30_000,
refetchOnMount: "always",
refetchOnWindowFocus: true,
});
}
export function useBudget(id: string) {
return useQuery({
queryKey: budgetKeys.detail(id),
queryFn: () => budgetService.getBudget(id),
enabled: Boolean(id),
staleTime: 30_000,
refetchOnMount: "always",
refetchOnWindowFocus: true,
});
}
export function useCreateBudget() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateBudgetInput) => budgetService.createBudget(input),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: budgetKeys.all });
},
});
}
export function useUpdateBudget(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: UpdateBudgetInput) => budgetService.updateBudget(id, input),
onSuccess: async (response) => {
queryClient.setQueryData(budgetKeys.detail(id), response);
await queryClient.invalidateQueries({ queryKey: budgetKeys.lists() });
},
});
}
function useBudgetAction(action: (id: string) => Promise<unknown>) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: action,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: budgetKeys.all });
},
});
}
export function useArchiveBudget() {
return useBudgetAction(budgetService.archiveBudget);
}
export function useRestoreBudget() {
return useBudgetAction(budgetService.restoreBudget);
}
...@@ -2,6 +2,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tansta ...@@ -2,6 +2,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tansta
import { transactionService } from "@/services/transaction.service"; import { transactionService } from "@/services/transaction.service";
import { CreateTransactionInput, TransactionQuery, UpdateTransactionInput } from "@/types/transaction"; import { CreateTransactionInput, TransactionQuery, UpdateTransactionInput } from "@/types/transaction";
import { walletKeys } from "./use-wallets"; import { walletKeys } from "./use-wallets";
import { budgetKeys } from "./use-budgets";
export const transactionKeys = { export const transactionKeys = {
all: ["transactions"] as const, all: ["transactions"] as const,
...@@ -37,6 +38,7 @@ export function useCreateTransaction() { ...@@ -37,6 +38,7 @@ export function useCreateTransaction() {
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: transactionKeys.all }), queryClient.invalidateQueries({ queryKey: transactionKeys.all }),
queryClient.invalidateQueries({ queryKey: walletKeys.all }), queryClient.invalidateQueries({ queryKey: walletKeys.all }),
queryClient.invalidateQueries({ queryKey: budgetKeys.all }),
]); ]);
}, },
}); });
...@@ -51,6 +53,7 @@ export function useUpdateTransaction(id: string) { ...@@ -51,6 +53,7 @@ export function useUpdateTransaction(id: string) {
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: transactionKeys.lists() }), queryClient.invalidateQueries({ queryKey: transactionKeys.lists() }),
queryClient.invalidateQueries({ queryKey: walletKeys.all }), queryClient.invalidateQueries({ queryKey: walletKeys.all }),
queryClient.invalidateQueries({ queryKey: budgetKeys.all }),
]); ]);
}, },
}); });
...@@ -64,6 +67,7 @@ export function useDeleteTransaction() { ...@@ -64,6 +67,7 @@ export function useDeleteTransaction() {
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: transactionKeys.all }), queryClient.invalidateQueries({ queryKey: transactionKeys.all }),
queryClient.invalidateQueries({ queryKey: walletKeys.all }), queryClient.invalidateQueries({ queryKey: walletKeys.all }),
queryClient.invalidateQueries({ queryKey: budgetKeys.all }),
]); ]);
}, },
}); });
......
...@@ -46,7 +46,9 @@ ...@@ -46,7 +46,9 @@
"categories": "Categories", "categories": "Categories",
"styleGuide": "Style Guide", "styleGuide": "Style Guide",
"transactions": "Transactions", "transactions": "Transactions",
"transfers": "Transfers" "transfers": "Transfers",
"budgets": "Budget Management",
"budgetDetail": "Budget Details"
}, },
"validation": { "validation": {
"emailRequired": "Email is required", "emailRequired": "Email is required",
...@@ -89,7 +91,15 @@ ...@@ -89,7 +91,15 @@
"transferDateRequired": "Please select the transfer date and time", "transferDateRequired": "Please select the transfer date and time",
"transferWalletsDifferent": "Source and destination wallets must be different", "transferWalletsDifferent": "Source and destination wallets must be different",
"transferInsufficientBalance": "The source wallet does not have enough balance", "transferInsufficientBalance": "The source wallet does not have enough balance",
"transferCurrencyMismatch": "Source and destination wallets must use the same currency" "transferCurrencyMismatch": "Source and destination wallets must use the same currency",
"budgetNameRequired": "Please enter a budget name",
"budgetNameMax": "Budget name cannot exceed 100 characters",
"budgetAmountRequired": "Please enter a budget limit",
"budgetAmountInvalid": "Limit must be greater than zero with at most 2 decimal places",
"budgetCategoryRequired": "Please select an expense category",
"budgetStartDateRequired": "Please select a start date",
"budgetEndDateRequired": "Please select an end date",
"budgetEndDateAfterStart": "End date must be after start date"
}, },
"auth": { "auth": {
"email": "Email", "email": "Email",
...@@ -172,7 +182,8 @@ ...@@ -172,7 +182,8 @@
"profile": "My profile", "profile": "My profile",
"styleGuide": "Explore Style Guide", "styleGuide": "Explore Style Guide",
"transactions": "Manage transactions", "transactions": "Manage transactions",
"transfers": "Transfer between wallets" "transfers": "Transfer between wallets",
"budgets": "Manage spending budgets"
}, },
"profile": { "profile": {
"header": "Account", "header": "Account",
...@@ -417,6 +428,135 @@ ...@@ -417,6 +428,135 @@
"otherExpense": "Other Expense" "otherExpense": "Other Expense"
} }
}, },
"budget": {
"header": "Budget Management",
"detailHeader": "Budget Details",
"overview": "Budget overview",
"resultCount": "budgets in the current view",
"alertCount": "{{count}} alerts on this page",
"list": "Budget list",
"syncing": "Syncing...",
"loading": "Loading budget list",
"loadFailed": "Could not load budgets",
"connectionFailed": "Check your connection and try again.",
"detailLoadFailed": "Could not load budget details",
"missing": "The budget does not exist or the connection was interrupted.",
"notFound": "No matching budgets found",
"empty": "No budgets yet",
"filteredHint": "Try a different keyword, category, or date range.",
"emptyHint": "Create your first budget to take control of spending.",
"create": "Create budget",
"createShort": "New budget",
"createFirst": "Create first budget",
"createSuccess": "Budget created successfully.",
"createFailed": "Could not create the budget. Check the details and try again.",
"updateSuccess": "Budget changes saved.",
"updateFailed": "Could not update the budget.",
"overall": "All expenses",
"limit": "Limit",
"spent": "Spent",
"remaining": "Remaining",
"usage": "Budget usage",
"alertThreshold": "Alerts from {{threshold}}% of the limit",
"status": "Time status",
"dateRange": "Date range",
"transactionCount": "Expense transactions",
"lastExpense": "Latest expense",
"noExpense": "None yet",
"info": "Budget information",
"management": "Manage budget",
"edit": "Edit budget",
"archive": "Archive budget",
"restore": "Restore budget",
"archiveHint": "Archiving is a soft delete: budget data and spending history remain available.",
"restoreHint": "The budget will return to the active list and resume tracking spending.",
"archiveTitle": "Archive budget?",
"archiveConfirm": "Confirm archive",
"archiveDescription": "“{{name}}” will be removed from the active list, while its data remains available for reports and can be restored at any time.",
"archiveSuccess": "Budget archived; historical data remains available.",
"archiveFailed": "Could not archive the budget.",
"restoreTitle": "Restore budget?",
"restoreConfirm": "Confirm restore",
"restoreDescription": "“{{name}}” will resume tracking spending. Its linked category must still be active.",
"restoreSuccess": "Budget restored successfully.",
"restoreFailed": "Could not restore the budget. Check its linked category.",
"listButton": "Budget list",
"paginationLabel": "Budget pagination",
"type": {
"all": "All",
"category": "By category",
"overall": "Overall spending"
},
"period": {
"all": "All periods",
"weekly": "Weekly",
"monthly": "Monthly",
"yearly": "Yearly",
"custom": "Custom"
},
"usageStatus": {
"on_track": "On track",
"near_limit": "Near limit",
"exceeded": "Over limit"
},
"timeStatus": {
"upcoming": "Upcoming",
"active": "Active",
"ended": "Ended"
},
"alert": {
"nearTitle": "Budget is nearing its limit",
"nearDescription": "Spending has reached the {{threshold}}% alert threshold. Consider your next expenses carefully.",
"exceededTitle": "Budget limit exceeded",
"exceededDescription": "Spending is over the limit. Review recent transactions or adjust your budget plan."
},
"filters": {
"searchLabel": "Search budgets",
"searchPlaceholder": "Search by budget or category name...",
"period": "Period",
"category": "Expense category",
"allCategories": "All categories",
"time": "Active time",
"allTime": "Any time",
"current": "Active now",
"specificDate": "Active on a date",
"activeDate": "Date to check",
"sort": "Sort by",
"includeArchived": "Show archived budgets",
"includeArchivedHint": "Archived budgets are read-only until restored.",
"reset": "Clear filters"
},
"sort": {
"startDesc": "Latest start date",
"endAsc": "Ending soon",
"amountDesc": "Highest limit",
"amountAsc": "Lowest limit",
"nameAsc": "Name A → Z",
"updatedDesc": "Recently updated"
},
"form": {
"createTitle": "Create budget",
"editTitle": "Edit budget",
"saveChanges": "Save changes",
"name": "Budget name *",
"namePlaceholder": "For example: Dining this month",
"amount": "Limit *",
"amountPlaceholder": "Enter an amount...",
"currency": "Currency *",
"type": "Budget type *",
"period": "Period *",
"category": "Expense category *",
"selectCategory": "Select a category...",
"categoryLoadFailed": "Could not load categories. Close the form and try again.",
"startDate": "Start date *",
"endDate": "End date *",
"autoEndDateHint": "The system calculates the end date from the selected period.",
"alertThreshold": "Alert threshold",
"alertThresholdHint": "Used by budget alerts and notifications.",
"decreaseThreshold": "Decrease alert threshold",
"increaseThreshold": "Increase alert threshold"
}
},
"styleGuide": { "styleGuide": {
"header": "Style Guide & Design System", "header": "Style Guide & Design System",
"title": "Claymorphism Theme System", "title": "Claymorphism Theme System",
......
...@@ -46,7 +46,9 @@ ...@@ -46,7 +46,9 @@
"categories": "Quản lý danh mục", "categories": "Quản lý danh mục",
"styleGuide": "Style Guide", "styleGuide": "Style Guide",
"transactions": "Quản lý giao dịch", "transactions": "Quản lý giao dịch",
"transfers": "Quản lý chuyển tiền" "transfers": "Quản lý chuyển tiền",
"budgets": "Quản lý ngân sách",
"budgetDetail": "Chi tiết ngân sách"
}, },
"validation": { "validation": {
"emailRequired": "Email không được để trống", "emailRequired": "Email không được để trống",
...@@ -89,7 +91,15 @@ ...@@ -89,7 +91,15 @@
"transferDateRequired": "Vui lòng chọn ngày giờ chuyển tiền", "transferDateRequired": "Vui lòng chọn ngày giờ chuyển tiền",
"transferWalletsDifferent": "Ví nguồn và ví đích phải khác nhau", "transferWalletsDifferent": "Ví nguồn và ví đích phải khác nhau",
"transferInsufficientBalance": "Số dư ví nguồn không đủ để thực hiện giao dịch", "transferInsufficientBalance": "Số dư ví nguồn không đủ để thực hiện giao dịch",
"transferCurrencyMismatch": "Ví nguồn và ví đích phải sử dụng cùng loại tiền tệ" "transferCurrencyMismatch": "Ví nguồn và ví đích phải sử dụng cùng loại tiền tệ",
"budgetNameRequired": "Vui lòng nhập tên ngân sách",
"budgetNameMax": "Tên ngân sách tối đa 100 ký tự",
"budgetAmountRequired": "Vui lòng nhập hạn mức ngân sách",
"budgetAmountInvalid": "Hạn mức phải lớn hơn 0 và có tối đa 2 chữ số thập phân",
"budgetCategoryRequired": "Vui lòng chọn danh mục chi tiêu",
"budgetStartDateRequired": "Vui lòng chọn ngày bắt đầu",
"budgetEndDateRequired": "Vui lòng chọn ngày kết thúc",
"budgetEndDateAfterStart": "Ngày kết thúc phải sau ngày bắt đầu"
}, },
"auth": { "auth": {
"email": "Email", "email": "Email",
...@@ -172,7 +182,8 @@ ...@@ -172,7 +182,8 @@
"profile": "Trang cá nhân của tôi", "profile": "Trang cá nhân của tôi",
"styleGuide": "Khám phá Style Guide", "styleGuide": "Khám phá Style Guide",
"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"
}, },
"profile": { "profile": {
"header": "Tài Khoản", "header": "Tài Khoản",
...@@ -426,6 +437,135 @@ ...@@ -426,6 +437,135 @@
"otherExpense": "Chi tiêu khác" "otherExpense": "Chi tiêu khác"
} }
}, },
"budget": {
"header": "Quản lý ngân sách",
"detailHeader": "Chi tiết ngân sách",
"overview": "Tổng quan ngân sách",
"resultCount": "ngân sách theo bộ lọc hiện tại",
"alertCount": "{{count}} cảnh báo trên trang này",
"list": "Danh sách ngân sách",
"syncing": "Đang đồng bộ...",
"loading": "Đang tải danh sách ngân sách",
"loadFailed": "Không thể tải ngân sách",
"connectionFailed": "Kiểm tra kết nối và thử lại.",
"detailLoadFailed": "Không thể tải chi tiết ngân sách",
"missing": "Ngân sách không tồn tại hoặc kết nối bị gián đoạn.",
"notFound": "Không tìm thấy ngân sách phù hợp",
"empty": "Chưa có ngân sách",
"filteredHint": "Hãy thử từ khóa, danh mục hoặc khoảng thời gian khác.",
"emptyHint": "Tạo ngân sách đầu tiên để chủ động kiểm soát chi tiêu.",
"create": "Tạo ngân sách",
"createShort": "Tạo mới",
"createFirst": "Tạo ngân sách đầu tiên",
"createSuccess": "Đã tạo ngân sách thành công.",
"createFailed": "Không thể tạo ngân sách. Vui lòng kiểm tra thông tin và thử lại.",
"updateSuccess": "Đã lưu thay đổi ngân sách.",
"updateFailed": "Không thể cập nhật ngân sách.",
"overall": "Toàn bộ chi tiêu",
"limit": "Hạn mức",
"spent": "Đã chi",
"remaining": "Còn lại",
"usage": "Tỷ lệ sử dụng",
"alertThreshold": "Cảnh báo từ {{threshold}}% hạn mức",
"status": "Trạng thái thời gian",
"dateRange": "Khoảng thời gian",
"transactionCount": "Số giao dịch chi",
"lastExpense": "Chi tiêu gần nhất",
"noExpense": "Chưa có",
"info": "Thông tin ngân sách",
"management": "Quản lý ngân sách",
"edit": "Chỉnh sửa ngân sách",
"archive": "Lưu trữ ngân sách",
"restore": "Khôi phục ngân sách",
"archiveHint": "Lưu trữ là thao tác xóa mềm: dữ liệu và lịch sử chi tiêu vẫn được giữ lại.",
"restoreHint": "Ngân sách sẽ trở lại danh sách hoạt động và tiếp tục theo dõi chi tiêu.",
"archiveTitle": "Lưu trữ ngân sách?",
"archiveConfirm": "Xác nhận lưu trữ",
"archiveDescription": "“{{name}}” sẽ bị xóa khỏi danh sách hoạt động nhưng vẫn giữ nguyên dữ liệu để báo cáo và có thể khôi phục bất cứ lúc nào.",
"archiveSuccess": "Đã lưu trữ ngân sách; dữ liệu lịch sử vẫn được bảo toàn.",
"archiveFailed": "Không thể lưu trữ ngân sách.",
"restoreTitle": "Khôi phục ngân sách?",
"restoreConfirm": "Xác nhận khôi phục",
"restoreDescription": "“{{name}}” sẽ tiếp tục theo dõi chi tiêu. Danh mục liên kết phải còn hoạt động.",
"restoreSuccess": "Đã khôi phục ngân sách.",
"restoreFailed": "Không thể khôi phục ngân sách. Hãy kiểm tra danh mục liên kết.",
"listButton": "Danh sách ngân sách",
"paginationLabel": "Phân trang ngân sách",
"type": {
"all": "Tất cả",
"category": "Theo danh mục",
"overall": "Tổng chi tiêu"
},
"period": {
"all": "Mọi chu kỳ",
"weekly": "Hàng tuần",
"monthly": "Hàng tháng",
"yearly": "Hàng năm",
"custom": "Tùy chỉnh"
},
"usageStatus": {
"on_track": "Trong hạn mức",
"near_limit": "Sắp chạm hạn mức",
"exceeded": "Vượt hạn mức"
},
"timeStatus": {
"upcoming": "Sắp bắt đầu",
"active": "Đang diễn ra",
"ended": "Đã kết thúc"
},
"alert": {
"nearTitle": "Ngân sách sắp đạt giới hạn",
"nearDescription": "Chi tiêu đã đạt ngưỡng cảnh báo {{threshold}}%. Hãy cân nhắc các khoản chi tiếp theo.",
"exceededTitle": "Ngân sách đã vượt giới hạn",
"exceededDescription": "Chi tiêu đã vượt hạn mức. Hãy kiểm tra các giao dịch hoặc điều chỉnh kế hoạch ngân sách."
},
"filters": {
"searchLabel": "Tìm kiếm ngân sách",
"searchPlaceholder": "Tìm theo tên ngân sách hoặc danh mục...",
"period": "Chu kỳ",
"category": "Danh mục chi tiêu",
"allCategories": "Tất cả danh mục",
"time": "Thời gian hiệu lực",
"allTime": "Mọi thời gian",
"current": "Đang hiệu lực hiện tại",
"specificDate": "Hiệu lực tại một ngày",
"activeDate": "Ngày cần kiểm tra",
"sort": "Sắp xếp",
"includeArchived": "Hiển thị ngân sách đã lưu trữ",
"includeArchivedHint": "Ngân sách lưu trữ chỉ đọc cho đến khi được khôi phục.",
"reset": "Xóa bộ lọc"
},
"sort": {
"startDesc": "Bắt đầu gần nhất",
"endAsc": "Sắp kết thúc",
"amountDesc": "Hạn mức cao nhất",
"amountAsc": "Hạn mức thấp nhất",
"nameAsc": "Tên A → Z",
"updatedDesc": "Mới cập nhật"
},
"form": {
"createTitle": "Tạo ngân sách",
"editTitle": "Chỉnh sửa ngân sách",
"saveChanges": "Lưu thay đổi",
"name": "Tên ngân sách *",
"namePlaceholder": "Ví dụ: Ăn uống tháng này",
"amount": "Hạn mức *",
"amountPlaceholder": "Nhập số tiền...",
"currency": "Tiền tệ *",
"type": "Loại ngân sách *",
"period": "Chu kỳ *",
"category": "Danh mục chi tiêu *",
"selectCategory": "Chọn danh mục...",
"categoryLoadFailed": "Không thể tải danh mục. Vui lòng đóng biểu mẫu và thử lại.",
"startDate": "Ngày bắt đầu *",
"endDate": "Ngày kết thúc *",
"autoEndDateHint": "Ngày kết thúc sẽ được hệ thống tự tính theo chu kỳ đã chọn.",
"alertThreshold": "Ngưỡng cảnh báo",
"alertThresholdHint": "Dùng cho cảnh báo và thông báo ngân sách.",
"decreaseThreshold": "Giảm ngưỡng cảnh báo",
"increaseThreshold": "Tăng ngưỡng cảnh báo"
}
},
"styleGuide": { "styleGuide": {
"header": "Style Guide & Design System", "header": "Style Guide & Design System",
"title": "Hệ thống giao diện Claymorphism", "title": "Hệ thống giao diện Claymorphism",
......
import React from "react";
import { CategoryArtwork } from "@/components/shared/CategoryArtwork";
import { Badge, BadgeType } from "@/components/ui/Badge";
import { BudgetIcon } from "@/components/ui/icons";
import { Card } from "@/components/ui/Card";
import { ProgressBar } from "@/components/ui/ProgressBar";
import { useI18n } from "@/i18n";
import { getCategoryDisplayName } from "@/lib/category-format";
import { Budget, BudgetUsageStatus } from "@/types/budget";
interface BudgetCardProps {
budget: Budget;
onClick: () => void;
}
const statusStyles: Record<BudgetUsageStatus, { badge: BadgeType; progress: BadgeType }> = {
ON_TRACK: { badge: "income", progress: "income" },
NEAR_LIMIT: { badge: "warning", progress: "warning" },
EXCEEDED: { badge: "expense", progress: "expense" },
};
export const BudgetCard: React.FC<BudgetCardProps> = ({ budget, onClick }) => {
const { formatCurrency, formatDate, formatNumber, t } = useI18n();
const usagePercentage = Number(budget.usage.usagePercentage);
const statusStyle = statusStyles[budget.usage.status];
const categoryName = budget.category
? getCategoryDisplayName(budget.category, t)
: t("budget.overall");
return (
<Card
hoverable
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onClick();
}
}}
className={`p-5 ${budget.isArchived ? "opacity-70" : ""}`}
>
<div className="flex items-start gap-3">
{budget.category ? (
<CategoryArtwork
icon={budget.category.icon}
color={budget.category.color}
archived={budget.isArchived}
/>
) : (
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-clay bg-clay-warning/20 shadow-clay-raised">
<BudgetIcon size={28} />
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate font-baloo text-lg font-bold text-clay-text">{budget.name}</h3>
{budget.isArchived && <Badge type="info">{t("common.archived")}</Badge>}
</div>
<p className="clay-caption truncate">{categoryName} · {t(`budget.period.${budget.period.toLowerCase()}`)}</p>
<p className="mt-1 font-nunito text-xs font-semibold text-clay-text-muted">
{formatDate(budget.startDate, { day: "2-digit", month: "2-digit", year: "numeric" })}
{" – "}
{formatDate(budget.endDate, { day: "2-digit", month: "2-digit", year: "numeric" })}
</p>
</div>
<Badge type={statusStyle.badge}>{t(`budget.usageStatus.${budget.usage.status.toLowerCase()}`)}</Badge>
</div>
<div className="mt-5 grid grid-cols-3 gap-2">
<div className="rounded-clay-sm bg-clay-bg p-2.5 shadow-clay-pressed">
<p className="font-nunito text-[11px] font-bold text-clay-text-muted">{t("budget.limit")}</p>
<p className="mt-1 truncate font-baloo text-sm font-bold text-clay-text">
{formatCurrency(Number(budget.amount), budget.currency)}
</p>
</div>
<div className="rounded-clay-sm bg-clay-expense/10 p-2.5 shadow-clay-pressed">
<p className="font-nunito text-[11px] font-bold text-clay-text-muted">{t("budget.spent")}</p>
<p className="mt-1 truncate font-baloo text-sm font-bold text-clay-expense">
{formatCurrency(Number(budget.usage.spentAmount), budget.currency)}
</p>
</div>
<div className="rounded-clay-sm bg-clay-income/10 p-2.5 shadow-clay-pressed">
<p className="font-nunito text-[11px] font-bold text-clay-text-muted">{t("budget.remaining")}</p>
<p className={`mt-1 truncate font-baloo text-sm font-bold ${Number(budget.usage.remainingAmount) < 0 ? "text-clay-expense" : "text-clay-income"}`}>
{formatCurrency(Number(budget.usage.remainingAmount), budget.currency)}
</p>
</div>
</div>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="font-nunito text-xs font-bold text-clay-text-muted">{t("budget.usage")}</span>
<span className={`font-baloo text-base font-bold ${budget.usage.status === "EXCEEDED" ? "text-clay-expense" : budget.usage.status === "NEAR_LIMIT" ? "text-clay-warning" : "text-clay-text"}`}>
{formatNumber(usagePercentage, { maximumFractionDigits: 1 })}%
</span>
</div>
<ProgressBar value={usagePercentage} type={statusStyle.progress} />
</div>
</Card>
);
};
This diff is collapsed.
import React from "react";
import { Card } from "@/components/ui/Card";
export const BudgetSkeleton: React.FC = () => (
<Card className="animate-pulse p-5" aria-hidden="true">
<div className="flex items-start gap-3">
<div className="h-12 w-12 shrink-0 rounded-clay bg-clay-text-muted/15 shadow-clay-pressed" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-5 w-2/3 rounded-full bg-clay-text-muted/15" />
<div className="h-3 w-1/2 rounded-full bg-clay-text-muted/10" />
</div>
<div className="h-6 w-16 rounded-full bg-clay-warning/15" />
</div>
<div className="mt-5 grid grid-cols-3 gap-2">
{Array.from({ length: 3 }, (_, index) => (
<div key={index} className="h-14 rounded-clay-sm bg-clay-bg shadow-clay-pressed" />
))}
</div>
<div className="mt-4 h-4 rounded-full bg-clay-bg shadow-clay-pressed" />
</Card>
);
This diff is collapsed.
This diff is collapsed.
...@@ -74,6 +74,13 @@ function HomePage() { ...@@ -74,6 +74,13 @@ function HomePage() {
> >
{t("home.transfers")} {t("home.transfers")}
</Button> </Button>
<Button
variant="secondary"
fullWidth
onClick={() => navigate("/budgets")}
>
{t("home.budgets")}
</Button>
<Button <Button
variant="secondary" variant="secondary"
fullWidth fullWidth
......
import { apiClient } from "@/lib/api-client";
import {
BudgetListResponse,
BudgetQuery,
BudgetResponse,
CreateBudgetInput,
UpdateBudgetInput,
} from "@/types/budget";
function ensureSuccess<T extends { success: boolean; message?: string }>(response: T): T {
if (!response.success) {
throw new Error(response.message || "Budget request failed");
}
return response;
}
export const budgetService = {
async getBudgets(query: BudgetQuery): Promise<BudgetListResponse> {
const response = await apiClient.get<BudgetListResponse>("/budgets", { params: query });
return ensureSuccess(response.data);
},
async getBudget(id: string): Promise<BudgetResponse> {
const response = await apiClient.get<BudgetResponse>(`/budgets/${id}`);
return ensureSuccess(response.data);
},
async createBudget(input: CreateBudgetInput): Promise<BudgetResponse> {
const response = await apiClient.post<BudgetResponse>("/budgets", input);
return ensureSuccess(response.data);
},
async updateBudget(id: string, input: UpdateBudgetInput): Promise<BudgetResponse> {
const response = await apiClient.put<BudgetResponse>(`/budgets/${id}`, input);
return ensureSuccess(response.data);
},
async archiveBudget(id: string): Promise<BudgetResponse> {
const response = await apiClient.delete<BudgetResponse>(`/budgets/${id}`);
return ensureSuccess(response.data);
},
async restoreBudget(id: string): Promise<BudgetResponse> {
const response = await apiClient.patch<BudgetResponse>(`/budgets/${id}/restore`);
return ensureSuccess(response.data);
},
};
import { PaginationMeta } from "./wallet";
export type BudgetType = "OVERALL" | "CATEGORY";
export type BudgetPeriod = "CUSTOM" | "WEEKLY" | "MONTHLY" | "YEARLY";
export type BudgetTimeStatus = "UPCOMING" | "ACTIVE" | "ENDED";
export type BudgetUsageStatus = "ON_TRACK" | "NEAR_LIMIT" | "EXCEEDED";
export type BudgetSortField =
| "name"
| "amount"
| "startDate"
| "endDate"
| "createdAt"
| "updatedAt";
export type BudgetSortOrder = "asc" | "desc";
export interface BudgetCategory {
id: string;
name: string;
type: "EXPENSE";
icon: string | null;
color: string | null;
}
export interface BudgetUsage {
spentAmount: string;
remainingAmount: string;
usagePercentage: string;
transactionCount: number;
lastTransactionAt: string | null;
timeStatus: BudgetTimeStatus;
status: BudgetUsageStatus;
}
export interface Budget {
id: string;
name: string;
amount: string;
currency: string;
type: BudgetType;
period: BudgetPeriod;
categoryId: string | null;
startDate: string;
endDate: string;
alertThreshold: string;
isArchived: boolean;
createdAt: string;
updatedAt: string;
category: BudgetCategory | null;
usage: BudgetUsage;
}
export interface BudgetQuery {
search?: string;
type?: BudgetType;
period?: BudgetPeriod;
currency?: string;
categoryId?: string;
activeAt?: string;
includeArchived: boolean;
sortBy: BudgetSortField;
order: BudgetSortOrder;
page: number;
limit: number;
}
export interface CreateBudgetInput {
name: string;
amount: string;
currency: string;
type: BudgetType;
period: BudgetPeriod;
categoryId?: string | null;
startDate: string;
endDate?: string;
alertThreshold: string;
}
export type UpdateBudgetInput = Partial<CreateBudgetInput>;
export interface BudgetListResponse {
success: boolean;
message?: string;
data: Budget[];
meta: PaginationMeta;
errors?: unknown[] | null;
}
export interface BudgetResponse {
success: boolean;
message?: string;
data: Budget;
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