# Frontend Development Guidelines **สำหรับ:** NAP-DMS LCBP3 Frontend (Next.js + TypeScript) **เวอร์ชัน:** 1.5.0 **อัปเดต:** 2025-12-01 --- ## 🎯 หลักการพื้นฐาน ระบบ Frontend ของเรามุ่งเน้น **User Experience First** - ประสบการณ์ผู้ใช้ที่ราบรื่น รวดเร็ว และใช้งานง่าย ### หลักการหลัก 1. **Type Safety:** ใช้ TypeScript Strict Mode ตลอดทั้งโปรเจกต์ 2. **Responsive Design:** รองรับทุกขนาดหน้าจอ (Mobile-first approach) 3. **Performance:** Optimize การโหลดข้อมูล ใช้ Caching อย่างชาญฉลาด 4. **Accessibility:** ทุก Component ต้องรองรับ Screen Reader และ Keyboard Navigation 5. **Offline Support:** Auto-save Drafts และ Silent Token Refresh --- ## 📁 โครงสร้างโปรเจกต์ ``` frontend/ ├── app/ # Next.js App Router │ ├── (auth)/ # Auth routes (login, register) │ ├── (dashboard)/ # Protected dashboard routes │ ├── api/ # API routes (NextAuth) │ └── layout.tsx ├── components/ │ ├── ui/ # shadcn/ui components │ ├── custom/ # Custom components │ ├── forms/ # Form components │ ├── layout/ # Layout components (Navbar, Sidebar) │ └── tables/ # Data table components ├── hooks/ # Custom React hooks (Root level) ├── lib/ │ ├── api/ # API client (Axios) │ ├── services/ # API service functions │ ├── stores/ # Zustand stores │ └── utils.ts # Cn utility ├── providers/ # Context providers ├── public/ # Static assets ├── styles/ # Global styles ├── types/ # TypeScript types & DTOs └── middleware.ts # Next.js Middleware ``` --- ## 🎨 UI/UX Guidelines ### 1. Design System - Tailwind CSS **ใช้ Tailwind Utilities เท่านั้น:** ```tsx // ✅ Good
// ❌ Bad - Inline styles
``` **Responsive Design:** ```tsx
{items.map((item) => ( ))}
``` ### 2. shadcn/ui Components **ใช้ shadcn/ui สำหรับ UI Components:** ```tsx import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; export function Dashboard() { return ( งานของฉัน ); } ``` ### 3. Responsive Data Tables **Mobile: Card View, Desktop: Table View** ```tsx export function ResponsiveTable({ data }: { data: Correspondence[] }) { return ( <> {/* Desktop Table */}
เลขที่เอกสาร เรื่อง สถานะ {data.map((item) => ( {item.doc_number} {item.title} {item.status} ))}
{/* Mobile Card View */}
{data.map((item) => (
เลขที่เอกสาร
{item.doc_number}
เรื่อง
{item.title}
{item.status}
))}
); } ``` --- ## 🗄️ State Management ### 1. Server State - TanStack Query **ใช้สำหรับข้อมูลจาก API:** ```tsx import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; // Fetch data export function useCorrespondences(projectId: string) { return useQuery({ queryKey: ['correspondences', projectId], queryFn: () => correspondenceService.getAll(projectId), staleTime: 5 * 60 * 1000, // 5 minutes }); } // Mutation with optimistic update export function useCreateCorrespondence() { const queryClient = useQueryClient(); return useMutation({ mutationFn: correspondenceService.create, onMutate: async (newCorrespondence) => { // Optimistic update await queryClient.cancelQueries({ queryKey: ['correspondences'] }); const previous = queryClient.getQueryData(['correspondences']); queryClient.setQueryData(['correspondences'], (old: any) => [ ...old, newCorrespondence, ]); return { previous }; }, onError: (err, newCorrespondence, context) => { // Rollback on error queryClient.setQueryData(['correspondences'], context?.previous); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['correspondences'] }); }, }); } ``` ### 2. Form State - React Hook Form + Zod ```tsx import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; // Schema Definition const formSchema = z.object({ title: z.string().min(1, 'กรุณาระบุหัวเรื่อง').max(500), project_id: z.string().uuid('กรุณาเลือกโปรเจกต์'), type_id: z.string().uuid('กรุณาเลือกประเภทเอกสาร'), }); type FormData = z.infer; // Form Component export function CorrespondenceForm() { const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { title: '', project_id: '', type_id: '', }, }); const onSubmit = async (data: FormData) => { await createCorrespondence(data); }; return (
( หัวเรื่อง )} /> ); } ``` ### 3. UI State - Zustand ```tsx import { create } from 'zustand'; import { persist } from 'zustand/middleware'; // Draft Store (with localStorage persistence) interface DraftStore { drafts: Record; saveDraft: (formKey: string, data: any) => void; loadDraft: (formKey: string) => any; clearDraft: (formKey: string) => void; } export const useDraftStore = create()( persist( (set, get) => ({ drafts: {}, saveDraft: (formKey, data) => set((state) => ({ drafts: { ...state.drafts, [formKey]: data }, })), loadDraft: (formKey) => get().drafts[formKey], clearDraft: (formKey) => set((state) => { const { [formKey]: _, ...rest } = state.drafts; return { drafts: rest }; }), }), { name: 'correspondence-drafts' } ) ); ``` --- ## 🔌 API Integration ### 1. Axios Client Setup ```typescript // lib/api/client.ts import axios from 'axios'; import { v4 as uuidv4 } from 'uuid'; const apiClient = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL, timeout: 30000, }); // Request Interceptor - Add Auth & Idempotency apiClient.interceptors.request.use((config) => { // Add JWT Token const token = localStorage.getItem('access_token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } // Add Idempotency-Key for mutation requests if (['post', 'put', 'delete'].includes(config.method?.toLowerCase() || '')) { config.headers['Idempotency-Key'] = uuidv4(); } return config; }); // Response Interceptor - Handle Errors & Token Refresh apiClient.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config; // Auto refresh token on 401 if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; try { const refreshToken = localStorage.getItem('refresh_token'); const { data } = await axios.post('/auth/refresh', { refreshToken }); localStorage.setItem('access_token', data.access_token); originalRequest.headers.Authorization = `Bearer ${data.access_token}`; return apiClient(originalRequest); } catch (refreshError) { // Redirect to login window.location.href = '/login'; return Promise.reject(refreshError); } } return Promise.reject(error); } ); export default apiClient; ``` ### 2. Service Layer ```typescript // lib/services/correspondence.service.ts import apiClient from '@/lib/api/client'; import type { Correspondence, CreateCorrespondenceDto, SearchCorrespondenceDto, } from '@/types/dto/correspondence'; export const correspondenceService = { async getAll(params: SearchCorrespondenceDto): Promise { const { data } = await apiClient.get('/correspondences', { params }); return data; }, async getById(id: string): Promise { const { data } = await apiClient.get(`/correspondences/${id}`); return data; }, async create(dto: CreateCorrespondenceDto): Promise { const { data } = await apiClient.post('/correspondences', dto); return data; }, async update( id: string, dto: Partial ): Promise { const { data } = await apiClient.put(`/correspondences/${id}`, dto); return data; }, async delete(id: string): Promise { await apiClient.delete(`/correspondences/${id}`); }, }; ``` --- ## 📝 Dynamic Forms (JSON Schema) ### Dynamic Form Generator ```tsx import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useQuery } from '@tanstack/react-query'; interface DynamicFormProps { schemaName: string; onSubmit: (data: any) => void; } export function DynamicForm({ schemaName, onSubmit }: DynamicFormProps) { // Fetch JSON Schema from Backend const { data: schema } = useQuery({ queryKey: ['json-schema', schemaName], queryFn: () => jsonSchemaService.getByName(schemaName), }); // Generate Zod schema from JSON Schema const zodSchema = useMemo(() => { if (!schema) return null; return generateZodSchemaFromJsonSchema(schema.schema_definition); }, [schema]); const form = useForm({ resolver: zodResolver(zodSchema!), }); if (!schema) return ; return (
{Object.entries(schema.schema_definition.properties).map( ([key, prop]: [string, any]) => ( ( {prop.title || key} {renderFieldByType(prop.type, field)} )} /> ) )} ); } // Helper function to render different field types function renderFieldByType(type: string, field: any) { switch (type) { case 'string': return ; case 'number': return ; case 'boolean': return ; // Add more types as needed default: return ; } } ``` --- ## 📤 File Upload ### Drag & Drop File Upload ```tsx import { useCallback } from 'react'; import { useDropzone } from 'react-dropzone'; import { Upload, X } from 'lucide-react'; interface FileUploadZoneProps { onUpload: (files: File[]) => void; maxFiles?: number; maxSize?: number; acceptedTypes?: string[]; } export function FileUploadZone({ onUpload, maxFiles = 10, maxSize = 50 * 1024 * 1024, // 50MB acceptedTypes = ['.pdf', '.dwg', '.docx', '.xlsx', '.zip'], }: FileUploadZoneProps) { const onDrop = useCallback( (acceptedFiles: File[]) => { onUpload(acceptedFiles); }, [onUpload] ); const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, maxFiles, maxSize, accept: acceptedTypes.reduce((acc, type) => ({ ...acc, [type]: [] }), {}), }); return (

{isDragActive ? 'วางไฟล์ที่นี่...' : 'ลากไฟล์มาวางที่นี่ หรือคลิกเพื่อเลือกไฟล์'}

รองรับ: {acceptedTypes.join(', ')} (สูงสุด {maxFiles} ไฟล์,{' '} {maxSize / 1024 / 1024}MB/ไฟล์)

); } ``` --- ## ✅ Testing Standards ### 1. Component Testing (Vitest + React Testing Library) ```tsx import { render, screen, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import { CorrespondenceForm } from './correspondence-form'; describe('CorrespondenceForm', () => { it('should validate required fields', async () => { const onSubmit = vi.fn(); render(); const submitButton = screen.getByRole('button', { name: /บันทึก/i }); fireEvent.click(submitButton); expect(await screen.findByText('กรุณาระบุหัวเรื่อง')).toBeInTheDocument(); expect(onSubmit).not.toHaveBeenCalled(); }); it('should submit form with valid data', async () => { const onSubmit = vi.fn(); render(); const titleInput = screen.getByLabelText('หัวเรื่อง'); fireEvent.change(titleInput, { target: { value: 'Test Correspondence' } }); const submitButton = screen.getByRole('button', { name: /บันทึก/i }); fireEvent.click(submitButton); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ title: 'Test Correspondence' }) ); }); }); }); ``` ### 2. E2E Testing (Playwright) ```typescript import { test, expect } from '@playwright/test'; test.describe('Correspondence Workflow', () => { test.beforeEach(async ({ page }) => { // Login await page.goto('/login'); await page.fill('input[name="username"]', 'testuser'); await page.fill('input[name="password"]', 'password123'); await page.click('button[type="submit"]'); await page.waitForURL('/dashboard'); }); test('should create new correspondence', async ({ page }) => { // Navigate to create page await page.click('text=สร้างเอกสาร'); await page.waitForURL('/correspondences/new'); // Fill form await page.fill('input[name="title"]', 'E2E Test Correspondence'); await page.selectOption('select[name="project_id"]', { index: 1 }); await page.selectOption('select[name="type_id"]', { index: 1 }); // Submit await page.click('button[type="submit"]'); // Verify success await expect(page.locator('text=สร้างเอกสารสำเร็จ')).toBeVisible(); await expect(page).toHaveURL(/\/correspondences\/[a-f0-9-]+/); }); }); ``` --- ## 🚫 Anti-Patterns (สิ่งที่ห้ามทำ) 1. ❌ **ห้ามใช้ Inline Styles** - ใช้ Tailwind เท่านั้น 2. ❌ **ห้าม Fetch Data ใน useEffect** - ใช้ TanStack Query 3. ❌ **ห้าม Props Drilling** - ใช้ Context หรือ Zustand 4. ❌ **ห้าม Any Type** 5. ❌ **ห้าม console.log** ใน Production 6. ❌ **ห้ามใช้ Index เป็น Key** ใน List 7. ❌ **ห้าม Mutation โดยตรง** - ใช้ TanStack Query Mutation --- ## 📚 เอกสารอ้างอิง - [FullStack Guidelines](./fullftack-js-V1.5.0.md) - [Frontend Plan v1.4.5](../../docs/3_Frontend_Plan_V1_4_5.md) - [Next.js Documentation](https://nextjs.org/docs) - [TanStack Query](https://tanstack.com/query) - [shadcn/ui](https://ui.shadcn.com) --- ## 🔄 Update History | Version | Date | Changes | | ------- | ---------- | ----------------------------------- | | 1.5.0 | 2025-12-01 | Initial frontend guidelines created |