Prepare to version 1.5 use spec-kit

This commit is contained in:
admin
2025-11-30 13:58:46 +07:00
parent c302c5f9b1
commit ce795b26e2
169 changed files with 34584 additions and 26464 deletions

View File

@@ -0,0 +1,456 @@
# Backend Development Guidelines
**สำหรับ:** NAP-DMS LCBP3 Backend (NestJS + TypeScript)
**เวอร์ชัน:** 1.4.5
**อัปเดต:** 2025-11-30
---
## 🎯 หลักการพื้นฐาน
ระบบ Backend ของเรามุ่งเน้น **"Data Integrity First"** - ความถูกต้องของข้อมูลต้องมาก่อน ตามด้วย Security และ UX
### หลักการหลัก
1. **Strict Typing:** ใช้ TypeScript เต็มรูปแบบ ห้ามใช้ `any`
2. **Data Integrity:** ป้องกัน Race Condition ด้วย Optimistic Locking + Redis Lock
3. **Security First:** ทุก Endpoint ต้องผ่าน Authentication, Authorization, และ Input Validation
4. **Idempotency:** Request สำคัญต้องทำซ้ำได้โดยไม่เกิดผลกระทบซ้ำซ้อน
5. **Resilience:** รองรับ Network Failure และ External Service Downtime
---
## 📁 โครงสร้างโปรเจกต์
```
backend/
├── src/
│ ├── common/ # Shared utilities, decorators, guards
│ │ ├── auth/ # Authentication module
│ │ ├── config/ # Configuration management
│ │ ├── decorators/ # Custom decorators
│ │ ├── guards/ # Auth guards, RBAC
│ │ ├── interceptors/ # Logging, transform, idempotency
│ │ └── file-storage/ # Two-phase file storage
│ ├── modules/ # Business modules (domain-driven)
│ │ ├── user/
│ │ ├── project/
│ │ ├── correspondence/
│ │ ├── rfa/
│ │ ├── workflow-engine/
│ │ └── ...
│ └── database/
│ ├── migrations/
│ └── seeds/
├── test/ # E2E tests
└── scripts/ # Utility scripts
```
---
## 🔐 Security Guidelines
### 1. Authentication & Authorization
**JWT Authentication:**
```typescript
// ใช้ @UseGuards(JwtAuthGuard) สำหรับ Protected Routes
@Controller('projects')
@UseGuards(JwtAuthGuard)
export class ProjectController {
// ...
}
```
**RBAC (4 ระดับ):**
```typescript
// ใช้ @RequirePermission() Decorator
@Post(':id/contracts')
@RequirePermission('contract.create', { scope: 'project' })
async createContract() {
// Level 1: Global Permission
// Level 2: Organization Permission
// Level 3: Project Permission
// Level 4: Contract Permission
}
```
### 2. Input Validation
**ใช้ DTOs พร้อม class-validator:**
```typescript
import { IsNotEmpty, IsUUID, MaxLength } from 'class-validator';
export class CreateCorrespondenceDto {
@IsNotEmpty({ message: 'ต้องระบุโปรเจกต์' })
@IsUUID('4', { message: 'รูปแบบ Project ID ไม่ถูกต้อง' })
project_id: string;
@IsNotEmpty()
@MaxLength(500)
title: string;
}
```
### 3. Rate Limiting
```typescript
// กำหนด Rate Limit ตาม User Type
@UseGuards(RateLimitGuard)
@RateLimit({ points: 100, duration: 3600 }) // 100 requests/hour
@Post('upload')
async uploadFile() { }
```
### 4. Secrets Management
- **Production:** ใช้ Docker Environment Variables (ไม่ใส่ใน docker-compose.yml)
- **Development:** ใช้ `docker-compose.override.yml` (gitignored)
- **Validation:** Validate Environment Variables ตอน Start App
```typescript
// src/common/config/env.validation.ts
import * as Joi from 'joi';
export const envValidationSchema = Joi.object({
DATABASE_URL: Joi.string().required(),
JWT_SECRET: Joi.string().min(32).required(),
REDIS_URL: Joi.string().required(),
});
```
---
## 🗄️ Database Best Practices
### 1. Optimistic Locking
**ใช้ @VersionColumn() ป้องกัน Race Condition:**
```typescript
@Entity()
export class DocumentNumberCounter {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
last_number: number;
@VersionColumn() // Auto-increment on update
version: number;
}
```
### 2. Virtual Columns สำหรับ JSON
**สร้าง Index สำหรับ JSON field ที่ใช้ Search บ่อย:**
```sql
-- Migration Script
ALTER TABLE correspondence_revisions
ADD COLUMN ref_project_id INT GENERATED ALWAYS AS
(JSON_UNQUOTE(JSON_EXTRACT(details, '$.projectId'))) VIRTUAL;
CREATE INDEX idx_ref_project_id ON correspondence_revisions(ref_project_id);
```
### 3. Soft Delete
```typescript
// Base Entity
@Entity()
export abstract class BaseEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
@DeleteDateColumn()
deleted_at: Date; // NULL = Active, NOT NULL = Soft Deleted
}
```
---
## 📦 Core Modules
### 1. DocumentNumberingModule
**Double-Lock Mechanism:**
```typescript
@Injectable()
export class DocumentNumberingService {
async generateNextNumber(context: NumberingContext): Promise<string> {
const lockKey = `doc_num:${context.projectId}:${context.typeId}`;
// Layer 1: Redis Lock (2-5 seconds TTL)
const lock = await this.redisLock.acquire(lockKey, 3000);
try {
// Layer 2: Optimistic DB Lock
const counter = await this.counterRepo.findOne({
where: context,
lock: { mode: 'optimistic' },
});
counter.last_number++;
await this.counterRepo.save(counter); // Throws if version changed
return this.formatNumber(counter);
} finally {
await lock.release();
}
}
}
```
### 2. FileStorageService (Two-Phase)
**Phase 1: Upload to Temp**
```typescript
@Post('upload')
async uploadFile(@UploadedFile() file: Express.Multer.File) {
// 1. Virus Scan
await this.virusScan(file);
// 2. Save to temp/
const tempId = await this.fileStorage.saveToTemp(file);
// 3. Return temp_id
return { temp_id: tempId, expires_at: addHours(new Date(), 24) };
}
```
**Phase 2: Commit to Permanent**
```typescript
async createCorrespondence(dto: CreateDto, tempFileIds: string[]) {
return this.dataSource.transaction(async (manager) => {
// 1. Create Correspondence
const correspondence = await manager.save(Correspondence, dto);
// 2. Commit Files (ภายใน Transaction)
await this.fileStorage.commitFiles(tempFileIds, correspondence.id, manager);
return correspondence;
});
}
```
**Cleanup Job:**
```typescript
@Cron('0 */6 * * *') // ทุก 6 ชั่วโมง
async cleanupOrphanFiles() {
const expiredFiles = await this.attachmentRepo.find({
where: {
is_temporary: true,
expires_at: LessThan(new Date()),
},
});
for (const file of expiredFiles) {
await this.deleteFile(file.file_path);
await this.attachmentRepo.remove(file);
}
}
```
### 3. Idempotency Interceptor
```typescript
@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
async intercept(context: ExecutionContext, next: CallHandler) {
const request = context.switchToHttp().getRequest();
const idempotencyKey = request.headers['idempotency-key'];
if (!idempotencyKey) {
throw new BadRequestException('Idempotency-Key required');
}
// ตรวจสอบ Cache
const cached = await this.redis.get(`idempotency:${idempotencyKey}`);
if (cached) {
return of(JSON.parse(cached)); // Return ผลลัพธ์เดิม
}
// Execute & Cache Result
return next.handle().pipe(
tap(async (response) => {
await this.redis.set(
`idempotency:${idempotencyKey}`,
JSON.stringify(response),
'EX',
86400 // 24 hours
);
})
);
}
}
```
---
## 🔄 Workflow Engine Integration
**ห้ามสร้างตาราง Routing แยก** - ใช้ Unified Workflow Engine
```typescript
@Injectable()
export class CorrespondenceWorkflowService {
constructor(private workflowEngine: WorkflowEngineService) {}
async submitCorrespondence(corrId: string, templateId: string) {
// สร้าง Workflow Instance
const instance = await this.workflowEngine.createInstance({
definition_name: 'CORRESPONDENCE_ROUTING',
entity_type: 'correspondence',
entity_id: corrId,
template_id: templateId,
});
// Execute Initial Transition
await this.workflowEngine.executeTransition(instance.id, 'SUBMIT');
return instance;
}
}
```
---
## ✅ Testing Standards
### 1. Unit Tests
```typescript
describe('DocumentNumberingService', () => {
let service: DocumentNumberingService;
let mockRedisLock: jest.Mocked<RedisLock>;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
DocumentNumberingService,
{ provide: RedisLock, useValue: mockRedisLock },
],
}).compile();
service = module.get(DocumentNumberingService);
});
it('should generate unique numbers concurrently', async () => {
// Test concurrent number generation
const promises = Array(10)
.fill(null)
.map(() => service.generateNextNumber(context));
const results = await Promise.all(promises);
const unique = new Set(results);
expect(unique.size).toBe(10); // ไม่มีเลขซ้ำ
});
});
```
### 2. E2E Tests
```typescript
describe('Correspondence API (e2e)', () => {
it('should create correspondence with idempotency', async () => {
const idempotencyKey = uuidv4();
// Request 1
const response1 = await request(app.getHttpServer())
.post('/correspondences')
.set('Idempotency-Key', idempotencyKey)
.send(createDto);
expect(response1.status).toBe(201);
// Request 2 (Same Key)
const response2 = await request(app.getHttpServer())
.post('/correspondences')
.set('Idempotency-Key', idempotencyKey)
.send(createDto);
expect(response2.status).toBe(201);
expect(response2.body.id).toBe(response1.body.id); // Same entity
});
});
```
---
## 📊 Logging & Monitoring
### 1. Winston Logger
```typescript
// src/modules/monitoring/logger/winston.config.ts
export const winstonConfig = {
level: process.env.NODE_ENV === 'production' ? 'warn' : 'debug',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
],
};
```
### 2. Audit Logging
```typescript
@Post(':id/approve')
@UseInterceptors(AuditLogInterceptor)
async approve(@Param('id') id: string, @CurrentUser() user: User) {
// AuditLogInterceptor จะบันทึก:
// - user_id
// - action: 'correspondence.approve'
// - entity_type: 'correspondence'
// - entity_id: id
// - ip_address
// - timestamp
}
```
---
## 🚫 Anti-Patterns (สิ่งที่ห้ามทำ)
1.**ห้ามใช้ SQL Triggers** สำหรับ Business Logic
2.**ห้ามใช้ .env** ใน Production (ใช้ Docker ENV)
3.**ห้ามใช้ `any` Type**
4.**ห้าม Hardcode Secrets**
5.**ห้ามสร้างตาราง Routing แยก** (ใช้ Workflow Engine)
6.**ห้ามใช้ console.log** (ใช้ Logger)
---
## 📚 เอกสารอ้างอิง
- [FullStack Guidelines](./fullftack-js-V1.5.0.md)
- [Backend Plan v1.4.5](../../docs/2_Backend_Plan_V1_4_5.md)
- [Data Dictionary](../../docs/4_Data_Dictionary_V1_4_5.md)
- [Workflow Engine Plan](../../docs/2_Backend_Plan_V1_4_4.Phase6A.md)
---
## 🔄 Update History
| Version | Date | Changes |
| ------- | ---------- | ---------------------------------- |
| 1.0.0 | 2025-11-30 | Initial backend guidelines created |

View File

@@ -0,0 +1,653 @@
# Frontend Development Guidelines
**สำหรับ:** NAP-DMS LCBP3 Frontend (Next.js + TypeScript)
**เวอร์ชัน:** 1.4.5
**อัปเดต:** 2025-11-30
---
## 🎯 หลักการพื้นฐาน
ระบบ 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
├── lib/
│ ├── api/ # API client (Axios)
│ ├── hooks/ # Custom React hooks
│ ├── services/ # API service functions
│ └── stores/ # Zustand stores
├── types/ # TypeScript types & DTOs
└── providers/ # Context providers
```
---
## 🎨 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.0.0 | 2025-11-30 | Initial frontend guidelines created |

File diff suppressed because it is too large Load Diff