IoT Admin CRUD: ตารางข้อมูลแบบครบเครื่อง

IoT Admin CRUD: ตารางข้อมูลแบบครบเครื่อง

ShowkhunWorkshop

IoT Admin CRUD: ตารางข้อมูลแบบครบเครื่อง

Branch: step-16-admin-crud Phase: Development (16/19) — Admin Console Repo: kangana1024/showkhun-workshop


สวัสดีน้องๆ ทุกคน! พี่โชว์มาแล้ว วันนี้เราจะมาทำงานที่หลายคนคิดว่าน่าเบื่อ แต่จริงๆ แล้ว สนุกมากถ้าทำถูกวิธี นั่นคือหน้า Admin CRUD 555

เคยเจอไหม? เปิดหน้า Admin แล้วตารางมันช้า sort ไม่ได้ กด delete แล้วหายเลยไม่ถามอะไร… อ๊ะ อ๊ะ วันนี้เราไม่ทำแบบนั้น มาลุยกัน! (ง’̀-’́)ง

โพสต์ที่แล้วเราวาง foundation (Vite + Router + fetch client + TanStack Query) ไปแล้ว วันนี้เราจะต่อยอดเป็น CRUD จริงๆ สำหรับ devices และ alert rules


สิ่งที่น้องๆ จะได้เรียนรู้วันนี้

  • ทำไมต้อง DataTable แบบ generic <T> (ไม่ใช่แค่ <table> ธรรมดา)
  • ทำไม React Hook Form + Zod ถึงเปลี่ยนชีวิตการทำ form — และทำไม schema ต้อง mirror backend
  • เพิ่ม mutations ใน TanStack Query พร้อม cache invalidation อัตโนมัติ
  • Toast + Confirm Dialog แบบ imperative (เรียกจากที่ไหนก็ได้) ด้วย Zustand store
  • Bulk delete หลายแถวพร้อมกันแบบ Gmail
  • Filter + debounced search + offset pagination ที่ตรงกับ backend contract

ทำไมถึงต้องทำแบบนี้? (WHY ก่อนเสมอ)

ลองนึกภาพว่าน้องเป็น พนักงานคลังสินค้า ที่ต้องจัดการสินค้านับพันรายการ

ถ้ามีแค่กระดาษกับปากกา → ช้า, หายาก, ผิดพลาดบ่อย ถ้ามีระบบที่ดี → กรองได้, เรียงได้, ลบหลายอันพร้อมกันได้

ระบบ IoT Admin ก็เหมือนกัน มีอุปกรณ์เป็นร้อยเป็นพัน ถ้าไม่มี DataTable ที่ดี ชีวิต admin จะลำบากมาก เราเลยต้องสร้างให้ถูกต้องตั้งแต่ต้น


ภาพรวม Flow ทั้งหมด

graph TD
    A[👤 User action] --> B{อ่าน หรือ เขียน?}
    B -->|อ่าน| C[useQuery → GET /api/v1/...]
    B -->|เขียน| D[confirm dialog?]
    D -->|ยืนยัน| E[useMutation → POST/PUT/DELETE]
    E --> F[invalidateQueries]
    F --> C
    E --> G[🔔 toast แจ้งผล]
    C --> H[🗂️ DataTable render]

หัวใจคือ: อ่านผ่าน useQuery, เขียนผ่าน useMutation, พอเขียนเสร็จก็ invalidate cache ให้ table refetch เอง แล้ว toast แจ้งผล — ไม่ต้อง manage state เองเลย


Step 1: เพิ่ม dependencies สำหรับ form + validation

โพสต์นี้เราเพิ่ม 3 ตัว (เวอร์ชันตรงกับโปรเจกต์จริง):

npm install react-hook-form@7 zod@4 @hookform/resolvers
  • react-hook-form — จัดการ state ของ form ให้ re-render น้อยที่สุด (uncontrolled inputs)
  • zod — กำหนดกฎ validation แบบ type-safe
  • @hookform/resolvers — กาวเชื่อม Zod schema เข้ากับ React Hook Form

ส่วน toast กับ confirm dialog เรา เขียนเอง ด้วย Zustand (ที่มีอยู่แล้ว) ไม่ได้ลง sonner หรือ library อื่น — จะได้คุม UX ได้เต็มที่และไม่เพิ่ม dependency โดยไม่จำเป็น


Step 2: DataTable Component — หัวใจของระบบ

DataTable เปรียบเสมือน Excel ที่ฝังอยู่ใน app — มันต้อง sort, select หลายแถว, แสดง loading/error/empty ได้ และที่สำคัญ reusable ทุกหน้า ด้วย TypeScript generics <T>

เริ่มจากนิยาม Column<T> ที่บอกว่าแต่ละคอลัมน์ render ยังไง:

// src/components/data-table/types.ts
import type { ReactNode } from 'react'

export interface Column<T> {
  /** key เสถียร ใช้เป็น sort field ที่ส่งไป backend ด้วยถ้า sortable */
  key: string
  header: ReactNode
  /** ตัว render cell — รับทั้ง row เลยเอาหลายฟิลด์มาประกอบได้ */
  cell: (row: T) => ReactNode
  sortable?: boolean
  className?: string
}

export type SortDirection = 'asc' | 'desc'
export interface SortState {
  field: string
  direction: SortDirection
}

ตัว DataTable<T> รับ rows + columns + callback สำหรับ sort/select แล้วจัดการ loading/error/empty ให้ครบ:

// src/components/data-table/DataTable.tsx (ตัดมาเฉพาะ signature + กลไกหลัก)
export function DataTable<T>({
  columns,
  rows,
  rowKey,
  isLoading,
  error,
  onRetry,
  sort,
  onSortChange,
  selectedKeys,        // มาคู่กับ onSelectionChange → โชว์ checkbox column
  onSelectionChange,
  onRowClick,
}: DataTableProps<T>) {
  const selectable = Boolean(selectedKeys && onSelectionChange)

  function toggleSort(field: string) {
    if (!onSortChange) return
    if (sort?.field === field) {
      // คลิกซ้ำ → สลับทิศ asc ↔ desc
      onSortChange({ field, direction: sort.direction === 'asc' ? 'desc' : 'asc' })
    } else {
      onSortChange({ field, direction: 'asc' })
    }
  }

  // จัดการ 3 state ก่อนถึงตาราง: loading → error → empty
  if (isLoading) return <LoadingState />
  if (error) return <ErrorState message={error} onRetry={onRetry} />
  if (rows.length === 0) return <StateBlock title="No results" />

  // ... render <table> พร้อม checkbox column ถ้า selectable
}

WHY generic <T>? เพราะหน้า Devices, Users, Alert rules ใช้ table ตัวเดียวกันได้หมด แค่เปลี่ยน columns กับ rowKey ที่ส่งเข้าไป — เขียนครั้งเดียว แก้บั๊กที่เดียว แก้ทุกที่ และ TypeScript จะ infer type ของ row ใน cell() ให้อัตโนมัติ ไม่มี any

จุดเด่นอีกอย่างคือ checkbox “select all” ที่ดูว่าทุกแถวถูกเลือกหรือยัง — รองรับ bulk action สไตล์ Gmail:

function toggleAll() {
  if (!selectedKeys || !onSelectionChange) return
  const allKeys = rows.map(rowKey)
  const allSelected = allKeys.every((k) => selectedKeys.has(k))
  onSelectionChange(allSelected ? new Set() : new Set(allKeys))
}

ส่วน Pagination เป็นตัวแยก ที่ match contract ของ backend แบบ offset/limit (ไม่ใช่ page number):

// src/components/data-table/Pagination.tsx
export function Pagination({ total, limit, offset, onOffsetChange }: PaginationProps) {
  const from = total === 0 ? 0 : offset + 1
  const to = Math.min(offset + limit, total)
  const canPrev = offset > 0
  const canNext = offset + limit < total
  // โชว์ "1–20 of 137" + ปุ่ม Prev/Next ที่ขยับ offset ทีละ limit
}

Step 3: Confirm Dialog + Toast แบบ Imperative ด้วย Zustand

เคยเห็นไหม บางระบบกด Delete ปุ๊บหายเลย ไม่ถามอะไร ผู้ใช้ตกใจมาก… นั่นคือ UX ที่แย่

Confirm Dialog เหมือน แคชเชียร์ที่ถามว่า “ยืนยันการชำระเงินไหม?” ก่อนหักเงิน ป้องกันความผิดพลาดที่แก้ไม่ได้ แต่เราอยากเรียกมันแบบ imperativeawait confirm({...}) แล้วได้ boolean กลับมา ไม่ต้องวาง state เปิด/ปิด modal ในทุกหน้า

ทริคคือเก็บ resolve ของ Promise ไว้ใน Zustand store:

// src/stores/confirm.ts
export const useConfirmStore = create<ConfirmState>((set, get) => ({
  options: null,
  open: (options) =>
    new Promise<boolean>((resolve) => {
      set({ options: { ...options, resolve } })
    }),
  close: (ok) => {
    const current = get().options
    current?.resolve(ok)   // ปลด Promise ที่ await อยู่
    set({ options: null })
  },
}))

/** เรียกได้จากที่ไหนก็ได้: await confirm({ title, message }) */
export function confirm(options: ConfirmOptions): Promise<boolean> {
  return useConfirmStore.getState().open(options)
}

แล้ววาง host component (ConfirmDialogHost) ไว้ที่ root ครั้งเดียว มันคอยฟัง store แล้ว render modal เมื่อมี options

Toast ก็แนวเดียวกัน — store เก็บ list ของ toast + auto-dismiss แล้ว expose helper toast.success(...) / toast.error(...) เรียกจาก mutation callback ได้เลย:

// src/stores/toast.ts
export const toast = {
  success: (message: string) => useToastStore.getState().push('success', message),
  error: (message: string) => useToastStore.getState().push('error', message),
  info: (message: string) => useToastStore.getState().push('info', message),
}

วาง <Toaster /> กับ <ConfirmDialogHost /> ไว้ใน App.tsx ครั้งเดียว ใช้ได้ทั้ง app:

export function App() {
  return (
    <QueryProvider>
      <RouterProvider router={router} />
      <Toaster />
      <ConfirmDialogHost />
    </QueryProvider>
  )
}

WHY ไม่ใช้ library? เพราะ pattern นี้แค่ ~30 บรรทัด/ตัว แถมคุม UX ได้เต็มที่ และ Zustand ก็มีอยู่แล้ว — ไม่ต้องลากของใหม่เข้ามาให้ bundle หนัก


Step 4: Zod Schema ที่ Mirror Backend — คู่หูที่แยกกันไม่ออก

ทำไมต้องใช้ทั้ง React Hook Form + Zod? มาดูกัน:

React Hook Form  =  จัดการ state ของ form ให้ re-render น้อยที่สุด
Zod              =  กำหนดกฎ validation แบบ type-safe

เปรียบเหมือน:
React Hook Form  =  แบบฟอร์มสมัครสมาชิก
Zod              =  พนักงานที่ตรวจว่ากรอกครบและถูกต้องไหม

จุดที่สำคัญที่สุดคือ — schema ฝั่ง client ต้อง mirror validation ฝั่ง backend (backend/internal/model/device_request.go) เป๊ะ เพื่อให้ user ได้ feedback เร็ว และ backend ยังเป็น source of truth

src/features/devices/schema.ts:

import { z } from 'zod'
import { DEVICE_STATUSES, DEVICE_TYPES } from '../../api/types'

// device_id: required, min=3, max=128, alphanumdash ([A-Za-z0-9_-])
const deviceIdField = z
  .string()
  .min(3, 'must be at least 3 characters')
  .max(128, 'must be at most 128 characters')
  .regex(
    /^[A-Za-z0-9_-]+$/,
    'may contain only letters, digits, hyphens and underscores',
  )

export const deviceFormSchema = z.object({
  device_id: deviceIdField,
  name: z.string().min(1, 'is required').max(200, 'must be at most 200 characters'),
  description: z.string().max(1000).or(z.literal('')),
  type: z.enum(DEVICE_TYPES as [string, ...string[]]),
  status: z.enum(DEVICE_STATUSES as [string, ...string[]]),
  group_id: z
    .string()
    .regex(/^[0-9a-fA-F]{24}$/, 'must be a 24-character hex id')
    .or(z.literal('')),
  // form เก็บ tags เป็น comma-separated string เพื่อความง่าย แล้ว parse ตอน submit
  tags: z.string(),
  enabled: z.boolean(),
})

export type DeviceFormValues = z.infer<typeof deviceFormSchema>

สังเกตว่า comment ในโค้ดอ้างอิงกฎของ backend ตรงๆ เช่น group_id ต้องเป็น 24-char hex (Mongo ObjectID) — เพราะมันต้องตรงกับที่ backend จะ accept ไม่งั้น user กรอกผ่าน client แต่โดน backend ปัด

เกร็ด: type DeviceFormValues ได้มาจาก z.infer<typeof ...> ฟรีๆ — schema เดียว ได้ทั้ง validation runtime และ type compile-time


Step 5: Device Form — Modal เดียว ทำได้ทั้ง Create และ Edit

DeviceForm เป็น modal ที่ดูจาก prop device ว่ากำลัง create หรือ edit — ใช้ component เดียว ไม่ต้องเขียนซ้ำ

// src/features/devices/DeviceForm.tsx (ตัดมาเฉพาะแกนหลัก)
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { deviceFormSchema, type DeviceFormValues } from './schema'
import { useCreateDevice, useUpdateDevice } from './queries'

export function DeviceForm({ open, onClose, device }: DeviceFormProps) {
  const isEdit = Boolean(device)
  const create = useCreateDevice()
  const update = useUpdateDevice()

  const {
    register,
    handleSubmit,
    reset,
    setError,
    formState: { errors, isSubmitting },
  } = useForm<DeviceFormValues>({
    resolver: zodResolver(deviceFormSchema),
    values: defaults(device),   // เติมค่าเดิมอัตโนมัติเมื่อ edit
  })

  async function onSubmit(values: DeviceFormValues) {
    const tagError = validateTags(values.tags) // ตรวจ tag ทีละตัวเพิ่ม
    if (tagError) {
      setError('tags', { message: tagError })
      return
    }
    const tags = parseTags(values.tags)        // "a, b" → ["a","b"]

    if (isEdit && device) {
      await update.mutateAsync({ id: device.id, body: { ...values, tags } })
    } else {
      await create.mutateAsync({ ...values, tags: tags.length ? tags : undefined })
    }
    reset()
    onClose()
  }
  // ... <Modal> + <form> ที่ register ทุกฟิลด์ + โชว์ FieldError
}

จุดเด่น:

  • device_id disabled ตอน edit — เพราะ backend ห้ามเปลี่ยน device ID หลังสร้างแล้ว (immutable)
  • status disabled ตอน create — device ใหม่เริ่มที่ offline เสมอ แก้ตอน edit ได้
  • map server-side error กลับมาที่ฟิลด์ — ถ้า backend ส่ง fields มาใน ApiError ก็แปะกลับลง input ได้

Trick เด็ด: ใช้ values: defaults(device) (ไม่ใช่ defaultValues) ทำให้พอ prop device เปลี่ยน form reset เติมค่าใหม่ให้อัตโนมัติ — เปิด edit แถวไหน ฟอร์มก็เด้งค่าของแถวนั้นมาให้เลย


Step 6: Mutations + Cache Invalidation

หัวใจที่ทำให้ table refresh เองคือ mutation hooks ที่ invalidateQueries หลังสำเร็จ:

// src/features/devices/queries.ts
export function useCreateDevice() {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: (body: CreateDeviceRequest) => api.createDevice(body),
    onSuccess: (device) => {
      void qc.invalidateQueries({ queryKey: deviceKeys.all }) // table refetch เอง
      toast.success(`Device “${device.name}” created`)
    },
    onError: (err) => reportMutationError(err, 'Failed to create device'),
  })
}

export function useDeleteDevice() {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: (id: string) => api.deleteDevice(id),
    onSuccess: () => void qc.invalidateQueries({ queryKey: deviceKeys.all }),
    onError: (err) => reportMutationError(err, 'Failed to delete device'),
  })
}

WHY invalidate แทนการแก้ cache เอง? เพราะ invalidate ทำให้ TanStack Query ไป refetch ของจริงจาก backend — เลยไม่มีปัญหาข้อมูลในจอกับใน DB ไม่ตรงกัน ง่ายและถูกต้องกว่าการ patch cache ด้วยมือ

ส่วน endpoint ฝั่ง client ก็ตรงไปตรงมา — แตะ /api/v1/devices ผ่าน fetch client ที่เขียนไว้โพสต์ก่อน:

// src/api/endpoints.ts (เพิ่มเข้ามาในโพสต์นี้)
async createDevice(body: CreateDeviceRequest): Promise<Device> {
  const env = await request<DataEnvelope<Device>>('/devices', { method: 'POST', body })
  return env.data
},
async updateDevice(id: string, body: UpdateDeviceRequest): Promise<Device> {
  const env = await request<DataEnvelope<Device>>(
    `/devices/${encodeURIComponent(id)}`, { method: 'PUT', body })
  return env.data
},
async deleteDevice(id: string): Promise<void> {
  await request<void>(`/devices/${encodeURIComponent(id)}`, { method: 'DELETE' })
},

Step 7: Device Management Page — รวมทุกอย่างเข้าด้วยกัน

นี่คือหน้าหลักที่ประกอบ DataTable + filters + bulk actions + form + confirm เข้าด้วยกัน

Analogy: หน้านี้เหมือน แผงควบคุมในโรงงาน ที่มีทั้งจอแสดงสถานะ, ปุ่มควบคุม, และ alarm เตือน

จุดสำคัญในหน้านี้:

1. RBAC ฝั่ง UI — ปุ่ม mutate (New / Edit / Delete) โผล่เฉพาะ role operator ขึ้นไป:

const role = useAuthStore((s) => s.user?.role ?? null)
const canMutate = roleAtLeast(role, 'operator')
// ...
actions={canMutate ? <Button onClick={openCreate}>New device</Button> : undefined}

2. Debounced search — ไม่ยิง request ทุกตัวอักษร ใช้ hook useDebounced หน่วง 300ms:

const [search, setSearch] = useState('')
const debouncedSearch = useDebounced(search, 300)

3. Filter + sort → แปลงเป็น query params ที่ backend เข้าใจ สังเกต sort ใช้ prefix - แทน desc (Mongo-style):

const params = useMemo(() => ({
  search: debouncedSearch || undefined,
  status: statusFilter || undefined,
  type: typeFilter || undefined,
  sort: `${sort.direction === 'desc' ? '-' : ''}${sort.field}`, // เช่น "-created_at"
  limit: PAGE_SIZE,
  offset,
}), [debouncedSearch, statusFilter, typeFilter, sort, offset])

const { data, isLoading, error, refetch } = useDevicesQuery(params)

4. Bulk delete — ลบหลายแถวพร้อมกันด้วย Promise.allSettled (ไม่ให้แถวนึง fail แล้วล้มทั้งหมด):

async function onBulkDelete() {
  const ids = Array.from(selected)
  const ok = await confirm({
    title: 'Delete devices',
    message: `Delete ${ids.length} selected device(s)? This cannot be undone.`,
    confirmLabel: `Delete ${ids.length}`,
    tone: 'danger',
  })
  if (!ok) return
  const results = await Promise.allSettled(ids.map((id) => deleteDevice.mutateAsync(id)))
  const okCount = results.filter((r) => r.status === 'fulfilled').length
  const failed = results.length - okCount
  if (okCount > 0) toast.success(`Deleted ${okCount} device(s)`)
  if (failed > 0) toast.error(`Failed to delete ${failed} device(s)`)
  setSelected(new Set())
}

columns นิยามด้วย cell: (d) => ... ที่ render badge สี, มีคอลัมน์ actions (Power = toggle maintenance, Edit, Delete) เฉพาะตอน canMutate


Step 8: Alert Rule CRUD — validation ตาม type ด้วย superRefine

Alert rule ซับซ้อนกว่า device นิดหน่อย เพราะ field ที่ “จำเป็น” ขึ้นกับ type ของ rule:

  • threshold → ต้องมี metric + operator
  • offline → ต้องมี offline_after_seconds > 0
  • anomaly → ต้องมี metric + zscore_threshold > 0

Zod จัดการเงื่อนไขข้าม-field แบบนี้ด้วย .superRefine() — สะท้อน service.validateCondition ของ backend เป๊ะ:

// src/features/alertRules/schema.ts (ส่วน superRefine)
export const alertRuleFormSchema = z
  .object({
    name: z.string().min(1, 'is required').max(200),
    type: ruleTypeSchema,
    severity: severitySchema,
    device_id: deviceIdField.or(z.literal('')),
    cooldown_seconds: z.number().int().min(0).max(86400),
    metric: z.string().max(64).or(z.literal('')),
    operator: operatorSchema.or(z.literal('')),
    value: z.number(),
    offline_after_seconds: z.number().int().min(0).max(604800),
    zscore_threshold: z.number().min(0).max(100),
    // ... description
  })
  .superRefine((data, ctx) => {
    if (data.type === 'threshold') {
      if (!metricField.safeParse(data.metric).success)
        ctx.addIssue({ code: 'custom', path: ['metric'],
          message: 'threshold rules require a metric' })
      if (data.operator === '')
        ctx.addIssue({ code: 'custom', path: ['operator'],
          message: 'threshold rules require an operator' })
    }
    if (data.type === 'offline' && data.offline_after_seconds <= 0) {
      ctx.addIssue({ code: 'custom', path: ['offline_after_seconds'],
        message: 'offline rules require a positive window' })
    }
    if (data.type === 'anomaly') {
      if (!metricField.safeParse(data.metric).success)
        ctx.addIssue({ code: 'custom', path: ['metric'],
          message: 'anomaly rules require a metric' })
      if (data.zscore_threshold <= 0)
        ctx.addIssue({ code: 'custom', path: ['zscore_threshold'],
          message: 'anomaly rules require a positive z-score threshold' })
    }
  })

WHY mirror backend ขนาดนี้? เพราะ alert rule มันคือหัวใจของ monitoring — ถ้า rule ผิดเงื่อนไข มันจะไม่ยิง alert ตอนที่ควรจะยิง การ validate ตรงกันทั้ง 2 ฝั่งทำให้ user รู้ทันทีตอนกรอก ไม่ใช่ไปรู้ตอน production เงียบกริบ

หน้า AlertRulesPage ก็ใช้ DataTable + AlertRuleForm + mutation hooks ชุดเดียวกับ devices — นี่แหละพลังของการทำ component แบบ reusable ตั้งแต่ Step 2


มาดูของจริงกัน!

พูดมาเยอะแล้ว มาดูผลลัพธ์จริงกันดีกว่า — รูปทั้งหมดนี้พี่ถ่ายจาก E2E test ที่รันด้วย Playwright ยิงไปที่ backend จริง (Go + MongoDB) เลยนะ ข้อมูลในตารางคือ device ที่ seed เข้าไปจริงๆ ไม่ใช่ mock

หน้า Devices — DataTable ที่เราอุตส่าห์ทำมาทั้งโพสต์ มี search, filter ตาม status/type, sort ได้ทุกคอลัมน์ แถม status badge สีสวยแยก online / offline / error / maintenance ชัดเจน:

หน้ารายการ Devices — ตารางแสดง device 8 ตัว พร้อม status badge และปุ่ม action

กดปุ่ม New device ก็เด้ง modal ฟอร์มขึ้นมา พร้อม Zod validation ทุก field — device_id, name, type, group_id, tags, description ครบ (สังเกต status ถูก disable ไว้ เพราะ device ใหม่เริ่ม offline เสมอ):

ฟอร์มสร้าง device ใหม่ — modal พร้อมช่องกรอกและ validation

กดปุ่มดินสอ (Edit) ที่แถวไหน ฟอร์มเดียวกันก็เปิดขึ้นมาพร้อมเติมข้อมูลเดิมของ device นั้นให้อัตโนมัติ — create กับ edit ใช้ component เดียวกัน ไม่ต้องเขียนซ้ำ (สังเกต device_id ถูก disable เพราะ immutable):

ฟอร์มแก้ไข device — modal เติมข้อมูลเดิมของ Lobby Temp/Humidity ไว้ล่วงหน้า


สรุปสิ่งที่เราทำวันนี้

  ____  _   _ __  __ __  __    _    ______   __
 / ___|| | | |  \/  |  \/  |  / \  |  _ \ \ / /
 \___ \| | | | |\/| | |\/| | / _ \ | |_) \ V /
  ___) | |_| | |  | | |  | |/ ___ \|  _ < | |
 |____/ \___/|_|  |_|_|  |_/_/   \_\_| \_\|_|

  Step-16 DONE! (ง'̀-'́)ง
Feature ไฟล์จริง หมายเหตุ
DataTable data-table/DataTable.tsx generic <T>, sort, select, loading/error/empty
Pagination data-table/Pagination.tsx offset/limit ตรง backend contract
Confirm stores/confirm.ts + ConfirmDialog.tsx imperative await confirm(...)
Toast stores/toast.ts + Toaster.tsx imperative toast.success/error
Device Form features/devices/DeviceForm.tsx RHF + Zod, create/edit ตัวเดียว
Alert Rule Form features/alertRules/schema.ts superRefine ตาม type
Mutations features/*/queries.ts invalidateQueries + toast

Pattern ที่เราใช้และทำไมถึงเลือก

  • TanStack Query mutations — จัดการ server state + cache invalidation ไม่ต้อง manage loading/error เอง
  • Zod ที่ mirror backend — schema ฝั่ง client สะท้อนกฎ backend (รวม superRefine ตาม type) user ได้ feedback เร็ว backend ยังเป็น source of truth
  • Imperative toast/confirm — เขียนเองด้วย Zustand ~30 บรรทัด คุม UX ได้เต็มที่ ไม่ลาก library

Next Step

Workshop นี้ครบแล้ว! ตอนหน้าเราจะไปสร้าง Monitoring Dashboard แบบ real-time — มี overview cards, device status grid ที่ “ติดไฟ” เมื่อ live data เข้ามาทาง WebSocket, และกราฟ readings จาก InfluxDB ด้วย Recharts

ถ้าติดตรงไหนทักพี่โชว์มาได้เลยนะ มาลุยกัน! (^_^)/