tao-dialog-new
Quy tắc tạo Dialog/Form MỚI cho KetoanApp — 2 pattern từ code thực tế: Full-Screen Nghiệp Vụ (PTDialog.tsx) và Dialog Danh Mục CRUD (KHFormDialog.tsx). Cấu trúc: Header→Body→Footer, 3 mode (View/Creat
SKILL.md.claude/skills/_archived-sasuco/tao-dialog-new/SKILL.md
GitHubTạo Dialog Mới — 2 Pattern Từ Code Thực Tế
Nguồn tham khảo:
PTDialog.tsx(Phiếu thu Full-Screen) +KHFormDialog.tsx(Khách hàng CRUD) Foundation:tao-ui-dialog/SKILL.md(pattern gốc) +b0/SKILL.md(Component Catalog) Kiểm tra:check-dialog.cjs(từtao-ui-dialog) KHÔNG tạo master page ở đây. Master page →tao-ui-master-page-2/SKILL.md
0. Chọn Pattern
| Điều kiện | Pattern | File mẫu |
|---|---|---|
| Form phức tạp, bảng hạch toán, full-screen, nhiều section | Full-Screen Nghiệp Vụ | PTDialog.tsx |
| Form đơn giản (≤15 fields), CRUD danh mục, tabs | Dialog Danh Mục CRUD | KHFormDialog.tsx |
Quyết định dựa trên: có bảng hạch toán không? → Full-Screen. Còn lại → Danh Mục CRUD.
0.3 Form Có Tổ Chức / Cá Nhân — 2 Layout Riêng Biệt
⚠️ BẮT BUỘC: Khi dialog có radio "Tổ chức / Cá nhân", phải thiết kế CẢ 2 layout riêng biệt với
orgTypestate (0= Tổ chức,1= Cá nhân).
// State
const [orgType, setOrgType] = useState(initialData?.orgType ?? 0)
// Header
<label className='flex items-center gap-1.5 cursor-pointer select-none text-[13px]'>
<input type='radio' checked={orgType===0} onChange={() => setOrgType(0)}
className='accent-primary' data-qa='r_to_chuc' /> Tổ chức
</label>
<label className='flex items-center gap-1.5 cursor-pointer select-none text-[13px]'>
<input type='radio' checked={orgType===1} onChange={() => setOrgType(1)}
className='accent-primary' data-qa='r_ca_nhan' /> Cá nhân
</label>
// Body — 2 grid khác nhau
{orgType === 0 ? (
<div className='grid grid-cols-5 gap-x-3 gap-y-3'>
{/* Fields Tổ chức: Mã, Tên, MST, Người đại diện, Chức danh... */}
</div>
) : (
<div className='grid grid-cols-3 gap-x-3 gap-y-3'>
{/* Fields Cá nhân: Mã, Tên, Ngày sinh, Giới tính, CMND... */}
</div>
)}
Quy tắc:
- Fields chung (Mã, Tên, Địa chỉ...) → có trong cả 2 grid
- Fields riêng Tổ chức (MST, Người đại diện, Chức danh...) → chỉ trong grid
orgType === 0 - Fields riêng Cá nhân (Ngày sinh, Giới tính, CMND...) → chỉ trong grid
orgType === 1 - Không tự ý thêm field của chế độ này vào chế độ kia
0.4 Quy Tắc Field Thiếu — Cấm Tự Thêm Vào Type (BẮT BUỘC)
⚠️ TUYỆT ĐỐI CẤM: Tự ý thêm field vào
types.api.ts(DTO). Nếu UI cần field mà BE DTO chưa có:
| Tình huống | Hành động đúng | Hành động sai |
|---|---|---|
| UI cần field X, DTO BE chưa có X | DỪNG — tạo inbox/yêu cầu BE bổ sung | ❌ Tự thêm fieldX: string vào DTO |
| Clone ra nhiều field hơn DTO | Báo cáo danh sách field thiếu → chờ BE | ❌ Tự map tất cả vào type |
Lý do: BE và FE phải thống nhất DTO. FE tự thêm field vào type nhưng BE không có → API lỗi hoặc mất dữ liệu.
0.5 Chọn Layout Trong Pattern A: Grid vs Flex 2 Cột
Pattern A (Danh Mục CRUD) có 2 kiểu layout bên trong body:
| Layout | Dùng khi | File mẫu |
|---|---|---|
| grid-cols-5 (DmFormField) | Form đều các field, không có sidebar, field xếp theo grid | KHFormDialog.tsx |
| flex gap-6 2 cột (Label+Input) | Form có 2 cột rõ rệt: trái form chính (Mã, Tên, Đơn vị, Chức danh...) + phải sidebar (Ngày sinh, CMND, Nơi cấp...) | NVFormDialog.tsx |
Quy tắc chọn layout: Nếu clone từ MISA thấy layout chia 2 cột trái-phải rõ rệt → dùng flex. Nếu field xếp đều thành lưới → dùng grid-cols-5.
Layout flex 2 cột:
<div className='flex gap-6'>
{/* Cột trái: form chính */}
<div className='flex-1 space-y-3'>
{/* Label + Input / DmGroupInput */}
</div>
{/* Cột phải: sidebar */}
<div className='w-[300px] flex-shrink-0 space-y-3'>
{/* Ngày sinh, giới tính, CMND, ngày cấp, nơi cấp... */}
</div>
</div>
⚠️ Bài học từ Nhân viên: Không phải mọi danh mục CRUD đều dùng
grid-cols-5. Phải xem clone MISA để xác định layout chính xác. Dùng sai layout → dialog không giống bản gốc, thiếu field.
1. Pattern A — Dialog Danh Mục CRUD (KHFormDialog.tsx)
Dùng khi: Form danh mục (Khách hàng, Nhà cung cấp, Nhân viên, Hàng hóa, Loại công trình...) Đặc điểm:
maxWidth='920px', DmFormField, DmDialogFooter, Tabs bên dưới form
1.1 Import
import { useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/components/ui/dialog'
import { Button } from '@/shared/components/ui/button'
import { Tabs, TabsList, TabsContent } from '@/shared/components/ui/tabs'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select'
import { X } from 'lucide-react'
import { ValidationErrorDialog } from '@/shared/components/common'
import {
DmFormField, DmFormInput, DmFormTextarea,
DmFieldValue, DmTabFieldValue, DmGroupInput, DmDialogFooter,
} from '@/modules/KetoanApp/components'
import DmTabTrigger from '@/modules/KetoanApp/components/DMTabTrigger'
1.2 Props Interface
interface Props {
open: boolean
onOpenChange: (v: boolean) => void
initialData?: XxxDto | null // undefined = create, có giá trị = edit
viewOnly?: boolean // true = view mode
onSuccess?: () => void
}
1.3 3 Mode Logic
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, setFormData, updateField, handleBlur, clearErrors, autoFillCode,
errors, submitting, mode,
serverError, serverErrorOpen, setServerErrorOpen,
handleSubmit,
} = useXxxDialogForm(editMode ? initialData : null)
const f = formData as Record<string, unknown>
// Init khi mở dialog
useEffect(() => {
if (!open) { clearErrors(); return }
// Reset về initialData mới khi dialog mở lại với record khác
if (editMode && initialData) {
setFormData(buildFormState(initialData))
} else if (mode === 'create') {
setFormData(INITIAL_FORM as Record<string, unknown>)
autoFillCode()
}
}, [open, initialData?.id]) // dùng id để detect record thay đổi khi chuyển giữa các record
// Submit logic
const doSubmit = async () => {
const ok = await handleSubmit()
if (!ok) return
onSuccess?.()
onOpenChange(false)
}
1.4 Dialog Wrapper
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 border-b border-[#B7BCC3]'>
<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>
{/* Optional: radio/checkbox controls in header */}
{/* <label><input type='radio' ... /> Tổ chức</label> */}
<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>
1.5 Body — Grid Form
{/* ── Body ── */}
<div className='flex-1 min-h-0 overflow-y-auto px-5 py-4'>
<form id='xxx-form' onSubmit={(e) => { e.preventDefault(); doSubmit() }}>
{/* Grid form: 5 cột mặc định */}
<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')} />}
</DmFormField>
<DmFormField label='Tên' required className='col-span-3' 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='Nhóm' className='col-span-2'>
{isReadonly ? <DmFieldValue value={f.groupName as string} />
: <DmGroupInput withChevron
value={(f.groupName as string) ?? ''}
onChange={(v) => updateField('groupName', v)}
placeholder='Chọn nhóm'
dataQa='i_group' />}
</DmFormField>
<DmFormField label='Địa chỉ' className='col-span-3'>
{isReadonly ? <DmFieldValue value={f.address as string} />
: <DmFormTextarea data-qa='i_address' rows={2}
value={(f.address as string) ?? ''}
onChange={(e) => updateField('address', e.target.value)} />}
</DmFormField>
</div>
</form>
{/* ── Tabs (nếu có) ── */}
<div className='mt-4 rounded-[8px] overflow-hidden'>
<Tabs defaultValue='tab-1' className='gap-0'>
<TabsList className='w-full justify-start h-auto bg-transparent border-b border-[#B7BCC3] p-0 gap-1 overflow-x-auto rounded-none'>
<DmTabTrigger value='tab-1'>Tab 1</DmTabTrigger>
<DmTabTrigger value='tab-2'>Tab 2</DmTabTrigger>
</TabsList>
<TabsContent value='tab-1' className='m-0 p-4 h-[280px] overflow-y-auto border border-[#B7BCC3]'>
{/* Tab content — dùng DmTabFieldValue / DmFormInput */}
</TabsContent>
</Tabs>
</div>
</div>
1.6 Footer — DmDialogFooter (mặc định: Hủy + Lưu)
⚠️ QUY TẮC MẶC ĐỊNH: Footer chỉ gồm [Hủy] [Lưu]. Nút "Lưu & Thêm" chỉ thêm khi user yêu cầu riêng.
<DmDialogFooter
isView={isView}
submitting={submitting}
onClose={() => onOpenChange(false)}
onSave={() => doSubmit()}
/>
Khi user yêu cầu "Lưu & Thêm":
<DmDialogFooter
isView={isView}
isCreate={isCreate}
submitting={submitting}
onClose={() => onOpenChange(false)}
onSave={() => doSubmit(false)}
onSaveAndAdd={() => doSubmitAndNew()}
/>
1.7 Error Dialog
<ValidationErrorDialog
open={serverErrorOpen}
onOpenChange={setServerErrorOpen}
errors={serverError}
/>
</DialogContent>
</Dialog>
</>
)
}
1.8 DmFormField Pattern
// Pattern cố định cho MỌI field trong grid:
<DmFormField label='Nhãn' required error={errors.fieldName} tooltip='Giải thích' className='col-span-X'>
{isReadonly
? <DmFieldValue value={value} />
: <DmFormInput data-qa='i_field'
value={value}
onChange={(e) => updateField('field', e.target.value)}
onBlur={() => handleBlur('field')} />}
</DmFormField>
| Component | Dùng khi |
|---|---|
DmFormInput |
Text ngắn (code, name, phone, email...) |
DmFormTextarea |
Text dài (address, description) + rows={2} |
DmGroupInput withChevron |
FK chọn từ danh sách (nhóm KH, nhân viên...) |
DmFieldValue |
View mode — hiển thị text thuần |
DmTabFieldValue |
View mode trong tab — label='...' value={...} |
Select |
Enum cố định (giới tính, trạng thái...) |
1.9 Quy Tắc Tabs Trong Dialog
| Quy tắc | Pattern | Lý do |
|---|---|---|
| Tất cả TabsContent cùng chiều cao | h-[280px] overflow-y-auto |
Không bị nhảy layout khi đổi tab |
| Border ở TabsContent | border border-[#B7BCC3] |
Phân biệt nội dung với tab header |
| Tab mặc định khớp MISA | defaultValue = tab đầu tiên bên MISA |
Giữ UX nhất quán |
| Tên tab giữ nguyên từ MISA | Không tự ý đổi tên tab | Tránh sai lệch nghiệp vụ |
| Mỗi tab có fields khác nhau | Không tự phân phối fields giữa các tab | Tab Tiền lương ≠ Tab Liên hệ |
| Click từng tab khi clone | Dùng nhiều selector (MISA không dùng role=tab) | button:has-text() hoặc .ms-tabs-btn |
| Đối chiếu với BE DTO | Field MISA có mà BE chưa có → tạo inbox BE | Tránh submit field không tồn tại |
⚠️ KHÔNG để TabsContent không có chiều cao cố định — sẽ gây nhảy layout. ⚠️ KHÔNG tự ý phân phối fields giữa các tab — mỗi tab có nội dung khác nhau theo MISA. Ví dụ: Tab "Tiền lương" chỉ chứa fields lương (không có phone, email, địa chỉ). ⚠️ KHÔNG tự ý thêm/bớt tab so với MISA.
Ví dụ phân phối fields cho Nhân viên (NVFormDialog.tsx):
Tab "Thông tin tiền lương": MST, Lương thỏa thuận, Hệ số lương, Lương đóng BH, Loại HĐ, Số người phụ thuộc
Tab "Tài khoản ngân hàng": DmBankAccountsTab
Tab "Thông tin liên hệ": Điện thoại, Di động, Email, Người liên hệ, Địa chỉ, Ghi chú
2. Pattern B — Full-Screen Nghiệp Vụ (PTDialog.tsx)
Dùng khi: Chứng từ kế toán (Phiếu thu, Phiếu chi, Hóa đơn...), form có bảng hạch toán, nhiều section.
2.1 Import
import { useEffect, useRef, useState } from 'react'
import { Dialog, DialogContent } from '@/shared/components/ui/dialog'
import { Button } from '@/shared/components/ui/button'
import { Input } from '@/shared/components/ui/input'
import { Label } from '@/shared/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select'
import { Loader2, X, Plus, Trash2, AlertCircle } from 'lucide-react'
import { DatePicker } from '@/shared/components/common'
import { SearchCombobox } from '@/shared/components/common'
import { TableSearchCombobox } from '@/shared/components/common'
import { ValidationErrorDialog, ConfirmDialog } from '@/shared/components/common'
import { cn } from '@/shared/components/ui/utils'
import { formatCurrency, formatNumber } from '@/shared/utils'
2.2 Props Interface
interface PTDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
editingId?: string | null // null/undefined = create
defaultMode?: 'view' | 'edit' | 'create'
onSuccess: () => void
}
2.3 Dialog Wrapper (Full-Screen)
<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 + Body + Footer */}
</DialogContent>
</Dialog>
2.4 Header (Title + Select + Tổng tiền + Nút X)
<div className='flex items-center justify-between px-5 py-3.5 bg-white flex-shrink-0 border-b border-[#B7BCC3]'>
<div className='flex items-center gap-3'>
<span className='font-semibold text-black text-base'>Tiêu đề {formData.refNo}</span>
{isReadOnly ? (
<span className='text-sm text-black'>Loại chứng từ</span>
) : (
<Select value={String(formData.refType)} onValueChange={...}>
<SelectTrigger className='w-[200px] h-8 text-sm border-[#B7BCC3] rounded-lg bg-white' data-qa='sel_ref_type'>
<SelectValue placeholder='Loại chứng từ' />
</SelectTrigger>
<SelectContent>...</SelectContent>
</Select>
)}
</div>
<div className='flex items-center gap-4'>
<div className='text-right'>
<div className='text-[11px] text-black leading-tight'>Tổng tiền</div>
<div className='font-bold text-base text-black'>{formatCurrency(totalAmount)}</div>
</div>
<Button variant='ghost' size='sm' data-qa='btn_dong_dialog'
className='h-7 w-7 p-0 text-black hover:text-black hover:bg-gray-100' onClick={handleClose}>
<X className='h-4 w-4' />
</Button>
</div>
</div>
2.5 Body (2 Cột Layout: Main + Sidebar)
<div className='flex-1 min-h-0 overflow-y-auto bg-[#f4f5f7]'>
<div className='p-5 space-y-4'>
<div className='flex gap-6'>
{/* Cột trái: Form chính */}
<div className='flex-1 space-y-3'>
{/* grid grid-cols-2 gap-4 cho các row 2 field */}
{/* space-y-1 cho field full-width */}
</div>
{/* Cột phải: Sidebar (Ngày + Số CT) */}
<div className='w-[250px] flex-shrink-0 space-y-3'>
{/* DatePicker + Input số CT */}
</div>
</div>
</div>
</div>
2.6 Form Field Pattern (Label + Input)
<div className='space-y-1'>
<Label className='text-[13px] font-semibold text-black'>
Tên field <span className='text-red-500'>*</span>
</Label>
{isReadOnly ? (
<div className='text-[13px] text-black'>{value || '-'}</div>
) : (
<div className='space-y-0.5'>
<Input data-qa='i_field' value={value}
onChange={e => setField('field', e.target.value)}
onBlur={() => handleBlur('field')}
placeholder='...'
className={cn(
'h-[30px] text-[13px] rounded-lg bg-white',
touched.field && errors.field ? 'border-destructive' : 'border-[#B7BCC3]',
)} />
{touched.field && errors.field && (
<p className='text-xs text-destructive flex items-center gap-1'>
<AlertCircle className='h-3 w-3' />{errors.field}</p>)}
</div>
)}
</div>
2.7 Footer (3 Mode — mặc định: Hủy + Lưu)
⚠️ QUY TẮC MẶC ĐỊNH: Footer chỉ gồm [Hủy] [Lưu]. Nút "Lưu & Thêm" chỉ thêm khi user yêu cầu riêng.
<div className='flex items-center justify-between px-5 py-2.5 bg-white flex-shrink-0 border-t border-[#B7BCC3]'>
<span className='text-xs text-black truncate max-w-[50%]'>Tên đơn vị</span>
<div className='flex gap-2'>
{/* View mode */}
{isReadOnly ? (
<>
{!formData.isPosted && <Button className='btn-secondary h-8 text-[13px] rounded-lg' data-qa='btn_ghi_so'>Ghi sổ</Button>}
{formData.isPosted && <Button className='btn-primary h-8 text-[13px] rounded-lg' data-qa='btn_bo_ghi_va_sua'>Bỏ ghi và sửa</Button>}
<Button className='btn-secondary h-8 text-[13px] rounded-lg' data-qa='btn_dong'>Đóng</Button>
</>
) : (
<>
<Button className='btn-secondary h-8 text-[13px] rounded-lg' data-qa='btn_huy' disabled={submitting}>Hủy</Button>
<Button className='btn-primary h-8 text-[13px] rounded-lg' data-qa='btn_luu' onClick={handleSubmit}>Lưu</Button>
</>
)}
</div>
</div>
2.8 Bảng Hạch Toán Inline
Xem chi tiết tại b0/SKILL.md → Section B (Bảng Hạch Toán Trong Dialog). Pattern:
<div className='bg-white rounded-lg shadow-sm overflow-hidden'>
<div className='border-b border-[#B7BCC3] px-3'>
<button className='py-2.5 text-base font-normal text-primary border-b-2 border-primary -mb-[1px] px-1' data-qa='tab_hach_toan'>
Hạch toán
</button>
</div>
<div className='p-0'>
<div className='overflow-x-auto border border-[#B7BCC3]'>
<table className='w-full text-[13px] border-collapse'>
<thead><tr className='bg-[#ededf1]'>{/* Các <th> */}</tr></thead>
<tbody>
{lines.map((line, i) => (
<tr key={i} className='hover:bg-gray-50/50 border-b border-[#B7BCC3] last:border-b-0'>
{/* <td> với Input/SearchCombobox inline */}
</tr>
))}
{/* Sum row: <tr className='bg-[#f2f2f4] font-medium'> */}
</tbody>
</table>
</div>
{/* Nút Thêm dòng / Xóa hết dòng */}
</div>
</div>
3. Component Mapping — Chọn Đúng Component Cho Từng Field
| Loại field | Pattern A (Danh Mục) | Pattern B (Nghiệp Vụ) |
|---|---|---|
| Text ngắn (code, name) | DmFormInput |
Input + Label |
| Text dài (address, desc) | DmFormTextarea rows={2} |
Input (full-width) |
| FK đơn (chọn 1) | DmGroupInput withChevron (nếu không có API) / TableSearchCombobox + ApiService (nếu có API riêng) |
SearchCombobox / TableSearchCombobox |
| FK nhiều cột | (không dùng) | TableSearchCombobox + columns |
| Enum | Select |
Select |
| Ngày tháng | DmFormInput type='date' |
DatePicker |
| Số tiền | DmFormInput type='number' |
Input type='text' inputMode='numeric' text-right |
| Checkbox | <input type='checkbox'> |
<input type='checkbox'> |
| Radio | <input type='radio'> |
— |
| Mã số thuế | DmTaxCodeInput |
— |
| View mode display | DmFieldValue / DmTabFieldValue |
<div className='text-[13px] text-black'> |
| Footer | DmDialogFooter |
Custom footer |
3.1 SearchCombobox / TableSearchCombobox — Luôn Có initialLabel Khi Sửa / Nhân Bản (BẮT BUỘC)
⚠️ Lỗi phổ biến: Khi sửa/nhân bản,
SearchComboboxcóvalue(ID) nhưng chưa load options → không hiển thị label, hiển thị placeholder. User phải click mở dropdown mới thấy giá trị đã chọn.
Quy tắc: Mọi SearchCombobox / TableSearchCombobox trong dialog PHẢI có initialLabel và PHẢI preload tên hiển thị khi dialog mở ở chế độ sửa hoặc nhân bản.
3.1.1 Quy Tắc value Phải Khớp Với loadOptions (BẮT BUỘC)
⚠️ Lỗi nghiêm trọng: API response (DTO) thường KHÔNG trả về FK ID mà chỉ trả về code/name. Khi edit,
valuecủa TableSearchCombobox bị rỗng → không chọn được item trong dropdown → submit sẽ gửibankAccountID: ''→ mất dữ liệu FK.
Kiểm tra trước khi code:
- Xác định
valuecủa TableSearchCombobox là gì? (VD:bankId,accountObjectId) - Xác định
loadOptionstrả vềvaluetừ đâu? (VD:b.id,kh.id) - Kiểm tra API response (DTO) có chứa field đó không?
- Nếu KHÔNG có → PHẢI thêm
useEffectresolve FK ID khi mở dialog Edit
Pattern resolve FK ID khi Edit (BẮT BUỘC nếu DTO thiếu FK ID):
// ── Edit mode: resolve FK ID từ code/name (vì API response không trả về FK ID) ──
useEffect(() => {
if (!open || mode !== 'edit') return
if (formData.objectCode && !formData.accountObjectId) {
XxxApiService.list({ pageIndex: 1, pageSize: 1, keyword: formData.objectCode })
.then(r => {
if (r.success && r.data?.items?.length) {
const match = r.data.items.find(x => x.code === formData.objectCode)
if (match) {
onFieldChange('accountObjectId', match.id)
onFieldChange('objectName', match.name)
}
}
})
.catch(() => {})
}
}, [open, mode, formData.objectCode, formData.accountObjectId])
Ví dụ cụ thể các trường hợp phổ biến:
| Dialog | Combobox | value |
DTO có field? | Cần resolve? |
|---|---|---|---|---|
| TK Ngân hàng | TK ngân hàng | bankId |
OpeningAccountEntryBankResponse → ❌ Không có |
✅ Có |
| Công nợ KH | Khách hàng | accountObjectId |
OpeningAccountEntryCustomerResponse → ❌ Không có |
✅ Có |
| Công nợ NCC | Nhà cung cấp | accountObjectId |
OpeningAccountEntryVendorResponse → ❌ Không có |
✅ Có |
| Nhập số dư | TK kế toán | accountNumber |
OpeningAccountEntryOtherResponse → ✅ Có accountNumber |
❌ Không |
| Danh mục CRUD | Nhóm KH | customerGroupId |
CustomerDto → ✅ Có customerGroupId |
❌ Không |
3.1.2 Pattern Preload Label (khi DTO đã có FK ID nhưng thiếu tên hiển thị)
Pattern trong Dialog:
const effectiveData = isClone ? cloneFrom : initialData
// Preload tên FK để hiển thị initialLabel khi sửa / nhân bản
useEffect(() => {
if (!open || !effectiveData?.foreignKeyId) return
let cancelled = false
;(async () => {
try {
const res = await XxxApiService.getById(effectiveData!.foreignKeyId!)
if (cancelled || !res.success || !res.data) return
updateField('_foreignKeyName', res.data.name) // Lưu tên vào formData với prefix _
} catch { /* ignore */ }
})()
return () => { cancelled = true }
}, [open, effectiveData?.foreignKeyId, updateField])
// Trong JSX:
<SearchCombobox
value={(f.foreignKeyId as string) ?? ''}
onChange={(v: string) => updateField('foreignKeyId', v || null)}
loadOptions={loadXxxOptions}
placeholder='Chọn...'
dataQa='sel_xxx'
initialLabel={(f._foreignKeyName as string) ?? ''} // ← BẮT BUỘC
/>
Nếu API trả về sẵn tên trong DTO (vd: AccountObjectDto.customerGroup, AccountObjectDto.salesPersonName), dùng trực tiếp không cần preload:
<SearchCombobox
value={(f.customerGroupId as string) ?? ''}
initialLabel={f.customerGroup as string} // ← DTO đã có sẵn tên
...
/>
Quy ước đặt tên field preload: Dùng prefix _ + tên field + Name (vd: _parentName, _fromAccountName, _orgLevelName) để phân biệt với field từ DTO.
4. Quy Ước Chung (Cả 2 Pattern)
| Quy ước | Pattern A (Danh Mục) | Pattern B (Nghiệp Vụ) |
|---|---|---|
| maxWidth | '920px' |
'none' (100vw) |
| Body bg | white | bg-[#f4f5f7] |
| Header border | border-b border-[#B7BCC3] |
border-b border-[#B7BCC3] |
| Footer border | DmDialogFooter tự có | border-t border-[#B7BCC3] |
| Input height | DmFormInput mặc định (h-8) |
h-[30px] |
| Input border | DmFormInput mặc định | border-[#B7BCC3] |
| Input error | DmFormField error={errors.x} |
border-destructive + inline msg |
| Input rounding | rounded-[8px] |
rounded-lg |
| Font size | text-[13px] |
text-[13px] |
| Label style | DmFormField auto | text-[13px] font-semibold text-black |
| Required | required prop |
<span className='text-red-500'>*</span> |
| View mode | DmFieldValue / DmTabFieldValue |
<div> thay <Input> |
| Validate | onBlur (DmFormInput) | onBlur + touched check |
| data-qa prefix | i_=input, sel_=select, chk_=checkbox, r_=radio |
i_=input, sel_=select, dt_=date, btn_=button |
| Close button | h-7 w-7 text-gray-400 rounded-[8px] |
h-7 w-7 text-black |
| Grid layout | grid grid-cols-5 gap-x-3 gap-y-3 |
flex gap-6 (2 cột) |
| Column span | className='col-span-3', col-span-2 |
w-[250px] sidebar |
| Tabs | Có (trong body, dưới form) | Có (tab Hạch toán trong section) |
| Error dialog | ValidationErrorDialog |
ValidationErrorDialog |
| Submit modes | doSubmit(false) Lưu / doSubmit(true) Lưu & Thêm mới |
handleSubmit Lưu / handleSubmitAndNew Lưu & Thêm mới |
5. Cấu Trúc File (Từ cau-truc-du-an)
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 (getById, create, update, delete)
├── hooks/
│ ├── use{SN}.page.list.ts ← Nếu có master page
│ └── use{SN}.dlg.form.ts ← Hook dialog (submit, delete, init)
├── dialogs/
│ └── {SN}Dialog.tsx ← Dialog component ← ĐÂY LÀ FILE CHÍNH
├── pages/
│ └── {SN}Page.tsx ← Master page (nếu có)
└── index.ts ← Barrel exports
5.1 Hook Initialization Pattern — Reset State Khi Mở Lại Dialog
⚠️ BẮT BUỘC: Hook phải reset
formDatakhiinitialDatathay đổi. Dialog thường không unmount (chỉ ẩn/hiện), nênuseState(initial)chỉ chạy 1 lần.
export function useXxxDialogForm(initialData: XxxDto | null) {
// ✅ Dùng lazy initializer + useEffect reset
const buildInitial = (data: XxxDto | null): XxxFormState =>
data ? { code: data.code, name: data.name, ... } : { ...INITIAL_FORM }
const [formData, setFormData] = useState<XxxFormState>(() => buildInitial(initialData))
// Reset khi initialData thay đổi (dialog đóng → mở với record khác)
useEffect(() => {
setFormData(buildInitial(initialData))
clearErrors()
}, [initialData])
// ...
}
| Pattern | Dùng khi | Mô tả |
|---|---|---|
Hook nhận initialData |
Dialog đơn giản, 1 hook riêng | Hook tự reset trong useEffect khi initialData thay đổi |
Hook nhận editMode ? initialData : null |
Dialog dùng chung hook | Component gọi setFormData trong useEffect([open, initialData?.id]) |
Nguyên nhân lỗi:
const [formData] = useState(buildInitial(initialData))—useStatechỉ dùng giá trị khởi tạo 1 lần. Khi dialog đóng rồi mở lại vớiinitialDatamới,useStatekhông chạy lại → form vẫn giữ data cũ. Fix: Luôn cóuseEffectreset form khi dependency thay đổi (initialDatahoặc[open, initialData?.id]).
6. Checklist Trước Commit
- Chọn đúng pattern (A: Danh Mục / B: Nghiệp Vụ)
-
maxWidthđúng ('920px'hoặc'none') - 3 mode hoạt động: 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_) - View mode hiển thị
<div>/DmFieldValue(không dùng disabled input) - Input số:
type='text' inputMode='numeric'(Pattern B) - Footer đúng pattern (DmDialogFooter hoặc custom)
- Nút X close custom (ẩn mặc định Radix)
- Hook không gọi API trực tiếp từ dialog
- Types đủ fields từ DTO BE
- Barrel
index.tsexport dialog - Compile không lỗi, TypeScript check pass
- ⚠️ Reset state khi đóng/mở lại dialog:
useEffecttheo[open, initialData?.id]— form phải theo giá trị mới, không giữ giá trị cũ
Script Kiểm Tra
node .claude/skills/tao-ui-dialog/check-dialog.cjs src/modules/.../dialogs/XxxDialog.tsx