Commit c0ef7500 authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/transfer-management-fe' into 'develop'

feat(transfers): add transfer management interface

See merge request !11
parents ff2a2144 a27b33f3
......@@ -40,6 +40,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- Module quản lý ví đã có frontend tại `/wallets`, kết nối đầy đủ Wallet API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, đặt mặc định, lưu trữ/khôi phục, tìm kiếm, sắp xếp và phân trang. `DELETE /wallets/:id` được thể hiện trong UI là lưu trữ mềm, đúng quy tắc Backend bảo toàn lịch sử.
- Module quản lý danh mục đã có frontend tại `/categories`, kết nối Category API qua TanStack Query, gồm cây cha/con, tạo/sửa, tìm kiếm, lọc loại/nguồn/trạng thái, sắp xếp, lưu trữ và khôi phục; toàn bộ nội dung có bản dịch Việt/Anh.
- Module quản lý giao dịch đã có frontend tại `/transactions`, kết nối Transaction API qua TanStack Query, gồm danh sách lịch sử theo ngày, xem/tạo/sửa/xóa giao dịch, lọc nâng cao, sắp xếp, phân trang và đính kèm hóa đơn.
- Module quản lý chuyển tiền đã có frontend tại `/transfers`, dùng contract `GET/POST /transfers``DELETE /transfers/:id` qua TanStack Query; gồm form chọn ví nguồn/đích, validation số dư và hai ví khác nhau, xác nhận trước khi chuyển/xóa, lịch sử có tìm kiếm/lọc/sắp xếp/phân trang và đầy đủ loading/error/empty state. Sau thao tác tạo hoặc xóa, frontend làm mới cache transfer, wallet và transaction để lấy lại số dư do Backend tính toán.
- 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
......
......@@ -28,6 +28,7 @@ import WalletsPage from "@/pages/wallets/index";
import WalletDetailPage from "@/pages/wallets/detail";
import CategoriesPage from "@/pages/categories/index";
import TransactionsPage from "@/pages/transactions/index";
import TransfersPage from "@/pages/transfers/index";
const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { setAuth, clearAuth, setInitialized } = useAuthStore();
......@@ -90,6 +91,7 @@ const Layout = () => {
<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="/transfers" element={<AuthGuard><TransfersPage /></AuthGuard>}></Route>
<Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route>
</AnimationRoutes>
</AuthInitializer>
......
......@@ -17,6 +17,8 @@ const PAGE_TITLES: ReadonlyArray<{
{ 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 === "/transactions", key: "document.transactions" },
{ matches: (pathname) => pathname === "/transfers", key: "document.transfers" },
{ matches: (pathname) => pathname === "/style-guide", key: "document.styleGuide" },
];
......
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { transferService } from "@/services/transfer.service";
import { CreateTransferInput, TransferQuery } from "@/types/transfer";
import { transactionKeys } from "@/hooks/use-transactions";
import { walletKeys } from "@/hooks/use-wallets";
export const transferKeys = {
all: ["transfers"] as const,
lists: () => [...transferKeys.all, "list"] as const,
list: (query: TransferQuery) => [...transferKeys.lists(), query] as const,
};
export function useTransfers(query: TransferQuery) {
return useQuery({
queryKey: transferKeys.list(query),
queryFn: () => transferService.getTransfers(query),
placeholderData: keepPreviousData,
staleTime: 60_000,
});
}
function useTransferMutation<TVariables>(
mutationFn: (variables: TVariables) => ReturnType<typeof transferService.createTransfer>,
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: transferKeys.all }),
queryClient.invalidateQueries({ queryKey: walletKeys.all }),
queryClient.invalidateQueries({ queryKey: transactionKeys.all }),
]);
},
});
}
export function useCreateTransfer() {
return useTransferMutation((input: CreateTransferInput) => (
transferService.createTransfer(input)
));
}
export function useDeleteTransfer() {
return useTransferMutation((id: string) => transferService.deleteTransfer(id));
}
......@@ -45,7 +45,8 @@
"walletDetail": "Wallet Details",
"categories": "Categories",
"styleGuide": "Style Guide",
"transactions": "Transactions"
"transactions": "Transactions",
"transfers": "Transfers"
},
"validation": {
"emailRequired": "Email is required",
......@@ -79,7 +80,16 @@
"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"
"receiptTypeInvalid": "Receipt must be JPEG, PNG, WebP, or PDF",
"transferSourceRequired": "Please select a source wallet",
"transferDestinationRequired": "Please select a destination wallet",
"transferAmountRequired": "Please enter the transfer amount",
"transferAmountInvalid": "Amount must be greater than zero with at most 2 decimal places",
"transferNoteMax": "Note must not exceed 500 characters",
"transferDateRequired": "Please select the transfer date and time",
"transferWalletsDifferent": "Source and destination wallets must be different",
"transferInsufficientBalance": "The source wallet does not have enough balance",
"transferCurrencyMismatch": "Source and destination wallets must use the same currency"
},
"auth": {
"email": "Email",
......@@ -161,7 +171,8 @@
"categories": "Manage categories",
"profile": "My profile",
"styleGuide": "Explore Style Guide",
"transactions": "Manage transactions"
"transactions": "Manage transactions",
"transfers": "Transfer between wallets"
},
"profile": {
"header": "Account",
......@@ -581,5 +592,65 @@
"typeExpense": "Expense (-)",
"today": "Today",
"yesterday": "Yesterday"
},
"transfer": {
"header": "Transfer Management",
"create": "New transfer",
"createShort": "Transfer",
"review": "Review transfer",
"sourceWallet": "Source wallet",
"destinationWallet": "Destination wallet",
"selectSource": "Select source wallet...",
"selectDestination": "Select destination wallet...",
"swapWallets": "Swap source and destination wallets",
"amount": "Amount",
"amountPlaceholder": "Enter amount...",
"availableBalance": "Available balance",
"currencyNotice": "Transfers between {{source}} and {{destination}} are not supported. Choose wallets with the same currency.",
"transferredAt": "Transfer date and time",
"note": "Note",
"notePlaceholder": "For example: Move money to savings...",
"noNote": "No note",
"confirmTitle": "Confirm transfer",
"backToEdit": "Back to edit",
"confirmAction": "Confirm transfer",
"transferring": "Transferring...",
"confirmBalanceNotice": "The source and destination wallet balances will be updated by the backend after confirmation.",
"createSuccess": "Transfer completed successfully!",
"createFailed": "Could not complete the transfer. Please check the balance and try again.",
"deleteTitle": "Delete transfer?",
"deleteConfirm": "Confirm delete",
"deleteDescription": "Deleting this transfer will ask the backend to reverse its balance changes for both wallets.",
"deleteSuccess": "Transfer deleted and wallet balances synchronized.",
"deleteFailed": "Could not delete the transfer. Please try again.",
"deleteAria": "Delete transfer from {{source}} to {{destination}}",
"searchLabel": "Search transfer history",
"searchPlaceholder": "Search by note or wallet name...",
"filterTitle": "Filters",
"filterWallet": "Related wallet",
"allWallets": "All wallets",
"dateFrom": "From date",
"dateTo": "To date",
"sortBy": "Sort by",
"resetFilters": "Reset",
"clearFilters": "Clear filters",
"sortOptions": {
"dateDesc": "Transfer date (Newest)",
"dateAsc": "Transfer date (Oldest)",
"amountDesc": "Amount (Highest)",
"amountAsc": "Amount (Lowest)",
"createdDesc": "Recently created"
},
"syncing": "Syncing...",
"loading": "Loading transfer history...",
"loadFailed": "Failed to load transfer history",
"connectionFailed": "Could not connect to the transfer service.",
"empty": "No transfers found",
"emptyHint": "Transfer money between two wallets to see the history here.",
"emptyFilteredHint": "Try changing or clearing the current search and filters.",
"walletLoadFailed": "Could not load wallet balances",
"needTwoWallets": "You need at least two active wallets to make a transfer.",
"needTwoWalletsAction": "Create at least two active wallets before making a transfer. Tap to manage wallets.",
"paginationLabel": "Transfer history pagination"
}
}
......@@ -45,7 +45,8 @@
"walletDetail": "Chi tiết ví",
"categories": "Quản lý danh mục",
"styleGuide": "Style Guide",
"transactions": "Quản lý giao dịch"
"transactions": "Quản lý giao dịch",
"transfers": "Quản lý chuyển tiền"
},
"validation": {
"emailRequired": "Email không được để trống",
......@@ -79,7 +80,16 @@
"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"
"receiptTypeInvalid": "Hóa đơn chỉ chấp nhận JPEG, PNG, WebP hoặc PDF",
"transferSourceRequired": "Vui lòng chọn ví nguồn",
"transferDestinationRequired": "Vui lòng chọn ví đích",
"transferAmountRequired": "Vui lòng nhập số tiền chuyển",
"transferAmountInvalid": "Số tiền phải lớn hơn 0 và có tối đa 2 chữ số thập phân",
"transferNoteMax": "Ghi chú tối đa 500 ký tự",
"transferDateRequired": "Vui lòng chọn ngày giờ chuyển tiền",
"transferWalletsDifferent": "Ví nguồn và ví đích phải khác nhau",
"transferInsufficientBalance": "Số dư ví nguồn không đủ để thực hiện giao dịch",
"transferCurrencyMismatch": "Ví nguồn và ví đích phải sử dụng cùng loại tiền tệ"
},
"auth": {
"email": "Email",
......@@ -161,7 +171,8 @@
"categories": "Quản lý danh mục thu chi",
"profile": "Trang cá nhân của tôi",
"styleGuide": "Khám phá Style Guide",
"transactions": "Quản lý giao dịch thu chi"
"transactions": "Quản lý giao dịch thu chi",
"transfers": "Chuyển tiền giữa các ví"
},
"profile": {
"header": "Tài Khoản",
......@@ -590,5 +601,65 @@
"typeExpense": "Chi tiêu (-)",
"today": "Hôm nay",
"yesterday": "Hôm qua"
},
"transfer": {
"header": "Quản lý chuyển tiền",
"create": "Tạo lệnh chuyển tiền",
"createShort": "Chuyển tiền",
"review": "Kiểm tra thông tin",
"sourceWallet": "Ví nguồn",
"destinationWallet": "Ví đích",
"selectSource": "Chọn ví nguồn...",
"selectDestination": "Chọn ví đích...",
"swapWallets": "Đổi vị trí ví nguồn và ví đích",
"amount": "Số tiền",
"amountPlaceholder": "Nhập số tiền...",
"availableBalance": "Số dư khả dụng",
"currencyNotice": "Chưa hỗ trợ chuyển từ {{source}} sang {{destination}}. Hãy chọn hai ví cùng loại tiền tệ.",
"transferredAt": "Thời gian chuyển tiền",
"note": "Ghi chú",
"notePlaceholder": "Ví dụ: Chuyển sang ví tiết kiệm...",
"noNote": "Không có ghi chú",
"confirmTitle": "Xác nhận chuyển tiền",
"backToEdit": "Quay lại chỉnh sửa",
"confirmAction": "Xác nhận chuyển",
"transferring": "Đang chuyển tiền...",
"confirmBalanceNotice": "Số dư ví nguồn và ví đích sẽ được Backend cập nhật sau khi xác nhận.",
"createSuccess": "Chuyển tiền thành công!",
"createFailed": "Không thể chuyển tiền. Vui lòng kiểm tra số dư và thử lại.",
"deleteTitle": "Xóa giao dịch chuyển tiền?",
"deleteConfirm": "Xác nhận xóa",
"deleteDescription": "Khi xóa, Backend sẽ hoàn tác thay đổi số dư của cả hai ví từ giao dịch chuyển tiền này.",
"deleteSuccess": "Đã xóa giao dịch và đồng bộ lại số dư ví.",
"deleteFailed": "Không thể xóa giao dịch chuyển tiền. Vui lòng thử lại.",
"deleteAria": "Xóa giao dịch chuyển từ {{source}} đến {{destination}}",
"searchLabel": "Tìm kiếm lịch sử chuyển tiền",
"searchPlaceholder": "Tìm theo ghi chú hoặc tên ví...",
"filterTitle": "Bộ lọc",
"filterWallet": "Ví liên quan",
"allWallets": "Tất cả các ví",
"dateFrom": "Từ ngày",
"dateTo": "Đến ngày",
"sortBy": "Sắp xếp theo",
"resetFilters": "Đặt lại",
"clearFilters": "Xóa bộ lọc",
"sortOptions": {
"dateDesc": "Thời gian chuyển (Mới nhất)",
"dateAsc": "Thời gian chuyển (Cũ nhất)",
"amountDesc": "Số tiền (Lớn nhất)",
"amountAsc": "Số tiền (Nhỏ nhất)",
"createdDesc": "Mới tạo gần đây"
},
"syncing": "Đang đồng bộ...",
"loading": "Đang tải lịch sử chuyển tiền...",
"loadFailed": "Tải lịch sử chuyển tiền thất bại",
"connectionFailed": "Không thể kết nối dịch vụ chuyển tiền.",
"empty": "Không tìm thấy giao dịch chuyển tiền",
"emptyHint": "Hãy chuyển tiền giữa hai ví để xem lịch sử tại đây.",
"emptyFilteredHint": "Thử thay đổi hoặc xóa điều kiện tìm kiếm và bộ lọc hiện tại.",
"walletLoadFailed": "Không thể tải số dư ví",
"needTwoWallets": "Bạn cần ít nhất hai ví đang hoạt động để chuyển tiền.",
"needTwoWalletsAction": "Tạo ít nhất hai ví đang hoạt động trước khi chuyển tiền. Chạm để quản lý ví.",
"paginationLabel": "Phân trang lịch sử chuyển tiền"
}
}
......@@ -67,6 +67,13 @@ function HomePage() {
>
{t("home.transactions")}
</Button>
<Button
variant="secondary"
fullWidth
onClick={() => navigate("/transfers")}
>
{t("home.transfers")}
</Button>
<Button
variant="secondary"
fullWidth
......
import React from "react";
import { WalletArtwork } from "@/components/shared/WalletArtwork";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { useI18n } from "@/i18n";
import { formatWalletBalance } from "@/lib/wallet-format";
import { CreateTransferInput, Transfer } from "@/types/transfer";
import { Wallet } from "@/types/wallet";
interface CreateConfirmationProps {
isOpen: boolean;
draft: CreateTransferInput | null;
wallets: Wallet[];
isSubmitting: boolean;
onBack: () => void;
onConfirm: () => void;
}
export const CreateTransferConfirmationModal: React.FC<CreateConfirmationProps> = ({
isOpen,
draft,
wallets,
isSubmitting,
onBack,
onConfirm,
}) => {
const { t, intlLocale, formatDate } = useI18n();
const sourceWallet = wallets.find((wallet) => wallet.id === draft?.sourceWalletId);
const destinationWallet = wallets.find((wallet) => wallet.id === draft?.destinationWalletId);
if (!draft || !sourceWallet || !destinationWallet) return null;
return (
<Modal
isOpen={isOpen}
onClose={isSubmitting ? () => undefined : onBack}
title={t("transfer.confirmTitle")}
footer={
<>
<Button
type="button"
variant="ghost"
className="px-4 text-sm"
disabled={isSubmitting}
onClick={onBack}
>
{t("transfer.backToEdit")}
</Button>
<Button
type="button"
className="px-4 text-sm"
disabled={isSubmitting}
onClick={onConfirm}
>
{isSubmitting ? t("transfer.transferring") : t("transfer.confirmAction")}
</Button>
</>
}
>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-2 rounded-clay bg-clay-bg p-4 shadow-clay-pressed">
<div className="flex min-w-0 flex-1 flex-col items-center gap-2 text-center">
<WalletArtwork icon={sourceWallet.icon} color={sourceWallet.color} size="sm" />
<span className="w-full truncate text-sm font-bold text-clay-text">{sourceWallet.name}</span>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-clay-warning/25 text-lg font-bold text-clay-text">
</div>
<div className="flex min-w-0 flex-1 flex-col items-center gap-2 text-center">
<WalletArtwork icon={destinationWallet.icon} color={destinationWallet.color} size="sm" />
<span className="w-full truncate text-sm font-bold text-clay-text">{destinationWallet.name}</span>
</div>
</div>
<div className="text-center">
<p className="clay-caption">{t("transfer.amount")}</p>
<p className="font-baloo text-2xl font-bold text-clay-primary">
{formatWalletBalance(draft.amount, sourceWallet.currency, intlLocale)}
</p>
</div>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 rounded-clay bg-clay-primary/10 p-4 text-sm">
<dt className="font-semibold text-clay-text-muted">{t("transfer.transferredAt")}</dt>
<dd className="text-right font-bold text-clay-text">
{formatDate(draft.transferredAt, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</dd>
<dt className="font-semibold text-clay-text-muted">{t("transfer.note")}</dt>
<dd className="break-words text-right font-bold text-clay-text">
{draft.note || t("transfer.noNote")}
</dd>
</dl>
<p className="rounded-clay bg-clay-warning/15 p-3 text-center text-xs font-semibold text-clay-text">
{t("transfer.confirmBalanceNotice")}
</p>
</div>
</Modal>
);
};
interface DeleteConfirmationProps {
isOpen: boolean;
transfer: Transfer | null;
isSubmitting: boolean;
onClose: () => void;
onConfirm: () => void;
}
export const DeleteTransferConfirmationModal: React.FC<DeleteConfirmationProps> = ({
isOpen,
transfer,
isSubmitting,
onClose,
onConfirm,
}) => {
const { t, intlLocale } = useI18n();
if (!transfer) return null;
return (
<Modal
isOpen={isOpen}
onClose={isSubmitting ? () => undefined : onClose}
title={t("transfer.deleteTitle")}
footer={
<>
<Button
type="button"
variant="ghost"
className="px-4 text-sm"
disabled={isSubmitting}
onClick={onClose}
>
{t("common.cancel")}
</Button>
<Button
type="button"
className="border-clay-expense/30 bg-clay-expense px-4 text-sm hover:bg-clay-expense/90"
disabled={isSubmitting}
onClick={onConfirm}
>
{isSubmitting ? t("common.processing") : t("transfer.deleteConfirm")}
</Button>
</>
}
>
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-clay bg-clay-expense/15 text-2xl font-bold text-clay-expense shadow-clay-pressed">
!
</div>
<p className="font-semibold text-clay-text">{t("transfer.deleteDescription")}</p>
<div className="w-full rounded-clay bg-clay-bg p-3 shadow-clay-pressed">
<p className="text-sm font-bold text-clay-text">
{transfer.sourceWallet.name}{transfer.destinationWallet.name}
</p>
<p className="mt-1 font-baloo text-lg font-bold text-clay-expense">
{formatWalletBalance(transfer.amount, transfer.sourceWallet.currency, intlLocale)}
</p>
</div>
</div>
</Modal>
);
};
import React, { useEffect, useMemo } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select";
import { useWalletSearch } from "@/hooks/use-wallets";
import { TranslationFunction, useI18n } from "@/i18n";
import { getErrorMessage } from "@/lib/error-message";
import { formatWalletBalance } from "@/lib/wallet-format";
import { CreateTransferInput } from "@/types/transfer";
import { Wallet } from "@/types/wallet";
const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
function getNumberSeparators(locale: string): { group: string; decimal: string } {
const separators = new Intl.NumberFormat(locale).format(1234.5).match(/[^\d]/g) || [];
return {
group: separators[0] || ",",
decimal: separators[separators.length - 1] || ".",
};
}
function formatAmountInput(value: string, locale: string): string {
if (!value) return "";
const [integerPart, decimalPart] = value.split(".");
const { group, decimal } = getNumberSeparators(locale);
const groupedInteger = (integerPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, group);
return `${groupedInteger}${decimalPart !== undefined ? `${decimal}${decimalPart}` : ""}`;
}
function parseAmountInput(value: string, locale: string): string {
const trimmedValue = value.trim();
if (!trimmedValue) return "";
const { group, decimal } = getNumberSeparators(locale);
let integerDisplay = trimmedValue;
let decimalDisplay: string | undefined;
if (trimmedValue.includes(decimal)) {
const normalized = trimmedValue.split(group).join("");
[integerDisplay, decimalDisplay] = normalized.split(decimal, 2);
} else if (/^\d+[.,]\d{0,2}$/.test(trimmedValue)) {
const separatorIndex = Math.max(trimmedValue.lastIndexOf("."), trimmedValue.lastIndexOf(","));
integerDisplay = trimmedValue.slice(0, separatorIndex);
decimalDisplay = trimmedValue.slice(separatorIndex + 1);
} else {
integerDisplay = trimmedValue.split(group).join("");
}
const integerDigits = integerDisplay.replace(/\D/g, "").slice(0, 16);
const normalizedInteger = integerDigits.replace(/^0+(?=\d)/, "") || "0";
const decimalDigits = decimalDisplay?.replace(/\D/g, "").slice(0, 2);
return `${normalizedInteger}${decimalDisplay !== undefined ? `.${decimalDigits}` : ""}`;
}
function hasSufficientBalance(amount: string, balance: string): boolean {
if (balance.trim().startsWith("-")) return false;
const normalize = (value: string) => {
const [rawInteger = "0", rawDecimal = ""] = value.trim().split(".");
const integer = rawInteger.replace(/^0+(?=\d)/, "") || "0";
return { integer, decimal: rawDecimal.padEnd(2, "0").slice(0, 2) };
};
const left = normalize(amount);
const right = normalize(balance);
if (left.integer.length !== right.integer.length) {
return left.integer.length < right.integer.length;
}
if (left.integer !== right.integer) {
return left.integer < right.integer;
}
return left.decimal <= right.decimal;
}
function toLocalDateTimeInput(value?: string): string {
const date = value ? new Date(value) : new Date();
const validDate = Number.isNaN(date.getTime()) ? new Date() : date;
const localDate = new Date(validDate.getTime() - validDate.getTimezoneOffset() * 60_000);
return localDate.toISOString().slice(0, 16);
}
function createTransferSchema(t: TranslationFunction, wallets: Wallet[]) {
return z.object({
sourceWalletId: z.string().min(1, t("validation.transferSourceRequired")),
destinationWalletId: z.string().min(1, t("validation.transferDestinationRequired")),
amount: z.string()
.trim()
.min(1, t("validation.transferAmountRequired"))
.regex(amountPattern, t("validation.transferAmountInvalid"))
.refine((value) => Number(value) > 0, t("validation.transferAmountInvalid")),
note: z.string().max(500, t("validation.transferNoteMax")).optional().or(z.literal("")),
transferredAt: z.string().min(1, t("validation.transferDateRequired")),
}).superRefine((values, context) => {
if (values.sourceWalletId && values.sourceWalletId === values.destinationWalletId) {
context.addIssue({
code: "custom",
message: t("validation.transferWalletsDifferent"),
path: ["destinationWalletId"],
});
}
const sourceWallet = wallets.find((wallet) => wallet.id === values.sourceWalletId);
const destinationWallet = wallets.find((wallet) => wallet.id === values.destinationWalletId);
if (sourceWallet && destinationWallet && sourceWallet.currency !== destinationWallet.currency) {
context.addIssue({
code: "custom",
message: t("validation.transferCurrencyMismatch"),
path: ["destinationWalletId"],
});
}
if (sourceWallet && amountPattern.test(values.amount) && Number(values.amount) > 0
&& !hasSufficientBalance(values.amount, sourceWallet.balance)) {
context.addIssue({
code: "custom",
message: t("validation.transferInsufficientBalance"),
path: ["amount"],
});
}
});
}
type TransferFormValues = z.infer<ReturnType<typeof createTransferSchema>>;
interface TransferFormModalProps {
isOpen: boolean;
draft?: CreateTransferInput | null;
onClose: () => void;
onSubmit: (input: CreateTransferInput) => void;
}
export const TransferFormModal: React.FC<TransferFormModalProps> = ({
isOpen,
draft,
onClose,
onSubmit,
}) => {
const { t, intlLocale } = useI18n();
const walletsQuery = useWalletSearch({
includeArchived: false,
sortBy: "name",
order: "asc",
}, isOpen);
const wallets = useMemo(
() => (walletsQuery.data?.data || []).filter((wallet) => !wallet.isArchived),
[walletsQuery.data?.data],
);
const schema = useMemo(() => createTransferSchema(t, wallets), [t, wallets]);
const defaultValues = useMemo<TransferFormValues>(() => ({
sourceWalletId: draft?.sourceWalletId || "",
destinationWalletId: draft?.destinationWalletId || "",
amount: draft?.amount || "",
note: draft?.note || "",
transferredAt: toLocalDateTimeInput(draft?.transferredAt),
}), [draft]);
const {
control,
register,
handleSubmit,
reset,
setValue,
trigger,
watch,
formState: { errors },
} = useForm<TransferFormValues>({
resolver: zodResolver(schema),
defaultValues,
});
const sourceWalletId = watch("sourceWalletId");
const destinationWalletId = watch("destinationWalletId");
const amount = watch("amount");
const sourceWallet = wallets.find((wallet) => wallet.id === sourceWalletId);
const destinationWallet = wallets.find((wallet) => wallet.id === destinationWalletId);
useEffect(() => {
if (isOpen) reset(defaultValues);
}, [defaultValues, isOpen, reset]);
useEffect(() => {
if (!isOpen || draft || wallets.length < 2 || sourceWalletId) return;
const defaultWallet = wallets.find((wallet) => wallet.isDefault) || wallets[0];
const nextWallet = wallets.find((wallet) => wallet.id !== defaultWallet.id);
setValue("sourceWalletId", defaultWallet.id, { shouldValidate: true });
setValue("destinationWalletId", nextWallet?.id || "", { shouldValidate: true });
}, [draft, isOpen, setValue, sourceWalletId, wallets]);
useEffect(() => {
if (amount) void trigger("amount");
}, [amount, sourceWallet?.balance, trigger]);
const sourceOptions = [
{ value: "", label: t("transfer.selectSource") },
...wallets.filter((wallet) => wallet.id !== destinationWalletId).map((wallet) => ({
value: wallet.id,
label: `${wallet.name} · ${formatWalletBalance(wallet.balance, wallet.currency, intlLocale)}`,
})),
];
const destinationOptions = [
{ value: "", label: t("transfer.selectDestination") },
...wallets.filter((wallet) => wallet.id !== sourceWalletId).map((wallet) => ({
value: wallet.id,
label: `${wallet.name} · ${wallet.currency}`,
})),
];
const swapWallets = () => {
setValue("sourceWalletId", destinationWalletId, { shouldValidate: true });
setValue("destinationWalletId", sourceWalletId, { shouldValidate: true });
};
const submitForm = (values: TransferFormValues) => {
onSubmit({
sourceWalletId: values.sourceWalletId,
destinationWalletId: values.destinationWalletId,
amount: values.amount,
note: values.note?.trim() || null,
transferredAt: new Date(values.transferredAt).toISOString(),
});
};
const insufficientWallets = !walletsQuery.isLoading && !walletsQuery.isError && wallets.length < 2;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={t("transfer.create")}
footer={
<>
<Button type="button" variant="ghost" className="px-4 text-sm" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
type="submit"
form="create-transfer"
className="px-4 text-sm"
disabled={walletsQuery.isLoading || walletsQuery.isError || insufficientWallets}
>
{t("transfer.review")}
</Button>
</>
}
>
<form id="create-transfer" className="flex flex-col gap-4" onSubmit={handleSubmit(submitForm)}>
{walletsQuery.isError && (
<div className="rounded-clay bg-clay-expense/10 p-3 text-sm font-semibold text-clay-expense">
{getErrorMessage(walletsQuery.error, t("transfer.walletLoadFailed"))}
</div>
)}
{insufficientWallets && (
<div className="rounded-clay bg-clay-warning/15 p-3 text-sm font-semibold text-clay-text">
{t("transfer.needTwoWallets")}
</div>
)}
<Select
label={t("transfer.sourceWallet")}
options={sourceOptions}
error={errors.sourceWalletId?.message}
disabled={walletsQuery.isLoading || insufficientWallets}
{...register("sourceWalletId")}
/>
<div className="flex justify-center -my-1">
<button
type="button"
aria-label={t("transfer.swapWallets")}
className="flex h-10 w-10 items-center justify-center rounded-full bg-clay-primary text-clay-on-primary shadow-clay-raised transition-all duration-200 ease-in-out hover:shadow-clay-hover active:shadow-clay-pressed disabled:opacity-50"
onClick={swapWallets}
disabled={!sourceWalletId || !destinationWalletId}
>
</button>
</div>
<Select
label={t("transfer.destinationWallet")}
options={destinationOptions}
error={errors.destinationWalletId?.message}
disabled={walletsQuery.isLoading || insufficientWallets}
{...register("destinationWalletId")}
/>
<Controller
name="amount"
control={control}
render={({ field }) => (
<Input
{...field}
label={t("transfer.amount")}
inputMode="decimal"
placeholder={t("transfer.amountPlaceholder")}
error={errors.amount?.message}
disabled={walletsQuery.isLoading || insufficientWallets}
className="text-right font-semibold tabular-nums"
value={formatAmountInput(field.value, intlLocale)}
onChange={(event) => field.onChange(parseAmountInput(event.target.value, intlLocale))}
endAdornment={sourceWallet ? (
<span className="text-xs font-bold text-clay-text-muted">{sourceWallet.currency}</span>
) : undefined}
/>
)}
/>
{sourceWallet && (
<div className="-mt-2 flex flex-wrap justify-between gap-1 px-1 text-xs font-semibold text-clay-text-muted">
<span>{t("transfer.availableBalance")}</span>
<span className="tabular-nums text-clay-primary">
{formatWalletBalance(sourceWallet.balance, sourceWallet.currency, intlLocale)}
</span>
</div>
)}
{sourceWallet && destinationWallet && sourceWallet.currency !== destinationWallet.currency && (
<div className="rounded-clay bg-clay-info/15 p-3 text-xs font-semibold text-clay-text">
{t("transfer.currencyNotice", {
source: sourceWallet.currency,
destination: destinationWallet.currency,
})}
</div>
)}
<Input
label={t("transfer.transferredAt")}
type="datetime-local"
error={errors.transferredAt?.message}
disabled={walletsQuery.isLoading || insufficientWallets}
{...register("transferredAt")}
/>
<Input
label={t("transfer.note")}
placeholder={t("transfer.notePlaceholder")}
error={errors.note?.message}
disabled={walletsQuery.isLoading || insufficientWallets}
{...register("note")}
/>
</form>
</Modal>
);
};
import React from "react";
import { Card } from "@/components/ui/Card";
export const TransferSkeleton: React.FC = () => (
<Card className="flex animate-pulse items-center gap-3 p-4" aria-hidden="true">
<div className="h-12 w-12 shrink-0 rounded-clay-sm bg-clay-primary/15 shadow-clay-pressed" />
<div className="flex flex-1 flex-col gap-2">
<div className="h-4 w-2/3 rounded-full bg-clay-text-muted/15" />
<div className="h-3 w-1/2 rounded-full bg-clay-text-muted/10" />
</div>
<div className="h-5 w-20 rounded-full bg-clay-warning/20" />
</Card>
);
import React, { useDeferredValue, useEffect, useMemo, useState } from "react";
import { Header, Page, useNavigate, useSnackbar } from "zmp-ui";
import { WalletArtwork } from "@/components/shared/WalletArtwork";
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, TransferIcon } from "@/components/ui/icons";
import { useCreateTransfer, useDeleteTransfer, useTransfers } from "@/hooks/use-transfers";
import { useWalletSearch } from "@/hooks/use-wallets";
import { useI18n } from "@/i18n";
import { getErrorMessage } from "@/lib/error-message";
import { formatWalletBalance } from "@/lib/wallet-format";
import {
CreateTransferInput,
Transfer,
TransferQuery,
TransferSortField,
TransferSortOrder,
} from "@/types/transfer";
import {
CreateTransferConfirmationModal,
DeleteTransferConfirmationModal,
} from "./components/TransferConfirmationModal";
import { TransferFormModal } from "./components/TransferFormModal";
import { TransferSkeleton } from "./components/TransferSkeleton";
const PAGE_SIZE = 10;
function startOfLocalDay(value: string): string {
const date = new Date(`${value}T00:00:00`);
return date.toISOString();
}
function endOfLocalDay(value: string): string {
const date = new Date(`${value}T23:59:59.999`);
return date.toISOString();
}
const TransfersPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const { t, intlLocale, formatDate } = useI18n();
const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search.trim());
const [walletId, setWalletId] = useState("");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [sort, setSort] = useState("transferredAt:desc");
const [page, setPage] = useState(1);
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isCreateConfirmationOpen, setIsCreateConfirmationOpen] = useState(false);
const [draft, setDraft] = useState<CreateTransferInput | null>(null);
const [selectedForDelete, setSelectedForDelete] = useState<Transfer | null>(null);
const [sortBy, order] = sort.split(":") as [TransferSortField, TransferSortOrder];
const query = useMemo<TransferQuery>(() => ({
sortBy,
order,
page,
limit: PAGE_SIZE,
...(deferredSearch ? { search: deferredSearch } : {}),
...(walletId ? { walletId } : {}),
...(dateFrom ? { dateFrom: startOfLocalDay(dateFrom) } : {}),
...(dateTo ? { dateTo: endOfLocalDay(dateTo) } : {}),
}), [dateFrom, dateTo, deferredSearch, order, page, sortBy, walletId]);
const transfersQuery = useTransfers(query);
const walletsQuery = useWalletSearch({
includeArchived: false,
sortBy: "name",
order: "asc",
}, true);
const createMutation = useCreateTransfer();
const deleteMutation = useDeleteTransfer();
const wallets = useMemo(
() => (walletsQuery.data?.data || []).filter((wallet) => !wallet.isArchived),
[walletsQuery.data?.data],
);
useEffect(() => {
setPage(1);
}, [dateFrom, dateTo, deferredSearch, sort, walletId]);
const totalItems = transfersQuery.data?.meta.total || 0;
const totalPages = transfersQuery.data?.meta.totalPages || 0;
useEffect(() => {
if (totalPages > 0 && page > totalPages) setPage(totalPages);
}, [page, totalPages]);
const sortOptions = useMemo(() => [
{ value: "transferredAt:desc", label: t("transfer.sortOptions.dateDesc") },
{ value: "transferredAt:asc", label: t("transfer.sortOptions.dateAsc") },
{ value: "amount:desc", label: t("transfer.sortOptions.amountDesc") },
{ value: "amount:asc", label: t("transfer.sortOptions.amountAsc") },
{ value: "createdAt:desc", label: t("transfer.sortOptions.createdDesc") },
], [t]);
const walletOptions = useMemo(() => [
{ value: "", label: t("transfer.allWallets") },
...wallets.map((wallet) => ({
value: wallet.id,
label: `${wallet.name} (${wallet.currency})`,
})),
], [t, wallets]);
const activeFiltersCount = Number(Boolean(walletId)) + Number(Boolean(dateFrom)) + Number(Boolean(dateTo));
const hasFilters = Boolean(deferredSearch) || activeFiltersCount > 0;
const resetFilters = () => {
setSearch("");
setWalletId("");
setDateFrom("");
setDateTo("");
setSort("transferredAt:desc");
};
const openCreate = () => {
setDraft(null);
setIsCreateOpen(true);
};
const reviewTransfer = (input: CreateTransferInput) => {
setDraft(input);
setIsCreateOpen(false);
setIsCreateConfirmationOpen(true);
};
const returnToForm = () => {
setIsCreateConfirmationOpen(false);
setIsCreateOpen(true);
};
const confirmTransfer = () => {
if (!draft) return;
createMutation.mutate(draft, {
onSuccess: () => {
openSnackbar({ type: "success", text: t("transfer.createSuccess") });
setIsCreateConfirmationOpen(false);
setDraft(null);
},
onError: (error) => {
openSnackbar({
type: "error",
text: getErrorMessage(error, t("transfer.createFailed")),
});
},
});
};
const confirmDelete = () => {
if (!selectedForDelete) return;
deleteMutation.mutate(selectedForDelete.id, {
onSuccess: () => {
openSnackbar({ type: "success", text: t("transfer.deleteSuccess") });
setSelectedForDelete(null);
},
onError: (error) => {
openSnackbar({
type: "error",
text: getErrorMessage(error, t("transfer.deleteFailed")),
});
},
});
};
const createDisabled = walletsQuery.isLoading || walletsQuery.isError || wallets.length < 2;
return (
<Page className="page">
<Header title={t("transfer.header")} showBackIcon onBackClick={() => navigate("/")} />
<IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-4 px-4 pb-16">
<Card className="overflow-hidden p-4">
<div className="flex items-center gap-3">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-clay bg-clay-warning/20 shadow-clay-pressed">
<TransferIcon size={32} />
</div>
<div className="min-w-0 flex-1">
<h1 className="clay-title-h2">{t("document.transfers")}</h1>
<p className="clay-caption mt-0.5">
{transfersQuery.isFetching ? t("transfer.syncing") : t("common.results", { count: totalItems })}
</p>
</div>
<Button
shape="pill"
className="shrink-0 gap-1.5 px-4 text-sm"
disabled={createDisabled}
onClick={openCreate}
>
<PlusIcon size={18} /> {t("transfer.createShort")}
</Button>
</div>
{!walletsQuery.isLoading && wallets.length < 2 && !walletsQuery.isError && (
<button
type="button"
className="mt-3 w-full rounded-clay bg-clay-warning/15 p-3 text-left text-xs font-semibold text-clay-text transition-all duration-200 ease-in-out hover:bg-clay-warning/25"
onClick={() => navigate("/wallets")}
>
{t("transfer.needTwoWalletsAction")}
</button>
)}
{walletsQuery.isError && (
<button
type="button"
className="mt-3 w-full rounded-clay bg-clay-expense/10 p-3 text-left text-xs font-semibold text-clay-expense transition-all duration-200 ease-in-out hover:bg-clay-expense/15"
onClick={() => walletsQuery.refetch()}
>
{t("transfer.walletLoadFailed")} · {t("common.retry")}
</button>
)}
</Card>
<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("transfer.searchLabel")}
placeholder={t("transfer.searchPlaceholder")}
value={search}
onChange={(event) => setSearch(event.target.value)}
className="pl-11"
/>
</div>
<div className="flex items-center justify-between gap-3">
<button
type="button"
className="flex items-center gap-1.5 text-xs font-bold text-clay-primary transition-all duration-200 ease-in-out hover:underline"
onClick={() => setIsFilterExpanded((current) => !current)}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center" aria-hidden="true">
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
className={`block transition-all duration-200 ease-in-out ${isFilterExpanded ? "rotate-180" : ""}`}
>
<path
d="m4 6 4 4 4-4"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
{t("transfer.filterTitle")} {activeFiltersCount > 0 ? `(${activeFiltersCount})` : ""}
</button>
{hasFilters && (
<button
type="button"
className="text-xs font-bold text-clay-expense transition-all duration-200 ease-in-out hover:underline"
onClick={resetFilters}
>
{t("transfer.resetFilters")}
</button>
)}
</div>
{isFilterExpanded && (
<div className="flex flex-col gap-3 border-t border-clay-highlight/20 pt-3">
<div className="grid grid-cols-2 gap-3">
<Select
label={t("transfer.filterWallet")}
options={walletOptions}
value={walletId}
disabled={walletsQuery.isLoading || walletsQuery.isError}
onChange={(event) => setWalletId(event.target.value)}
/>
<Select
label={t("transfer.sortBy")}
options={sortOptions}
value={sort}
onChange={(event) => setSort(event.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label={t("transfer.dateFrom")}
type="date"
max={dateTo || undefined}
value={dateFrom}
onChange={(event) => setDateFrom(event.target.value)}
/>
<Input
label={t("transfer.dateTo")}
type="date"
min={dateFrom || undefined}
value={dateTo}
onChange={(event) => setDateTo(event.target.value)}
/>
</div>
</div>
)}
</Card>
{transfersQuery.isLoading && (
<div className="flex flex-col gap-3" aria-label={t("transfer.loading")}>
{Array.from({ length: 4 }, (_, index) => <TransferSkeleton key={index} />)}
</div>
)}
{transfersQuery.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-xl font-bold text-clay-expense shadow-clay-pressed">!</div>
<div>
<h2 className="clay-title-h3">{t("transfer.loadFailed")}</h2>
<p className="clay-caption mt-1">
{getErrorMessage(transfersQuery.error, t("transfer.connectionFailed"))}
</p>
</div>
<Button variant="secondary" className="text-sm" onClick={() => transfersQuery.refetch()}>
{t("common.retry")}
</Button>
</Card>
)}
{!transfersQuery.isLoading && !transfersQuery.isError && (transfersQuery.data?.data.length || 0) === 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">
<TransferIcon size={42} />
</div>
<div>
<h2 className="clay-title-h3">{t("transfer.empty")}</h2>
<p className="clay-caption mt-1">
{hasFilters ? t("transfer.emptyFilteredHint") : t("transfer.emptyHint")}
</p>
</div>
{hasFilters ? (
<Button variant="secondary" className="text-sm" onClick={resetFilters}>
{t("transfer.clearFilters")}
</Button>
) : (
<Button className="text-sm" disabled={createDisabled} onClick={openCreate}>
{t("transfer.create")}
</Button>
)}
</Card>
)}
{!transfersQuery.isLoading && !transfersQuery.isError && (transfersQuery.data?.data.length || 0) > 0 && (
<div className="flex flex-col gap-3">
{transfersQuery.data?.data.map((transfer) => (
<Card key={transfer.id} className="p-4">
<div className="flex items-start gap-3">
<div className="flex shrink-0 items-center">
<WalletArtwork icon={transfer.sourceWallet.icon} color={transfer.sourceWallet.color} size="sm" />
<span className="-mx-1 z-10 flex h-7 w-7 items-center justify-center rounded-full bg-clay-warning text-xs font-bold text-clay-text shadow-clay-raised"></span>
<WalletArtwork icon={transfer.destinationWallet.icon} color={transfer.destinationWallet.color} size="sm" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-bold text-clay-text">
{transfer.sourceWallet.name}{transfer.destinationWallet.name}
</p>
<p className="mt-0.5 text-xs font-semibold text-clay-text-muted">
{formatDate(transfer.transferredAt, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
<p className="shrink-0 font-baloo text-base font-bold text-clay-primary tabular-nums">
{formatWalletBalance(transfer.amount, transfer.sourceWallet.currency, intlLocale)}
</p>
</div>
<div className="mt-2 flex items-end justify-between gap-2 border-t border-clay-highlight/20 pt-2">
<p className="min-w-0 flex-1 break-words text-xs font-semibold text-clay-text-muted">
{transfer.note || t("transfer.noNote")}
</p>
<button
type="button"
aria-label={t("transfer.deleteAria", {
source: transfer.sourceWallet.name,
destination: transfer.destinationWallet.name,
})}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-clay-expense/10 text-clay-expense shadow-clay-pressed transition-all duration-200 ease-in-out hover:bg-clay-expense/20 active:translate-y-[1px]"
onClick={() => setSelectedForDelete(transfer)}
>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.3" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18M8 6V4h8v2m-9 0 1 14h8l1-14M10 10v6m4-6v6" />
</svg>
</button>
</div>
</div>
</div>
</Card>
))}
</div>
)}
{!transfersQuery.isLoading && !transfersQuery.isError && totalPages > 1 && (
<nav className="flex items-center justify-between gap-3" aria-label={t("transfer.paginationLabel")}>
<Button
variant="secondary"
className="px-4 text-sm"
disabled={page <= 1 || transfersQuery.isFetching}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
{t("common.previous")}
</Button>
<span className="clay-caption font-bold">
{t("common.pageOf", { page, total: totalPages })}
</span>
<Button
variant="secondary"
className="px-4 text-sm"
disabled={page >= totalPages || transfersQuery.isFetching}
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
>
{t("common.next")}
</Button>
</nav>
)}
</main>
<TransferFormModal
isOpen={isCreateOpen}
draft={draft}
onClose={() => {
setIsCreateOpen(false);
setDraft(null);
}}
onSubmit={reviewTransfer}
/>
<CreateTransferConfirmationModal
isOpen={isCreateConfirmationOpen}
draft={draft}
wallets={wallets}
isSubmitting={createMutation.isPending}
onBack={returnToForm}
onConfirm={confirmTransfer}
/>
<DeleteTransferConfirmationModal
isOpen={Boolean(selectedForDelete)}
transfer={selectedForDelete}
isSubmitting={deleteMutation.isPending}
onClose={() => setSelectedForDelete(null)}
onConfirm={confirmDelete}
/>
</Page>
);
};
export default TransfersPage;
import { apiClient } from "@/lib/api-client";
import {
CreateTransferInput,
TransferListResponse,
TransferQuery,
TransferResponse,
} from "@/types/transfer";
function ensureSuccess<T extends { success: boolean; message?: string }>(response: T): T {
if (!response.success) {
throw new Error(response.message || "Transfer request failed");
}
return response;
}
export const transferService = {
async getTransfers(query: TransferQuery): Promise<TransferListResponse> {
const response = await apiClient.get<TransferListResponse>("/transfers", {
params: query,
});
return ensureSuccess(response.data);
},
async createTransfer(input: CreateTransferInput): Promise<TransferResponse> {
const response = await apiClient.post<TransferResponse>("/transfers", input);
return ensureSuccess(response.data);
},
async deleteTransfer(id: string): Promise<TransferResponse> {
const response = await apiClient.delete<TransferResponse>(`/transfers/${id}`);
return ensureSuccess(response.data);
},
};
export type TransferSortField = "amount" | "transferredAt" | "createdAt";
export type TransferSortOrder = "asc" | "desc";
export interface TransferWallet {
id: string;
name: string;
currency: string;
icon: string | null;
color: string | null;
}
export interface Transfer {
id: string;
sourceWalletId: string;
destinationWalletId: string;
amount: string;
note: string | null;
transferredAt: string;
createdAt: string;
updatedAt: string;
sourceWallet: TransferWallet;
destinationWallet: TransferWallet;
}
export interface TransferQuery {
search?: string;
walletId?: string;
dateFrom?: string;
dateTo?: string;
sortBy: TransferSortField;
order: TransferSortOrder;
page: number;
limit: number;
}
export interface CreateTransferInput {
sourceWalletId: string;
destinationWalletId: string;
amount: string;
note?: string | null;
transferredAt: string;
}
export interface TransferPaginationMeta {
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface TransferListResponse {
success: boolean;
message?: string;
data: Transfer[];
meta: TransferPaginationMeta;
errors?: unknown[] | null;
}
export interface TransferResponse {
success: boolean;
message?: string;
data: Transfer;
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