Commit 396f3368 authored by ThinhNC's avatar ThinhNC

feat(statements): implement statement export with claymorphism UI and home navigation

parent c8497a48
...@@ -93,6 +93,15 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th ...@@ -93,6 +93,15 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- Admin Control Center tại `/admin` dùng TanStack Query và permission động, gồm dashboard KPI/hoạt động gần đây, danh sách người dùng có tìm kiếm-lọc-phân trang, tạo/đổi trạng thái/xóa mềm, chi tiết người dùng có đổi vai trò/khôi phục (lịch sử kiểm toán được quản lý tập trung tại `/admin/audit-logs`), cùng audit viewer lọc theo khoảng ngày Việt Nam/action/target và so sánh JSON trước-sau. Trang chủ chỉ hiện lối vào khi có `USER_READ`. - Admin Control Center tại `/admin` dùng TanStack Query và permission động, gồm dashboard KPI/hoạt động gần đây, danh sách người dùng có tìm kiếm-lọc-phân trang, tạo/đổi trạng thái/xóa mềm, chi tiết người dùng có đổi vai trò/khôi phục (lịch sử kiểm toán được quản lý tập trung tại `/admin/audit-logs`), cùng audit viewer lọc theo khoảng ngày Việt Nam/action/target và so sánh JSON trước-sau. Trang chủ chỉ hiện lối vào khi có `USER_READ`.
- Mọi lối vào module trên trang chủ phải được ẩn khi người dùng thiếu quyền đang bảo vệ route tương ứng; tiêu đề nhóm Tính năng Nâng cao cũng ẩn khi không có bất kỳ module con nào khả dụng. Lối vào Hồ sơ vẫn hiển thị vì route này không có permission riêng; lối vào Style Guide mặc định ẩn với vai trò hệ thống `USER` và hiển thị cho các vai trò còn lại. - Mọi lối vào module trên trang chủ phải được ẩn khi người dùng thiếu quyền đang bảo vệ route tương ứng; tiêu đề nhóm Tính năng Nâng cao cũng ẩn khi không có bất kỳ module con nào khả dụng. Lối vào Hồ sơ vẫn hiển thị vì route này không có permission riêng; lối vào Style Guide mặc định ẩn với vai trò hệ thống `USER` và hiển thị cho các vai trò còn lại.
- Contract Backend hiện chưa cho list hoặc đọc user đã soft-delete (`GET /users` và `GET /users/:id` đều loại `deletedAt != null`), nên nút restore trên detail đã sẵn sàng theo type/permission nhưng chỉ khả dụng khi Backend trả được bản ghi đã xóa. - Contract Backend hiện chưa cho list hoặc đọc user đã soft-delete (`GET /users` và `GET /users/:id` đều loại `deletedAt != null`), nên nút restore trên detail đã sẵn sàng theo type/permission nhưng chỉ khả dụng khi Backend trả được bản ghi đã xóa.
- **Xuất sao kê giao dịch bất đồng bộ (Statement Exporter)**:
- Tuân thủ nghiêm ngặt quy chuẩn cấu trúc trang Mini App: Bọc toàn bộ trong `<Page className="page">`, `<Header title={t("statement.pageTitle")} showBackIcon onBackClick={() => navigate("/")} />`, `<IconGradients />`, và `<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12">`. Tuyệt đối không dùng `<div>` trần để tránh mất header, che khuất thanh điều khiển và vỡ bố cục Zalo Mini App.
- Thẻ Hero Overview Claymorphic: Nền tím đậm (`!bg-clay-primary-dark !text-clay-on-primary`), bong bóng trang trí đa chiều, icon `ReportIcon` trong khối nổi 3D, số đếm tổng số sao kê và huy hiệu số lượng tác vụ đang xử lý nền.
- Lưới thông số giao dịch dạng ô gạch Claymorphism (`rounded-clay-sm bg-clay-bg p-2.5 shadow-clay-pressed`): Kỳ sao kê, Tên ví tài khoản thực tế (ánh xạ qua `walletMap`), Số lượng giao dịch và Dung lượng tệp. Không dùng raw emoji dạng văn bản trên giao diện.
- Trạng thái phản hồi (Feedback states): Sử dụng `StatementSkeleton` chuyên biệt mô phỏng trực quan cấu trúc thẻ đất sét thay cho skeleton khối trơn. Card trạng thái rỗng (Empty state) và lỗi (Error state) theo chuẩn chung với nút thử lại hoặc tạo mới.
- Modal tạo yêu cầu xuất sao kê `ExportStatementModal`: Sử dụng prop `footer` của base `Modal` cố định đáy, kiểm soát dữ liệu bằng Zod (`createExportSchema`) + React Hook Form, dùng `LocalizedDateInput` cho ngày bắt đầu/kết thúc chuẩn định dạng Việt Nam (`DD/MM/YYYY`), phím tắt khoảng ngày bo tròn (Pill presets), bộ chọn định dạng tệp 3D (Excel, PDF, CSV), và tùy chọn bảo vệ mật khẩu (PDF mã hóa, Excel khóa bảo vệ sheet).
- Tích hợp hook `useStatementJob` và `useStatementHistory` tự động polling trạng thái (mỗi 2.5s - 3s) khi có job ở trạng thái PENDING hoặc PROCESSING. Tải file an toàn qua blob request có đính kèm Bearer token tự động hoặc mở trực tiếp presigned Cloudflare R2 URL, có thông báo toast phản hồi thành công/thất bại qua `useSnackbar`.
- Route `/statements` được bảo vệ bởi `PermissionGate` với quyền `PERMISSIONS.STATEMENT_READ`. Nút điều hướng trên Trang chủ xuất hiện trong nhóm Mô-đun Tài chính Cốt lõi khi người dùng có quyền đọc. Tiêu đề tài liệu tự động cập nhật qua `DocumentTitle`.
- Đầy đủ hỗ trợ đa ngôn ngữ Việt - Anh qua `src/i18n/`.
## Khi cập nhật file này ## Khi cập nhật file này
......
...@@ -126,6 +126,10 @@ export const PERMISSIONS = { ...@@ -126,6 +126,10 @@ export const PERMISSIONS = {
// ASYNC JOB // ASYNC JOB
JOB_READ: 'JOB_READ', JOB_READ: 'JOB_READ',
JOB_CREATE: 'JOB_CREATE', JOB_CREATE: 'JOB_CREATE',
// STATEMENT EXPORT
STATEMENT_READ: 'STATEMENT_READ',
STATEMENT_EXPORT: 'STATEMENT_EXPORT',
} as const; } as const;
export type PermissionName = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]; export type PermissionName = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
...@@ -58,6 +58,7 @@ const AdminSettingsPage = React.lazy(() => import("@/pages/admin/settings/index" ...@@ -58,6 +58,7 @@ const AdminSettingsPage = React.lazy(() => import("@/pages/admin/settings/index"
const AdminNotificationsPage = React.lazy(() => import("@/pages/admin/notifications/index")); const AdminNotificationsPage = React.lazy(() => import("@/pages/admin/notifications/index"));
const AdminAiPage = React.lazy(() => import("@/pages/admin/ai/index")); const AdminAiPage = React.lazy(() => import("@/pages/admin/ai/index"));
const IntegrationsPage = React.lazy(() => import("@/pages/integrations/index")); const IntegrationsPage = React.lazy(() => import("@/pages/integrations/index"));
const StatementsPage = React.lazy(() => import("@/pages/statements/index"));
const NotFoundPage = React.lazy(() => import("@/pages/not-found/index")); const NotFoundPage = React.lazy(() => import("@/pages/not-found/index"));
import { PermissionGate } from "@/components/shared/PermissionGate"; import { PermissionGate } from "@/components/shared/PermissionGate";
...@@ -183,6 +184,7 @@ export const Layout = () => { ...@@ -183,6 +184,7 @@ export const Layout = () => {
<Route path="/saving-goals" element={<AuthGuard><PermissionGate permission={PERMISSIONS.SAVING_GOAL_READ} fallback={<AccessDenied />}><SavingGoalsPage /></PermissionGate></AuthGuard>}></Route> <Route path="/saving-goals" element={<AuthGuard><PermissionGate permission={PERMISSIONS.SAVING_GOAL_READ} fallback={<AccessDenied />}><SavingGoalsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/saving-goals/:id" element={<AuthGuard><PermissionGate permission={PERMISSIONS.SAVING_GOAL_READ} fallback={<AccessDenied />}><SavingGoalDetailPage /></PermissionGate></AuthGuard>}></Route> <Route path="/saving-goals/:id" element={<AuthGuard><PermissionGate permission={PERMISSIONS.SAVING_GOAL_READ} fallback={<AccessDenied />}><SavingGoalDetailPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/reports" element={<AuthGuard><PermissionGate permission={PERMISSIONS.REPORT_READ} fallback={<AccessDenied />}><ReportsPage /></PermissionGate></AuthGuard>}></Route> <Route path="/reports" element={<AuthGuard><PermissionGate permission={PERMISSIONS.REPORT_READ} fallback={<AccessDenied />}><ReportsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/statements" element={<AuthGuard><PermissionGate permission={PERMISSIONS.STATEMENT_READ} fallback={<AccessDenied />}><StatementsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/forecast" element={<AuthGuard><PermissionGate permission={PERMISSIONS.FORECAST_READ} fallback={<AccessDenied />}><ForecastPage /></PermissionGate></AuthGuard>}></Route> <Route path="/forecast" element={<AuthGuard><PermissionGate permission={PERMISSIONS.FORECAST_READ} fallback={<AccessDenied />}><ForecastPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/simulations" element={<AuthGuard><PermissionGate permission={PERMISSIONS.SIMULATION_READ} fallback={<AccessDenied />}><SimulationsPage /></PermissionGate></AuthGuard>}></Route> <Route path="/simulations" element={<AuthGuard><PermissionGate permission={PERMISSIONS.SIMULATION_READ} fallback={<AccessDenied />}><SimulationsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/anomalies" element={<AuthGuard><PermissionGate permission={PERMISSIONS.ANOMALY_READ} fallback={<AccessDenied />}><AnomaliesPage /></PermissionGate></AuthGuard>}></Route> <Route path="/anomalies" element={<AuthGuard><PermissionGate permission={PERMISSIONS.ANOMALY_READ} fallback={<AccessDenied />}><AnomaliesPage /></PermissionGate></AuthGuard>}></Route>
......
...@@ -25,6 +25,7 @@ const PAGE_TITLES: ReadonlyArray<{ ...@@ -25,6 +25,7 @@ const PAGE_TITLES: ReadonlyArray<{
{ matches: (pathname) => pathname === "/saving-goals", key: "document.savingGoals" }, { matches: (pathname) => pathname === "/saving-goals", key: "document.savingGoals" },
{ matches: (pathname) => pathname.startsWith("/saving-goals/"), key: "document.savingGoalDetail" }, { matches: (pathname) => pathname.startsWith("/saving-goals/"), key: "document.savingGoalDetail" },
{ matches: (pathname) => pathname === "/reports", key: "document.reports" }, { matches: (pathname) => pathname === "/reports", key: "document.reports" },
{ matches: (pathname) => pathname === "/statements", key: "document.statements" },
{ matches: (pathname) => pathname === "/forecast", key: "document.forecast" }, { matches: (pathname) => pathname === "/forecast", key: "document.forecast" },
{ matches: (pathname) => pathname === "/simulations", key: "document.simulations" }, { matches: (pathname) => pathname === "/simulations", key: "document.simulations" },
{ matches: (pathname) => pathname === "/anomalies", key: "document.anomalies" }, { matches: (pathname) => pathname === "/anomalies", key: "document.anomalies" },
......
...@@ -372,5 +372,24 @@ export const ExchangeIcon: React.FC<IconProps> = ({ size = 20, ...props }) => ( ...@@ -372,5 +372,24 @@ export const ExchangeIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
</svg> </svg>
); );
// Download Icon
export const DownloadIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
);
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { statementService } from "@/services/statement.service";
import { CreateStatementExportDto } from "@/types/statement";
export const STATEMENT_QUERY_KEYS = {
all: ["statements"] as const,
history: (page = 1, limit = 10) => ["statements", "history", page, limit] as const,
job: (id: string) => ["statements", "job", id] as const,
verify: (code: string) => ["statements", "verify", code] as const,
};
export function useStatementHistory(page = 1, limit = 10) {
return useQuery({
queryKey: STATEMENT_QUERY_KEYS.history(page, limit),
queryFn: () => statementService.listHistory(page, limit),
staleTime: 30 * 1000,
// Automatically poll every 3s if any job in history is pending or processing
refetchInterval: (query) => {
const hasActiveJob = query.state.data?.data?.some(
(job) => job.status === "PENDING" || job.status === "PROCESSING",
);
return hasActiveJob ? 3000 : false;
},
});
}
export function useStatementJob(jobId: string, options?: { enabled?: boolean }) {
return useQuery({
queryKey: STATEMENT_QUERY_KEYS.job(jobId),
queryFn: () => statementService.getJob(jobId),
enabled: options?.enabled !== false && Boolean(jobId),
// Polling every 2.5s if job is still pending or processing
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "PENDING" || status === "PROCESSING" ? 2500 : false;
},
});
}
export function useInitiateStatementExport() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: CreateStatementExportDto) => statementService.initiateExport(dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: STATEMENT_QUERY_KEYS.all });
},
});
}
export function useVerifyStatement(code: string) {
return useQuery({
queryKey: STATEMENT_QUERY_KEYS.verify(code),
queryFn: () => statementService.verifyStatement(code),
enabled: Boolean(code),
staleTime: 5 * 60 * 1000,
});
}
...@@ -62,6 +62,7 @@ ...@@ -62,6 +62,7 @@
"savingGoals": "Saving Goals", "savingGoals": "Saving Goals",
"savingGoalDetail": "Saving Goal Details", "savingGoalDetail": "Saving Goal Details",
"reports": "Financial Reports", "reports": "Financial Reports",
"statements": "Export Financial Statements",
"forecast": "Cash Flow Runway & Budget Forecaster", "forecast": "Cash Flow Runway & Budget Forecaster",
"simulations": "What-if Financial Simulation Sandbox", "simulations": "What-if Financial Simulation Sandbox",
"anomalies": "Multi-dimensional Anomaly Detection", "anomalies": "Multi-dimensional Anomaly Detection",
...@@ -236,30 +237,31 @@ ...@@ -236,30 +237,31 @@
"home": { "home": {
"header": "FinWise Mini App", "header": "FinWise Mini App",
"greeting": "Hello, {{name}}!", "greeting": "Hello, {{name}}!",
"subtitle": "Expense Journal & Financial Reports", "subtitle": "Smart Financial Management & Runway Forecasting",
"account": "Account:", "account": "Account:",
"authTitle": "System authentication", "authTitle": "Operational Status",
"authDescription": "You are signed in. Upcoming API requests will be authenticated automatically using secure cookies and JWT.", "authDescription": "Secure multi-tenant authentication with isolated data permissions.",
"aiAssistant": "AI Financial Assistant", "aiAssistant": "FinWise AI Assistant",
"smartQuery": "Smart Natural Language Query", "smartQuery": "Smart DSL Query Engine",
"advancedFeatures": "Advanced Intelligence Features", "advancedFeatures": "Advanced Upgrades",
"forecast": "Cash Flow Forecast", "forecast": "Cash Flow Runway",
"simulations": "What-if Simulation", "simulations": "What-if Sandbox",
"anomalies": "Anomaly Detection", "anomalies": "Anomaly Alerts",
"subscriptions": "Recurring Subscriptions", "subscriptions": "Recurring Bills",
"coreModules": "Core Financial Modules", "recurringTransactions": "Auto Transactions",
"wallets": "Manage wallets", "coreModules": "Core Financials",
"categories": "Manage categories", "wallets": "My Wallets",
"transactions": "Manage transactions", "transactions": "Transactions",
"recurringTransactions": "Automated Recurring Transactions", "transfers": "Transfers",
"transfers": "Transfer between wallets", "budgets": "Budgets",
"budgets": "Manage spending budgets", "savingGoals": "Saving Goals",
"savingGoals": "Manage saving goals", "categories": "Categories",
"reports": "Financial reports & analytics", "reports": "Financial Reports",
"notifications": "Notifications & reminders", "statements": "Statements",
"rolesManagement": "Roles & RBAC Management", "notifications": "Notifications",
"profile": "My profile", "rolesManagement": "Roles & RBAC",
"styleGuide": "Explore Style Guide" "profile": "Profile",
"styleGuide": "Style Guide"
}, },
"profile": { "profile": {
"header": "Account", "header": "Account",
...@@ -1488,33 +1490,6 @@ ...@@ -1488,33 +1490,6 @@
"extractFailed": "Failed to extract receipt data. Please check the image and try again." "extractFailed": "Failed to extract receipt data. Please check the image and try again."
} }
}, },
"home": {
"header": "FinWise Mini App",
"greeting": "Hello, {{name}}!",
"subtitle": "Smart Financial Management & Runway Forecasting",
"account": "Account:",
"authTitle": "Operational Status",
"authDescription": "Secure multi-tenant authentication with isolated data permissions.",
"aiAssistant": "FinWise AI Assistant",
"smartQuery": "Smart DSL Query Engine",
"advancedFeatures": "Advanced Upgrades",
"forecast": "Cash Flow Runway",
"simulations": "What-if Sandbox",
"anomalies": "Anomaly Alerts",
"subscriptions": "Recurring Bills",
"recurringTransactions": "Auto Transactions",
"coreModules": "Core Financials",
"wallets": "My Wallets",
"transactions": "Transactions",
"transfers": "Transfers",
"budgets": "Budgets",
"savingGoals": "Saving Goals",
"categories": "Categories",
"reports": "Financial Reports",
"notifications": "Notifications",
"profile": "Profile",
"styleGuide": "Style Guide"
},
"forecast": { "forecast": {
"pageTitle": "Cash Flow Runway & Budget Forecaster", "pageTitle": "Cash Flow Runway & Budget Forecaster",
"selectHorizon": "Forecast Horizon", "selectHorizon": "Forecast Horizon",
...@@ -2313,6 +2288,59 @@ ...@@ -2313,6 +2288,59 @@
"estimated": "Baseline", "estimated": "Baseline",
"refreshRate": "Refresh market rate", "refreshRate": "Refresh market rate",
"sameCurrencyNote": "Exchange rate for identical currencies" "sameCurrencyNote": "Exchange rate for identical currencies"
},
"statement": {
"pageTitle": "Export Financial Statements",
"pageSubtitle": "Asynchronous export to Excel (4 sheets), Bank-standard PDF, and CSV",
"heroTitle": "Statement Overview",
"newExportBtn": "New Statement Export",
"bannerTitle": "Secure & Unlimited Export",
"bannerDesc": "Background asynchronous processing ensures zero server freeze or memory overflow. Completed files will notify via In-App and Zalo with download links valid for 48 hours.",
"historyTitle": "Export Request History",
"emptyTitle": "No statements yet",
"emptyDesc": "You haven't requested any statements yet. Click the button above to start.",
"createFirstExport": "Create First Export",
"exportModalTitle": "Export Financial Statement",
"selectWallet": "Wallet",
"allWallets": "All wallets",
"selectedWallet": "Selected wallet",
"dateRange": "Statement Period [From, To)",
"dateFrom": "From date",
"dateTo": "To date",
"presetThisMonth": "This month",
"presetLastMonth": "Last month",
"presetThisYear": "This year",
"presetLast365": "Last 365 days",
"format": "File Format",
"protectWithPassword": "Password Protect File",
"password": "Password protection",
"passwordHint": "Password hint",
"showPassword": "Show password",
"hidePassword": "Hide password",
"passwordPlaceholder": "Enter password (birthday / ID / PIN)",
"passwordHintPlaceholder": "Password hint (e.g. Birthday DDMMYYYY)",
"startExport": "Start Export",
"errorDateRange": "Start date must be before end date",
"errorPasswordLength": "Password must be at least 4 characters",
"createSuccess": "Statement export initiated successfully",
"createFailed": "Failed to initiate statement export",
"downloadSuccess": "Statement downloaded successfully",
"downloadFailed": "Failed to download statement file",
"loadFailed": "Failed to load statement history",
"activeCount": "{{count}} requests processing",
"statusPending": "Pending",
"statusProcessing": "Generating file...",
"statusCompleted": "Ready",
"statusFailed": "Failed",
"period": "Period",
"wallet": "Wallet",
"recordCount": "Transactions",
"fileSize": "File size",
"createdAt": "Created at",
"expiresIn48h": "Expires in 48h",
"downloadFile": "Download",
"processingNotice": "Extracting data in background, please wait a moment...",
"failedNotice": "Processing failed. Please try again later."
} }
} }
...@@ -62,6 +62,7 @@ ...@@ -62,6 +62,7 @@
"savingGoals": "Mục tiêu tiết kiệm", "savingGoals": "Mục tiêu tiết kiệm",
"savingGoalDetail": "Chi tiết mục tiêu tiết kiệm", "savingGoalDetail": "Chi tiết mục tiêu tiết kiệm",
"reports": "Báo cáo tài chính", "reports": "Báo cáo tài chính",
"statements": "Xuất sao kê giao dịch",
"forecast": "Dự báo Dòng tiền & Ngân sách", "forecast": "Dự báo Dòng tiền & Ngân sách",
"simulations": "Mô phỏng Kịch bản Tài chính (What-if)", "simulations": "Mô phỏng Kịch bản Tài chính (What-if)",
"anomalies": "Phát hiện Chi tiêu Bất thường", "anomalies": "Phát hiện Chi tiêu Bất thường",
...@@ -235,31 +236,32 @@ ...@@ -235,31 +236,32 @@
}, },
"home": { "home": {
"header": "FinWise Mini App", "header": "FinWise Mini App",
"greeting": "Chào, {{name}}!", "greeting": "Xin chào, {{name}}!",
"subtitle": "Sổ tay Chi tiêu & Báo cáo Tài chính", "subtitle": "Quản lý Tài chính Thông minh & Dự báo Dòng tiền",
"account": "Tài khoản:", "account": "Tài khoản:",
"authTitle": "Xác thực hệ thống", "authTitle": "Trạng thái Hoạt động",
"authDescription": "Bạn đã đăng nhập thành công. Các API tiếp theo sẽ tự động được xác thực nhờ cookie an toàn và cơ chế JWT.", "authDescription": "Hệ thống bảo mật xác thực với đa tài khoản và phân quyền dữ liệu độc lập.",
"aiAssistant": "Trợ lý tài chính AI", "aiAssistant": "Trợ lý FinWise AI",
"smartQuery": "Truy vấn thông minh DSL", "smartQuery": "Truy vấn Thông minh DSL",
"advancedFeatures": "Tính năng Phân tích Nâng cao", "advancedFeatures": "Tính năng Nâng cao",
"forecast": "Dự báo dòng tiền", "forecast": "Dự báo Dòng tiền",
"simulations": "Mô phỏng kịch bản", "simulations": "Mô phỏng What-if",
"anomalies": "Cảnh báo bất thường", "anomalies": "Cảnh báo Bất thường",
"subscriptions": "Hóa đơn định kỳ", "subscriptions": "Hóa đơn Định kỳ",
"coreModules": "Các mô-đun tài chính cốt lõi", "recurringTransactions": "Giao dịch Tự động",
"wallets": "Quản lý ví", "coreModules": "Danh mục Nghiệp vụ",
"categories": "Quản lý danh mục thu chi", "wallets": "Ví của tôi",
"transactions": "Quản lý giao dịch thu chi", "transactions": "Giao dịch",
"recurringTransactions": "Giao dịch tự động định kỳ", "transfers": "Chuyển tiền",
"transfers": "Chuyển tiền giữa các ví", "budgets": "Ngân sách",
"budgets": "Quản lý ngân sách chi tiêu", "savingGoals": "Mục tiêu Tiết kiệm",
"savingGoals": "Quản lý mục tiêu tiết kiệm", "categories": "Danh mục Chi tiêu",
"reports": "Báo cáo & phân tích tài chính", "reports": "Báo cáo",
"notifications": "Thông báo & nhắc nhở", "statements": "Xuất sao kê",
"notifications": "Thông báo",
"rolesManagement": "Phân quyền & Vai trò", "rolesManagement": "Phân quyền & Vai trò",
"profile": "Trang cá nhân của tôi", "profile": "Cá nhân",
"styleGuide": "Khám phá Style Guide" "styleGuide": "Hướng dẫn Giao diện"
}, },
"profile": { "profile": {
"header": "Tài Khoản", "header": "Tài Khoản",
...@@ -1589,33 +1591,6 @@ ...@@ -1589,33 +1591,6 @@
"extractFailed": "Không thể phân tích hóa đơn. Vui lòng kiểm tra ảnh và thử lại." "extractFailed": "Không thể phân tích hóa đơn. Vui lòng kiểm tra ảnh và thử lại."
} }
}, },
"home": {
"header": "FinWise Mini App",
"greeting": "Xin chào, {{name}}!",
"subtitle": "Quản lý Tài chính Thông minh & Dự báo Dòng tiền",
"account": "Tài khoản:",
"authTitle": "Trạng thái Hoạt động",
"authDescription": "Hệ thống bảo mật xác thực với đa tài khoản và phân quyền dữ liệu độc lập.",
"aiAssistant": "Trợ lý FinWise AI",
"smartQuery": "Truy vấn Thông minh DSL",
"advancedFeatures": "Tính năng Nâng cao",
"forecast": "Dự báo Dòng tiền",
"simulations": "Mô phỏng What-if",
"anomalies": "Cảnh báo Bất thường",
"subscriptions": "Hóa đơn Định kỳ",
"recurringTransactions": "Giao dịch Tự động",
"coreModules": "Danh mục Nghiệp vụ",
"wallets": "Ví của tôi",
"transactions": "Giao dịch",
"transfers": "Chuyển tiền",
"budgets": "Ngân sách",
"savingGoals": "Mục tiêu Tiết kiệm",
"categories": "Danh mục Chi tiêu",
"reports": "Báo cáo",
"notifications": "Thông báo",
"profile": "Cá nhân",
"styleGuide": "Hướng dẫn Giao diện"
},
"forecast": { "forecast": {
"pageTitle": "Dự báo Dòng tiền & Ngân sách", "pageTitle": "Dự báo Dòng tiền & Ngân sách",
"selectHorizon": "Khoảng thời gian dự báo", "selectHorizon": "Khoảng thời gian dự báo",
...@@ -2414,6 +2389,59 @@ ...@@ -2414,6 +2389,59 @@
"estimated": "Cơ sở", "estimated": "Cơ sở",
"refreshRate": "Cập nhật tỷ giá thị trường", "refreshRate": "Cập nhật tỷ giá thị trường",
"sameCurrencyNote": "Tỷ giá giữa cùng một loại tiền tệ" "sameCurrencyNote": "Tỷ giá giữa cùng một loại tiền tệ"
},
"statement": {
"pageTitle": "Xuất sao kê giao dịch",
"pageSubtitle": "Kết xuất bất đồng bộ Excel (4 sheet), PDF chuẩn ngân hàng và CSV",
"heroTitle": "Tổng quan sao kê",
"newExportBtn": "Xuất sao kê mới",
"bannerTitle": "Kết xuất an toàn & Không giới hạn số lượng",
"bannerDesc": "Hệ thống xử lý bất đồng bộ trong nền, không gây nghẽn mạng hay tràn bộ nhớ. Tệp hoàn tất sẽ có thông báo In-app và tin nhắn Zalo kèm đường dẫn tải về trong 48 giờ.",
"historyTitle": "Lịch sử yêu cầu xuất sao kê",
"emptyTitle": "Chưa có sao kê nào",
"emptyDesc": "Bạn chưa tạo yêu cầu xuất sao kê nào. Hãy nhấn nút phía trên để bắt đầu.",
"createFirstExport": "Tạo sao kê đầu tiên",
"exportModalTitle": "Xuất sao kê giao dịch",
"selectWallet": "Ví tài khoản",
"allWallets": "Tất cả ví tài khoản",
"selectedWallet": "Ví được chọn",
"dateRange": "Kỳ sao kê [Từ ngày, Đến ngày)",
"dateFrom": "Từ ngày",
"dateTo": "Đến ngày",
"presetThisMonth": "Tháng này",
"presetLastMonth": "Tháng trước",
"presetThisYear": "Năm nay",
"presetLast365": "365 ngày qua",
"format": "Định dạng tệp",
"protectWithPassword": "Bảo vệ tệp bằng mật khẩu",
"password": "Mật khẩu bảo vệ",
"passwordHint": "Gợi ý nhớ mật khẩu",
"showPassword": "Hiện mật khẩu",
"hidePassword": "Ẩn mật khẩu",
"passwordPlaceholder": "Nhập mật khẩu (ngày sinh / CCCD / số bí mật)",
"passwordHintPlaceholder": "Gợi ý nhớ mật khẩu (VD: Ngày sinh DDMMYYYY)",
"startExport": "Bắt đầu xuất",
"errorDateRange": "Ngày bắt đầu phải trước ngày kết thúc",
"errorPasswordLength": "Mật khẩu bảo vệ phải từ 4 ký tự trở lên",
"createSuccess": "Đã khởi tạo yêu cầu xuất sao kê thành công",
"createFailed": "Không thể khởi tạo yêu cầu xuất sao kê",
"downloadSuccess": "Đã tải sao kê thành công",
"downloadFailed": "Không thể tải tệp sao kê",
"loadFailed": "Không thể tải danh sách sao kê",
"activeCount": "{{count}} yêu cầu đang xử lý",
"statusPending": "Đang chờ",
"statusProcessing": "Đang tạo file...",
"statusCompleted": "Đã sẵn sàng",
"statusFailed": "Thất bại",
"period": "Kỳ sao kê",
"wallet": "Ví",
"recordCount": "Số giao dịch",
"fileSize": "Dung lượng",
"createdAt": "Thời gian tạo",
"expiresIn48h": "Hết hạn sau 48h",
"downloadFile": "Tải xuống",
"processingNotice": "Hệ thống đang trích xuất dữ liệu, vui lòng đợi trong giây lát...",
"failedNotice": "Xử lý thất bại. Vui lòng thử lại sau."
} }
} }
...@@ -274,6 +274,11 @@ function HomePage() { ...@@ -274,6 +274,11 @@ function HomePage() {
{t("home.categories")} {t("home.categories")}
</Button> </Button>
</PermissionGate> </PermissionGate>
<PermissionGate permission={PERMISSIONS.STATEMENT_READ}>
<Button variant="secondary" fullWidth onClick={() => navigate("/statements")}>
{t("home.statements")}
</Button>
</PermissionGate>
<PermissionGate permission={PERMISSIONS.NOTIFICATION_READ}> <PermissionGate permission={PERMISSIONS.NOTIFICATION_READ}>
<Button <Button
variant="secondary" variant="secondary"
......
This diff is collapsed.
import React, { useState } from "react";
import { useSnackbar } from "zmp-ui";
import { Badge, BadgeType } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { ClockIcon, DownloadIcon, LockIcon } from "@/components/ui/icons";
import { useI18n } from "@/i18n";
import { getErrorMessage } from "@/lib/error-message";
import { statementService } from "@/services/statement.service";
import { StatementJobItem } from "@/types/statement";
interface StatementJobCardProps {
job: StatementJobItem;
walletName?: string;
onRefresh?: () => void;
}
export const StatementJobCard: React.FC<StatementJobCardProps> = ({ job, walletName }) => {
const { formatDate, formatNumber, t } = useI18n();
const { openSnackbar } = useSnackbar();
const [isDownloading, setIsDownloading] = useState(false);
const handleDownload = async () => {
if (isDownloading) return;
setIsDownloading(true);
try {
if (job.downloadUrl && job.downloadUrl.startsWith("http")) {
window.open(job.downloadUrl, "_blank");
openSnackbar({
type: "success",
text: t("statement.downloadSuccess") || "Đang mở tệp sao kê để tải xuống",
});
return;
}
const blob = await statementService.downloadStatement(job.jobId, job.downloadUrl);
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `finwise-statement-${job.jobId.slice(0, 8)}.${job.format.toLowerCase()}`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.URL.revokeObjectURL(url);
openSnackbar({
type: "success",
text: t("statement.downloadSuccess") || "Đã tải sao kê thành công",
});
} catch (err) {
openSnackbar({
type: "error",
text: getErrorMessage(err, t("statement.downloadFailed") || "Tải sao kê thất bại"),
});
} finally {
setIsDownloading(false);
}
};
const getFormatBadge = (format: string) => {
switch (format) {
case "XLSX":
return <Badge type="income">Excel (.xlsx)</Badge>;
case "PDF":
return <Badge type="expense">PDF</Badge>;
case "CSV":
return <Badge type="warning">CSV</Badge>;
default:
return <Badge type="primary">{format}</Badge>;
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case "PENDING":
return <Badge type="warning">{t("statement.statusPending") || "Đang chờ"}</Badge>;
case "PROCESSING":
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-clay-primary/20 px-3 py-1 font-nunito text-xs font-bold text-clay-primary shadow-clay-pressed">
<span className="h-2 w-2 animate-ping rounded-full bg-clay-primary" />
{t("statement.statusProcessing") || "Đang tạo file..."}
</span>
);
case "COMPLETED":
return <Badge type="income">{t("statement.statusCompleted") || "Đã sẵn sàng"}</Badge>;
case "FAILED":
return <Badge type="expense">{t("statement.statusFailed") || "Thất bại"}</Badge>;
default:
return <Badge type="info">{status}</Badge>;
}
};
const formatFileSize = (bytes: number | null) => {
if (!bytes) return null;
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<Card hoverable className="flex flex-col gap-4 p-5 transition-all duration-200">
{/* Top row: Format & Security info + Status */}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
{getFormatBadge(job.format)}
{job.isPasswordProtected && (
<span
className="inline-flex items-center gap-1.5 rounded-full bg-clay-bg px-2.5 py-1 font-nunito text-xs font-bold text-clay-text-muted shadow-clay-pressed"
title={job.passwordHint ? `${t("statement.passwordHint")}: ${job.passwordHint}` : t("statement.protectWithPassword")}
>
<LockIcon size={13} className="text-clay-warning" />
<span>{job.passwordHint ? job.passwordHint : t("statement.protectWithPassword")}</span>
</span>
)}
</div>
<div>{getStatusBadge(job.status)}</div>
</div>
{/* Metric Tiles Grid */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{/* Period */}
<div className="rounded-clay-sm bg-clay-bg p-2.5 shadow-clay-pressed">
<p className="font-nunito text-[11px] font-bold text-clay-text-muted">{t("statement.period")}</p>
<p className="mt-1 truncate font-baloo text-sm font-bold text-clay-text" title={`${job.dateFrom} → ${job.dateTo}`}>
{formatDate(job.dateFrom, { day: "2-digit", month: "2-digit" })} – {formatDate(job.dateTo, { day: "2-digit", month: "2-digit" })}
</p>
</div>
{/* Wallet */}
<div className="rounded-clay-sm bg-clay-bg p-2.5 shadow-clay-pressed">
<p className="font-nunito text-[11px] font-bold text-clay-text-muted">{t("statement.wallet")}</p>
<p className="mt-1 truncate font-baloo text-sm font-bold text-clay-text" title={walletName || (job.walletId ? t("statement.selectedWallet") : t("statement.allWallets"))}>
{walletName || (job.walletId ? t("statement.selectedWallet") : t("statement.allWallets"))}
</p>
</div>
{/* Transaction Count */}
<div className="rounded-clay-sm bg-clay-bg p-2.5 shadow-clay-pressed">
<p className="font-nunito text-[11px] font-bold text-clay-text-muted">{t("statement.recordCount")}</p>
<p className="mt-1 font-baloo text-sm font-bold text-clay-text">
{job.recordCount !== null ? formatNumber(job.recordCount) : "—"}
</p>
</div>
{/* File Size */}
<div className="rounded-clay-sm bg-clay-bg p-2.5 shadow-clay-pressed">
<p className="font-nunito text-[11px] font-bold text-clay-text-muted">{t("statement.fileSize")}</p>
<p className="mt-1 font-baloo text-sm font-bold text-clay-text">
{formatFileSize(job.fileSize) || "—"}
</p>
</div>
</div>
{/* Bottom Footer Action & Timestamps */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-clay-text-muted/10 pt-3">
<span className="font-nunito text-xs text-clay-text-muted">
{formatDate(job.createdAt, {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
{job.status === "COMPLETED" && (
<div className="flex items-center gap-3">
<span className="hidden sm:inline-flex items-center gap-1 font-nunito text-[11px] text-clay-text-muted">
<ClockIcon size={14} className="text-clay-warning" />
{t("statement.expiresIn48h")}
</span>
<Button
variant="primary"
shape="pill"
className="gap-1.5 px-4 py-1.5 text-xs font-bold"
onClick={handleDownload}
disabled={isDownloading}
>
<DownloadIcon size={15} />
{isDownloading ? t("common.loading") : t("statement.downloadFile")}
</Button>
</div>
)}
{job.status === "PROCESSING" && (
<p className="font-nunito text-xs font-semibold text-clay-primary animate-pulse">
{t("statement.processingNotice")}
</p>
)}
{job.status === "FAILED" && (
<p className="font-nunito text-xs font-semibold text-clay-expense">
{t("statement.failedNotice")}
</p>
)}
</div>
</Card>
);
};
import React from "react";
import { Card } from "@/components/ui/Card";
export const StatementSkeleton: React.FC = () => (
<Card className="animate-pulse p-5" aria-hidden="true">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<div className="h-6 w-24 rounded-full bg-clay-text-muted/15" />
<div className="h-6 w-20 rounded-full bg-clay-text-muted/10" />
</div>
<div className="h-6 w-28 rounded-full bg-clay-primary/20" />
</div>
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-4">
{Array.from({ length: 4 }, (_, index) => (
<div key={index} className="h-16 rounded-clay-sm bg-clay-bg p-2.5 shadow-clay-pressed space-y-2">
<div className="h-3 w-12 rounded-full bg-clay-text-muted/15" />
<div className="h-4 w-20 rounded-full bg-clay-text-muted/20" />
</div>
))}
</div>
<div className="mt-4 flex items-center justify-between border-t border-clay-text-muted/10 pt-3">
<div className="h-3.5 w-36 rounded-full bg-clay-text-muted/10" />
<div className="h-8 w-28 rounded-full bg-clay-primary/25" />
</div>
</Card>
);
import React, { useMemo, useState } from "react";
import { Header, Page, useNavigate } from "zmp-ui";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { IconGradients, PlusIcon, ReportIcon } from "@/components/ui/icons";
import { useStatementHistory } from "@/hooks/use-statements";
import { useWallets } from "@/hooks/use-wallets";
import { useI18n } from "@/i18n";
import { getErrorMessage } from "@/lib/error-message";
import { ExportStatementModal } from "./components/ExportStatementModal";
import { StatementJobCard } from "./components/StatementJobCard";
import { StatementSkeleton } from "./components/StatementSkeleton";
const PAGE_SIZE = 6;
export const StatementHistoryPage: React.FC = () => {
const navigate = useNavigate();
const { t } = useI18n();
const [isModalOpen, setIsModalOpen] = useState(false);
const [page, setPage] = useState(1);
const historyQuery = useStatementHistory(page, PAGE_SIZE);
const walletsQuery = useWallets({
includeArchived: true,
sortBy: "name",
order: "asc",
page: 1,
limit: 100,
});
const walletMap = useMemo(() => {
const map = new Map<string, string>();
walletsQuery.data?.data.forEach((w) => {
map.set(w.id, `${w.name} (${w.currency})`);
});
return map;
}, [walletsQuery.data?.data]);
const items = historyQuery.data?.data || [];
const totalItems = historyQuery.data?.meta.total || 0;
const totalPages = historyQuery.data?.meta.totalPages || 0;
const activeJobsCount = useMemo(
() => items.filter((job) => job.status === "PENDING" || job.status === "PROCESSING").length,
[items],
);
return (
<Page className="page">
<Header
title={t("statement.pageTitle") || "Xuất sao kê giao dịch"}
showBackIcon
onBackClick={() => navigate("/")}
/>
<IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12">
{/* Claymorphic Hero Overview Card */}
<Card className="relative overflow-hidden !bg-clay-primary-dark p-5 !text-clay-on-primary">
<div className="absolute -right-8 -top-8 h-32 w-32 rounded-full bg-clay-income/20" />
<div className="absolute -bottom-10 right-14 h-24 w-24 rounded-full bg-clay-on-primary/10" />
<div className="relative flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-clay bg-clay-info shadow-clay-raised">
<ReportIcon size={28} />
</div>
<p className="font-nunito text-sm font-bold text-clay-on-primary">
{t("statement.heroTitle") || "Tổng quan sao kê"}
</p>
<p className="font-baloo text-3xl font-bold text-clay-on-primary">
{historyQuery.isLoading ? "—" : totalItems}
</p>
<p className="font-nunito text-xs font-semibold text-clay-on-primary/80">
{t("statement.pageSubtitle") || "Kết xuất bất đồng bộ Excel, PDF và CSV"}
</p>
{activeJobsCount > 0 && (
<span className="mt-3 inline-flex items-center gap-1.5 rounded-full bg-clay-warning px-3 py-1 font-nunito text-xs font-bold text-clay-on-status">
<span className="h-2 w-2 animate-ping rounded-full bg-clay-text" />
{t("statement.activeCount", { count: activeJobsCount }) || `${activeJobsCount} yêu cầu đang xử lý...`}
</span>
)}
</div>
<Button
shape="pill"
variant="secondary"
className="gap-1.5 px-4 py-2 text-sm font-bold shrink-0"
onClick={() => setIsModalOpen(true)}
>
<PlusIcon size={17} /> {t("statement.newExportBtn") || "Xuất mới"}
</Button>
</div>
</Card>
{/* Feature Information Pill */}
<Card className="flex items-center gap-3 p-3.5 bg-clay-surface border border-clay-highlight/50 shadow-clay-raised">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-clay-sm bg-clay-primary/15 text-clay-primary">
<ReportIcon size={20} />
</div>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="font-baloo text-xs font-bold text-clay-text">
{t("statement.bannerTitle") || "Xử lý nền an toàn & Không nghẽn mạng"}
</span>
<span className="font-nunito text-[11px] text-clay-text-muted leading-tight">
{t("statement.bannerDesc") ||
"Hệ thống tạo tệp trong nền và gửi thông báo khi hoàn tất. Đường dẫn tải về có hiệu lực trong 48 giờ."}
</span>
</div>
</Card>
{/* History List Section */}
<section aria-labelledby="statement-list-heading" className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3 px-1">
<div>
<h2 id="statement-list-heading" className="clay-title-h2">
{t("statement.historyTitle") || "Lịch sử yêu cầu xuất sao kê"}
</h2>
<p className="clay-caption">
{t("common.results", { count: totalItems })}
</p>
</div>
{historyQuery.isFetching && !historyQuery.isLoading && (
<span className="clay-caption animate-pulse">{t("common.updating") || "Đang đồng bộ..."}</span>
)}
</div>
{/* Loading Skeletons */}
{historyQuery.isLoading && (
<div className="flex flex-col gap-3" aria-label={t("common.loading")}>
<StatementSkeleton />
<StatementSkeleton />
<StatementSkeleton />
</div>
)}
{/* Error State */}
{historyQuery.isError && (
<Card className="flex flex-col items-center gap-3 py-9 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-clay bg-clay-expense/15 font-baloo text-2xl font-bold text-clay-expense shadow-clay-pressed">
!
</div>
<div>
<h3 className="clay-title-h3">{t("statement.loadFailed") || "Không thể tải danh sách sao kê"}</h3>
<p className="clay-caption mt-1">
{getErrorMessage(historyQuery.error, t("common.connectionFailed") || "Vui lòng kiểm tra lại kết nối mạng")}
</p>
</div>
<Button
variant="secondary"
className="text-sm font-bold"
onClick={() => historyQuery.refetch()}
>
{t("common.retry")}
</Button>
</Card>
)}
{/* Empty State */}
{!historyQuery.isLoading && !historyQuery.isError && items.length === 0 && (
<Card className="flex flex-col items-center gap-4 py-9 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-clay-lg bg-clay-info/20 shadow-clay-pressed">
<ReportIcon size={34} />
</div>
<div>
<h3 className="clay-title-h3">{t("statement.emptyTitle") || "Chưa có sao kê nào"}</h3>
<p className="clay-caption mt-1 max-w-xs">
{t("statement.emptyDesc") ||
"Bạn chưa tạo yêu cầu xuất sao kê nào. Hãy nhấn nút bên dưới để tạo bản sao kê đầu tiên."}
</p>
</div>
<Button
className="gap-2 text-sm font-bold"
onClick={() => setIsModalOpen(true)}
>
<PlusIcon size={18} /> {t("statement.createFirstExport") || "Tạo sao kê đầu tiên"}
</Button>
</Card>
)}
{/* Job Items */}
{!historyQuery.isLoading &&
!historyQuery.isError &&
items.map((job) => (
<StatementJobCard
key={job.jobId}
job={job}
walletName={job.walletId ? walletMap.get(job.walletId) : undefined}
onRefresh={() => historyQuery.refetch()}
/>
))}
{/* Pagination */}
{totalPages > 1 && (
<nav className="flex items-center justify-between gap-3 pt-2" aria-label={t("common.pagination")}>
<Button
variant="secondary"
className="px-4 text-sm font-bold"
disabled={page <= 1 || historyQuery.isFetching}
onClick={() => setPage((current) => current - 1)}
>
{t("common.previous")}
</Button>
<span className="rounded-full bg-clay-surface px-4 py-2 font-nunito text-sm font-bold text-clay-text shadow-clay-pressed">
{t("common.pageOf", { page, total: totalPages })}
</span>
<Button
variant="secondary"
className="px-4 text-sm font-bold"
disabled={page >= totalPages || historyQuery.isFetching}
onClick={() => setPage((current) => current + 1)}
>
{t("common.next")}
</Button>
</nav>
)}
</section>
</main>
{/* Export Statement Modal */}
<ExportStatementModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSuccess={() => {
historyQuery.refetch();
}}
/>
</Page>
);
};
export default StatementHistoryPage;
import { apiClient } from "@/lib/api-client";
import {
CreateStatementExportDto,
StatementHistoryResponse,
StatementJobItem,
StatementVerifyResponse,
} from "@/types/statement";
export const statementService = {
async initiateExport(dto: CreateStatementExportDto): Promise<StatementJobItem> {
const response = await apiClient.post<{ success: boolean; data: StatementJobItem }>(
"/statements/export",
dto,
);
return response.data.data;
},
async getJob(jobId: string): Promise<StatementJobItem> {
const response = await apiClient.get<{ success: boolean; data: StatementJobItem }>(
`/statements/jobs/${jobId}`,
);
return response.data.data;
},
async listHistory(page = 1, limit = 10): Promise<StatementHistoryResponse> {
const response = await apiClient.get<StatementHistoryResponse>("/statements/history", {
params: { page, limit },
});
return response.data;
},
async verifyStatement(code: string): Promise<StatementVerifyResponse> {
const response = await apiClient.get<{ success: boolean; data: StatementVerifyResponse }>(
`/statements/verify/${code}`,
);
return response.data.data;
},
async downloadStatement(jobId: string, downloadUrl?: string | null): Promise<Blob> {
const targetUrl =
downloadUrl && downloadUrl.startsWith("http")
? downloadUrl
: `/statements/jobs/${jobId}/download`;
const response = await apiClient.get<Blob>(targetUrl, {
responseType: "blob",
});
return response.data;
},
};
export type StatementExportFormat = 'XLSX' | 'PDF' | 'CSV';
export type StatementJobStatus = 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
export interface CreateStatementExportDto {
walletId?: string;
dateFrom: string; // YYYY-MM-DD
dateTo: string; // YYYY-MM-DD
format: StatementExportFormat;
password?: string;
passwordHint?: string;
}
export interface StatementJobItem {
jobId: string;
userId: string;
walletId: string | null;
format: StatementExportFormat;
status: StatementJobStatus;
dateFrom: string;
dateTo: string;
isPasswordProtected: boolean;
passwordHint: string | null;
downloadUrl: string | null;
fileSize: number | null;
recordCount: number | null;
expiresAt: string | null;
startedAt: string | null;
completedAt: string | null;
createdAt: string;
checkStatusUrl: string;
}
export interface StatementHistoryResponse {
success: boolean;
data: StatementJobItem[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export interface StatementVerifyResponse {
isValid: boolean;
verificationCode: string;
userName: string | null;
walletName: string | null;
period: string;
recordCount: number | null;
issuedAt: string | null;
status: StatementJobStatus;
}
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