Commit dffa28c4 authored by ThinhNC's avatar ThinhNC

feat(categories): add frontend category management

parent 52e200d9
......@@ -13,6 +13,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- **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.
- **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.
- **Khoảng trống và nút điều khiển trên header hệ thống**: Zalo Mini App mặc định hiển thị `zaui-header` ở phía trên. Mọi page/layout phải chừa đủ khoảng cách phía trên (tính cả safe area khi cần) để nội dung và phần tử tương tác không bị header che khuất. Cụm điều khiển riêng của app (hiện gồm theme và ngôn ngữ) phải nằm trong `.finwise-header-controls`, đặt về bên trái vùng native `right-buttons` rộng 96px; đồng thời `zaui-header` phải dành đủ `padding-right` cho cả `right-buttons` và cụm này. Không đặt từng nút bằng offset `right` rời rạc vì có thể làm switch ngôn ngữ bị che khuất.
- **Cấu hình API**: Base URL mặc định là `http://localhost:7777/api/v1` (tương tác trực tiếp với port 7777 của Backend).
......@@ -36,6 +37,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- Đã kết nối frontend với API xác thực, React Query và Zustand auth store.
- 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.
## Khi cập nhật file này
......
......@@ -26,6 +26,7 @@ import ResetPasswordPage from "@/pages/auth/reset-password";
import ProfilePage from "@/pages/profile/index";
import WalletsPage from "@/pages/wallets/index";
import WalletDetailPage from "@/pages/wallets/detail";
import CategoriesPage from "@/pages/categories/index";
const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { setAuth, clearAuth, setInitialized } = useAuthStore();
......@@ -86,6 +87,7 @@ const Layout = () => {
<Route path="/profile" element={<AuthGuard><ProfilePage /></AuthGuard>}></Route>
<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="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route>
</AnimationRoutes>
</AuthInitializer>
......
import React from "react";
interface CategoryArtworkProps {
icon?: string | null;
color?: string | null;
size?: "sm" | "md" | "lg";
archived?: boolean;
}
const dimensions = {
sm: "h-10 w-10 rounded-clay-sm",
md: "h-12 w-12 rounded-clay",
lg: "h-16 w-16 rounded-clay-lg",
};
function CategoryGlyph({ icon }: { icon: string }) {
if (["briefcase", "work"].includes(icon)) {
return <><rect x="3" y="7" width="18" height="13" rx="3" /><path d="M9 7V5h6v2M3 12h18M10 12v2h4v-2" /></>;
}
if (["trending-up", "investment"].includes(icon)) {
return <><path d="m4 17 6-6 4 4 6-8" /><path d="M15 7h5v5" /></>;
}
if (["utensils", "food"].includes(icon)) {
return <><path d="M7 3v8m-3-8v5a3 3 0 0 0 6 0V3M7 11v10M17 3c-2 2-3 5-3 8h6c0-3-1-6-3-8Zm0 8v10" /></>;
}
if (["shopping-basket", "groceries"].includes(icon)) {
return <><path d="m5 10 3-6m11 6-3-6M3 10h18l-2 10H5L3 10Z" /><path d="M9 14v3m6-3v3" /></>;
}
if (["chef-hat", "restaurant"].includes(icon)) {
return <><path d="M6 13a4 4 0 0 1 1-8 5 5 0 0 1 10 0 4 4 0 0 1 1 8v7H6v-7Z" /><path d="M6 16h12" /></>;
}
if (["car", "transport"].includes(icon)) {
return <><path d="m5 16-1-3 2-6h12l2 6-1 3H5Z" /><circle cx="7" cy="17" r="2" /><circle cx="17" cy="17" r="2" /><path d="M6 12h12" /></>;
}
if (["shopping-bag", "shopping"].includes(icon)) {
return <><path d="M5 8h14l1 13H4L5 8Z" /><path d="M9 9V6a3 3 0 0 1 6 0v3" /></>;
}
if (["receipt", "bills"].includes(icon)) {
return <><path d="M6 3h12v18l-3-2-3 2-3-2-3 2V3Z" /><path d="M9 8h6m-6 4h6m-6 4h4" /></>;
}
if (["heart-pulse", "health"].includes(icon)) {
return <><path d="M20 5c-2-2-6-2-8 1-2-3-6-3-8-1-3 3-1 8 8 15 9-7 11-12 8-15Z" /><path d="M6 12h3l1-3 3 6 1-3h4" /></>;
}
if (["gamepad-2", "entertainment"].includes(icon)) {
return <><path d="M7 8h10c3 0 5 3 4 7l-1 4c-1 2-3 2-4 0l-1-2H9l-1 2c-1 2-3 2-4 0l-1-4c-1-4 1-7 4-7Z" /><path d="M7 11v4m-2-2h4m7-1h.01m2 2h.01" /></>;
}
if (["graduation-cap", "education"].includes(icon)) {
return <><path d="m2 9 10-5 10 5-10 5L2 9Z" /><path d="M6 11v5c3 3 9 3 12 0v-5m4-2v7" /></>;
}
if (["home", "housing"].includes(icon)) {
return <><path d="m3 11 9-8 9 8v10H3V11Z" /><path d="M9 21v-6h6v6" /></>;
}
if (["gift", "bonus"].includes(icon)) {
return <><rect x="3" y="9" width="18" height="12" rx="2" /><path d="M12 9v12M3 13h18M7 9c-3 0-3-5 0-5 2 0 5 5 5 5m5 0c3 0 3-5 0-5-2 0-5 5-5 5" /></>;
}
return <><circle cx="12" cy="12" r="9" /><path d="M12 8v8m-4-4h8" /></>;
}
export const CategoryArtwork: React.FC<CategoryArtworkProps> = ({
icon = "circle-plus",
color = "#8B7CF6",
size = "md",
archived = false,
}) => (
<div
aria-hidden="true"
className={`${dimensions[size]} flex shrink-0 items-center justify-center border-2 border-clay-highlight/50 text-white shadow-clay-raised transition-all duration-200 ease-in-out ${archived ? "grayscale opacity-55" : ""}`}
style={{ backgroundColor: color || "#8B7CF6" }}
>
<svg
width={size === "lg" ? 34 : size === "sm" ? 22 : 26}
height={size === "lg" ? 34 : size === "sm" ? 22 : 26}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<CategoryGlyph icon={icon || "circle-plus"} />
</svg>
</div>
);
......@@ -16,6 +16,7 @@ const PAGE_TITLES: ReadonlyArray<{
{ matches: (pathname) => pathname === "/profile", key: "document.profile" },
{ matches: (pathname) => pathname === "/wallets", key: "document.wallets" },
{ matches: (pathname) => pathname.startsWith("/wallets/"), key: "document.walletDetail" },
{ matches: (pathname) => pathname === "/categories", key: "document.categories" },
{ matches: (pathname) => pathname === "/style-guide", key: "document.styleGuide" },
];
......
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { categoryService } from "@/services/category.service";
import { CategoryInput, CategoryTreeQuery, UpdateCategoryInput } from "@/types/category";
export const categoryKeys = {
all: ["categories"] as const,
trees: () => [...categoryKeys.all, "tree"] as const,
tree: (query: CategoryTreeQuery) => [...categoryKeys.trees(), query] as const,
};
export function useCategoryTree(query: CategoryTreeQuery) {
return useQuery({
queryKey: categoryKeys.tree(query),
queryFn: () => categoryService.getTree(query),
staleTime: 60_000,
});
}
export function useCreateCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CategoryInput) => categoryService.createCategory(input),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: categoryKeys.all });
},
});
}
export function useUpdateCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: string; input: UpdateCategoryInput }) => (
categoryService.updateCategory(id, input)
),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: categoryKeys.all });
},
});
}
function useCategoryAction(action: (id: string) => Promise<unknown>) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: action,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: categoryKeys.all });
},
});
}
export function useArchiveCategory() {
return useCategoryAction(categoryService.archiveCategory);
}
export function useRestoreCategory() {
return useCategoryAction(categoryService.restoreCategory);
}
......@@ -3,6 +3,7 @@
"appName": "FinWise",
"cancel": "Cancel",
"save": "Save",
"edit": "Edit",
"saving": "Saving...",
"delete": "Delete",
"retry": "Try again",
......@@ -42,6 +43,7 @@
"profile": "Account",
"wallets": "My Wallets",
"walletDetail": "Wallet Details",
"categories": "Categories",
"styleGuide": "Style Guide"
},
"validation": {
......@@ -64,7 +66,10 @@
"balanceInvalid": "Balance supports up to 16 digits and 2 decimal places",
"currencyLength": "Currency code must contain exactly 3 letters",
"currencyLetters": "Currency code can contain letters only",
"descriptionMax": "Description cannot exceed 500 characters"
"descriptionMax": "Description cannot exceed 500 characters",
"categoryNameRequired": "Enter a category name",
"categoryNameMax": "Category name cannot exceed 100 characters",
"colorInvalid": "Choose a valid category color"
},
"auth": {
"email": "Email",
......@@ -143,6 +148,7 @@
"authTitle": "System authentication",
"authDescription": "You are signed in. Upcoming API requests will be authenticated automatically using secure cookies and JWT.",
"wallets": "Manage wallets",
"categories": "Manage categories",
"profile": "My profile",
"styleGuide": "Explore Style Guide"
},
......@@ -293,6 +299,102 @@
"icons": { "wallet": "Wallet", "cash": "Cash", "bank": "Bank", "card": "Card", "savings": "Savings" },
"sort": { "createdDesc": "Recently created", "updatedDesc": "Recently updated", "nameAsc": "Name A → Z", "nameDesc": "Name Z → A", "balanceDesc": "Highest balance", "balanceAsc": "Lowest balance" }
},
"category": {
"header": "Categories",
"overview": "Category overview",
"visibleCount": "categories in the current view",
"incomeCount": "{{count}} income",
"expenseCount": "{{count}} expense",
"treeTitle": "Category tree",
"syncing": "Syncing...",
"create": "New category",
"createFirst": "Create first category",
"searchLabel": "Search categories",
"searchPlaceholder": "Search by category name...",
"sourceLabel": "Category source",
"sortLabel": "Sort categories",
"includeArchived": "Show archived categories",
"includeArchivedHint": "Archived personal categories appear with a muted style.",
"loadingList": "Loading category tree",
"loadFailed": "Could not load categories",
"connectionFailed": "Check your connection and try again.",
"notFound": "No matching categories found",
"empty": "No categories yet",
"searchHint": "Try another keyword or adjust the filters.",
"emptyHint": "Create a personal category to organize your transactions.",
"system": "System",
"personal": "Personal",
"rootCategory": "Top-level category",
"childCategory": "Subcategory",
"childCount": "{{count}} subcategories",
"expand": "Expand subcategories",
"collapse": "Collapse subcategories",
"archive": "Archive",
"restore": "Restore",
"createSuccess": "Category created successfully.",
"createFailed": "Could not create the category. Check for a duplicate name and try again.",
"updateSuccess": "Category changes saved.",
"updateFailed": "Could not update the category. It may be in use or conflict with another category.",
"archiveSuccess": "Category and its personal subcategories were archived.",
"archiveFailed": "Could not archive the category.",
"restoreSuccess": "Category restored successfully.",
"restoreFailed": "Restore its parent category first, then try again.",
"archiveTitle": "Archive category?",
"archiveConfirm": "Confirm archive",
"archiveDescription": "“{{name}}” and all of its personal subcategories will be archived. Existing transaction history is preserved.",
"restoreTitle": "Restore category?",
"restoreConfirm": "Confirm restore",
"restoreDescription": "“{{name}}” will become available for new transactions again. Its parent must already be active.",
"type": { "all": "All", "income": "Income", "expense": "Expense" },
"source": { "all": "All categories", "user": "Personal", "system": "System" },
"sort": { "nameAsc": "Name A → Z", "nameDesc": "Name Z → A", "createdDesc": "Recently created", "updatedDesc": "Recently updated" },
"form": {
"editTitle": "Edit category",
"createTitle": "Create category",
"saveChanges": "Save changes",
"preview": "Category preview",
"previewHint": "Choose an icon and identifying color",
"name": "Category name *",
"namePlaceholder": "For example: Coffee",
"type": "Transaction type *",
"parent": "Parent category",
"noParent": "No parent · top level",
"parentHint": "Only active categories of the same transaction type can be selected.",
"icon": "Icon",
"color": "Category color",
"chooseColor": "Choose color {{color}}"
},
"icons": {
"briefcase": "Work",
"trending-up": "Investment",
"utensils": "Food",
"shopping-basket": "Groceries",
"car": "Transport",
"shopping-bag": "Shopping",
"receipt": "Bills",
"heart-pulse": "Health",
"gamepad-2": "Entertainment",
"graduation-cap": "Education",
"home": "Home",
"gift": "Gift",
"circle-plus": "Other"
},
"systemNames": {
"salary": "Salary",
"investment": "Investment",
"otherIncome": "Other Income",
"foodDining": "Food & Dining",
"groceries": "Groceries",
"restaurants": "Restaurants",
"transport": "Transport",
"shopping": "Shopping",
"billsUtilities": "Bills & Utilities",
"health": "Health",
"entertainment": "Entertainment",
"education": "Education",
"otherExpense": "Other Expense"
}
},
"styleGuide": {
"header": "Style Guide & Design System",
"title": "Claymorphism Theme System",
......
......@@ -3,6 +3,7 @@
"appName": "FinWise",
"cancel": "Hủy",
"save": "Lưu",
"edit": "Chỉnh sửa",
"saving": "Đang lưu...",
"delete": "Xóa",
"retry": "Thử lại",
......@@ -42,6 +43,7 @@
"profile": "Tài khoản",
"wallets": "Ví của tôi",
"walletDetail": "Chi tiết ví",
"categories": "Quản lý danh mục",
"styleGuide": "Style Guide"
},
"validation": {
......@@ -64,7 +66,10 @@
"balanceInvalid": "Số dư có tối đa 16 chữ số và 2 số thập phân",
"currencyLength": "Mã tiền tệ gồm đúng 3 chữ cái",
"currencyLetters": "Mã tiền tệ chỉ gồm chữ cái",
"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",
"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ệ"
},
"auth": {
"email": "Email",
......@@ -143,6 +148,7 @@
"authTitle": "Xác thực hệ thống",
"authDescription": "Bạn đã đăng nhập thành công. Các API tiếp theo sẽ tự động được xác thực nhờ cookie an toàn và cơ chế JWT.",
"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"
},
......@@ -293,6 +299,111 @@
"icons": { "wallet": "Ví", "cash": "Tiền mặt", "bank": "Ngân hàng", "card": "Thẻ", "savings": "Tiết kiệm" },
"sort": { "createdDesc": "Mới tạo gần đây", "updatedDesc": "Mới cập nhật", "nameAsc": "Tên A → Z", "nameDesc": "Tên Z → A", "balanceDesc": "Số dư cao nhất", "balanceAsc": "Số dư thấp nhất" }
},
"category": {
"header": "Quản lý danh mục",
"overview": "Tổng quan danh mục",
"visibleCount": "Danh mục trong chế độ xem",
"incomeCount": "{{count}} thu nhập",
"expenseCount": "{{count}} chi tiêu",
"treeTitle": "Cây danh mục",
"syncing": "Đang đồng bộ...",
"create": "Tạo danh mục",
"createFirst": "Tạo danh mục đầu tiên",
"searchLabel": "Tìm kiếm danh mục",
"searchPlaceholder": "Tìm theo tên danh mục...",
"sourceLabel": "Nguồn danh mục",
"sortLabel": "Sắp xếp danh mục",
"includeArchived": "Hiện danh mục đã lưu trữ",
"includeArchivedHint": "Danh mục cá nhân đã lưu trữ sẽ được hiển thị mờ.",
"loadingList": "Đang tải cây danh mục",
"loadFailed": "Không thể tải danh mục",
"connectionFailed": "Vui lòng kiểm tra kết nối và thử lại.",
"notFound": "Không tìm thấy danh mục phù hợp",
"empty": "Chưa có danh mục nào",
"searchHint": "Thử từ khóa khác hoặc điều chỉnh bộ lọc.",
"emptyHint": "Tạo danh mục cá nhân để sắp xếp các giao dịch của bạn.",
"system": "Hệ thống",
"personal": "Cá nhân",
"rootCategory": "Danh mục cấp cao nhất",
"childCategory": "Danh mục con",
"childCount": "{{count}} danh mục con",
"expand": "Mở danh mục con",
"collapse": "Thu gọn danh mục con",
"archive": "Lưu trữ",
"restore": "Khôi phục",
"createSuccess": "Đã tạo danh mục thành công.",
"createFailed": "Không thể tạo danh mục. Hãy kiểm tra tên trùng và thử lại.",
"updateSuccess": "Đã lưu thay đổi danh mục.",
"updateFailed": "Không thể cập nhật danh mục. Danh mục có thể đang được sử dụng hoặc bị trùng.",
"archiveSuccess": "Đã lưu trữ danh mục và các danh mục con cá nhân.",
"archiveFailed": "Không thể lưu trữ danh mục.",
"restoreSuccess": "Đã khôi phục danh mục thành công.",
"restoreFailed": "Hãy khôi phục danh mục cha trước rồi thử lại.",
"archiveTitle": "Lưu trữ danh mục?",
"archiveConfirm": "Xác nhận lưu trữ",
"archiveDescription": "“{{name}}” và toàn bộ danh mục con cá nhân sẽ được lưu trữ. Lịch sử giao dịch hiện có vẫn được bảo toàn.",
"restoreTitle": "Khôi phục danh mục?",
"restoreConfirm": "Xác nhận khôi phục",
"restoreDescription": "“{{name}}” sẽ có thể dùng lại cho giao dịch mới. Danh mục cha phải đang hoạt động.",
"type": { "all": "Tất cả", "income": "Thu nhập", "expense": "Chi tiêu" },
"source": {
"all": "Tất cả danh mục",
"user": "Cá nhân",
"system": "Hệ thống"
},
"sort": {
"nameAsc": "Tên A → Z",
"nameDesc": "Tên Z → A",
"createdDesc": "Mới tạo gần đây",
"updatedDesc": "Mới cập nhật"
},
"form": {
"editTitle": "Chỉnh sửa danh mục",
"createTitle": "Tạo danh mục",
"saveChanges": "Lưu thay đổi",
"preview": "Xem trước danh mục",
"previewHint": "Chọn biểu tượng và màu nhận diện",
"name": "Tên danh mục *",
"namePlaceholder": "Ví dụ: Cà phê",
"type": "Loại giao dịch *",
"parent": "Danh mục cha",
"noParent": "Không có · cấp cao nhất",
"parentHint": "Chỉ có thể chọn danh mục đang hoạt động và cùng loại giao dịch.",
"icon": "Biểu tượng",
"color": "Màu danh mục",
"chooseColor": "Chọn màu {{color}}"
},
"icons": {
"briefcase": "Công việc",
"trending-up": "Đầu tư",
"utensils": "Ăn uống",
"shopping-basket": "Đi chợ",
"car": "Di chuyển",
"shopping-bag": "Mua sắm",
"receipt": "Hóa đơn",
"heart-pulse": "Sức khỏe",
"gamepad-2": "Giải trí",
"graduation-cap": "Giáo dục",
"home": "Nhà ở",
"gift": "Quà tặng",
"circle-plus": "Khác"
},
"systemNames": {
"salary": "Lương",
"investment": "Đầu tư",
"otherIncome": "Thu nhập khác",
"foodDining": "Ăn uống",
"groceries": "Đi chợ",
"restaurants": "Nhà hàng",
"transport": "Di chuyển",
"shopping": "Mua sắm",
"billsUtilities": "Hóa đơn & tiện ích",
"health": "Sức khỏe",
"entertainment": "Giải trí",
"education": "Giáo dục",
"otherExpense": "Chi tiêu khác"
}
},
"styleGuide": {
"header": "Style Guide & Design System",
"title": "Hệ thống giao diện Claymorphism",
......
import { TranslationFunction } from "@/i18n";
import { Category } from "@/types/category";
const SYSTEM_CATEGORY_NAME_KEYS: Readonly<Record<string, string>> = {
Salary: "category.systemNames.salary",
Investment: "category.systemNames.investment",
"Other Income": "category.systemNames.otherIncome",
"Food & Dining": "category.systemNames.foodDining",
Groceries: "category.systemNames.groceries",
Restaurants: "category.systemNames.restaurants",
Transport: "category.systemNames.transport",
Shopping: "category.systemNames.shopping",
"Bills & Utilities": "category.systemNames.billsUtilities",
Health: "category.systemNames.health",
Entertainment: "category.systemNames.entertainment",
Education: "category.systemNames.education",
"Other Expense": "category.systemNames.otherExpense",
};
export function getCategoryDisplayName(
category: Pick<Category, "isSystem" | "name">,
t: TranslationFunction,
): string {
if (!category.isSystem) return category.name;
const translationKey = SYSTEM_CATEGORY_NAME_KEYS[category.name];
return translationKey ? t(translationKey) : category.name;
}
export function normalizeCategorySearch(value: string, locale: string): string {
return value
.trim()
.toLocaleLowerCase(locale)
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/đ/g, "d");
}
import React, { useEffect, useMemo } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { CategoryArtwork } from "@/components/shared/CategoryArtwork";
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 { getCategoryDisplayName } from "@/lib/category-format";
import { Category, CategoryInput, CategoryTreeNode, TransactionType } from "@/types/category";
const CATEGORY_COLORS = [
"#16A34A", "#0D9488", "#3B82F6", "#6366F1", "#A855F7", "#EC4899", "#EF4444", "#F97316", "#EAB308", "#64748B",
];
const CATEGORY_ICONS = [
"briefcase", "trending-up", "utensils", "shopping-basket", "car", "shopping-bag", "receipt", "heart-pulse", "gamepad-2", "graduation-cap", "home", "gift", "circle-plus",
];
const createCategorySchema = (t: TranslationFunction) => z.object({
name: z.string().trim().min(1, t("validation.categoryNameRequired")).max(100, t("validation.categoryNameMax")),
type: z.enum(["INCOME", "EXPENSE"]),
parentId: z.string(),
icon: z.string().trim().min(1).max(100),
color: z.string().regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, t("validation.colorInvalid")),
});
type CategoryFormValues = z.infer<ReturnType<typeof createCategorySchema>>;
interface CategoryFormModalProps {
isOpen: boolean;
category?: Category;
categoryTree: CategoryTreeNode[];
isSubmitting: boolean;
onClose: () => void;
onSubmit: (input: CategoryInput) => 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),
]);
}
function collectDescendantIds(nodes: CategoryTreeNode[], categoryId: string): Set<string> {
const descendants = new Set<string>();
const visit = (node: CategoryTreeNode, isDescendant: boolean) => {
const childOfTarget = isDescendant || node.parentId === categoryId;
if (childOfTarget) descendants.add(node.id);
node.children.forEach((child) => visit(child, childOfTarget));
};
nodes.forEach((node) => visit(node, false));
return descendants;
}
function getDefaultValues(category?: Category): CategoryFormValues {
return {
name: category?.name || "",
type: category?.type || "EXPENSE",
parentId: category?.parentId || "",
icon: category?.icon || "circle-plus",
color: category?.color || "#8B7CF6",
};
}
export const CategoryFormModal: React.FC<CategoryFormModalProps> = ({
isOpen,
category,
categoryTree,
isSubmitting,
onClose,
onSubmit,
}) => {
const { t } = useI18n();
const schema = useMemo(() => createCategorySchema(t), [t]);
const formId = category ? `edit-category-${category.id}` : "create-category";
const {
register,
handleSubmit,
reset,
setValue,
watch,
formState: { errors },
} = useForm<CategoryFormValues>({
resolver: zodResolver(schema),
defaultValues: getDefaultValues(category),
});
useEffect(() => {
if (isOpen) reset(getDefaultValues(category));
}, [category, isOpen, reset]);
const selectedType = watch("type") as TransactionType;
const selectedParent = watch("parentId");
const selectedIcon = watch("icon");
const selectedColor = watch("color");
const descendants = useMemo(
() => category ? collectDescendantIds(categoryTree, category.id) : new Set<string>(),
[category, categoryTree],
);
const parentOptions = useMemo(() => [
{ value: "", label: t("category.form.noParent") },
...flattenTree(categoryTree)
.filter(({ category: option }) => (
option.type === selectedType
&& !option.isArchived
&& option.id !== category?.id
&& !descendants.has(option.id)
))
.map(({ category: option, depth }) => ({
value: option.id,
label: `${"— ".repeat(Math.min(depth, 3))}${getCategoryDisplayName(option, t)}${option.isSystem ? ` · ${t("category.system")}` : ""}`,
})),
], [category?.id, categoryTree, descendants, selectedType, t]);
useEffect(() => {
if (selectedParent && !parentOptions.some((option) => option.value === selectedParent)) {
setValue("parentId", "");
}
}, [parentOptions, selectedParent, setValue]);
const submitForm = (values: CategoryFormValues) => {
onSubmit({
name: values.name.trim(),
type: values.type,
parentId: values.parentId || null,
icon: values.icon,
color: values.color,
});
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={category ? t("category.form.editTitle") : t("category.form.createTitle")}
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}>
{isSubmitting ? t("common.saving") : category ? t("category.form.saveChanges") : t("category.create")}
</Button>
</>
)}
>
<form id={formId} className="flex flex-col gap-4" onSubmit={handleSubmit(submitForm)}>
<div className="flex items-center gap-3 rounded-clay bg-clay-bg p-3 shadow-clay-pressed">
<CategoryArtwork icon={selectedIcon} color={selectedColor} />
<div>
<p className="font-baloo font-bold text-clay-text">{t("category.form.preview")}</p>
<p className="clay-caption">{t("category.form.previewHint")}</p>
</div>
</div>
<Input
label={t("category.form.name")}
placeholder={t("category.form.namePlaceholder")}
error={errors.name?.message}
disabled={isSubmitting}
{...register("name")}
/>
<Select
label={t("category.form.type")}
options={[
{ value: "EXPENSE", label: t("category.type.expense") },
{ value: "INCOME", label: t("category.type.income") },
]}
error={errors.type?.message}
disabled={isSubmitting}
{...register("type")}
/>
<Select
label={t("category.form.parent")}
options={parentOptions}
error={errors.parentId?.message}
disabled={isSubmitting}
{...register("parentId")}
/>
<p className="clay-caption -mt-2 px-1">{t("category.form.parentHint")}</p>
<fieldset className="flex flex-col gap-2">
<legend className="px-1 font-nunito text-sm font-semibold text-clay-text">{t("category.form.icon")}</legend>
<div className="grid grid-cols-5 gap-2">
{CATEGORY_ICONS.map((icon) => (
<button
key={icon}
type="button"
aria-label={t(`category.icons.${icon}`)}
aria-pressed={selectedIcon === icon}
disabled={isSubmitting}
className={`flex justify-center rounded-clay-sm p-1.5 transition-all duration-200 ease-in-out ${selectedIcon === icon ? "bg-clay-primary/20 shadow-clay-pressed" : "bg-clay-bg shadow-clay-raised"}`}
onClick={() => setValue("icon", icon, { shouldValidate: true })}
>
<CategoryArtwork icon={icon} color={selectedColor} size="sm" />
</button>
))}
</div>
</fieldset>
<fieldset className="flex flex-col gap-2">
<legend className="px-1 font-nunito text-sm font-semibold text-clay-text">{t("category.form.color")}</legend>
<div className="flex flex-wrap gap-3 px-1">
{CATEGORY_COLORS.map((color) => (
<button
key={color}
type="button"
aria-label={t("category.form.chooseColor", { color })}
aria-pressed={selectedColor === color}
disabled={isSubmitting}
className={`h-9 w-9 rounded-full border-2 transition-all duration-200 ease-in-out ${selectedColor === color ? "scale-110 border-clay-text shadow-clay-raised" : "border-clay-highlight/70"}`}
style={{ backgroundColor: color }}
onClick={() => setValue("color", color, { shouldValidate: true })}
/>
))}
</div>
</fieldset>
</form>
</Modal>
);
};
import React from "react";
import { Card } from "@/components/ui/Card";
export const CategorySkeleton: React.FC<{ nested?: boolean }> = ({ nested = false }) => (
<div className={nested ? "ml-5 border-l-2 border-clay-border/40 pl-3" : ""}>
<Card className="flex animate-pulse items-center gap-3 !rounded-clay p-4">
<div className="h-11 w-11 shrink-0 rounded-clay-sm bg-clay-text-muted/15" />
<div className="flex-1 space-y-2">
<div className="h-4 w-2/5 rounded-full bg-clay-text-muted/15" />
<div className="h-3 w-3/5 rounded-full bg-clay-text-muted/10" />
</div>
<div className="h-7 w-16 rounded-full bg-clay-text-muted/10" />
</Card>
</div>
);
import React, { useEffect, useState } from "react";
import { CategoryArtwork } from "@/components/shared/CategoryArtwork";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { ChevronDownIcon, ChevronRightIcon } from "@/components/ui/icons";
import { useI18n } from "@/i18n";
import { getCategoryDisplayName } from "@/lib/category-format";
import { Category, CategoryTreeNode } from "@/types/category";
interface CategoryTreeProps {
nodes: CategoryTreeNode[];
expandAll: boolean;
onEdit: (category: Category) => void;
onArchive: (category: Category) => void;
onRestore: (category: Category) => void;
}
interface CategoryNodeProps extends Omit<CategoryTreeProps, "nodes"> {
node: CategoryTreeNode;
depth: number;
}
const CategoryNode: React.FC<CategoryNodeProps> = ({
node,
depth,
expandAll,
onArchive,
onEdit,
onRestore,
}) => {
const { t } = useI18n();
const hasChildren = node.children.length > 0;
const displayName = getCategoryDisplayName(node, t);
const [isExpanded, setIsExpanded] = useState(depth === 0);
useEffect(() => {
if (expandAll) setIsExpanded(true);
}, [expandAll]);
return (
<li className={depth > 0 ? "ml-4 border-l-2 border-clay-border/55 pl-3" : ""}>
<Card className={`!rounded-clay p-4 ${node.isArchived ? "bg-clay-text-muted/10" : ""}`}>
<div className="flex items-start gap-3">
<button
type="button"
disabled={!hasChildren}
aria-label={isExpanded ? t("category.collapse") : t("category.expand")}
aria-expanded={hasChildren ? isExpanded : undefined}
onClick={() => setIsExpanded((current) => !current)}
className={`mt-3 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-clay-text-muted transition-all duration-200 ease-in-out ${hasChildren ? "bg-clay-bg shadow-clay-pressed hover:text-clay-primary" : "opacity-0"}`}
>
{isExpanded ? <ChevronDownIcon size={15} /> : <ChevronRightIcon size={15} />}
</button>
<CategoryArtwork icon={node.icon} color={node.color} archived={node.isArchived} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate font-baloo text-base font-bold text-clay-text">{displayName}</h3>
<Badge type={node.type === "INCOME" ? "income" : "expense"}>
{t(`category.type.${node.type.toLocaleLowerCase()}`)}
</Badge>
<Badge type={node.isSystem ? "info" : "primary"}>
{node.isSystem ? t("category.system") : t("category.personal")}
</Badge>
{node.isArchived && <Badge type="warning">{t("common.archived")}</Badge>}
</div>
<p className="clay-caption mt-1">
{hasChildren
? t("category.childCount", { count: node.children.length })
: depth > 0
? t("category.childCategory")
: t("category.rootCategory")}
</p>
</div>
</div>
{!node.isSystem && (
<div className="mt-3 flex flex-wrap justify-end gap-2 border-t border-clay-text-muted/10 pt-3">
{!node.isArchived && (
<>
<Button variant="ghost" className="px-3 py-1 text-sm" onClick={() => onEdit(node)}>
{t("common.edit")}
</Button>
<Button variant="ghost" className="px-3 py-1 text-sm text-clay-expense" onClick={() => onArchive(node)}>
{t("category.archive")}
</Button>
</>
)}
{node.isArchived && (
<Button variant="secondary" className="px-3 py-1 text-sm" onClick={() => onRestore(node)}>
{t("category.restore")}
</Button>
)}
</div>
)}
</Card>
{hasChildren && isExpanded && (
<ul className="mt-3 flex flex-col gap-3">
{node.children.map((child) => (
<CategoryNode
key={child.id}
node={child}
depth={depth + 1}
expandAll={expandAll}
onEdit={onEdit}
onArchive={onArchive}
onRestore={onRestore}
/>
))}
</ul>
)}
</li>
);
};
export const CategoryTree: React.FC<CategoryTreeProps> = ({ nodes, ...props }) => (
<ul className="flex flex-col gap-4">
{nodes.map((node) => <CategoryNode key={node.id} node={node} depth={0} {...props} />)}
</ul>
);
import React, { useDeferredValue, useMemo, useState } from "react";
import { Header, Page, useNavigate, useSnackbar } from "zmp-ui";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select";
import { Tabs } from "@/components/ui/Tabs";
import { IconGradients, PlusIcon } from "@/components/ui/icons";
import {
useArchiveCategory,
useCategoryTree,
useCreateCategory,
useRestoreCategory,
useUpdateCategory,
} from "@/hooks/use-categories";
import { TranslationFunction, useI18n } from "@/i18n";
import { getCategoryDisplayName, normalizeCategorySearch } from "@/lib/category-format";
import { getErrorMessage } from "@/lib/error-message";
import {
Category,
CategoryInput,
CategorySortField,
CategorySortOrder,
CategorySource,
CategoryTreeNode,
TransactionType,
} from "@/types/category";
import { CategoryFormModal } from "./components/CategoryFormModal";
import { CategorySkeleton } from "./components/CategorySkeleton";
import { CategoryTree } from "./components/CategoryTree";
type TypeFilter = "ALL" | TransactionType;
type ConfirmAction = { type: "archive" | "restore"; category: Category } | null;
function sortTree(
nodes: CategoryTreeNode[],
sortBy: CategorySortField,
order: CategorySortOrder,
locale: string,
t: TranslationFunction,
): CategoryTreeNode[] {
const direction = order === "asc" ? 1 : -1;
const collator = new Intl.Collator(locale, { sensitivity: "base", numeric: true });
const compare = (left: CategoryTreeNode, right: CategoryTreeNode) => {
if (sortBy === "name") {
return collator.compare(
getCategoryDisplayName(left, t),
getCategoryDisplayName(right, t),
) * direction;
}
return (new Date(left[sortBy]).getTime() - new Date(right[sortBy]).getTime()) * direction;
};
return nodes
.map((node) => ({ ...node, children: sortTree(node.children, sortBy, order, locale, t) }))
.sort(compare);
}
function filterTree(
nodes: CategoryTreeNode[],
search: string,
locale: string,
t: TranslationFunction,
): CategoryTreeNode[] {
const normalizedSearch = normalizeCategorySearch(search, locale);
if (!normalizedSearch) return nodes;
return nodes.reduce<CategoryTreeNode[]>((matches, node) => {
const children = filterTree(node.children, search, locale, t);
const localizedName = normalizeCategorySearch(getCategoryDisplayName(node, t), locale);
const apiName = normalizeCategorySearch(node.name, locale);
if (localizedName.includes(normalizedSearch) || apiName.includes(normalizedSearch) || children.length > 0) {
matches.push({ ...node, children });
}
return matches;
}, []);
}
function flattenTree(nodes: CategoryTreeNode[]): CategoryTreeNode[] {
return nodes.flatMap((node) => [node, ...flattenTree(node.children)]);
}
const CategoriesPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const { intlLocale, t } = useI18n();
const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search.trim());
const [typeFilter, setTypeFilter] = useState<TypeFilter>("ALL");
const [source, setSource] = useState<CategorySource>("ALL");
const [includeArchived, setIncludeArchived] = useState(false);
const [sort, setSort] = useState("name:asc");
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [editingCategory, setEditingCategory] = useState<Category | undefined>();
const [confirmAction, setConfirmAction] = useState<ConfirmAction>(null);
const [sortBy, order] = sort.split(":") as [CategorySortField, CategorySortOrder];
const treeQuery = useMemo(() => ({
...(typeFilter !== "ALL" ? { type: typeFilter } : {}),
source,
includeArchived,
}), [includeArchived, source, typeFilter]);
const parentTreeQuery = useMemo(() => ({ source: "ALL" as const, includeArchived: false }), []);
const categoriesQuery = useCategoryTree(treeQuery);
const parentCategoriesQuery = useCategoryTree(parentTreeQuery);
const createMutation = useCreateCategory();
const updateMutation = useUpdateCategory();
const archiveMutation = useArchiveCategory();
const restoreMutation = useRestoreCategory();
const localizedTree = useMemo(
() => filterTree(categoriesQuery.data?.data || [], deferredSearch, intlLocale, t),
[categoriesQuery.data?.data, deferredSearch, intlLocale, t],
);
const sortedTree = useMemo(
() => sortTree(localizedTree, sortBy, order, intlLocale, t),
[intlLocale, localizedTree, order, sortBy, t],
);
const visibleCategories = useMemo(() => flattenTree(sortedTree), [sortedTree]);
const visibleIncome = visibleCategories.filter((category) => category.type === "INCOME").length;
const visibleExpense = visibleCategories.filter((category) => category.type === "EXPENSE").length;
const showError = (error: unknown, fallback: string) => {
openSnackbar({ type: "error", text: getErrorMessage(error, fallback) });
};
const handleCreate = (input: CategoryInput) => {
createMutation.mutate(input, {
onSuccess: () => {
setIsCreateOpen(false);
openSnackbar({ type: "success", text: t("category.createSuccess") });
},
onError: (error) => showError(error, t("category.createFailed")),
});
};
const handleUpdate = (input: CategoryInput) => {
if (!editingCategory) return;
updateMutation.mutate({ id: editingCategory.id, input }, {
onSuccess: () => {
setEditingCategory(undefined);
openSnackbar({ type: "success", text: t("category.updateSuccess") });
},
onError: (error) => showError(error, t("category.updateFailed")),
});
};
const handleConfirmedAction = () => {
if (!confirmAction) return;
const mutation = confirmAction.type === "archive" ? archiveMutation : restoreMutation;
const successKey = confirmAction.type === "archive" ? "category.archiveSuccess" : "category.restoreSuccess";
const errorKey = confirmAction.type === "archive" ? "category.archiveFailed" : "category.restoreFailed";
mutation.mutate(confirmAction.category.id, {
onSuccess: () => {
setConfirmAction(null);
openSnackbar({ type: "success", text: t(successKey) });
},
onError: (error) => showError(error, t(errorKey)),
});
};
const isActionPending = archiveMutation.isPending || restoreMutation.isPending;
const isFiltered = Boolean(deferredSearch) || typeFilter !== "ALL" || source !== "ALL" || includeArchived;
return (
<Page className="page">
<Header title={t("category.header")} showBackIcon onBackClick={() => navigate("/")} />
<IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12">
<Card className="relative overflow-hidden !bg-clay-primary-dark p-5 !text-clay-on-primary">
<div className="absolute -right-7 -top-8 h-28 w-28 rounded-full bg-clay-on-primary/10" />
<div className="absolute -bottom-10 left-20 h-24 w-24 rounded-full bg-clay-on-primary/10" />
<div className="relative flex items-start justify-between gap-4">
<div>
<p className="font-nunito text-sm font-bold text-clay-on-primary">{t("category.overview")}</p>
<p className="mt-1 font-baloo text-3xl font-bold text-clay-on-primary">
{categoriesQuery.isLoading ? "—" : visibleCategories.length}
</p>
<p className="font-nunito text-xs font-semibold text-clay-on-primary/80">{t("category.visibleCount")}</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs font-bold">
<span className="rounded-full bg-clay-income px-3 py-1 text-clay-on-status">{t("category.incomeCount", { count: visibleIncome })}</span>
<span className="rounded-full bg-clay-expense px-3 py-1 text-clay-on-status">{t("category.expenseCount", { count: visibleExpense })}</span>
</div>
</div>
<Button
variant="secondary"
className="relative gap-1 px-4 py-2 text-sm"
onClick={() => setIsCreateOpen(true)}
>
<PlusIcon size={17} /> {t("category.create")}
</Button>
</div>
</Card>
<Card className="flex flex-col gap-4 p-4">
<Tabs
activeTab={typeFilter}
onChange={(key) => setTypeFilter(key as TypeFilter)}
tabs={[
{ key: "ALL", label: t("category.type.all") },
{ key: "EXPENSE", label: t("category.type.expense") },
{ key: "INCOME", label: t("category.type.income") },
]}
/>
<Input
label={t("category.searchLabel")}
value={search}
maxLength={100}
placeholder={t("category.searchPlaceholder")}
onChange={(event) => setSearch(event.target.value)}
endAdornment={(
<svg aria-hidden="true" width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round">
<circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" />
</svg>
)}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Select
label={t("category.sourceLabel")}
value={source}
onChange={(event) => setSource(event.target.value as CategorySource)}
options={[
{ value: "ALL", label: t("category.source.all") },
{ value: "USER", label: t("category.source.user") },
{ value: "SYSTEM", label: t("category.source.system") },
]}
/>
<Select
label={t("category.sortLabel")}
value={sort}
onChange={(event) => setSort(event.target.value)}
options={[
{ value: "name:asc", label: t("category.sort.nameAsc") },
{ value: "name:desc", label: t("category.sort.nameDesc") },
{ value: "createdAt:desc", label: t("category.sort.createdDesc") },
{ value: "updatedAt:desc", label: t("category.sort.updatedDesc") },
]}
/>
</div>
<label className="flex cursor-pointer items-center gap-3 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
<input
type="checkbox"
checked={includeArchived}
className="h-4 w-4 accent-clay-primary"
onChange={(event) => setIncludeArchived(event.target.checked)}
/>
<span>
<span className="block font-nunito text-sm font-bold text-clay-text">{t("category.includeArchived")}</span>
<span className="clay-caption block">{t("category.includeArchivedHint")}</span>
</span>
</label>
</Card>
<section aria-labelledby="category-tree-heading" className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3 px-1">
<div>
<h2 id="category-tree-heading" className="clay-title-h2">{t("category.treeTitle")}</h2>
<p className="clay-caption">{t("common.results", { count: visibleCategories.length })}</p>
</div>
{categoriesQuery.isFetching && !categoriesQuery.isLoading && (
<span className="clay-caption animate-pulse">{t("category.syncing")}</span>
)}
</div>
{categoriesQuery.isLoading && (
<div className="flex flex-col gap-3" aria-label={t("category.loadingList")}>
<CategorySkeleton />
<CategorySkeleton nested />
<CategorySkeleton />
</div>
)}
{categoriesQuery.isError && (
<Card className="flex flex-col items-center gap-3 py-9 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-clay bg-clay-expense/15 font-baloo text-2xl font-bold text-clay-expense shadow-clay-pressed">!</div>
<div>
<h3 className="clay-title-h3">{t("category.loadFailed")}</h3>
<p className="clay-caption mt-1">{t("category.connectionFailed")}</p>
</div>
<Button variant="secondary" className="text-sm" onClick={() => categoriesQuery.refetch()}>{t("common.retry")}</Button>
</Card>
)}
{!categoriesQuery.isLoading && !categoriesQuery.isError && sortedTree.length === 0 && (
<Card className="flex flex-col items-center gap-4 py-9 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-clay-lg bg-clay-primary/15 text-clay-primary shadow-clay-pressed">
<PlusIcon size={28} />
</div>
<div>
<h3 className="clay-title-h3">{isFiltered ? t("category.notFound") : t("category.empty")}</h3>
<p className="clay-caption mt-1 max-w-xs">{isFiltered ? t("category.searchHint") : t("category.emptyHint")}</p>
</div>
{!isFiltered && <Button className="text-sm" onClick={() => setIsCreateOpen(true)}>{t("category.createFirst")}</Button>}
</Card>
)}
{sortedTree.length > 0 && (
<CategoryTree
nodes={sortedTree}
expandAll={Boolean(deferredSearch)}
onEdit={setEditingCategory}
onArchive={(category) => setConfirmAction({ type: "archive", category })}
onRestore={(category) => setConfirmAction({ type: "restore", category })}
/>
)}
</section>
</main>
<CategoryFormModal
isOpen={isCreateOpen}
categoryTree={parentCategoriesQuery.data?.data || []}
isSubmitting={createMutation.isPending}
onClose={() => setIsCreateOpen(false)}
onSubmit={handleCreate}
/>
<CategoryFormModal
isOpen={Boolean(editingCategory)}
category={editingCategory}
categoryTree={parentCategoriesQuery.data?.data || []}
isSubmitting={updateMutation.isPending}
onClose={() => setEditingCategory(undefined)}
onSubmit={handleUpdate}
/>
<Modal
isOpen={Boolean(confirmAction)}
onClose={() => setConfirmAction(null)}
title={confirmAction?.type === "archive" ? t("category.archiveTitle") : t("category.restoreTitle")}
footer={(
<>
<Button variant="ghost" className="px-4 text-sm" disabled={isActionPending} onClick={() => setConfirmAction(null)}>
{t("common.cancel")}
</Button>
<Button
className={`px-4 text-sm ${confirmAction?.type === "archive" ? "bg-clay-expense" : ""}`}
disabled={isActionPending}
onClick={handleConfirmedAction}
>
{isActionPending
? t("common.processing")
: confirmAction?.type === "archive"
? t("category.archiveConfirm")
: t("category.restoreConfirm")}
</Button>
</>
)}
>
<p className="clay-body text-sm">
{confirmAction?.type === "archive"
? t("category.archiveDescription", { name: confirmAction.category.name })
: t("category.restoreDescription", { name: confirmAction?.category.name || "" })}
</p>
</Modal>
</Page>
);
};
export default CategoriesPage;
......@@ -60,6 +60,13 @@ function HomePage() {
>
{t("home.wallets")}
</Button>
<Button
variant="secondary"
fullWidth
onClick={() => navigate("/categories")}
>
{t("home.categories")}
</Button>
<Button
variant="secondary"
fullWidth
......
import { apiClient } from "@/lib/api-client";
import {
CategoryInput,
CategoryResponse,
CategoryTreeQuery,
CategoryTreeResponse,
UpdateCategoryInput,
} from "@/types/category";
function ensureSuccess<T extends { success: boolean; message?: string }>(response: T): T {
if (!response.success) {
throw new Error(response.message || "Category request failed");
}
return response;
}
export const categoryService = {
async getTree(query: CategoryTreeQuery): Promise<CategoryTreeResponse> {
const response = await apiClient.get<CategoryTreeResponse>("/categories/tree", {
params: query,
});
return ensureSuccess(response.data);
},
async createCategory(input: CategoryInput): Promise<CategoryResponse> {
const response = await apiClient.post<CategoryResponse>("/categories", input);
return ensureSuccess(response.data);
},
async updateCategory(id: string, input: UpdateCategoryInput): Promise<CategoryResponse> {
const response = await apiClient.put<CategoryResponse>(`/categories/${id}`, input);
return ensureSuccess(response.data);
},
async archiveCategory(id: string): Promise<CategoryResponse> {
const response = await apiClient.delete<CategoryResponse>(`/categories/${id}`);
return ensureSuccess(response.data);
},
async restoreCategory(id: string): Promise<CategoryResponse> {
const response = await apiClient.patch<CategoryResponse>(`/categories/${id}/restore`);
return ensureSuccess(response.data);
},
};
export type TransactionType = "INCOME" | "EXPENSE";
export type CategorySource = "ALL" | "SYSTEM" | "USER";
export type CategorySortField = "name" | "createdAt" | "updatedAt";
export type CategorySortOrder = "asc" | "desc";
export interface Category {
id: string;
parentId: string | null;
name: string;
type: TransactionType;
icon: string | null;
color: string | null;
isSystem: boolean;
isArchived: boolean;
createdAt: string;
updatedAt: string;
}
export interface CategoryTreeNode extends Category {
children: CategoryTreeNode[];
}
export interface CategoryTreeQuery {
search?: string;
type?: TransactionType;
source: CategorySource;
includeArchived: boolean;
}
export interface CategoryInput {
name: string;
type: TransactionType;
parentId?: string | null;
icon?: string | null;
color?: string | null;
}
export type UpdateCategoryInput = Partial<CategoryInput>;
export interface CategoryTreeResponse {
success: boolean;
message?: string;
data: CategoryTreeNode[];
errors?: unknown[] | null;
}
export interface CategoryResponse {
success: boolean;
message?: string;
data: Category;
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