IoT Admin CRUD: ตารางข้อมูลแบบครบเครื่อง
IoT Admin CRUD: ตารางข้อมูลแบบครบเครื่อง
Branch:
step-16-admin-crudPhase: 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 เหมือน แคชเชียร์ที่ถามว่า “ยืนยันการชำระเงินไหม?” ก่อนหักเงิน ป้องกันความผิดพลาดที่แก้ไม่ได้ แต่เราอยากเรียกมันแบบ imperative — await 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_iddisabled ตอน edit — เพราะ backend ห้ามเปลี่ยน device ID หลังสร้างแล้ว (immutable)statusdisabled ตอน create — device ใหม่เริ่มที่offlineเสมอ แก้ตอน edit ได้- map server-side error กลับมาที่ฟิลด์ — ถ้า backend ส่ง
fieldsมาในApiErrorก็แปะกลับลง input ได้
Trick เด็ด: ใช้
values: defaults(device)(ไม่ใช่defaultValues) ทำให้พอ propdeviceเปลี่ยน 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+operatoroffline→ ต้องมีoffline_after_seconds> 0anomaly→ ต้องมี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 ชัดเจน:

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

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

สรุปสิ่งที่เราทำวันนี้
____ _ _ __ __ __ __ _ ______ __
/ ___|| | | | \/ | \/ | / \ | _ \ \ / /
\___ \| | | | |\/| | |\/| | / _ \ | |_) \ 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
ถ้าติดตรงไหนทักพี่โชว์มาได้เลยนะ มาลุยกัน! (^_^)/