tao-ui-dialog
Quy tắc tạo Dialog/Form. Cấu trúc: DialogHeader+DialogContent+DialogFooter, 3 mode (View/Create/Edit), maxWidth bắt buộc, nút X custom, validate onBlur, input lỗi border-destructive, input số type=tex
Files18
View on GitHubSKILL.md.claude/skills/_archived-sasuco/tao-ui-dialog/SKILL.md
GitHubDialog / Form
Foundation:
tao-ui-giao-dien. Kiểm tra:check-dialog.cjs(tự động chạy trongcheck-all.cjs).
maxWidth — BẮT BUỘC
| Form | maxWidth | className |
|---|---|---|
| Nhỏ ≤4 fields | — | max-h-[90vh] |
| Trung bình CRUD | '600px' |
w-[600px] max-h-[90vh] overflow-y-auto |
| Lớn | '800px' |
w-[800px] max-h-[90vh] |
| Nhiều tab | '920px' |
w-[920px] max-h-[90vh] |
| In A4 | '210mm' |
w-[210mm] max-h-[92vh] p-0 |
⚠️ CÁCH DÙNG ĐÚNG (kinh nghiệm thực tế)
DialogContent KHÔNG truyền
maxWidthprop → bị giới hạnsm:max-w-lg= 512px, MỌIw-[...]trong className đều KHÔNG hiệu lực!
❌ SAI — chỉ có className w-[...], không có maxWidth → dialog luôn 512px dù khai 900px
<DialogContent className='w-[900px] max-h-[92vh] overflow-y-auto'>
❌ SAI — nhét maxWidth vào className string (không phải prop) → vẫn không hiệu lực
<DialogContent className='maxWidth="800px" w-[800px] max-h-[92vh]'>
✅ ĐÚNG — truyền maxWidth prop + className w-[...] KHỚP nhau
<DialogContent maxWidth='900px' className='w-[900px] max-h-[92vh] overflow-y-auto'>
Quy tắc:
maxWidthlà prop riêng củaDialogContent(nhập qua inline style) — KHÔNG được nhét vàoclassName.- Giá trị
maxWidthphải KHỚP vớiw-[...]trong className (VD:maxWidth='900px'↔w-[900px]). - Mọi dialog có
w-[...]bắt buộc kèmmaxWidthtương ứng — nếu không, width không hiệu lực (bị fallback 512px).
Cấu Trúc
⚠️ Title dialog: Luôn dùng
text-[20px]choDialogTitle— đồng nhất trên tất cả dialog.
<DialogContent maxWidth='600px' className='w-[600px] max-h-[90vh] overflow-y-auto'>
<DialogHeader>
<div className='flex items-center justify-between'>
<DialogTitle className='text-[20px]'>{mode === 'view' ? 'Xem chi tiết' : mode === 'edit' ? 'Chỉnh sửa' : 'Thêm mới'}</DialogTitle>
<Button variant='ghost' size='sm' data-qa='btn_dong_dialog' onClick={() => onOpenChange(false)}><X className='h-4 w-4'/></Button>
</div>
</DialogHeader>
<div className='space-y-4 py-4'>
{/* Input fields */}
</div>
<DialogFooter className='flex justify-end gap-3 pt-4 border-t'>
{mode === 'view'
? <Button className='btn-secondary' data-qa='btn_dong' onClick={() => onOpenChange(false)}>Đóng</Button>
: <>
<Button className='btn-secondary' data-qa='btn_huy' disabled={submitting} onClick={() => onOpenChange(false)}>Hủy</Button>
<Button className='btn-primary' data-qa='btn_luu' type='submit' disabled={submitting}>
{submitting && <Loader2 className='animate-spin h-4 w-4 mr-2'/>}
{submitting ? 'Đang lưu...' : mode === 'create' ? 'Tạo mới' : 'Lưu'}
</Button>
</>
}
</DialogFooter>
</DialogContent>
Footer — Quy Tắc Button
Áp dụng cho cả
DmDialogFootervàDialogFooterinline.
| Nút | Class | Màu sắc |
|---|---|---|
| Lưu / Tạo mới / Lưu & Thêm | btn-primary |
bg-[#1565C0] text-white (primary) |
| Hủy / Đóng | btn-secondary |
bg-white border border-gray-300 text-gray-700 |
| Xóa | btn-danger |
bg-[#f44336] text-white (phải kèm ConfirmDialog) |
Dùng DmDialogFooter (khuyến nghị cho KetoanApp):
import { DmDialogFooter } from '@/modules/KetoanApp/components'
<DmDialogFooter
isView={isReadonly}
isCreate={isCreate}
submitting={submitting}
onClose={() => onOpenChange(false)}
onSave={doSubmit}
onSaveAndAdd={doSubmitAndAdd} // chỉ hiện khi isCreate=true
/>
isView: [Đóng]isCreate: [Hủy] ── [Lưu] [Lưu & Thêm]- Edit (không isCreate, không isView): [Hủy] ── [Lưu]
⚠️ TUYỆT ĐỐI KHÔNG dùng
variant='outline'cho nút Lưu. Phải dùngclassName='btn-primary rounded-[8px]'.
Input Fields
- Validate onBlur — KHÔNG validate onChange
- Lỗi:
border-destructive+<AlertCircle className='h-3 w-3'/>inline - Input số:
type='text' inputMode='numeric'+text-right - Textarea:
className='invoice-textarea' - FK field:
<SearchCombobox dataQa='cmb_...' /> - View mode:
<div className='px-3 py-2 text-gray-900'>(KHÔNG dùng input disabled) - DatePicker:
data-qa='dt_...'
Địa Chỉ (Vị Trí Địa Lý) — Dùng DmAddressFields
Khi dialog có tab "Địa chỉ khác" hoặc form chứa trường địa chỉ (Tỉnh/TP, Quận/Huyện, Xã/Phường), dùng component
DmAddressFieldstừ@/modules/KetoanApp/components.
Cấu trúc 4 field:
| Field | Loại | Nguồn dữ liệu |
|---|---|---|
| Quốc gia | Input disabled | Mặc định "Việt Nam" |
| Tỉnh/Thành phố | Select | API /api/base/v1/provinces |
| Quận/Huyện | Select | API /api/base/v1/communes?provinceId=X |
| Xã/Phường | Select | API /api/base/v1/communes?provinceId=X |
Sử dụng trong DmShippingAddressTab:
import { DmAddressFields } from '@/modules/KetoanApp/components'
<DmAddressFields
formData={f}
updateField={updateField}
isReadonly={isReadonly}
showLabels={false} // ẩn label khi dùng trong tab có title riêng
dataQaPrefix='i' // → data-qa='i_province', 'i_district', 'i_ward'
/>
Sử dụng độc lập (có label):
<DmAddressFields
formData={f}
updateField={updateField}
isReadonly={isReadonly}
showLabels={true} // hiển thị label "Tỉnh/Thành phố", "Quận/Huyện", "Xã/Phường"
gridCols='grid-cols-2' // layout 2 cột
dataQaPrefix='addr'
/>
Lưu ý:
DmAddressFieldstự quản lý việc gọi API qua hookuseAddressData— không cần truyền props loadOptions- Khi chọn tỉnh → tự reset quận/huyện và xã/phường
- Khi chọn quận/huyện → tự reset xã/phường
- Nếu chưa chọn tỉnh, select Quận/Huyện và Xã/Phường hiển thị "Chưa có dữ liệu"
2 Cột: grid grid-cols-2 gap-4, field rộng thêm col-span-2
Nhiều Tab: maxWidth='920px', <Tabs> trong body, data-qa='tab_...'
Quy Tắc Field Thiếu — Cấm Tự Thêm Vào Type
⚠️ TUYỆT ĐỐI CẤM: Tự ý thêm field vào type DTO (
.types.api.ts). Nếu UI có field mà BE chưa có → phải yêu cầu BE bổ sung, không tự thêm vào type. Lý do: BE và FE phải thống nhất DTO. FE tự thêm field → API lỗi hoặc bỏ qua field → mất dữ liệu.
Reset State Khi Đóng/Mở Lại Dialog (BẮT BUỘC)
⚠️ Khi dialog đóng rồi mở lại, state phải theo
initialDatamới, KHÔNG được giữ giá trị cũ.
| Tình huống | Hành vi đúng | Hành vi sai |
|---|---|---|
| Đóng dialog → mở lại với record khác | Form reset về giá trị từ initialData mới |
❌ Form vẫn giữ giá trị record cũ |
| Đóng dialog → mở lại để tạo mới | Form trắng, autoFillCode() chạy lại |
❌ Form còn data cũ từ lần edit trước |
| Đóng dialog → mở lại View cùng record | Form hiển thị đúng initialData |
❌ Form hiển thị data cũ đã bị sửa (chưa save) |
Pattern đúng trong hook (truyền open vào hook):
// Hook nhận thêm `open` để reset mỗi lần dialog mở
export function useXxxDialogForm(initialData: XxxDto | null, onSuccess, onClose, cloneMode, open: boolean) {
const [formData, setFormData] = useState<XxxFormState>(() => buildInitial(initialData))
// Reset khi dialog mở HOẶC initialData/cloneMode thay đổi
useEffect(() => {
if (open === false) return // <-- QUAN TRỌNG: không reset khi đóng dialog
setFormData(buildInitial(initialData))
setErrors({})
setTouched({})
setServerError(null)
setServerErrorOpen(false)
setSubmitting(false)
}, [initialData, cloneMode, open]) // <-- open là key dependency
}
Gọi từ component:
const hookData = useXxxDialogForm(
isClone ? cloneFrom : (editMode ? initialData : null),
() => { onOpenChange(false); onSuccess?.() },
() => onOpenChange(false),
isClone,
open, // <-- truyền open từ props
)
Nguyên nhân lỗi phổ biến #1: Hook chỉ dùng
useState(initial)— component không unmount khi đóng dialog (chỉ ẩn), nênuseStatekhông chạy lại. Khi mở dialog với record mới, state vẫn là giá trị cũ.Nguyên nhân lỗi phổ biến #2:
useEffectchỉ có[initialData, cloneMode]— khi đóng dialog tạo mới rồi mở lại tạo mới,initialDatavẫn lànullvàcloneModevẫn làfalse, không thay đổi → useEffect không chạy. Phải thêmopenvào deps để reset mỗi lần dialog mở.
Script Kiểm Tra (tự động trong check-all.cjs)
# Chạy riêng 1 portal / feature
node .claude/skills/checklist-sau-code/scripts/check-for-skill/check-dialog.cjs src/modules/KiemThuApp
node .claude/skills/checklist-sau-code/scripts/check-for-skill/check-dialog.cjs src/modules/KiemThuApp features/release-versions
3 checks phát hiện:
| # | Lỗi | Severity |
|---|---|---|
| 1 | DialogContent có w-[...] NHƯNG thiếu prop maxWidth → width không hiệu lực (bị giới hạn sm:max-w-lg 512px) |
🔴 HIGH |
| 2 | Cú pháp sai: maxWidth= nằm TRONG className string (phải là prop riêng) |
🔴 HIGH |
| 3 | maxWidth="X" KHÁC w-[X] trong className (2 giá trị không khớp) |
🟡 WARNING |
Exit 0 = PASS. Script được đăng ký trong
check-all.cjs(mảngchecks) → tự chạy mỗi lần review.