Skip to content

Frontend Architecture (React)

ระบบมี 2 React apps ที่ใช้ stack เหมือนกันทุก library (React 19 + Vite 6 + TanStack Query 5 + React Router 7) แต่แยก domain ผู้ใช้ชัดเจน

Two Apps

AppPathAudiencePort (dev)Production URL
Main WMS (@wms/web)apps/web/operator, supervisor, manager (warehouse staff)5173https://wms-dev.pages.dev
Admin Portal (@wms/admin)apps/admin/admin, superadmin (system management)5174https://wms-admin-8p4.pages.dev

ทั้งสอง app cuts the same backend API — แค่ permission scope ต่างกัน

Provider Tree

ทั้งสอง app มี provider tree เหมือนกันใน src/main.tsx:

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):

typescript
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)

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:

tsx
<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 stateuseState (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 ที่:

  1. Inject Authorization: Bearer <token> จาก localStorage
  2. Handle 401 Unauthorized → clear token + redirect /login
  3. Catch error → throw normalized
typescript
// 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:

typescript
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

AppApprox bundle sizeOutput dir
@wms/web~600 KB gzipped (vendor + app)apps/web/dist/
@wms/admin~550 KB gzippedapps/admin/dist/
@wms/docsstatic HTML/CSS (per page)apps/docs/.vitepress/dist/

ทั้งหมด deploy เป็น static asset (HTML + JS + CSS) — ไม่มี SSR

เผยแพร่ภายใต้ Digital Outsourcing