690326:2139 Fixing Refactor ADR-019 Naming convention uuid #07
This commit is contained in:
@@ -38,7 +38,7 @@ import { SearchContractDto, CreateContractDto, UpdateContractDto } from '@/types
|
|||||||
import { AxiosError } from 'axios';
|
import { AxiosError } from 'axios';
|
||||||
|
|
||||||
interface _Project {
|
interface _Project {
|
||||||
id: string; // ADR-019: uuid exposed as 'id'
|
id: string; // ADR-019: uuid exposed as 'id' (string)
|
||||||
projectCode: string;
|
projectCode: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
}
|
}
|
||||||
@@ -86,7 +86,7 @@ const useProjectsList = () => {
|
|||||||
export default function ContractsPage() {
|
export default function ContractsPage() {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const { data: contracts, isLoading } = useContracts({ search: search || undefined });
|
const { data: contracts, isLoading } = useContracts({ search: search || undefined });
|
||||||
const { data: projects } = useProjectsList();
|
const { data: projects } = useProjectsList() as { data: _Project[] | undefined };
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -286,7 +286,9 @@ export default function ContractsPage() {
|
|||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editingUuid ? 'Edit Contract' : 'New Contract'}</DialogTitle>
|
<DialogTitle>
|
||||||
|
{editingUuid ? `Edit Contract: ${watch('contractCode') || '...'}` : 'New Contract'}
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -296,13 +298,12 @@ export default function ContractsPage() {
|
|||||||
<SelectValue placeholder="Select Project" />
|
<SelectValue placeholder="Select Project" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(projects as { id?: number; uuid?: string; projectCode: string; projectName: string }[])?.map(
|
{projects?.map((p) => (
|
||||||
(p) => (
|
// ADR-019: Project exposes UUID as 'id' (string)
|
||||||
<SelectItem key={p.uuid || p.id} value={String(p.id || p.uuid)}>
|
<SelectItem key={p.id} value={p.id}>
|
||||||
{p.projectCode} - {p.projectName}
|
{p.projectCode} - {p.projectName}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{errors.projectId && <p className="text-sm text-red-500">{errors.projectId.message}</p>}
|
{errors.projectId && <p className="text-sm text-red-500">{errors.projectId.message}</p>}
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { useProjects, useCorrespondenceTypes, useContracts, useDisciplines } from '@/hooks/use-master-data';
|
import { useProjects, useCorrespondenceTypes, useContracts, useDisciplines } from '@/hooks/use-master-data';
|
||||||
|
|
||||||
interface ProjectItem {
|
interface ProjectItem {
|
||||||
id: number | string;
|
publicId: string; // ADR-019: UUID from API
|
||||||
publicId?: string; // ADR-019: exposed as 'id' in API responses
|
|
||||||
projectName: string;
|
projectName: string;
|
||||||
projectCode: string;
|
projectCode: string;
|
||||||
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
import { ManualOverrideForm } from '@/components/numbering/manual-override-form';
|
import { ManualOverrideForm } from '@/components/numbering/manual-override-form';
|
||||||
@@ -38,7 +38,7 @@ export default function NumberingPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projects.length > 0 && !selectedProjectId) {
|
if (projects.length > 0 && !selectedProjectId) {
|
||||||
const first = projects[0] as ProjectItem;
|
const first = projects[0] as ProjectItem;
|
||||||
setSelectedProjectId(String(first.publicId ?? first.id));
|
setSelectedProjectId(String(first.publicId));
|
||||||
}
|
}
|
||||||
}, [projects, selectedProjectId]);
|
}, [projects, selectedProjectId]);
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ export default function NumberingPage() {
|
|||||||
const [isTesting, setIsTesting] = useState(false);
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
const [testTemplate, setTestTemplate] = useState<NumberingTemplate | null>(null);
|
const [testTemplate, setTestTemplate] = useState<NumberingTemplate | null>(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';
|
const selectedProjectName = selectedProject?.projectName || 'Unknown Project';
|
||||||
|
|
||||||
// Master Data
|
// Master Data
|
||||||
@@ -116,7 +116,7 @@ export default function NumberingPage() {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(projects as ProjectItem[]).map((project) => (
|
{(projects as ProjectItem[]).map((project) => (
|
||||||
<SelectItem key={String(project.publicId ?? project.id)} value={String(project.publicId ?? project.id)}>
|
<SelectItem key={project.publicId} value={project.publicId}>
|
||||||
{project.projectCode} - {project.projectName}
|
{project.projectCode} - {project.projectName}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ import {
|
|||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
uuid: string;
|
id: string; // ADR-019: uuid exposed as 'id'
|
||||||
id?: number; // Excluded from API responses (ADR-019)
|
|
||||||
projectCode: string;
|
projectCode: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
@@ -72,7 +71,7 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
const confirmDelete = () => {
|
const confirmDelete = () => {
|
||||||
if (projectToDelete) {
|
if (projectToDelete) {
|
||||||
deleteProject.mutate(projectToDelete.uuid, {
|
deleteProject.mutate(projectToDelete.id, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
setProjectToDelete(null);
|
setProjectToDelete(null);
|
||||||
@@ -146,7 +145,7 @@ export default function ProjectsPage() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const handleEdit = (project: Project) => {
|
const handleEdit = (project: Project) => {
|
||||||
setEditingUuid(project.uuid);
|
setEditingUuid(project.id);
|
||||||
reset({
|
reset({
|
||||||
projectCode: project.projectCode,
|
projectCode: project.projectCode,
|
||||||
projectName: project.projectName,
|
projectName: project.projectName,
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ export default function DisciplinesPage() {
|
|||||||
header: 'Code',
|
header: 'Code',
|
||||||
cell: ({ row }) => <span className="font-mono font-bold">{row.getValue('disciplineCode')}</span>,
|
cell: ({ row }) => <span className="font-mono font-bold">{row.getValue('disciplineCode')}</span>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'contract',
|
||||||
|
header: 'Contract',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const contract = row.original.contract;
|
||||||
|
return contract ? (
|
||||||
|
<span className="text-sm">{contract.contractName} ({contract.contractCode})</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-sm">-</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'codeNameTh',
|
accessorKey: 'codeNameTh',
|
||||||
header: 'Name (TH)',
|
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})`,
|
label: `${c.contractName} (${c.contractCode})`,
|
||||||
value: String(c.id),
|
value: String(c.publicId ?? c.id ?? ''),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -86,8 +98,8 @@ export default function DisciplinesPage() {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Contracts</SelectItem>
|
<SelectItem value="all">All Contracts</SelectItem>
|
||||||
{contracts.map((c: { id: number; contractCode: string; contractName: string }) => (
|
{contracts.map((c: { id?: number; publicId?: string; contractCode: string; contractName: string }) => (
|
||||||
<SelectItem key={c.id} value={String(c.id)}>
|
<SelectItem key={String(c.publicId ?? c.id ?? '')} value={String(c.publicId ?? c.id ?? '')}>
|
||||||
{c.contractName} ({c.contractCode})
|
{c.contractName} ({c.contractCode})
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ export default function RfaTypesPage() {
|
|||||||
header: 'Code',
|
header: 'Code',
|
||||||
cell: ({ row }) => <span className="font-mono font-bold">{row.getValue('typeCode')}</span>,
|
cell: ({ row }) => <span className="font-mono font-bold">{row.getValue('typeCode')}</span>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'contract',
|
||||||
|
header: 'Contract',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const contract = row.original.contract;
|
||||||
|
return contract ? (
|
||||||
|
<span className="text-sm">{contract.contractName} ({contract.contractCode})</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-sm">-</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'typeNameTh',
|
accessorKey: 'typeNameTh',
|
||||||
header: 'Name (TH)',
|
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})`,
|
label: `${c.contractName || c.contract_name} (${c.contractCode || c.contract_code})`,
|
||||||
value: String(c.id),
|
value: String(c.publicId ?? c.id ?? ''),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -87,8 +99,8 @@ export default function RfaTypesPage() {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Contracts</SelectItem>
|
<SelectItem value="all">All Contracts</SelectItem>
|
||||||
{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 }) => (
|
||||||
<SelectItem key={c.id} value={String(c.id)}>
|
<SelectItem key={String(c.publicId ?? c.id ?? '')} value={String(c.publicId ?? c.id ?? '')}>
|
||||||
{c.contractName || c.contract_name} ({c.contractCode || c.contract_code})
|
{c.contractName || c.contract_name} ({c.contractCode || c.contract_code})
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -104,11 +116,11 @@ export default function RfaTypesPage() {
|
|||||||
required: true,
|
required: true,
|
||||||
options: contractOptions,
|
options: contractOptions,
|
||||||
},
|
},
|
||||||
{ name: 'type_code', label: 'Code', type: 'text', required: true },
|
{ name: 'typeCode', label: 'Code', type: 'text', required: true },
|
||||||
{ name: 'type_name_th', label: 'Name (TH)', type: 'text', required: true },
|
{ name: 'typeNameTh', label: 'Name (TH)', type: 'text', required: true },
|
||||||
{ name: 'type_name_en', label: 'Name (EN)', type: 'text' },
|
{ name: 'typeNameEn', label: 'Name (EN)', type: 'text' },
|
||||||
{ name: 'remark', label: 'Remark', type: 'textarea' },
|
{ name: 'remark', label: 'Remark', type: 'textarea' },
|
||||||
{ name: 'is_active', label: 'Active', type: 'checkbox' },
|
{ name: 'isActive', label: 'Active', type: 'checkbox' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ export default function TagsPage() {
|
|||||||
|
|
||||||
const projectOptions = [
|
const projectOptions = [
|
||||||
{ label: 'Global (All Projects)', value: '__none__' },
|
{ label: 'Global (All Projects)', value: '__none__' },
|
||||||
...(projectsData || []).map((p: { id: number | string; projectName?: string; projectCode?: string }) => ({
|
...(projectsData || []).map((p: { id?: number; publicId?: string; projectName?: string; projectCode?: string }) => ({
|
||||||
label: (p.projectName || p.projectCode || `Project ${p.id}`) as string,
|
label: (p.projectName || p.projectCode || `Project ${p.publicId || p.id}`) as string,
|
||||||
value: String(p.id), // p.id = UUID string via serialization
|
value: String(p.publicId ?? p.id ?? ''), // ADR-019: publicId is the UUID exposed in API
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ export interface Discipline {
|
|||||||
codeNameEn: string;
|
codeNameEn: string;
|
||||||
codeNameTh?: string;
|
codeNameTh?: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
contract?: {
|
||||||
|
id?: number;
|
||||||
|
publicId?: string;
|
||||||
|
contractCode: string;
|
||||||
|
contractName: string;
|
||||||
|
};
|
||||||
|
contractId?: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RfaType {
|
export interface RfaType {
|
||||||
@@ -25,6 +32,13 @@ export interface RfaType {
|
|||||||
typeNameEn?: string;
|
typeNameEn?: string;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
contract?: {
|
||||||
|
id?: number;
|
||||||
|
publicId?: string;
|
||||||
|
contractCode: string;
|
||||||
|
contractName: string;
|
||||||
|
};
|
||||||
|
contractId?: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Tag {
|
export interface Tag {
|
||||||
|
|||||||
@@ -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) => <SelectItem key={c.id} value={String(c.id)}>)
|
||||||
|
|
||||||
|
// ✅ CORRECT - ใช้ publicId (UUID) ที่ API ส่งมา
|
||||||
|
contracts.map((c) => <SelectItem key={c.publicId} value={c.publicId}>)
|
||||||
|
// หรือ fallback สำหรับ backward compatibility
|
||||||
|
contracts.map((c) => <SelectItem key={c.publicId ?? c.id} value={String(c.publicId ?? c.id)}>)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 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<Discipline>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'disciplineCode',
|
||||||
|
header: 'Code',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'contract', // relation object จาก backend
|
||||||
|
header: 'Contract',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const contract = row.original.contract;
|
||||||
|
return contract ? (
|
||||||
|
<span>{contract.contractName} ({contract.contractCode})</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// ... 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 (สิ่งที่ห้ามทำ)
|
## 🚫 Anti-Patterns (สิ่งที่ห้ามทำ)
|
||||||
|
|
||||||
1. ❌ **ห้ามใช้ Inline Styles** - ใช้ Tailwind เท่านั้น
|
1. ❌ **ห้ามใช้ Inline Styles** - ใช้ Tailwind เท่านั้น
|
||||||
|
|||||||
Reference in New Issue
Block a user