tao-ui-giao-dien-new
FOUNDATION — Quy tắc UI nền tảng cho MỌI loại giao diện KetoanApp, cập nhật từ code thực tế (PTDialog.tsx + KHFormDialog.tsx): buttons (btn-primary/btn-secondary/btn-danger), DmFormField/DmFormInput/D
Files1
View on GitHubSKILL.md.claude/skills/_archived-sasuco/tao-ui-giao-dien-new/SKILL.md
GitHubQuy Tắc UI Nền Tảng (New) — SASUCO KetoanApp
FOUNDATION SKILL — Áp dụng cho MỌI loại UI trong KetoanApp. Nguồn tham khảo:
PTDialog.tsx(Full-Screen Nghiệp Vụ) +KHFormDialog.tsx(Danh Mục CRUD). Kế thừa:tao-ui-giao-dien/SKILL.md(nền tảng gốc).
1. Component Catalog — Có Gì Dùng Nấy
1.1 Shared Components (@/shared/components)
| Component | Import | Dùng trong |
|---|---|---|
Dialog, DialogContent |
@/shared/components/ui/dialog |
Cả 2 pattern |
DialogHeader, DialogTitle |
@/shared/components/ui/dialog |
Pattern Danh Mục |
Tabs, TabsList, TabsContent |
@/shared/components/ui/tabs |
Pattern Danh Mục |
Input |
@/shared/components/ui/input |
Pattern Nghiệp Vụ |
Label |
@/shared/components/ui/label |
Pattern Nghiệp Vụ |
Button |
@/shared/components/ui/button |
Cả 2 |
Select |
@/shared/components/ui/select |
Cả 2 |
DatePicker |
@/shared/components/common |
Pattern Nghiệp Vụ |
SearchCombobox |
@/shared/components/common |
Pattern Nghiệp Vụ |
TableSearchCombobox |
@/shared/components/common |
Pattern Nghiệp Vụ |
ConfirmDialog |
@/shared/components/common |
Cả 2 |
ValidationErrorDialog |
@/shared/components/common |
Cả 2 |
BulkActionBar |
@/shared/components/common |
Master Page |
TableSettingsPanel |
@/shared/components/table-settings |
Master Page |
1.2 KetoanApp Components (@/modules/KetoanApp/components)
| Component | Dùng trong | Mô tả |
|---|---|---|
DmFormField |
Pattern Danh Mục | Wrapper: label + required + error + tooltip |
DmFormInput |
Pattern Danh Mục | Input text chuẩn (h-8, rounded-[8px]) |
DmFormTextarea |
Pattern Danh Mục | Textarea (rows={2}) |
DmFieldValue |
Pattern Danh Mục | Hiển thị text view mode |
DmTabFieldValue |
Pattern Danh Mục | Hiển thị label+value view mode trong tab |
DmGroupInput |
Pattern Danh Mục | FK tạm (chưa có API) — nếu có API riêng → dùng TableSearchCombobox |
DmDialogFooter |
Pattern Danh Mục | Footer dialog: Hủy+Lưu+Lưu&Thêm mới |
DmTaxCodeInput |
Pattern Danh Mục | Input MST + auto-fill |
DmTabTrigger |
Pattern Danh Mục | Tab trigger trong TabsList |
DmPageHeader |
Master Page | Header trang |
DmSearchToolbar |
Master Page | Thanh tìm kiếm |
DmTable, DmTablePagination |
Master Page | Bảng + phân trang |
DmRowActions |
Master Page | Row actions (tự sinh data-qa) |
DmValidatedInput |
Pattern Danh Mục | Input email/phone/website với validate realtime + border đổi màu |
1.3 Shared Hooks
| Hook | Source | Dùng ở |
|---|---|---|
useTableSettings |
@/shared/hooks/useTableSettings |
Master Page |
useTableLayout |
@/shared/hooks |
Master Page |
useDmRowActions |
@/shared/hooks |
Master Page |
useExcelExport |
@/shared/hooks |
Master Page |
useBulkSelection |
@/shared/hooks |
Master Page |
1.4 Utilities
| Utility | Source |
|---|---|
cn() |
@/shared/components/ui/utils |
formatCurrency(), formatNumber() |
@/shared/utils |
formatDate(), formatDateTime() |
@/shared/utils |
2. Buttons
| Class | Dùng cho |
|---|---|
.btn-primary |
Thêm mới, Lưu, Xác nhận, Ghi sổ |
.btn-secondary |
Hủy, Đóng, Làm mới |
.btn-danger |
Xóa (phải kèm ConfirmDialog) |
Kích thước & rounding
| Vị trí | Pattern |
|---|---|
| Dialog header/footer | h-8 text-[13px] rounded-lg |
| Icon nhỏ (X, Trash2) | h-7 w-7 p-0 hoặc h-6 w-6 p-0 |
| Danh Mục CRUD | rounded-[8px] |
| Nghiệp Vụ | rounded-lg |
| Ghost icon button | variant='ghost' size='sm' text-gray-400 hover:text-gray-600 |
Nút Làm mới
<Button variant='ghost' size='sm'
className='h-8 w-8 p-0 text-gray-500 hover:text-gray-700 hover:bg-gray-100'
onClick={handleRefresh} disabled={refreshing} title='Làm mới' data-qa='btn_lam_moi'>
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
</Button>
3. Form Fields — 2 Pattern
3.1 Pattern A: Danh Mục CRUD (DmFormField)
<DmFormField label='Mã' required error={errors.code} tooltip='Tự động tạo nếu trống' className='col-span-X'>
{isReadonly
? <DmFieldValue value={value} />
: <DmFormInput data-qa='i_code'
value={(f.code as string) ?? ''}
onChange={(e) => updateField('code', e.target.value)}
onBlur={() => handleBlur('code')} />}
</DmFormField>
Các DmFormField components:
- Text ngắn:
DmFormInput - Text dài:
DmFormTextarea rows={2} - FK chọn (có API riêng):
TableSearchCombobox+ ApiService + loadOptions - FK tạm (chưa có API):
DmGroupInput withChevron - MST tra cứu:
DmTaxCodeInput - View mode:
DmFieldValue/DmTabFieldValue
3.1.1 DmValidatedInput — Email / Phone / Website (Validate Realtime)
Dùng
DmValidatedInputthay choDmFormInputcho các field email, số điện thoại, website. Component tự xử lý validate realtime + border đổi màu + progressive message.
import { DmValidatedInput } from '@/modules/KetoanApp/components'
// Email
<DmFormField label='Email' error={errors.email}>
<DmValidatedInput inputType='email' data-qa='i_email'
value={f.email ?? ''}
onChange={(e) => updateField('email', e.target.value)}
onBlur={() => handleBlur('email')} />
</DmFormField>
// Số điện thoại
<DmFormField label='Điện thoại' error={errors.phone}>
<DmValidatedInput inputType='phone' data-qa='i_phone'
value={f.phone ?? ''}
onChange={(e) => updateField('phone', e.target.value)}
onBlur={() => handleBlur('phone')} />
</DmFormField>
// Website
<DmFormField label='Website' error={errors.website}>
<DmValidatedInput inputType='website' data-qa='i_website'
value={f.website ?? ''}
onChange={(e) => updateField('website', e.target.value)}
onBlur={() => handleBlur('website')} />
</DmFormField>
Quy tắc:
DmValidatedInputtự quản lýdirtystate nội bộ → không cầntouchedtừ hook- Border: mặc định →
border-success(xanh) khi hợp lệ →border-destructive(đỏ) khi sai - Progressive message: chỉ hiển thị khi field có giá trị — field trống = không message
- Message rỗng (
'') từMessageProgressHelper→ không render thẻ<p>(tránh DOM rỗng) handleBlurtrong hook vẫn cần validatephone/mobile/websiteđểvalidateAllhoạt động
⚠️ FK field có API riêng (như Đơn vị → DVTCApiService) → luôn dùng
TableSearchComboboxgọi API.DmGroupInput withChevronchỉ là tạm thời khi chưa có API. Xemb0/SKILL.mdSection C.1 để có pattern đầy đủ.
3.2 Pattern B: Nghiệp Vụ (Label + Input)
<div className='space-y-1'>
<Label className='text-[13px] font-bold 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')}
className={cn(
'h-[30px] text-[13px] rounded-lg bg-white',
touched.field && errors.field ? 'border-destructive' : 'border-[#AEB3BA]',
)} />
{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>
Các field components:
- Text:
Input - FK đơn:
SearchCombobox - FK nhiều cột:
TableSearchCombobox+ column definitions - Enum:
Select+SelectItem - Ngày:
DatePicker - Số tiền:
Input type='text' inputMode='numeric' text-right - View mode:
<div className='text-[13px] text-black'>
4. SearchCombobox / TableSearchCombobox (FK Fields)
SearchCombobox (FK đơn)
<SearchCombobox
value={line.debitAccount}
initialLabel={line.debitAccount}
onChange={(val, label) => updateLine(index, 'debitAccount', val || label)}
loadOptions={loadTaiKhoan} // async (keyword) => ComboboxOption[]
placeholder='TK Nợ'
dataQa='sel_tk_no'
className='h-6 text-[13px] border-0 bg-transparent' // inline trong table
/>
TableSearchCombobox (FK nhiều cột hiển thị)
<TableSearchCombobox
value={formData.accountObjectId}
initialLabel={formData.accountObjectName}
displayField='name'
onChange={(id, rowData) => {
setField('accountObjectId', id)
setField('accountObjectName', rowData.name || '')
setField('accountObjectAddress', rowData.address || '')
}}
loadOptions={loadDoiTuong} // async (keyword) => TableComboboxRow[]
columns={doiTuongColumns} // TableComboboxColumn[]
placeholder='Chọn đối tượng...'
dataQa='sel_doi_tuong'
showQuickAdd
debounceMs={800}
/>
Column definitions (TableComboboxColumn[]):
const doiTuongColumns: TableComboboxColumn[] = [
{ field: 'code', title: 'Mã', width: 100 },
{ field: 'name', title: 'Tên', width: 220 },
{ field: 'taxCode', title: 'MST', width: 130 },
]
TableSearchCombobox — View Mode & initialLabel (⚠️ BẮT BUỘC)
Cốt lõi:
initialLabelPHẢI khớp vớidisplayField. Khi ở view mode (disabled={true}), dropdown không mở được →rowsrỗng →selectedLabelrỗng → chỉ còninitialLabellàm fallback. NếuinitialLabelkhông khớpdisplayField, người dùng sẽ thấy sai dữ liệu.
Rule 1: initialLabel luôn khớp displayField
displayField |
initialLabel phải là |
Ví dụ form state |
|---|---|---|
accountNumber |
Số tài khoản (string) | formData.bankAccountNumber |
code |
Mã (string) | formData.objectCode |
name |
Tên (string) | formData.accountObjectName |
// ❌ SAI: displayField='accountNumber' nhưng initialLabel là bankName
<TableSearchCombobox
displayField='accountNumber'
initialLabel={formData.bankName} // ❌ Hiển thị tên NH thay vì số TK!
/>
// ✅ ĐÚNG: displayField='accountNumber' → initialLabel là accountNumber
<TableSearchCombobox
displayField='accountNumber'
initialLabel={formData.bankAccountNumber} // ✅ Hiển thị đúng số tài khoản
/>
Rule 2: View mode KHÔNG được hiển thị sai dữ liệu
Khi disabled={isReadOnly}:
- Người dùng không thể click mở dropdown →
rowskhông bao giờ được load selectedLabelluôn là''(vìrowsrỗng)- Fallback về
initialLabel - Nếu
initialLabelkhông khớpdisplayField→ UI hiển thị sai!
// ❌ SAI trong view mode: initialLabel là bankName, displayField là accountNumber
// Kết quả: view mode hiển thị "Vietcombank" thay vì "1234567890"
<TableSearchCombobox
value={formData.bankAccountID}
initialLabel={formData.bankName} // ❌ Sai khi view mode
displayField='accountNumber'
disabled={isReadOnly}
/>
// ✅ ĐÚNG: initialLabel khớp displayField, view mode hiển thị đúng
<TableSearchCombobox
value={formData.bankAccountID}
initialLabel={formData.bankAccountNumber} // ✅ Đúng số TK
displayField='accountNumber'
disabled={isReadOnly}
/>
Rule 3: Nếu DTO không có display value → resolve bằng useEffect
Khi API DTO chỉ có FK ID (vd: bankAccountID) mà không có display value (vd: accountNumber), cần:
- Thêm field display value vào
TGFormState(vd:bankAccountNumber: string) - Resolve FK ID → display value bằng
useEffectkhi mở dialog edit/view - Dùng display value làm
initialLabel
// Step 1: Types — thêm field display value
// TG.types.ui.ts:
export interface TGFormState {
bankAccountID: string // FK ID từ API
bankAccountNumber: string // ← Display value (thêm mới)
bankName: string
// ...
}
// Step 2: Dialog — resolve FK ID khi edit/view
useEffect(() => {
if (!open || mode === 'create') return
if (formData.bankAccountID && !formData.bankAccountNumber) {
TKNHApiService.list({ pageIndex: 1, pageSize: 1, keyword: formData.bankAccountID })
.then(r => {
if (r.success && r.data?.items?.length) {
const match = r.data.items.find(x => x.id === formData.bankAccountID)
if (match) {
setField('bankAccountNumber', match.accountNumber ?? '')
}
}
})
.catch(() => {})
}
}, [open, mode, formData.bankAccountID, formData.bankAccountNumber])
// Step 3: Combobox — initialLabel khớp displayField
<TableSearchCombobox
value={formData.bankAccountID}
initialLabel={formData.bankAccountNumber} // ← Khớp displayField
displayField='accountNumber'
disabled={isReadOnly}
onChange={(id, rowData) => {
setField('bankAccountID', id)
setField('bankAccountNumber', rowData.accountNumber ?? '') // ← Update display value
}}
/>
Tóm tắt:
initialLabel= giá trị hiển thị trong input khi chưa có row được chọn. Nó PHẢI cùng loại vớidisplayField. View mode (disabled) phụ thuộc hoàn toàn vàoinitialLabel→ nếu sai → UI hiển thị sai.
5. Validate — onBlur (BẮT BUỘC)
// ✅ Validate khi rời field — không gây jitter
onBlur={() => handleBlur('fieldName')}
// ❌ KHÔNG validate onChange
3 cấp độ error:
- Inline error — dưới field:
<AlertCircle />+ message - Toast tổng hợp —
showValidationErrorsToast(errors)trước submit - Server error —
ValidationErrorDialog
6. 3 Mode View/Create/Edit
const isView = viewOnly === true // Pattern Danh Mục
const isReadOnly = mode === 'view' // Pattern Nghiệp Vụ
| Mode | Behavior |
|---|---|
| View | DmFieldValue / DmTabFieldValue (A) hoặc <div> (B) |
| Create | Input trống, autoFillCode(), submit POST |
| Edit | Input có giá trị từ DTO, submit PUT |
Cấm: dùng
disabledtrên input cho view mode. Phải render thẻ khác.
7. Dialog Footer
⚠️ 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.
Pattern A: DmDialogFooter (Danh Mục CRUD) — mặc định
<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()}
/>
Pattern B: Custom Footer (Nghiệp Vụ) — mặc định
<div className='flex items-center justify-between px-5 py-2.5 bg-white border-t border-[#AEB3BA]'>
<span>Đơn vị</span>
<div className='flex gap-2'>
{/* View: Ghi sổ / Bỏ ghi và sửa / Đóng */}
{/* Edit: Hủy (btn-secondary) / Lưu (btn-primary) */}
</div>
</div>
8. ClassName Quy Ước (Từ Code Thực Tế)
| Thành phần | Pattern A (Danh Mục) | Pattern B (Nghiệp Vụ) |
|---|---|---|
| Input height | h-8 (DmFormInput mặc định) |
h-[30px] |
| Input border | DmFormInput mặc định | border-[#AEB3BA] |
| Input error | error={errors.x} prop |
border-destructive |
| Input rounding | rounded-[8px] |
rounded-lg |
| Body font | text-[13px] |
text-[13px] |
| Header border | không có border-b |
không có border-b |
| Body bg | white | bg-[#f4f5f7] |
| Table header bg | — | bg-[#ededf1] |
| Table row hover | — | hover:bg-gray-50/50 |
| Label style | DmFormField auto | font-bold text-black |
| Required | required prop |
<span className='text-red-500'>*</span> |
| Dialog width | maxWidth='920px' |
maxWidth='none' (100vw) |
| Close button | h-7 w-7 text-gray-400 rounded-[8px] |
h-7 w-7 text-black |
| Tabs container | rounded-[8px] overflow-hidden |
tab button trong section |
| Tabs | className='gap-0' |
— |
| TabsList | bg-transparent border-b border-[#AEB3BA] p-0 gap-1 rounded-none |
— |
| DmTabTrigger | rounded-none border-b-1 border-gray-200 |
— |
| TabsContent | border border-[#AEB3BA] h-[280px] overflow-y-auto (CỐ ĐỊNH chiều cao, TẤT CẢ tab giống nhau) |
— |
⚠️ TẤT CẢ TabsContent PHẢI có
h-[280px] overflow-y-auto— nếu không sẽ bị nhảy layout khi chuyển tab. ⚠️ Mỗi tab có nội dung KHÁC NHAU — không tự ý đưa phone/email vào tab Tiền lương. Phân phối fields theo đúng MISA. ⚠️ Nếu MISA có field mà BE chưa có → vẫn render UI, gửi quaformData, tạo inbox BE bổ sung.
9. Bảng Hạch Toán Inline (Pattern B)
<div className='bg-white rounded-lg shadow-sm overflow-hidden'>
<div className='border-b border-[#AEB3BA] 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-[#AEB3BA]'>
<table className='w-full text-[13px] border-collapse'>
<thead>
<tr className='bg-[#ededf1]'>
<th className='w-10 px-2 py-2 text-center font-semibold border-r'>#</th>
<th className='px-2 py-2 text-left font-semibold border-r min-w-[200px]'>Diễn giải</th>
<th className='w-[110px] px-2 py-2 text-center font-semibold border-r'>TK Nợ</th>
<th className='w-[110px] px-2 py-2 text-center font-semibold border-r'>TK Có</th>
<th className='w-[130px] px-2 py-2 text-right font-semibold border-r'>Số tiền</th>
{/* Các cột FK... */}
{!isReadOnly && <th className='w-10 px-2 py-2 border-r'></th>}
</tr>
</thead>
<tbody>
{lines.map((line, i) => (
<tr key={i} className='hover:bg-gray-50/50 border-b last:border-b-0'>
<td className='px-2 py-1.5 text-center border-r'>{i + 1}</td>
<td className='px-2 py-1.5 border-r'>
{isReadOnly ? <span>{line.desc}</span> :
<Input className='h-6 border-0 p-0 text-[13px] bg-transparent focus-visible:ring-0 rounded-none' />}
</td>
{/* SearchCombobox inline: className='h-6 text-[13px] border-0 bg-transparent' */}
{/* Input số: type='text' inputMode='numeric' text-right */}
{!isReadOnly && (
<td className='px-2 py-1.5 text-center'>
<Button variant='ghost' size='sm' className='h-6 w-6 p-0 icon-danger'><Trash2 className='h-3.5 w-3.5' /></Button>
</td>)}
</tr>
))}
{/* Sum row: <tr className='bg-[#f2f2f4] font-medium'> */}
</tbody>
</table>
</div>
{/* Nút Thêm dòng / Xóa hết dòng */}
{!isReadOnly && (
<div className='flex gap-2 mt-2.5'>
<Button variant='ghost' size='sm' data-qa='btn_them_dong' onClick={addLine}
className='text-[13px] font-medium text-black hover:text-black hover:bg-gray-100 h-7 px-2'>
<Plus className='h-3.5 w-3.5 mr-1' /> Thêm dòng</Button>
<Button variant='ghost' size='sm' data-qa='btn_xoa_het_dong' onClick={...} disabled={...}
className='text-[13px] font-medium text-black hover:text-black hover:bg-gray-100 h-7 px-2'>
<Trash2 className='h-3.5 w-3.5 mr-1' /> Xóa hết dòng</Button>
</div>
)}
</div>
</div>
10. Grid Layout
Pattern A: Grid form
<div className='grid grid-cols-5 gap-x-3 gap-y-3'> {/* 5 cột mặc định */}
<DmFormField ... /> {/* 1 cột */}
<DmFormField className='col-span-3' ... /> {/* 3 cột */}
<DmFormField className='col-span-2' ... /> {/* 2 cột */}
</div>
Pattern B: Flex 2 cột (Nghiệp Vụ / Danh Mục có sidebar)
{/* Dùng cho cả Pattern B Nghiệp Vụ (full-screen) và Pattern A Danh Mục (920px) khi layout có 2 cột trái-phải rõ rệt */}
<div className='flex gap-6'>
<div className='flex-1 space-y-3'> {/* Main form */}
<div className='grid grid-cols-2 gap-4'>...</div> {/* Row 2 field */}
<div className='space-y-1'>...</div> {/* Full-width field */}
</div>
<div className='w-[250px] md:w-[300px] flex-shrink-0 space-y-3'> {/* Sidebar */}
<DatePicker ... />
<Input ... />
</div>
</div>
⚠️ Bài học từ NVFormDialog: Khi clone MISA thấy layout 2 cột (trái: Mã+Tên+Đơn vị+Chức danh; phải: Ngày sinh+Giới tính+CMND+Nơi cấp) → phải dùng flex 2 cột. Không ép tất cả vào grid-cols-5.
11. data-qa (BẮT BUỘC)
| Phần tử | Prefix | Ví dụ |
|---|---|---|
| Button | btn_ |
btn_them_moi, btn_luu, btn_huy, btn_dong_dialog |
| Input | i_ |
i_code, i_name, i_address, i_tim_kiem |
| Select/Combobox | sel_ |
sel_doi_tuong, sel_ref_type, sel_gender |
| DatePicker | dt_ |
dt_ref_date, dt_posted_date |
| Checkbox | chk_ |
chk_nha_cung_cap, chk_chon_tat_ca |
| Radio | r_ |
r_to_chuc, r_ca_nhan |
| Tab | tab_ |
tab_hach_toan |
| Table | tbl_ |
tbl_danh_sach |
| Row action | btn_xem_{id}, btn_sua_{id}, btn_xoa_{id} |
(DmRowActions tự sinh) |
12. Format Utilities & Quy Tắc Hiển Thị Dữ Liệu
Tham chiếu chuẩn quốc tế: ISO 8601 (date), ISO 4217 (currency), ISO 31-0 (numbers), Nielsen Norman Group (table UX: số căn phải, text căn trái), Edward Tufte (data-ink ratio: không dùng
-trong ô trống).
12.1 Danh Mục Format Functions
| Hàm | Input | Output | Null/Empty | Source |
|---|---|---|---|---|
formatCurrency(n) |
number |
"1.000.000 ₫" |
Không check null | @/shared/utils |
formatNumber(n) |
number |
"1.234.567" |
Không check null | @/shared/utils |
formatDate(s) |
string | null | undefined |
"15-01-2024" |
"-" |
@/shared/utils |
formatDateTime(s) |
string | null | undefined |
"15-01-2024 10:30:45" |
"-" |
@/shared/utils |
formatPercent(n, decimals?) |
number |
"12.50%" |
Không check null | @/shared/utils |
formatPhoneNumber(s) |
string | null | undefined |
"0912 345 678" |
"-" |
@/shared/utils |
formatTaxCode(s) |
string |
"1234-567-890" |
Không check null | @/shared/utils |
formatFileSize(bytes) |
number |
"1.2 MB" |
"0 B" khi 0 |
@/shared/utils |
formatVatRate(rate) |
number | null | undefined |
"10%", "KCT", "KKKNT" |
"" |
@/shared/utils |
formatDateForInput(s) |
string | Date | null |
"2024-01-15" (HTML5) |
"" |
@/shared/utils |
formatDateTimeForInput(s) |
string | Date | null |
"2024-01-15T10:30" (HTML5) |
"" |
@/shared/utils |
12.2 Nguyên Tắc Alignment Theo Loại Dữ Liệu
Chuẩn UX quốc tế (Nielsen Norman Group, ISO):
- Số → canh phải (
text-right): Giúp mắt so sánh độ lớn dễ dàng (cùng đơn vị, thẳng hàng decimal).- Text → canh trái (mặc định): Hướng đọc tự nhiên từ trái→phải.
- Mã ngắn / trạng thái → canh giữa (
text-center): Các giá trị có độ dài cố định, ngắn.- Header đồng bộ với data: Header của cột số canh phải giống data; header text canh trái.
| # | Loại dữ liệu | Ví dụ field | Alignment | Format | Ghi chú |
|---|---|---|---|---|---|
| 1 | Tiền tệ (table list) | totalAmount, debitAmount, creditAmount, amount |
Right text-right |
formatNumber() |
Không ₫ — sạch, dễ scan |
| 2 | Tiền tệ (dialog header/footer) | Tổng tiền dialog | — | formatCurrency() |
Có ₫ — xác nhận "đây là tiền" |
| 3 | Tiền tệ (bảng hạch toán) | Số tiền từng dòng | Right text-right |
formatNumber() |
Không ₫ |
| 4 | Số lượng | quantity, qty |
Right text-right |
formatNumber() |
Số nguyên hoặc decimal |
| 5 | Đơn giá | unitPrice, unitCost |
Right text-right |
formatNumber() |
|
| 6 | Tỷ giá / Hệ số | exchangeRate, coefficient |
Right text-right |
formatNumber() |
Hiển thị đủ decimal |
| 7 | Phần trăm | percent, discountRate |
Right text-right |
formatPercent(n, 2) |
"12.50%" |
| 8 | Thuế suất VAT | vatRate |
Right text-right |
formatVatRate() |
"10%" / "KCT" / "KKKNT" |
| 9 | Ngày tháng | refDate, postedDate, voucherDate |
Left (mặc định) | formatDate() |
dd-MM-yyyy |
| 10 | Ngày giờ | postedDate (trong dialog) |
Left (mặc định) | formatDateTime() |
dd-MM-yyyy HH:mm:ss |
| 11 | Mã code | code, refNo, refNoManagement, invoiceNumber |
Left (mặc định) | Raw | '' |
Style text-blue-700 font-medium |
| 12 | Tên | name, accountObjectName, employeeName |
Left (mặc định) | Raw | '' |
Style text-black truncate |
| 13 | Địa chỉ | address, accountObjectAddress |
Left (mặc định) | Raw | '' |
truncate |
| 14 | Diễn giải | description, reason |
Left (mặc định) | Raw | '' |
truncate + title={fullText} |
| 15 | Số điện thoại | phone, phoneNumber |
Left (mặc định) | formatPhoneNumber() |
"0912 345 678" |
| 16 | Mã số thuế | taxCode |
Left (mặc định) | formatTaxCode() |
"1234-567-890" |
| 17 | email |
Left (mặc định) | Raw | '' |
truncate |
|
| 18 | Boolean / Trạng thái | isPosted, isActive, isLocked |
Center text-center |
Badge component | Pill: xanh lá (true), amber/đỏ (false) |
| 19 | Enum / Loại | refType, paymentMethod, gender |
Left (mặc định) | Label lookup | Qua *_LABELS map |
| 20 | Object / FK (đã resolve) | accountObjectName, employeeName |
Left (mặc định) | Raw | '' |
Đã join sẵn từ API |
12.2a Quy Tắc Font-Weight Cho Cell Trong Bảng
⚠️ BẮT BUỘC: Cell trong bảng mặc định dùng
font-normal(400) — nét chữ thanh, dễ đọc. KHÔNG dùngfont-medium(500) làm mặc định cho bất kỳ cell nào.
| Loại cell | Font-weight | Ghi chú |
|---|---|---|
| Mặc định (text, tên, địa chỉ, diễn giải, số tiền...) | font-normal (400) |
Mặc định của trình duyệt — không cần khai báo class |
Số chứng từ / Mã link xanh (refNo, code, invoiceNumber, voucherCode...) |
font-medium (500) |
Giữ để nhấn mạnh tính định danh |
Badge trạng thái (isPosted, isActive) |
font-medium |
Giữ trong class text-xs font-medium của badge |
| Hàng tổng cộng | font-semibold (600) |
Đậm hơn để phân biệt với data rows |
| Header bảng | font-semibold (600) |
Mặc định trong DmTableHead |
// ❌ SAI — font-medium mặc định cho mọi cell
const CELL_CLASS: Record<string, string> = {
totalAmount: 'font-medium',
name: 'font-medium text-black',
}
const getCellClass = (...) => {
return (CELL_CLASS[field] ? `${CELL_CLASS[field]}` : 'text-black font-medium')
}
// ✅ ĐÚNG — font-normal mặc định, chỉ refNo giữ font-medium
const CELL_CLASS: Record<string, string> = {
refNo: 'font-medium text-blue-700', // ← chỉ field định danh link xanh
}
const getCellClass = (...) => {
return (CELL_CLASS[field] ? `${CELL_CLASS[field]}` : 'text-black')
}
12.3 Bảng Tổng Hợp CELL_VALUE & CELL_CLASS Mẫu
Áp dụng pattern này cho MỌI master page trong KetoanApp:
import { formatNumber, formatDate, formatDateTime, formatPercent, formatVatRate } from '@/shared/utils'
const CELL_VALUE: Record<string, (item: XxxDto) => string> = {
// === Số tiền (Right-align) ===
totalAmount: (i) => formatNumber(i.totalAmount),
debitAmount: (i) => formatNumber(i.debitAmount),
unitPrice: (i) => formatNumber(i.unitPrice),
// === Số lượng (Right-align) ===
quantity: (i) => formatNumber(i.quantity),
// === Ngày (Left-align, mặc định) ===
refDate: (i) => formatDate(i.refDate),
postedDate: (i) => i.postedDate ? formatDate(i.postedDate) : '',
voucherDate: (i) => formatDate(i.voucherDate),
// === Mã code (Left-align, xanh primary) ===
refNo: (i) => i.refNo || '',
code: (i) => i.code || '',
// === Tên (Left-align, bold) ===
name: (i) => i.name || '',
accountObjectName: (i) => i.accountObjectName || '',
employeeName: (i) => i.employeeName || '',
// === Text dài (Left-align, truncate) ===
description: (i) => i.description || '',
accountObjectAddress: (i) => i.accountObjectAddress || '',
// === Boolean (Center, Badge) ===
isPosted: (i) => i.isPosted ? 'Đã ghi sổ' : 'Chưa ghi sổ',
isActive: (i) => i.isActive ? 'Đang dùng' : 'Ngừng dùng',
// === Enum (Left-align, Label lookup) ===
refType: (i) => getReceiptTypeLabel(Number(i.refType), i.reasonTypeId),
}
const CELL_CLASS: Record<string, string> = {
// Mã (luôn xanh + font-medium để nhấn mạnh)
refNo: 'font-medium text-blue-700',
code: 'font-medium text-blue-700',
// Tên
name: 'text-black truncate',
accountObjectName: 'text-black truncate',
employeeName: 'text-black truncate',
// Text dài
description: 'text-black truncate',
accountObjectAddress: 'text-black truncate',
}
12.4 Alignment Trong JSX — Pattern Chuẩn
// Trong DmTableHead:
<DmTableHead
className={cn(
// Số → canh phải
(col.field === 'totalAmount' || col.field === 'debitAmount' || col.field === 'quantity') && 'text-right',
// Boolean / Badge → canh giữa
(col.field === 'isPosted' || col.field === 'isActive') && 'text-center',
)}
>
// Trong DmTableCell (data rows):
<DmTableCell
className={cn(
getCellClass(col.field, item),
// Số → canh phải
(col.field === 'totalAmount' || col.field === 'debitAmount' || col.field === 'quantity') && 'text-right',
// Boolean / Badge → canh giữa
(col.field === 'isPosted' || col.field === 'isActive') && 'text-center',
)}
>
12.5 Quy Tắc Giá Trị Rỗng
| Ngữ cảnh | Quy tắc | Ví dụ |
|---|---|---|
| Table cell (CELL_VALUE) | '' — chuỗi rỗng |
(i) => i.code || '' |
| Dialog header/footer total | formatCurrency(0) — không bỏ trống |
formatCurrency(totalAmount) luôn hiển thị |
| Form input (view mode) | '' — để trống |
<div>{value || ''}</div> |
| Ngày null | '' — không dùng '-' |
(i) => i.postedDate ? formatDate(i.postedDate) : '' |
| Fallback render function | ?? (() => '') |
(CELL_VALUE[col.field] ?? (() => ''))(item) |
⚠️ TUYỆT ĐỐI CẤM dùng
'-'cho giá trị rỗng — đây là "chartjunk", gây rối mắt, vi phạm nguyên tắc data-ink ratio. Ô trống = không có dữ liệu, không cần đánh dấu.
12.6 Quy Tắc Dùng truncate
| Cột | truncate? |
Tooltip title? |
Lý do |
|---|---|---|---|
name, accountObjectName |
✅ Có | ✅ Có | Tên đối tượng dài |
description |
✅ Có | ✅ Có | Diễn giải dài |
address |
✅ Có | ✅ Có | Địa chỉ dài |
code, refNo |
Không | Không | Mã thường ngắn < 20 ký tự |
totalAmount, quantity |
Không | Không | Số đã format ngắn |
isPosted, isActive |
Không | Không | Badge cố định |
refDate, postedDate |
Không | Không | Ngày format cố định dd-MM-yyyy |
DmTableCellđã mặc địnhtruncate— nhưng với cột tên, địa chỉ nên thêmtitle={fullText}để hiển thị tooltip khi hover.
12.7 Độ Rộng Cột Gợi Ý Theo Loại Dữ Liệu
| Loại dữ liệu | Độ rộng gợi ý | Lý do |
|---|---|---|
| Checkbox (chọn) | 40px |
Cố định |
| STT | 60px |
Số thứ tự 1-999 |
| Mã (code, refNo) | 120-160px |
Mã ~10-20 ký tự |
| Ngày (refDate) | 110px |
dd-MM-yyyy = 10 ký tự |
| Số tiền | 130-150px |
9.999.999.999 max |
| Trạng thái (badge) | 120px |
"Chưa ghi sổ" ~12 ký tự |
| Tên (name) | 180-250px |
Tên dài, có truncate |
| Diễn giải (desc) | 200-300px |
Text dài, có truncate |
| Địa chỉ | 200-300px |
Text dài, có truncate |
| Ghost action | 0px |
Overlay, không chiếm space |
12.8 Cột Định Danh (Identifier Column) — Click Mở View (BẮT BUỘC)
Tham khảo đầy đủ:
tao-master-pageSection 2.1a. Reference implementation:PhieuThuPage.tsx.
Cột định danh (mã, số chứng từ, số tài khoản...) phải có style xanh + click được để mở dialog xem chi tiết. Đây là hành vi bổ sung cho double-click (mở Edit).
| Loại trang | Cột định danh | Class bắt buộc |
|---|---|---|
| Nghiệp vụ (phiếu thu/chi, mua/bán, kho...) | refNo, refNoManagement, invoiceNumber, voucherCode, returnNo, returnCode, discountNo, discountCode, orderNo, orderCode |
text-[#1B6FC8] font-medium cursor-pointer hover:underline |
| Danh mục CRUD (KH, NCC, HH, NV, Kho, Tiền tệ...) | code |
font-medium text-blue-700 cursor-pointer hover:underline |
| Đơn vị tính | name (không có code) |
text-black cursor-pointer hover:underline |
| Tài khoản (bảng cây) | accountNumber |
font-mono text-[#1B6FC8] cursor-pointer hover:underline |
Quy tắc render (áp dụng cho mọi pattern):
- Bọc giá trị trong
<span onClick={e => { e.stopPropagation(); ... }}>để không triggeronDoubleClickcủa row data-qaformat:link_{fieldName}_{item.id}- Handler: nghiệp vụ dùng
setSelectedId+setDialogMode('view'), danh mục dùngsetSelectedItem
// Pattern nghiệp vụ (trong getCellClass + render cell)
const getCellClass = (field: string, _item?: Dto) => {
if (field === 'refNo') return 'text-[#1B6FC8] font-medium cursor-pointer hover:underline'
// ...
}
// Trong render:
{col.field === 'refNo' ? (
<span className='cursor-pointer hover:underline'
onClick={(e) => { e.stopPropagation(); setSelectedId(item.id); setDialogMode('view'); setDialogOpen(true) }}
data-qa={`link_refNo_${item.id}`}>{item.refNo || ''}</span>
) : /* badge / CELL_VALUE fallback */}
// Pattern danh mục (trong CELL_CLASS + render cell)
const CELL_CLASS = { code: 'font-medium text-blue-700 cursor-pointer hover:underline', ... }
// Trong render:
{col.field === 'code' ? (
<span className='cursor-pointer hover:underline'
onClick={(e: React.MouseEvent) => { e.stopPropagation(); setSelectedItem(item); setDialogOpen(true) }}
data-qa={`link_code_${item.id}`}>{item.code || ''}</span>
) : ((CELL_VALUE[col.field] ?? (() => ''))(item))}
13. Import Chuẩn — Tránh Runtime Error
// ✅ cn()
import { cn } from '@/shared/components/ui/utils'
// ✅ Validate
import { validateAllFields, hasAnyError } from '@/shared/utils/ValidationUtils'
import { showValidationErrorsToast } from '@/shared/utils/ValidationToastHelper'
// ❌ SAI
import { cn } from '@/lib/utils'
import { cn } from '@/shared/utils/cn'
14. Nguyên Tắc Cốt Lõi
- File
.tsxchỉ chứa UI — toàn bộ logic đặt trong/hooks/ - KHÔNG gọi API trực tiếp từ Pages / Components / Dialogs
- KHÔNG hardcode API URL
- KHÔNG tạo lại utils / components đã có trong
/shared - KHÔNG dùng
anytrong TypeScript - Dùng
ConfirmDialogcho xóa,ValidationErrorDialogcho lỗi server - ⚠️ KHÔNG tự thêm field vào
types.api.ts(DTO) — field UI có mà BE chưa có → tạo inbox/yêu cầu BE bổ sung. FE và BE phải thống nhất DTO.
15. Checklist Chung
- Component đúng pattern (DmFormField cho Danh Mục, Label+Input cho Nghiệp Vụ)
- 3 mode hoạt động (View/Create/Edit) với isReadonly flag
- View mode: DmFieldValue / DmTabFieldValue /
<div>(không input disabled) - Validate onBlur + inline error message
- Input số:
type='text' inputMode='numeric'+text-right - FK: SearchCombobox / TableSearchCombobox / DmGroupInput
- data-qa đầy đủ (btn_, i_, sel_, dt_, chk_, r_, tab_)
-
cn()import đúng:@/shared/components/ui/utils - Dialog footer đúng pattern
- Nút X custom (ẩn Radix mặc định)
- Compile không lỗi, không runtime error
- KHÔNG tự thêm field vào types.api.ts — field UI có mà BE chưa có → tạo inbox BE
- ⚠️ Reset state dialog: Hook có
useEffect([initialData])resetformData— form phải theo giá trị mới khi đóng/mở lại
🔥 Lỗi Hay Gặp Khi Code Dialog (Checklist Tránh Lặp)
Mục đích: Liệt kê các lỗi phổ biến trong dialog KetoanApp. Đọc trước khi code để tránh.
1. ❌ KHÔNG set font-family ở bất kỳ đâu
Font kế thừa từ body (globals.css: Inter → InterVariable → "Noto Sans" → "Open Sans" → sans-serif). Mọi font-family override trong dialog đều bị cấm.
❌ SAI: className='font-sans', style={{ fontFamily: 'Inter' }}
✅ ĐÚNG: KHÔNG set font-family — để element kế thừa từ body
2. ❌ Thiếu showQuickAdd trên TableSearchCombobox cho FK
MỌI TableSearchCombobox dùng chọn FK (NCC, Hàng hóa, Kho, Nhân viên, Tài khoản...) PHẢI có nút (+) tạo nhanh:
❌ SAI: <TableSearchCombobox value={...} loadOptions={loadNCC} columns={nccColumns}
onChange={handleNccChange} />
✅ ĐÚNG: <TableSearchCombobox value={...} loadOptions={loadNCC} columns={nccColumns}
onChange={handleNccChange}
showQuickAdd
onCreateClick={() => { setNccDialogOpen(true) }} />
Quy tắc: Nếu combobox chọn từ danh mục (có thể tạo mới) → có showQuickAdd. Nếu combobox chọn từ enum cố định (trạng thái, loại) → không cần.
3. ❌ Import sai formatCurrency path
Một số dialog import formatCurrency từ @/shared/InvoiceDesigner/utils/data-binding.utils — đây là path sai. Luôn import từ @/shared/utils:
❌ SAI: import { formatCurrency } from '@/shared/InvoiceDesigner/utils/data-binding.utils'
✅ ĐÚNG: import { formatCurrency } from '@/shared/utils'
4. ❌ Thiếu UnsavedChangesConfirm trong dialog
Dialog nghiệp vụ full-screen PHẢI có UnsavedChangesConfirm để cảnh báo khi user vô tình đóng:
✅ ĐÚNG: <UnsavedChangesConfirm
open={dialogOpen}
dirty={isDirty}
onConfirm={() => { /* đóng */ }}
/>
5. ❌ Form field số dùng type='number'
❌ SAI: <Input type='number' ... />
✅ ĐÚNG: <Input type='text' inputMode='numeric' className='... text-right' ... />
6. ❌ View mode dùng <Input disabled> thay vì <DmFieldValue>
❌ SAI: <Input value={formData.name} disabled />
✅ ĐÚNG: <DmFieldValue value={formData.name} />
7. ❌ FK field chỉ gửi ID, thiếu code/name cho display
Khi chọn FK trong TableSearchCombobox, ngoài id cần lưu thêm code và name để hiển thị trong view mode và gửi lên payload:
❌ SAI: onChange={(id) => setField('nccId', id)}
✅ ĐÚNG: onChange={(id, rowData) => {
setField('nccId', id)
setField('nccCode', rowData.code || '')
setField('nccName', rowData.name || '')
}}
8. ❌ Dialog không reset form khi đóng/mở lại
Hook phải có useEffect reset formData theo initialData để khi đóng/mở dialog form luôn fresh:
// Trong hook
useEffect(() => {
if (initialData) setFormData(initialData)
}, [initialData])