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 ...@@ -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ý ví đã có frontend tại `/wallets`, kết nối đầy đủ Wallet API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, đặt mặc định, lưu trữ/khôi phục, tìm kiếm, sắp xếp và phân trang. `DELETE /wallets/:id` được thể hiện trong UI là lưu trữ mềm, đúng quy tắc Backend bảo toàn lịch sử.
- Module quản lý danh mục đã có frontend tại `/categories`, kết nối Category API qua TanStack Query, gồm cây cha/con, tạo/sửa, tìm kiếm, lọc loại/nguồn/trạng thái, sắp xếp, lưu trữ và khôi phục; toàn bộ nội dung có bản dịch Việt/Anh. - Module quản lý danh mục đã có frontend tại `/categories`, kết nối Category API qua TanStack Query, gồm cây cha/con, tạo/sửa, tìm kiếm, lọc loại/nguồn/trạng thái, sắp xếp, lưu trữ và khôi phục; toàn bộ nội dung có bản dịch Việt/Anh.
- 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ý 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. - Các module ngân sách, tiết kiệm, báo cáo và AI Assistant chưa được triển khai trong frontend hiện tại.
## Khi cập nhật file này ## Khi cập nhật file này
......
...@@ -28,6 +28,7 @@ import WalletsPage from "@/pages/wallets/index"; ...@@ -28,6 +28,7 @@ import WalletsPage from "@/pages/wallets/index";
import WalletDetailPage from "@/pages/wallets/detail"; import WalletDetailPage from "@/pages/wallets/detail";
import CategoriesPage from "@/pages/categories/index"; import CategoriesPage from "@/pages/categories/index";
import TransactionsPage from "@/pages/transactions/index"; import TransactionsPage from "@/pages/transactions/index";
import TransfersPage from "@/pages/transfers/index";
const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => { const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { setAuth, clearAuth, setInitialized } = useAuthStore(); const { setAuth, clearAuth, setInitialized } = useAuthStore();
...@@ -90,6 +91,7 @@ const Layout = () => { ...@@ -90,6 +91,7 @@ const Layout = () => {
<Route path="/wallets/:id" element={<AuthGuard><WalletDetailPage /></AuthGuard>}></Route> <Route path="/wallets/:id" element={<AuthGuard><WalletDetailPage /></AuthGuard>}></Route>
<Route path="/categories" element={<AuthGuard><CategoriesPage /></AuthGuard>}></Route> <Route path="/categories" element={<AuthGuard><CategoriesPage /></AuthGuard>}></Route>
<Route path="/transactions" element={<AuthGuard><TransactionsPage /></AuthGuard>}></Route> <Route path="/transactions" element={<AuthGuard><TransactionsPage /></AuthGuard>}></Route>
<Route path="/transfers" element={<AuthGuard><TransfersPage /></AuthGuard>}></Route>
<Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route> <Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route>
</AnimationRoutes> </AnimationRoutes>
</AuthInitializer> </AuthInitializer>
......
...@@ -17,6 +17,8 @@ const PAGE_TITLES: ReadonlyArray<{ ...@@ -17,6 +17,8 @@ const PAGE_TITLES: ReadonlyArray<{
{ matches: (pathname) => pathname === "/wallets", key: "document.wallets" }, { matches: (pathname) => pathname === "/wallets", key: "document.wallets" },
{ matches: (pathname) => pathname.startsWith("/wallets/"), key: "document.walletDetail" }, { matches: (pathname) => pathname.startsWith("/wallets/"), key: "document.walletDetail" },
{ matches: (pathname) => pathname === "/categories", key: "document.categories" }, { 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" }, { 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 @@ ...@@ -45,7 +45,8 @@
"walletDetail": "Wallet Details", "walletDetail": "Wallet Details",
"categories": "Categories", "categories": "Categories",
"styleGuide": "Style Guide", "styleGuide": "Style Guide",
"transactions": "Transactions" "transactions": "Transactions",
"transfers": "Transfers"
}, },
"validation": { "validation": {
"emailRequired": "Email is required", "emailRequired": "Email is required",
...@@ -79,7 +80,16 @@ ...@@ -79,7 +80,16 @@
"transactionDescriptionMax": "Description must not exceed 500 characters", "transactionDescriptionMax": "Description must not exceed 500 characters",
"transactionLocationMax": "Location must not exceed 200 characters", "transactionLocationMax": "Location must not exceed 200 characters",
"receiptSizeLimit": "Receipt file must be smaller than 5 MB", "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": { "auth": {
"email": "Email", "email": "Email",
...@@ -161,7 +171,8 @@ ...@@ -161,7 +171,8 @@
"categories": "Manage categories", "categories": "Manage categories",
"profile": "My profile", "profile": "My profile",
"styleGuide": "Explore Style Guide", "styleGuide": "Explore Style Guide",
"transactions": "Manage transactions" "transactions": "Manage transactions",
"transfers": "Transfer between wallets"
}, },
"profile": { "profile": {
"header": "Account", "header": "Account",
...@@ -581,5 +592,65 @@ ...@@ -581,5 +592,65 @@
"typeExpense": "Expense (-)", "typeExpense": "Expense (-)",
"today": "Today", "today": "Today",
"yesterday": "Yesterday" "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 @@ ...@@ -45,7 +45,8 @@
"walletDetail": "Chi tiết ví", "walletDetail": "Chi tiết ví",
"categories": "Quản lý danh mục", "categories": "Quản lý danh mục",
"styleGuide": "Style Guide", "styleGuide": "Style Guide",
"transactions": "Quản lý giao dịch" "transactions": "Quản lý giao dịch",
"transfers": "Quản lý chuyển tiền"
}, },
"validation": { "validation": {
"emailRequired": "Email không được để trống", "emailRequired": "Email không được để trống",
...@@ -79,7 +80,16 @@ ...@@ -79,7 +80,16 @@
"transactionDescriptionMax": "Ghi chú tối đa 500 ký tự", "transactionDescriptionMax": "Ghi chú tối đa 500 ký tự",
"transactionLocationMax": "Địa điểm tối đa 200 ký tự", "transactionLocationMax": "Địa điểm tối đa 200 ký tự",
"receiptSizeLimit": "Hóa đơn phải nhỏ hơn 5 MB", "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": { "auth": {
"email": "Email", "email": "Email",
...@@ -161,7 +171,8 @@ ...@@ -161,7 +171,8 @@
"categories": "Quản lý danh mục thu chi", "categories": "Quản lý danh mục thu chi",
"profile": "Trang cá nhân của tôi", "profile": "Trang cá nhân của tôi",
"styleGuide": "Khám phá Style Guide", "styleGuide": "Khám phá Style Guide",
"transactions": "Quản lý giao dịch thu chi" "transactions": "Quản lý giao dịch thu chi",
"transfers": "Chuyển tiền giữa các ví"
}, },
"profile": { "profile": {
"header": "Tài Khoản", "header": "Tài Khoản",
...@@ -590,5 +601,65 @@ ...@@ -590,5 +601,65 @@
"typeExpense": "Chi tiêu (-)", "typeExpense": "Chi tiêu (-)",
"today": "Hôm nay", "today": "Hôm nay",
"yesterday": "Hôm qua" "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() { ...@@ -67,6 +67,13 @@ function HomePage() {
> >
{t("home.transactions")} {t("home.transactions")}
</Button> </Button>
<Button
variant="secondary"
fullWidth
onClick={() => navigate("/transfers")}
>
{t("home.transfers")}
</Button>
<Button <Button
variant="secondary" variant="secondary"
fullWidth 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>
);
};
This diff is collapsed.
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>
);
This diff is collapsed.
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