diff --git a/frontend/app/(admin)/admin/doc-control/contracts/page.tsx b/frontend/app/(admin)/admin/doc-control/contracts/page.tsx index ddf3d6b..88966c4 100644 --- a/frontend/app/(admin)/admin/doc-control/contracts/page.tsx +++ b/frontend/app/(admin)/admin/doc-control/contracts/page.tsx @@ -38,7 +38,7 @@ import { SearchContractDto, CreateContractDto, UpdateContractDto } from '@/types import { AxiosError } from 'axios'; interface _Project { - id: string; // ADR-019: uuid exposed as 'id' + id: string; // ADR-019: uuid exposed as 'id' (string) projectCode: string; projectName: string; } @@ -86,7 +86,7 @@ const useProjectsList = () => { export default function ContractsPage() { const [search, setSearch] = useState(''); const { data: contracts, isLoading } = useContracts({ search: search || undefined }); - const { data: projects } = useProjectsList(); + const { data: projects } = useProjectsList() as { data: _Project[] | undefined }; const queryClient = useQueryClient(); @@ -286,7 +286,9 @@ export default function ContractsPage() { - {editingUuid ? 'Edit Contract' : 'New Contract'} + + {editingUuid ? `Edit Contract: ${watch('contractCode') || '...'}` : 'New Contract'} +
@@ -296,13 +298,12 @@ export default function ContractsPage() { - {(projects as { id?: number; uuid?: string; projectCode: string; projectName: string }[])?.map( - (p) => ( - - {p.projectCode} - {p.projectName} - - ) - )} + {projects?.map((p) => ( + // ADR-019: Project exposes UUID as 'id' (string) + + {p.projectCode} - {p.projectName} + + ))} {errors.projectId &&

{errors.projectId.message}

} diff --git a/frontend/app/(admin)/admin/doc-control/numbering/page.tsx b/frontend/app/(admin)/admin/doc-control/numbering/page.tsx index ea02ac4..35639ab 100644 --- a/frontend/app/(admin)/admin/doc-control/numbering/page.tsx +++ b/frontend/app/(admin)/admin/doc-control/numbering/page.tsx @@ -16,10 +16,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { useProjects, useCorrespondenceTypes, useContracts, useDisciplines } from '@/hooks/use-master-data'; interface ProjectItem { - id: number | string; - publicId?: string; // ADR-019: exposed as 'id' in API responses + publicId: string; // ADR-019: UUID from API projectName: string; projectCode: string; + isActive?: boolean; } import { ManualOverrideForm } from '@/components/numbering/manual-override-form'; @@ -38,7 +38,7 @@ export default function NumberingPage() { useEffect(() => { if (projects.length > 0 && !selectedProjectId) { const first = projects[0] as ProjectItem; - setSelectedProjectId(String(first.publicId ?? first.id)); + setSelectedProjectId(String(first.publicId)); } }, [projects, selectedProjectId]); @@ -48,7 +48,7 @@ export default function NumberingPage() { const [isTesting, setIsTesting] = useState(false); const [testTemplate, setTestTemplate] = useState(null); - const selectedProject = (projects as ProjectItem[]).find((p) => String(p.publicId ?? p.id) === selectedProjectId); + const selectedProject = (projects as ProjectItem[]).find((p) => String(p.publicId) === selectedProjectId); const selectedProjectName = selectedProject?.projectName || 'Unknown Project'; // Master Data @@ -116,7 +116,7 @@ export default function NumberingPage() { {(projects as ProjectItem[]).map((project) => ( - + {project.projectCode} - {project.projectName} ))} diff --git a/frontend/app/(admin)/admin/doc-control/projects/page.tsx b/frontend/app/(admin)/admin/doc-control/projects/page.tsx index 1eca385..4ddb725 100644 --- a/frontend/app/(admin)/admin/doc-control/projects/page.tsx +++ b/frontend/app/(admin)/admin/doc-control/projects/page.tsx @@ -33,8 +33,7 @@ import { import { Skeleton } from '@/components/ui/skeleton'; interface Project { - uuid: string; - id?: number; // Excluded from API responses (ADR-019) + id: string; // ADR-019: uuid exposed as 'id' projectCode: string; projectName: string; isActive: boolean; @@ -72,7 +71,7 @@ export default function ProjectsPage() { const confirmDelete = () => { if (projectToDelete) { - deleteProject.mutate(projectToDelete.uuid, { + deleteProject.mutate(projectToDelete.id, { onSuccess: () => { setDeleteDialogOpen(false); setProjectToDelete(null); @@ -146,7 +145,7 @@ export default function ProjectsPage() { ]; const handleEdit = (project: Project) => { - setEditingUuid(project.uuid); + setEditingUuid(project.id); reset({ projectCode: project.projectCode, projectName: project.projectName, diff --git a/frontend/app/(admin)/admin/doc-control/reference/disciplines/page.tsx b/frontend/app/(admin)/admin/doc-control/reference/disciplines/page.tsx index c9e1299..c510c1e 100644 --- a/frontend/app/(admin)/admin/doc-control/reference/disciplines/page.tsx +++ b/frontend/app/(admin)/admin/doc-control/reference/disciplines/page.tsx @@ -21,6 +21,18 @@ export default function DisciplinesPage() { header: 'Code', cell: ({ row }) => {row.getValue('disciplineCode')}, }, + { + accessorKey: 'contract', + header: 'Contract', + cell: ({ row }) => { + const contract = row.original.contract; + return contract ? ( + {contract.contractName} ({contract.contractCode}) + ) : ( + - + ); + }, + }, { accessorKey: 'codeNameTh', header: 'Name (TH)', @@ -44,9 +56,9 @@ export default function DisciplinesPage() { }, ]; - const contractOptions = contracts.map((c: { id: number; contractCode: string; contractName: string }) => ({ + const contractOptions = contracts.map((c: { id?: number; publicId?: string; contractCode: string; contractName: string }) => ({ label: `${c.contractName} (${c.contractCode})`, - value: String(c.id), + value: String(c.publicId ?? c.id ?? ''), })); return ( @@ -86,8 +98,8 @@ export default function DisciplinesPage() { All Contracts - {contracts.map((c: { id: number; contractCode: string; contractName: string }) => ( - + {contracts.map((c: { id?: number; publicId?: string; contractCode: string; contractName: string }) => ( + {c.contractName} ({c.contractCode}) ))} diff --git a/frontend/app/(admin)/admin/doc-control/reference/rfa-types/page.tsx b/frontend/app/(admin)/admin/doc-control/reference/rfa-types/page.tsx index d38f0b7..aefad00 100644 --- a/frontend/app/(admin)/admin/doc-control/reference/rfa-types/page.tsx +++ b/frontend/app/(admin)/admin/doc-control/reference/rfa-types/page.tsx @@ -21,6 +21,18 @@ export default function RfaTypesPage() { header: 'Code', cell: ({ row }) => {row.getValue('typeCode')}, }, + { + accessorKey: 'contract', + header: 'Contract', + cell: ({ row }) => { + const contract = row.original.contract; + return contract ? ( + {contract.contractName} ({contract.contractCode}) + ) : ( + - + ); + }, + }, { accessorKey: 'typeNameTh', header: 'Name (TH)', @@ -48,9 +60,9 @@ export default function RfaTypesPage() { }, ]; - const contractOptions = contracts.map((c: { id: number | string; contract_name?: string; contract_code?: string; contractName?: string; contractCode?: string }) => ({ + const contractOptions = contracts.map((c: { id?: number; publicId?: string; contract_name?: string; contract_code?: string; contractName?: string; contractCode?: string }) => ({ label: `${c.contractName || c.contract_name} (${c.contractCode || c.contract_code})`, - value: String(c.id), + value: String(c.publicId ?? c.id ?? ''), })); return ( @@ -87,8 +99,8 @@ export default function RfaTypesPage() { All Contracts - {contracts.map((c: { id: number | string; contract_name?: string; contract_code?: string; contractName?: string; contractCode?: string }) => ( - + {contracts.map((c: { id?: number; publicId?: string; contract_name?: string; contract_code?: string; contractName?: string; contractCode?: string }) => ( + {c.contractName || c.contract_name} ({c.contractCode || c.contract_code}) ))} @@ -104,11 +116,11 @@ export default function RfaTypesPage() { required: true, options: contractOptions, }, - { name: 'type_code', label: 'Code', type: 'text', required: true }, - { name: 'type_name_th', label: 'Name (TH)', type: 'text', required: true }, - { name: 'type_name_en', label: 'Name (EN)', type: 'text' }, + { name: 'typeCode', label: 'Code', type: 'text', required: true }, + { name: 'typeNameTh', label: 'Name (TH)', type: 'text', required: true }, + { name: 'typeNameEn', label: 'Name (EN)', type: 'text' }, { name: 'remark', label: 'Remark', type: 'textarea' }, - { name: 'is_active', label: 'Active', type: 'checkbox' }, + { name: 'isActive', label: 'Active', type: 'checkbox' }, ]} />
diff --git a/frontend/app/(admin)/admin/doc-control/reference/tags/page.tsx b/frontend/app/(admin)/admin/doc-control/reference/tags/page.tsx index 0ee4365..dee875c 100644 --- a/frontend/app/(admin)/admin/doc-control/reference/tags/page.tsx +++ b/frontend/app/(admin)/admin/doc-control/reference/tags/page.tsx @@ -16,9 +16,9 @@ export default function TagsPage() { const projectOptions = [ { label: 'Global (All Projects)', value: '__none__' }, - ...(projectsData || []).map((p: { id: number | string; projectName?: string; projectCode?: string }) => ({ - label: (p.projectName || p.projectCode || `Project ${p.id}`) as string, - value: String(p.id), // p.id = UUID string via serialization + ...(projectsData || []).map((p: { id?: number; publicId?: string; projectName?: string; projectCode?: string }) => ({ + label: (p.projectName || p.projectCode || `Project ${p.publicId || p.id}`) as string, + value: String(p.publicId ?? p.id ?? ''), // ADR-019: publicId is the UUID exposed in API })), ]; diff --git a/frontend/types/master-data.ts b/frontend/types/master-data.ts index bf829c5..7ba4d38 100644 --- a/frontend/types/master-data.ts +++ b/frontend/types/master-data.ts @@ -16,6 +16,13 @@ export interface Discipline { codeNameEn: string; codeNameTh?: string; isActive: boolean; + contract?: { + id?: number; + publicId?: string; + contractCode: string; + contractName: string; + }; + contractId?: number | string; } export interface RfaType { @@ -25,6 +32,13 @@ export interface RfaType { typeNameEn?: string; remark?: string; isActive: boolean; + contract?: { + id?: number; + publicId?: string; + contractCode: string; + contractName: string; + }; + contractId?: number | string; } export interface Tag { diff --git a/specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md b/specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md index 938d61c..85fc2b1 100644 --- a/specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md +++ b/specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md @@ -606,6 +606,124 @@ test.describe('Correspondence Workflow', () => { --- +## 🆔 ADR-019 UUID Handling (Critical) + +**Backend ใช้ Hybrid ID Strategy:** INT PK (internal) + UUID (public API) +- **Database:** `id` = INT AI (Primary Key), `uuid` = UUID (MariaDB native type) +- **Backend Entity:** `id` = INT (@Exclude), `publicId` = UUID (exposed as `id` in API via @Expose) +- **API Response:** ส่ง `id` (ซึ่งจริงๆ คือ `publicId` UUID string) ไม่มี INT id + +### ⚠️ Common Pitfalls (และวิธีแก้) + +#### 1. ใช้ `.id` กับ Entity ที่ควรใช้ `.publicId` +```tsx +// ❌ WRONG - entity.id อาจเป็น undefined หรือ INT ที่ถูก @Exclude +contracts.map((c) => ) + +// ✅ CORRECT - ใช้ publicId (UUID) ที่ API ส่งมา +contracts.map((c) => ) +// หรือ fallback สำหรับ backward compatibility +contracts.map((c) => ) +``` + +#### 2. parseInt() บน UUID string +```tsx +// ❌ WRONG - parseInt บน UUID จะได้ค่า garbage +const id = parseInt(projectId); // "0195..." → 19 (wrong!) + +// ✅ CORRECT - ส่ง UUID string ตรงๆ ไป backend +const id = projectId; // "019505a1-7c3e-7000-8000-abc123def456" + +// ✅ CORRECT - ถ้าต้องการ INT ให้ backend resolve เองผ่าน UuidResolver +// Backend DTO รับ `projectUuid: string` แล้ว resolve เป็น `projectId: number` เอง +``` + +#### 3. Field Name Mismatch (snake_case vs camelCase) +```tsx +// ❌ WRONG - ใช้ชื่อ field ไม่ตรงกับ TypeScript interface +fields={[{ name: 'type_code', label: 'Code' }]} +// interface มี typeCode (camelCase) แต่ form ส่ง type_code (snake_case) + +// ✅ CORRECT - ใช้ชื่อ field ตรงกับ interface +fields={[{ name: 'typeCode', label: 'Code' }]} +``` + +#### 4. Contract/Project Select ไม่มีข้อมูล +```tsx +// ❌ WRONG - สมมติว่า API ส่ง { id: number } +const options = contracts.map((c) => ({ value: String(c.id), label: c.contractName })) + +// ✅ CORRECT - API ส่ง { publicId: string } ตาม ADR-019 +const options = contracts.map((c) => ({ + value: String(c.publicId ?? c.id ?? ''), // fallback รองรับทั้ง 2 กรณี + label: c.contractName +})) +``` + +### 📝 Pattern: Contract/Project Select Options + +```typescript +// types/master-data.ts - Entity interfaces +export interface Contract { + id?: number; // Internal INT (อาจถูก @Exclude) + publicId?: string; // UUID ที่ API ส่ง (ต้องใช้ตัวนี้) + contractCode: string; + contractName: string; +} + +// page.tsx - Select options +const contractOptions = contracts.map((c) => ({ + label: `${c.contractName} (${c.contractCode})`, + value: String(c.publicId ?? c.id ?? ''), // ADR-019: publicId เป็น UUID +})); + +// GenericCrudTable fields +fields={[ + { + name: 'contractId', + label: 'Contract', + type: 'select', + required: true, + options: contractOptions, // ใช้ UUID string เป็น value + }, +]} +``` + +### 📝 Pattern: Relation Column in Table + +```typescript +// เพิ่ม column แสดง relation (Contract/Project) +const columns: ColumnDef[] = [ + { + accessorKey: 'disciplineCode', + header: 'Code', + }, + { + accessorKey: 'contract', // relation object จาก backend + header: 'Contract', + cell: ({ row }) => { + const contract = row.original.contract; + return contract ? ( + {contract.contractName} ({contract.contractCode}) + ) : ( + - + ); + }, + }, + // ... other columns +]; +``` + +### ✅ Checklist ก่อน Commit + +- [ ] ใช้ `publicId ?? id` pattern สำหรับ entity identifiers +- [ ] ไม่ใช้ `parseInt()` บน UUID values +- [ ] Field names ตรงกับ TypeScript interfaces (camelCase) +- [ ] Select options ใช้ UUID string เป็น value +- [ ] แสดง relation columns (Contract/Project) ในตาราง + +--- + ## 🚫 Anti-Patterns (สิ่งที่ห้ามทำ) 1. ❌ **ห้ามใช้ Inline Styles** - ใช้ Tailwind เท่านั้น