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

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

Showkhun · Workshop ·

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

Branch: workshop/dev-16-admin-crud Phase: Development (16/21) Repo: kangana1024/iot-workshop


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

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


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

  • ทำไมต้อง DataTable แบบ reusable (ไม่ใช่แค่ <table> ธรรมดา)
  • ทำไม Zod + React Hook Form ถึงเปลี่ยนชีวิตการทำ form
  • Pattern การจัดการ state ใน admin page แบบโปร
  • Bulk actions — ลบทีเดียวหลายอัน เหมือน Gmail
  • Toast + Confirm Dialog — UX ที่ user ไม่งง

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

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

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

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

แนวคิดหลัก:

  [User Action]  →  [TanStack Query]  →  [API]  →  [Toast]
       ↓                  ↓                            ↓
  [Confirm?]    →   [Mutation]    →  [Invalidate Cache]

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

Mermaid Diagram


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

DataTable เปรียบเสมือน Excel ที่ฝังอยู่ใน app ของเรา มันต้องทำได้ทุกอย่างที่ Excel ทำ: เรียง, กรอง, เลือกหลายแถว, เปลี่ยนหน้า

เหตุผลที่สร้าง reusable component แทนที่จะเขียนทุกหน้าใหม่:

  • เขียนครั้งเดียว ใช้ทุกหน้า
  • แก้บั๊กที่เดียว แก้ทุกที่
  • Type-safe ด้วย TypeScript generics <T>

สร้าง src/components/ui/DataTable.tsx:

import { useState, useMemo } from 'react'
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
import { clsx } from 'clsx'

export type SortDirection = 'asc' | 'desc' | null

export interface Column<T> {
  key: keyof T | string
  header: string
  sortable?: boolean
  render?: (value: unknown, row: T) => React.ReactNode
  className?: string
  headerClassName?: string
}

export interface DataTableProps<T> {
  data: T[]
  columns: Column<T>[]
  keyField: keyof T
  loading?: boolean
  selectable?: boolean
  selectedRows?: Set<string>
  onRowSelect?: (id: string) => void
  onSelectAll?: (ids: string[]) => void
  emptyMessage?: string
  pagination?: {
    page: number
    limit: number
    total: number
    onPageChange: (page: number) => void
    onLimitChange: (limit: number) => void
  }
  onSort?: (key: string, direction: SortDirection) => void
}

export function DataTable<T extends Record<string, unknown>>({
  data,
  columns,
  keyField,
  loading = false,
  selectable = false,
  selectedRows = new Set(),
  onRowSelect,
  onSelectAll,
  emptyMessage = 'ไม่พบข้อมูล',
  pagination,
  onSort,
}: DataTableProps<T>) {
  const [sortKey, setSortKey] = useState<string | null>(null)
  const [sortDirection, setSortDirection] = useState<SortDirection>(null)

  const handleSort = (key: string) => {
    if (!onSort) return

    let newDirection: SortDirection = 'asc'
    if (sortKey === key) {
      if (sortDirection === 'asc') newDirection = 'desc'
      else if (sortDirection === 'desc') newDirection = null
    }

    setSortKey(newDirection ? key : null)
    setSortDirection(newDirection)
    onSort(key, newDirection)
  }

  const allIds = useMemo(() => data.map((row) => String(row[keyField])), [data, keyField])
  const isAllSelected = allIds.length > 0 && allIds.every((id) => selectedRows.has(id))
  const isPartialSelected = allIds.some((id) => selectedRows.has(id)) && !isAllSelected

  const LIMIT_OPTIONS = [10, 25, 50, 100]

  return (
    <div className="flex flex-col gap-4">
      <div className="overflow-x-auto rounded-xl border border-gray-200 bg-white">
        <table className="w-full text-sm">
          <thead>
            <tr className="border-b border-gray-200 bg-gray-50">
              {selectable && (
                <th className="w-12 px-4 py-3 text-left">
                  <input
                    type="checkbox"
                    checked={isAllSelected}
                    ref={(el) => {
                      if (el) el.indeterminate = isPartialSelected
                    }}
                    onChange={() => onSelectAll?.(isAllSelected ? [] : allIds)}
                    className="w-4 h-4 rounded border-gray-300 text-primary-600 cursor-pointer"
                  />
                </th>
              )}
              {columns.map((col) => (
                <th
                  key={String(col.key)}
                  className={clsx(
                    'px-4 py-3 text-left font-medium text-gray-600',
                    col.sortable && 'cursor-pointer select-none hover:text-gray-900',
                    col.headerClassName
                  )}
                  onClick={() => col.sortable && handleSort(String(col.key))}
                >
                  <div className="flex items-center gap-1">
                    {col.header}
                    {col.sortable && (
                      <span className="text-gray-400">
                        {sortKey === String(col.key) ? (
                          sortDirection === 'asc' ? (
                            <ChevronUp className="w-4 h-4" />
                          ) : (
                            <ChevronDown className="w-4 h-4" />
                          )
                        ) : (
                          <ChevronsUpDown className="w-4 h-4" />
                        )}
                      </span>
                    )}
                  </div>
                </th>
              ))}
            </tr>
          </thead>
          <tbody className="divide-y divide-gray-100">
            {loading ? (
              <tr>
                <td
                  colSpan={columns.length + (selectable ? 1 : 0)}
                  className="px-4 py-12 text-center text-gray-500"
                >
                  <div className="flex items-center justify-center gap-2">
                    <div className="w-5 h-5 border-2 border-primary-600 border-t-transparent rounded-full animate-spin" />
                    <span>กำลังโหลด...</span>
                  </div>
                </td>
              </tr>
            ) : data.length === 0 ? (
              <tr>
                <td
                  colSpan={columns.length + (selectable ? 1 : 0)}
                  className="px-4 py-12 text-center text-gray-500"
                >
                  {emptyMessage}
                </td>
              </tr>
            ) : (
              data.map((row) => {
                const id = String(row[keyField])
                const isSelected = selectedRows.has(id)

                return (
                  <tr
                    key={id}
                    className={clsx(
                      'transition-colors',
                      isSelected ? 'bg-primary-50' : 'hover:bg-gray-50'
                    )}
                  >
                    {selectable && (
                      <td className="w-12 px-4 py-3">
                        <input
                          type="checkbox"
                          checked={isSelected}
                          onChange={() => onRowSelect?.(id)}
                          className="w-4 h-4 rounded border-gray-300 text-primary-600 cursor-pointer"
                        />
                      </td>
                    )}
                    {columns.map((col) => {
                      const value = row[col.key as keyof T]
                      return (
                        <td
                          key={String(col.key)}
                          className={clsx('px-4 py-3 text-gray-700', col.className)}
                        >
                          {col.render ? col.render(value, row) : String(value ?? '-')}
                        </td>
                      )
                    })}
                  </tr>
                )
              })
            )}
          </tbody>
        </table>
      </div>

      {/* Pagination */}
      {pagination && (
        <div className="flex items-center justify-between">
          <div className="flex items-center gap-2 text-sm text-gray-600">
            <span>แสดง</span>
            <select
              value={pagination.limit}
              onChange={(e) => pagination.onLimitChange(Number(e.target.value))}
              className="border border-gray-300 rounded px-2 py-1 text-sm"
            >
              {LIMIT_OPTIONS.map((opt) => (
                <option key={opt} value={opt}>{opt}</option>
              ))}
            </select>
            <span>
              จาก {pagination.total.toLocaleString()} รายการ
            </span>
          </div>

          <div className="flex items-center gap-1">
            <button
              onClick={() => pagination.onPageChange(1)}
              disabled={pagination.page === 1}
              className="px-2 py-1 rounded border border-gray-300 text-sm disabled:opacity-40 hover:bg-gray-50"
            >
              «
            </button>
            <button
              onClick={() => pagination.onPageChange(pagination.page - 1)}
              disabled={pagination.page === 1}
              className="px-2 py-1 rounded border border-gray-300 text-sm disabled:opacity-40 hover:bg-gray-50"
            >

            </button>

            {/* Page Numbers */}
            {Array.from(
              { length: Math.min(5, Math.ceil(pagination.total / pagination.limit)) },
              (_, i) => {
                const totalPages = Math.ceil(pagination.total / pagination.limit)
                let pageNum: number
                if (totalPages <= 5) {
                  pageNum = i + 1
                } else if (pagination.page <= 3) {
                  pageNum = i + 1
                } else if (pagination.page >= totalPages - 2) {
                  pageNum = totalPages - 4 + i
                } else {
                  pageNum = pagination.page - 2 + i
                }
                return (
                  <button
                    key={pageNum}
                    onClick={() => pagination.onPageChange(pageNum)}
                    className={clsx(
                      'px-3 py-1 rounded border text-sm',
                      pagination.page === pageNum
                        ? 'bg-primary-600 text-white border-primary-600'
                        : 'border-gray-300 hover:bg-gray-50'
                    )}
                  >
                    {pageNum}
                  </button>
                )
              }
            )}

            <button
              onClick={() =>
                pagination.onPageChange(pagination.page + 1)
              }
              disabled={pagination.page >= Math.ceil(pagination.total / pagination.limit)}
              className="px-2 py-1 rounded border border-gray-300 text-sm disabled:opacity-40 hover:bg-gray-50"
            >

            </button>
            <button
              onClick={() =>
                pagination.onPageChange(Math.ceil(pagination.total / pagination.limit))
              }
              disabled={pagination.page >= Math.ceil(pagination.total / pagination.limit)}
              className="px-2 py-1 rounded border border-gray-300 text-sm disabled:opacity-40 hover:bg-gray-50"
            >
              »
            </button>
          </div>
        </div>
      )}
    </div>
  )
}

Tip จากพี่โชว์: สังเกต indeterminate state ของ checkbox ไหม? นั่นคือสถานะ “เลือกบางส่วน” ที่ Gmail ใช้ ทำให้ UX ดูโปรขึ้นมากเลย ✨


Step 2: Confirmation Dialog — “แน่ใจแล้วนะ?”

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

Confirmation Dialog เหมือน พนักงานแคชเชียร์ที่ถามว่า “ยืนยันการชำระเงินไหม?” ก่อนหักเงิน ป้องกันความผิดพลาดที่แก้ไม่ได้

สร้าง src/components/ui/ConfirmDialog.tsx:

import { AlertTriangle, X } from 'lucide-react'
import { clsx } from 'clsx'

interface ConfirmDialogProps {
  open: boolean
  title: string
  description: string
  confirmLabel?: string
  cancelLabel?: string
  variant?: 'danger' | 'warning' | 'default'
  isLoading?: boolean
  onConfirm: () => void
  onCancel: () => void
}

export function ConfirmDialog({
  open,
  title,
  description,
  confirmLabel = 'ยืนยัน',
  cancelLabel = 'ยกเลิก',
  variant = 'danger',
  isLoading = false,
  onConfirm,
  onCancel,
}: ConfirmDialogProps) {
  if (!open) return null

  const iconColors = {
    danger: 'text-red-500 bg-red-50',
    warning: 'text-yellow-500 bg-yellow-50',
    default: 'text-blue-500 bg-blue-50',
  }

  const confirmColors = {
    danger: 'bg-red-600 hover:bg-red-700 focus:ring-red-500',
    warning: 'bg-yellow-600 hover:bg-yellow-700 focus:ring-yellow-500',
    default: 'bg-primary-600 hover:bg-primary-700 focus:ring-primary-500',
  }

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center">
      {/* Backdrop */}
      <div
        className="absolute inset-0 bg-black/40 backdrop-blur-sm"
        onClick={onCancel}
      />

      {/* Dialog */}
      <div className="relative bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
        <button
          onClick={onCancel}
          className="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
        >
          <X className="w-5 h-5" />
        </button>

        <div className="flex items-start gap-4">
          <div className={clsx('p-2 rounded-full flex-shrink-0', iconColors[variant])}>
            <AlertTriangle className="w-6 h-6" />
          </div>

          <div className="flex-1 min-w-0">
            <h3 className="text-base font-semibold text-gray-900">{title}</h3>
            <p className="mt-1 text-sm text-gray-500">{description}</p>
          </div>
        </div>

        <div className="mt-6 flex justify-end gap-3">
          <button
            onClick={onCancel}
            disabled={isLoading}
            className="btn-secondary"
          >
            {cancelLabel}
          </button>
          <button
            onClick={onConfirm}
            disabled={isLoading}
            className={clsx(
              'btn text-white focus:outline-none focus:ring-2 focus:ring-offset-2',
              confirmColors[variant]
            )}
          >
            {isLoading ? (
              <span className="flex items-center gap-2">
                <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
                กำลังดำเนินการ...
              </span>
            ) : (
              confirmLabel
            )}
          </button>
        </div>
      </div>
    </div>
  )
}

Step 3: Device Management Page — หน้าจัดการอุปกรณ์

นี่คือหน้าหลักที่รวมทุกอย่างเข้าด้วยกัน ตั้งแต่ DataTable, Filters, Bulk Actions จนถึง ConfirmDialog

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

สร้าง src/pages/devices/DevicesPage.tsx:

import { useState, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus, Trash2, RefreshCw, Download, Filter } from 'lucide-react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { DataTable, type Column } from '@components/ui/DataTable'
import { ConfirmDialog } from '@components/ui/ConfirmDialog'
import { PageHeader } from '@components/common/PageHeader'
import { deviceService } from '@services/device.service'
import type { Device, DeviceFilter, DeviceStatus, DeviceType } from '@types/device.types'
import { ROUTES } from '@router/routes'

const STATUS_BADGE_COLORS: Record<DeviceStatus, string> = {
  online: 'bg-green-100 text-green-800',
  offline: 'bg-gray-100 text-gray-600',
  maintenance: 'bg-yellow-100 text-yellow-800',
  error: 'bg-red-100 text-red-800',
}

const STATUS_LABELS: Record<DeviceStatus, string> = {
  online: 'ออนไลน์',
  offline: 'ออฟไลน์',
  maintenance: 'บำรุงรักษา',
  error: 'ข้อผิดพลาด',
}

export default function DevicesPage() {
  const navigate = useNavigate()
  const queryClient = useQueryClient()

  // State
  const [filter, setFilter] = useState<DeviceFilter>({ page: 1, limit: 25 })
  const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set())
  const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
  const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
  const [searchInput, setSearchInput] = useState('')

  // Queries
  const { data, isLoading, refetch } = useQuery({
    queryKey: ['devices', filter],
    queryFn: () => deviceService.getDevices(filter),
  })

  // Mutations
  const deleteMutation = useMutation({
    mutationFn: deviceService.deleteDevice,
    onSuccess: () => {
      toast.success('ลบอุปกรณ์สำเร็จ')
      queryClient.invalidateQueries({ queryKey: ['devices'] })
      setDeleteTarget(null)
    },
    onError: () => {
      toast.error('ไม่สามารถลบอุปกรณ์ได้')
    },
  })

  const bulkDeleteMutation = useMutation({
    mutationFn: (ids: string[]) => deviceService.bulkDeleteDevices(ids),
    onSuccess: () => {
      toast.success(`ลบ ${selectedRows.size} อุปกรณ์สำเร็จ`)
      queryClient.invalidateQueries({ queryKey: ['devices'] })
      setSelectedRows(new Set())
      setShowBulkDeleteConfirm(false)
    },
    onError: () => {
      toast.error('ไม่สามารถลบอุปกรณ์ได้')
    },
  })

  // Handlers
  const handleSearch = useCallback(() => {
    setFilter((prev) => ({ ...prev, search: searchInput, page: 1 }))
  }, [searchInput])

  const handleRowSelect = (id: string) => {
    setSelectedRows((prev) => {
      const next = new Set(prev)
      if (next.has(id)) next.delete(id)
      else next.add(id)
      return next
    })
  }

  const handleSelectAll = (ids: string[]) => {
    setSelectedRows(new Set(ids))
  }

  const handleExportCSV = () => {
    if (!data?.data) return
    const headers = ['ID', 'ชื่อ', 'ประเภท', 'สถานะ', 'ตำแหน่ง', 'อัปเดตล่าสุด']
    const rows = data.data.map((d) => [
      d.deviceId, d.name, d.type, d.status, d.location,
      new Date(d.lastSeen).toLocaleString('th-TH'),
    ])
    const csv = [headers, ...rows].map((r) => r.join(',')).join('\n')
    const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' })
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = `devices-${new Date().toISOString().split('T')[0]}.csv`
    a.click()
    URL.revokeObjectURL(url)
    toast.success('ดาวน์โหลด CSV สำเร็จ')
  }

  // Columns definition
  const columns: Column<Device>[] = [
    {
      key: 'deviceId',
      header: 'Device ID',
      sortable: true,
      render: (value) => (
        <span className="font-mono text-xs bg-gray-100 px-2 py-0.5 rounded">
          {String(value)}
        </span>
      ),
    },
    {
      key: 'name',
      header: 'ชื่ออุปกรณ์',
      sortable: true,
      render: (value, row) => (
        <div>
          <p className="font-medium text-gray-900">{String(value)}</p>
          <p className="text-xs text-gray-500">{row.location}</p>
        </div>
      ),
    },
    {
      key: 'type',
      header: 'ประเภท',
      sortable: true,
      render: (value) => (
        <span className="badge badge-blue capitalize">{String(value)}</span>
      ),
    },
    {
      key: 'status',
      header: 'สถานะ',
      sortable: true,
      render: (value) => {
        const status = value as DeviceStatus
        return (
          <span className={`badge ${STATUS_BADGE_COLORS[status]}`}>
            {STATUS_LABELS[status]}
          </span>
        )
      },
    },
    {
      key: 'firmware',
      header: 'Firmware',
      render: (value) => (
        <span className="font-mono text-xs">{String(value)}</span>
      ),
    },
    {
      key: 'lastSeen',
      header: 'เห็นล่าสุด',
      sortable: true,
      render: (value) => (
        <span className="text-xs text-gray-500">
          {new Date(String(value)).toLocaleString('th-TH')}
        </span>
      ),
    },
    {
      key: 'id',
      header: 'จัดการ',
      headerClassName: 'text-right',
      className: 'text-right',
      render: (_, row) => (
        <div className="flex items-center justify-end gap-2">
          <button
            onClick={() => navigate(`/devices/${row.id}`)}
            className="text-xs text-primary-600 hover:text-primary-800 font-medium"
          >
            ดูรายละเอียด
          </button>
          <button
            onClick={() => navigate(`/devices/${row.id}/edit`)}
            className="text-xs text-gray-500 hover:text-gray-700 font-medium"
          >
            แก้ไข
          </button>
          <button
            onClick={() => setDeleteTarget(row.id)}
            className="text-xs text-red-500 hover:text-red-700 font-medium"
          >
            ลบ
          </button>
        </div>
      ),
    },
  ]

  return (
    <div className="space-y-6">
      <PageHeader
        title="จัดการอุปกรณ์"
        description={`อุปกรณ์ IoT ทั้งหมด ${data?.pagination.total ?? 0} เครื่อง`}
      >
        <div className="flex items-center gap-2">
          <button onClick={() => refetch()} className="btn-secondary">
            <RefreshCw className="w-4 h-4 mr-1.5" />
            รีเฟรช
          </button>
          <button onClick={handleExportCSV} className="btn-secondary">
            <Download className="w-4 h-4 mr-1.5" />
            Export CSV
          </button>
          <button
            onClick={() => navigate(ROUTES.DEVICE_CREATE)}
            className="btn-primary"
          >
            <Plus className="w-4 h-4 mr-1.5" />
            เพิ่มอุปกรณ์
          </button>
        </div>
      </PageHeader>

      {/* Filters */}
      <div className="card">
        <div className="flex flex-wrap items-center gap-3">
          <div className="flex-1 min-w-48">
            <input
              type="text"
              placeholder="ค้นหาชื่อหรือ ID..."
              value={searchInput}
              onChange={(e) => setSearchInput(e.target.value)}
              onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
              className="input"
            />
          </div>
          <select
            onChange={(e) =>
              setFilter((p) => ({
                ...p,
                status: (e.target.value as DeviceStatus) || undefined,
                page: 1,
              }))
            }
            className="input w-40"
          >
            <option value="">ทุกสถานะ</option>
            {Object.entries(STATUS_LABELS).map(([val, label]) => (
              <option key={val} value={val}>{label}</option>
            ))}
          </select>
          <select
            onChange={(e) =>
              setFilter((p) => ({
                ...p,
                type: (e.target.value as DeviceType) || undefined,
                page: 1,
              }))
            }
            className="input w-40"
          >
            <option value="">ทุกประเภท</option>
            <option value="sensor">Sensor</option>
            <option value="actuator">Actuator</option>
            <option value="gateway">Gateway</option>
            <option value="controller">Controller</option>
          </select>
          <button onClick={handleSearch} className="btn-primary">
            <Filter className="w-4 h-4 mr-1.5" />
            กรอง
          </button>
        </div>
      </div>

      {/* Bulk Actions */}
      {selectedRows.size > 0 && (
        <div className="flex items-center gap-3 px-4 py-3 bg-primary-50 border border-primary-200 rounded-xl">
          <span className="text-sm font-medium text-primary-700">
            เลือก {selectedRows.size} รายการ
          </span>
          <button
            onClick={() => setShowBulkDeleteConfirm(true)}
            className="btn-danger text-xs"
          >
            <Trash2 className="w-3.5 h-3.5 mr-1.5" />
            ลบทีเลือก
          </button>
          <button
            onClick={() => setSelectedRows(new Set())}
            className="text-sm text-primary-600 hover:text-primary-800"
          >
            ยกเลิกการเลือก
          </button>
        </div>
      )}

      {/* Table */}
      <DataTable
        data={data?.data ?? []}
        columns={columns}
        keyField="id"
        loading={isLoading}
        selectable
        selectedRows={selectedRows}
        onRowSelect={handleRowSelect}
        onSelectAll={handleSelectAll}
        emptyMessage="ไม่พบอุปกรณ์ที่ตรงกับเงื่อนไข"
        pagination={
          data
            ? {
                page: filter.page ?? 1,
                limit: filter.limit ?? 25,
                total: data.pagination.total,
                onPageChange: (page) => setFilter((p) => ({ ...p, page })),
                onLimitChange: (limit) => setFilter((p) => ({ ...p, limit, page: 1 })),
              }
            : undefined
        }
        onSort={(key, direction) => {
          console.log('Sort:', key, direction)
        }}
      />

      {/* Confirm Delete Single */}
      <ConfirmDialog
        open={!!deleteTarget}
        title="ลบอุปกรณ์"
        description="คุณแน่ใจหรือไม่ที่จะลบอุปกรณ์นี้? การกระทำนี้ไม่สามารถย้อนกลับได้"
        confirmLabel="ลบ"
        isLoading={deleteMutation.isPending}
        onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
        onCancel={() => setDeleteTarget(null)}
      />

      {/* Confirm Bulk Delete */}
      <ConfirmDialog
        open={showBulkDeleteConfirm}
        title={`ลบ ${selectedRows.size} อุปกรณ์`}
        description={`คุณแน่ใจหรือไม่ที่จะลบอุปกรณ์ที่เลือกทั้ง ${selectedRows.size} เครื่อง? การกระทำนี้ไม่สามารถย้อนกลับได้`}
        confirmLabel="ลบทั้งหมด"
        isLoading={bulkDeleteMutation.isPending}
        onConfirm={() => bulkDeleteMutation.mutate(Array.from(selectedRows))}
        onCancel={() => setShowBulkDeleteConfirm(false)}
      />
    </div>
  )
}

Step 4: Device Form + React Hook Form + Zod — คู่หูที่แยกกันไม่ออก

ทำไมต้องใช้ทั้งสองตัวด้วย? มาดูกัน:

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

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

ข้อดีสำคัญมาก: Schema Zod เดียวกันนี้ สามารถ share ไปใช้ที่ Backend ได้เลย ไม่ต้องเขียน validation ซ้ำสองที่!

สร้าง src/pages/devices/DeviceFormPage.tsx:

import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useNavigate, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { deviceService } from '@services/device.service'
import { PageHeader } from '@components/common/PageHeader'

// Zod schema สำหรับ validation
const deviceSchema = z.object({
  deviceId: z
    .string()
    .min(3, 'Device ID ต้องมีอย่างน้อย 3 ตัวอักษร')
    .max(50, 'Device ID ยาวเกินไป')
    .regex(/^[a-zA-Z0-9_-]+$/, 'Device ID ใช้ได้เฉพาะ a-z, A-Z, 0-9, _ และ -'),
  name: z.string().min(2, 'ชื่อต้องมีอย่างน้อย 2 ตัวอักษร').max(100),
  type: z.enum(['sensor', 'actuator', 'gateway', 'controller'], {
    errorMap: () => ({ message: 'กรุณาเลือกประเภท' }),
  }),
  location: z.string().min(2, 'กรุณาระบุตำแหน่ง').max(200),
  ipAddress: z
    .string()
    .optional()
    .refine(
      (val) =>
        !val ||
        /^(\d{1,3}\.){3}\d{1,3}$/.test(val),
      'IP Address ไม่ถูกต้อง'
    ),
  macAddress: z
    .string()
    .optional()
    .refine(
      (val) =>
        !val ||
        /^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/.test(val),
      'MAC Address ไม่ถูกต้อง (รูปแบบ: XX:XX:XX:XX:XX:XX)'
    ),
  firmware: z.string().optional(),
})

type DeviceFormValues = z.infer<typeof deviceSchema>

export default function DeviceFormPage() {
  const { id } = useParams<{ id?: string }>()
  const navigate = useNavigate()
  const queryClient = useQueryClient()
  const isEditing = Boolean(id)

  const {
    register,
    handleSubmit,
    reset,
    formState: { errors, isDirty, isSubmitting },
  } = useForm<DeviceFormValues>({
    resolver: zodResolver(deviceSchema),
    defaultValues: {
      type: 'sensor',
      firmware: '1.0.0',
    },
  })

  // โหลดข้อมูล device เมื่อแก้ไข
  const { data: deviceData } = useQuery({
    queryKey: ['device', id],
    queryFn: () => deviceService.getDevice(id!),
    enabled: isEditing,
  })

  // Reset form เมื่อได้ข้อมูล
  useEffect(() => {
    if (deviceData?.data) {
      const d = deviceData.data
      reset({
        deviceId: d.deviceId,
        name: d.name,
        type: d.type,
        location: d.location,
        ipAddress: d.ipAddress ?? '',
        macAddress: d.macAddress ?? '',
        firmware: d.firmware,
      })
    }
  }, [deviceData, reset])

  // Mutations
  const createMutation = useMutation({
    mutationFn: deviceService.createDevice,
    onSuccess: (data) => {
      toast.success('เพิ่มอุปกรณ์สำเร็จ')
      queryClient.invalidateQueries({ queryKey: ['devices'] })
      navigate(`/devices/${data.data.id}`)
    },
    onError: () => {
      toast.error('ไม่สามารถเพิ่มอุปกรณ์ได้')
    },
  })

  const updateMutation = useMutation({
    mutationFn: ({ id, input }: { id: string; input: DeviceFormValues }) =>
      deviceService.updateDevice(id, input),
    onSuccess: () => {
      toast.success('อัปเดตอุปกรณ์สำเร็จ')
      queryClient.invalidateQueries({ queryKey: ['devices'] })
      queryClient.invalidateQueries({ queryKey: ['device', id] })
      navigate(-1)
    },
    onError: () => {
      toast.error('ไม่สามารถอัปเดตอุปกรณ์ได้')
    },
  })

  const onSubmit = (values: DeviceFormValues) => {
    if (isEditing && id) {
      updateMutation.mutate({ id, input: values })
    } else {
      createMutation.mutate(values)
    }
  }

  return (
    <div className="max-w-2xl mx-auto space-y-6">
      <PageHeader
        title={isEditing ? 'แก้ไขอุปกรณ์' : 'เพิ่มอุปกรณ์ใหม่'}
        description={isEditing ? 'แก้ไขข้อมูลอุปกรณ์ IoT' : 'เพิ่มอุปกรณ์ IoT เข้าระบบ'}
        backLink="/devices"
      />

      <form onSubmit={handleSubmit(onSubmit)} className="card space-y-5">
        {/* Device ID */}
        <div>
          <label className="label">Device ID *</label>
          <input
            {...register('deviceId')}
            disabled={isEditing}
            placeholder="เช่น sensor-temp-01"
            className="input font-mono"
          />
          {errors.deviceId && (
            <p className="mt-1 text-xs text-red-500">{errors.deviceId.message}</p>
          )}
          {isEditing && (
            <p className="mt-1 text-xs text-gray-400">Device ID ไม่สามารถเปลี่ยนได้หลังจากสร้างแล้ว</p>
          )}
        </div>

        {/* Name */}
        <div>
          <label className="label">ชื่ออุปกรณ์ *</label>
          <input
            {...register('name')}
            placeholder="เช่น Temperature Sensor Floor 1"
            className="input"
          />
          {errors.name && (
            <p className="mt-1 text-xs text-red-500">{errors.name.message}</p>
          )}
        </div>

        {/* Type */}
        <div>
          <label className="label">ประเภทอุปกรณ์ *</label>
          <select {...register('type')} className="input">
            <option value="sensor">Sensor</option>
            <option value="actuator">Actuator</option>
            <option value="gateway">Gateway</option>
            <option value="controller">Controller</option>
          </select>
          {errors.type && (
            <p className="mt-1 text-xs text-red-500">{errors.type.message}</p>
          )}
        </div>

        {/* Location */}
        <div>
          <label className="label">ตำแหน่งติดตั้ง *</label>
          <input
            {...register('location')}
            placeholder="เช่น ชั้น 1 ห้อง Server"
            className="input"
          />
          {errors.location && (
            <p className="mt-1 text-xs text-red-500">{errors.location.message}</p>
          )}
        </div>

        {/* IP Address */}
        <div>
          <label className="label">IP Address</label>
          <input
            {...register('ipAddress')}
            placeholder="192.168.1.100"
            className="input font-mono"
          />
          {errors.ipAddress && (
            <p className="mt-1 text-xs text-red-500">{errors.ipAddress.message}</p>
          )}
        </div>

        {/* MAC Address */}
        <div>
          <label className="label">MAC Address</label>
          <input
            {...register('macAddress')}
            placeholder="AA:BB:CC:DD:EE:FF"
            className="input font-mono uppercase"
          />
          {errors.macAddress && (
            <p className="mt-1 text-xs text-red-500">{errors.macAddress.message}</p>
          )}
        </div>

        {/* Firmware */}
        <div>
          <label className="label">Firmware Version</label>
          <input
            {...register('firmware')}
            placeholder="1.0.0"
            className="input font-mono"
          />
        </div>

        {/* Buttons */}
        <div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
          <button
            type="button"
            onClick={() => navigate(-1)}
            className="btn-secondary"
          >
            ยกเลิก
          </button>
          <button
            type="submit"
            disabled={isSubmitting || (!isDirty && isEditing)}
            className="btn-primary"
          >
            {isSubmitting ? 'กำลังบันทึก...' : isEditing ? 'บันทึก' : 'เพิ่มอุปกรณ์'}
          </button>
        </div>
      </form>
    </div>
  )
}

Trick เด็ด: disabled={isSubmitting || (!isDirty && isEditing)} — ปุ่ม Save จะ disabled ถ้ายังไม่ได้แก้ไขอะไร (!isDirty) ตอน edit mode ทำให้ user รู้ว่ายังไม่ได้เปลี่ยนแปลงอะไร UX ดีขึ้นทันที!


Step 5: User Management Page — จัดการผู้ใช้งาน

หน้านี้คล้ายๆ Device แต่มีความพิเศษตรงที่เราไม่ลบ user ทิ้ง แต่ toggle active/inactive แทน เหมือนการ “พักงาน” แทน “ไล่ออก” ข้อมูลยังอยู่ในระบบ

สร้าง src/pages/users/UsersPage.tsx:

import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus, Shield, UserCheck, UserX } from 'lucide-react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { DataTable, type Column } from '@components/ui/DataTable'
import { ConfirmDialog } from '@components/ui/ConfirmDialog'
import { PageHeader } from '@components/common/PageHeader'
import { userService } from '@services/user.service'
import type { User, UserRole } from '@types/user.types'

const ROLE_BADGE_COLORS: Record<UserRole, string> = {
  admin: 'bg-purple-100 text-purple-800',
  operator: 'bg-blue-100 text-blue-800',
  viewer: 'bg-gray-100 text-gray-600',
}

const ROLE_LABELS: Record<UserRole, string> = {
  admin: 'ผู้ดูแล',
  operator: 'ผู้ปฏิบัติงาน',
  viewer: 'ผู้ดูข้อมูล',
}

export default function UsersPage() {
  const navigate = useNavigate()
  const queryClient = useQueryClient()

  const [search, setSearch] = useState('')
  const [roleFilter, setRoleFilter] = useState<UserRole | ''>('')
  const [page, setPage] = useState(1)
  const [deactivateTarget, setDeactivateTarget] = useState<User | null>(null)

  const { data, isLoading } = useQuery({
    queryKey: ['users', { search, role: roleFilter, page }],
    queryFn: () => userService.getUsers({ search, role: roleFilter || undefined, page, limit: 25 }),
  })

  const toggleActiveMutation = useMutation({
    mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
      userService.updateUser(id, { isActive }),
    onSuccess: (_, variables) => {
      toast.success(variables.isActive ? 'เปิดใช้งานบัญชีสำเร็จ' : 'ปิดใช้งานบัญชีสำเร็จ')
      queryClient.invalidateQueries({ queryKey: ['users'] })
      setDeactivateTarget(null)
    },
    onError: () => {
      toast.error('ไม่สามารถเปลี่ยนสถานะบัญชีได้')
    },
  })

  const columns: Column<User>[] = [
    {
      key: 'username',
      header: 'Username',
      sortable: true,
      render: (value, row) => (
        <div className="flex items-center gap-3">
          <div className="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-bold text-sm">
            {row.firstName.charAt(0)}{row.lastName.charAt(0)}
          </div>
          <div>
            <p className="font-medium text-gray-900">{String(value)}</p>
            <p className="text-xs text-gray-500">{row.email}</p>
          </div>
        </div>
      ),
    },
    {
      key: 'firstName',
      header: 'ชื่อ-นามสกุล',
      render: (_, row) => (
        <span>{row.firstName} {row.lastName}</span>
      ),
    },
    {
      key: 'role',
      header: 'Role',
      sortable: true,
      render: (value) => {
        const role = value as UserRole
        return (
          <span className={`badge ${ROLE_BADGE_COLORS[role]}`}>
            <Shield className="w-3 h-3 mr-1" />
            {ROLE_LABELS[role]}
          </span>
        )
      },
    },
    {
      key: 'isActive',
      header: 'สถานะ',
      render: (value) =>
        value ? (
          <span className="badge badge-green">
            <UserCheck className="w-3 h-3 mr-1" />
            ใช้งานอยู่
          </span>
        ) : (
          <span className="badge badge-red">
            <UserX className="w-3 h-3 mr-1" />
            ปิดใช้งาน
          </span>
        ),
    },
    {
      key: 'lastLogin',
      header: 'Login ล่าสุด',
      render: (value) =>
        value ? (
          <span className="text-xs text-gray-500">
            {new Date(String(value)).toLocaleString('th-TH')}
          </span>
        ) : (
          <span className="text-xs text-gray-400">ยังไม่เคย Login</span>
        ),
    },
    {
      key: 'id',
      header: 'จัดการ',
      headerClassName: 'text-right',
      className: 'text-right',
      render: (_, row) => (
        <div className="flex items-center justify-end gap-2">
          <button
            onClick={() => navigate(`/users/${row.id}/edit`)}
            className="text-xs text-gray-500 hover:text-gray-700 font-medium"
          >
            แก้ไข
          </button>
          <button
            onClick={() => setDeactivateTarget(row)}
            className={`text-xs font-medium ${
              row.isActive
                ? 'text-red-500 hover:text-red-700'
                : 'text-green-500 hover:text-green-700'
            }`}
          >
            {row.isActive ? 'ปิดใช้งาน' : 'เปิดใช้งาน'}
          </button>
        </div>
      ),
    },
  ]

  return (
    <div className="space-y-6">
      <PageHeader
        title="จัดการผู้ใช้"
        description={`ผู้ใช้งานทั้งหมด ${data?.pagination.total ?? 0} คน`}
      >
        <button onClick={() => navigate('/users/new')} className="btn-primary">
          <Plus className="w-4 h-4 mr-1.5" />
          เพิ่มผู้ใช้
        </button>
      </PageHeader>

      {/* Filters */}
      <div className="card flex flex-wrap gap-3">
        <input
          type="text"
          placeholder="ค้นหาชื่อหรืออีเมล..."
          value={search}
          onChange={(e) => { setSearch(e.target.value); setPage(1) }}
          className="input flex-1 min-w-48"
        />
        <select
          value={roleFilter}
          onChange={(e) => { setRoleFilter(e.target.value as UserRole | ''); setPage(1) }}
          className="input w-44"
        >
          <option value="">ทุก Role</option>
          {Object.entries(ROLE_LABELS).map(([val, label]) => (
            <option key={val} value={val}>{label}</option>
          ))}
        </select>
      </div>

      <DataTable
        data={data?.data ?? []}
        columns={columns}
        keyField="id"
        loading={isLoading}
        emptyMessage="ไม่พบผู้ใช้งาน"
        pagination={
          data
            ? {
                page,
                limit: 25,
                total: data.pagination.total,
                onPageChange: setPage,
                onLimitChange: () => {},
              }
            : undefined
        }
      />

      <ConfirmDialog
        open={!!deactivateTarget}
        title={deactivateTarget?.isActive ? 'ปิดใช้งานบัญชี' : 'เปิดใช้งานบัญชี'}
        description={
          deactivateTarget?.isActive
            ? `ต้องการปิดใช้งานบัญชีของ "${deactivateTarget?.username}" ใช่หรือไม่? ผู้ใช้นี้จะไม่สามารถเข้าสู่ระบบได้`
            : `ต้องการเปิดใช้งานบัญชีของ "${deactivateTarget?.username}" ใช่หรือไม่?`
        }
        variant={deactivateTarget?.isActive ? 'danger' : 'default'}
        confirmLabel={deactivateTarget?.isActive ? 'ปิดใช้งาน' : 'เปิดใช้งาน'}
        isLoading={toggleActiveMutation.isPending}
        onConfirm={() =>
          deactivateTarget &&
          toggleActiveMutation.mutate({
            id: deactivateTarget.id,
            isActive: !deactivateTarget.isActive,
          })
        }
        onCancel={() => setDeactivateTarget(null)}
      />
    </div>
  )
}

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

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

  Workshop Dev-16 DONE! (ง'̀-'́)ง
FeatureComponentหมายเหตุ
DataTableDataTable.tsxSorting, filtering, pagination, selectable
ConfirmationConfirmDialog.tsxVariant: danger/warning/default
Device ListDevicesPage.tsxFilter, bulk delete, export CSV
Device FormDeviceFormPage.tsxCreate/Edit พร้อม Zod validation
User ListUsersPage.tsxFilter by role, toggle active
NotificationsSonnerToast แจ้งผลทุก action

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

  • TanStack Query — จัดการ server state, caching และ invalidation อัตโนมัติ ไม่ต้อง manage loading/error state เอง
  • Zod — Type-safe validation ที่ share กันระหว่าง frontend และ backend ได้ เขียน schema ครั้งเดียว ใช้ได้สองที่
  • Optimistic Updates — สามารถเพิ่มใน mutation onMutate สำหรับ UX ที่ดีขึ้น (เดี๋ยว workshop หน้าจะพูดถึง)

Next Step

Workshop นี้ครบแล้ว! น้องๆ ลองทำตามและ push ขึ้น branch workshop/dev-16-admin-crud ได้เลย

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