Commit 56df7923 authored by ThinhNC's avatar ThinhNC

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

parent dffa28c4
......@@ -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.
- **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.
- **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`.
- **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.
......@@ -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.
- 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.
- 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
......
......@@ -27,6 +27,7 @@ import ProfilePage from "@/pages/profile/index";
import WalletsPage from "@/pages/wallets/index";
import WalletDetailPage from "@/pages/wallets/detail";
import CategoriesPage from "@/pages/categories/index";
import TransactionsPage from "@/pages/transactions/index";
const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { setAuth, clearAuth, setInitialized } = useAuthStore();
......@@ -88,6 +89,7 @@ const Layout = () => {
<Route path="/wallets" element={<AuthGuard><WalletsPage /></AuthGuard>}></Route>
<Route path="/wallets/:id" element={<AuthGuard><WalletDetailPage /></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>
</AnimationRoutes>
</AuthInitializer>
......
......@@ -12,52 +12,57 @@ export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElemen
error?: string;
}
export const Select: React.FC<SelectProps> = ({
label,
options,
error,
className = "",
id,
...props
}) => {
const selectId = id || `select-${Math.random().toString(36).substr(2, 9)}`;
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({
label,
options,
error,
className = "",
id,
...props
}, ref) => {
const selectId = id || `select-${Math.random().toString(36).substr(2, 9)}`;
return (
<div className="flex flex-col gap-2 w-full relative">
{label && (
<label htmlFor={selectId} className="font-nunito font-semibold text-sm text-clay-text px-1">
{label}
</label>
)}
<div className="relative w-full">
<select
id={selectId}
className={`
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
transition-all duration-150 ease-in-out
focus:outline-none focus:border-clay-primary focus:ring-2 focus:ring-clay-primary/20
disabled:opacity-60 disabled:cursor-not-allowed
${error ? "border-clay-expense focus:border-clay-expense focus:ring-clay-expense/20" : ""}
${className}
`}
{...props}
>
{options.map((option) => (
<option key={option.value} value={option.value} className="bg-clay-surface text-clay-text">
{option.label}
</option>
))}
</select>
<div className="absolute right-3.5 top-1/2 -translate-y-1/2 pointer-events-none text-clay-text-muted">
<ChevronDownIcon size={16} />
return (
<div className="flex flex-col gap-2 w-full relative">
{label && (
<label htmlFor={selectId} className="font-nunito font-semibold text-sm text-clay-text px-1">
{label}
</label>
)}
<div className="relative w-full">
<select
id={selectId}
ref={ref}
className={`
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
transition-all duration-150 ease-in-out
focus:outline-none focus:border-clay-primary focus:ring-2 focus:ring-clay-primary/20
disabled:opacity-60 disabled:cursor-not-allowed
${error ? "border-clay-expense focus:border-clay-expense focus:ring-clay-expense/20" : ""}
${className}
`}
{...props}
>
{options.map((option) => (
<option key={option.value} value={option.value} className="bg-clay-surface text-clay-text">
{option.label}
</option>
))}
</select>
<div className="absolute right-3.5 top-1/2 -translate-y-1/2 pointer-events-none text-clay-text-muted">
<ChevronDownIcon size={16} />
</div>
</div>
{error && (
<span className="font-nunito text-xs text-clay-expense px-1">
{error}
</span>
)}
</div>
{error && (
<span className="font-nunito text-xs text-clay-expense px-1">
{error}
</span>
)}
</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 @@
"wallets": "My Wallets",
"walletDetail": "Wallet Details",
"categories": "Categories",
"styleGuide": "Style Guide"
"styleGuide": "Style Guide",
"transactions": "Transactions"
},
"validation": {
"emailRequired": "Email is required",
......@@ -69,7 +70,16 @@
"descriptionMax": "Description cannot exceed 500 characters",
"categoryNameRequired": "Enter a category name",
"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": {
"email": "Email",
......@@ -150,7 +160,8 @@
"wallets": "Manage wallets",
"categories": "Manage categories",
"profile": "My profile",
"styleGuide": "Explore Style Guide"
"styleGuide": "Explore Style Guide",
"transactions": "Manage transactions"
},
"profile": {
"header": "Account",
......@@ -497,5 +508,78 @@
"accessibility": {
"closeModal": "Close dialog",
"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 @@
"wallets": "Ví của tôi",
"walletDetail": "Chi tiết ví",
"categories": "Quản lý danh mục",
"styleGuide": "Style Guide"
"styleGuide": "Style Guide",
"transactions": "Quản lý giao dịch"
},
"validation": {
"emailRequired": "Email không được để trống",
......@@ -69,7 +70,16 @@
"descriptionMax": "Mô tả tối đa 500 ký tự",
"categoryNameRequired": "Vui lòng nhập tên danh mục",
"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": {
"email": "Email",
......@@ -150,7 +160,8 @@
"wallets": "Quản lý ví",
"categories": "Quản lý danh mục thu chi",
"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": {
"header": "Tài Khoản",
......@@ -506,5 +517,78 @@
"accessibility": {
"closeModal": "Đóng hộp thoại",
"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>> = {
};
export function getCategoryDisplayName(
category: Pick<Category, "isSystem" | "name">,
category: { isSystem?: boolean; name: string },
t: TranslationFunction,
): 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];
return translationKey ? t(translationKey) : category.name;
......
......@@ -60,6 +60,13 @@ function HomePage() {
>
{t("home.wallets")}
</Button>
<Button
variant="secondary"
fullWidth
onClick={() => navigate("/transactions")}
>
{t("home.transactions")}
</Button>
<Button
variant="secondary"
fullWidth
......
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 { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
......@@ -28,6 +28,7 @@ import {
import { useI18n } from "@/i18n";
const StyleGuidePage: React.FC = () => {
const navigate = useNavigate();
const { t } = useI18n();
// State for interactive tab component
const [activeTab, setActiveTab] = useState("tab-1");
......@@ -52,7 +53,7 @@ const StyleGuidePage: React.FC = () => {
return (
<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 */}
<IconGradients />
......
This diff is collapsed.
This diff is collapsed.
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>
);
This diff is collapsed.
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