Commit 4e0a8443 authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/wallet-management' into 'develop'

Feat/wallet management

See merge request !3
parents fd7cc404 24913d00
......@@ -11,6 +11,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- **Nguồn tài nguyên Fonts**: Load thông qua thẻ `<link>` của Google Fonts trực tiếp trong `index.html` để tối ưu thời gian tải trang.
- **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.
- **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`.
- **Khoảng trống cho 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ấ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).
- **Vite/ZMP entry**: Giữ `index.html` tại root repository và không cấu hình Vite `root: "./src"`. ZMP CLI khởi chạy dev server với project root; cấu hình khác sẽ khiến iframe app trả 404. Build output chuẩn là `www/` tại root.
- **Luồng xác thực**: Khi app mount, `AuthInitializer` gọi `/auth/me`; `AuthGuard` chỉ render private route sau khi khởi tạo xong và chuyển người dùng chưa đăng nhập tới `/login`. Cookie HTTP-only là cơ chế xác thực ưu tiên.
......@@ -21,7 +22,8 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- Đã có các màn hình đăng nhập, đăng ký, quên mật khẩu, đặt lại mật khẩu, trang chủ, hồ sơ và Style Guide.
- Đã 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.
- Các module tài chính chính (ví, giao dịch, ngân sách, tiết kiệm, báo cáo, AI Assistant) chưa được triển khai trong frontend hiện tại.
- 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ử.
- 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
......
......@@ -21,6 +21,8 @@ import RegisterPage from "@/pages/auth/register";
import ForgotPasswordPage from "@/pages/auth/forgot-password";
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";
const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { setAuth, clearAuth, setInitialized } = useAuthStore();
......@@ -65,6 +67,8 @@ const Layout = () => {
{/* Private Protected Routes */}
<Route path="/" element={<AuthGuard><HomePage /></AuthGuard>}></Route>
<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="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route>
</AnimationRoutes>
</AuthInitializer>
......
import React from "react";
interface WalletArtworkProps {
icon?: string | null;
color?: string | null;
size?: "sm" | "md" | "lg";
archived?: boolean;
}
const dimensions = {
sm: "w-11 h-11 rounded-clay-sm",
md: "w-14 h-14 rounded-clay",
lg: "w-20 h-20 rounded-clay-lg",
};
function ArtworkIcon({ icon }: { icon: string }) {
if (icon === "bank") {
return <path d="M4 9 12 4l8 5M6 10v7m4-7v7m4-7v7m4-7v7M4 20h16" />;
}
if (icon === "card") {
return <><rect x="3" y="5" width="18" height="14" rx="3" /><path d="M3 10h18M7 15h4" /></>;
}
if (icon === "cash") {
return <><rect x="3" y="6" width="18" height="12" rx="3" /><circle cx="12" cy="12" r="3" /><path d="M7 9H6v1m11-1h1v1M7 15H6v-1m11 1h1v-1" /></>;
}
if (icon === "savings") {
return <><path d="M5 11c0-4 3-7 8-7 4 0 7 2 7 6 0 2-1 4-3 5v3h-3v-2H9v2H6v-3c-1-1-2-2-2-4H2" /><circle cx="15.5" cy="8.5" r=".5" fill="currentColor" /></>;
}
return <><path d="M4 7h15a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h13" /><path d="M16 12h5v4h-5a2 2 0 0 1 0-4Z" /></>;
}
export const WalletArtwork: React.FC<WalletArtworkProps> = ({
icon = "wallet",
color = "#8B7CF6",
size = "md",
archived = false,
}) => (
<div
className={`${dimensions[size]} shrink-0 flex items-center justify-center text-white shadow-clay-raised border-2 border-white/50 transition-all duration-200 ease-in-out ${archived ? "grayscale opacity-60" : ""}`}
style={{ backgroundColor: color || "#8B7CF6" }}
aria-hidden="true"
>
<svg width={size === "lg" ? 40 : 28} height={size === "lg" ? 40 : 28} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<ArtworkIcon icon={icon || "wallet"} />
</svg>
</div>
);
import React from "react";
import { Badge } from "@/components/ui/Badge";
import { Card } from "@/components/ui/Card";
import { ChevronRightIcon } from "@/components/ui/icons";
import { formatWalletBalance } from "@/lib/wallet-format";
import { Wallet } from "@/types/wallet";
import { WalletArtwork } from "./WalletArtwork";
interface WalletCardProps {
wallet: Wallet;
onClick: () => void;
onSetDefault?: () => void;
isSettingDefault?: boolean;
}
export const WalletCard: React.FC<WalletCardProps> = ({
wallet,
onClick,
onSetDefault,
isSettingDefault = false,
}) => (
<Card
hoverable
className={`p-4 ${wallet.isArchived ? "opacity-75" : ""}`}
onClick={onClick}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
onClick();
}
}}
>
<div className="flex items-center gap-3">
<WalletArtwork icon={wallet.icon} color={wallet.color} archived={wallet.isArchived} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-baloo text-lg font-bold text-clay-text truncate">{wallet.name}</h3>
{wallet.isDefault && <Badge type="primary">Mặc định</Badge>}
{wallet.isArchived && <Badge type="warning">Đã lưu trữ</Badge>}
</div>
<p className="font-baloo text-xl font-bold text-clay-primary-dark truncate">
{formatWalletBalance(wallet.balance, wallet.currency)}
</p>
{wallet.description && <p className="clay-caption truncate mt-0.5">{wallet.description}</p>}
</div>
<ChevronRightIcon className="shrink-0 text-clay-text-muted" />
</div>
{!wallet.isDefault && !wallet.isArchived && onSetDefault && (
<button
type="button"
className="mt-3 w-full rounded-full bg-clay-bg px-3 py-2 font-nunito text-xs font-bold text-clay-primary-dark shadow-clay-pressed transition-all duration-200 ease-in-out hover:text-clay-primary disabled:opacity-50"
disabled={isSettingDefault}
onClick={(event) => {
event.stopPropagation();
onSetDefault();
}}
>
{isSettingDefault ? "Đang cập nhật..." : "Đặt làm ví mặc định"}
</button>
)}
</Card>
);
......@@ -7,7 +7,7 @@ body {
/* Custom style override for ZMP Page containers */
.page {
padding: 16px 16px 96px 16px;
padding: calc(var(--zaui-safe-area-inset-top, env(safe-area-inset-top, 0px)) + 60px) 16px 96px;
background-color: #F3F0FA; /* clay-bg */
min-height: 100vh;
}
......
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { walletService } from "@/services/wallet.service";
import { UpdateWalletInput, WalletInput, WalletQuery, WalletSearchQuery } from "@/types/wallet";
export const walletKeys = {
all: ["wallets"] as const,
lists: () => [...walletKeys.all, "list"] as const,
list: (query: WalletQuery) => [...walletKeys.lists(), query] as const,
details: () => [...walletKeys.all, "detail"] as const,
detail: (id: string) => [...walletKeys.details(), id] as const,
};
export function useWallets(query: WalletQuery) {
return useQuery({
queryKey: walletKeys.list(query),
queryFn: () => walletService.getWallets(query),
placeholderData: keepPreviousData,
staleTime: 60_000,
});
}
export function useWalletSearch(query: WalletSearchQuery, enabled: boolean) {
return useQuery({
queryKey: [...walletKeys.lists(), "search", query],
queryFn: () => walletService.getAllWallets(query),
enabled,
staleTime: 60_000,
});
}
export function useWallet(id: string) {
return useQuery({
queryKey: walletKeys.detail(id),
queryFn: () => walletService.getWallet(id),
enabled: Boolean(id),
staleTime: 60_000,
});
}
export function useCreateWallet() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: WalletInput) => walletService.createWallet(input),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
export function useUpdateWallet(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: UpdateWalletInput) => walletService.updateWallet(id, input),
onSuccess: async (response) => {
queryClient.setQueryData(walletKeys.detail(id), response);
await queryClient.invalidateQueries({ queryKey: walletKeys.lists() });
},
});
}
function useWalletAction(action: (id: string) => ReturnType<typeof walletService.setDefault>) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: action,
onSuccess: async (response) => {
queryClient.setQueryData(walletKeys.detail(response.data.id), response);
await queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
export function useSetDefaultWallet() {
return useWalletAction(walletService.setDefault);
}
export function useArchiveWallet() {
return useWalletAction(walletService.archiveWallet);
}
export function useRestoreWallet() {
return useWalletAction(walletService.restoreWallet);
}
import axios from "axios";
interface ErrorResponseBody {
message?: string;
}
export function getErrorMessage(error: unknown, fallback: string): string {
if (axios.isAxiosError<ErrorResponseBody>(error)) {
return error.response?.data?.message || fallback;
}
if (error instanceof Error && error.message) {
return error.message;
}
return fallback;
}
import { Wallet } from "@/types/wallet";
export const WALLET_COLORS = ["#8B7CF6", "#60A5FA", "#34D399", "#FBBF24", "#FB7185"] as const;
export const WALLET_ICONS = [
{ value: "wallet", label: "Ví" },
{ value: "cash", label: "Tiền mặt" },
{ value: "bank", label: "Ngân hàng" },
{ value: "card", label: "Thẻ" },
{ value: "savings", label: "Tiết kiệm" },
] as const;
export function formatWalletBalance(balance: string, currency: string): string {
const numericBalance = Number(balance);
if (!Number.isFinite(numericBalance)) {
return `${balance} ${currency}`;
}
try {
return new Intl.NumberFormat("vi-VN", {
style: "currency",
currency,
maximumFractionDigits: currency === "VND" ? 0 : 2,
}).format(numericBalance);
} catch {
return `${new Intl.NumberFormat("vi-VN", { maximumFractionDigits: 2 }).format(numericBalance)} ${currency}`;
}
}
export function groupBalancesByCurrency(wallets: Wallet[]): Array<{ currency: string; balance: string }> {
const balances = new Map<string, number>();
wallets
.filter((wallet) => !wallet.isArchived)
.forEach((wallet) => {
balances.set(wallet.currency, (balances.get(wallet.currency) || 0) + Number(wallet.balance));
});
return Array.from(balances.entries()).map(([currency, balance]) => ({
currency,
balance: balance.toFixed(2),
}));
}
......@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Avatar } from "@/components/ui/Avatar";
import { useAuthStore } from "@/stores/auth-store";
import { AIAssistantIcon, IconGradients } from "@/components/ui/icons";
import { AIAssistantIcon, IconGradients, WalletIcon } from "@/components/ui/icons";
function HomePage() {
const navigate = useNavigate();
......@@ -47,16 +47,24 @@ function HomePage() {
<Button
variant="primary"
fullWidth
onClick={() => navigate("/wallets")}
className="gap-2"
>
Quản lý ví
</Button>
<Button
variant="secondary"
fullWidth
onClick={() => navigate("/profile")}
>
Trang cá nhân của tôi 👤
Trang cá nhân của tôi
</Button>
<Button
variant="secondary"
fullWidth
onClick={() => navigate("/style-guide")}
>
Khám phá Style Guide 🚀
Khám phá Style Guide
</Button>
</div>
</Page>
......
......@@ -391,7 +391,7 @@ const ProfilePage: React.FC = () => {
{/* Global Logout Button */}
<div className="px-4">
<Button variant="secondary" onClick={handleLogout} fullWidth className="text-clay-expense">
Đăng Xuất Tài Khoản
Đăng Xuất
</Button>
</div>
</div>
......
import React, { useEffect } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { 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 { WALLET_COLORS, WALLET_ICONS } from "@/lib/wallet-format";
import { Wallet, WalletInput } from "@/types/wallet";
import { WalletArtwork } from "@/components/shared/WalletArtwork";
const balancePattern = /^-?(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
const walletFormSchema = z.object({
name: z.string().trim().min(1, "Vui lòng nhập tên ví").max(100, "Tên ví tối đa 100 ký tự"),
balance: z.string().trim().regex(balancePattern, "Số dư có tối đa 16 chữ số và 2 số thập phân"),
currency: z.string().trim().length(3, "Mã tiền tệ gồm đúng 3 chữ cái").regex(/^[A-Za-z]{3}$/, "Mã tiền tệ chỉ gồm chữ cái"),
icon: z.string().min(1),
color: z.string().regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/),
description: z.string().trim().max(500, "Mô tả tối đa 500 ký tự"),
isDefault: z.boolean(),
});
type WalletFormValues = z.infer<typeof walletFormSchema>;
interface WalletFormModalProps {
isOpen: boolean;
wallet?: Wallet;
isSubmitting: boolean;
onClose: () => void;
onSubmit: (input: WalletInput) => void;
}
function getDefaultValues(wallet?: Wallet): WalletFormValues {
return {
name: wallet?.name || "",
balance: wallet?.balance || "0",
currency: wallet?.currency || "VND",
icon: wallet?.icon || "wallet",
color: wallet?.color || WALLET_COLORS[0],
description: wallet?.description || "",
isDefault: wallet?.isDefault || false,
};
}
export const WalletFormModal: React.FC<WalletFormModalProps> = ({
isOpen,
wallet,
isSubmitting,
onClose,
onSubmit,
}) => {
const formId = wallet ? `edit-wallet-${wallet.id}` : "create-wallet";
const {
register,
handleSubmit,
reset,
watch,
setValue,
formState: { errors },
} = useForm<WalletFormValues>({
resolver: zodResolver(walletFormSchema),
defaultValues: getDefaultValues(wallet),
});
useEffect(() => {
if (isOpen) {
reset(getDefaultValues(wallet));
}
}, [isOpen, reset, wallet]);
const selectedIcon = watch("icon");
const selectedColor = watch("color");
const submitForm = (values: WalletFormValues) => {
onSubmit({
name: values.name.trim(),
balance: values.balance.trim(),
currency: values.currency.trim().toUpperCase(),
icon: values.icon,
color: values.color,
description: values.description.trim() || null,
...(!wallet ? { isDefault: values.isDefault } : {}),
});
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={wallet ? "Chỉnh sửa ví" : "Tạo ví mới"}
footer={(
<>
<Button type="button" variant="ghost" onClick={onClose} disabled={isSubmitting} className="text-sm px-4">
Hủy
</Button>
<Button type="submit" form={formId} disabled={isSubmitting} className="text-sm px-4">
{isSubmitting ? "Đang lưu..." : wallet ? "Lưu thay đổi" : "Tạo ví"}
</Button>
</>
)}
>
<form id={formId} onSubmit={handleSubmit(submitForm)} className="flex flex-col gap-4">
<div className="flex items-center gap-3 rounded-clay bg-clay-bg p-3 shadow-clay-pressed">
<WalletArtwork icon={selectedIcon} color={selectedColor} />
<div>
<p className="font-baloo font-bold text-clay-text">Xem trước ví</p>
<p className="clay-caption">Chọn biểu tượng và màu nhận diện</p>
</div>
</div>
<Input label="Tên ví *" placeholder="Ví dụ: Tiền mặt" error={errors.name?.message} disabled={isSubmitting} {...register("name")} />
<div className="grid grid-cols-[1fr_96px] gap-3">
<Input label="Số dư *" inputMode="decimal" placeholder="0" error={errors.balance?.message} disabled={isSubmitting} {...register("balance")} />
<Input label="Tiền tệ *" maxLength={3} placeholder="VND" error={errors.currency?.message} disabled={isSubmitting} className="uppercase" {...register("currency")} />
</div>
<fieldset className="flex flex-col gap-2">
<legend className="px-1 font-nunito text-sm font-semibold text-clay-text">Biểu tượng</legend>
<div className="grid grid-cols-5 gap-2">
{WALLET_ICONS.map((item) => (
<button
key={item.value}
type="button"
title={item.label}
aria-label={item.label}
aria-pressed={selectedIcon === item.value}
className={`flex justify-center rounded-clay-sm p-2 transition-all duration-200 ease-in-out ${selectedIcon === item.value ? "bg-clay-primary/20 shadow-clay-pressed" : "bg-clay-bg shadow-clay-raised"}`}
onClick={() => setValue("icon", item.value, { shouldValidate: true })}
>
<WalletArtwork icon={item.value} 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">Màu ví</legend>
<div className="flex gap-3 px-1">
{WALLET_COLORS.map((color) => (
<button
key={color}
type="button"
aria-label={`Chọn màu ${color}`}
aria-pressed={selectedColor === color}
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-white/70"}`}
style={{ backgroundColor: color }}
onClick={() => setValue("color", color, { shouldValidate: true })}
/>
))}
</div>
</fieldset>
<div className="flex flex-col gap-2">
<label htmlFor={`${formId}-description`} className="px-1 font-nunito text-sm font-semibold text-clay-text">Mô tả</label>
<textarea
id={`${formId}-description`}
rows={3}
placeholder="Mục đích sử dụng ví..."
disabled={isSubmitting}
className={`w-full resize-none rounded-clay-sm border bg-clay-bg px-4 py-3 font-nunito text-base text-clay-text shadow-clay-pressed transition-all duration-200 ease-in-out placeholder:text-clay-text-muted/65 focus:border-clay-primary focus:outline-none focus:ring-2 focus:ring-clay-primary/20 ${errors.description ? "border-clay-expense" : "border-transparent"}`}
{...register("description")}
/>
{errors.description && <span className="px-1 font-nunito text-xs text-clay-expense">{errors.description.message}</span>}
</div>
{!wallet && (
<label className="flex cursor-pointer items-start gap-3 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
<input type="checkbox" className="mt-1 h-4 w-4 accent-clay-primary" disabled={isSubmitting} {...register("isDefault")} />
<span>
<span className="block font-nunito text-sm font-bold text-clay-text">Đặt làm ví mặc định</span>
<span className="clay-caption block">Ví đầu tiên luôn tự động trở thành ví mặc định.</span>
</span>
</label>
)}
</form>
</Modal>
);
};
import React from "react";
export const WalletSkeleton: React.FC = () => (
<div className="animate-pulse rounded-clay-lg border border-white/40 bg-clay-surface p-4 shadow-clay-raised">
<div className="flex items-center gap-3">
<div className="h-14 w-14 rounded-clay bg-clay-primary/15" />
<div className="flex-1 space-y-2">
<div className="h-4 w-2/5 rounded-full bg-clay-primary/15" />
<div className="h-6 w-3/5 rounded-full bg-clay-primary/20" />
<div className="h-3 w-4/5 rounded-full bg-clay-primary/10" />
</div>
</div>
</div>
);
import React, { useState } from "react";
import { Header, Page, useNavigate, useParams, useSnackbar } from "zmp-ui";
import { WalletArtwork } from "@/components/shared/WalletArtwork";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Modal } from "@/components/ui/Modal";
import { IconGradients } from "@/components/ui/icons";
import {
useArchiveWallet,
useRestoreWallet,
useSetDefaultWallet,
useUpdateWallet,
useWallet,
} from "@/hooks/use-wallets";
import { getErrorMessage } from "@/lib/error-message";
import { formatWalletBalance } from "@/lib/wallet-format";
import { WalletInput } from "@/types/wallet";
import { WalletFormModal } from "./components/WalletFormModal";
import { WalletSkeleton } from "./components/WalletSkeleton";
function formatDate(value: string): string {
return new Intl.DateTimeFormat("vi-VN", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
const WalletDetailPage: React.FC = () => {
const params = useParams<{ id: string }>();
const walletId = params.id || "";
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const [isEditOpen, setIsEditOpen] = useState(false);
const [isArchiveOpen, setIsArchiveOpen] = useState(false);
const walletQuery = useWallet(walletId);
const updateMutation = useUpdateWallet(walletId);
const setDefaultMutation = useSetDefaultWallet();
const archiveMutation = useArchiveWallet();
const restoreMutation = useRestoreWallet();
const wallet = walletQuery.data?.data;
const showError = (error: unknown, fallback: string) => {
openSnackbar({ type: "error", text: getErrorMessage(error, fallback) });
};
const handleUpdate = (input: WalletInput) => {
const { isDefault: _isDefault, ...updateInput } = input;
updateMutation.mutate(updateInput, {
onSuccess: () => {
setIsEditOpen(false);
openSnackbar({ type: "success", text: "Đã lưu thay đổi của ví." });
},
onError: (error) => showError(error, "Không thể cập nhật ví."),
});
};
const handleSetDefault = () => {
setDefaultMutation.mutate(walletId, {
onSuccess: () => openSnackbar({ type: "success", text: "Đã đặt làm ví mặc định." }),
onError: (error) => showError(error, "Không thể đặt ví mặc định."),
});
};
const handleArchive = () => {
archiveMutation.mutate(walletId, {
onSuccess: () => {
setIsArchiveOpen(false);
openSnackbar({ type: "success", text: "Ví đã được lưu trữ; lịch sử giao dịch vẫn được giữ nguyên." });
},
onError: (error) => showError(error, "Không thể lưu trữ ví."),
});
};
const handleRestore = () => {
restoreMutation.mutate(walletId, {
onSuccess: () => openSnackbar({ type: "success", text: "Đã khôi phục ví thành công." }),
onError: (error) => showError(error, "Không thể khôi phục ví."),
});
};
return (
<Page className="page">
<Header title="Chi tiết ví" showBackIcon onBackClick={() => navigate("/wallets")} />
<IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12">
{walletQuery.isLoading && <><WalletSkeleton /><WalletSkeleton /></>}
{walletQuery.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>
<h1 className="clay-title-h3">Không thể tải thông tin ví</h1>
<p className="clay-caption mt-1">{getErrorMessage(walletQuery.error, "Ví không tồn tại hoặc kết nối bị gián đoạn.")}</p>
</div>
<div className="flex gap-3">
<Button variant="ghost" className="text-sm" onClick={() => navigate("/wallets")}>Danh sách ví</Button>
<Button variant="secondary" className="text-sm" onClick={() => walletQuery.refetch()}>Thử lại</Button>
</div>
</Card>
)}
{wallet && (
<>
<Card className={`relative overflow-hidden p-6 ${wallet.isArchived ? "bg-clay-text-muted/10" : ""}`}>
<div className="absolute -right-8 -top-8 h-32 w-32 rounded-full opacity-10" style={{ backgroundColor: wallet.color || "#8B7CF6" }} />
<div className="relative flex flex-col items-center text-center">
<WalletArtwork icon={wallet.icon} color={wallet.color} size="lg" archived={wallet.isArchived} />
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
<h1 className="clay-title-h2">{wallet.name}</h1>
{wallet.isDefault && <Badge type="primary">Ví mặc định</Badge>}
{wallet.isArchived && <Badge type="warning">Đã lưu trữ</Badge>}
</div>
<p className={`mt-2 font-baloo text-3xl font-bold ${Number(wallet.balance) < 0 ? "text-clay-expense" : "text-clay-primary-dark"}`}>
{formatWalletBalance(wallet.balance, wallet.currency)}
</p>
<p className="mt-1 font-nunito text-xs font-bold uppercase tracking-wider text-clay-text-muted">{wallet.currency}</p>
{wallet.description && <p className="clay-body mt-4 max-w-sm text-sm">{wallet.description}</p>}
</div>
</Card>
<Card className="p-5">
<h2 className="clay-title-h3 mb-4">Thông tin ví</h2>
<dl className="divide-y divide-clay-text-muted/10">
<div className="flex justify-between gap-4 py-3">
<dt className="clay-caption">Trạng thái</dt>
<dd className="font-nunito text-sm font-bold text-clay-text">{wallet.isArchived ? "Đã lưu trữ" : "Đang hoạt động"}</dd>
</div>
<div className="flex justify-between gap-4 py-3">
<dt className="clay-caption">Ngày tạo</dt>
<dd className="text-right font-nunito text-sm font-semibold text-clay-text">{formatDate(wallet.createdAt)}</dd>
</div>
<div className="flex justify-between gap-4 py-3">
<dt className="clay-caption">Cập nhật gần nhất</dt>
<dd className="text-right font-nunito text-sm font-semibold text-clay-text">{formatDate(wallet.updatedAt)}</dd>
</div>
</dl>
</Card>
<Card className="flex flex-col gap-3 p-5">
<h2 className="clay-title-h3">Quản lý ví</h2>
{!wallet.isArchived && (
<>
<Button fullWidth onClick={() => setIsEditOpen(true)}>Chỉnh sửa thông tin</Button>
{!wallet.isDefault && (
<Button variant="secondary" fullWidth disabled={setDefaultMutation.isPending} onClick={handleSetDefault}>
{setDefaultMutation.isPending ? "Đang cập nhật..." : "Đặt làm ví mặc định"}
</Button>
)}
<Button
variant="ghost"
fullWidth
disabled={wallet.isDefault}
onClick={() => setIsArchiveOpen(true)}
className="text-clay-expense"
>
Lưu trữ ví
</Button>
{wallet.isDefault && (
<p className="clay-caption text-center">Hãy đặt một ví khác làm mặc định trước khi lưu trữ ví này.</p>
)}
</>
)}
{wallet.isArchived && (
<>
<Button fullWidth disabled={restoreMutation.isPending} onClick={handleRestore}>
{restoreMutation.isPending ? "Đang khôi phục..." : "Khôi phục ví"}
</Button>
<p className="clay-caption text-center">Nếu chưa có ví hoạt động mặc định, ví này sẽ tự động được chọn sau khi khôi phục.</p>
</>
)}
</Card>
</>
)}
</main>
{wallet && (
<WalletFormModal
isOpen={isEditOpen}
wallet={wallet}
isSubmitting={updateMutation.isPending}
onClose={() => setIsEditOpen(false)}
onSubmit={handleUpdate}
/>
)}
<Modal
isOpen={isArchiveOpen}
onClose={() => setIsArchiveOpen(false)}
title="Lưu trữ ví?"
footer={(
<>
<Button variant="ghost" className="text-sm" disabled={archiveMutation.isPending} onClick={() => setIsArchiveOpen(false)}>Hủy</Button>
<Button className="bg-clay-expense text-sm" disabled={archiveMutation.isPending} onClick={handleArchive}>
{archiveMutation.isPending ? "Đang lưu trữ..." : "Xác nhận lưu trữ"}
</Button>
</>
)}
>
<p className="clay-body text-sm">Ví sẽ không còn dùng được cho giao dịch mới, nhưng toàn bộ lịch sử giao dịch và số dư vẫn được bảo toàn. Bạn có thể khôi phục ví bất cứ lúc nào.</p>
</Modal>
</Page>
);
};
export default WalletDetailPage;
import React, { useDeferredValue, useEffect, useMemo, useState } from "react";
import { Header, Page, useNavigate, useSnackbar } from "zmp-ui";
import { WalletCard } from "@/components/shared/WalletCard";
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 { IconGradients, PlusIcon, WalletIcon } from "@/components/ui/icons";
import { useCreateWallet, useSetDefaultWallet, useWalletSearch, useWallets } from "@/hooks/use-wallets";
import { getErrorMessage } from "@/lib/error-message";
import { formatWalletBalance, groupBalancesByCurrency } from "@/lib/wallet-format";
import { SortOrder, WalletInput, WalletQuery, WalletSortField } from "@/types/wallet";
import { WalletFormModal } from "./components/WalletFormModal";
import { WalletSkeleton } from "./components/WalletSkeleton";
const PAGE_SIZE = 6;
const sortOptions = [
{ value: "createdAt:desc", label: "Mới tạo gần đây" },
{ value: "updatedAt:desc", label: "Mới cập nhật" },
{ value: "name:asc", label: "Tên A → Z" },
{ value: "name:desc", label: "Tên Z → A" },
{ value: "balance:desc", label: "Số dư cao nhất" },
{ value: "balance:asc", label: "Số dư thấp nhất" },
];
const WalletsPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search.trim().toLocaleLowerCase("vi"));
const [includeArchived, setIncludeArchived] = useState(false);
const [sort, setSort] = useState("createdAt:desc");
const [page, setPage] = useState(1);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [sortBy, order] = sort.split(":") as [WalletSortField, SortOrder];
const query = useMemo<WalletQuery>(() => ({
includeArchived,
sortBy,
order,
page,
limit: PAGE_SIZE,
}), [includeArchived, order, page, sortBy]);
const summaryQuery = useMemo<WalletQuery>(() => ({
includeArchived: false,
sortBy: "createdAt",
order: "desc",
page: 1,
limit: 100,
}), []);
const paginatedWalletsQuery = useWallets(query);
const searchedWalletsQuery = useWalletSearch(
{ includeArchived, sortBy, order },
Boolean(deferredSearch),
);
const walletsQuery = deferredSearch ? searchedWalletsQuery : paginatedWalletsQuery;
const summary = useWallets(summaryQuery);
const createMutation = useCreateWallet();
const setDefaultMutation = useSetDefaultWallet();
useEffect(() => {
setPage(1);
}, [deferredSearch, includeArchived, sort]);
const filteredWallets = useMemo(() => {
const wallets = walletsQuery.data?.data || [];
if (!deferredSearch) {
return wallets;
}
return wallets.filter((wallet) => [wallet.name, wallet.description || "", wallet.currency]
.some((value) => value.toLocaleLowerCase("vi").includes(deferredSearch)));
}, [deferredSearch, walletsQuery.data?.data]);
const visibleWallets = deferredSearch
? filteredWallets.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
: filteredWallets;
const totalItems = deferredSearch ? filteredWallets.length : (walletsQuery.data?.meta.total || 0);
const totalPages = deferredSearch
? Math.ceil(filteredWallets.length / PAGE_SIZE)
: (walletsQuery.data?.meta.totalPages || 0);
const balances = groupBalancesByCurrency(summary.data?.data || []);
const activeWalletCount = summary.data?.meta.total || 0;
useEffect(() => {
if (totalPages > 0 && page > totalPages) {
setPage(totalPages);
} else if (!walletsQuery.isFetching && walletsQuery.data && totalPages === 0 && page > 1) {
setPage(1);
}
}, [page, totalPages, walletsQuery.data, walletsQuery.isFetching]);
const handleCreate = (input: WalletInput) => {
createMutation.mutate(input, {
onSuccess: () => {
setIsCreateOpen(false);
openSnackbar({ type: "success", text: "Đã tạo ví mới thành công." });
},
onError: (error) => {
openSnackbar({ type: "error", text: getErrorMessage(error, "Không thể tạo ví. Vui lòng thử lại.") });
},
});
};
const handleSetDefault = (id: string) => {
setDefaultMutation.mutate(id, {
onSuccess: () => openSnackbar({ type: "success", text: "Đã cập nhật ví mặc định." }),
onError: (error) => openSnackbar({ type: "error", text: getErrorMessage(error, "Không thể đặt ví mặc định.") }),
});
};
return (
<Page className="page">
<Header title="Ví của tôi" 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 p-5 text-white">
<div className="absolute -right-6 -top-7 h-28 w-28 rounded-full bg-white/10" />
<div className="absolute -bottom-9 right-16 h-24 w-24 rounded-full bg-clay-primary-dark/25" />
<div className="relative flex items-start justify-between gap-3">
<div>
<p className="font-nunito text-sm font-bold text-white/80">Tổng số dư theo tiền tệ</p>
{summary.isLoading ? (
<div className="mt-3 h-8 w-40 animate-pulse rounded-full bg-white/20" />
) : balances.length > 0 ? (
<div className="mt-2 flex flex-col gap-1">
{balances.map((item) => (
<p key={item.currency} className="font-baloo text-2xl font-bold">
{formatWalletBalance(item.balance, item.currency)}
</p>
))}
</div>
) : (
<p className="mt-2 font-baloo text-2xl font-bold">Chưa có số dư</p>
)}
<p className="mt-2 font-nunito text-xs font-semibold text-white/75">{activeWalletCount} ví đang hoạt động</p>
</div>
<div className="rounded-clay bg-white/20 p-3 shadow-clay-pressed"><WalletIcon size={30} /></div>
</div>
</Card>
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="clay-title-h2">Danh sách ví</h1>
<p className="clay-caption">{walletsQuery.isFetching ? "Đang đồng bộ..." : `${totalItems} kết quả`}</p>
</div>
<Button shape="pill" className="gap-2 px-4 text-sm" onClick={() => setIsCreateOpen(true)}>
<PlusIcon size={18} /> Tạo ví
</Button>
</div>
<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ìm kiếm ví"
placeholder="Tìm theo tên, mô tả, tiền tệ..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="pl-11"
/>
</div>
<div className="grid grid-cols-[1fr_auto] items-end gap-3">
<Select aria-label="Sắp xếp ví" options={sortOptions} value={sort} onChange={(event) => setSort(event.target.value)} />
<label className="flex h-[50px] cursor-pointer items-center gap-2 rounded-clay-sm bg-clay-bg px-3 shadow-clay-pressed transition-all duration-200 ease-in-out">
<input type="checkbox" checked={includeArchived} onChange={(event) => setIncludeArchived(event.target.checked)} className="h-4 w-4 accent-clay-primary" />
<span className="whitespace-nowrap font-nunito text-xs font-bold text-clay-text">Đã lưu trữ</span>
</label>
</div>
</Card>
{walletsQuery.isLoading && (
<div className="flex flex-col gap-4" aria-label="Đang tải danh sách ví">
{Array.from({ length: 3 }, (_, index) => <WalletSkeleton key={index} />)}
</div>
)}
{walletsQuery.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">Không thể tải danh sách ví</h2>
<p className="clay-caption mt-1">{getErrorMessage(walletsQuery.error, "Vui lòng kiểm tra kết nối và thử lại.")}</p>
</div>
<Button variant="secondary" className="text-sm" onClick={() => walletsQuery.refetch()}>Thử lại</Button>
</Card>
)}
{!walletsQuery.isLoading && !walletsQuery.isError && visibleWallets.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"><WalletIcon size={42} /></div>
<div>
<h2 className="clay-title-h3">{deferredSearch ? "Không tìm thấy ví phù hợp" : includeArchived ? "Chưa có ví nào" : "Bắt đầu với ví đầu tiên"}</h2>
<p className="clay-caption mt-1 max-w-xs">{deferredSearch ? "Thử một từ khóa khác hoặc bật danh sách đã lưu trữ." : "Tạo ví để theo dõi số dư và quản lý tài chính của bạn."}</p>
</div>
{!deferredSearch && <Button className="gap-2 text-sm" onClick={() => setIsCreateOpen(true)}><PlusIcon size={18} /> Tạo ví mới</Button>}
</Card>
)}
{!walletsQuery.isLoading && !walletsQuery.isError && visibleWallets.length > 0 && (
<div className={`flex flex-col gap-4 transition-opacity duration-200 ease-in-out ${walletsQuery.isFetching ? "opacity-65" : "opacity-100"}`}>
{visibleWallets.map((wallet) => (
<WalletCard
key={wallet.id}
wallet={wallet}
onClick={() => navigate(`/wallets/${wallet.id}`)}
onSetDefault={() => handleSetDefault(wallet.id)}
isSettingDefault={setDefaultMutation.isPending && setDefaultMutation.variables === wallet.id}
/>
))}
</div>
)}
{totalPages > 1 && (
<nav className="flex items-center justify-between gap-3" aria-label="Phân trang ví">
<Button variant="secondary" className="px-4 text-sm" disabled={page <= 1 || walletsQuery.isFetching} onClick={() => setPage((current) => current - 1)}>Trước</Button>
<span className="rounded-full bg-clay-surface px-4 py-2 font-nunito text-sm font-bold text-clay-text shadow-clay-pressed">{page} / {totalPages}</span>
<Button variant="secondary" className="px-4 text-sm" disabled={page >= totalPages || walletsQuery.isFetching} onClick={() => setPage((current) => current + 1)}>Sau</Button>
</nav>
)}
</main>
<WalletFormModal
isOpen={isCreateOpen}
isSubmitting={createMutation.isPending}
onClose={() => setIsCreateOpen(false)}
onSubmit={handleCreate}
/>
</Page>
);
};
export default WalletsPage;
import { apiClient } from "@/lib/api-client";
import {
UpdateWalletInput,
WalletInput,
WalletListResponse,
WalletQuery,
WalletResponse,
WalletSearchQuery,
} from "@/types/wallet";
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ý ví không thành công");
}
return response;
}
export const walletService = {
async getWallets(query: WalletQuery): Promise<WalletListResponse> {
const response = await apiClient.get<WalletListResponse>("/wallets", {
params: query,
});
return ensureSuccess(response.data);
},
async getAllWallets(query: WalletSearchQuery): Promise<WalletListResponse> {
const firstPage = await this.getWallets({ ...query, page: 1, limit: 100 });
if (firstPage.meta.totalPages <= 1) {
return firstPage;
}
const remainingPages = await Promise.all(
Array.from({ length: firstPage.meta.totalPages - 1 }, (_, index) => (
this.getWallets({ ...query, page: index + 2, limit: 100 })
)),
);
const data = [firstPage, ...remainingPages].reduce(
(wallets, page) => wallets.concat(page.data),
firstPage.data.slice(0, 0),
);
return {
...firstPage,
data,
meta: {
total: firstPage.meta.total,
page: 1,
limit: data.length,
totalPages: data.length > 0 ? 1 : 0,
},
};
},
async getWallet(id: string): Promise<WalletResponse> {
const response = await apiClient.get<WalletResponse>(`/wallets/${id}`);
return ensureSuccess(response.data);
},
async createWallet(input: WalletInput): Promise<WalletResponse> {
const response = await apiClient.post<WalletResponse>("/wallets", input);
return ensureSuccess(response.data);
},
async updateWallet(id: string, input: UpdateWalletInput): Promise<WalletResponse> {
const response = await apiClient.put<WalletResponse>(`/wallets/${id}`, input);
return ensureSuccess(response.data);
},
async setDefault(id: string): Promise<WalletResponse> {
const response = await apiClient.patch<WalletResponse>(`/wallets/${id}/default`);
return ensureSuccess(response.data);
},
async archiveWallet(id: string): Promise<WalletResponse> {
const response = await apiClient.delete<WalletResponse>(`/wallets/${id}`);
return ensureSuccess(response.data);
},
async restoreWallet(id: string): Promise<WalletResponse> {
const response = await apiClient.patch<WalletResponse>(`/wallets/${id}/restore`);
return ensureSuccess(response.data);
},
};
export type WalletSortField = "name" | "balance" | "createdAt" | "updatedAt";
export type SortOrder = "asc" | "desc";
export interface Wallet {
id: string;
name: string;
balance: string;
currency: string;
icon: string | null;
color: string | null;
description: string | null;
isDefault: boolean;
isArchived: boolean;
createdAt: string;
updatedAt: string;
}
export interface WalletQuery {
includeArchived: boolean;
sortBy: WalletSortField;
order: SortOrder;
page: number;
limit: number;
}
export type WalletSearchQuery = Pick<WalletQuery, "includeArchived" | "sortBy" | "order">;
export interface WalletInput {
name: string;
balance?: string;
currency?: string;
icon?: string | null;
color?: string | null;
description?: string | null;
isDefault?: boolean;
}
export type UpdateWalletInput = Omit<WalletInput, "isDefault">;
export interface PaginationMeta {
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface WalletListResponse {
success: boolean;
message?: string;
data: Wallet[];
meta: PaginationMeta;
errors?: unknown[] | null;
}
export interface WalletResponse {
success: boolean;
message?: string;
data: Wallet;
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