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 ...@@ -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`. - 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`. - 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/`. - Đầ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 ## Khi cập nhật file này
......
...@@ -59,6 +59,8 @@ const AdminNotificationsPage = React.lazy(() => import("@/pages/admin/notificati ...@@ -59,6 +59,8 @@ const AdminNotificationsPage = React.lazy(() => import("@/pages/admin/notificati
const AdminAiPage = React.lazy(() => import("@/pages/admin/ai/index")); const AdminAiPage = React.lazy(() => import("@/pages/admin/ai/index"));
const IntegrationsPage = React.lazy(() => import("@/pages/integrations/index")); const IntegrationsPage = React.lazy(() => import("@/pages/integrations/index"));
const StatementsPage = React.lazy(() => import("@/pages/statements/index")); const 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")); const NotFoundPage = React.lazy(() => import("@/pages/not-found/index"));
import { PermissionGate } from "@/components/shared/PermissionGate"; import { PermissionGate } from "@/components/shared/PermissionGate";
...@@ -205,6 +207,8 @@ export const Layout = () => { ...@@ -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="/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="/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="/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 */} {/* 404 Catch-All Route */}
<Route path="*" element={<NotFoundPage />}></Route> <Route path="*" element={<NotFoundPage />}></Route>
</AnimationRoutes> </AnimationRoutes>
......
...@@ -298,14 +298,14 @@ body { ...@@ -298,14 +298,14 @@ body {
var(--zaui-safe-area-inset-top, env(safe-area-inset-top, 0px)) + 8px var(--zaui-safe-area-inset-top, env(safe-area-inset-top, 0px)) + 8px
); );
right: calc( 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. */ /* Reserve space for native right-buttons (96px), three controls, and gaps. */
.zaui-header { .zaui-header {
padding-right: calc( 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 { ...@@ -313,12 +313,12 @@ body {
.finwise-header-controls { .finwise-header-controls {
gap: 4px; gap: 4px;
right: calc( 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 { .zaui-header {
padding-right: calc( 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 @@ ...@@ -2341,6 +2341,188 @@
"downloadFile": "Download", "downloadFile": "Download",
"processingNotice": "Extracting data in background, please wait a moment...", "processingNotice": "Extracting data in background, please wait a moment...",
"failedNotice": "Processing failed. Please try again later." "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:"
}
} }
} }
This diff is collapsed.
This diff is collapsed.
...@@ -122,11 +122,11 @@ export const AdminAiPage: React.FC = () => { ...@@ -122,11 +122,11 @@ export const AdminAiPage: React.FC = () => {
}; };
return ( 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')} /> <Header title={t('admin.ai.header')} showBackIcon onBackClick={() => navigate('/admin')} />
<IconGradients /> <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 */} {/* Banner */}
<Card className="overflow-hidden bg-clay-primary/10"> <Card className="overflow-hidden bg-clay-primary/10">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
......
...@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/Button'; ...@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { Select } from '@/components/ui/Select'; import { Select } from '@/components/ui/Select';
import { IconGradients } from '@/components/ui/icons';
import { LocalizedDateInput } from '@/components/shared/LocalizedDateInput'; import { LocalizedDateInput } from '@/components/shared/LocalizedDateInput';
import { addCalendarDays, businessDateStartIso } from '@/lib/business-time'; import { addCalendarDays, businessDateStartIso } from '@/lib/business-time';
import { useAdminAuditLogs, useArchiveCleanupAuditLogs } from '@/hooks/use-admin'; import { useAdminAuditLogs, useArchiveCleanupAuditLogs } from '@/hooks/use-admin';
...@@ -89,9 +90,10 @@ const AdminAuditLogsPage: React.FC = () => { ...@@ -89,9 +90,10 @@ const AdminAuditLogsPage: React.FC = () => {
}; };
return ( 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')} /> <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 */} {/* 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"> <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"> <div className="flex items-start gap-3">
......
...@@ -39,10 +39,10 @@ const AdminDashboardPage: React.FC = () => { ...@@ -39,10 +39,10 @@ const AdminDashboardPage: React.FC = () => {
] : []; ] : [];
return ( return (
<Page className="page min-h-screen bg-clay-bg pb-12"> <Page className="page">
<Header title={t('admin.dashboard.title')} showBackIcon onBackClick={() => navigate('/')} /> <Header title={t('admin.dashboard.title')} showBackIcon onBackClick={() => navigate('/')} />
<IconGradients /> <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"> <Card className="overflow-hidden bg-clay-primary/10">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="rounded-clay bg-clay-primary p-3 text-clay-on-primary shadow-clay-raised"> <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 = () => { ...@@ -175,11 +175,11 @@ export const AdminNotificationsPage: React.FC = () => {
}; };
return ( 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')} /> <Header title={t('admin.notifications.header')} showBackIcon onBackClick={() => navigate('/admin')} />
<IconGradients /> <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 */} {/* Banner */}
<Card className="overflow-hidden bg-clay-primary/10"> <Card className="overflow-hidden bg-clay-primary/10">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
......
...@@ -272,7 +272,7 @@ const RolesPage: React.FC = () => { ...@@ -272,7 +272,7 @@ const RolesPage: React.FC = () => {
/> />
<IconGradients /> <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 */} {/* Banner Header Card */}
<Card className="flex items-center gap-4 py-4 bg-clay-surface border border-clay-highlight/60 shadow-clay-raised"> <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"> <div className="p-3 bg-clay-primary/10 rounded-full text-clay-primary flex-none">
...@@ -424,7 +424,7 @@ const RolesPage: React.FC = () => { ...@@ -424,7 +424,7 @@ const RolesPage: React.FC = () => {
</div> </div>
)} )}
</div> </div>
</div> </main>
{/* ======================================================== */} {/* ======================================================== */}
{/* MODAL: PERMISSION MATRIX EDITOR */} {/* MODAL: PERMISSION MATRIX EDITOR */}
......
...@@ -189,11 +189,11 @@ export const AdminSettingsPage: React.FC = () => { ...@@ -189,11 +189,11 @@ export const AdminSettingsPage: React.FC = () => {
}; };
return ( 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')} /> <Header title={t('admin.settings.header')} showBackIcon onBackClick={() => navigate('/admin')} />
<IconGradients /> <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 */} {/* Banner */}
<Card className="overflow-hidden bg-clay-primary/10"> <Card className="overflow-hidden bg-clay-primary/10">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
......
...@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/Button'; ...@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Select } from '@/components/ui/Select'; import { Select } from '@/components/ui/Select';
import { PermissionGate } from '@/components/shared/PermissionGate'; import { PermissionGate } from '@/components/shared/PermissionGate';
import { IconGradients } from '@/components/ui/icons';
import { PERMISSIONS } from '@/common/constants'; import { PERMISSIONS } from '@/common/constants';
import { import {
useAdminRoles, useAdminRoles,
...@@ -62,9 +63,10 @@ const AdminUserDetailPage: React.FC = () => { ...@@ -62,9 +63,10 @@ const AdminUserDetailPage: React.FC = () => {
}; };
return ( 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')} /> <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 ? ( {userQuery.isLoading ? (
<AdminSkeleton rows={3} /> <AdminSkeleton rows={3} />
) : userQuery.isError || !user ? ( ) : userQuery.isError || !user ? (
......
...@@ -11,6 +11,7 @@ import { Input } from '@/components/ui/Input'; ...@@ -11,6 +11,7 @@ import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { Select } from '@/components/ui/Select'; import { Select } from '@/components/ui/Select';
import { PermissionGate } from '@/components/shared/PermissionGate'; import { PermissionGate } from '@/components/shared/PermissionGate';
import { IconGradients } from '@/components/ui/icons';
import { PERMISSIONS } from '@/common/constants'; import { PERMISSIONS } from '@/common/constants';
import { import {
useAdminRoles, useAdminRoles,
...@@ -123,9 +124,10 @@ const AdminUsersPage: React.FC = () => { ...@@ -123,9 +124,10 @@ const AdminUsersPage: React.FC = () => {
}; };
return ( 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')} /> <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> <Card>
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
......
This diff is collapsed.
...@@ -294,6 +294,9 @@ function HomePage() { ...@@ -294,6 +294,9 @@ function HomePage() {
)} )}
</Button> </Button>
</PermissionGate> </PermissionGate>
<Button variant="secondary" fullWidth onClick={() => navigate("/data-management")}>
{t("dataManagement.header")}
</Button>
<Button variant="secondary" fullWidth onClick={() => navigate("/profile")}> <Button variant="secondary" fullWidth onClick={() => navigate("/profile")}>
{t("home.profile")} {t("home.profile")}
</Button> </Button>
......
...@@ -31,11 +31,11 @@ const IntegrationsPage: React.FC = () => { ...@@ -31,11 +31,11 @@ const IntegrationsPage: React.FC = () => {
if (!canReadApiKeys && !canReadWebhooks) return <AccessDenied />; if (!canReadApiKeys && !canReadWebhooks) return <AccessDenied />;
return ( return (
<Page className="page min-h-screen bg-clay-bg pb-12"> <Page className="page">
<Header title={t("integrations.header")} showBackIcon onBackClick={() => navigate("/admin")} /> <Header title={t("integrations.header")} showBackIcon onBackClick={() => navigate("/admin")} />
<IconGradients /> <IconGradients />
<main className="mx-auto w-full max-w-5xl space-y-5 px-4 pt-6"> <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 px-1"> <div className="flex items-center gap-3">
<span className="relative flex h-12 w-16 items-center justify-center"> <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 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> <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"; ...@@ -14,7 +14,7 @@ import { Badge } from "@/components/ui/Badge";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { authService } from "@/services/auth.service"; import { authService } from "@/services/auth.service";
import { uploadService } from "@/services/upload.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 { import {
AvatarEditorModal, AvatarEditorModal,
AvatarPosition, AvatarPosition,
...@@ -690,6 +690,49 @@ const ProfilePage: React.FC = () => { ...@@ -690,6 +690,49 @@ const ProfilePage: React.FC = () => {
</div> </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 */} {/* Global Logout Button */}
<div className="px-4 pb-6"> <div className="px-4 pb-6">
<Button variant="secondary" onClick={handleLogout} fullWidth className="text-clay-expense"> <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