IoT Admin CRUD: ตารางข้อมูลแบบครบเครื่อง
IoT Admin CRUD: ตารางข้อมูลแบบครบเครื่อง
Branch:
workshop/dev-16-admin-crudPhase: 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 ทั้งหมด
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 จากพี่โชว์: สังเกต
indeterminatestate ของ 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! (ง'̀-'́)ง
| Feature | Component | หมายเหตุ |
|---|---|---|
| DataTable | DataTable.tsx | Sorting, filtering, pagination, selectable |
| Confirmation | ConfirmDialog.tsx | Variant: danger/warning/default |
| Device List | DevicesPage.tsx | Filter, bulk delete, export CSV |
| Device Form | DeviceFormPage.tsx | Create/Edit พร้อม Zod validation |
| User List | UsersPage.tsx | Filter by role, toggle active |
| Notifications | Sonner | Toast แจ้งผลทุก 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 ได้เลย
ถ้าติดตรงไหนทักพี่โชว์มาได้เลยนะ มาลุยกัน! (^_^)/