Commit 8632c51e authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/backup-import-and-account-handover-ui' into 'develop'

feat(data): implement backup import merge, QR/OTP account handover, and layout standardization

See merge request !52
parents 4cf6a734 5ef8ba07
......@@ -102,6 +102,12 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- 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/`.
- **Quy chuẩn bố cục trang (Page Layout & Safe-Area Padding Standards)**:
- `.page` trong `src/css/app.scss` đã có sẵn padding ngang mặc định (14px/16px) và padding an toàn cho header/bottom-nav.
- Tuyệt đối KHÔNG bọc thêm `px-4`, `p-4`, `min-h-screen`, `bg-clay-bg` bên trong `<Page className="page">` hoặc trên container con cấp 1 (như `<main>`), vì sẽ gây ra hiện tượng cộng dồn padding (32px+ mỗi bên), khiến viền hai bên màn hình bị thụt vào quá lớn trên thiết bị di động.
- Cấu trúc chuẩn áp dụng đồng nhất cho toàn bộ các trang (bao gồm cả phân hệ Quản lý dữ liệu `/data-management`, Chuyển giao `/account-handover`, Tích hợp API Key & Webhooks `/admin/integrations`, và toàn bộ các trang thuộc Trung tâm quản trị `/admin/*`):
`<Page className="page">` -> `<Header ... />` -> `<IconGradients />` -> `<main className="mx-auto mt-4 flex w-full max-w-5xl flex-col gap-5 pb-12">` (hoặc `max-w-lg`/`max-w-4xl` tùy đặc thù trang).
- **Khoảng cách hàng nút điều khiển trên Header (`.finwise-header-controls`)**: Nút chuyển đổi ngôn ngữ (Language Switcher), Darkmode và Thông báo được đặt cố định ở góc trên bên phải. Để tránh chạm viền hoặc đè lên cụm nút đóng/tùy chọn mặc định của Zalo (capsule buttons chiếm khoảng 88-96px từ mép phải), `.finwise-header-controls` phải giữ khoảng cách `right: calc(var(--zaui-safe-area-inset-right) + 104px)` trên màn hình di động (<= 480px) và `+ 108px` trên desktop; đồng thời `.zaui-header` phải có `padding-right: calc(var(--zaui-safe-area-inset-right) + 224px)` (hoặc `+ 248px`) để tiêu đề không bị che khuất.
## Khi cập nhật file này
......
......@@ -59,6 +59,8 @@ const AdminNotificationsPage = React.lazy(() => import("@/pages/admin/notificati
const AdminAiPage = React.lazy(() => import("@/pages/admin/ai/index"));
const IntegrationsPage = React.lazy(() => import("@/pages/integrations/index"));
const StatementsPage = React.lazy(() => import("@/pages/statements/index"));
const DataManagementPage = React.lazy(() => import("@/pages/data-management/index"));
const AccountHandoverPage = React.lazy(() => import("@/pages/account-handover/index"));
const NotFoundPage = React.lazy(() => import("@/pages/not-found/index"));
import { PermissionGate } from "@/components/shared/PermissionGate";
......@@ -205,6 +207,8 @@ export const Layout = () => {
<Route path="/admin/roles" element={<AuthGuard><PermissionGate permission={PERMISSIONS.ROLE_READ} fallback={<AccessDenied />}><RolesPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/ai-assistant" element={<AuthGuard><PermissionGate permission={PERMISSIONS.AI_ASSISTANT_USE} fallback={<AccessDenied />}><AIAssistantPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route>
<Route path="/data-management" element={<AuthGuard><DataManagementPage /></AuthGuard>}></Route>
<Route path="/account-handover" element={<AuthGuard><AccountHandoverPage /></AuthGuard>}></Route>
{/* 404 Catch-All Route */}
<Route path="*" element={<NotFoundPage />}></Route>
</AnimationRoutes>
......
......@@ -298,14 +298,14 @@ body {
var(--zaui-safe-area-inset-top, env(safe-area-inset-top, 0px)) + 8px
);
right: calc(
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 96px
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 108px
);
}
/* Reserve space for native right-buttons (96px), three controls, and gaps. */
.zaui-header {
padding-right: calc(
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 236px
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 248px
);
}
......@@ -313,12 +313,12 @@ body {
.finwise-header-controls {
gap: 4px;
right: calc(
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 88px
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 104px
);
}
.zaui-header {
padding-right: calc(
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 192px
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 224px
);
}
}
......
......@@ -2341,6 +2341,188 @@
"downloadFile": "Download",
"processingNotice": "Extracting data in background, please wait a moment...",
"failedNotice": "Processing failed. Please try again later."
},
"dataManagement": {
"header": "Data Management",
"subtitle": "Backup, restore and smart merge financial records",
"tabs": {
"import": "Restore / Smart Merge",
"export": "Export Backup"
},
"steps": {
"step1": "Select File",
"step2": "Preview",
"step3": "Conflict Resolution",
"step4": "Execute"
},
"step1": {
"title": "Step 1: Select JSON Backup File",
"subtitle": "Supports standard FinWise JSON backup exported from device or Zalo",
"dragDrop": "Click or drag & drop backup file (.json)",
"fileSelected": "Selected: {{name}} ({{size}})",
"reselect": "Choose another file",
"encryptedNotice": "This backup file is password protected. Enter password to continue:",
"passwordLabel": "Decryption Password",
"passwordPlaceholder": "Enter password used during export...",
"analyzeButton": "Analyze Backup File",
"analyzing": "Analyzing..."
},
"step2": {
"title": "Step 2: Analysis & Data Preview",
"statusEmpty": "Account is Empty (Ready for 100% clean restore)",
"statusEmptyDesc": "This account currently has no transactions. Data will be completely restored.",
"statusHasData": "Account already has existing data ({{wallets}} wallets, {{txs}} transactions)",
"statusHasDataDesc": "FinWise will perform smart merge, preserving existing transactions and preventing duplicates.",
"fileInfo": "Backup File Metadata",
"fileVersion": "Version: v{{version}}",
"exportedAt": "Exported: {{date}}",
"checksum": "SHA-256 Checksum: Verified",
"summaryTitle": "File Records Breakdown",
"wallets": "Wallets",
"categories": "Categories",
"transactions": "Transactions",
"transfers": "Transfers",
"budgets": "Budgets",
"savingGoals": "Saving Goals",
"duplicatesNotice": "Found {{count}} potential duplicate transactions with existing data (will be skipped).",
"continueButton": "Continue: Conflict Resolution"
},
"step3": {
"title": "Step 3: Conflict Resolution Settings",
"walletSection": "Wallet Conflicts",
"walletSectionDesc": "Choose whether to merge into existing wallet or create a new independent wallet",
"mergeInto": "Merge into existing wallet:",
"createNew": "Create new independent wallet",
"targetWallet": "Target Wallet:",
"newWalletName": "New Wallet Name:",
"balanceSection": "Balance Resolution Policy",
"balanceSectionDesc": "Choose how balances are adjusted during import",
"balanceAccumulate": "(A) Accumulate balance from file into current balance",
"balanceAccumulateDesc": "Balances from file will be added to your current wallet balances.",
"balanceMaintain": "(B) Maintain current actual balance",
"balanceMaintainDesc": "Keep current actual wallet balance untouched. Historical transactions are recorded without changing current balance.",
"categorySection": "Expense & Income Categories",
"categoryAutoMerged": "Automatically matched with existing category with same name",
"categoryCreated": "Will be created as a new category",
"confirmButton": "Confirm Settings"
},
"step4": {
"title": "Step 4: Confirm & Import Ledger",
"warningTitle": "Ledger Safety Notice",
"warningDesc": "Import runs inside a serializable database transaction. Any error will trigger 100% rollback without leaving dirty records.",
"startButton": "Start Data Import",
"processing": "Importing data...",
"progressSteps": {
"validating": "Validating file integrity and signatures...",
"mapping": "Translating foreign keys and categories...",
"ledger": "Writing atomic ledger and deduplicating...",
"completing": "Finalizing balances and audit trail..."
},
"successTitle": "Import Completed Successfully!",
"successDesc": "All financial records have been safely merged into your account.",
"resultTitle": "Detailed Merge Summary",
"importedRecords": "Records imported successfully:",
"skippedDuplicates": "Duplicate transactions/transfers skipped:",
"homeButton": "Return to Dashboard",
"transactionsButton": "View Transactions"
},
"export": {
"title": "Export Financial Backup",
"subtitle": "Export all wallets, categories, and transactions into a standard FinWise JSON backup",
"passwordLabel": "Protection Password (Optional)",
"passwordPlaceholder": "Leave blank for unencrypted backup...",
"passwordHint": "If provided, the file will be encrypted using military-grade AES-256-GCM.",
"exportButton": "Download JSON Backup",
"exporting": "Generating backup...",
"exportSuccess": "Backup file created and downloaded successfully!",
"exportFailed": "Failed to export backup. Please try again."
}
},
"handover": {
"header": "Account Ownership Handover",
"tabSend": "Sender (Old Device)",
"tabReceive": "Receiver (New Device)",
"menuTitle": "Transfer data to another Zalo account",
"menuSubtitle": "Real-time QR scan & 2-way OTP handover",
"bannerTitle": "Switching to new Zalo? Transfer via QR code",
"bannerSubtitle": "Instant 2-way OTP authorization without manual file export/import",
"btnTryNow": "Try now",
"warningTitle": "Important Warning",
"warningSubtitle": "Transferring ownership of financial ledger",
"warningNotice1": "Notice before proceeding:",
"warningNotice2": "All wallets, income/expense history, budgets, and goals will be permanently moved to the new Zalo account.",
"warningNotice3": "Your current account will be immediately logged out and securely locked.",
"warningNotice4": "The QR code and PIN code will automatically expire after 15 minutes.",
"btnInitiate": "Create Code",
"initiateSuccess": "Handover session created successfully",
"timeLeft": "Time remaining:",
"scanInstructions": "Open Zalo camera on your new device to scan this code:",
"orEnterPin": "Or enter this 6-digit PIN manually:",
"copyPin": "Copy PIN Code",
"pinCopied": "PIN code copied to clipboard",
"cancelTransfer": "Cancel Handover",
"cancelled": "Handover session cancelled",
"targetConnected": "New Account Connected!",
"otpAuthNotice": "Two-way Mutual Authorization:",
"otpInputLabel": "OTP Authorization Code (6 digits)",
"otpRequired": "Please enter the 6-digit OTP code",
"btnConfirmOtp": "Authorize & Transfer Data Now",
"rejectTransfer": "Reject & Cancel",
"confirmSuccess": "Ownership transferred successfully!",
"completedTitle": "Transfer Completed!",
"completedSubtitle": "All your data has been safely transferred to the new account. This login session has been closed.",
"backToLogin": "Understood & Back to Login",
"expiredTitle": "Handover Code Expired",
"expiredSubtitle": "The 15-minute validity window has expired. Please create a new code to continue.",
"btnReInitiate": "Create New Code",
"scanQrOption": "Scan QR Code from old device",
"scanQrDesc": "Open camera to scan QR code directly from device A's screen",
"btnScanQr": "Scan QR",
"enterPinOption": "Enter 6-digit PIN manually",
"enterPinDesc": "Enter the 6-digit PIN shown on your old device's screen",
"pinRequired": "Please enter a valid 6-digit PIN",
"btnConnectPin": "Connect with Old Account",
"claimSuccess": "Connected to old account. Waiting for OTP confirmation...",
"qrEmpty": "Could not recognize QR code content",
"qrInvalid": "QR code format does not match FinWise specification",
"connectedWaitingTitle": "Successfully Connected!",
"waitingOtpApproval": "Please wait for the old account owner to confirm the OTP code on their screen...",
"receiverSuccessTitle": "Data Received Successfully!",
"receiverSuccessSubtitle": "All financial records from the old account have been transferred into your Zalo account intact.",
"goToDashboard": "Go to Dashboard",
"senderStep2Title": "Scan Code or Enter PIN",
"senderStep2Subtitle": "Open Zalo on your new device to connect with this session",
"senderStep3Title": "Authorize Transfer",
"senderStep3Subtitle": "Sign and approve the ownership transfer of the financial ledger",
"otpDevLabel": "Confirmation OTP code (Sandbox):",
"transferredSummaryTitle": "Data transferred successfully",
"receivedSummaryTitle": "Received data summary",
"securityTitle": "HMAC-SHA256 & 2-way OTP encryption",
"securitySubtitle": "All records are transferred intact within a single safe database transaction.",
"receiverStep1Title": "Receive Data",
"receiverStep1Subtitle": "Choose a method to connect with your old account",
"receiverStep2Title": "Waiting for Authorization",
"receiverStep2Subtitle": "Successfully connected to source account",
"connectedWith": "Connected with:",
"autoListeningNotice": "System is listening in real time and will update records immediately once completed.",
"viewTransactions": "View Transactions",
"steps": {
"send1": "Initiate",
"send2": "QR & PIN",
"send3": "Authorize OTP",
"send4": "Completed",
"receive1": "Connect",
"receive2": "Waiting OTP",
"receive3": "Receive"
},
"records": {
"wallets": "Wallets:",
"categories": "Categories:",
"transactions": "Transactions:",
"transfers": "Transfers:",
"budgets": "Budgets:",
"savingGoals": "Goals:"
}
}
}
......@@ -2442,6 +2442,188 @@
"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."
},
"dataManagement": {
"header": "Quản lý dữ liệu",
"subtitle": "Sao lưu, khôi phục và hợp nhất dữ liệu tài chính",
"tabs": {
"import": "Khôi phục / Hợp nhất",
"export": "Sao lưu dữ liệu"
},
"steps": {
"step1": "Chọn file",
"step2": "Xem trước",
"step3": "Xử lý xung đột",
"step4": "Tiến hành"
},
"step1": {
"title": "Bước 1: Chọn file sao lưu JSON",
"subtitle": "Hỗ trợ file JSON sao lưu tiêu chuẩn FinWise được xuất từ thiết bị hoặc Zalo",
"dragDrop": "Chọn hoặc kéo thả tệp tin sao lưu (.json)",
"fileSelected": "Đã chọn: {{name}} ({{size}})",
"reselect": "Chọn tệp khác",
"encryptedNotice": "Tệp tin này đã được mã hóa bảo vệ. Vui lòng nhập mật khẩu để tiếp tục:",
"passwordLabel": "Mật khẩu giải mã",
"passwordPlaceholder": "Nhập mật khẩu đã dùng khi sao lưu...",
"analyzeButton": "Phân tích file sao lưu",
"analyzing": "Đang phân tích..."
},
"step2": {
"title": "Bước 2: Phân tích & Xem trước dữ liệu",
"statusEmpty": "Tài khoản trống (Sẵn sàng nạp sạch 100%)",
"statusEmptyDesc": "Tài khoản hiện chưa có giao dịch nào. Dữ liệu sẽ được phục hồi nguyên vẹn như tài khoản cũ.",
"statusHasData": "Tài khoản đã có dữ liệu sẵn ({{wallets}} ví, {{txs}} giao dịch)",
"statusHasDataDesc": "FinWise sẽ tiến hành hợp nhất thông minh, bảo toàn giao dịch hiện tại và chống trùng lặp.",
"fileInfo": "Thông tin file sao lưu",
"fileVersion": "Phiên bản: v{{version}}",
"exportedAt": "Ngày xuất: {{date}}",
"checksum": "Checksum SHA-256: Hợp lệ",
"summaryTitle": "Chi tiết các bản ghi trong file",
"wallets": "Ví tài khoản",
"categories": "Danh mục",
"transactions": "Giao dịch",
"transfers": "Chuyển tiền",
"budgets": "Ngân sách",
"savingGoals": "Mục tiêu tiết kiệm",
"duplicatesNotice": "Phát hiện {{count}} giao dịch có thể trùng lặp với dữ liệu hiện tại (sẽ tự động bỏ qua).",
"continueButton": "Tiếp tục: Xử lý xung đột"
},
"step3": {
"title": "Bước 3: Thiết lập xử lý xung đột dữ liệu",
"walletSection": "Xung đột ví tiền",
"walletSectionDesc": "Chọn gộp vào ví hiện tại hoặc tạo thành ví mới độc lập",
"mergeInto": "Gộp vào ví hiện tại:",
"createNew": "Tạo ví mới độc lập",
"targetWallet": "Ví đích tiếp nhận:",
"newWalletName": "Tên ví mới:",
"balanceSection": "Cơ chế xử lý số dư",
"balanceSectionDesc": "Chọn phương án điều chỉnh số dư khi nạp",
"balanceAccumulate": "(A) Cộng dồn số dư từ file vào số dư hiện tại",
"balanceAccumulateDesc": "Số dư của các ví trong file sao lưu sẽ được cộng thêm vào số dư thực tế hiện tại của bạn.",
"balanceMaintain": "(B) Giữ nguyên số dư thực tế hiện tại",
"balanceMaintainDesc": "Giữ nguyên số tiền hiện có trong túi của bạn. Tự sinh giao dịch cân chỉnh số dư mở đầu cho các giao dịch lịch sử.",
"categorySection": "Danh mục chi tiêu & thu nhập",
"categoryAutoMerged": "Đã tự động ghép nối với danh mục hiện tại trùng tên",
"categoryCreated": "Sẽ được tạo mới bổ sung",
"confirmButton": "Xác nhận thiết lập"
},
"step4": {
"title": "Bước 4: Xác nhận & Nạp dữ liệu",
"warningTitle": "Cảnh báo an toàn sổ cái",
"warningDesc": "Quá trình nạp sẽ thực hiện nguyên tử trong cơ sở dữ liệu. Mọi lỗi phát sinh sẽ tự động hoàn tác (Rollback 100%) mà không ảnh hưởng tới dữ liệu hiện tại.",
"startButton": "Bắt đầu nạp dữ liệu",
"processing": "Đang nạp dữ liệu...",
"progressSteps": {
"validating": "Đang xác thực và kiểm tra tính toàn vẹn...",
"mapping": "Đang dịch ánh xạ khóa ngoại và danh mục...",
"ledger": "Đang ghi sổ cái giao dịch và chống trùng lặp...",
"completing": "Đang hoàn tất và cập nhật số dư..."
},
"successTitle": "Nạp dữ liệu thành công!",
"successDesc": "Toàn bộ dữ liệu tài chính đã được hợp nhất an toàn vào tài khoản của bạn.",
"resultTitle": "Kết quả hợp nhất chi tiết",
"importedRecords": "Bản ghi đã nạp thành công:",
"skippedDuplicates": "Giao dịch/chuyển khoản trùng lặp đã bỏ qua:",
"homeButton": "Về trang chủ",
"transactionsButton": "Xem danh sách giao dịch"
},
"export": {
"title": "Sao lưu dữ liệu tài chính",
"subtitle": "Xuất toàn bộ ví, danh mục, giao dịch thành file JSON tiêu chuẩn FinWise",
"passwordLabel": "Mật khẩu bảo vệ (Tùy chọn)",
"passwordPlaceholder": "Để trống nếu không muốn đặt mật khẩu...",
"passwordHint": "Nếu nhập mật khẩu, file sao lưu sẽ được mã hóa chuẩn AES-256-GCM quân sự.",
"exportButton": "Tải xuống file sao lưu JSON",
"exporting": "Đang tạo bản sao lưu...",
"exportSuccess": "Đã tạo và tải xuống file sao lưu thành công!",
"exportFailed": "Không thể xuất file sao lưu. Vui lòng thử lại."
}
},
"handover": {
"header": "Chuyển giao Quyền sở hữu",
"tabSend": "Bên Chuyển (Máy Cũ)",
"tabReceive": "Bên Nhận (Máy Mới)",
"menuTitle": "Chuyển dữ liệu sang Zalo khác",
"menuSubtitle": "Quét mã QR & OTP 2 chiều tức thời",
"bannerTitle": "Đổi Zalo mới? Chuyển giao qua quét mã QR",
"bannerSubtitle": "Xác thực OTP 2 chiều tức thời, không cần xuất/nhập file thủ công",
"btnTryNow": "Thử ngay",
"warningTitle": "Cảnh Báo Quan Trọng",
"warningSubtitle": "Chuyển giao quyền sở hữu sổ cái tài chính",
"warningNotice1": "Lưu ý trước khi tiếp tục:",
"warningNotice2": "Toàn bộ ví tiền, lịch sử thu chi, ngân sách, mục tiêu sẽ được chuyển vĩnh viễn sang tài khoản Zalo mới.",
"warningNotice3": "Tài khoản hiện tại sẽ tự động đăng xuất và bị khóa an toàn.",
"warningNotice4": "Mã QR và mã PIN sẽ tự động hết hạn sau 15 phút.",
"btnInitiate": "Tạo Mã",
"initiateSuccess": "Đã tạo mã chuyển giao thành công",
"timeLeft": "Hiệu lực mã:",
"scanInstructions": "Mở camera Zalo trên máy mới để quét mã này:",
"orEnterPin": "Hoặc nhập thủ công mã PIN 6 số:",
"copyPin": "Sao chép mã PIN",
"pinCopied": "Đã sao chép mã PIN vào bộ nhớ tạm",
"cancelTransfer": "Hủy Phiên Chuyển Giao",
"cancelled": "Đã hủy phiên chuyển giao",
"targetConnected": "Tài Khoản Mới Đã Kết Nối!",
"otpAuthNotice": "Xác thực bảo mật 2 chiều:",
"otpInputLabel": "Mã xác thực OTP (6 chữ số)",
"otpRequired": "Vui lòng nhập mã OTP gồm 6 chữ số",
"btnConfirmOtp": "Ký Duyệt & Chuyển Dữ Liệu Ngay",
"rejectTransfer": "Từ chối & Hủy bỏ",
"confirmSuccess": "Chuyển giao quyền sở hữu thành công!",
"completedTitle": "Chuyển Giao Thành Công!",
"completedSubtitle": "Toàn bộ dữ liệu của bạn đã được chuyển giao an toàn sang tài khoản mới. Phiên đăng nhập này đã được khóa.",
"backToLogin": "Đã Hiểu & Về Màn Hình Đăng Nhập",
"expiredTitle": "Mã Chuyển Giao Đã Hết Hạn",
"expiredSubtitle": "Thời gian hiệu lực 15 phút đã kết thúc. Vui lòng tạo mã mới để tiếp tục.",
"btnReInitiate": "Tạo Mã Mới",
"scanQrOption": "Quét mã QR từ máy cũ",
"scanQrDesc": "Mở camera Zalo quét trực tiếp mã QR trên màn hình máy A",
"btnScanQr": "Quét QR",
"enterPinOption": "Nhập mã PIN 6 số thủ công",
"enterPinDesc": "Nhập chuỗi 6 số hiển thị trên màn hình máy cũ",
"pinRequired": "Vui lòng nhập đúng 6 chữ số mã PIN",
"btnConnectPin": "Kết Nối Với Tài Khoản Cũ",
"claimSuccess": "Đã kết nối với tài khoản cũ. Đang chờ xác thực OTP...",
"qrEmpty": "Không nhận diện được nội dung mã QR",
"qrInvalid": "Mã QR không đúng định dạng FinWise",
"connectedWaitingTitle": "Đã Kết Nối Thành Công!",
"waitingOtpApproval": "Vui lòng chờ người dùng trên tài khoản cũ nhập mã OTP xác thực...",
"receiverSuccessTitle": "Đã Tiếp Nhận Dữ Liệu Thành Công!",
"receiverSuccessSubtitle": "Toàn bộ dữ liệu tài chính từ tài khoản cũ đã được chuyển giao nguyên vẹn vào tài khoản Zalo của bạn.",
"goToDashboard": "Về Bảng Điều Khiển",
"senderStep2Title": "Quét mã hoặc Nhập PIN",
"senderStep2Subtitle": "Dùng máy mới mở Zalo để kết nối với phiên này",
"senderStep3Title": "Xác Thực Chuyển Giao",
"senderStep3Subtitle": "Ký duyệt lệnh bàn giao quyền sở hữu sổ cái",
"otpDevLabel": "Mã OTP xác nhận (Sandbox):",
"transferredSummaryTitle": "Dữ liệu đã chuyển giao thành công",
"receivedSummaryTitle": "Thống kê dữ liệu đã tiếp nhận",
"securityTitle": "Bảo mật mã hóa HMAC-SHA256 & OTP 2 chiều",
"securitySubtitle": "Toàn bộ dữ liệu được di chuyển nguyên vẹn trong một giao dịch cơ sở dữ liệu an toàn.",
"receiverStep1Title": "Tiếp Nhận Dữ Liệu",
"receiverStep1Subtitle": "Chọn phương thức kết nối với tài khoản cũ của bạn",
"receiverStep2Title": "Đang Chờ Xác Thực",
"receiverStep2Subtitle": "Đã kết nối thành công với tài khoản nguồn",
"connectedWith": "Đang liên kết với:",
"autoListeningNotice": "Hệ thống đang tự động lắng nghe và sẽ cập nhật dữ liệu ngay khi hoàn tất.",
"viewTransactions": "Xem Giao Dịch",
"steps": {
"send1": "Khởi tạo",
"send2": "Quét & PIN",
"send3": "Duyệt OTP",
"send4": "Hoàn tất",
"receive1": "Kết nối",
"receive2": "Chờ duyệt OTP",
"receive3": "Tiếp nhận"
},
"records": {
"wallets": "Ví tiền:",
"categories": "Danh mục:",
"transactions": "Giao dịch:",
"transfers": "Chuyển tiền:",
"budgets": "Ngân sách:",
"savingGoals": "Mục tiêu:"
}
}
}
import React, { useEffect, useState, useRef } from "react";
import { Page, Header, useNavigate, useSnackbar } from "zmp-ui";
import { useQueryClient } from "@tanstack/react-query";
import { scanQRCode } from "zmp-sdk/apis";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Badge } from "@/components/ui/Badge";
import { Tabs } from "@/components/ui/Tabs";
import {
AlertTriangleIcon,
CameraIcon,
CheckIcon,
ClockIcon,
CopyIcon,
IconGradients,
LockIcon,
RefreshIcon,
ShieldIcon,
UserIcon,
WalletIcon,
TransactionIcon,
TransferIcon,
BudgetIcon,
SavingGoalIcon,
} from "@/components/ui/icons";
import { useAuthStore } from "@/stores/auth-store";
import { handoverService } from "@/services/handover.service";
import {
HandoverStatus,
InitiateHandoverResponse,
TransferredRecordCounts,
} from "@/types/handover";
import { useI18n } from "@/i18n";
import { copyToClipboard } from "@/lib/clipboard";
import { getErrorMessage } from "@/lib/error-message";
type TabMode = "send" | "receive";
export const AccountHandoverPage: React.FC = () => {
const { t } = useI18n();
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const queryClient = useQueryClient();
const { clearAuth } = useAuthStore();
// Read URL query parameter for default mode
const urlParams = new URLSearchParams(window.location.search);
const initialMode: TabMode = urlParams.get("mode") === "receive" ? "receive" : "send";
const [activeTab, setActiveTab] = useState<TabMode>(initialMode);
// ── Sender (Máy A - Zalo Cũ) State ──────────────────────────────────────
const [initiateData, setInitiateData] = useState<InitiateHandoverResponse | null>(null);
const [senderStatus, setSenderStatus] = useState<HandoverStatus | "IDLE">("IDLE");
const [targetUserName, setTargetUserName] = useState<string>("");
const [otpPrompt, setOtpPrompt] = useState<string>("");
const [otpDevCode, setOtpDevCode] = useState<string>("");
const [otpInput, setOtpInput] = useState<string>("");
const [senderTimeLeft, setSenderTimeLeft] = useState<number>(900);
const [isInitiating, setIsInitiating] = useState<boolean>(false);
const [isConfirming, setIsConfirming] = useState<boolean>(false);
const [senderCompletedRecords, setSenderCompletedRecords] =
useState<TransferredRecordCounts | null>(null);
// ── Receiver (Máy B - Zalo Mới) State ──────────────────────────────────
const [receiverPinInput, setReceiverPinInput] = useState<string>("");
const [receiverHandoverToken, setReceiverHandoverToken] = useState<string>("");
const [receiverStatus, setReceiverStatus] = useState<HandoverStatus | "IDLE">("IDLE");
const [connectedSourceName, setConnectedSourceName] = useState<string>("");
const [isClaiming, setIsClaiming] = useState<boolean>(false);
const [receiverSummary, setReceiverSummary] = useState<TransferredRecordCounts | null>(null);
// Timer Ref for Countdown
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Step Calculation for Sender Wizard (1 to 4)
const senderCurrentStep: number = (() => {
switch (senderStatus) {
case "PENDING_CLAIM":
return 2;
case "CLAIMED":
return 3;
case "COMPLETED":
return 4;
case "IDLE":
case "EXPIRED":
default:
return 1;
}
})();
// Step Calculation for Receiver Wizard (1 to 3)
const receiverCurrentStep: number = (() => {
switch (receiverStatus) {
case "CLAIMED":
return 2;
case "COMPLETED":
return 3;
case "IDLE":
default:
return 1;
}
})();
// ── SENDER ACTIONS ─────────────────────────────────────────────────────
// 1. Máy A khởi tạo phiên chuyển giao
const handleInitiateHandover = async () => {
try {
setIsInitiating(true);
const res = await handoverService.initiate();
setInitiateData(res);
setSenderStatus("PENDING_CLAIM");
setSenderTimeLeft(res.expiresIn || 900);
openSnackbar({
type: "success",
text: t("handover.initiateSuccess") || "Đã tạo mã chuyển giao thành công",
});
} catch (err) {
openSnackbar({
type: "error",
text: getErrorMessage(err, "Không thể tạo mã chuyển giao"),
});
} finally {
setIsInitiating(false);
}
};
// 2. Countdown timer cho Máy A
useEffect(() => {
if (senderStatus === "PENDING_CLAIM" || senderStatus === "CLAIMED") {
timerRef.current = setInterval(() => {
setSenderTimeLeft((prev) => {
if (prev <= 1) {
clearInterval(timerRef.current!);
setSenderStatus("EXPIRED");
return 0;
}
return prev - 1;
});
}, 1000);
} else {
if (timerRef.current) clearInterval(timerRef.current);
}
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [senderStatus]);
// 3. Polling trạng thái cho Máy A
useEffect(() => {
let pollInterval: ReturnType<typeof setInterval> | null = null;
if (senderStatus === "PENDING_CLAIM" || senderStatus === "CLAIMED") {
pollInterval = setInterval(async () => {
try {
const statusRes = await handoverService.getStatus();
if (statusRes.status === "CLAIMED") {
setSenderStatus("CLAIMED");
if (statusRes.targetUserName) setTargetUserName(statusRes.targetUserName);
if (statusRes.otpPrompt) setOtpPrompt(statusRes.otpPrompt);
if (statusRes.otpCodeDev) setOtpDevCode(statusRes.otpCodeDev);
} else if (statusRes.status === "EXPIRED") {
setSenderStatus("EXPIRED");
}
} catch (err: any) {
if (err?.response?.status === 404) {
setSenderStatus("EXPIRED");
}
}
}, 2500);
}
return () => {
if (pollInterval) clearInterval(pollInterval);
};
}, [senderStatus]);
// 4. Máy A xác nhận mã OTP để ký chuyển giao
const handleConfirmOtp = async () => {
if (!initiateData?.handoverToken || !otpInput.trim()) {
openSnackbar({
type: "warning",
text: t("handover.otpRequired") || "Vui lòng nhập mã OTP gồm 6 chữ số",
});
return;
}
try {
setIsConfirming(true);
const res = await handoverService.confirm({
handoverToken: initiateData.handoverToken,
otp: otpInput.trim(),
});
setSenderStatus("COMPLETED");
setSenderCompletedRecords(res.transferredRecords);
// Đăng xuất và dọn dẹp auth store
clearAuth();
openSnackbar({
type: "success",
text: t("handover.confirmSuccess") || "Chuyển giao quyền sở hữu thành công!",
});
} catch (err) {
openSnackbar({
type: "error",
text: getErrorMessage(err, "Không thể xác nhận chuyển giao"),
});
} finally {
setIsConfirming(false);
}
};
// 5. Hủy phiên chuyển giao
const handleCancelHandover = async () => {
try {
await handoverService.cancel(initiateData?.handoverToken);
setSenderStatus("IDLE");
setInitiateData(null);
setOtpInput("");
openSnackbar({
type: "info",
text: t("handover.cancelled") || "Đã hủy phiên chuyển giao",
});
} catch (err) {
openSnackbar({
type: "error",
text: getErrorMessage(err, "Không thể hủy phiên chuyển giao"),
});
}
};
// ── RECEIVER ACTIONS ───────────────────────────────────────────────────
// 1. Quét mã QR bằng Camera Zalo
const handleScanQRCode = async () => {
try {
const scanResult = await scanQRCode({});
const scannedContent = (scanResult as any)?.content || (scanResult as any)?.data || "";
if (!scannedContent) {
openSnackbar({
type: "warning",
text: t("handover.qrEmpty") || "Không nhận diện được nội dung mã QR",
});
return;
}
let token = "";
let sig = "";
if (scannedContent.includes("token=")) {
const parsedUrl = new URL(scannedContent.replace("finwise://", "https://finwise.local/"));
token = parsedUrl.searchParams.get("token") || "";
sig = parsedUrl.searchParams.get("sig") || "";
} else {
token = scannedContent;
}
if (!token) {
openSnackbar({
type: "error",
text: t("handover.qrInvalid") || "Mã QR không đúng định dạng FinWise",
});
return;
}
await executeClaim({ handoverToken: token, signature: sig });
} catch (err) {
openSnackbar({
type: "error",
text: getErrorMessage(err, "Không thể mở camera quét mã QR"),
});
}
};
// 2. Nhập mã PIN 6 số thủ công
const handleClaimByPin = async () => {
if (!receiverPinInput.trim() || receiverPinInput.trim().length !== 6) {
openSnackbar({
type: "warning",
text: t("handover.pinRequired") || "Vui lòng nhập đúng 6 chữ số mã PIN",
});
return;
}
await executeClaim({ pinCode: receiverPinInput.trim() });
};
// 3. Thực thi Claim
const executeClaim = async (payload: {
handoverToken?: string;
signature?: string;
pinCode?: string;
}) => {
try {
setIsClaiming(true);
const res = await handoverService.claim(payload);
setReceiverHandoverToken(res.handoverToken);
setConnectedSourceName(res.sourceUserName);
setReceiverStatus("CLAIMED");
openSnackbar({
type: "success",
text: t("handover.claimSuccess") || "Đã kết nối với tài khoản cũ. Đang chờ xác thực OTP...",
});
} catch (err) {
openSnackbar({
type: "error",
text: getErrorMessage(err, "Không thể kết nối với tài khoản cũ"),
});
} finally {
setIsClaiming(false);
}
};
// 4. Polling kết quả hoàn tất cho Máy B
useEffect(() => {
let pollInterval: ReturnType<typeof setInterval> | null = null;
if (receiverStatus === "CLAIMED" && receiverHandoverToken) {
pollInterval = setInterval(async () => {
try {
const res = await handoverService.getResult(receiverHandoverToken);
if (res.status === "COMPLETED") {
setReceiverStatus("COMPLETED");
if (res.transferredRecords) setReceiverSummary(res.transferredRecords);
// Invalidate React Query để cập nhật ngay dashboard
queryClient.invalidateQueries();
}
} catch {
// Bỏ qua lỗi polling tạm thời
}
}, 2500);
}
return () => {
if (pollInterval) clearInterval(pollInterval);
};
}, [receiverStatus, receiverHandoverToken, queryClient]);
// Helper format giây thành MM:SS
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
};
// Copy PIN helper
const handleCopyPin = (pin: string) => {
copyToClipboard(pin);
openSnackbar({
type: "success",
text: t("handover.pinCopied") || "Đã sao chép mã PIN vào bộ nhớ tạm",
});
};
const tabs = [
{ key: "send", label: t("handover.tabSend") || "Bên Chuyển (Máy Cũ)" },
{ key: "receive", label: t("handover.tabReceive") || "Bên Nhận (Máy Mới)" },
];
return (
<Page className="page">
<Header
title={t("handover.header") || "Chuyển Giao Quyền Sở Hữu"}
showBackIcon
onBackClick={() => navigate(-1)}
/>
<IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12 font-nunito">
{/* Navigation Tabs */}
<div className="flex justify-center">
<Tabs
tabs={tabs}
activeTab={activeTab}
onChange={(tabKey) => setActiveTab(tabKey as TabMode)}
/>
</div>
{/* ═════════════════════════════════════════════════════════════════ */}
{/* TAB 1: SENDER (BÊN CHUYỂN - ZALO CŨ) */}
{/* ═════════════════════════════════════════════════════════════════ */}
{activeTab === "send" && (
<div className="flex flex-col gap-4">
{/* Step Indicator Card */}
<Card className="p-3 bg-clay-surface shadow-clay-raised">
<div className="grid grid-cols-4 gap-1 text-center">
{[
{ step: 1, label: t("handover.steps.send1") || "Khởi tạo" },
{ step: 2, label: t("handover.steps.send2") || "Quét & PIN" },
{ step: 3, label: t("handover.steps.send3") || "Duyệt OTP" },
{ step: 4, label: t("handover.steps.send4") || "Hoàn tất" },
].map(({ step, label }) => {
const isActive = senderCurrentStep === step;
const isPassed = senderCurrentStep > step;
return (
<div
key={step}
className={`flex flex-col items-center gap-1 transition-all ${
isActive
? "text-clay-primary font-bold"
: isPassed
? "text-clay-income font-medium"
: "text-clay-text-muted"
}`}
>
<div
className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-nunito font-extrabold shadow-clay-raised transition-all ${
isActive
? "bg-clay-primary text-white scale-110"
: isPassed
? "bg-clay-income text-white"
: "bg-clay-bg text-clay-text-muted"
}`}
>
{isPassed ? <CheckIcon size={14} /> : step}
</div>
<span className="text-[10px] sm:text-xs truncate w-full">{label}</span>
</div>
);
})}
</div>
</Card>
{/* ──────── STEP 1: CHƯA TẠO PHIÊN (IDLE) ──────── */}
{senderStatus === "IDLE" && (
<Card className="p-5 flex flex-col gap-4 bg-clay-surface shadow-clay-raised">
<div>
<h2 className="clay-title-h2 text-clay-primary text-lg">
{t("handover.warningTitle") || "Cảnh Báo Quan Trọng"}
</h2>
<p className="clay-caption text-xs mt-1">
{t("handover.warningSubtitle") || "Chuyển giao quyền sở hữu sổ cái tài chính"}
</p>
</div>
{/* Important Warning Notice */}
<div className="p-3.5 rounded-clay-sm bg-clay-warning/15 border border-clay-warning/30 flex items-start gap-2.5">
<div className="p-1.5 rounded-full bg-white/70 text-clay-warning mt-0.5 shadow-clay-raised shrink-0">
<AlertTriangleIcon size={18} />
</div>
<div className="flex flex-col gap-1 text-xs">
<span className="font-bold text-clay-text text-xs sm:text-sm">
{t("handover.warningNotice1") || "Lưu ý trước khi tiếp tục:"}
</span>
<ul className="space-y-1 text-clay-text/85 list-disc pl-4 mt-0.5 text-[11px] sm:text-xs">
<li>
{t("handover.warningNotice2") ||
"Toàn bộ ví tiền, lịch sử thu chi, ngân sách, mục tiêu sẽ được chuyển vĩnh viễn sang tài khoản Zalo mới."}
</li>
<li>
{t("handover.warningNotice3") ||
"Tài khoản hiện tại sẽ tự động đăng xuất và bị khóa an toàn."}
</li>
<li>
{t("handover.warningNotice4") ||
"Mã QR và mã PIN sẽ tự động hết hạn sau 15 phút."}
</li>
</ul>
</div>
</div>
{/* Security Badge Highlight */}
<div className="p-3.5 rounded-clay-sm bg-clay-bg/70 border border-clay-text/5 flex items-center gap-3">
<div className="p-2 rounded-clay-sm bg-clay-primary/15 text-clay-primary shadow-clay-raised shrink-0">
<ShieldIcon size={20} />
</div>
<div className="text-xs">
<span className="font-bold text-clay-text block">
{t("handover.securityTitle") || "Bảo mật mã hóa HMAC-SHA256 & OTP 2 chiều"}
</span>
<span className="text-[11px] text-clay-text-muted block mt-0.5">
{t("handover.securitySubtitle") ||
"Toàn bộ dữ liệu được di chuyển nguyên vẹn trong một giao dịch cơ sở dữ liệu an toàn."}
</span>
</div>
</div>
<Button
variant="primary"
fullWidth
onClick={handleInitiateHandover}
disabled={isInitiating}
className="mt-2"
>
{isInitiating
? t("common.processing") || "Đang xử lý..."
: t("handover.btnInitiate") || "Tạo Mã Chuyển Giao Quyền Sở Hữu"}
</Button>
</Card>
)}
{/* ──────── STEP 2: ĐANG CHỜ QUÉT MÃ & NHẬP PIN (PENDING_CLAIM) ──────── */}
{senderStatus === "PENDING_CLAIM" && initiateData && (
<Card className="p-5 flex flex-col gap-4 bg-clay-surface shadow-clay-raised">
<div className="flex items-center justify-between">
<div>
<h2 className="clay-title-h2 text-clay-primary text-lg">
{t("handover.senderStep2Title") || "Quét mã hoặc Nhập PIN"}
</h2>
<p className="clay-caption text-xs mt-0.5">
{t("handover.senderStep2Subtitle") ||
"Dùng máy mới mở Zalo để kết nối với phiên này"}
</p>
</div>
{/* Countdown Timer Badge */}
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-clay-sm bg-clay-bg shadow-clay-pressed border border-clay-text/10 shrink-0">
<ClockIcon size={16} className="text-clay-warning animate-pulse" />
<span className="font-baloo font-bold text-sm text-clay-text">
{formatTime(senderTimeLeft)}
</span>
</div>
</div>
{/* QR Code Presentation */}
<div className="flex flex-col items-center justify-center p-4 bg-clay-bg/50 rounded-clay border border-clay-text/10">
<span className="text-xs text-clay-text-muted mb-3 font-semibold text-center">
{t("handover.scanInstructions") ||
"Mở camera Zalo trên máy mới để quét mã này:"}
</span>
{initiateData.qrDataUrl ? (
<div className="p-3.5 bg-white rounded-clay border-2 border-clay-primary/20 shadow-clay-raised transition-all hover:scale-[1.01]">
<img
src={initiateData.qrDataUrl}
alt="QR Code Handover"
className="w-52 h-52 sm:w-56 sm:h-56 object-contain"
/>
</div>
) : (
<div className="w-52 h-52 flex items-center justify-center bg-clay-surface rounded-clay shadow-clay-pressed">
<RefreshIcon size={32} className="animate-spin text-clay-primary" />
</div>
)}
</div>
{/* 6-Digit PIN Display */}
<div className="flex flex-col items-center gap-2">
<span className="text-xs text-clay-text-muted font-semibold">
{t("handover.orEnterPin") || "Hoặc nhập thủ công mã PIN 6 số:"}
</span>
<div className="flex items-center justify-center gap-1.5 sm:gap-2">
{initiateData.pinCode.split("").map((digit, idx) => (
<div
key={idx}
className="w-9 h-11 sm:w-11 sm:h-14 flex items-center justify-center font-baloo text-xl sm:text-2xl font-bold bg-clay-surface text-clay-primary border border-clay-primary/25 rounded-clay-sm shadow-clay-raised select-all"
>
{digit}
</div>
))}
</div>
<button
type="button"
onClick={() => handleCopyPin(initiateData.pinCode)}
className="mt-1 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-clay-sm bg-clay-bg text-clay-primary hover:text-clay-primary-dark font-baloo font-bold text-xs shadow-clay-raised active:shadow-clay-pressed transition-all"
>
<CopyIcon size={14} />
<span>{t("handover.copyPin") || "Sao chép mã PIN"}</span>
</button>
</div>
{/* Cancel Handover Button */}
<Button
variant="secondary"
fullWidth
onClick={handleCancelHandover}
className="mt-2 text-clay-expense text-xs sm:text-sm font-semibold"
>
{t("handover.cancelTransfer") || "Hủy Phiên Chuyển Giao"}
</Button>
</Card>
)}
{/* ──────── STEP 3: MÁY B ĐÃ KẾT NỐI -> DUYỆT OTP (CLAIMED) ──────── */}
{senderStatus === "CLAIMED" && (
<Card className="p-5 flex flex-col gap-4 bg-clay-surface shadow-clay-raised">
<div>
<h2 className="clay-title-h2 text-clay-primary text-lg">
{t("handover.senderStep3Title") || "Xác Thực Chuyển Giao"}
</h2>
<p className="clay-caption text-xs mt-0.5">
{t("handover.senderStep3Subtitle") ||
"Ký duyệt lệnh bàn giao quyền sở hữu sổ cái"}
</p>
</div>
{/* Target Device Connected Notification */}
<div className="p-3.5 rounded-clay-sm bg-clay-primary/10 border border-clay-primary/25 flex items-center gap-3">
<div className="p-2.5 rounded-clay-sm bg-clay-primary/20 text-clay-primary shadow-clay-raised shrink-0">
<UserIcon size={24} />
</div>
<div>
<span className="text-xs text-clay-text-muted block">
{t("handover.targetConnected") || "Tài Khoản Mới Đã Kết Nối!"}
</span>
<span className="font-baloo font-bold text-sm sm:text-base text-clay-text block">
{targetUserName || "Người dùng Zalo mới"}
</span>
</div>
</div>
{/* Mutual OTP Notice */}
<div className="p-3.5 rounded-clay-sm bg-clay-bg/70 border border-clay-text/10 flex items-start gap-2.5">
<ShieldIcon size={18} className="text-clay-primary shrink-0 mt-0.5" />
<div className="text-xs text-clay-text leading-relaxed">
<span className="font-bold text-clay-primary block mb-0.5">
{t("handover.otpAuthNotice") || "Xác thực bảo mật 2 chiều:"}
</span>
<span>
{otpPrompt ||
"Vui lòng nhập mã OTP gồm 6 chữ số để ký duyệt lệnh chuyển giao quyền sở hữu."}
</span>
</div>
</div>
{/* Sandbox / Dev OTP Code Notice (if present) */}
{otpDevCode && (
<div className="p-2.5 rounded-clay-sm bg-clay-income/15 border border-clay-income/30 text-center">
<span className="text-xs text-clay-income font-bold">
{t("handover.otpDevLabel") || "Mã OTP xác nhận (Sandbox):"}{" "}
<span className="font-baloo text-base tracking-widest">{otpDevCode}</span>
</span>
</div>
)}
{/* OTP Input Field */}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-clay-text px-1">
{t("handover.otpInputLabel") || "Mã xác thực OTP (6 chữ số)"}
</label>
<Input
placeholder="123456"
maxLength={6}
value={otpInput}
onChange={(e) => setOtpInput(e.target.value.replace(/\D/g, ""))}
className="font-baloo text-center tracking-widest text-xl font-bold text-clay-primary"
/>
</div>
{/* Action Buttons */}
<div className="flex gap-2.5 mt-2">
<Button
variant="secondary"
className="w-1/3 text-xs"
disabled={isConfirming}
onClick={handleCancelHandover}
>
{t("handover.rejectTransfer") || "Từ chối & Hủy bỏ"}
</Button>
<Button
variant="primary"
className="flex-1 text-xs sm:text-sm"
disabled={isConfirming || otpInput.trim().length !== 6}
onClick={handleConfirmOtp}
>
{isConfirming
? t("common.processing") || "Đang xử lý..."
: t("handover.btnConfirmOtp") || "Ký Duyệt & Chuyển Dữ Liệu Ngay"}
</Button>
</div>
</Card>
)}
{/* ──────── STEP 4: HOÀN TẤT & ĐĂNG XUẤT (COMPLETED) ──────── */}
{senderStatus === "COMPLETED" && (
<Card className="p-5 flex flex-col gap-4 bg-clay-surface shadow-clay-raised">
<div className="flex flex-col items-center justify-center text-center gap-2 py-2">
<div className="w-14 h-14 rounded-full bg-clay-income/20 text-clay-income flex items-center justify-center shadow-clay-raised">
<CheckIcon size={28} />
</div>
<h3 className="font-baloo font-bold text-base text-clay-income">
{t("handover.completedTitle") || "Chuyển Giao Thành Công!"}
</h3>
<p className="clay-caption text-xs max-w-xs">
{t("handover.completedSubtitle") ||
"Toàn bộ dữ liệu của bạn đã được chuyển giao an toàn sang tài khoản mới. Phiên đăng nhập này đã được khóa."}
</p>
</div>
{/* Transferred Records Summary Grid */}
{senderCompletedRecords && (
<div className="p-3.5 rounded-clay bg-clay-bg shadow-clay-pressed flex flex-col gap-2 text-xs">
<span className="font-bold text-clay-text block border-b border-clay-text/10 pb-1">
{t("handover.transferredSummaryTitle") || "Dữ liệu đã chuyển giao thành công"}
</span>
<div className="grid grid-cols-2 gap-y-2 gap-x-4 text-[11px] pt-1">
<div className="flex justify-between items-center">
<span className="text-clay-text-muted flex items-center gap-1.5">
<WalletIcon size={14} className="text-clay-info" />{" "}
{t("handover.records.wallets") || "Ví tiền:"}
</span>
<span className="font-bold text-clay-primary font-baloo text-xs">
{senderCompletedRecords.wallets}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-clay-text-muted flex items-center gap-1.5">
<TransactionIcon size={14} className="text-clay-primary" />{" "}
{t("handover.records.transactions") || "Giao dịch:"}
</span>
<span className="font-bold text-clay-primary font-baloo text-xs">
{senderCompletedRecords.transactions}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-clay-text-muted flex items-center gap-1.5">
<TransferIcon size={14} className="text-clay-warning" />{" "}
{t("handover.records.transfers") || "Chuyển tiền:"}
</span>
<span className="font-bold text-clay-primary font-baloo text-xs">
{senderCompletedRecords.transfers}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-clay-text-muted flex items-center gap-1.5">
<BudgetIcon size={14} className="text-clay-expense" />{" "}
{t("handover.records.budgets") || "Ngân sách:"}
</span>
<span className="font-bold text-clay-primary font-baloo text-xs">
{senderCompletedRecords.budgets}
</span>
</div>
</div>
</div>
)}
<Button
variant="primary"
fullWidth
onClick={() => navigate("/auth/login")}
className="mt-2"
>
{t("handover.backToLogin") || "Đã Hiểu & Về Màn Hình Đăng Nhập"}
</Button>
</Card>
)}
{/* ──────── STEP 1B: HẾT HẠN (EXPIRED) ──────── */}
{senderStatus === "EXPIRED" && (
<Card className="p-5 flex flex-col items-center text-center gap-3 bg-clay-surface shadow-clay-raised">
<div className="w-14 h-14 rounded-full bg-clay-expense/15 text-clay-expense flex items-center justify-center shadow-clay-raised">
<AlertTriangleIcon size={28} />
</div>
<h3 className="clay-title-h3 text-clay-text">
{t("handover.expiredTitle") || "Mã Chuyển Giao Đã Hết Hạn"}
</h3>
<p className="clay-caption text-xs max-w-xs">
{t("handover.expiredSubtitle") ||
"Thời gian hiệu lực 15 phút đã kết thúc. Vui lòng tạo mã mới để tiếp tục."}
</p>
<Button variant="primary" fullWidth onClick={handleInitiateHandover} className="mt-2">
{t("handover.btnReInitiate") || "Tạo Mã Mới"}
</Button>
</Card>
)}
</div>
)}
{/* ═════════════════════════════════════════════════════════════════ */}
{/* TAB 2: RECEIVER (BÊN NHẬN - ZALO MỚI) */}
{/* ═════════════════════════════════════════════════════════════════ */}
{activeTab === "receive" && (
<div className="flex flex-col gap-4">
{/* Step Indicator Card */}
<Card className="p-3 bg-clay-surface shadow-clay-raised">
<div className="grid grid-cols-3 gap-1 text-center">
{[
{ step: 1, label: t("handover.steps.receive1") || "Kết nối" },
{ step: 2, label: t("handover.steps.receive2") || "Chờ duyệt OTP" },
{ step: 3, label: t("handover.steps.receive3") || "Tiếp nhận" },
].map(({ step, label }) => {
const isActive = receiverCurrentStep === step;
const isPassed = receiverCurrentStep > step;
return (
<div
key={step}
className={`flex flex-col items-center gap-1 transition-all ${
isActive
? "text-clay-primary font-bold"
: isPassed
? "text-clay-income font-medium"
: "text-clay-text-muted"
}`}
>
<div
className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-nunito font-extrabold shadow-clay-raised transition-all ${
isActive
? "bg-clay-primary text-white scale-110"
: isPassed
? "bg-clay-income text-white"
: "bg-clay-bg text-clay-text-muted"
}`}
>
{isPassed ? <CheckIcon size={14} /> : step}
</div>
<span className="text-[10px] sm:text-xs truncate w-full">{label}</span>
</div>
);
})}
</div>
</Card>
{/* ──────── STEP 1: CHỌN PHƯƠNG THỨC KẾT NỐI (IDLE) ──────── */}
{receiverStatus === "IDLE" && (
<Card className="p-5 flex flex-col gap-4 bg-clay-surface shadow-clay-raised">
<div>
<h2 className="clay-title-h2 text-clay-primary text-lg">
{t("handover.receiverStep1Title") || "Tiếp Nhận Dữ Liệu"}
</h2>
<p className="clay-caption text-xs mt-1">
{t("handover.receiverStep1Subtitle") ||
"Chọn phương thức kết nối với tài khoản cũ của bạn"}
</p>
</div>
{/* Option 1: Camera QR Scan */}
<div className="p-4 rounded-clay-sm bg-clay-bg/70 border border-clay-text/10 flex flex-col gap-3">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-clay-sm bg-clay-primary/15 text-clay-primary shadow-clay-raised shrink-0">
<CameraIcon size={22} />
</div>
<div>
<h4 className="font-baloo font-bold text-sm text-clay-text">
{t("handover.scanQrOption") || "Quét mã QR từ máy cũ"}
</h4>
<p className="text-xs text-clay-text-muted">
{t("handover.scanQrDesc") ||
"Mở camera Zalo quét trực tiếp mã QR trên màn hình máy A"}
</p>
</div>
</div>
<Button
variant="primary"
fullWidth
onClick={handleScanQRCode}
disabled={isClaiming}
className="text-xs sm:text-sm"
>
{t("handover.btnScanQr") || "Quét QR"}
</Button>
</div>
{/* Visual Divider */}
<div className="flex items-center gap-3 my-0.5">
<div className="flex-1 h-[1px] bg-clay-text/10" />
<span className="text-[11px] text-clay-text-muted font-bold uppercase tracking-wider">
{t("common.or") || "Hoặc"}
</span>
<div className="flex-1 h-[1px] bg-clay-text/10" />
</div>
{/* Option 2: Enter 6-digit PIN manually */}
<div className="p-4 rounded-clay-sm bg-clay-bg/70 border border-clay-text/10 flex flex-col gap-3">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-clay-sm bg-clay-warning/15 text-clay-warning shadow-clay-raised shrink-0">
<LockIcon size={22} />
</div>
<div>
<h4 className="font-baloo font-bold text-sm text-clay-text">
{t("handover.enterPinOption") || "Nhập mã PIN 6 số thủ công"}
</h4>
<p className="text-xs text-clay-text-muted">
{t("handover.enterPinDesc") ||
"Nhập chuỗi 6 số hiển thị trên màn hình máy cũ"}
</p>
</div>
</div>
<Input
placeholder="123456"
maxLength={6}
value={receiverPinInput}
onChange={(e) => setReceiverPinInput(e.target.value.replace(/\D/g, ""))}
className="font-baloo text-center tracking-widest text-lg font-bold"
/>
<Button
variant="secondary"
fullWidth
onClick={handleClaimByPin}
disabled={isClaiming || receiverPinInput.trim().length !== 6}
className="text-xs sm:text-sm font-bold"
>
{isClaiming
? t("common.processing") || "Đang kết nối..."
: t("handover.btnConnectPin") || "Kết Nối Với Tài Khoản Cũ"}
</Button>
</div>
</Card>
)}
{/* ──────── STEP 2: ĐÃ KẾT NỐI -> CHỜ DUYỆT OTP (CLAIMED) ──────── */}
{receiverStatus === "CLAIMED" && (
<Card className="p-5 flex flex-col gap-4 bg-clay-surface shadow-clay-raised">
<div>
<h2 className="clay-title-h2 text-clay-primary text-lg">
{t("handover.receiverStep2Title") || "Đang Chờ Xác Thực"}
</h2>
<p className="clay-caption text-xs mt-0.5">
{t("handover.receiverStep2Subtitle") ||
"Đã kết nối thành công với tài khoản nguồn"}
</p>
</div>
{/* Waiting Pulsing State */}
<div className="flex flex-col items-center justify-center text-center gap-3 py-4">
<div className="w-14 h-14 rounded-full bg-clay-primary/20 text-clay-primary flex items-center justify-center shadow-clay-raised">
<RefreshIcon size={28} className="animate-spin" />
</div>
<div>
<h3 className="font-baloo font-bold text-base text-clay-text">
{t("handover.connectedWaitingTitle") || "Đã Kết Nối Thành Công!"}
</h3>
<p className="text-xs text-clay-text-muted mt-1">
{t("handover.connectedWith") || "Đang liên kết với:"}{" "}
<span className="font-bold text-clay-text">{connectedSourceName}</span>
</p>
</div>
</div>
<div className="p-3.5 rounded-clay-sm bg-clay-info/10 border border-clay-info/25 flex items-start gap-2.5">
<ShieldIcon size={18} className="text-clay-info shrink-0 mt-0.5" />
<p className="text-xs text-clay-text leading-relaxed">
{t("handover.waitingOtpApproval") ||
"Vui lòng chờ người dùng trên tài khoản cũ nhập mã OTP xác thực..."}
</p>
</div>
<div className="p-3 bg-clay-bg rounded-clay-sm text-[11px] text-clay-text-muted text-center">
{t("handover.autoListeningNotice") ||
"Hệ thống đang tự động lắng nghe và sẽ cập nhật dữ liệu ngay khi hoàn tất."}
</div>
</Card>
)}
{/* ──────── STEP 3: TIẾP NHẬN DỮ LIỆU THÀNH CÔNG (COMPLETED) ──────── */}
{receiverStatus === "COMPLETED" && (
<Card className="p-5 flex flex-col gap-4 bg-clay-surface shadow-clay-raised">
<div className="flex flex-col items-center justify-center text-center gap-2 py-2">
<div className="w-14 h-14 rounded-full bg-clay-income/20 text-clay-income flex items-center justify-center shadow-clay-raised">
<CheckIcon size={28} />
</div>
<h3 className="font-baloo font-bold text-base text-clay-income">
{t("handover.receiverSuccessTitle") || "Đã Tiếp Nhận Dữ Liệu Thành Công!"}
</h3>
<p className="clay-caption text-xs max-w-xs">
{t("handover.receiverSuccessSubtitle") ||
"Toàn bộ dữ liệu tài chính từ tài khoản cũ đã được chuyển giao nguyên vẹn vào tài khoản Zalo của bạn."}
</p>
</div>
{/* Receiver Summary Grid */}
{receiverSummary && (
<div className="p-3.5 rounded-clay bg-clay-bg shadow-clay-pressed flex flex-col gap-2 text-xs">
<span className="font-bold text-clay-text block border-b border-clay-text/10 pb-1">
{t("handover.receivedSummaryTitle") || "Thống kê dữ liệu đã tiếp nhận"}
</span>
<div className="grid grid-cols-2 gap-y-2 gap-x-4 text-[11px] pt-1">
<div className="flex justify-between items-center">
<span className="text-clay-text-muted flex items-center gap-1.5">
<WalletIcon size={14} className="text-clay-info" />{" "}
{t("handover.records.wallets") || "Ví tiền:"}
</span>
<span className="font-bold text-clay-primary font-baloo text-xs">
{receiverSummary.wallets}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-clay-text-muted flex items-center gap-1.5">
<TransactionIcon size={14} className="text-clay-primary" />{" "}
{t("handover.records.categories") || "Danh mục:"}
</span>
<span className="font-bold text-clay-primary font-baloo text-xs">
{receiverSummary.categories}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-clay-text-muted flex items-center gap-1.5">
<TransactionIcon size={14} className="text-clay-primary" />{" "}
{t("handover.records.transactions") || "Giao dịch:"}
</span>
<span className="font-bold text-clay-primary font-baloo text-xs">
{receiverSummary.transactions}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-clay-text-muted flex items-center gap-1.5">
<TransferIcon size={14} className="text-clay-warning" />{" "}
{t("handover.records.transfers") || "Chuyển tiền:"}
</span>
<span className="font-bold text-clay-primary font-baloo text-xs">
{receiverSummary.transfers}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-clay-text-muted flex items-center gap-1.5">
<BudgetIcon size={14} className="text-clay-expense" />{" "}
{t("handover.records.budgets") || "Ngân sách:"}
</span>
<span className="font-bold text-clay-primary font-baloo text-xs">
{receiverSummary.budgets}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-clay-text-muted flex items-center gap-1.5">
<SavingGoalIcon size={14} className="text-clay-income" />{" "}
{t("handover.records.savingGoals") || "Mục tiêu:"}
</span>
<span className="font-bold text-clay-primary font-baloo text-xs">
{receiverSummary.savingGoals}
</span>
</div>
</div>
</div>
)}
<div className="flex gap-2.5 mt-2">
<Button
variant="secondary"
fullWidth
onClick={() => navigate("/transactions")}
>
{t("handover.viewTransactions") || "Xem Giao Dịch"}
</Button>
<Button
variant="primary"
fullWidth
onClick={() => {
queryClient.invalidateQueries();
navigate("/");
}}
>
{t("handover.goToDashboard") || "Về Bảng Điều Khiển"}
</Button>
</div>
</Card>
)}
</div>
)}
</main>
</Page>
);
};
export default AccountHandoverPage;
......@@ -122,11 +122,11 @@ export const AdminAiPage: React.FC = () => {
};
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Page className="page">
<Header title={t('admin.ai.header')} showBackIcon onBackClick={() => navigate('/admin')} />
<IconGradients />
<main className="mx-auto w-full max-w-5xl space-y-6 px-4 pt-6">
<main className="mx-auto mt-4 flex w-full max-w-5xl flex-col gap-6 pb-12">
{/* Banner */}
<Card className="overflow-hidden bg-clay-primary/10">
<div className="flex items-center gap-4">
......
......@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Modal } from '@/components/ui/Modal';
import { Select } from '@/components/ui/Select';
import { IconGradients } from '@/components/ui/icons';
import { LocalizedDateInput } from '@/components/shared/LocalizedDateInput';
import { addCalendarDays, businessDateStartIso } from '@/lib/business-time';
import { useAdminAuditLogs, useArchiveCleanupAuditLogs } from '@/hooks/use-admin';
......@@ -89,9 +90,10 @@ const AdminAuditLogsPage: React.FC = () => {
};
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Page className="page">
<Header title={t('admin.audit.title')} showBackIcon onBackClick={() => navigate('/admin')} />
<main className="mx-auto w-full max-w-5xl space-y-5 px-4 pt-6">
<IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-5xl flex-col gap-5 pb-12">
{/* Retention Policy Banner */}
<Card className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border border-clay-info/30 bg-clay-info/10 p-4 shadow-clay-raised">
<div className="flex items-start gap-3">
......
......@@ -39,10 +39,10 @@ const AdminDashboardPage: React.FC = () => {
] : [];
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Page className="page">
<Header title={t('admin.dashboard.title')} showBackIcon onBackClick={() => navigate('/')} />
<IconGradients />
<main className="mx-auto w-full max-w-5xl space-y-6 px-4 pt-6">
<main className="mx-auto mt-4 flex w-full max-w-5xl flex-col gap-6 pb-12">
<Card className="overflow-hidden bg-clay-primary/10">
<div className="flex items-center gap-4">
<div className="rounded-clay bg-clay-primary p-3 text-clay-on-primary shadow-clay-raised">
......
......@@ -175,11 +175,11 @@ export const AdminNotificationsPage: React.FC = () => {
};
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Page className="page">
<Header title={t('admin.notifications.header')} showBackIcon onBackClick={() => navigate('/admin')} />
<IconGradients />
<main className="mx-auto w-full max-w-5xl space-y-6 px-4 pt-6">
<main className="mx-auto mt-4 flex w-full max-w-5xl flex-col gap-6 pb-12">
{/* Banner */}
<Card className="overflow-hidden bg-clay-primary/10">
<div className="flex items-center gap-4">
......
......@@ -272,7 +272,7 @@ const RolesPage: React.FC = () => {
/>
<IconGradients />
<div className="flex flex-col gap-5 mt-4 pb-20">
<main className="mx-auto mt-4 flex w-full max-w-5xl flex-col gap-5 pb-12">
{/* Banner Header Card */}
<Card className="flex items-center gap-4 py-4 bg-clay-surface border border-clay-highlight/60 shadow-clay-raised">
<div className="p-3 bg-clay-primary/10 rounded-full text-clay-primary flex-none">
......@@ -424,7 +424,7 @@ const RolesPage: React.FC = () => {
</div>
)}
</div>
</div>
</main>
{/* ======================================================== */}
{/* MODAL: PERMISSION MATRIX EDITOR */}
......
......@@ -189,11 +189,11 @@ export const AdminSettingsPage: React.FC = () => {
};
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Page className="page">
<Header title={t('admin.settings.header')} showBackIcon onBackClick={() => navigate('/admin')} />
<IconGradients />
<main className="mx-auto w-full max-w-5xl space-y-6 px-4 pt-6">
<main className="mx-auto mt-4 flex w-full max-w-5xl flex-col gap-6 pb-12">
{/* Banner */}
<Card className="overflow-hidden bg-clay-primary/10">
<div className="flex items-center gap-4">
......
......@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Select } from '@/components/ui/Select';
import { PermissionGate } from '@/components/shared/PermissionGate';
import { IconGradients } from '@/components/ui/icons';
import { PERMISSIONS } from '@/common/constants';
import {
useAdminRoles,
......@@ -62,9 +63,10 @@ const AdminUserDetailPage: React.FC = () => {
};
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Page className="page">
<Header title={t('admin.userDetail.title')} showBackIcon onBackClick={() => navigate('/admin/users')} />
<main className="mx-auto w-full max-w-4xl space-y-5 px-4 pt-6">
<IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-4xl flex-col gap-5 pb-12">
{userQuery.isLoading ? (
<AdminSkeleton rows={3} />
) : userQuery.isError || !user ? (
......
......@@ -11,6 +11,7 @@ import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { Select } from '@/components/ui/Select';
import { PermissionGate } from '@/components/shared/PermissionGate';
import { IconGradients } from '@/components/ui/icons';
import { PERMISSIONS } from '@/common/constants';
import {
useAdminRoles,
......@@ -123,9 +124,10 @@ const AdminUsersPage: React.FC = () => {
};
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Page className="page">
<Header title={t('admin.users.title')} showBackIcon onBackClick={() => navigate('/admin')} />
<main className="mx-auto w-full max-w-5xl space-y-5 px-4 pt-6">
<IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-5xl flex-col gap-5 pb-12">
<Card>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
......
import React, { useState, useMemo } from "react";
import { Page, Header, useNavigate, useSnackbar } from "zmp-ui";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { Badge } from "@/components/ui/Badge";
import { ProgressBar } from "@/components/ui/ProgressBar";
import { Tabs } from "@/components/ui/Tabs";
import {
CheckIcon,
EyeIcon,
EyeOffIcon,
IconGradients,
ShieldIcon,
AlertTriangleIcon,
ClockIcon,
ReportIcon,
} from "@/components/ui/icons";
import { useI18n } from "@/i18n";
import { backupService } from "@/services/backup.service";
import {
BackupImportResult,
BackupPreviewResponse,
BalanceResolutionMode,
WalletResolution,
} from "@/types/backup";
import { getErrorMessage } from "@/lib/error-message";
type TabType = "import" | "export";
type StepType = 1 | 2 | 3 | 4;
export const DataManagementPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const queryClient = useQueryClient();
const { t, formatDate } = useI18n();
const [activeTab, setActiveTab] = useState<TabType>("import");
const [currentStep, setCurrentStep] = useState<StepType>(1);
// ── Step 1 State (File & Decryption Password) ──────────────────────────
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isFileEncrypted, setIsFileEncrypted] = useState(false);
const [decryptPassword, setDecryptPassword] = useState("");
const [showDecryptPassword, setShowDecryptPassword] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
// ── Step 2 State (Preview & Analysis) ──────────────────────────────────
const [previewData, setPreviewData] = useState<BackupPreviewResponse | null>(null);
// ── Step 3 State (Conflict Resolution) ─────────────────────────────────
const [walletResolutions, setWalletResolutions] = useState<Record<string, WalletResolution>>({});
const [balanceMode, setBalanceMode] = useState<BalanceResolutionMode>("ACCUMULATE");
// ── Step 4 State (Execution & Progress) ────────────────────────────────
const [isImporting, setIsImporting] = useState(false);
const [progressPercent, setProgressPercent] = useState(0);
const [progressStageText, setProgressStageText] = useState("");
const [importResult, setImportResult] = useState<BackupImportResult | null>(null);
// ── Export Tab State ───────────────────────────────────────────────────
const [exportPassword, setExportPassword] = useState("");
const [showExportPassword, setShowExportPassword] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const tabs = [
{ key: "import", label: t("dataManagement.tabs.import") },
{ key: "export", label: t("dataManagement.tabs.export") },
];
// ── Handle File Selection ──────────────────────────────────────────────
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setSelectedFile(file);
setPreviewData(null);
setImportResult(null);
// Đọc nhanh header tệp JSON để kiểm tra cờ isEncrypted
try {
const text = await file.text();
const parsed = JSON.parse(text);
if (parsed?.isEncrypted === true) {
setIsFileEncrypted(true);
} else {
setIsFileEncrypted(false);
}
} catch {
setIsFileEncrypted(false);
}
};
// ── Handle Step 1: Analyze File ────────────────────────────────────────
const handleAnalyzeFile = async () => {
if (!selectedFile) {
openSnackbar({ type: "error", text: t("dataManagement.step1.dragDrop") });
return;
}
if (isFileEncrypted && !decryptPassword) {
openSnackbar({ type: "error", text: t("dataManagement.step1.encryptedNotice") });
return;
}
setIsAnalyzing(true);
try {
const preview = await backupService.previewBackup(
selectedFile,
isFileEncrypted ? decryptPassword : undefined,
);
setPreviewData(preview);
// Khởi tạo thiết lập phân giải ví mặc định
const initialResolutions: Record<string, WalletResolution> = {};
for (const w of preview.wallets) {
if (w.matchedExistingWalletId) {
// Gợi ý gộp vào ví hiện tại nếu trùng tên
initialResolutions[w.id] = {
oldWalletId: w.id,
action: "MERGE",
targetWalletId: w.matchedExistingWalletId,
};
} else {
// Tạo ví mới nếu chưa có
initialResolutions[w.id] = {
oldWalletId: w.id,
action: "CREATE_NEW",
newWalletName: w.name,
};
}
}
setWalletResolutions(initialResolutions);
setCurrentStep(2);
openSnackbar({ type: "success", text: t("dataManagement.step2.checksum") });
} catch (error) {
openSnackbar({
type: "error",
text: getErrorMessage(error, "Lỗi phân tích file sao lưu."),
});
} finally {
setIsAnalyzing(false);
}
};
// ── Handle Step 4: Execute Import ──────────────────────────────────────
const handleStartImport = async () => {
if (!selectedFile || !previewData) return;
setIsImporting(true);
setProgressPercent(15);
setProgressStageText(t("dataManagement.step4.progressSteps.validating"));
try {
setTimeout(() => {
setProgressPercent(40);
setProgressStageText(t("dataManagement.step4.progressSteps.mapping"));
}, 400);
setTimeout(() => {
setProgressPercent(70);
setProgressStageText(t("dataManagement.step4.progressSteps.ledger"));
}, 800);
const resolutionsList = Object.values(walletResolutions);
const result = await backupService.importBackup(selectedFile, {
walletResolutions: resolutionsList,
balanceMode,
password: isFileEncrypted ? decryptPassword : undefined,
});
setProgressPercent(100);
setProgressStageText(t("dataManagement.step4.progressSteps.completing"));
setImportResult(result);
// Tự động làm mới toàn bộ TanStack Query cache để Dashboard cập nhật
await queryClient.invalidateQueries();
openSnackbar({
type: "success",
text: t("dataManagement.step4.successTitle"),
});
} catch (error) {
openSnackbar({
type: "error",
text: getErrorMessage(error, "Nạp dữ liệu thất bại."),
});
setProgressPercent(0);
} finally {
setIsImporting(false);
}
};
// ── Handle Export ──────────────────────────────────────────────────────
const handleExport = async () => {
setIsExporting(true);
try {
await backupService.exportBackup(
exportPassword.trim().length >= 6
? { password: exportPassword.trim() }
: undefined,
);
openSnackbar({
type: "success",
text: t("dataManagement.export.exportSuccess"),
});
setExportPassword("");
} catch (error) {
openSnackbar({
type: "error",
text: getErrorMessage(error, t("dataManagement.export.exportFailed")),
});
} finally {
setIsExporting(false);
}
};
return (
<Page className="page">
<Header title={t("dataManagement.header")} showBackIcon onBackClick={() => navigate(-1)} />
<IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12">
{/* Real-time Handover Shortcut Banner */}
<Card
className="flex items-center justify-between p-3.5 bg-clay-surface border border-clay-primary/20 shadow-clay-card cursor-pointer hover:shadow-clay-hover transition-all active:scale-[0.99]"
onClick={() => navigate("/account-handover?mode=send")}
>
<div className="flex items-center gap-3">
<div className="p-2 rounded-clay-sm bg-clay-primary/15 text-clay-primary shadow-clay-raised">
<ShieldIcon size={20} />
</div>
<div>
<span className="font-baloo font-bold text-xs sm:text-sm text-clay-primary block">
{t("handover.bannerTitle") || "Đổi Zalo mới? Chuyển giao qua quét mã QR"}
</span>
<span className="text-[11px] text-clay-text-muted block">
{t("handover.bannerSubtitle") || "Xác thực OTP 2 chiều tức thời, không cần xuất/nhập file thủ công"}
</span>
</div>
</div>
<span className="text-xs font-bold text-clay-primary underline whitespace-nowrap ml-2">
{t("handover.btnTryNow") || "Thử ngay →"}
</span>
</Card>
{/* Navigation Tabs */}
<div className="flex justify-center">
<Tabs
tabs={tabs}
activeTab={activeTab}
onChange={(tabKey) => setActiveTab(tabKey as TabType)}
/>
</div>
{/* ════════════════════ TAB 1: IMPORT & MERGE WIZARD ════════════════════ */}
{activeTab === "import" && (
<div className="flex flex-col gap-4">
{/* Step Indicator */}
<Card className="p-3 bg-clay-surface shadow-clay-raised">
<div className="grid grid-cols-4 gap-1 text-center">
{[1, 2, 3, 4].map((step) => {
const isActive = currentStep === step;
const isPassed = currentStep > step;
return (
<div
key={step}
className={`flex flex-col items-center gap-1 cursor-pointer transition-all ${
isActive
? "text-clay-primary font-bold"
: isPassed
? "text-clay-income font-medium"
: "text-clay-text-muted"
}`}
onClick={() => {
if (isPassed && !isImporting) setCurrentStep(step as StepType);
}}
>
<div
className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-nunito font-extrabold shadow-clay-raised transition-all ${
isActive
? "bg-clay-primary text-white scale-110"
: isPassed
? "bg-clay-income text-white"
: "bg-clay-bg text-clay-text-muted"
}`}
>
{isPassed ? <CheckIcon size={14} /> : step}
</div>
<span className="text-[10px] sm:text-xs truncate w-full">
{step === 1 && t("dataManagement.steps.step1")}
{step === 2 && t("dataManagement.steps.step2")}
{step === 3 && t("dataManagement.steps.step3")}
{step === 4 && t("dataManagement.steps.step4")}
</span>
</div>
);
})}
</div>
</Card>
{/* ──────── STEP 1: CHỌN FILE & MẬT KHẨU ──────── */}
{currentStep === 1 && (
<Card className="p-5 flex flex-col gap-4 bg-clay-surface shadow-clay-raised">
<div>
<h2 className="clay-title-h2 text-clay-primary text-lg">
{t("dataManagement.step1.title")}
</h2>
<p className="clay-caption text-xs mt-1">
{t("dataManagement.step1.subtitle")}
</p>
</div>
{/* File Dropzone / Picker */}
<label className="border-2 border-dashed border-clay-primary/30 rounded-clay p-6 flex flex-col items-center justify-center gap-2.5 bg-clay-bg/50 hover:bg-clay-bg cursor-pointer transition-all active:scale-[0.99]">
<input
type="file"
accept=".json,application/json"
onChange={handleFileChange}
className="hidden"
/>
<div className="w-12 h-12 rounded-full bg-clay-primary/15 text-clay-primary flex items-center justify-center shadow-clay-raised">
<ReportIcon size={24} />
</div>
<div className="text-center">
{selectedFile ? (
<div>
<span className="font-bold text-sm text-clay-text block">
{selectedFile.name}
</span>
<span className="text-xs text-clay-text-muted">
{(selectedFile.size / 1024).toFixed(1)} KB • Bấm để đổi file khác
</span>
</div>
) : (
<div>
<span className="font-bold text-sm text-clay-primary block">
{t("dataManagement.step1.dragDrop")}
</span>
<span className="text-xs text-clay-text-muted">
Hỗ trợ file FinWise JSON sao lưu
</span>
</div>
)}
</div>
</label>
{/* Password input if file is encrypted */}
{isFileEncrypted && (
<div className="p-3.5 rounded-clay-sm bg-clay-warning/15 border border-clay-warning/30 flex flex-col gap-2">
<div className="flex items-center gap-2 text-clay-text font-bold text-xs">
<ShieldIcon size={16} className="text-clay-warning" />
<span>{t("dataManagement.step1.encryptedNotice")}</span>
</div>
<div className="relative">
<Input
type={showDecryptPassword ? "text" : "password"}
value={decryptPassword}
onChange={(e) => setDecryptPassword(e.target.value)}
placeholder={t("dataManagement.step1.passwordPlaceholder")}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowDecryptPassword(!showDecryptPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-clay-text-muted hover:text-clay-text"
>
{showDecryptPassword ? <EyeOffIcon size={18} /> : <EyeIcon size={18} />}
</button>
</div>
</div>
)}
<Button
variant="primary"
fullWidth
disabled={!selectedFile || isAnalyzing}
onClick={handleAnalyzeFile}
className="mt-2"
>
{isAnalyzing
? t("dataManagement.step1.analyzing")
: t("dataManagement.step1.analyzeButton")}
</Button>
</Card>
)}
{/* ──────── STEP 2: PHÂN TÍCH & XEM TRƯỚC (PREVIEW) ──────── */}
{currentStep === 2 && previewData && (
<Card className="p-5 flex flex-col gap-4 bg-clay-surface shadow-clay-raised">
<div>
<h2 className="clay-title-h2 text-clay-primary text-lg">
{t("dataManagement.step2.title")}
</h2>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="text-xs text-clay-text-muted font-baloo">
{t("dataManagement.step2.fileVersion", { version: previewData.fileVersion })}
</span>
<span className="text-xs text-clay-text-muted">•</span>
<span className="text-xs text-clay-text-muted font-baloo">
{t("dataManagement.step2.exportedAt", {
date: formatDate(previewData.exportedAt, {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}),
})}
</span>
</div>
</div>
{/* Account Status Badge */}
<div
className={`p-3.5 rounded-clay-sm border flex items-start gap-2.5 ${
previewData.accountStatus === "EMPTY"
? "bg-clay-income/15 border-clay-income/30 text-clay-income"
: "bg-clay-warning/15 border-clay-warning/30 text-clay-warning"
}`}
>
<div className="p-1 rounded-full bg-white/60 mt-0.5">
{previewData.accountStatus === "EMPTY" ? (
<CheckIcon size={16} />
) : (
<AlertTriangleIcon size={16} />
)}
</div>
<div>
<span className="font-bold text-xs sm:text-sm block text-clay-text">
{previewData.accountStatus === "EMPTY"
? t("dataManagement.step2.statusEmpty")
: t("dataManagement.step2.statusHasData", {
wallets: previewData.existingWalletsCount,
txs: previewData.existingTransactionsCount,
})}
</span>
<p className="text-[11px] text-clay-text-muted mt-0.5">
{previewData.accountStatus === "EMPTY"
? t("dataManagement.step2.statusEmptyDesc")
: t("dataManagement.step2.statusHasDataDesc")}
</p>
</div>
</div>
{/* Entity Summary Grid */}
<div>
<span className="text-xs font-bold text-clay-text-muted uppercase tracking-wider block mb-2">
{t("dataManagement.step2.summaryTitle")}
</span>
<div className="grid grid-cols-3 gap-2 text-center">
<div className="p-2.5 rounded-clay-sm bg-clay-bg shadow-clay-pressed flex flex-col items-center">
<span className="text-base font-bold text-clay-primary font-baloo">
{previewData.summary.walletsCount}
</span>
<span className="text-[11px] text-clay-text-muted">
{t("dataManagement.step2.wallets")}
</span>
</div>
<div className="p-2.5 rounded-clay-sm bg-clay-bg shadow-clay-pressed flex flex-col items-center">
<span className="text-base font-bold text-clay-primary font-baloo">
{previewData.summary.categoriesCount}
</span>
<span className="text-[11px] text-clay-text-muted">
{t("dataManagement.step2.categories")}
</span>
</div>
<div className="p-2.5 rounded-clay-sm bg-clay-bg shadow-clay-pressed flex flex-col items-center">
<span className="text-base font-bold text-clay-primary font-baloo">
{previewData.summary.transactionsCount}
</span>
<span className="text-[11px] text-clay-text-muted">
{t("dataManagement.step2.transactions")}
</span>
</div>
<div className="p-2.5 rounded-clay-sm bg-clay-bg shadow-clay-pressed flex flex-col items-center">
<span className="text-base font-bold text-clay-primary font-baloo">
{previewData.summary.transfersCount}
</span>
<span className="text-[11px] text-clay-text-muted">
{t("dataManagement.step2.transfers")}
</span>
</div>
<div className="p-2.5 rounded-clay-sm bg-clay-bg shadow-clay-pressed flex flex-col items-center">
<span className="text-base font-bold text-clay-primary font-baloo">
{previewData.summary.budgetsCount}
</span>
<span className="text-[11px] text-clay-text-muted">
{t("dataManagement.step2.budgets")}
</span>
</div>
<div className="p-2.5 rounded-clay-sm bg-clay-bg shadow-clay-pressed flex flex-col items-center">
<span className="text-base font-bold text-clay-primary font-baloo">
{previewData.summary.savingGoalsCount}
</span>
<span className="text-[11px] text-clay-text-muted">
{t("dataManagement.step2.savingGoals")}
</span>
</div>
</div>
</div>
{/* Duplicates Notice */}
{previewData.potentialDuplicatesCount > 0 && (
<div className="p-2.5 rounded-clay-sm bg-clay-info/10 border border-clay-info/30 flex items-center gap-2 text-xs text-clay-text">
<ClockIcon size={16} className="text-clay-info shrink-0" />
<span>
{t("dataManagement.step2.duplicatesNotice", {
count: previewData.potentialDuplicatesCount,
})}
</span>
</div>
)}
<div className="flex gap-2.5 mt-2">
<Button
variant="secondary"
className="w-1/3"
onClick={() => setCurrentStep(1)}
>
Quay lại
</Button>
<Button
variant="primary"
className="flex-1"
onClick={() => setCurrentStep(3)}
>
{t("dataManagement.step2.continueButton")}
</Button>
</div>
</Card>
)}
{/* ──────── STEP 3: XỬ LÝ XUNG ĐỘT (CONFLICT RESOLUTION) ──────── */}
{currentStep === 3 && previewData && (
<Card className="p-5 flex flex-col gap-4 bg-clay-surface shadow-clay-raised">
<div>
<h2 className="clay-title-h2 text-clay-primary text-lg">
{t("dataManagement.step3.title")}
</h2>
</div>
{/* 1. Xung đột Ví tiền */}
<div className="space-y-3">
<div className="border-b border-clay-text/10 pb-1.5">
<h3 className="font-baloo font-bold text-sm text-clay-text">
{t("dataManagement.step3.walletSection")}
</h3>
<p className="text-xs text-clay-text-muted">
{t("dataManagement.step3.walletSectionDesc")}
</p>
</div>
{previewData.wallets.map((w) => {
const res = walletResolutions[w.id] || {
oldWalletId: w.id,
action: "CREATE_NEW",
newWalletName: w.name,
};
return (
<div
key={w.id}
className="p-3 rounded-clay-sm bg-clay-bg/70 border border-clay-text/5 flex flex-col gap-2.5"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-bold text-sm text-clay-text">{w.name}</span>
<Badge type="info">
{parseFloat(w.balance).toLocaleString("vi-VN")} {w.currency}
</Badge>
</div>
</div>
{/* Options: Gộp vs Tạo mới */}
<div className="flex flex-col gap-2 text-xs">
{previewData.existingWallets.length > 0 && (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name={`wallet-action-${w.id}`}
checked={res.action === "MERGE"}
onChange={() => {
setWalletResolutions((prev) => ({
...prev,
[w.id]: {
oldWalletId: w.id,
action: "MERGE",
targetWalletId:
res.targetWalletId ||
w.matchedExistingWalletId ||
previewData.existingWallets[0].id,
},
}));
}}
className="accent-clay-primary"
/>
<span className="font-semibold text-clay-text">
{t("dataManagement.step3.mergeInto")}
</span>
</label>
)}
{res.action === "MERGE" && (
<select
value={res.targetWalletId}
onChange={(e) => {
const targetId = e.target.value;
setWalletResolutions((prev) => ({
...prev,
[w.id]: {
...prev[w.id],
targetWalletId: targetId,
},
}));
}}
className="w-full p-2 text-xs rounded-clay-sm bg-clay-surface border border-clay-text/15 text-clay-text focus:outline-none focus:border-clay-primary"
>
{previewData.existingWallets.map((ew) => (
<option key={ew.id} value={ew.id}>
{ew.name} (Hiện có: {parseFloat(ew.balance).toLocaleString("vi-VN")}{" "}
{ew.currency})
</option>
))}
</select>
)}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name={`wallet-action-${w.id}`}
checked={res.action === "CREATE_NEW"}
onChange={() => {
setWalletResolutions((prev) => ({
...prev,
[w.id]: {
oldWalletId: w.id,
action: "CREATE_NEW",
newWalletName: res.newWalletName || w.name,
},
}));
}}
className="accent-clay-primary"
/>
<span className="font-semibold text-clay-text">
{t("dataManagement.step3.createNew")}
</span>
</label>
{res.action === "CREATE_NEW" && (
<Input
value={res.newWalletName || w.name}
onChange={(e) => {
const val = e.target.value;
setWalletResolutions((prev) => ({
...prev,
[w.id]: {
...prev[w.id],
newWalletName: val,
},
}));
}}
placeholder={t("dataManagement.step3.newWalletName")}
className="text-xs py-1.5"
/>
)}
</div>
</div>
);
})}
</div>
{/* 2. Cơ chế xử lý số dư */}
<div className="space-y-2.5">
<div className="border-b border-clay-text/10 pb-1.5">
<h3 className="font-baloo font-bold text-sm text-clay-text">
{t("dataManagement.step3.balanceSection")}
</h3>
<p className="text-xs text-clay-text-muted">
{t("dataManagement.step3.balanceSectionDesc")}
</p>
</div>
<div className="flex flex-col gap-2">
<label
className={`p-3 rounded-clay-sm border cursor-pointer transition-all flex flex-col gap-1 ${
balanceMode === "ACCUMULATE"
? "bg-clay-primary/10 border-clay-primary shadow-clay-hover"
: "bg-clay-bg/50 border-clay-text/10"
}`}
>
<div className="flex items-center gap-2">
<input
type="radio"
name="balance-mode"
checked={balanceMode === "ACCUMULATE"}
onChange={() => setBalanceMode("ACCUMULATE")}
className="accent-clay-primary"
/>
<span className="font-bold text-xs text-clay-text">
{t("dataManagement.step3.balanceAccumulate")}
</span>
</div>
<p className="text-[11px] text-clay-text-muted pl-5">
{t("dataManagement.step3.balanceAccumulateDesc")}
</p>
</label>
<label
className={`p-3 rounded-clay-sm border cursor-pointer transition-all flex flex-col gap-1 ${
balanceMode === "MAINTAIN_CURRENT"
? "bg-clay-primary/10 border-clay-primary shadow-clay-hover"
: "bg-clay-bg/50 border-clay-text/10"
}`}
>
<div className="flex items-center gap-2">
<input
type="radio"
name="balance-mode"
checked={balanceMode === "MAINTAIN_CURRENT"}
onChange={() => setBalanceMode("MAINTAIN_CURRENT")}
className="accent-clay-primary"
/>
<span className="font-bold text-xs text-clay-text">
{t("dataManagement.step3.balanceMaintain")}
</span>
</div>
<p className="text-[11px] text-clay-text-muted pl-5">
{t("dataManagement.step3.balanceMaintainDesc")}
</p>
</label>
</div>
</div>
{/* 3. Tự động ánh xạ danh mục */}
<div className="space-y-2">
<div className="border-b border-clay-text/10 pb-1.5">
<h3 className="font-baloo font-bold text-sm text-clay-text">
{t("dataManagement.step3.categorySection")}
</h3>
</div>
<div className="max-h-40 overflow-y-auto space-y-1.5 pr-1">
{previewData.categoriesMatching.map((cat) => (
<div
key={cat.id}
className="flex items-center justify-between text-xs p-2 rounded-clay-sm bg-clay-bg/60 border border-clay-text/5"
>
<div className="flex items-center gap-2">
<span className="font-semibold text-clay-text">{cat.name}</span>
<span className="text-[10px] text-clay-text-muted">
({cat.type === "INCOME" ? "Thu nhập" : "Chi tiêu"})
</span>
</div>
{cat.isNew ? (
<Badge type="income" className="text-[10px] py-0.5">
{t("dataManagement.step3.categoryCreated")}
</Badge>
) : (
<Badge type="info" className="text-[10px] py-0.5">
{t("dataManagement.step3.categoryAutoMerged")}
</Badge>
)}
</div>
))}
</div>
</div>
<div className="flex gap-2.5 mt-2">
<Button
variant="secondary"
className="w-1/3"
onClick={() => setCurrentStep(2)}
>
Quay lại
</Button>
<Button
variant="primary"
className="flex-1"
onClick={() => setCurrentStep(4)}
>
{t("dataManagement.step3.confirmButton")}
</Button>
</div>
</Card>
)}
{/* ──────── STEP 4: XÁC NHẬN & TIẾN HÀNH (EXECUTION) ──────── */}
{currentStep === 4 && (
<Card className="p-5 flex flex-col gap-4 bg-clay-surface shadow-clay-raised">
<div>
<h2 className="clay-title-h2 text-clay-primary text-lg">
{t("dataManagement.step4.title")}
</h2>
</div>
{!importResult ? (
<>
{/* Safety Alert */}
<div className="p-3.5 rounded-clay-sm bg-clay-info/10 border border-clay-info/30 flex items-start gap-2.5">
<ShieldIcon size={20} className="text-clay-info shrink-0 mt-0.5" />
<div>
<span className="font-bold text-xs text-clay-text block">
{t("dataManagement.step4.warningTitle")}
</span>
<p className="text-[11px] text-clay-text-muted mt-0.5">
{t("dataManagement.step4.warningDesc")}
</p>
</div>
</div>
{/* Progress Bar (if importing) */}
{isImporting && (
<div className="flex flex-col gap-2 py-3">
<div className="flex justify-between text-xs font-bold text-clay-text font-nunito">
<span>{progressStageText}</span>
<span>{progressPercent}%</span>
</div>
<ProgressBar value={progressPercent} type="primary" height="h-3" />
</div>
)}
<div className="flex gap-2.5 mt-2">
<Button
variant="secondary"
className="w-1/3"
disabled={isImporting}
onClick={() => setCurrentStep(3)}
>
Quay lại
</Button>
<Button
variant="primary"
className="flex-1"
disabled={isImporting}
onClick={handleStartImport}
>
{isImporting
? t("dataManagement.step4.processing")
: t("dataManagement.step4.startButton")}
</Button>
</div>
</>
) : (
/* Success Summary Card */
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col items-center justify-center text-center gap-2">
<div className="w-14 h-14 rounded-full bg-clay-income/20 text-clay-income flex items-center justify-center shadow-clay-raised">
<CheckIcon size={28} />
</div>
<h3 className="font-baloo font-bold text-base text-clay-income">
{t("dataManagement.step4.successTitle")}
</h3>
<p className="clay-caption text-xs max-w-xs">
{t("dataManagement.step4.successDesc")}
</p>
</div>
{/* Result breakdown */}
<div className="p-3.5 rounded-clay bg-clay-bg shadow-clay-pressed flex flex-col gap-2 text-xs">
<span className="font-bold text-clay-text block border-b border-clay-text/10 pb-1">
{t("dataManagement.step4.resultTitle")}
</span>
<div className="grid grid-cols-2 gap-y-1.5 gap-x-4 text-[11px] pt-1">
<div className="flex justify-between">
<span className="text-clay-text-muted">Ví đã nạp:</span>
<span className="font-bold text-clay-primary">
{importResult.importedCount.wallets}
</span>
</div>
<div className="flex justify-between">
<span className="text-clay-text-muted">Danh mục:</span>
<span className="font-bold text-clay-primary">
{importResult.importedCount.categories}
</span>
</div>
<div className="flex justify-between">
<span className="text-clay-text-muted">Giao dịch đã nạp:</span>
<span className="font-bold text-clay-primary">
{importResult.importedCount.transactions}
</span>
</div>
<div className="flex justify-between">
<span className="text-clay-text-muted">Giao dịch trùng đã bỏ qua:</span>
<span className="font-bold text-clay-warning">
{importResult.skippedDuplicates.transactions}
</span>
</div>
<div className="flex justify-between">
<span className="text-clay-text-muted">Chuyển tiền:</span>
<span className="font-bold text-clay-primary">
{importResult.importedCount.transfers}
</span>
</div>
<div className="flex justify-between">
<span className="text-clay-text-muted">Ngân sách:</span>
<span className="font-bold text-clay-primary">
{importResult.importedCount.budgets}
</span>
</div>
<div className="flex justify-between">
<span className="text-clay-text-muted">Mục tiêu tiết kiệm:</span>
<span className="font-bold text-clay-primary">
{importResult.importedCount.savingGoals}
</span>
</div>
</div>
</div>
<div className="flex gap-2 mt-2">
<Button
variant="secondary"
fullWidth
onClick={() => navigate("/transactions")}
>
{t("dataManagement.step4.transactionsButton")}
</Button>
<Button
variant="primary"
fullWidth
onClick={() => navigate("/")}
>
{t("dataManagement.step4.homeButton")}
</Button>
</div>
</div>
)}
</Card>
)}
</div>
)}
{/* ════════════════════ TAB 2: EXPORT BACKUP ════════════════════ */}
{activeTab === "export" && (
<Card className="p-5 flex flex-col gap-4 bg-clay-surface shadow-clay-raised">
<div>
<h2 className="clay-title-h2 text-clay-primary text-lg">
{t("dataManagement.export.title")}
</h2>
<p className="clay-caption text-xs mt-1">
{t("dataManagement.export.subtitle")}
</p>
</div>
<div className="p-4 rounded-clay bg-clay-bg/60 border border-clay-text/5 flex flex-col gap-3">
<div>
<label className="block text-xs font-bold text-clay-text mb-1">
{t("dataManagement.export.passwordLabel")}
</label>
<div className="relative">
<Input
type={showExportPassword ? "text" : "password"}
value={exportPassword}
onChange={(e) => setExportPassword(e.target.value)}
placeholder={t("dataManagement.export.passwordPlaceholder")}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowExportPassword(!showExportPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-clay-text-muted hover:text-clay-text"
>
{showExportPassword ? <EyeOffIcon size={18} /> : <EyeIcon size={18} />}
</button>
</div>
<p className="text-[11px] text-clay-text-muted mt-1.5">
{t("dataManagement.export.passwordHint")}
</p>
</div>
</div>
<Button
variant="primary"
fullWidth
disabled={isExporting}
onClick={handleExport}
className="mt-2"
>
{isExporting
? t("dataManagement.export.exporting")
: t("dataManagement.export.exportButton")}
</Button>
</Card>
)}
</main>
</Page>
);
};
export default DataManagementPage;
......@@ -294,6 +294,9 @@ function HomePage() {
)}
</Button>
</PermissionGate>
<Button variant="secondary" fullWidth onClick={() => navigate("/data-management")}>
{t("dataManagement.header")}
</Button>
<Button variant="secondary" fullWidth onClick={() => navigate("/profile")}>
{t("home.profile")}
</Button>
......
......@@ -31,11 +31,11 @@ const IntegrationsPage: React.FC = () => {
if (!canReadApiKeys && !canReadWebhooks) return <AccessDenied />;
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Page className="page">
<Header title={t("integrations.header")} showBackIcon onBackClick={() => navigate("/admin")} />
<IconGradients />
<main className="mx-auto w-full max-w-5xl space-y-5 px-4 pt-6">
<div className="flex items-center gap-3 px-1">
<main className="mx-auto mt-4 flex w-full max-w-5xl flex-col gap-5 pb-12">
<div className="flex items-center gap-3">
<span className="relative flex h-12 w-16 items-center justify-center">
<span className="absolute left-0 rounded-clay-sm bg-clay-primary p-2 text-clay-on-primary shadow-clay-raised"><KeyIcon size={20} /></span>
<span className="absolute right-0 rounded-clay-sm bg-clay-info p-2 text-clay-on-primary shadow-clay-raised"><NotificationIcon size={20} /></span>
......
......@@ -14,7 +14,7 @@ import { Badge } from "@/components/ui/Badge";
import { useAuthStore } from "@/stores/auth-store";
import { authService } from "@/services/auth.service";
import { uploadService } from "@/services/upload.service";
import { CheckIcon, CopyIcon, EyeIcon, EyeOffIcon, IconGradients } from "@/components/ui/icons";
import { CheckIcon, ChevronRightIcon, CopyIcon, ExchangeIcon, EyeIcon, EyeOffIcon, IconGradients, ReportIcon, SendIcon } from "@/components/ui/icons";
import {
AvatarEditorModal,
AvatarPosition,
......@@ -690,6 +690,49 @@ const ProfilePage: React.FC = () => {
</div>
)}
{/* Data Management Section */}
<div className="px-4 flex flex-col gap-2.5">
<Card
className="flex items-center justify-between p-3.5 cursor-pointer hover:shadow-clay-hover transition-all bg-clay-surface"
onClick={() => navigate("/data-management")}
>
<div className="flex items-center gap-3">
<div className="p-2 rounded-clay-sm bg-clay-primary/15 text-clay-primary shadow-clay-raised">
<ReportIcon size={20} />
</div>
<div>
<span className="font-baloo font-bold text-sm text-clay-text block">
{t("dataManagement.header")}
</span>
<span className="text-xs text-clay-text-muted block">
{t("dataManagement.subtitle")}
</span>
</div>
</div>
<ChevronRightIcon />
</Card>
<Card
className="flex items-center justify-between p-3.5 cursor-pointer hover:shadow-clay-hover transition-all bg-clay-surface"
onClick={() => navigate("/account-handover?mode=send")}
>
<div className="flex items-center gap-3">
<div className="p-2 rounded-clay-sm bg-clay-warning/15 text-clay-warning shadow-clay-raised">
<ExchangeIcon size={20} />
</div>
<div>
<span className="font-baloo font-bold text-sm text-clay-text block">
{t("handover.menuTitle") || "Chuyển giao dữ liệu sang Zalo khác"}
</span>
<span className="text-xs text-clay-text-muted block">
{t("handover.menuSubtitle") || "Quét mã QR & OTP 2 chiều tức thời"}
</span>
</div>
</div>
<ChevronRightIcon />
</Card>
</div>
{/* Global Logout Button */}
<div className="px-4 pb-6">
<Button variant="secondary" onClick={handleLogout} fullWidth className="text-clay-expense">
......
import { apiClient } from "@/lib/api-client";
import {
BackupImportResult,
BackupPreviewResponse,
ExportBackupOptions,
ImportBackupPayload,
} from "@/types/backup";
export const backupService = {
/**
* Xuất file sao lưu JSON (có hoặc không có mật khẩu mã hóa) và kích hoạt tải về thiết bị
*/
async exportBackup(options?: ExportBackupOptions): Promise<{ data: any; filename: string }> {
const response = await apiClient.post<{ success: boolean; data: any }>(
"/backup/export",
options || {},
);
const backupData = response.data.data;
const jsonString = JSON.stringify(backupData, null, 2);
const blob = new Blob([jsonString], { type: "application/json" });
const filename = `finwise-backup-${new Date().toISOString().slice(0, 10)}.json`;
// Kích hoạt download qua thẻ <a> ảo
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", filename);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
return { data: backupData, filename };
},
/**
* Phân tích và xem trước nội dung file JSON sao lưu (Step 2 Preview)
*/
async previewBackup(file: File, password?: string): Promise<BackupPreviewResponse> {
const formData = new FormData();
formData.append("file", file);
if (password && password.trim().length > 0) {
formData.append("password", password.trim());
}
const response = await apiClient.post<{ success: boolean; data: BackupPreviewResponse }>(
"/backup/preview",
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
},
);
return response.data.data;
},
/**
* Thực hiện nạp và hợp nhất dữ liệu nguyên tử (Step 4 Execute)
*/
async importBackup(file: File, options: ImportBackupPayload): Promise<BackupImportResult> {
const formData = new FormData();
formData.append("file", file);
formData.append("options", JSON.stringify(options));
if (options.password) {
formData.append("password", options.password);
}
const response = await apiClient.post<{ success: boolean; data: BackupImportResult }>(
"/backup/import",
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
},
);
return response.data.data;
},
};
import { apiClient } from "@/lib/api-client";
import {
ClaimHandoverRequest,
ClaimHandoverResponse,
ConfirmHandoverRequest,
ConfirmHandoverResponse,
HandoverResultResponse,
HandoverStatusResponse,
InitiateHandoverResponse,
} from "@/types/handover";
export const handoverService = {
/**
* Bước 1: Máy A tạo phiên chuyển giao, nhận về mã QR, mã PIN và TTL 15 phút
*/
async initiate(): Promise<InitiateHandoverResponse> {
const response = await apiClient.post<{ success: boolean; data: InitiateHandoverResponse }>(
"/handover/initiate",
{},
);
return response.data.data;
},
/**
* Máy A truy vấn trạng thái phiên chuyển giao (Polling)
*/
async getStatus(): Promise<HandoverStatusResponse> {
const response = await apiClient.get<{ success: boolean; data: HandoverStatusResponse }>(
"/handover/status",
);
return response.data.data;
},
/**
* Bước 2: Máy B quét QR hoặc nhập mã PIN 6 số để kết nối với máy A
*/
async claim(dto: ClaimHandoverRequest): Promise<ClaimHandoverResponse> {
const response = await apiClient.post<{
success: boolean;
message: string;
data: ClaimHandoverResponse;
}>("/handover/claim", dto);
return response.data.data;
},
/**
* Bước 3: Máy A xác nhận mã OTP để ký duyệt lệnh chuyển giao quyền sở hữu nguyên tử
*/
async confirm(dto: ConfirmHandoverRequest): Promise<ConfirmHandoverResponse> {
const response = await apiClient.post<{
success: boolean;
message: string;
data: ConfirmHandoverResponse;
}>("/handover/confirm", dto);
return response.data.data;
},
/**
* Bước 4: Máy B truy vấn kết quả chuyển giao
*/
async getResult(handoverToken: string): Promise<HandoverResultResponse> {
const response = await apiClient.get<{ success: boolean; data: HandoverResultResponse }>(
`/handover/result/${encodeURIComponent(handoverToken)}`,
);
return response.data.data;
},
/**
* Hủy phiên chuyển giao trước khi xác nhận
*/
async cancel(handoverToken?: string): Promise<void> {
await apiClient.post("/handover/cancel", { handoverToken });
},
};
export type TransactionTypeEnum = 'INCOME' | 'EXPENSE';
export type BudgetTypeEnum = 'OVERALL' | 'CATEGORY';
export type BudgetPeriodEnum = 'CUSTOM' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
export type SavingGoalStatusEnum = 'ACTIVE' | 'PAUSED' | 'COMPLETED';
export type WalletResolutionAction = 'MERGE' | 'CREATE_NEW';
export interface WalletResolution {
oldWalletId: string;
action: WalletResolutionAction;
targetWalletId?: string;
newWalletName?: string;
}
export type BalanceResolutionMode = 'ACCUMULATE' | 'MAINTAIN_CURRENT';
export interface ImportBackupPayload {
walletResolutions: WalletResolution[];
balanceMode: BalanceResolutionMode;
password?: string;
}
export interface BackupPreviewCategoryMatch {
id: string;
name: string;
type: TransactionTypeEnum;
willMergeWithId: string | null;
willMergeWithName: string | null;
isNew: boolean;
}
export interface BackupPreviewWalletItem {
id: string;
name: string;
balance: string;
currency: string;
icon?: string | null;
color?: string | null;
matchedExistingWalletId?: string | null;
matchedExistingWalletName?: string | null;
}
export interface BackupPreviewResponse {
fileVersion: string;
exportedAt: string;
isEncrypted: boolean;
checksum: string;
isChecksumValid: boolean;
accountStatus: 'EMPTY' | 'HAS_DATA';
existingWalletsCount: number;
existingTransactionsCount: number;
existingCategoriesCount: number;
existingWallets: Array<{ id: string; name: string; balance: string; currency: string }>;
summary: {
walletsCount: number;
categoriesCount: number;
transactionsCount: number;
transfersCount: number;
budgetsCount: number;
savingGoalsCount: number;
};
wallets: BackupPreviewWalletItem[];
categoriesMatching: BackupPreviewCategoryMatch[];
potentialDuplicatesCount: number;
}
export interface BackupImportResult {
success: boolean;
importedCount: {
wallets: number;
categories: number;
transactions: number;
transfers: number;
budgets: number;
savingGoals: number;
savingContributions: number;
};
skippedDuplicates: {
transactions: number;
transfers: number;
};
balanceMode: BalanceResolutionMode;
importedAt: string;
}
export interface ExportBackupOptions {
password?: string;
}
export type HandoverStatus =
| 'PENDING_CLAIM'
| 'CLAIMED'
| 'COMPLETED'
| 'CANCELLED'
| 'EXPIRED';
export interface TransferredRecordCounts {
wallets: number;
categories: number;
transactions: number;
transfers: number;
budgets: number;
savingGoals: number;
recurringSchedules: number;
reminders: number;
}
export interface InitiateHandoverResponse {
handoverToken: string;
signature: string;
pinCode: string;
expiresIn: number;
expiresAt: string;
qrPayload: string;
qrDataUrl?: string;
}
export interface HandoverStatusResponse {
handoverToken: string;
status: HandoverStatus;
pinCode: string;
expiresIn: number;
targetUserId?: string | null;
targetUserName?: string | null;
otpPrompt?: string;
otpCodeDev?: string;
}
export interface ClaimHandoverRequest {
handoverToken?: string;
signature?: string;
pinCode?: string;
}
export interface ClaimHandoverResponse {
handoverToken: string;
sourceUserName: string;
status: HandoverStatus;
}
export interface ConfirmHandoverRequest {
handoverToken: string;
otp: string;
}
export interface ConfirmHandoverResponse {
status: HandoverStatus;
targetUserId: string;
targetUserName: string;
transferredRecords: TransferredRecordCounts;
}
export interface HandoverResultResponse {
handoverToken: string;
status: HandoverStatus;
sourceUserName?: string | null;
completedAt?: string;
transferredRecords?: TransferredRecordCounts;
}
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