Commit ff2a2144 authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/transaction-management-frontend' into 'develop'

feat(fe): implement transaction management module and refactor Select component

See merge request !10
parents 414f52fa 56df7923
...@@ -12,6 +12,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th ...@@ -12,6 +12,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- **Phong cách Icon**: Tự thiết kế các inline SVG dạng blob dày, tròn trịa, nhiều màu sắc pastel thay vì dùng icon nét mảnh phẳng thông thường. - **Phong cách Icon**: Tự thiết kế các inline SVG dạng blob dày, tròn trịa, nhiều màu sắc pastel thay vì dùng icon nét mảnh phẳng thông thường.
- **Hệ thống Light/Dark Theme**: Màu nền, surface, chữ, border, trạng thái và bóng Claymorphism phải đi qua semantic CSS variables được ánh xạ trong `tailwind.config.js`; không gắn màu light-only trực tiếp trong component. Lựa chọn `light`/`dark` được lưu cục bộ bằng Zustand, áp dụng lên `data-theme``zaui-theme`, đồng thời mọi màn hình dùng toggle chung để chuyển đổi nhất quán. - **Hệ thống Light/Dark Theme**: Màu nền, surface, chữ, border, trạng thái và bóng Claymorphism phải đi qua semantic CSS variables được ánh xạ trong `tailwind.config.js`; không gắn màu light-only trực tiếp trong component. Lựa chọn `light`/`dark` được lưu cục bộ bằng Zustand, áp dụng lên `data-theme``zaui-theme`, đồng thời mọi màn hình dùng toggle chung để chuyển đổi nhất quán.
- **Đa ngôn ngữ frontend**: UI hỗ trợ `vi``en` qua `src/i18n/`, lưu lựa chọn bằng khóa `finwise.locale` và dùng `Intl` với `vi-VN`/`en-US` cho tiền tệ, số và ngày. Chỉ dịch text hiển thị; enum, mã tiền tệ, ID và payload API giữ nguyên. Khi thêm locale mới, thêm resource/config locale và translation key tương ứng, không đưa text hiển thị trực tiếp vào component. - **Đa ngôn ngữ frontend**: UI hỗ trợ `vi``en` qua `src/i18n/`, lưu lựa chọn bằng khóa `finwise.locale` và dùng `Intl` với `vi-VN`/`en-US` cho tiền tệ, số và ngày. Chỉ dịch text hiển thị; enum, mã tiền tệ, ID và payload API giữ nguyên. Khi thêm locale mới, thêm resource/config locale và translation key tương ứng, không đưa text hiển thị trực tiếp vào component.
- **Nhập liệu số tiền**: Mọi ô nhập số tiền trong ứng dụng (gồm số dư ví, số tiền giao dịch trong form, và số tiền tối thiểu/tối đa trong bộ lọc giao dịch) đều được định dạng số tự động (ngăn cách hàng nghìn cục bộ và tối đa 2 chữ số thập phân), đảm bảo trải nghiệm nhập liệu tài chính thống nhất và đồng bộ.
- **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.
- **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.
...@@ -38,7 +39,8 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th ...@@ -38,7 +39,8 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- Dev server đã được sửa để nội dung app tại cổng 2999 trả 200 và render đúng thay vì màn hình đen do sai Vite root. - Dev server đã được sửa để nội dung app tại cổng 2999 trả 200 và render đúng thay vì màn hình đen do sai Vite root.
- Module quản lý ví đã có frontend tại `/wallets`, kết nối đầy đủ Wallet API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, đặt mặc định, lưu trữ/khôi phục, tìm kiếm, sắp xếp và phân trang. `DELETE /wallets/:id` được thể hiện trong UI là lưu trữ mềm, đúng quy tắc Backend bảo toàn lịch sử. - Module quản lý ví đã có frontend tại `/wallets`, kết nối đầy đủ Wallet API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, đặt mặc định, lưu trữ/khôi phục, tìm kiếm, sắp xếp và phân trang. `DELETE /wallets/:id` được thể hiện trong UI là lưu trữ mềm, đúng quy tắc Backend bảo toàn lịch sử.
- 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.
- Các module giao dịch, 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ý 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.
- 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.
## Khi cập nhật file này ## Khi cập nhật file này
......
...@@ -27,6 +27,7 @@ import ProfilePage from "@/pages/profile/index"; ...@@ -27,6 +27,7 @@ import ProfilePage from "@/pages/profile/index";
import WalletsPage from "@/pages/wallets/index"; import WalletsPage from "@/pages/wallets/index";
import WalletDetailPage from "@/pages/wallets/detail"; import WalletDetailPage from "@/pages/wallets/detail";
import CategoriesPage from "@/pages/categories/index"; import CategoriesPage from "@/pages/categories/index";
import TransactionsPage from "@/pages/transactions/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();
...@@ -88,6 +89,7 @@ const Layout = () => { ...@@ -88,6 +89,7 @@ const Layout = () => {
<Route path="/wallets" element={<AuthGuard><WalletsPage /></AuthGuard>}></Route> <Route path="/wallets" element={<AuthGuard><WalletsPage /></AuthGuard>}></Route>
<Route path="/wallets/:id" element={<AuthGuard><WalletDetailPage /></AuthGuard>}></Route> <Route path="/wallets/:id" element={<AuthGuard><WalletDetailPage /></AuthGuard>}></Route>
<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="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route> <Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route>
</AnimationRoutes> </AnimationRoutes>
</AuthInitializer> </AuthInitializer>
......
...@@ -12,14 +12,15 @@ export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElemen ...@@ -12,14 +12,15 @@ export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElemen
error?: string; error?: string;
} }
export const Select: React.FC<SelectProps> = ({ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({
label, label,
options, options,
error, error,
className = "", className = "",
id, id,
...props ...props
}) => { }, ref) => {
const selectId = id || `select-${Math.random().toString(36).substr(2, 9)}`; const selectId = id || `select-${Math.random().toString(36).substr(2, 9)}`;
return ( return (
...@@ -32,6 +33,7 @@ export const Select: React.FC<SelectProps> = ({ ...@@ -32,6 +33,7 @@ export const Select: React.FC<SelectProps> = ({
<div className="relative w-full"> <div className="relative w-full">
<select <select
id={selectId} id={selectId}
ref={ref}
className={` className={`
w-full bg-clay-bg text-clay-text font-nunito text-base px-4 py-3 pr-10 w-full bg-clay-bg text-clay-text font-nunito text-base px-4 py-3 pr-10
rounded-clay-sm shadow-clay-pressed border border-transparent appearance-none rounded-clay-sm shadow-clay-pressed border border-transparent appearance-none
...@@ -60,4 +62,7 @@ export const Select: React.FC<SelectProps> = ({ ...@@ -60,4 +62,7 @@ export const Select: React.FC<SelectProps> = ({
)} )}
</div> </div>
); );
}; }
);
Select.displayName = "Select";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { transactionService } from "@/services/transaction.service";
import { CreateTransactionInput, TransactionQuery, UpdateTransactionInput } from "@/types/transaction";
import { walletKeys } from "./use-wallets";
export const transactionKeys = {
all: ["transactions"] as const,
lists: () => [...transactionKeys.all, "list"] as const,
list: (query: TransactionQuery) => [...transactionKeys.lists(), query] as const,
details: () => [...transactionKeys.all, "detail"] as const,
detail: (id: string) => [...transactionKeys.details(), id] as const,
};
export function useTransactions(query: TransactionQuery) {
return useQuery({
queryKey: transactionKeys.list(query),
queryFn: () => transactionService.getTransactions(query),
placeholderData: keepPreviousData,
staleTime: 60_000,
});
}
export function useTransaction(id: string) {
return useQuery({
queryKey: transactionKeys.detail(id),
queryFn: () => transactionService.getTransaction(id),
enabled: Boolean(id),
staleTime: 60_000,
});
}
export function useCreateTransaction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateTransactionInput) => transactionService.createTransaction(input),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: transactionKeys.all }),
queryClient.invalidateQueries({ queryKey: walletKeys.all }),
]);
},
});
}
export function useUpdateTransaction(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: UpdateTransactionInput) => transactionService.updateTransaction(id, input),
onSuccess: async (response) => {
queryClient.setQueryData(transactionKeys.detail(id), response);
await Promise.all([
queryClient.invalidateQueries({ queryKey: transactionKeys.lists() }),
queryClient.invalidateQueries({ queryKey: walletKeys.all }),
]);
},
});
}
export function useDeleteTransaction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => transactionService.deleteTransaction(id),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: transactionKeys.all }),
queryClient.invalidateQueries({ queryKey: walletKeys.all }),
]);
},
});
}
export function useUploadReceipt(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (file: File) => transactionService.uploadReceipt(id, file),
onSuccess: async (response) => {
queryClient.setQueryData(transactionKeys.detail(id), response);
await queryClient.invalidateQueries({ queryKey: transactionKeys.lists() });
},
});
}
export function useDeleteReceipt(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => transactionService.deleteReceipt(id),
onSuccess: async (response) => {
queryClient.setQueryData(transactionKeys.detail(id), response);
await queryClient.invalidateQueries({ queryKey: transactionKeys.lists() });
},
});
}
...@@ -44,7 +44,8 @@ ...@@ -44,7 +44,8 @@
"wallets": "My Wallets", "wallets": "My Wallets",
"walletDetail": "Wallet Details", "walletDetail": "Wallet Details",
"categories": "Categories", "categories": "Categories",
"styleGuide": "Style Guide" "styleGuide": "Style Guide",
"transactions": "Transactions"
}, },
"validation": { "validation": {
"emailRequired": "Email is required", "emailRequired": "Email is required",
...@@ -69,7 +70,16 @@ ...@@ -69,7 +70,16 @@
"descriptionMax": "Description cannot exceed 500 characters", "descriptionMax": "Description cannot exceed 500 characters",
"categoryNameRequired": "Enter a category name", "categoryNameRequired": "Enter a category name",
"categoryNameMax": "Category name cannot exceed 100 characters", "categoryNameMax": "Category name cannot exceed 100 characters",
"colorInvalid": "Choose a valid category color" "colorInvalid": "Choose a valid category color",
"transactionAmountRequired": "Please enter the transaction amount",
"transactionAmountInvalid": "Amount must be a positive number with at most 2 decimal places",
"transactionWalletRequired": "Please select a wallet for this transaction",
"transactionCategoryRequired": "Please select a category for this transaction",
"transactionDateRequired": "Please select a transaction date",
"transactionDescriptionMax": "Description must not exceed 500 characters",
"transactionLocationMax": "Location must not exceed 200 characters",
"receiptSizeLimit": "Receipt file must be smaller than 5 MB",
"receiptTypeInvalid": "Receipt must be JPEG, PNG, WebP, or PDF"
}, },
"auth": { "auth": {
"email": "Email", "email": "Email",
...@@ -150,7 +160,8 @@ ...@@ -150,7 +160,8 @@
"wallets": "Manage wallets", "wallets": "Manage wallets",
"categories": "Manage categories", "categories": "Manage categories",
"profile": "My profile", "profile": "My profile",
"styleGuide": "Explore Style Guide" "styleGuide": "Explore Style Guide",
"transactions": "Manage transactions"
}, },
"profile": { "profile": {
"header": "Account", "header": "Account",
...@@ -497,5 +508,78 @@ ...@@ -497,5 +508,78 @@
"accessibility": { "accessibility": {
"closeModal": "Close dialog", "closeModal": "Close dialog",
"userAvatar": "User profile picture" "userAvatar": "User profile picture"
},
"transaction": {
"header": "Transaction Management",
"create": "Create transaction",
"edit": "Edit transaction",
"detail": "Transaction Details",
"amount": "Amount",
"type": "Transaction Type",
"wallet": "Wallet",
"category": "Category",
"date": "Transaction Date",
"description": "Description",
"location": "Location",
"receipt": "Attached Receipt",
"uploadReceipt": "Upload receipt",
"changeReceipt": "Change receipt",
"deleteReceipt": "Delete receipt",
"noReceipt": "No receipt attached",
"receiptSizeHint": "JPEG, PNG, WebP or PDF under 5MB are accepted.",
"locationPlaceholder": "E.g., Supermarket, Restaurant...",
"descriptionPlaceholder": "Enter detailed description...",
"amountPlaceholder": "Enter amount...",
"selectWallet": "Select wallet...",
"selectCategory": "Select category...",
"filterTitle": "Advanced Filters",
"filterSearchPlaceholder": "Search by note, location, wallet, category...",
"filterWallet": "Filter by wallet",
"filterCategory": "Filter by category",
"filterType": "Filter by type",
"filterMinAmount": "Min amount",
"filterMaxAmount": "Max amount",
"filterDateFrom": "Date from",
"filterDateTo": "Date to",
"allTypes": "All types",
"allWallets": "All wallets",
"allCategories": "All categories",
"sortBy": "Sort by",
"resetFilters": "Reset",
"clearFilters": "Clear Filters",
"sortOptions": {
"dateDesc": "Transaction Date (Newest)",
"dateAsc": "Transaction Date (Oldest)",
"amountDesc": "Amount (Highest)",
"amountAsc": "Amount (Lowest)",
"createdAtDesc": "Recently Created"
},
"summary": {
"title": "Page Summary",
"income": "Total Income",
"expense": "Total Expense",
"net": "Net Cashflow"
},
"empty": "No transactions found",
"emptyHint": "Create your first transaction or try adjusting your active filters.",
"loading": "Loading transactions...",
"loadFailed": "Failed to load transactions",
"createSuccess": "Transaction created successfully! 🎉",
"createFailed": "Failed to create transaction. Please try again.",
"updateSuccess": "Transaction updated successfully! 🎉",
"updateFailed": "Failed to update transaction. Please try again.",
"deleteSuccess": "Transaction deleted successfully!",
"deleteFailed": "Failed to delete transaction. Please try again.",
"deleteTitle": "Delete transaction?",
"deleteConfirm": "Confirm Delete",
"deleteDescription": "Are you sure you want to delete this transaction? The wallet balance will be adjusted automatically by reversing this transaction's amount.",
"receiptUploadSuccess": "Receipt uploaded successfully!",
"receiptUploadFailed": "Failed to upload receipt.",
"receiptDeleteSuccess": "Receipt deleted successfully!",
"receiptDeleteFailed": "Failed to delete receipt.",
"typeIncome": "Income (+)",
"typeExpense": "Expense (-)",
"today": "Today",
"yesterday": "Yesterday"
} }
} }
...@@ -44,7 +44,8 @@ ...@@ -44,7 +44,8 @@
"wallets": "Ví của tôi", "wallets": "Ví của tôi",
"walletDetail": "Chi tiết ví", "walletDetail": "Chi tiết ví",
"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"
}, },
"validation": { "validation": {
"emailRequired": "Email không được để trống", "emailRequired": "Email không được để trống",
...@@ -69,7 +70,16 @@ ...@@ -69,7 +70,16 @@
"descriptionMax": "Mô tả tối đa 500 ký tự", "descriptionMax": "Mô tả tối đa 500 ký tự",
"categoryNameRequired": "Vui lòng nhập tên danh mục", "categoryNameRequired": "Vui lòng nhập tên danh mục",
"categoryNameMax": "Tên danh mục tối đa 100 ký tự", "categoryNameMax": "Tên danh mục tối đa 100 ký tự",
"colorInvalid": "Vui lòng chọn màu danh mục hợp lệ" "colorInvalid": "Vui lòng chọn màu danh mục hợp lệ",
"transactionAmountRequired": "Vui lòng nhập số tiền giao dịch",
"transactionAmountInvalid": "Số tiền phải là số lớn hơn 0 và tối đa 2 chữ số thập phân",
"transactionWalletRequired": "Vui lòng chọn ví thực hiện giao dịch",
"transactionCategoryRequired": "Vui lòng chọn danh mục giao dịch",
"transactionDateRequired": "Vui lòng chọn ngày giao dịch",
"transactionDescriptionMax": "Ghi chú tối đa 500 ký tự",
"transactionLocationMax": "Địa điểm tối đa 200 ký tự",
"receiptSizeLimit": "Hóa đơn phải nhỏ hơn 5 MB",
"receiptTypeInvalid": "Hóa đơn chỉ chấp nhận JPEG, PNG, WebP hoặc PDF"
}, },
"auth": { "auth": {
"email": "Email", "email": "Email",
...@@ -150,7 +160,8 @@ ...@@ -150,7 +160,8 @@
"wallets": "Quản lý ví", "wallets": "Quản lý ví",
"categories": "Quản lý danh mục thu chi", "categories": "Quản lý danh mục thu chi",
"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"
}, },
"profile": { "profile": {
"header": "Tài Khoản", "header": "Tài Khoản",
...@@ -506,5 +517,78 @@ ...@@ -506,5 +517,78 @@
"accessibility": { "accessibility": {
"closeModal": "Đóng hộp thoại", "closeModal": "Đóng hộp thoại",
"userAvatar": "Ảnh đại diện người dùng" "userAvatar": "Ảnh đại diện người dùng"
},
"transaction": {
"header": "Quản lý giao dịch",
"create": "Tạo giao dịch",
"edit": "Chỉnh sửa giao dịch",
"detail": "Chi tiết giao dịch",
"amount": "Số tiền",
"type": "Loại giao dịch",
"wallet": "Ví sử dụng",
"category": "Danh mục",
"date": "Ngày giao dịch",
"description": "Ghi chú",
"location": "Địa điểm",
"receipt": "Hóa đơn đính kèm",
"uploadReceipt": "Tải hóa đơn lên",
"changeReceipt": "Thay đổi hóa đơn",
"deleteReceipt": "Xóa hóa đơn",
"noReceipt": "Chưa có hóa đơn nào",
"receiptSizeHint": "Chấp nhận ảnh (JPEG, PNG, WebP) hoặc PDF dưới 5MB.",
"locationPlaceholder": "Ví dụ: Siêu thị, Cửa hàng tiện lợi...",
"descriptionPlaceholder": "Nhập ghi chú chi tiết...",
"amountPlaceholder": "Nhập số tiền...",
"selectWallet": "Chọn ví...",
"selectCategory": "Chọn danh mục...",
"filterTitle": "Bộ lọc nâng cao",
"filterSearchPlaceholder": "Tìm theo ghi chú, địa điểm, ví, danh mục...",
"filterWallet": "Lọc theo ví",
"filterCategory": "Lọc theo danh mục",
"filterType": "Lọc theo loại",
"filterMinAmount": "Số tiền tối thiểu",
"filterMaxAmount": "Số tiền tối đa",
"filterDateFrom": "Từ ngày",
"filterDateTo": "Đến ngày",
"allTypes": "Tất cả các loại",
"allWallets": "Tất cả các ví",
"allCategories": "Tất cả danh mục",
"sortBy": "Sắp xếp theo",
"resetFilters": "Đặt lại",
"clearFilters": "Xóa bộ lọc",
"sortOptions": {
"dateDesc": "Ngày giao dịch (Mới nhất)",
"dateAsc": "Ngày giao dịch (Cũ nhất)",
"amountDesc": "Số tiền (Lớn nhất)",
"amountAsc": "Số tiền (Nhỏ nhất)",
"createdAtDesc": "Mới tạo gần đây"
},
"summary": {
"title": "Báo cáo trang này",
"income": "Tổng thu",
"expense": "Tổng chi",
"net": "Thu nhập ròng"
},
"empty": "Không tìm thấy giao dịch phù hợp",
"emptyHint": "Hãy tạo giao dịch đầu tiên hoặc thử thay đổi điều kiện lọc của bạn.",
"loading": "Đang tải danh sách giao dịch...",
"loadFailed": "Tải giao dịch thất bại",
"createSuccess": "Tạo giao dịch thành công! 🎉",
"createFailed": "Không thể tạo giao dịch. Vui lòng thử lại.",
"updateSuccess": "Cập nhật giao dịch thành công! 🎉",
"updateFailed": "Không thể cập nhật giao dịch. Vui lòng thử lại.",
"deleteSuccess": "Xóa giao dịch thành công!",
"deleteFailed": "Không thể xóa giao dịch. Vui lòng thử lại.",
"deleteTitle": "Xóa giao dịch?",
"deleteConfirm": "Xác nhận xóa",
"deleteDescription": "Bạn có chắc chắn muốn xóa giao dịch này? Số dư ví sẽ được cập nhật tự động bằng cách hoàn tác số tiền giao dịch này.",
"receiptUploadSuccess": "Đã tải hóa đơn lên thành công!",
"receiptUploadFailed": "Tải hóa đơn lên thất bại.",
"receiptDeleteSuccess": "Đã xóa hóa đơn thành công!",
"receiptDeleteFailed": "Xóa hóa đơn thất bại.",
"typeIncome": "Thu nhập (+)",
"typeExpense": "Chi tiêu (-)",
"today": "Hôm nay",
"yesterday": "Hôm qua"
} }
} }
...@@ -18,10 +18,14 @@ const SYSTEM_CATEGORY_NAME_KEYS: Readonly<Record<string, string>> = { ...@@ -18,10 +18,14 @@ const SYSTEM_CATEGORY_NAME_KEYS: Readonly<Record<string, string>> = {
}; };
export function getCategoryDisplayName( export function getCategoryDisplayName(
category: Pick<Category, "isSystem" | "name">, category: { isSystem?: boolean; name: string },
t: TranslationFunction, t: TranslationFunction,
): string { ): string {
if (!category.isSystem) return category.name; const isSystemCategory =
category.isSystem ||
(category.isSystem === undefined && category.name in SYSTEM_CATEGORY_NAME_KEYS);
if (!isSystemCategory) return category.name;
const translationKey = SYSTEM_CATEGORY_NAME_KEYS[category.name]; const translationKey = SYSTEM_CATEGORY_NAME_KEYS[category.name];
return translationKey ? t(translationKey) : category.name; return translationKey ? t(translationKey) : category.name;
......
...@@ -60,6 +60,13 @@ function HomePage() { ...@@ -60,6 +60,13 @@ function HomePage() {
> >
{t("home.wallets")} {t("home.wallets")}
</Button> </Button>
<Button
variant="secondary"
fullWidth
onClick={() => navigate("/transactions")}
>
{t("home.transactions")}
</Button>
<Button <Button
variant="secondary" variant="secondary"
fullWidth fullWidth
......
import React, { useState } from "react"; import React, { useState } from "react";
import { Page, Header } from "zmp-ui"; import { Page, Header, useNavigate } from "zmp-ui";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
...@@ -28,6 +28,7 @@ import { ...@@ -28,6 +28,7 @@ import {
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
const StyleGuidePage: React.FC = () => { const StyleGuidePage: React.FC = () => {
const navigate = useNavigate();
const { t } = useI18n(); const { t } = useI18n();
// State for interactive tab component // State for interactive tab component
const [activeTab, setActiveTab] = useState("tab-1"); const [activeTab, setActiveTab] = useState("tab-1");
...@@ -52,7 +53,7 @@ const StyleGuidePage: React.FC = () => { ...@@ -52,7 +53,7 @@ const StyleGuidePage: React.FC = () => {
return ( return (
<Page className="page"> <Page className="page">
<Header title={t("styleGuide.header")} showBackIcon={false} /> <Header title={t("styleGuide.header")} showBackIcon={true} onBackClick={() => navigate("/")} />
{/* Load SVG Gradients for Claymorphism Icons */} {/* Load SVG Gradients for Claymorphism Icons */}
<IconGradients /> <IconGradients />
......
import React, { useEffect, useState, useRef } from "react";
import { useSnackbar } from "zmp-ui";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { Card } from "@/components/ui/Card";
import { useI18n } from "@/i18n";
import { formatWalletBalance } from "@/lib/wallet-format";
import { getCategoryDisplayName } from "@/lib/category-format";
import { CategoryArtwork } from "@/components/shared/CategoryArtwork";
import { Transaction } from "@/types/transaction";
import { transactionService } from "@/services/transaction.service";
import { useDeleteReceipt, useUploadReceipt } from "@/hooks/use-transactions";
import { getErrorMessage } from "@/lib/error-message";
interface TransactionDetailModalProps {
isOpen: boolean;
transaction?: Transaction;
onClose: () => void;
onEdit: () => void;
onDelete: () => void;
}
export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
isOpen,
transaction,
onClose,
onEdit,
onDelete,
}) => {
const { t, intlLocale } = useI18n();
const { openSnackbar } = useSnackbar();
const fileInputRef = useRef<HTMLInputElement>(null);
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
// Receipt loading state
const [receiptObjectURL, setReceiptObjectURL] = useState<string | null>(null);
const [receiptType, setReceiptType] = useState<string | null>(null);
const [isReceiptLoading, setIsReceiptLoading] = useState(false);
const [receiptError, setReceiptError] = useState(false);
// Receipt mutations
const uploadReceiptMutation = useUploadReceipt(transaction?.id || "");
const deleteReceiptMutation = useDeleteReceipt(transaction?.id || "");
useEffect(() => {
if (isOpen) {
setShowConfirmDelete(false);
}
}, [isOpen]);
useEffect(() => {
if (!isOpen || !transaction?.receiptUrl) {
setReceiptObjectURL(null);
setReceiptType(null);
return;
}
let active = true;
let objectUrl: string | null = null;
setIsReceiptLoading(true);
setReceiptError(false);
transactionService
.getReceiptBlob(transaction.id)
.then((blob) => {
if (!active) return;
objectUrl = URL.createObjectURL(blob);
setReceiptObjectURL(objectUrl);
setReceiptType(blob.type);
setIsReceiptLoading(false);
})
.catch((err) => {
if (!active) return;
console.error("Failed to fetch receipt blob:", err);
setReceiptError(true);
setIsReceiptLoading(false);
});
return () => {
active = false;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [transaction?.id, transaction?.receiptUrl, isOpen]);
if (!transaction) return null;
const handleUploadReceipt = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
openSnackbar({ type: "error", text: t("validation.receiptSizeLimit") });
return;
}
uploadReceiptMutation.mutate(file, {
onSuccess: () => {
openSnackbar({ type: "success", text: t("transaction.receiptUploadSuccess") });
},
onError: (error) => {
openSnackbar({
type: "error",
text: getErrorMessage(error, t("transaction.receiptUploadFailed")),
});
},
});
};
const handleDeleteReceipt = () => {
deleteReceiptMutation.mutate(undefined, {
onSuccess: () => {
openSnackbar({ type: "success", text: t("transaction.receiptDeleteSuccess") });
},
onError: (error) => {
openSnackbar({
type: "error",
text: getErrorMessage(error, t("transaction.receiptDeleteFailed")),
});
},
});
};
const formattedDate = () => {
try {
return new Intl.DateTimeFormat(intlLocale, {
dateStyle: "full",
timeStyle: "short",
}).format(new Date(transaction.date));
} catch {
return transaction.date;
}
};
const isIncome = transaction.type === "INCOME";
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={t("transaction.detail")}
footer={
showConfirmDelete ? (
/* Confirm Deletion Mode */
<div className="flex w-full flex-col gap-2">
<p className="text-center font-nunito text-xs font-semibold text-clay-expense">
{t("transaction.deleteDescription")}
</p>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
className="px-4 text-sm"
onClick={() => setShowConfirmDelete(false)}
>
{t("common.cancel")}
</Button>
<Button variant="danger" className="px-4 text-sm" onClick={onDelete}>
{t("transaction.deleteConfirm")}
</Button>
</div>
</div>
) : (
/* Normal Options Mode */
<>
<Button
variant="danger"
className="mr-auto px-4 text-sm"
onClick={() => setShowConfirmDelete(true)}
>
{t("common.delete")}
</Button>
<Button variant="secondary" className="px-4 text-sm" onClick={onEdit}>
{t("common.edit")}
</Button>
<Button variant="primary" className="px-4 text-sm" onClick={onClose}>
{t("accessibility.closeModal")}
</Button>
</>
)
}
>
<div className="flex flex-col gap-4">
{/* Main Card Amount Display */}
<Card className="flex flex-col items-center justify-center p-6 text-center">
<CategoryArtwork
icon={transaction.category.icon}
color={transaction.category.color}
size="lg"
className="mb-3"
/>
<h2 className="clay-title-h3 mt-1">
{getCategoryDisplayName(transaction.category, t)}
</h2>
<p
className={`font-baloo text-3xl font-bold mt-2 ${
isIncome ? "text-clay-income" : "text-clay-expense"
}`}
>
{isIncome ? "+" : "-"}
{formatWalletBalance(transaction.amount, transaction.wallet.currency, intlLocale)}
</p>
<div className="mt-3 rounded-full bg-clay-bg px-4 py-1.5 border border-clay-highlight/20 shadow-clay-pressed">
<span className="font-nunito text-xs font-bold text-clay-text-muted">
{transaction.wallet.name} ({transaction.wallet.currency})
</span>
</div>
</Card>
{/* Transaction Metadata Grid */}
<div className="grid grid-cols-1 gap-3 rounded-clay bg-clay-bg/50 p-4 border border-clay-highlight/20 shadow-clay-pressed">
<div className="flex flex-col">
<span className="font-nunito text-xs text-clay-text-muted font-semibold">
{t("transaction.date")}
</span>
<span className="font-nunito text-sm font-bold text-clay-text mt-0.5">
{formattedDate()}
</span>
</div>
{transaction.location && (
<div className="flex flex-col border-t border-clay-highlight/20 pt-2.5">
<span className="font-nunito text-xs text-clay-text-muted font-semibold flex items-center gap-1">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
className="text-clay-primary"
>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
{t("transaction.location")}
</span>
<span className="font-nunito text-sm font-bold text-clay-text mt-0.5">
{transaction.location}
</span>
</div>
)}
{transaction.description && (
<div className="flex flex-col border-t border-clay-highlight/20 pt-2.5">
<span className="font-nunito text-xs text-clay-text-muted font-semibold">
{t("transaction.description")}
</span>
<span className="font-nunito text-sm font-semibold text-clay-text bg-clay-surface/60 rounded-clay-sm p-2 shadow-clay-pressed mt-1.5 whitespace-pre-wrap">
{transaction.description}
</span>
</div>
)}
</div>
{/* Receipt Display Section */}
<div className="flex flex-col gap-2 mt-1">
<div className="flex items-center justify-between px-1">
<span className="font-nunito text-sm font-semibold text-clay-text">
{t("transaction.receipt")}
</span>
{/* Hidden File Picker */}
<input
type="file"
ref={fileInputRef}
accept="image/jpeg,image/png,image/webp,application/pdf"
className="hidden"
onChange={handleUploadReceipt}
disabled={uploadReceiptMutation.isPending}
/>
{transaction.receiptUrl && !isReceiptLoading && (
<div className="flex items-center gap-2">
<button
type="button"
className="text-xs font-bold text-clay-primary hover:underline"
onClick={() => fileInputRef.current?.click()}
disabled={uploadReceiptMutation.isPending}
>
{t("transaction.changeReceipt")}
</button>
<span className="text-clay-text-muted/40"></span>
<button
type="button"
className="text-xs font-bold text-clay-expense hover:underline"
onClick={handleDeleteReceipt}
disabled={deleteReceiptMutation.isPending}
>
{t("common.delete")}
</button>
</div>
)}
</div>
{isReceiptLoading ? (
/* Loading State */
<div className="flex h-40 animate-pulse items-center justify-center rounded-clay bg-clay-bg border border-clay-highlight/30">
<span className="font-nunito text-xs text-clay-text-muted">
{t("common.loading")}
</span>
</div>
) : receiptError ? (
/* Error Loading */
<div className="flex h-36 flex-col items-center justify-center rounded-clay bg-clay-expense/10 border border-clay-expense/25 p-4 text-center">
<span className="font-nunito text-sm font-bold text-clay-expense">
{t("transaction.loadFailed")}
</span>
<button
type="button"
className="text-xs font-bold text-clay-primary mt-1 underline"
onClick={() => {
setReceiptObjectURL(null);
setIsReceiptLoading(true);
transactionService
.getReceiptBlob(transaction.id)
.then((blob) => {
const url = URL.createObjectURL(blob);
setReceiptObjectURL(url);
setReceiptType(blob.type);
setIsReceiptLoading(false);
setReceiptError(false);
})
.catch(() => {
setReceiptError(true);
setIsReceiptLoading(false);
});
}}
>
{t("common.retry")}
</button>
</div>
) : receiptObjectURL ? (
/* Display Receipt */
<div className="flex flex-col items-center justify-center overflow-hidden rounded-clay bg-clay-bg p-3 shadow-clay-pressed border border-clay-highlight/30">
{receiptType?.startsWith("image/") ? (
<img
src={receiptObjectURL}
alt={t("transaction.receipt")}
className="max-h-60 max-w-full rounded-clay-sm object-contain shadow-clay-raised border border-clay-highlight/20"
/>
) : (
<div className="flex flex-col items-center gap-2 py-4">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
className="text-clay-primary"
>
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span className="font-nunito text-xs font-bold text-clay-text">
PDF Document Receipt
</span>
<button
type="button"
className="rounded-clay-sm bg-clay-primary text-white text-xs font-bold px-4 py-2 mt-2 shadow-clay-raised transition-all duration-200 hover:scale-105 active:scale-95"
onClick={() => window.open(receiptObjectURL || "", "_blank")}
>
Open PDF Document
</button>
</div>
)}
</div>
) : (
/* No Receipt: Offer Upload */
<button
type="button"
className="flex flex-col items-center justify-center rounded-clay border border-dashed border-clay-primary/20 bg-clay-bg/30 py-6 text-center hover:bg-clay-primary/5 transition-all duration-200 shadow-clay-pressed"
onClick={() => fileInputRef.current?.click()}
disabled={uploadReceiptMutation.isPending}
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
className="text-clay-primary/60 mb-1"
>
<path d="M12 5v14M5 12h14" />
</svg>
<span className="font-nunito text-xs font-bold text-clay-text">
{t("transaction.uploadReceipt")}
</span>
<span className="clay-caption mt-0.5 text-clay-text-muted">
{t("transaction.receiptSizeHint")}
</span>
</button>
)}
</div>
</div>
</Modal>
);
};
import React, { useEffect, useMemo, useRef, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select";
import { TranslationFunction, useI18n } from "@/i18n";
import { useWallets } from "@/hooks/use-wallets";
import { useCategoryTree } from "@/hooks/use-categories";
import { getCategoryDisplayName } from "@/lib/category-format";
import { CategoryTreeNode, TransactionType } from "@/types/category";
import { CreateTransactionInput, Transaction, UpdateTransactionInput } from "@/types/transaction";
const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
function getNumberSeparators(locale: string): { group: string; decimal: string } {
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
return {
group: parts.find((part) => part.type === "group")?.value || ",",
decimal: parts.find((part) => part.type === "decimal")?.value || ".",
};
}
function formatAmountInput(value: string, locale: string): string {
if (!value) {
return value;
}
const [integerPart, decimalPart] = value.split(".");
const { group, decimal } = getNumberSeparators(locale);
const groupedInteger = (integerPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, group);
return `${groupedInteger}${decimalPart !== undefined ? `${decimal}${decimalPart}` : ""}`;
}
function parseAmountInput(value: string, locale: string): string {
const trimmedValue = value.trim();
if (!trimmedValue) {
return "";
}
const { group, decimal } = getNumberSeparators(locale);
const unsignedValue = trimmedValue;
let integerDisplay = unsignedValue;
let decimalDisplay: string | undefined;
if (unsignedValue.includes(decimal)) {
const localeNormalized = unsignedValue.split(group).join("");
[integerDisplay, decimalDisplay] = localeNormalized.split(decimal, 2);
} else if (/^\d+[.,]\d{0,2}$/.test(unsignedValue)) {
const separatorIndex = Math.max(unsignedValue.lastIndexOf("."), unsignedValue.lastIndexOf(","));
integerDisplay = unsignedValue.slice(0, separatorIndex);
decimalDisplay = unsignedValue.slice(separatorIndex + 1);
} else {
integerDisplay = unsignedValue.split(group).join("");
}
const integerDigits = integerDisplay.replace(/\D/g, "").slice(0, 16);
const normalizedInteger = integerDigits.replace(/^0+(?=\d)/, "") || "0";
const decimalDigits = decimalDisplay?.replace(/\D/g, "").slice(0, 2);
return `${normalizedInteger}${decimalDisplay !== undefined ? `.${decimalDigits}` : ""}`;
}
const createTransactionSchema = (t: TranslationFunction) => z.object({
amount: z.string().trim().min(1, t("validation.transactionAmountRequired"))
.regex(amountPattern, t("validation.transactionAmountInvalid"))
.refine((val) => Number(val) > 0, t("validation.transactionAmountInvalid")),
type: z.enum(["INCOME", "EXPENSE"]),
walletId: z.string().min(1, t("validation.transactionWalletRequired")),
categoryId: z.string().min(1, t("validation.transactionCategoryRequired")),
date: z.string().min(1, t("validation.transactionDateRequired")),
description: z.string().max(500, t("validation.transactionDescriptionMax")).optional().or(z.literal("")),
location: z.string().max(200, t("validation.transactionLocationMax")).optional().or(z.literal("")),
});
type TransactionFormValues = z.infer<ReturnType<typeof createTransactionSchema>>;
interface TransactionFormModalProps {
isOpen: boolean;
transaction?: Transaction;
isSubmitting: boolean;
onClose: () => void;
onSubmit: (
input: CreateTransactionInput | UpdateTransactionInput,
receiptFile: File | null,
deleteReceipt: boolean
) => void;
}
interface FlatCategoryOption {
category: CategoryTreeNode;
depth: number;
}
function flattenTree(nodes: CategoryTreeNode[], depth = 0): FlatCategoryOption[] {
return nodes.flatMap((node) => [
{ category: node, depth },
...flattenTree(node.children, depth + 1),
]);
}
const getLocalDateString = (dateInput?: string | Date) => {
const date = dateInput ? new Date(dateInput) : new Date();
if (isNaN(date.getTime())) return new Date().toISOString().split("T")[0];
const offset = date.getTimezoneOffset();
const localDate = new Date(date.getTime() - (offset * 60 * 1000));
return localDate.toISOString().split("T")[0];
};
export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
isOpen,
transaction,
isSubmitting,
onClose,
onSubmit,
}) => {
const { t, intlLocale } = useI18n();
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [fileError, setFileError] = useState<string | null>(null);
const [deleteCurrentReceipt, setDeleteCurrentReceipt] = useState(false);
const walletsQuery = useWallets({
includeArchived: false,
sortBy: "name",
order: "asc",
page: 1,
limit: 100,
});
const categoriesQuery = useCategoryTree({
source: "ALL",
includeArchived: false,
});
const schema = useMemo(() => createTransactionSchema(t), [t]);
const formId = transaction ? `edit-transaction-${transaction.id}` : "create-transaction";
const defaultValues = useMemo<TransactionFormValues>(() => {
if (transaction) {
return {
amount: transaction.amount,
type: transaction.type,
walletId: transaction.walletId,
categoryId: transaction.categoryId,
date: getLocalDateString(transaction.date),
description: transaction.description || "",
location: transaction.location || "",
};
}
return {
amount: "",
type: "EXPENSE",
walletId: "",
categoryId: "",
date: getLocalDateString(),
description: "",
location: "",
};
}, [transaction]);
const {
control,
register,
handleSubmit,
reset,
setValue,
watch,
formState: { errors },
} = useForm<TransactionFormValues>({
resolver: zodResolver(schema),
defaultValues,
});
useEffect(() => {
if (isOpen) {
reset(defaultValues);
setSelectedFile(null);
setFileError(null);
setDeleteCurrentReceipt(false);
}
}, [defaultValues, isOpen, reset]);
const selectedType = watch("type") as TransactionType;
const selectedWalletId = watch("walletId");
const selectedCategoryId = watch("categoryId");
// Pre-select default wallet when creating a transaction
useEffect(() => {
if (!isOpen) return;
if (transaction) return; // Only for creation mode
const wallets = walletsQuery.data?.data || [];
if (wallets.length > 0 && !selectedWalletId) {
const defaultWallet = wallets.find((w) => w.isDefault && !w.isArchived) || wallets.find((w) => !w.isArchived);
if (defaultWallet) {
setValue("walletId", defaultWallet.id, { shouldValidate: true });
}
}
}, [walletsQuery.data?.data, isOpen, transaction, selectedWalletId, setValue]);
// Flat categories filtered by current type (INCOME/EXPENSE)
const categoryOptions = useMemo(() => {
const tree = categoriesQuery.data?.data || [];
const flat = flattenTree(tree);
const filtered = flat.filter(
({ category: item }) => item.type === selectedType && !item.isArchived
);
// If editing and the transaction's category is archived/missing, temporarily add it
if (transaction && transaction.category && transaction.type === selectedType) {
const isAlreadyIncluded = filtered.some(({ category: item }) => item.id === transaction.categoryId);
if (!isAlreadyIncluded) {
filtered.unshift({
category: {
...transaction.category,
parentId: null,
isSystem: false,
isArchived: true,
createdAt: "",
updatedAt: "",
children: [],
},
depth: 0,
});
}
}
return [
{ value: "", label: t("transaction.selectCategory") },
...filtered.map(({ category: item, depth }) => ({
value: item.id,
label: `${"— ".repeat(Math.min(depth, 3))}${getCategoryDisplayName(item, t)}${
item.isArchived ? ` (${t("common.archived")})` : ""
}`,
})),
];
}, [categoriesQuery.data?.data, selectedType, transaction, t]);
// Active wallets list
const walletOptions = useMemo(() => {
const list = walletsQuery.data?.data || [];
const filtered = list.filter((w) => !w.isArchived);
// If editing and the wallet is archived, temporarily append it
if (transaction && transaction.wallet) {
const isAlreadyIncluded = filtered.some((w) => w.id === transaction.walletId);
if (!isAlreadyIncluded) {
filtered.unshift({
...transaction.wallet,
balance: "0",
icon: null,
color: null,
description: null,
isDefault: false,
isArchived: true,
createdAt: "",
updatedAt: "",
});
}
}
return [
{ value: "", label: t("transaction.selectWallet") },
...filtered.map((w) => ({
value: w.id,
label: `${w.name} (${w.currency})${w.isArchived ? ` (${t("common.archived")})` : ""}`,
})),
];
}, [walletsQuery.data?.data, transaction, t]);
// Adjust category if the type switches
useEffect(() => {
if (selectedCategoryId) {
const currentCategoryOption = categoryOptions.find(
(opt) => opt.value === selectedCategoryId
);
if (!currentCategoryOption) {
setValue("categoryId", "");
}
}
}, [selectedType, categoryOptions, selectedCategoryId, setValue]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFileError(null);
const file = e.target.files?.[0];
if (!file) return;
// Validate size (5MB max)
if (file.size > 5 * 1024 * 1024) {
setFileError(t("validation.receiptSizeLimit"));
return;
}
// Validate mime-type
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
if (!allowedTypes.includes(file.type)) {
setFileError(t("validation.receiptTypeInvalid"));
return;
}
setSelectedFile(file);
setDeleteCurrentReceipt(false);
};
const removeSelectedFile = () => {
setSelectedFile(null);
setFileError(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const submitForm = (values: TransactionFormValues) => {
if (fileError) return;
// Construct local Date at noon to avoid timezone shift on backend parser
const localDate = new Date(values.date);
localDate.setHours(12, 0, 0, 0);
const payload = {
amount: values.amount,
type: values.type,
walletId: values.walletId,
categoryId: values.categoryId,
date: localDate.toISOString(),
description: values.description?.trim() || null,
location: values.location?.trim() || null,
};
onSubmit(payload, selectedFile, deleteCurrentReceipt);
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={transaction ? t("transaction.edit") : t("transaction.create")}
footer={
<>
<Button
type="button"
variant="ghost"
className="px-4 text-sm"
disabled={isSubmitting}
onClick={onClose}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
form={formId}
className="px-4 text-sm"
disabled={isSubmitting || Boolean(fileError)}
>
{isSubmitting ? t("common.saving") : transaction ? t("common.save") : t("transaction.create")}
</Button>
</>
}
>
<form id={formId} className="flex flex-col gap-4" onSubmit={handleSubmit(submitForm)}>
<div className="grid grid-cols-2 gap-3">
<Select
label={t("transaction.type")}
options={[
{ value: "EXPENSE", label: t("transaction.typeExpense") },
{ value: "INCOME", label: t("transaction.typeIncome") },
]}
disabled={isSubmitting}
{...register("type")}
/>
<Controller
name="amount"
control={control}
render={({ field }) => (
<Input
{...field}
label={t("transaction.amount")}
inputMode="decimal"
placeholder={t("transaction.amountPlaceholder")}
error={errors.amount?.message}
disabled={isSubmitting}
className="text-right tabular-nums font-semibold"
value={formatAmountInput(field.value, intlLocale)}
onChange={(event) => field.onChange(parseAmountInput(event.target.value, intlLocale))}
/>
)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<Select
label={t("transaction.wallet")}
options={walletOptions}
error={errors.walletId?.message}
disabled={isSubmitting || walletsQuery.isLoading}
{...register("walletId")}
/>
<Select
label={t("transaction.category")}
options={categoryOptions}
error={errors.categoryId?.message}
disabled={isSubmitting || categoriesQuery.isLoading}
{...register("categoryId")}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label={t("transaction.date")}
type="date"
error={errors.date?.message}
disabled={isSubmitting}
{...register("date")}
/>
<Input
label={t("transaction.location")}
placeholder={t("transaction.locationPlaceholder")}
error={errors.location?.message}
disabled={isSubmitting}
{...register("location")}
/>
</div>
<Input
label={t("transaction.description")}
placeholder={t("transaction.descriptionPlaceholder")}
error={errors.description?.message}
disabled={isSubmitting}
{...register("description")}
/>
{/* Receipt Attachment Interface */}
<div className="flex flex-col gap-2">
<span className="px-1 font-nunito text-sm font-semibold text-clay-text">
{t("transaction.receipt")}
</span>
<input
type="file"
ref={fileInputRef}
accept="image/jpeg,image/png,image/webp,application/pdf"
className="hidden"
onChange={handleFileChange}
disabled={isSubmitting}
/>
{selectedFile ? (
/* User selected a new file locally */
<div className="flex items-center justify-between rounded-clay bg-clay-primary/10 p-3 shadow-clay-pressed border border-clay-primary/20">
<div className="flex flex-col min-w-0 pr-2">
<span className="font-nunito text-sm font-bold text-clay-text truncate">
{selectedFile.name}
</span>
<span className="clay-caption">
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</span>
</div>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full bg-clay-expense text-white shadow-clay-raised hover:scale-105 transition-all duration-200"
onClick={removeSelectedFile}
disabled={isSubmitting}
>
</button>
</div>
) : transaction?.receiptUrl && !deleteCurrentReceipt ? (
/* Transaction already has a receipt, show replace or delete controls */
<div className="flex items-center justify-between rounded-clay bg-clay-bg p-3 shadow-clay-pressed border border-clay-highlight/30">
<div className="flex flex-col min-w-0">
<span className="font-nunito text-sm font-bold text-clay-text">
{t("transaction.receipt")}
</span>
<span className="clay-caption truncate text-clay-primary font-semibold">
{t("transaction.detail")}
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded-clay-sm bg-clay-primary text-white text-xs font-bold px-3 py-1.5 shadow-clay-raised transition-all duration-200 hover:scale-105 active:scale-95"
onClick={() => fileInputRef.current?.click()}
disabled={isSubmitting}
>
{t("transaction.changeReceipt")}
</button>
<button
type="button"
className="rounded-clay-sm bg-clay-expense text-white text-xs font-bold px-3 py-1.5 shadow-clay-raised transition-all duration-200 hover:scale-105 active:scale-95"
onClick={() => setDeleteCurrentReceipt(true)}
disabled={isSubmitting}
>
{t("common.delete")}
</button>
</div>
</div>
) : (
/* Empty state: Click to browse */
<button
type="button"
className="flex flex-col items-center justify-center rounded-clay border-2 border-dashed border-clay-primary/30 bg-clay-bg/40 p-5 text-center transition-all duration-200 ease-in-out hover:bg-clay-primary/5 hover:border-clay-primary/50 shadow-clay-pressed"
onClick={() => fileInputRef.current?.click()}
disabled={isSubmitting}
>
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-clay-primary mb-1"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4m4-5 5-5 5 5m-5-5v12" />
</svg>
<span className="font-nunito text-sm font-bold text-clay-text">
{t("transaction.uploadReceipt")}
</span>
<span className="clay-caption mt-0.5 text-clay-text-muted">
{t("transaction.receiptSizeHint")}
</span>
</button>
)}
{fileError && (
<span className="font-nunito text-xs text-clay-expense px-1 mt-1 font-semibold">
{fileError}
</span>
)}
{deleteCurrentReceipt && (
<div className="flex items-center justify-between rounded-clay bg-clay-expense/10 p-2.5 border border-clay-expense/30">
<span className="font-nunito text-xs font-semibold text-clay-expense">
{t("transaction.receipt")} {t("common.archived").toLowerCase()}
</span>
<button
type="button"
className="text-xs font-bold text-clay-primary underline"
onClick={() => setDeleteCurrentReceipt(false)}
disabled={isSubmitting}
>
{t("common.retry")}
</button>
</div>
)}
</div>
</form>
</Modal>
);
};
import React from "react";
export const TransactionSkeleton: React.FC = () => (
<div className="animate-pulse rounded-clay border border-clay-highlight/40 bg-clay-surface p-4 shadow-clay-raised">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 flex-1">
<div className="h-12 w-12 rounded-clay bg-clay-primary/15 shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-4 w-3/5 rounded-full bg-clay-primary/15" />
<div className="h-3.5 w-2/5 rounded-full bg-clay-primary/10" />
</div>
</div>
<div className="h-5 w-20 rounded-full bg-clay-primary/20 shrink-0" />
</div>
</div>
);
import React, { useDeferredValue, useEffect, useMemo, useState } from "react";
import { Header, Page, useNavigate, useSnackbar } from "zmp-ui";
import { useQueryClient } from "@tanstack/react-query";
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 { CategoryArtwork } from "@/components/shared/CategoryArtwork";
import { IconGradients, PlusIcon, TransactionIcon } from "@/components/ui/icons";
import { useI18n } from "@/i18n";
import { formatWalletBalance } from "@/lib/wallet-format";
import { getCategoryDisplayName } from "@/lib/category-format";
import {
useCreateTransaction,
useDeleteTransaction,
useTransactions,
useUpdateTransaction,
transactionKeys,
} from "@/hooks/use-transactions";
import { useWallets, walletKeys } from "@/hooks/use-wallets";
import { useCategoryTree } from "@/hooks/use-categories";
import { getErrorMessage } from "@/lib/error-message";
import {
CreateTransactionInput,
Transaction,
TransactionQuery,
TransactionSortField,
SortOrder,
UpdateTransactionInput,
} from "@/types/transaction";
import { TransactionFormModal } from "./components/TransactionFormModal";
import { TransactionDetailModal } from "./components/TransactionDetailModal";
import { TransactionSkeleton } from "./components/TransactionSkeleton";
import { transactionService } from "@/services/transaction.service";
const PAGE_SIZE = 10;
const getLocalDateString = (dateInput?: string | Date) => {
const date = dateInput ? new Date(dateInput) : new Date();
if (isNaN(date.getTime())) return new Date().toISOString().split("T")[0];
const offset = date.getTimezoneOffset();
const localDate = new Date(date.getTime() - (offset * 60 * 1000));
return localDate.toISOString().split("T")[0];
};
function getNumberSeparators(locale: string): { group: string; decimal: string } {
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
return {
group: parts.find((part) => part.type === "group")?.value || ",",
decimal: parts.find((part) => part.type === "decimal")?.value || ".",
};
}
function formatAmountInput(value: string, locale: string): string {
if (!value) {
return value;
}
const [integerPart, decimalPart] = value.split(".");
const { group, decimal } = getNumberSeparators(locale);
const groupedInteger = (integerPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, group);
return `${groupedInteger}${decimalPart !== undefined ? `${decimal}${decimalPart}` : ""}`;
}
function parseAmountInput(value: string, locale: string): string {
const trimmedValue = value.trim();
if (!trimmedValue) {
return "";
}
const { group, decimal } = getNumberSeparators(locale);
const unsignedValue = trimmedValue;
let integerDisplay = unsignedValue;
let decimalDisplay: string | undefined;
if (unsignedValue.includes(decimal)) {
const localeNormalized = unsignedValue.split(group).join("");
[integerDisplay, decimalDisplay] = localeNormalized.split(decimal, 2);
} else if (/^\d+[.,]\d{0,2}$/.test(unsignedValue)) {
const separatorIndex = Math.max(unsignedValue.lastIndexOf("."), unsignedValue.lastIndexOf(","));
integerDisplay = unsignedValue.slice(0, separatorIndex);
decimalDisplay = unsignedValue.slice(separatorIndex + 1);
} else {
integerDisplay = unsignedValue.split(group).join("");
}
const integerDigits = integerDisplay.replace(/\D/g, "").slice(0, 16);
const normalizedInteger = integerDigits.replace(/^0+(?=\d)/, "") || "0";
const decimalDigits = decimalDisplay?.replace(/\D/g, "").slice(0, 2);
return `${normalizedInteger}${decimalDisplay !== undefined ? `.${decimalDigits}` : ""}`;
}
const TransactionsPage: React.FC = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { openSnackbar } = useSnackbar();
const { t, intlLocale } = useI18n();
// Search & Filter state
const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search.trim());
const [walletId, setWalletId] = useState("");
const [categoryId, setCategoryId] = useState("");
const [type, setType] = useState("");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [minAmount, setMinAmount] = useState("");
const [maxAmount, setMaxAmount] = useState("");
const [sort, setSort] = useState("date:desc");
const [page, setPage] = useState(1);
// Filter Panel Visibility
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
// Modals Visibility
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false);
const [isDetailOpen, setIsDetailOpen] = useState(false);
// Selected transactions
const [selectedTransaction, setSelectedTransaction] = useState<Transaction | undefined>(undefined);
// Options for filter selects
const walletsQuery = useWallets({
includeArchived: false,
sortBy: "name",
order: "asc",
page: 1,
limit: 100,
});
const categoriesQuery = useCategoryTree({
source: "ALL",
includeArchived: false,
});
const [sortBy, order] = sort.split(":") as [TransactionSortField, SortOrder];
const query = useMemo<TransactionQuery>(() => {
const q: any = {
sortBy,
order,
page,
limit: PAGE_SIZE,
};
if (deferredSearch) q.search = deferredSearch;
if (walletId) q.walletId = walletId;
if (categoryId) q.categoryId = categoryId;
if (type) q.type = type;
if (dateFrom) {
const from = new Date(dateFrom);
from.setHours(0, 0, 0, 0);
q.dateFrom = from.toISOString();
}
if (dateTo) {
const to = new Date(dateTo);
to.setHours(23, 59, 59, 999);
q.dateTo = to.toISOString();
}
if (minAmount) q.minAmount = minAmount;
if (maxAmount) q.maxAmount = maxAmount;
return q;
}, [deferredSearch, walletId, categoryId, type, dateFrom, dateTo, minAmount, maxAmount, sortBy, order, page]);
// Main Transactions Query
const transactionsQuery = useTransactions(query);
// Mutations
const createMutation = useCreateTransaction();
const deleteMutation = useDeleteTransaction();
// Reset page when filters change
useEffect(() => {
setPage(1);
}, [deferredSearch, walletId, categoryId, type, dateFrom, dateTo, minAmount, maxAmount, sort]);
const totalItems = transactionsQuery.data?.meta.total || 0;
const totalPages = transactionsQuery.data?.meta.totalPages || 0;
useEffect(() => {
if (totalPages > 0 && page > totalPages) {
setPage(totalPages);
}
}, [page, totalPages]);
// Group transactions by date
const groupedTransactions = useMemo(() => {
const groups: Record<string, Transaction[]> = {};
const list = transactionsQuery.data?.data || [];
list.forEach((item) => {
const localDateStr = getLocalDateString(item.date);
if (!groups[localDateStr]) {
groups[localDateStr] = [];
}
groups[localDateStr].push(item);
});
return groups;
}, [transactionsQuery.data?.data]);
// Sorted date keys
const sortedDateKeys = useMemo(() => {
return Object.keys(groupedTransactions).sort((a, b) => {
// Sort dates according to overall query sort order
return order === "desc"
? new Date(b).getTime() - new Date(a).getTime()
: new Date(a).getTime() - new Date(b).getTime();
});
}, [groupedTransactions, order]);
// Calculate Page Summary Metrics
const pageSummary = useMemo(() => {
let income = 0;
let expense = 0;
const list = transactionsQuery.data?.data || [];
let activeCurrency = "VND";
list.forEach((tx) => {
const amountNum = Number(tx.amount);
if (tx.wallet?.currency) {
activeCurrency = tx.wallet.currency;
}
if (tx.type === "INCOME") {
income += amountNum;
} else {
expense += amountNum;
}
});
return {
income: income.toString(),
expense: expense.toString(),
net: (income - expense).toString(),
currency: activeCurrency,
};
}, [transactionsQuery.data?.data]);
// Generate localized group header
const getGroupHeaderLabel = (dateStr: string) => {
const todayStr = getLocalDateString(new Date());
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = getLocalDateString(yesterday);
if (dateStr === todayStr) {
return t("transaction.today");
}
if (dateStr === yesterdayStr) {
return t("transaction.yesterday");
}
try {
return new Intl.DateTimeFormat(intlLocale, {
dateStyle: "full",
}).format(new Date(dateStr));
} catch {
return dateStr;
}
};
// Build select option arrays
const sortOptions = useMemo(() => [
{ value: "date:desc", label: t("transaction.sortOptions.dateDesc") },
{ value: "date:asc", label: t("transaction.sortOptions.dateAsc") },
{ value: "amount:desc", label: t("transaction.sortOptions.amountDesc") },
{ value: "amount:asc", label: t("transaction.sortOptions.amountAsc") },
{ value: "createdAt:desc", label: t("transaction.sortOptions.createdAtDesc") },
], [t]);
const walletFilterOptions = useMemo(() => {
const list = walletsQuery.data?.data || [];
return [
{ value: "", label: t("transaction.allWallets") },
...list.filter((w) => !w.isArchived).map((w) => ({
value: w.id,
label: `${w.name} (${w.currency})`,
})),
];
}, [walletsQuery.data?.data, t]);
const categoryFilterOptions = useMemo(() => {
const tree = categoriesQuery.data?.data || [];
const flat = flattenTree(tree);
return [
{ value: "", label: t("transaction.allCategories") },
...flat.filter(({ category: item }) => !item.isArchived).map(({ category: item, depth }) => ({
value: item.id,
label: `${"— ".repeat(Math.min(depth, 3))}${getCategoryDisplayName(item, t)}`,
})),
];
}, [categoriesQuery.data?.data, t]);
function flattenTree(nodes: CategoryTreeNode[], depth = 0): FlatCategoryOption[] {
return nodes.flatMap((node) => [
{ category: node, depth },
...flattenTree(node.children, depth + 1),
]);
}
// Active filters count
const activeFiltersCount = useMemo(() => {
let count = 0;
if (walletId) count++;
if (categoryId) count++;
if (type) count++;
if (dateFrom) count++;
if (dateTo) count++;
if (minAmount) count++;
if (maxAmount) count++;
return count;
}, [walletId, categoryId, type, dateFrom, dateTo, minAmount, maxAmount]);
const resetFilters = () => {
setSearch("");
setWalletId("");
setCategoryId("");
setType("");
setDateFrom("");
setDateTo("");
setMinAmount("");
setMaxAmount("");
setSort("date:desc");
};
// CRUD handlers
const handleCreate = (
input: CreateTransactionInput | UpdateTransactionInput,
receiptFile: File | null
) => {
createMutation.mutate(input as CreateTransactionInput, {
onSuccess: async (response) => {
const newTx = response.data;
if (receiptFile) {
try {
await transactionService.uploadReceipt(newTx.id, receiptFile);
await queryClient.invalidateQueries({ queryKey: transactionKeys.all });
openSnackbar({ type: "success", text: t("transaction.createSuccess") });
} catch (uploadError) {
openSnackbar({
type: "warning",
text: `${t("transaction.createSuccess")} ${t("transaction.receiptUploadFailed")}`,
});
}
} else {
openSnackbar({ type: "success", text: t("transaction.createSuccess") });
}
setIsCreateOpen(false);
},
onError: (error) => {
openSnackbar({
type: "error",
text: getErrorMessage(error, t("transaction.createFailed")),
});
},
});
};
const handleEdit = (
input: CreateTransactionInput | UpdateTransactionInput,
receiptFile: File | null,
deleteReceipt: boolean
) => {
if (!selectedTransaction) return;
const txId = selectedTransaction.id;
transactionService.updateTransaction(txId, input as UpdateTransactionInput)
.then(async (response) => {
try {
if (deleteReceipt && selectedTransaction.receiptUrl) {
await transactionService.deleteReceipt(txId);
}
if (receiptFile) {
await transactionService.uploadReceipt(txId, receiptFile);
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: transactionKeys.all }),
queryClient.invalidateQueries({ queryKey: walletKeys.all }),
]);
openSnackbar({ type: "success", text: t("transaction.updateSuccess") });
} catch (uploadError) {
openSnackbar({
type: "warning",
text: `${t("transaction.updateSuccess")} ${t("transaction.receiptUploadFailed")}`,
});
}
setIsEditOpen(false);
setIsDetailOpen(false);
setSelectedTransaction(undefined);
})
.catch((error) => {
openSnackbar({
type: "error",
text: getErrorMessage(error, t("transaction.updateFailed")),
});
});
};
const handleDelete = () => {
if (!selectedTransaction) return;
const txId = selectedTransaction.id;
deleteMutation.mutate(txId, {
onSuccess: () => {
openSnackbar({ type: "success", text: t("transaction.deleteSuccess") });
setIsDetailOpen(false);
setSelectedTransaction(undefined);
},
onError: (error) => {
openSnackbar({
type: "error",
text: getErrorMessage(error, t("transaction.deleteFailed")),
});
},
});
};
return (
<Page className="page">
<Header
title={t("transaction.header")}
showBackIcon
onBackClick={() => navigate("/")}
/>
<IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-4 pb-16 px-4">
{/* Statistics Summary Card */}
<Card className="p-4 flex flex-col gap-3">
<div className="flex items-center justify-between border-b border-clay-highlight/25 pb-2">
<span className="font-baloo text-sm font-bold text-clay-text">
{t("transaction.summary.title")}
</span>
<span className="clay-caption">
{transactionsQuery.isFetching ? t("category.syncing") : t("common.results", { count: totalItems })}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-center">
<div className="flex flex-col">
<span className="clay-caption text-clay-income font-bold">
{t("transaction.summary.income")}
</span>
<span className="font-baloo text-base font-bold text-clay-income mt-0.5">
{transactionsQuery.isLoading ? "..." : formatWalletBalance(pageSummary.income, pageSummary.currency, intlLocale)}
</span>
</div>
<div className="flex flex-col border-x border-clay-highlight/25 px-1">
<span className="clay-caption text-clay-expense font-bold">
{t("transaction.summary.expense")}
</span>
<span className="font-baloo text-base font-bold text-clay-expense mt-0.5">
{transactionsQuery.isLoading ? "..." : formatWalletBalance(pageSummary.expense, pageSummary.currency, intlLocale)}
</span>
</div>
<div className="flex flex-col">
<span className="clay-caption text-clay-text font-bold">
{t("transaction.summary.net")}
</span>
<span className={`font-baloo text-base font-bold mt-0.5 ${
Number(pageSummary.net) >= 0 ? "text-clay-primary" : "text-clay-expense"
}`}>
{transactionsQuery.isLoading ? "..." : formatWalletBalance(pageSummary.net, pageSummary.currency, intlLocale)}
</span>
</div>
</div>
</Card>
{/* Title area & Create button */}
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="clay-title-h2">{t("document.transactions")}</h1>
<p className="clay-caption">{t("common.results", { count: totalItems })}</p>
</div>
<Button
shape="pill"
className="gap-2 px-4 text-sm"
onClick={() => setIsCreateOpen(true)}
>
<PlusIcon size={18} /> {t("transaction.create")}
</Button>
</div>
{/* Search and Filters panel */}
<Card className="flex flex-col gap-3 p-4">
<div className="relative">
<span className="pointer-events-none absolute left-4 top-1/2 z-10 -translate-y-1/2 text-clay-text-muted">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-4-4" />
</svg>
</span>
<Input
aria-label={t("category.searchLabel")}
placeholder={t("transaction.filterSearchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-11"
/>
</div>
<div className="flex items-center justify-between gap-3">
<button
type="button"
className="flex items-center gap-1.5 font-nunito text-xs font-bold text-clay-primary hover:underline"
onClick={() => setIsFilterExpanded(!isFilterExpanded)}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className={`transition-all duration-200 ${isFilterExpanded ? "rotate-180" : ""}`}
>
<polyline points="6 9 12 15 18 9" />
</svg>
{t("transaction.filterTitle")}{" "}
{activeFiltersCount > 0 && `(${activeFiltersCount})`}
</button>
{activeFiltersCount > 0 && (
<button
type="button"
className="font-nunito text-xs font-bold text-clay-expense hover:underline"
onClick={resetFilters}
>
{t("transaction.resetFilters")}
</button>
)}
</div>
{/* Advanced Filter Inputs Container */}
{isFilterExpanded && (
<div className="flex flex-col gap-3 border-t border-clay-highlight/20 pt-3 transition-all duration-200">
<div className="grid grid-cols-2 gap-3">
<Select
label={t("transaction.filterWallet")}
options={walletFilterOptions}
value={walletId}
onChange={(e) => setWalletId(e.target.value)}
/>
<Select
label={t("transaction.filterCategory")}
options={categoryFilterOptions}
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<Select
label={t("transaction.filterType")}
options={[
{ value: "", label: t("transaction.allTypes") },
{ value: "INCOME", label: t("category.type.income") },
{ value: "EXPENSE", label: t("category.type.expense") },
]}
value={type}
onChange={(e) => setType(e.target.value)}
/>
<Select
label={t("transaction.sortBy")}
options={sortOptions}
value={sort}
onChange={(e) => setSort(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label={t("transaction.filterDateFrom")}
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
<Input
label={t("transaction.filterDateTo")}
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label={t("transaction.filterMinAmount")}
inputMode="decimal"
placeholder={t("transaction.amountPlaceholder")}
className="text-right tabular-nums"
value={formatAmountInput(minAmount, intlLocale)}
onChange={(e) => setMinAmount(parseAmountInput(e.target.value, intlLocale))}
/>
<Input
label={t("transaction.filterMaxAmount")}
inputMode="decimal"
placeholder={t("transaction.amountPlaceholder")}
className="text-right tabular-nums"
value={formatAmountInput(maxAmount, intlLocale)}
onChange={(e) => setMaxAmount(parseAmountInput(e.target.value, intlLocale))}
/>
</div>
</div>
)}
</Card>
{/* Loading skeleton state */}
{transactionsQuery.isLoading && (
<div className="flex flex-col gap-4">
{Array.from({ length: 4 }, (_, idx) => (
<TransactionSkeleton key={idx} />
))}
</div>
)}
{/* Error state */}
{transactionsQuery.isError && (
<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-expense/15 text-clay-expense shadow-clay-pressed">
!
</div>
<div>
<h2 className="clay-title-h3">{t("transaction.loadFailed")}</h2>
<p className="clay-caption mt-1">
{getErrorMessage(transactionsQuery.error, t("category.connectionFailed"))}
</p>
</div>
<Button
variant="secondary"
className="text-sm"
onClick={() => transactionsQuery.refetch()}
>
{t("common.retry")}
</Button>
</Card>
)}
{/* Empty state */}
{!transactionsQuery.isLoading && !transactionsQuery.isError && sortedDateKeys.length === 0 && (
<Card className="flex flex-col items-center gap-3 py-9 text-center">
<div className="rounded-clay-lg bg-clay-info/15 p-4 shadow-clay-pressed">
<TransactionIcon size={42} />
</div>
<div>
<h2 className="clay-title-h3">{t("transaction.empty")}</h2>
<p className="clay-caption mt-1">{t("transaction.emptyHint")}</p>
</div>
{activeFiltersCount > 0 && (
<Button variant="secondary" className="text-sm" onClick={resetFilters}>
{t("transaction.clearFilters")}
</Button>
)}
</Card>
)}
{/* List of chronological grouped transactions */}
{!transactionsQuery.isLoading && !transactionsQuery.isError && sortedDateKeys.length > 0 && (
<div className="flex flex-col gap-4">
{sortedDateKeys.map((dateKey) => (
<div key={dateKey} className="flex flex-col gap-2">
<div className="sticky top-[52px] z-10 -mx-4 bg-clay-bg/90 px-4 py-1.5 backdrop-blur-md">
<span className="font-baloo text-xs font-bold text-clay-text-muted">
{getGroupHeaderLabel(dateKey)}
</span>
</div>
<div className="flex flex-col gap-2">
{groupedTransactions[dateKey].map((item) => {
const isTxIncome = item.type === "INCOME";
return (
<div
key={item.id}
onClick={() => {
setSelectedTransaction(item);
setIsDetailOpen(true);
}}
className="flex cursor-pointer items-center justify-between gap-3 rounded-clay bg-clay-surface p-3.5 border border-clay-highlight/20 shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed transition-all duration-200 ease-in-out"
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<CategoryArtwork
icon={item.category.icon}
color={item.category.color}
size="sm"
/>
<div className="flex-1 min-w-0">
<h3 className="font-nunito text-sm font-bold text-clay-text truncate">
{item.description || getCategoryDisplayName(item.category, t)}
</h3>
<p className="clay-caption truncate mt-0.5">
{item.wallet.name}
{item.location && ` · ${item.location}`}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{item.receiptUrl && (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
className="text-clay-primary/65"
>
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
</svg>
)}
<span
className={`font-baloo text-sm font-bold ${
isTxIncome ? "text-clay-income" : "text-clay-expense"
}`}
>
{isTxIncome ? "+" : "-"}
{formatWalletBalance(item.amount, item.wallet.currency, intlLocale)}
</span>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
)}
{/* Pagination indicators */}
{!transactionsQuery.isLoading && !transactionsQuery.isError && totalPages > 1 && (
<div className="flex items-center justify-between gap-4 mt-4 border-t border-clay-highlight/15 pt-4">
<Button
variant="secondary"
className="text-xs px-3 py-1.5"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
{t("common.previous")}
</Button>
<span className="font-nunito text-xs font-bold text-clay-text-muted">
{t("common.pageOf", { page, total: totalPages })}
</span>
<Button
variant="secondary"
className="text-xs px-3 py-1.5"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
{t("common.next")}
</Button>
</div>
)}
</main>
{/* Transaction Details Modal */}
<TransactionDetailModal
isOpen={isDetailOpen}
transaction={selectedTransaction}
onClose={() => {
setIsDetailOpen(false);
setSelectedTransaction(undefined);
}}
onEdit={() => {
setIsEditOpen(true);
}}
onDelete={handleDelete}
/>
{/* Create Transaction Modal */}
<TransactionFormModal
isOpen={isCreateOpen}
isSubmitting={createMutation.isPending}
onClose={() => setIsCreateOpen(false)}
onSubmit={handleCreate}
/>
{/* Edit Transaction Modal */}
<TransactionFormModal
isOpen={isEditOpen}
transaction={selectedTransaction}
isSubmitting={false} // Loading handled via detail triggers
onClose={() => {
setIsEditOpen(false);
}}
onSubmit={handleEdit}
/>
</Page>
);
};
export default TransactionsPage;
import { apiClient } from "@/lib/api-client";
import {
CreateTransactionInput,
TransactionListResponse,
TransactionQuery,
TransactionResponse,
UpdateTransactionInput,
} from "@/types/transaction";
function ensureSuccess<T extends { success: boolean; message?: string }>(response: T): T {
if (!response.success) {
throw new Error(response.message || "Yêu cầu quản lý giao dịch thất bại");
}
return response;
}
export const transactionService = {
async getTransactions(query: TransactionQuery): Promise<TransactionListResponse> {
const response = await apiClient.get<TransactionListResponse>("/transactions", {
params: query,
});
return ensureSuccess(response.data);
},
async getTransaction(id: string): Promise<TransactionResponse> {
const response = await apiClient.get<TransactionResponse>(`/transactions/${id}`);
return ensureSuccess(response.data);
},
async createTransaction(input: CreateTransactionInput): Promise<TransactionResponse> {
const response = await apiClient.post<TransactionResponse>("/transactions", input);
return ensureSuccess(response.data);
},
async updateTransaction(id: string, input: UpdateTransactionInput): Promise<TransactionResponse> {
const response = await apiClient.put<TransactionResponse>(`/transactions/${id}`, input);
return ensureSuccess(response.data);
},
async deleteTransaction(id: string): Promise<TransactionResponse> {
const response = await apiClient.delete<TransactionResponse>(`/transactions/${id}`);
return ensureSuccess(response.data);
},
async uploadReceipt(id: string, file: File): Promise<TransactionResponse> {
const formData = new FormData();
formData.append("receipt", file);
const response = await apiClient.put<TransactionResponse>(
`/transactions/${id}/receipt`,
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
}
);
return ensureSuccess(response.data);
},
async deleteReceipt(id: string): Promise<TransactionResponse> {
const response = await apiClient.delete<TransactionResponse>(`/transactions/${id}/receipt`);
return ensureSuccess(response.data);
},
async getReceiptBlob(id: string): Promise<Blob> {
const response = await apiClient.get<Blob>(`/transactions/${id}/receipt`, {
responseType: "blob",
});
return response.data;
},
};
import { TransactionType } from "./category";
export type TransactionSortField =
| "amount"
| "date"
| "description"
| "createdAt"
| "updatedAt";
export type SortOrder = "asc" | "desc";
export interface TransactionWallet {
id: string;
name: string;
currency: string;
}
export interface TransactionCategory {
id: string;
name: string;
type: TransactionType;
icon: string | null;
color: string | null;
}
export interface Transaction {
id: string;
walletId: string;
categoryId: string;
amount: string;
type: TransactionType;
description: string | null;
receiptUrl: string | null;
location: string | null;
date: string;
createdAt: string;
updatedAt: string;
wallet: TransactionWallet;
category: TransactionCategory;
}
export interface TransactionQuery {
search?: string;
walletId?: string;
categoryId?: string;
type?: TransactionType;
dateFrom?: string;
dateTo?: string;
minAmount?: string;
maxAmount?: string;
sortBy: TransactionSortField;
order: SortOrder;
page: number;
limit: number;
}
export interface CreateTransactionInput {
walletId: string;
categoryId: string;
amount: string;
type: TransactionType;
description?: string | null;
location?: string | null;
date: string;
}
export interface UpdateTransactionInput {
walletId?: string;
categoryId?: string;
amount?: string;
type?: TransactionType;
description?: string | null;
location?: string | null;
date?: string;
}
export interface PaginationMeta {
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface TransactionListResponse {
success: boolean;
message?: string;
data: Transaction[];
meta: PaginationMeta;
errors?: unknown[] | null;
}
export interface TransactionResponse {
success: boolean;
message?: string;
data: Transaction;
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