Files
lcbp3/specs/03-implementation/frontend-guidelines.md
admin 863a727756
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
251208:1625 Frontend: to be complete admin panel, Backend: tobe recheck all task
2025-12-08 16:25:56 +07:00

658 lines
18 KiB
Markdown

# 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
<div className="flex items-center gap-4 rounded-lg border p-4">
<Button variant="outline">Cancel</Button>
</div>
// ❌ Bad - Inline styles
<div style={{ display: 'flex', padding: '16px' }}>
```
**Responsive Design:**
```tsx
<div
className="
grid
grid-cols-1 /* Mobile: 1 column */
md:grid-cols-2 /* Tablet: 2 columns */
lg:grid-cols-3 /* Desktop: 3 columns */
gap-4
"
>
{items.map((item) => (
<Card key={item.id} />
))}
</div>
```
### 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 (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<Button></Button>
</CardContent>
</Card>
);
}
```
### 3. Responsive Data Tables
**Mobile: Card View, Desktop: Table View**
```tsx
export function ResponsiveTable({ data }: { data: Correspondence[] }) {
return (
<>
{/* Desktop Table */}
<div className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.doc_number}</TableCell>
<TableCell>{item.title}</TableCell>
<TableCell>
<Badge>{item.status}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Mobile Card View */}
<div className="md:hidden space-y-4">
{data.map((item) => (
<Card key={item.id}>
<CardContent className="pt-6">
<div className="space-y-2">
<div className="text-sm text-muted-foreground">
</div>
<div className="font-medium">{item.doc_number}</div>
<div className="text-sm text-muted-foreground"></div>
<div>{item.title}</div>
<Badge>{item.status}</Badge>
</div>
</CardContent>
</Card>
))}
</div>
</>
);
}
```
---
## 🗄️ 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<typeof formSchema>;
// Form Component
export function CorrespondenceForm() {
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
title: '',
project_id: '',
type_id: '',
},
});
const onSubmit = async (data: FormData) => {
await createCorrespondence(data);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit"></Button>
</form>
</Form>
);
}
```
### 3. UI State - Zustand
```tsx
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// Draft Store (with localStorage persistence)
interface DraftStore {
drafts: Record<string, any>;
saveDraft: (formKey: string, data: any) => void;
loadDraft: (formKey: string) => any;
clearDraft: (formKey: string) => void;
}
export const useDraftStore = create<DraftStore>()(
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<Correspondence[]> {
const { data } = await apiClient.get('/correspondences', { params });
return data;
},
async getById(id: string): Promise<Correspondence> {
const { data } = await apiClient.get(`/correspondences/${id}`);
return data;
},
async create(dto: CreateCorrespondenceDto): Promise<Correspondence> {
const { data } = await apiClient.post('/correspondences', dto);
return data;
},
async update(
id: string,
dto: Partial<CreateCorrespondenceDto>
): Promise<Correspondence> {
const { data } = await apiClient.put(`/correspondences/${id}`, dto);
return data;
},
async delete(id: string): Promise<void> {
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 <Skeleton />;
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{Object.entries(schema.schema_definition.properties).map(
([key, prop]: [string, any]) => (
<FormField
key={key}
control={form.control}
name={key}
render={({ field }) => (
<FormItem>
<FormLabel>{prop.title || key}</FormLabel>
<FormControl>
{renderFieldByType(prop.type, field)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)
)}
<Button type="submit"></Button>
</form>
</Form>
);
}
// Helper function to render different field types
function renderFieldByType(type: string, field: any) {
switch (type) {
case 'string':
return <Input {...field} />;
case 'number':
return <Input type="number" {...field} />;
case 'boolean':
return <Switch {...field} />;
// Add more types as needed
default:
return <Input {...field} />;
}
}
```
---
## 📤 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 (
<div
{...getRootProps()}
className={`
border-2 border-dashed rounded-lg p-8 text-center cursor-pointer
transition-colors
${
isDragActive
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25'
}
hover:border-primary hover:bg-primary/5
`}
>
<input {...getInputProps()} />
<Upload className="mx-auto h-12 w-12 text-muted-foreground" />
<p className="mt-2 text-sm text-muted-foreground">
{isDragActive
? 'วางไฟล์ที่นี่...'
: 'ลากไฟล์มาวางที่นี่ หรือคลิกเพื่อเลือกไฟล์'}
</p>
<p className="mt-1 text-xs text-muted-foreground">
: {acceptedTypes.join(', ')} ( {maxFiles} ,{' '}
{maxSize / 1024 / 1024}MB/)
</p>
</div>
);
}
```
---
## ✅ 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(<CorrespondenceForm onSubmit={onSubmit} />);
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(<CorrespondenceForm onSubmit={onSubmit} />);
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 |