tao-dialog
Tạo Dialog hoặc sửa Dialog, áp dụng component dùng chung và logic từ tao-dialog-new. Đầu vào: mô tả dialog bằng tiếng Việt (các field) hoặc ảnh (Claude model). Tự động đặt tên file theo quy tắc dat-te
Files1
View on GitHubSKILL.md.claude/skills/_archived-sasuco/tao-dialog/SKILL.md
GitHubTạo Dialog / Sửa Dialog — SASUCO InvoiceEasy
Kế thừa:
tao-dialog-new/SKILL.md(pattern + cấu trúc) +tao-ui-giao-dien-new/SKILL.md(component catalog + className chuẩn) Đặt tên file:dat-ten/SKILL.mdXác định phạm vi:xac-dinh-pham-vi/SKILL.md
0. Workflow Tổng Quan
Đầu vào (text / ảnh)
↓
Bước 1: Phân tích đầu vào → trích xuất fields, tên đối tượng, loại dialog
↓
Bước 2: Sinh shortName + tên file theo dat-ten
↓
Bước 3: Xác định phạm vi (tạo mới hay sửa) bằng xac-dinh-pham-vi
↓
Bước 4: Chọn Pattern (A: Danh Mục CRUD / B: Nghiệp Vụ)
↓
Bước 5: Tạo/Sửa types (.types.api.ts + .types.ui.ts)
↓
Bước 6: Tạo/Sửa API Service (nếu chưa có)
↓
Bước 7: Tạo/Sửa Hook dialog (use{SN}.dlg.form.ts)
↓
Bước 8: Tạo/Sửa Dialog component ({SN}Dialog.tsx)
↓
Bước 9: Barrel export (index.ts)
↓
Bước 10: Checklist kiểm tra
0. Font Chữ Toàn Cục — TUYỆT ĐỐI Không Ghi Đè
Global font: Inter (ưu tiên 1) → InterVariable → "Noto Sans" → "Open Sans" → sans-serif
⚠️ QUAN TRỌNG: Font được khai báo tập trung trong
src/styles/globals.csstrênbodyvàhtml. KHÔNG BAO GIỜ setfont-familyriêng cho bất kỳ element nào trong dialog (input,button,label,select,textarea,table). Tất cả element kế thừa font từbody.
Nguyên nhân font đôi khi khác:
- Tailwind v4 dùng
@themeCSS thay vìtailwind.config.js— nếu--font-sanskhông được khai báo trong@theme inline, Tailwind fallback vềui-sans-serif(Segoe UI trên Windows) thay vì Inter. font-familybị set cục bộ trên một component nào đó ghi đè global.
Quy tắc:
- ❌ CẤM:
className='font-sans',style={{ fontFamily: '...' }}, hoặc bất kỳ CSSfont-familynào trên dialog element - ✅ ĐÚNG: Không set
font-family— để element tự kế thừa từbody - ✅ Nếu component bắt buộc cần font vì bị reset (vd:
button,inputtrong một số browser): dùngfont-family: inherit
1. Phân Tích Đầu Vào
1.1 Đầu vào dạng text (tiếng Việt)
Người dùng mô tả dialog bằng tiếng Việt. Agent phải trích xuất:
| Từ mô tả | Trích xuất |
|---|---|
| "Dialog Loại công trình" | Tên đối tượng: Loại công trình → ProjectWorkCategory |
| "gồm các field: Mã, Tên, Mô tả" | Fields: code, name, description |
| "có tab Thông tin chung, Liên hệ" | Tabs: tab-chung, tab-lien-he |
| "chọn Nhóm khách hàng từ dropdown" | FK field: customerGroupId → SearchCombobox |
| "có bảng hạch toán" | Pattern B (Nghiệp Vụ) |
| "trạng thái active/inactive" | Field: isActive (boolean) |
| "ngày sinh, giới tính" | Fields: birthDate (date), gender (enum) |
| "Tổ chức / Cá nhân" | Radio orgType: organizationType field |
Quy tắc dịch tên tiếng Việt → tên field tiếng Anh:
| Tiếng Việt | Tên field | Loại |
|---|---|---|
| Mã | code |
string |
| Tên | name |
string |
| Mô tả / Ghi chú / Diễn giải | description / note / explanation |
string |
| Địa chỉ | address |
string |
| Điện thoại | phone / phoneNumber |
string |
email |
string | |
| Mã số thuế / MST | taxCode |
string |
| Ngày sinh | birthDate / dateOfBirth |
date |
| Ngày cấp | issuedDate |
date |
| Giới tính | gender |
enum (0: Nam, 1: Nữ, 2: Khác) |
| Trạng thái | isActive |
boolean |
| Số tiền | amount / totalAmount |
number |
| Số lượng | quantity |
number |
| Đơn giá | unitPrice |
number |
| Đơn vị tính | unitId / unitName |
FK |
| Nhóm | groupId / groupName |
FK |
| Loại | typeId / typeName |
FK / enum |
| Người đại diện | representativeName |
string |
| Chức danh | position / title |
string |
| CMND/CCCD | identityNumber |
string |
| Nơi cấp | issuedPlace |
string |
| Số tài khoản | accountNumber |
string |
| Ngân hàng | bankName / bankId |
string / FK |
| Tỉnh/Thành phố | provinceId |
FK |
| Quận/Huyện | districtId |
FK |
| Xã/Phường | wardId |
FK |
1.2 Đầu vào dạng ảnh (Claude model)
Khi người dùng cung cấp ảnh chụp màn hình dialog:
- Dùng
view_imageđể đọc ảnh - Phân tích:
- Tiêu đề dialog → tên đối tượng
- Các field trong form → tên + loại (text, select, date, checkbox...)
- Layout: grid hay flex 2 cột, có tab không, có sidebar không
- Các nút footer: Lưu, Hủy, Lưu & Thêm...
- Có bảng hạch toán không
- Có radio Tổ chức/Cá nhân không
- Nếu ảnh từ MISA hoặc web khác → tham khảo thêm
clone-web-playwright/SKILL.mdvàve-theo-template/SKILL.md
1.3 Quy Tắc Hiển Thị FK Từ Danh Mục — TableSearchCombobox (BẮT BUỘC)
⚠️ Khi một field tham chiếu đến entity trong danh mục khác, PHẢI dùng
TableSearchComboboxhiển thị ít nhất Mã + Tên.
Nguyên tắc: Mọi field FK trỏ đến danh mục (khách hàng, nhân viên, kho, đơn vị tính, ngân hàng, tài khoản ngân hàng, hàng hóa, nhóm khách hàng, nhà cung cấp...) → dùng TableSearchCombobox với columns [{ field: 'code', title: 'Mã ...' }, { field: 'name', title: 'Tên ...' }].
Bảng ánh xạ danh mục → TableSearchCombobox columns:
| Field tiếng Việt | Danh mục tham chiếu | Cột hiển thị mặc định | Cột bổ sung (nếu cần) | API Service |
|---|---|---|---|---|
| Khách hàng | AccountObject | Mã khách hàng (code), Tên khách hàng (name) |
MST (taxCode) |
KHApiService |
| Nhà cung cấp | Supplier | Mã NCC (code), Tên NCC (name) |
MST (taxCode) |
NCCApiService |
| Nhân viên | Employee | Mã NV (code), Tên NV (name) |
SĐT (phone) |
NVApiService |
| Kho / Kho hàng | Warehouse | Mã kho (code), Tên kho (name) |
Địa chỉ (address) |
WarehouseApiService |
| Đơn vị tính | Unit | Mã ĐVT (code), Tên ĐVT (name) |
— | UnitApiService |
| Ngân hàng | Bank | Mã NH (code), Tên NH (name) |
— | BankApiService |
| Tài khoản ngân hàng | BankAccount | Số TK (accountNumber), Tên TK (name) |
Ngân hàng (bankName) |
BankAccountApiService |
| Loại hàng hóa | GoodsType | Mã loại (code), Tên loại (name) |
— | GoodsTypeApiService |
| Hàng hóa / Vật tư | Goods | Mã HH (code), Tên HH (name) |
ĐVT (unitName) |
GoodsApiService |
| Nhóm khách hàng | CustomerGroup | Mã nhóm (code), Tên nhóm (name) |
— | CustomerGroupApiService |
| Đối tượng tập hợp CP | CostObject | Mã ĐT (code), Tên ĐT (name) |
— | CostObjectApiService |
| Tài khoản ngầm định | DefaultAccount | Mã TK (code), Tên TK (name) |
— | DefaultAccountApiService |
| Loại tiền / Tiền tệ | Currency | Mã tiền (code), Tên tiền (name) |
— | CurrencyApiService |
| Loại công trình | ProjectWorkCategory | Mã loại (code), Tên loại (name) |
— | PWCApiService |
| Điều khoản thanh toán | PaymentTerm | Mã ĐK (code), Tên ĐK (name) |
Số ngày (days) |
PTApiService |
| TK kế toán (hệ thống) | Account | Số TK (accountNumber), Tên TK (name) |
— | (SearchCombobox do quá nhiều bản ghi) |
Quy tắc bắt buộc:
- Mặc định hiển thị 2 cột: Mã + Tên. Tên cột tiếng Việt phản ánh đúng nghiệp vụ (VD: "Mã khách hàng" chứ không phải "Mã" chung chung).
- Có thể thêm cột thứ 3 nếu user yêu cầu (VD: MST cho Khách hàng/NCC, SĐT cho Nhân viên, Địa chỉ cho Kho).
- Nếu danh mục chưa có API list/search → tạo inbox yêu cầu BE. Sau đó dùng tạm
DmGroupInput withChevronvà ghi chú// TODO: CHỜ API — thay bằng TableSearchCombobox. - Column definitions phải có
widthcụ thể (VD:width: 100cho Mã,width: 220cho Tên). displayFieldlà'name'vàonChangephải set cả id + code + name.
Pattern code mẫu:
// Column definitions
const xxxColumns: TableComboboxColumn[] = [
{ field: 'code', title: 'Mã khách hàng', width: 100 },
{ field: 'name', title: 'Tên khách hàng', width: 220 },
]
// loadOptions function
const loadXxx = async (keyword: string): Promise<TableComboboxRow[]> => {
const r = await XxxApiService.list({ pageIndex: 1, pageSize: 30, keyword })
if (r.success && r.data?.items) {
return r.data.items.map(x => ({ id: x.id ?? '', code: x.code ?? '', name: x.name ?? '', ...x }))
}
return []
}
// Trong JSX
<TableSearchCombobox
value={formData.customerId}
initialLabel={formData.customerName}
displayField='name'
onChange={(id, rowData) => {
updateField('customerId', id)
updateField('customerCode', rowData.code ?? '')
updateField('customerName', rowData.name ?? '')
}}
loadOptions={loadKhachHang}
columns={khColumns}
placeholder='Chọn khách hàng...'
dataQa='sel_khach_hang'
debounceMs={800}
/>
⚠️ Nếu dialog có nhiều FK cùng loại (VD: nhiều dòng chọn khách hàng trong bảng hạch toán), mỗi dòng dùng riêng
TableSearchComboboxhoặc dùng chungloadOptionsnhưng state riêng.
2. Sinh ShortName & Tên File
Quy tắc đầy đủ: xem
dat-ten/SKILL.md
2.1 Từ tên tiếng Việt → shortName
| Tên đối tượng | Tên tiếng Anh | shortName |
|---|---|---|
| Loại công trình | ProjectWorkCategory | PWC |
| Nhóm khách hàng | CustomerGroup | CG |
| Điều khoản thanh toán | PaymentTerm | PT |
| Loại hàng hóa | GoodsType | GT |
| Đơn vị tính | Unit | U → giữ nguyên Unit (≤2 từ) |
| Kho | Warehouse | Warehouse |
| Nhân viên | Employee | E → NV (theo convention VN) |
| Khách hàng | Customer / AccountObject | KH |
| Nhà cung cấp | Supplier | NCC |
| Phiếu thu | ReceiptVoucher | PT |
| Phiếu chi | PaymentVoucher | PC |
⚠️ Ưu tiên shortName theo convention dự án. Nếu đối tượng đã có shortName trong codebase → dùng lại. Dùng
grep_searchđể kiểm tra.
2.2 Danh sách file cần tạo
features/{nhom}/{ten-feature}/
├── types/
│ └── {SN}.types.api.ts ← Enums + DTO từ BE
│ └── {SN}.types.ui.ts ← FormState, initial values, errors type
├── services/
│ └── {SN}ApiService.ts ← CRUD API
├── hooks/
│ └── use{SN}.dlg.form.ts ← Hook dialog (submit, validate, init, reset)
├── dialogs/
│ └── {SN}Dialog.tsx ← Dialog component
└── index.ts ← Barrel exports
Tên thư mục feature: kebab-case, phản ánh nghiệp vụ. VD: loai-cong-trinh, nhom-khach-hang.
3. Xác Định Phạm Vi — Tạo Mới Hay Sửa
Dùng
xac-dinh-pham-vi/SKILL.mdđể tìm file hiện có.
3.1 Tạo mới (không có file cũ)
- Tìm feature trong index:
grep_searchvới từ khóa tên đối tượng - Nếu không thấy → tạo mới hoàn toàn → hỏi user xác nhận portal + nhóm feature
3.2 Sửa dialog hiện có
- Tìm thấy dialog trong index → đọc file hiện có
- Phân tích diff: cần thêm/xóa/sửa field gì
- Không sửa những phần không liên quan
- Nếu thêm field mới → kiểm tra DTO BE đã có chưa, nếu chưa → tạo inbox BE
4. Chọn Pattern
Chi tiết pattern:
tao-dialog-new/SKILL.md
| Điều kiện | Pattern | File mẫu |
|---|---|---|
| Form phức tạp, bảng hạch toán, full-screen, nhiều section | B: Full-Screen Nghiệp Vụ | PTDialog.tsx |
| Form đơn giản (≤15 fields), CRUD danh mục, tabs | A: Dialog Danh Mục CRUD | KHFormDialog.tsx |
⚠️ Quy tắc bảng editable trong dialog: Nếu dialog có bảng dữ liệu dạng editable (thêm/xóa/sửa dòng) → BẮT BUỘC load skill
tao-bang-data/SKILL.mdvà dùng componentEditableDataTabletừ@/modules/KetoanApp/components. Với các cell cần custom behavior (TableSearchCombobox, disabled có điều kiện...), dùngrenderprop trênEditableColumnDef. Không tự viết<table>thủ công khi đã cóEditableDataTable.
4.1 Trong Pattern A: Chọn Layout
| Layout | Dùng khi |
|---|---|
grid grid-cols-5 gap-x-3 gap-y-3 (DmFormField) |
Form đều các field, không có sidebar |
flex gap-6 2 cột (Label+Input) |
Layout 2 cột trái-phải rõ rệt (Mã+Tên+Đơn vị | Ngày sinh+Giới tính+CMND) |
5. Tạo Types
5.1 {SN}.types.api.ts — Enums + DTO
// Enum (nếu có)
export enum XxxStatus {
Active = 1,
Inactive = 0,
}
// DTO từ BE
export interface XxxDto {
id?: string
code?: string
name?: string
description?: string
isActive?: boolean
// ... các field khác từ BE
}
⚠️ TUYỆT ĐỐI CẤM tự thêm field vào DTO. Nếu UI cần field mà BE chưa có → tạo inbox/yêu cầu BE bổ sung.
5.2 {SN}.types.ui.ts — Form State
import type { XxxDto } from './{SN}.types.api'
export interface XxxFormState {
code: string
name: string
description: string
isActive: boolean
// ... tất cả field UI
}
export const INITIAL_XXX_FORM: XxxFormState = {
code: '',
name: '',
description: '',
isActive: true,
}
export interface XxxFormErrors {
code?: string
name?: string
description?: string
// ...
}
6. Tạo API Service
Pattern đầy đủ:
tao-apiservice/SKILL.md
// {SN}ApiService.ts
import { apiCall, buildApiUrl, ApiResponse, PagingInfo } from '@/shared/services/api'
import type { XxxDto } from '../types/{SN}.types.api'
const BASE_PATH = '/api/xxx/v1/xxx'
export const XxxApiService = {
async list(params: { pageIndex: number; pageSize: number; keyword?: string }): Promise<ApiResponse<PagingInfo<XxxDto>>> {
return apiCall(buildApiUrl(BASE_PATH, params))
},
async getById(id: string): Promise<ApiResponse<XxxDto>> {
return apiCall(buildApiUrl(`${BASE_PATH}/${id}`))
},
async create(data: Partial<XxxDto>): Promise<ApiResponse<XxxDto>> {
return apiCall(buildApiUrl(BASE_PATH), { method: 'POST', body: JSON.stringify(data) })
},
async update(id: string, data: Partial<XxxDto>): Promise<ApiResponse<XxxDto>> {
return apiCall(buildApiUrl(`${BASE_PATH}/${id}`), { method: 'PUT', body: JSON.stringify(data) })
},
async delete(id: string): Promise<ApiResponse<void>> {
return apiCall(buildApiUrl(`${BASE_PATH}/${id}`), { method: 'DELETE' })
},
}
7. Tạo Hook Dialog
⚠️ BẮT BUỘC: Hook phải reset
formDatamỗi lần dialog mở. Đây là yêu cầu cốt lõi của skill này.
7.1 Pattern Hook Chuẩn (Có Reset)
// use{SN}.dlg.form.ts
import { useState, useEffect, useCallback } from 'react'
import { validateAllFields, hasAnyError } from '@/shared/utils/ValidationUtils'
import { showValidationErrorsToast } from '@/shared/utils/ValidationToastHelper'
import { XxxApiService } from '../services/{SN}ApiService'
import type { XxxDto } from '../types/{SN}.types.api'
import type { XxxFormState, XxxFormErrors } from '../types/{SN}.types.ui'
import { INITIAL_XXX_FORM } from '../types/{SN}.types.ui'
export function useXxxDialogForm(initialData: XxxDto | null, onSuccess?: () => void) {
const mode = initialData ? 'edit' as const : 'create' as const
// ── State ──
const buildInitial = (data: XxxDto | null): XxxFormState => {
if (!data) return { ...INITIAL_XXX_FORM }
return {
code: data.code ?? '',
name: data.name ?? '',
description: data.description ?? '',
isActive: data.isActive ?? true,
}
}
const [formData, setFormData] = useState<XxxFormState>(() => buildInitial(initialData))
const [errors, setErrors] = useState<XxxFormErrors>({})
const [touched, setTouched] = useState<Record<string, boolean>>({})
const [submitting, setSubmitting] = useState(false)
const [serverError, setServerError] = useState<string[] | null>(null)
const [serverErrorOpen, setServerErrorOpen] = useState(false)
// ── ⚠️ RESET KHI initialData THAY ĐỔI (BẮT BUỘC) ──
useEffect(() => {
setFormData(buildInitial(initialData))
setErrors({})
setTouched({})
setServerError(null)
setServerErrorOpen(false)
setSubmitting(false)
}, [initialData])
// ── Update field ──
const updateField = useCallback((field: string, value: unknown) => {
setFormData(prev => ({ ...prev, [field]: value }))
// Xóa lỗi khi user sửa
setErrors(prev => { const next = { ...prev }; delete next[field as keyof XxxFormErrors]; return next })
}, [])
// ── Blur handler ──
const handleBlur = useCallback((field: string) => {
setTouched(prev => ({ ...prev, [field]: true }))
// Validate field đơn
const value = formData[field as keyof XxxFormState]
if (field === 'code' && !String(value ?? '').trim()) {
setErrors(prev => ({ ...prev, code: 'Mã không được để trống' }))
} else if (field === 'name' && !String(value ?? '').trim()) {
setErrors(prev => ({ ...prev, name: 'Tên không được để trống' }))
}
}, [formData])
// ── Clear errors ──
const clearErrors = useCallback(() => {
setErrors({})
setTouched({})
setServerError(null)
setServerErrorOpen(false)
}, [])
// ── Auto fill code ──
const autoFillCode = useCallback(async () => {
// Gọi API preview code nếu có
try {
const r = await XxxApiService.previewCode?.()
if (r?.success && r.data) {
setFormData(prev => ({ ...prev, code: r.data! }))
}
} catch { /* ignore */ }
}, [])
// ── Validate tất cả fields ──
const validateAll = useCallback((): boolean => {
const allErrors: XxxFormErrors = {}
if (!formData.code?.trim()) allErrors.code = 'Mã không được để trống'
if (!formData.name?.trim()) allErrors.name = 'Tên không được để trống'
// ... thêm validate khác
setErrors(allErrors)
// Đánh dấu tất cả field đã touched
const allTouched: Record<string, boolean> = {}
Object.keys(formData).forEach(k => { allTouched[k] = true })
setTouched(allTouched)
if (hasAnyError(allErrors)) {
showValidationErrorsToast(allErrors)
return false
}
return true
}, [formData])
// ── Submit ──
const handleSubmit = useCallback(async (): Promise<boolean> => {
if (!validateAll()) return false
setSubmitting(true)
setServerError(null)
try {
const payload: Partial<XxxDto> = {
code: formData.code || undefined,
name: formData.name,
description: formData.description || undefined,
isActive: formData.isActive,
}
let result
if (mode === 'edit' && initialData?.id) {
result = await XxxApiService.update(initialData.id, payload)
} else {
result = await XxxApiService.create(payload)
}
if (result.success) {
onSuccess?.()
return true
} else {
setServerError([result.message ?? 'Có lỗi xảy ra'])
setServerErrorOpen(true)
return false
}
} catch (e: any) {
setServerError([e?.message ?? 'Có lỗi xảy ra'])
setServerErrorOpen(true)
return false
} finally {
setSubmitting(false)
}
}, [formData, mode, initialData, onSuccess, validateAll])
return {
formData, setFormData, updateField, handleBlur, clearErrors, autoFillCode,
errors, touched, submitting, mode,
serverError, serverErrorOpen, setServerErrorOpen,
handleSubmit, validateAll,
}
}
7.2 Pattern Reset Khi Có open Prop (Khuyến nghị)
Khi dialog không unmount (chỉ ẩn/hiện), useEffect([initialData]) có thể không chạy nếu initialData cùng tham chiếu. Pattern an toàn hơn:
// Hook nhận thêm `open`
export function useXxxDialogForm(initialData: XxxDto | null, onSuccess?: () => void, open?: boolean) {
// ...
// Reset khi dialog mở HOẶC initialData thay đổi
useEffect(() => {
if (open === false) return // <-- QUAN TRỌNG: không reset khi đóng
setFormData(buildInitial(initialData))
setErrors({})
setTouched({})
setServerError(null)
setServerErrorOpen(false)
setSubmitting(false)
}, [initialData, open])
}
Gọi từ component:
const hookData = useXxxDialogForm(editMode ? initialData : null, onSuccess, open)
8. Tạo Dialog Component
8.0 Quy Tắc Mặc Định Khi Tạo Dialog
⚠️ Các quy tắc sau áp dụng cho MỌI dialog được tạo ra từ skill này.
| # | Quy tắc | Mô tả |
|---|---|---|
| 1 | Không thêm placeholder |
Mặc định KHÔNG thêm placeholder cho input, select, datepicker, textarea. Chỉ thêm placeholder khi user yêu cầu cụ thể. |
| 2 | Label font-bold |
Tất cả label trong dialog (DmFormField, Label, DmTabFieldValue) đều dùng font-bold. Đối với Pattern A: DmFormField tự xử lý. Đối với Pattern B: className='text-[13px] font-bold text-black'. |
| 3 | Border #AEB3BA |
Tất cả input, select, datepicker, textarea dùng border-[#AEB3BA]. |
| 4 | Không border-b ở header |
Header dialog KHÔNG có border-b. Chỉ border-t ở footer dialog dùng border-[#AEB3BA]. |
| 5 | Font text-[13px] đồng bộ page |
Font chữ trong dialog đồng bộ với page bên ngoài: text-[13px] cho label, input, select, datepicker, textarea, view mode. Pattern A: DmFormField/DmFormInput tự xử lý. Pattern B: thêm text-[13px] vào Label và Input. View mode: <div className='text-[13px] text-black'>. |
| 6 | FK danh mục → TableSearchCombobox |
Mọi field FK trỏ đến danh mục khác (Khách hàng, NCC, Nhân viên, Kho, ĐVT, Ngân hàng, TK ngân hàng, Hàng hóa, Nhóm KH, ĐT THCP...) PHẢI dùng TableSearchCombobox hiển thị ít nhất Mã + Tên (code + name). Nếu danh mục chưa có API → tạo inbox BE, dùng tạm DmGroupInput. Xem chi tiết → Section 1.3. |
8.1 Pattern A — Dialog Danh Mục CRUD
// {SN}Dialog.tsx
import { useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/components/ui/dialog'
import { Button } from '@/shared/components/ui/button'
import { X } from 'lucide-react'
import { ValidationErrorDialog } from '@/shared/components/common'
import {
DmFormField, DmFormInput, DmFormTextarea,
DmFieldValue, DmDialogFooter,
} from '@/modules/KetoanApp/components'
import { useXxxDialogForm } from '../hooks/use{SN}.dlg.form'
import type { XxxDto } from '../types/{SN}.types.api'
interface Props {
open: boolean
onOpenChange: (v: boolean) => void
initialData?: XxxDto | null
viewOnly?: boolean
onSuccess?: () => void
}
export function XxxDialog({ open, onOpenChange, initialData, viewOnly, onSuccess }: Props) {
const isView = viewOnly === true
const isCreate = !initialData
const editMode = !!initialData && !isView
const isReadonly = isView
const {
formData, updateField, handleBlur, clearErrors, autoFillCode,
errors, submitting, mode,
serverError, serverErrorOpen, setServerErrorOpen,
handleSubmit,
} = useXxxDialogForm(editMode ? initialData : null, onSuccess)
const f = formData as Record<string, unknown>
// Init khi mở dialog
useEffect(() => {
if (!open) { clearErrors(); return }
if (mode === 'create') autoFillCode()
}, [open, clearErrors, autoFillCode, mode])
const doSubmit = async () => {
const ok = await handleSubmit()
if (!ok) return
onOpenChange(false)
}
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent maxWidth='920px' className='max-h-[90vh] flex flex-col gap-0 p-0 overflow-hidden'>
{/* ── Header ── */}
<DialogHeader className='flex-none px-5 py-3'>
<div className='flex items-center gap-2'>
<DialogTitle className='text-[20px] font-bold text-gray-900 shrink-0'>
{isCreate ? 'Thêm mới' : isView ? 'Xem chi tiết' : 'Chỉnh sửa'} {tên đối tượng}
</DialogTitle>
<Button variant='ghost' size='sm'
className='ml-auto h-7 w-7 p-0 text-gray-400 hover:text-gray-600 rounded-[8px]'
onClick={() => onOpenChange(false)} data-qa='btn_dong_dialog'>
<X className='h-4 w-4' />
</Button>
</div>
</DialogHeader>
{/* ── Body ── */}
<div className='flex-1 min-h-0 overflow-y-auto px-5 py-4'>
<form id='xxx-form' onSubmit={(e) => { e.preventDefault(); doSubmit() }}>
<div className='grid grid-cols-5 gap-x-3 gap-y-3'>
<DmFormField label='Mã' required error={errors.code} tooltip='Tự động tạo nếu để trống'>
{isReadonly
? <DmFieldValue value={f.code as string} />
: <DmFormInput data-qa='i_code'
value={(f.code as string) ?? ''}
onChange={(e) => updateField('code', e.target.value)}
onBlur={() => handleBlur('code')}
placeholder='' />}
</DmFormField>
<DmFormField label='Tên' required className='col-span-4' error={errors.name}>
{isReadonly
? <DmFieldValue value={f.name as string} />
: <DmFormInput data-qa='i_name'
value={(f.name as string) ?? ''}
onChange={(e) => updateField('name', e.target.value)}
onBlur={() => handleBlur('name')} />}
</DmFormField>
<DmFormField label='Mô tả' className='col-span-5'>
{isReadonly
? <DmFieldValue value={f.description as string} />
: <DmFormTextarea data-qa='i_description' rows={2}
value={(f.description as string) ?? ''}
onChange={(e) => updateField('description', e.target.value)} />}
</DmFormField>
</div>
</form>
</div>
{/* ── Footer ── */}
<DmDialogFooter
isView={isView}
submitting={submitting}
onClose={() => onOpenChange(false)}
onSave={() => doSubmit()}
/>
<ValidationErrorDialog
open={serverErrorOpen}
onOpenChange={setServerErrorOpen}
errors={serverError}
/>
</DialogContent>
</Dialog>
</>
)
}
8.2 Pattern B — Full-Screen Nghiệp Vụ
Xem chi tiết trong tao-dialog-new/SKILL.md Section 2. Cấu trúc chính:
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent maxWidth='none'
className='w-[100vw] max-w-[100vw] h-[100vh] max-h-[100vh] p-0 gap-0
rounded-none shadow-[0_5px_20px_rgba(0,0,0,0.1)] border-0
flex flex-col bg-white overflow-hidden [&>button:first-child]:hidden'>
{/* Header: title + select + tổng tiền + nút X */}
{/* Body: flex gap-6 (main + sidebar) + bảng hạch toán */}
{/* Footer: Hủy + Lưu (btn-secondary + btn-primary) */}
</DialogContent>
</Dialog>
9. Barrel Export
// index.ts
export { XxxDialog } from './dialogs/{SN}Dialog'
export { useXxxDialogForm } from './hooks/use{SN}.dlg.form'
export { XxxApiService } from './services/{SN}ApiService'
export type { XxxDto } from './types/{SN}.types.api'
export type { XxxFormState, XxxFormErrors } from './types/{SN}.types.ui'
10. Xử Lý Ảnh Đầu Vào (Claude Model)
Khi nhận ảnh chụp màn hình dialog:
view_imageđể đọc ảnh- Phân tích layout:
- Tiêu đề → tên đối tượng
- Các field → tên + loại (text, select, date, checkbox)
- Grid layout → Pattern A grid-cols-5 hay flex 2 cột
- Tabs → nội dung từng tab
- Footer → các nút
- Có bảng hạch toán → Pattern B
- Đối chiếu với convention:
- Field MISA có mà BE chưa có → render UI, gửi qua formData, tạo inbox BE
- Không tự ý thêm/bớt field so với ảnh
- Giữ nguyên thứ tự field như trong ảnh
- Giữ nguyên tên tab như trong ảnh
- Sinh code theo pattern tương ứng
11. Reset Form Khi Đóng/Mở Lại (BẮT BUỘC)
⚠️ Đây là yêu cầu cốt lõi của skill
tao-dialog. Mọi dialog tạo ra PHẢI reset đúng cách.
| Tình huống | Hành vi đúng | Cách implement |
|---|---|---|
| Đóng dialog → mở lại với record khác | Form reset về giá trị từ initialData mới |
useEffect([initialData]) trong hook |
| Đóng dialog → mở lại để tạo mới | Form trắng, autoFillCode() chạy lại |
useEffect([open]) với if (!open) return |
| Chuyển từ edit record A → edit record B (không đóng dialog) | Form cập nhật theo record B | useEffect([initialData?.id]) |
| Đóng dialog → mở lại View cùng record | Form hiển thị đúng initialData |
useEffect([open, initialData?.id]) |
Pattern An Toàn Nhất (Khuyến nghị)
// Trong hook:
useEffect(() => {
if (open === false) return // Không reset khi đóng
setFormData(buildInitial(initialData))
setErrors({})
setTouched({})
setServerError(null)
setServerErrorOpen(false)
setSubmitting(false)
}, [initialData, open]) // Cả 2 dependency
// Trong component:
const hookData = useXxxDialogForm(
editMode ? initialData : null,
onSuccess,
open, // Truyền open vào hook
)
12. Checklist Trước Commit
- Đã xác định đúng portal + nhóm feature (
xac-dinh-pham-vi) - Chọn đúng pattern (A: Danh Mục / B: Nghiệp Vụ)
- Sinh đúng shortName + tên file (
dat-ten) - Types: DTO khớp BE, không tự thêm field
- API Service: đúng
BASE_PATH, CRUD đầy đủ - Hook: có
useEffectreset khiinitialDatahoặcopenthay đổi - Label
font-bold: tất cả label trong dialog dùngfont-bold(Pattern B:font-bold text-black; Pattern A: DmFormField tự xử lý) - Không thêm
placeholdermặc định cho input/select/datepicker/textarea - Border
#AEB3BA: input, select, datepicker, textarea dùngborder-[#AEB3BA] - Không
border-bở header: header dialog không cóborder-b, chỉborder-tở footer dùngborder-[#AEB3BA] - Font
text-[13px]đồng bộ page: label, input, select, datepicker, textarea, view mode dùngtext-[13px](Pattern B:LabelvàInputthêmtext-[13px]) - Dialog:
maxWidthđúng, 3 mode (View/Create/Edit) -
isReadonlyflag cho tất cả fields - Validate onBlur + inline error message
-
ValidationErrorDialogcho lỗi server -
data-qađầy đủ (btn_,i_,sel_,dt_,chk_,r_,tab_) - View mode hiển thị
<div>/DmFieldValue(không dùng disabled input) - Input số:
type='text' inputMode='numeric'(Pattern B) - Footer đúng: mặc định [Hủy] [Lưu], "Lưu & Thêm" chỉ khi user yêu cầu
- Nút X close custom (ẩn mặc định Radix)
-
SearchCombobox/TableSearchComboboxcóinitialLabelkhi sửa - ⚠️ FK ID resolve: nếu combobox
valuelà ID nhưng DTO không có → thêmuseEffectresolve FK ID (xemtao-dialog-new3.1.1) - ⚠️ FK danh mục dùng
TableSearchCombobox: mọi field FK trỏ đến danh mục phải hiển thị dạngTableSearchComboboxvới ít nhất Mã + Tên. Nếu danh mục chưa có API → tạo inbox BE, dùng tạmDmGroupInput. (xem Section 1.3) - Barrel
index.tsexport dialog - ⚠️ Reset state khi đóng/mở lại dialog: form phải theo giá trị mới, không giữ giá trị cũ
- Compile không lỗi, TypeScript check pass
13. Kế Thừa & Liên Kết Skill
| Khi cần | Load skill |
|---|---|
| Xác định file cần sửa | xac-dinh-pham-vi/SKILL.md |
| Pattern dialog chi tiết | tao-dialog-new/SKILL.md |
| Component catalog + className | tao-ui-giao-dien-new/SKILL.md |
| Quy tắc UI nền tảng | tao-ui-giao-dien/SKILL.md |
| Đặt tên file | dat-ten/SKILL.md |
| Tạo API Service | tao-apiservice/SKILL.md |
| Validate input | validate-input/SKILL.md |
| Cấu trúc thư mục | cau-truc-du-an/SKILL.md |
| Quy tắc code TS/React | quy-tac-code/SKILL.md |
| Filter + phân trang (nếu có master page) | filter-phan-trang/SKILL.md |
| Thêm nút "+" tạo nhanh FK | them-nhanh-fk/SKILL.md |
| DatePicker / ngày tháng | date-input/SKILL.md |
| Tạo bảng dữ liệu editable trong dialog | tao-bang-data/SKILL.md |
| Clone web bằng Playwright | clone-web-playwright/SKILL.md |
| Vẽ theo ảnh template | ve-theo-template/SKILL.md |
| Checklist sau code | checklist-sau-code/SKILL.md |
14. Ví Dụ Cụ Thể
Ví dụ 1: "Tạo dialog Loại công trình gồm field Mã, Tên, Mô tả"
- Phân tích: Tên = "Loại công trình" →
ProjectWorkCategory, shortName =PWC - Pattern: A (Danh Mục CRUD, 3 fields)
- Files:
features/danh-muc/loai-cong-trinh/types/PWC.types.api.tsfeatures/danh-muc/loai-cong-trinh/types/PWC.types.ui.tsfeatures/danh-muc/loai-cong-trinh/services/PWCApiService.tsfeatures/danh-muc/loai-cong-trinh/hooks/usePWC.dlg.form.tsfeatures/danh-muc/loai-cong-trinh/dialogs/PWCDialog.tsxfeatures/danh-muc/loai-cong-trinh/index.ts
- Layout:
grid-cols-5, Mã (1 cột) + Tên (3 cột) + Mô tả (5 cột)
Ví dụ 2: "Sửa dialog Nhân viên, thêm field Số điện thoại"
- Xác định phạm vi:
grep_searchtìmNVFormDialog.tsx - Đọc file hiện có: dialog + hook + types
- Kiểm tra DTO:
phoneđã có trongEmployeeDtochưa?- Có → thêm field vào form
- Chưa → tạo inbox BE → chờ → mới thêm
- Sửa files:
NV.types.ui.ts: thêmphone: stringvàoEmployeeFormStateuseNV.dlg.form.ts: thêm validate chophoneNVFormDialog.tsx: thêm<DmFormField label='Điện thoại'>
- KHÔNG sửa: các field khác, layout khác không liên quan
Ví dụ 3: "Tạo dialog từ ảnh chụp MISA"
view_image→ phân tích ảnh- Trích xuất: tên dialog, danh sách field, tabs, layout
- Đối chiếu convention → chọn pattern
- Sinh code như ví dụ 1
- Field MISA có mà BE chưa có → tạo inbox BE