Frontend Architecture (React)
ระบบมี 2 React apps ที่ใช้ stack เหมือนกันทุก library (React 19 + Vite 6 + TanStack Query 5 + React Router 7) แต่แยก domain ผู้ใช้ชัดเจน
Two Apps
| App | Path | Audience | Port (dev) | Production URL |
|---|---|---|---|---|
Main WMS (@wms/web) | apps/web/ | operator, supervisor, manager (warehouse staff) | 5173 | https://wms-dev.pages.dev |
Admin Portal (@wms/admin) | apps/admin/ | admin, superadmin (system management) | 5174 | https://wms-admin-8p4.pages.dev |
ทั้งสอง app cuts the same backend API — แค่ permission scope ต่างกัน
Provider Tree
ทั้งสอง app มี provider tree เหมือนกันใน src/main.tsx:
<React.StrictMode>
<QueryClientProvider client={queryClient}> {/* server state */}
<BrowserRouter> {/* routing */}
<AuthProvider> {/* JWT user state */}
<HelpProvider> {/* in-app help drawer */}
<App />
<Toaster position="top-right" /> {/* react-hot-toast */}
</HelpProvider>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>QueryClient config (default options):
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // 30s cache
retry: 1, // retry failed query once
},
},
});Routing Structure
ใช้ react-router-dom v7 — traditional <Routes> pattern (ไม่ใช้ data router)
Main WMS (apps/web/src/App.tsx)
<Routes>
<Route path="/login" element={<Login />} /> {/* public */}
<Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route index element={<Dashboard />} />
<Route path="master-data" element={<MasterDataLayout />}>
<Route path="items" element={<ItemsPage />} />
<Route path="locations" element={<LocationsPage />} />
…
</Route>
<Route path="inbound" element={<InboundLayout />}>
<Route path="asn" element={<AsnPage />} />
<Route path="grn" element={<GrnPage />} />
<Route path="putaway" element={<PutawayPage />} />
</Route>
<Route path="outbound" element={<OutboundLayout />}>…</Route>
<Route path="inventory" element={<InventoryLayout />}>…</Route>
<Route path="returns" element={<ReturnsLayout />}>…</Route>
<Route path="reports" element={<ReportsHub />} />
<Route path="reports/inventory" element={<InventoryReportsPage />} />
…
</Route>
</Routes>Admin Portal (apps/admin/src/App.tsx)
แตกต่างจาก Main คือ — ทุก route wrap ด้วย <ProtectedRoute module="<key>"> เพื่อเช็ค per-module permission:
<Route
path="users"
element={
<ProtectedRoute module="users">
<UsersPage />
</ProtectedRoute>
}
/>Module keys ที่ใช้ใน Admin: dashboard, users, roles, master-data.items, master-data.locations, master-data.uoms, master-data.partners, warehouses, inbound.asn, inbound.grn, inbound.putaway-rules, outbound.orders, outbound.waves, outbound.pick-tasks, outbound.shipments, inventory.stock, inventory.movements, inventory.adjustments, inventory.cycle-counts, returns.rma, lpn, replenishment.rules, replenishment.tasks, approvals, audit, announcements, api-keys, webhooks, wms-settings, global-config, health, reports.inventory, reports.receiving, reports.outbound, reports.delivery, reports.users
State Management
| ประเภท state | ใช้ tool ไหน |
|---|---|
| Server state (lists, detail, mutations) | TanStack Query (useQuery, useMutation) |
| Auth user (JWT, profile) | React Context (AuthContext) |
| In-app help (drawer open/topic) | React Context (HelpContext) |
| Local form state | useState (no React Hook Form) |
| URL state (filters, pagination) | useSearchParams |
ทำไมไม่ใช้ Redux?
Server state ให้ TanStack Query จัดการ + local state แค่ form → Redux จะกลายเป็น boilerplate ที่ไม่ได้ประโยชน์ ระบบใหญ่กว่านี้ค่อยพิจารณา
API Client Pattern
lib/api.ts — axios instance ที่:
- Inject
Authorization: Bearer <token>จาก localStorage - Handle
401 Unauthorized→ clear token + redirect/login - Catch error → throw normalized
// pseudo
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL + '/api/v1',
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('access_token');
window.location.href = '/login';
}
throw err;
}
);useApi Hooks
แทนที่จะเขียน useQuery({ queryKey: ['items'], queryFn: () => api.get('/items') }) ตรง ๆ ทุกที่ — มี wrapper hooks:
useList(resource, params?) // GET /<resource>?... → paginated list
useDetail(resource, id) // GET /<resource>/<id>
useCreate(resource) // POST /<resource>
useUpdate(resource) // PATCH /<resource>/<id>
useRemove(resource) // DELETE /<resource>/<id>
useAction(resource, action) // POST /<resource>/<id>/<action> (release, cancel, …)ทั้งหมด auto-invalidate cache ของ resource เมื่อ mutation success → ไม่ต้อง manual refetch
Permission System (Frontend)
Main WMS
- ไม่มี per-module guard — ทุกคน login แล้วเห็นเมนูทั้งหมด
- ปุ่ม action บางอย่าง (เช่น "ลบ", "อนุมัติ") ถูก hide ตาม
user.role
Admin Portal
- เช็คผ่าน
<ProtectedRoute module="xxx"> - Logic:
user.role === 'admin' || user.adminModules.includes('xxx') || user.adminModulesWrite.includes('xxx') - เมนูใน sidebar ก็ filter ตาม permission เดียวกัน
ดูเต็ม: Authorization
i18n Setup
- Library:
react-i18next+i18next(ลง runtime) - ภาษา:
th(default),en - Resource files:
src/locales/th.ts,src/locales/en.ts - เปลี่ยนภาษา:
i18n.changeLanguage('en')→ re-render ทุกuseTranslation() - ภาษาผู้ใช้ถูก save ที่
user.language(column ใน DB) — sync เมื่อ login
Build Output
| App | Approx bundle size | Output dir |
|---|---|---|
@wms/web | ~600 KB gzipped (vendor + app) | apps/web/dist/ |
@wms/admin | ~550 KB gzipped | apps/admin/dist/ |
@wms/docs | static HTML/CSS (per page) | apps/docs/.vitepress/dist/ |
ทั้งหมด deploy เป็น static asset (HTML + JS + CSS) — ไม่มี SSR