Admin · Deep dive

Kỹ thuật chi tiết — Admin Dashboard

Đi sâu vào cách bảng điều khiển nội bộ thực thi: CRA bootstrap, React Router + ProtectedRoute, hook useAuth không phải context, tầng apiClient bọc axios, lớp React Query, luồng duyệt rút tiền 3 bước, và những điểm "code thật khác tài liệu". Đọc sau báo cáo tổng quan 03-admin.html.

React 18.2 TypeScript 4.9 CRA · react-scripts 5 React Query v3 React Router v6 Tailwind 3

Stack & tư duy tổng thể

Trước khi đọc code, nắm 6 đặc điểm quyết định cách app này được tổ chức. Đây là công cụ nghiệp vụ nội bộ (vận hành, tài chính) — khác hẳn kiosk frontend hướng người chơi.

🏗️

CRA + CRACO, alias @/

craco start / craco build. Webpack alias @src/. Shared package @kioskgaming/ui + @kioskgaming/page-loading. Dev port mặc định CRA 3000 (không còn hardcode 3002).

🧭

Điều hướng bằng URL thật

Ngược với kiosk frontend (state machine in-memory), admin dùng React Router v6 với route URL thật: /dashboard, /users/:id, /withdrawal-requests… Mỗi route bọc ProtectedRoute.

🔌

axios + React Query, không fetch tay

Một singleton apiClient bọc axios (interceptor token + 401). Mọi đọc/ghi server đi qua hook React Query v3 trong hooks/useApi.ts — cache, refetch, mutation.

🔐

useAuth là Context (AuthProvider)

App bọc <AuthProvider> — state auth đồng bộ toàn app. logout() clear React Query cache. Permissions từ resolveUserPermissions() (API objects hoặc ROLE_PERMISSIONS map).

🪟

UI = modal-per-action

Mỗi hành động nhạy cảm (approve / reject / complete / ban / update status) có 1 modal riêng + xác nhận. Mẫu lặp lại: useState cho selected* + cờ show*Modal.

🎨

Tailwind 3 + theme kiểu shadcn

tailwind.config.js map sang biến HSL trong index.css (--primary, --card…). Đã định nghĩa cả .dark nhưng chưa có nút bật dark mode.

💡
Đọc theo thứ tự nào?

Người mới nên mở: src/App.tsx (bản đồ route) → components/auth/ProtectedRoute.tsx + hooks/useAuth.ts (gác cổng & phân quyền) → services/api.ts (cách gọi backend) → hooks/useApi.ts (React Query) → một feature, ví dụ components/pages/withdrawalRequest/. Khoảng 5 file này là ~70% hệ thống.

Cấu trúc thư mục src/

Tổ chức theo "feature folder": mỗi nghiệp vụ là một thư mục trong components/pages/ với index.tsx điều phối + các List/Details/Modal con. Import dùng đường dẫn tương đối (không có alias @/).

src/ ├── index.tsx # Entry: ReactDOM.createRoot + StrictMode ├── App.tsx # ★ QueryClient + Router + toàn bộ <Route> ├── index.css # @tailwind + biến HSL :root/.dark (shadcn-style) ├── components/ │ ├── auth/ │ │ ├── ProtectedRoute.tsx # ★ Gác cổng auth + permission │ │ └── LoginPage.tsx # Login 2 bước (password → email code) │ ├── layout/ # Layout, Sidebar, Header, MenuContainer │ ├── pages/ │ │ ├── Dashboard.tsx # 6 stat card + 4 panel tóm tắt │ │ ├── users/ # UserList, UserDetailPage, Wallet*, Ban/Status modal │ │ ├── withdrawalRequest/ # index + List/Details + Approve/Reject/Complete modal │ │ ├── transactions/ # List, Details, UpdateStatus modal │ │ └── tickets/ # List, Details, Reply/UpdateStatus modal │ └── ui/ # Card, Badge, Table, Button, Input, Loading, Menu ├── hooks/ │ ├── useAuth.ts # ★ Auth state (hook, không context) + RBAC │ └── useApi.ts # ★ Toàn bộ hook React Query (query + mutation) ├── services/ api.ts # ★ Singleton apiClient bọc axios ├── types/ index.ts # ~700 dòng interface cho mọi entity/response ├── constants/ index.ts # API_BASE_URL, ENDPOINTS, STORAGE_KEYS, messages… └── utils/ index.ts # cn(), formatCurrency, formatDate, debounce…
🗂️
Mỗi feature folder có cả file *_API.md

Trong từng thư mục page (vd withdrawalRequest/WITHDRAWAL_REQUEST_API.md, users/USER_MANAGEMENT_API.md) có tài liệu API kèm theo. Tiện tra cứu, nhưng luôn đối chiếu với code thật vì một số endpoint trong tài liệu/constants đã lệch (xem mục Bẫy).

Bootstrap & cây provider

Điều gì xảy ra khi trình duyệt mở app. Khác kiosk frontend, ở đây có QueryClientProvider bao toàn bộ và BrowserRouter điều phối theo URL.

1
index.tsx mount React

ReactDOM.createRoot(#root).render(<StrictMode><App/></StrictMode>). StrictMode bật → ở dev mỗi effect chạy 2 lần.

2
App.tsx dựng provider

QueryClientProvider (retry 1, refetchOnWindowFocus:false) → BrowserRouter<Routes> + <Toaster> của react-hot-toast (góc top-right).

3
ProtectedRoute kiểm tra phiên

Mỗi route private bọc ProtectedRoute. Nó gọi useAuth() → đọc localStorage. Trong lúc isLoading hiển thị <LoadingPage> "Đang xác thực…".

Render Layout + trang

Có token hợp lệ → render <Layout> (Sidebar + Header + main) bọc nội dung trang. Không có token → <Navigate to="/login">.

src/App.tsx (rút gọn)tsx
const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
});

return (
  <QueryClientProvider client={queryClient}>
    <Router>
      <Routes>
        <Route path="/login" element={<LoginPage/>} />
        <Route path="/users" element={
          <ProtectedRoute requiredPermission="userManagement">
            <Layout><UsersManagement/></Layout>
          </ProtectedRoute>} />
        // … catch-all → Navigate to="/dashboard"
      </Routes>
      <Toaster position="top-right" />
    </Router>
  </QueryClientProvider>
)

Bảng route & ProtectedRoute

Tất cả route khai báo phẳng trong App.tsx (không nested layout route, không lazy load). Mỗi route private bọc 2 lớp: ProtectedRoute (gác) + Layout (khung UI).

URLComponentrequiredPermissionGhi chú
/loginLoginPagepublicLogin 2 bước, redirect nếu đã đăng nhập
/NavigateBọc Layout, redirect → /dashboard
/dashboardDashboard— (chỉ cần đăng nhập)6 stat card + 4 panel
/usersUsersManagementuserManagementList + filter
/users/:idUserDetailPageuserManagementChi tiết + ví + ban/status
/transactionsTransactionsManagementtransactionManagementMọi giao dịch nền tảng
/withdrawal-requestsWithdrawalManagementwithdrawalManagementDuyệt rút tiền 3 bước
/ticketsTicketManagementticketManagementHỗ trợ khách hàng
/walletsJSX inline placeholderwalletManagement"Đang được phát triển…" ngay trong App.tsx
/adminsJSX inline placeholderadminManagement"Đang được phát triển…" ngay trong App.tsx
*NavigateCatch-all → /dashboard
src/components/auth/ProtectedRoute.tsxtsx
if (isLoading) return <LoadingPage message="Đang xác thực..." />;

if (!isAuthenticated)
  return <Navigate to="/login" state={{ from: location }} replace />;

if (requiredRole && !hasRole(requiredRole))
  return <Navigate to="/dashboard" replace />;

if (requiredPermission && !hasPermission(requiredPermission))
  return <Navigate to="/dashboard" replace />;

return <>{children}</>;
🧭
Sidebar & NAVIGATION_ITEMS lệch nhau

Layout.tsx hardcode đúng 5 mục sidebar (Dashboard, Users, Transactions, Withdrawal Requests, Support Tickets) — không có Wallets/Admins. Trong khi constants.NAVIGATION_ITEMS liệt kê 7 mục và còn ghi path rút tiền là /withdrawals (route thật là /withdrawal-requests). Hằng số này hiện không được dùng để render sidebar.

Auth & RBAC — AuthProvider + permissions.ts

useAuth là React Context qua AuthProvider (bọc trong App.tsx). Permissions resolve từ permissionObjects API hoặc ROLE_PERMISSIONS map với 5 roles chính.

localStorage useEffect đọc token+userData authState hasPermission()

Login 2 bước (gửi mã → đăng nhập)

1
Nhập email + mật khẩu

useSendEmailCode()POST /api/auth/send-code { email, password }. Thành công → chuyển bước code, bật đếm ngược 60s.

2
Nhập mã 6 số từ email

useLogin()POST /api/auth/login { email, password, emailCode }.

3
Lưu token + user

onSuccess của useLogin ghi thẳng admin_auth_token & admin_user_data vào localStorage (không qua useAuth.login()).

Điều hướng vào app

navigate(from). Khi ProtectedRoute mount, useAuth đọc lại localStorage → thấy đã đăng nhập.

Phân quyền (RBAC) — hardcode theo role

src/hooks/useAuth.ts — hasPermissionts
if (!authState.user) return false;

// Admin có TẤT CẢ quyền
if (user.profile?.role === 'admin') return true;

// Finance chỉ có 4 quyền tài chính
if (user.profile?.role === 'finance') {
  const financePermissions = [
    'walletManagement', 'transactionManagement',
    'withdrawalManagement', 'financeManagement',
  ];
  return financePermissions.includes(permission);
}
return false; // role khác → không quyền
PermissionadminfinanceKhác
userManagement
walletManagement
transactionManagement
withdrawalManagement
financeManagement
ticketManagement
adminManagement
🐛
3 điểm dễ vấp về auth

(1) AuthProvider sync state toàn app; logout() gọi queryClient.clear(). (2) RBAC client-only — backend authorize JWT. (3) Roles: system_admin, super_admin, platform_admin, finance_admin, support_admin — không còn chỉ admin/finance.

apiClient — singleton bọc axios

Một class ApiClient tạo sẵn axios.create() với base URL + timeout 10s, gắn 2 interceptor và phơi ra ~25 method tương ứng từng endpoint backend. Export 1 instance apiClient dùng chung toàn app.

PATTERNSingleton + axios instance + interceptors
  • Base URL: API_BASE_URL = 'http://localhost:3001/api' — hardcode trong constants, mọi path đã gồm prefix /api
  • Request interceptor: đọc localStorage.getItem('admin_auth_token') → thêm Authorization: Bearer <token>
  • Response interceptor: nếu 401 → xoá admin_auth_token + admin_user_datawindow.location.href = '/login' (hard redirect)
  • Không refresh token: 401 là đăng xuất luôn, không có cơ chế refresh như kiosk frontend
  • Log dày đặc: mỗi method có nhiều console.log request/response/error (debug để lại)
src/services/api.ts (rút gọn)ts
this.client = axios.create({ baseURL: API_BASE_URL, timeout: 10000 });

this.client.interceptors.request.use((config) => {
  const token = localStorage.getItem('admin_auth_token');
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

this.client.interceptors.response.use(r => r, (error) => {
  if (error.response?.status === 401) {
    localStorage.removeItem('admin_auth_token');
    window.location.href = '/login';
  }
  return Promise.reject(error);
});

Bản đồ endpoint (tất cả dưới prefix /api)

NhómMethod apiClientHTTP & endpoint thật
AuthsendEmailCode, loginPOST /auth/send-code, /auth/login
DashboardgetDashboardStatsGET /admin/dashboard/stats
UsersgetUsers, getUserDetails, banUser, unbanUser, updateUserStatusGET /admin/users, /admin/users/:id · POST …/ban, …/unban · PATCH …/status
WalletgetUserWallet, getWalletTransactionsGET /admin/users/:id/wallet, …/wallet/transactions
TransactionsgetTransactions, getTransactionStats, getTransactionDetail, updateTransactionStatusGET /admin/transactions, …/stats, …/:id · PATCH …/:id/status
WithdrawalsgetWithdrawals, approve/reject/completeGET /payment/admin/withdrawals/pending · POST /payment/admin/withdrawals/:id/{approve,reject,complete}
TicketsgetTickets, getTicket, replyTicket, updateTicketStatus, markTicketDone, getSupportStats, getCategoriesGET /support/admin/tickets, /support/tickets/:num, /support/admin/stats, /support/categories · POST …/reply · PATCH …/:num, …/:num/done
📍
constants.ENDPOINTS đã lỗi thời — đừng tin

Object ENDPOINTS trong constants/index.ts không được api.ts dùng (api.ts viết path literal). Nhiều giá trị đã lệch: ENDPOINTS.USERS = '/users' nhưng thật là /admin/users; ENDPOINTS.TRANSACTIONS = '/payment/transactions' nhưng thật là /admin/transactions. Khi thêm endpoint, sửa trực tiếp trong api.ts.

Lớp React Query — hooks/useApi.ts

Component không gọi apiClient trực tiếp. Chúng dùng hook React Query trong useApi.ts: useQuery cho đọc, useMutation cho ghi. Mutation tự invalidate/refetch query liên quan và bắn toast.

useQuery (đọc)
  • useDashboardStats — staleTime 5′, refetchInterval 30s
  • useUsers(filters), useUserDetails(id)keepPreviousData
  • useTransactions, useTransactionStats, useTransactionDetail
  • useWithdrawals(filters) — staleTime 2′
  • useTickets, useTicket, useSupportStats, useCategories
useMutation (ghi)
  • useSendEmailCode, useLogin (toast + ghi localStorage)
  • useBanUser, useUnbanUser, useUpdateUserStatus
  • useApproveWithdrawal, useRejectWithdrawal, useCompleteWithdrawal
  • useReplyTicket, useUpdateTicketStatus, useMarkTicketDone
  • useUpdateTransactionStatus
src/hooks/useApi.ts — mẫu query key + invalidatets
export const useUsers = (filters) =>
  useQuery(['users', filters], () => apiClient.getUsers(filters),
    { keepPreviousData: true, staleTime: 5 * 60 * 1000 });

export const useBanUser = () => {
  const qc = useQueryClient();
  return useMutation(({ userId, data }) => apiClient.banUser(userId, data), {
    onSuccess: () => {
      qc.invalidateQueries('users');
      qc.invalidateQueries('userDetails');
      toast.success(SUCCESS_MESSAGES.USER_BANNED);
    },
    onError: (e) => toast.error(parseErrorMessage(e)),
  });
};
🔁
2 phong cách invalidate cùng tồn tại

Hook user/transaction dùng cú pháp React Query v3 cũ (invalidateQueries('users') + toast trong hook). Hook withdrawal/ticket dùng cú pháp object mới (invalidateQueries({ queryKey: [...] })) và không toast trong hook — page tự xử lý refetch (vd WithdrawalManagement truyền onSuccess gọi refetch()). Khi thêm hook, theo phong cách của feature đang sửa cho nhất quán.

Type system & "envelope" API

Toàn bộ kiểu nằm trong types/index.ts (~700 dòng). Hầu hết response theo "envelope" chuẩn { success, message, statusCode, timestamp, data }. Đây là nơi nhiều "bẫy hình dạng dữ liệu" trú ngụ.

types/index.ts — envelope & entity tiêu biểuts
export interface ApiResponse<T> {
  success: boolean; message?: string;
  data: T; statusCode?: number; timestamp?: string;
}

export interface User {
  id: string; email: string; status: 'active'|'inactive'|'suspended';
  banned: boolean; profile: Record<string, any>; // role nằm trong profile.role
}

// ⚠ KHÔNG có envelope — chỉ phẳng:
export interface WithdrawalsResponse {
  withdrawals: Withdrawal[]; pagination: Pagination;
}
🧩

role nằm trong profile

Quyền đọc qua user.profile?.role. Nhưng User.profile khai báo Record<string, any> kèm chú thích "API trả về {} empty object" → cần dữ liệu backend đặt role đúng chỗ thì RBAC mới chạy.

⚠️

DashboardStats khai báo 2 lần

Interface DashboardStats bị định nghĩa trùng (2 block giống hệt) trong types/index.ts. Vô hại vì giống nhau, nhưng là dấu hiệu copy-paste cần dọn.

🧨
Lệch hình dạng: WithdrawalsResponse vs cách dùng

Type khai báo response rút tiền là { withdrawals, pagination } (phẳng), nhưng withdrawalRequest/index.tsx lại đọc withdrawalsData?.data?.withdrawals…?.data?.pagination — tức kỳ vọng có thêm lớp data. Vì useWithdrawals không ràng buộc generic chặt nên TS không bắt lỗi; chạy thật phải bám theo JSON backend trả về. Khi nối API, kiểm tra console.log response để biết có lớp data hay không.

Duyệt rút tiền (Withdrawal) — 3 bước

Nghiệp vụ quan trọng nhất, ví dụ mẫu cho toàn bộ kiến trúc "list + modal-per-action". index.tsx giữ state & điều phối; WithdrawalList hiển thị + lọc; 3 modal thực thi từng action.

pending approved completed rejected
BướcHookHTTP & endpointTrạng thái
Xem danh sáchuseWithdrawals(filters)GET /payment/admin/withdrawals/pendingpending
Duyệt (Approve)useApproveWithdrawalPOST /payment/admin/withdrawals/:id/approveapproved
Hoàn tất (Complete)useCompleteWithdrawalPOST /payment/admin/withdrawals/:id/completecompleted
Từ chối (Reject)useRejectWithdrawalPOST /payment/admin/withdrawals/:id/rejectrejected
withdrawalRequest/index.tsx — mẫu modal-per-actiontsx
const [selectedWithdrawal, setSelectedWithdrawal] =
  useState<WithdrawalRequest | null>(null);
const [showApproveModal, setShowApproveModal] = useState(false);

const handleApproveWithdrawal = (w) => {
  setSelectedWithdrawal(w);
  setShowApproveModal(true);
};

// onSuccess của modal → refetch() để cập nhật list
const handleWithdrawalUpdate = () => refetch();
🔎

Bộ lọc của WithdrawalList

Theo status (pending/approved/completed/rejected), withdrawalType (bank_transfer / bitcoin_transfer), và dateFrom/dateTo. Đổi filter → reset page = 1. Mặc định state limit: 20.

🏦

2 loại rút

Bank: bankCode, accountNumber, accountName, routingNumber. Bitcoin: địa chỉ ví. Finance team chuyển khoản/crypto thủ công rồi mới bấm Complete.

🧷
dateFrom/dateTo không nằm trong type filter

WithdrawalFilters chỉ khai báo status, page, limit, userId, withdrawalTypekhôngdateFrom/dateTo, dù index.tsx khởi tạo và WithdrawalList set 2 trường này. Đây là lệch type vô hại lúc compile (do dùng spread) nhưng dễ gây nhầm. Nếu cần lọc ngày chuẩn, bổ sung 2 trường vào type.

Quản lý user & ví

Cùng mẫu list + modal. users/index.tsx giữ filters và truyền xuống UserList; UserDetailPage ghép thông tin user + ví + lịch sử giao dịch.

📋

UserList

Tìm theo email, lọc status & banned, sort theo created_at/email/status/banned, phân trang. Click hàng → navigate('/users/:id'). Mỗi hàng có nút Ban & Update Status mở modal.

👤

UserDetailPage

Hiển thị info + stats (totalDeposits, totalWithdrawals, walletBalance, totalTransactions), WalletSection (credit/debit thủ công) và WalletTransactionTable (lịch sử ví có filter + phân trang).

ActionModalHookEndpoint
Cấm tài khoảnBanUserModaluseBanUserPOST /admin/users/:id/ban { reason? }
Bỏ cấmBanUserModal (chế độ unban)useUnbanUserPOST /admin/users/:id/unban
Đổi trạng tháiUpdateUserStatusModaluseUpdateUserStatusPATCH /admin/users/:id/status
Nạp/trừ víWalletSection(qua apiClient ví)credit/debit (amount + reason + description)
🔒
Ghi audit khi ban

Response ban trả về banned_at, ban_reason (và backend ghi banned_by). Modal yêu cầu nhập lý do. status (active/inactive/suspended) tách biệt với cờ banned — suspended nhẹ hơn ban.

Hỗ trợ khách hàng & giám sát giao dịch

Hai module còn lại theo cùng triết lý. Điểm khác: tickets/index.tsx dùng "master-detail" trong cùng trang (chọn ticket → hiện TicketDetails) thay vì điều hướng URL.

🎫 Tickets
  • TicketList lọc theo status: pending / in_progress / resolved / closed / done
  • TicketDetails hiển thị thread reply (phân biệt admin/user)
  • ReplyTicketModal — có cờ isInternal (ghi chú nội bộ)
  • UpdateTicketStatusModal + markTicketDone (ghi resolvedAt/By)
  • Danh mục lấy động qua useCategories()
💳 Transactions
  • TransactionList lọc type/status/method/date + sort
  • TransactionDetails — payload thô (paymentResult, linkMePayRequest)
  • UpdateTransactionStatusModal — pending/completed/failed/cancelled
  • useTransactionStats — overview + byType + byMethod
  • ID giao dịch là number (getTransactionDetail(id: number))
🔀
2 cách "mở chi tiết" khác nhau

User dùng route (/users/:id → trang riêng). Ticket & Withdrawal dùng state nội trang (modal/panel). Khi thêm feature, chọn mẫu phù hợp: cần deep-link/refresh giữ ngữ cảnh → route; thao tác nhanh trong list → modal.

Tailwind 3 & theme kiểu shadcn

Khác kiosk frontend (Tailwind v4 không config), admin dùng Tailwind 3 có tailwind.config.js + PostCSS. Màu map sang biến HSL khai báo trong index.css theo phong cách shadcn/ui.

src/index.css + tailwind.config.jscss
@tailwind base; @tailwind components; @tailwind utilities;

@layer base {
  :root {
    --primary: 221.2 83.2% 53.3%;  /* xanh dương */
    --card: 0 0% 100%; --border: 214.3 31.8% 91.4%;
    --radius: 0.5rem;
  }
  .dark { --background: 222.2 84% 4.9%; /* … định nghĩa nhưng chưa dùng */ }
}

// tailwind.config.js → colors: { primary: 'hsl(var(--primary))', card: 'hsl(var(--card))' … }

Component UI

Bộ ui/ tự viết kiểu shadcn: Card, Badge, Button (variant default/outline/ghost), Input, Table, Loading, Menu. Gộp class bằng cn() (twMerge(clsx())).

Icon & toast

Icon lucide-react. Thông báo react-hot-toast (<Toaster> ở App, style theo biến card/border). Biểu đồ dashboard dùng recharts.

Layout

Layout = Sidebar (cố định, thu gọn mobile) + Header + main scroll. Tiêu đề trang suy ra từ location.pathname trong getPageTitle().

🧹
Debug UI còn sót trong sidebar

Layout.tsx in một dòng đỏ "Debug: ADMIN / NOT ADMIN" ngay dưới tên user ở footer sidebar. Cùng với hàng loạt console.log trong api.ts/useApi.ts/useAuth.ts, đây là code debug cần dọn trước production.

Config & biến môi trường

Cấu hình tập trung ở constants/index.ts. Đáng chú ý: base URL backend bị hardcode, không đọc biến môi trường REACT_APP_* như chuẩn CRA.

Hằng sốGiá trịÝ nghĩa
API_BASE_URLprocess.env.REACT_APP_API_BASE_URLMặc định http://localhost:3001/api — đọc từ env qua config/env.ts
PORT3000 (CRA default)Dev server qua craco start
STORAGE_KEYSadmin_auth_token, admin_user_data, admin_themeKhóa localStorage
PAGINATION_DEFAULTS{ page:1, limit:10 }Mặc định hằng số (nhưng các page tự đặt limit:20)
CURRENCY{ symbol:'$', code:'USD' }formatCurrency render theo en-US/USD
🌐
Đổi backend URL phải sửa code

ENV.API_BASE_URL đọc REACT_APP_API_BASE_URL, tạo .env cho môi trường khác. Turnstile: REACT_APP_TURNSTILE_ENABLED + REACT_APP_TURNSTILE_SITE_KEY khi backend bật CAPTCHA login.

Testing & build

Hạ tầng test là mặc định của CRA (Jest + react-scripts) nhưng repo chưa có file test nào. Build/dev cũng theo CRA (Webpack), khác turbopack của kiosk frontend.

🧪

Có gì

10 unit test files (*.test.ts(x)) cho utils, forgot-password, USD wallet mappers. @testing-library/react trong deps. Playwright E2E: tests/visual/, scripts test:e2e, test:visual.

Scripts

npm start (craco, port 3000), npm run build (+ SPA fallback postbuild), npm test, npm run test:e2e (Playwright).

Scripts (package.json)bash
npm start    # PORT=3002 react-scripts start → http://localhost:3002
npm run build  # react-scripts build (Webpack) → build/
npm test     # react-scripts test (Jest, chưa có test)
npm run eject  # bung config CRA (không khuyến nghị)
🛡️
Khác kiosk frontend: build KHÔNG bỏ qua lỗi type

CRA react-scripts build sẽ fail khi có lỗi compile (không như Next với ignoreBuildErrors). Nhờ tsconfig strict, lỗi type được bắt khi build — nhưng các lệch any/spread (xem mục type) vẫn lọt qua.

Cookbook cho dev mới

Các tác vụ phổ biến theo đúng quy ước của project.

HOW-TOThêm một trang/route mới (vd /reports)
  1. Tạo thư mục src/components/pages/reports/index.tsx (feature folder).
  2. Thêm <Route> trong App.tsx, bọc <ProtectedRoute requiredPermission="..."> + <Layout>.
  3. Thêm SidebarItem tương ứng trong layout/Layout.tsx (nhớ: sidebar hardcode, không tự sinh từ constants).
  4. Nếu có quyền mới, bổ sung nhánh trong useAuth.hasPermission.
HOW-TOGọi một API backend mới
  1. Thêm method vào class ApiClient trong services/api.ts (dùng this.client.get/post/patch, path literal có prefix /api).
  2. Khai báo interface request/response trong types/index.ts (theo envelope { success, data }).
  3. Tạo hook trong hooks/useApi.ts: useQuery (đọc) hoặc useMutation (ghi) + invalidate query liên quan.
  4. Component gọi hook; lỗi báo qua toast.error(parseErrorMessage(e)).
HOW-TOThêm một action + modal (vd "Refund")
  1. Tạo RefundModal.tsx trong feature folder (nhận isOpen, onClose, onSuccess, entity được chọn).
  2. Trong index.tsx: thêm state selected* + showRefundModal + handler set cả hai.
  3. Modal gọi useRefund(); onSuccess đóng modal + refetch()/invalidate.
  4. Thêm nút mở modal trong component List.
HOW-TOTrỏ app sang backend khác
  1. Sửa API_BASE_URL trong src/constants/index.ts (hoặc refactor sang process.env.REACT_APP_API_URL + tạo .env).
  2. Khởi động lại dev server (CRA chỉ đọc env lúc start).
  3. Kiểm tra Network/console: mọi request đi qua interceptor có gắn Bearer token.

Bẫy thường gặp (đọc trước khi sửa)

BẫyChi tiết & cách xử lý
useAuth là ContextAuthProvider bọc app; logout() clear query cache. Đừng kỳ vọng hook độc lập như báo cáo cũ.
Backend URL qua envREACT_APP_API_BASE_URL trong config/env.ts — tạo .env cho staging/prod.
Sidebar ≠ NAVIGATION_ITEMSSidebar hardcode 5 mục trong Layout.tsx; hằng số 7 mục với path rút tiền sai (/withdrawals vs /withdrawal-requests). Thêm mục phải sửa Layout.
Lệch data ở withdrawalsType WithdrawalsResponse phẳng nhưng code đọc ?.data?.withdrawals. Bám JSON thật khi nối API.
RBAC chỉ ở clientBackend phải tự authorize theo JWT. Role operation/platform chưa có trong hasPermission.
Backend URL hardcodeAPI_BASE_URL cố định localhost:3001/api, không có .env. Đổi môi trường phải sửa code + build lại.
Tiền tệ mặc định USDformatCurrency render USD, nghiệp vụ có thể là VND. Kiểm tra currency từ dữ liệu.
Chưa có testnpm test báo "No tests found"; thiếu Testing Library. Cài trước khi viết test component.
Debug còn sótDòng "Debug: ADMIN/NOT ADMIN" trong sidebar + nhiều console.log trong api/hooks. Dọn trước production.
StrictMode chạy effect 2 lần (dev)Ở dev, effect/log chạy đôi do React.StrictMode. Bình thường, không phải bug.