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.
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
Có 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.
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 @/).
*_API.mdTrong 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.
ReactDOM.createRoot(#root).render(<StrictMode><App/></StrictMode>). StrictMode bật → ở dev mỗi effect chạy 2 lần.
QueryClientProvider (retry 1, refetchOnWindowFocus:false) → BrowserRouter → <Routes> + <Toaster> của react-hot-toast (góc top-right).
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…".
Có token hợp lệ → render <Layout> (Sidebar + Header + main) bọc nội dung trang. Không có token → <Navigate to="/login">.
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).
| URL | Component | requiredPermission | Ghi chú |
|---|---|---|---|
/login | LoginPage | public | Login 2 bước, redirect nếu đã đăng nhập |
/ | Navigate | — | Bọc Layout, redirect → /dashboard |
/dashboard | Dashboard | — (chỉ cần đăng nhập) | 6 stat card + 4 panel |
/users | UsersManagement | userManagement | List + filter |
/users/:id | UserDetailPage | userManagement | Chi tiết + ví + ban/status |
/transactions | TransactionsManagement | transactionManagement | Mọi giao dịch nền tảng |
/withdrawal-requests | WithdrawalManagement | withdrawalManagement | Duyệt rút tiền 3 bước |
/tickets | TicketManagement | ticketManagement | Hỗ trợ khách hàng |
/wallets | JSX inline placeholder | walletManagement | "Đang được phát triển…" ngay trong App.tsx |
/admins | JSX inline placeholder | adminManagement | "Đang được phát triển…" ngay trong App.tsx |
* | Navigate | — | Catch-all → /dashboard |
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}</>;
NAVIGATION_ITEMS lệch nhauLayout.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.
Login 2 bước (gửi mã → đăng nhập)
useSendEmailCode() → POST /api/auth/send-code { email, password }. Thành công → chuyển bước code, bật đếm ngược 60s.
useLogin() → POST /api/auth/login { email, password, emailCode }.
onSuccess của useLogin ghi thẳng admin_auth_token & admin_user_data vào localStorage (không qua useAuth.login()).
navigate(from). Khi ProtectedRoute mount, useAuth đọc lại localStorage → thấy đã đăng nhập.
Phân quyền (RBAC) — hardcode theo role
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
| Permission | admin | finance | Khác |
|---|---|---|---|
userManagement | ✅ | ❌ | ❌ |
walletManagement | ✅ | ✅ | ❌ |
transactionManagement | ✅ | ✅ | ❌ |
withdrawalManagement | ✅ | ✅ | ❌ |
financeManagement | ✅ | ✅ | ❌ |
ticketManagement | ✅ | ❌ | ❌ |
adminManagement | ✅ | ❌ | ❌ |
(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.
- Base URL:
API_BASE_URL = 'http://localhost:3001/api'— hardcode trongconstants, mọi path đã gồm prefix/api - Request interceptor: đọc
localStorage.getItem('admin_auth_token')→ thêmAuthorization: Bearer <token> - Response interceptor: nếu
401→ xoáadmin_auth_token+admin_user_data→window.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.logrequest/response/error (debug để lại)
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óm | Method apiClient | HTTP & endpoint thật |
|---|---|---|
| Auth | sendEmailCode, login | POST /auth/send-code, /auth/login |
| Dashboard | getDashboardStats | GET /admin/dashboard/stats |
| Users | getUsers, getUserDetails, banUser, unbanUser, updateUserStatus | GET /admin/users, /admin/users/:id · POST …/ban, …/unban · PATCH …/status |
| Wallet | getUserWallet, getWalletTransactions | GET /admin/users/:id/wallet, …/wallet/transactions |
| Transactions | getTransactions, getTransactionStats, getTransactionDetail, updateTransactionStatus | GET /admin/transactions, …/stats, …/:id · PATCH …/:id/status |
| Withdrawals | getWithdrawals, approve/reject/complete | GET /payment/admin/withdrawals/pending · POST /payment/admin/withdrawals/:id/{approve,reject,complete} |
| Tickets | getTickets, getTicket, replyTicket, updateTicketStatus, markTicketDone, getSupportStats, getCategories | GET /support/admin/tickets, /support/tickets/:num, /support/admin/stats, /support/categories · POST …/reply · PATCH …/:num, …/:num/done |
constants.ENDPOINTS đã lỗi thời — đừng tinObject 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.
useDashboardStats— staleTime 5′, refetchInterval 30suseUsers(filters),useUserDetails(id)—keepPreviousDatauseTransactions,useTransactionStats,useTransactionDetailuseWithdrawals(filters)— staleTime 2′useTickets,useTicket,useSupportStats,useCategories
useSendEmailCode,useLogin(toast + ghi localStorage)useBanUser,useUnbanUser,useUpdateUserStatususeApproveWithdrawal,useRejectWithdrawal,useCompleteWithdrawaluseReplyTicket,useUpdateTicketStatus,useMarkTicketDoneuseUpdateTransactionStatus
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)), }); };
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ụ.
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.
WithdrawalsResponse vs cách dùngType khai báo response rút tiền là { withdrawals, pagination } (phẳng), nhưng withdrawalRequest/index.tsx lại đọc withdrawalsData?.data?.withdrawals và …?.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.
| Bước | Hook | HTTP & endpoint | Trạng thái |
|---|---|---|---|
| Xem danh sách | useWithdrawals(filters) | GET /payment/admin/withdrawals/pending | pending |
| Duyệt (Approve) | useApproveWithdrawal | POST /payment/admin/withdrawals/:id/approve | approved |
| Hoàn tất (Complete) | useCompleteWithdrawal | POST /payment/admin/withdrawals/:id/complete | completed |
| Từ chối (Reject) | useRejectWithdrawal | POST /payment/admin/withdrawals/:id/reject | rejected |
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 filterWithdrawalFilters chỉ khai báo status, page, limit, userId, withdrawalType — không có dateFrom/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).
| Action | Modal | Hook | Endpoint |
|---|---|---|---|
| Cấm tài khoản | BanUserModal | useBanUser | POST /admin/users/:id/ban { reason? } |
| Bỏ cấm | BanUserModal (chế độ unban) | useUnbanUser | POST /admin/users/:id/unban |
| Đổi trạng thái | UpdateUserStatusModal | useUpdateUserStatus | PATCH /admin/users/:id/status |
| Nạp/trừ ví | WalletSection | (qua apiClient ví) | credit/debit (amount + reason + description) |
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.
TicketListlọc theo status: pending / in_progress / resolved / closed / doneTicketDetailshiể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()
TransactionListlọc type/status/method/date + sortTransactionDetails— payload thô (paymentResult,linkMePayRequest)UpdateTransactionStatusModal— pending/completed/failed/cancelleduseTransactionStats— overview + byType + byMethod- ID giao dịch là number (
getTransactionDetail(id: number))
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.
@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().
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_URL | process.env.REACT_APP_API_BASE_URL | Mặc định http://localhost:3001/api — đọc từ env qua config/env.ts |
PORT | 3000 (CRA default) | Dev server qua craco start |
STORAGE_KEYS | admin_auth_token, admin_user_data, admin_theme | Khó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 |
Vì 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).
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ị)
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.
- Tạo thư mục
src/components/pages/reports/index.tsx(feature folder). - Thêm
<Route>trongApp.tsx, bọc<ProtectedRoute requiredPermission="...">+<Layout>. - Thêm
SidebarItemtương ứng tronglayout/Layout.tsx(nhớ: sidebar hardcode, không tự sinh từ constants). - Nếu có quyền mới, bổ sung nhánh trong
useAuth.hasPermission.
- Thêm method vào class
ApiClienttrongservices/api.ts(dùngthis.client.get/post/patch, path literal có prefix/api). - Khai báo interface request/response trong
types/index.ts(theo envelope{ success, data }). - Tạo hook trong
hooks/useApi.ts:useQuery(đọc) hoặcuseMutation(ghi) + invalidate query liên quan. - Component gọi hook; lỗi báo qua
toast.error(parseErrorMessage(e)).
- Tạo
RefundModal.tsxtrong feature folder (nhậnisOpen,onClose,onSuccess, entity được chọn). - Trong
index.tsx: thêm stateselected*+showRefundModal+ handler set cả hai. - Modal gọi
useRefund();onSuccessđóng modal +refetch()/invalidate. - Thêm nút mở modal trong component List.
- Sửa
API_BASE_URLtrongsrc/constants/index.ts(hoặc refactor sangprocess.env.REACT_APP_API_URL+ tạo.env). - Khởi động lại dev server (CRA chỉ đọc env lúc start).
- 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ẫy | Chi tiết & cách xử lý |
|---|---|
useAuth là Context | AuthProvider bọc app; logout() clear query cache. Đừng kỳ vọng hook độc lập như báo cáo cũ. |
| Backend URL qua env | REACT_APP_API_BASE_URL trong config/env.ts — tạo .env cho staging/prod. |
Sidebar ≠ NAVIGATION_ITEMS | Sidebar 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 ở withdrawals | Type WithdrawalsResponse phẳng nhưng code đọc ?.data?.withdrawals. Bám JSON thật khi nối API. |
| RBAC chỉ ở client | Backend phải tự authorize theo JWT. Role operation/platform chưa có trong hasPermission. |
| Backend URL hardcode | API_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 USD | formatCurrency render USD, nghiệp vụ có thể là VND. Kiểm tra currency từ dữ liệu. |
| Chưa có test | npm test báo "No tests found"; thiếu Testing Library. Cài trước khi viết test component. |
| Debug còn sót | Dò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. |