tao-dialog-full
Tạo Dialog full-screen cho nghiệp vụ KetoanApp. Layout: Header (title + tổng tiền + nút X) → Body (2 cột: trái flex-1 fields, phải w-[250px] ngày/số phiếu) → Bảng hạch toán EditableDataTable (white ca
Files1
View on GitHubSKILL.md.claude/skills/_archived-sasuco/tao-dialog-full/SKILL.md
GitHubTạo Dialog Full-Screen — KetoanApp
Kế thừa:
tao-ui-giao-dien-new(foundation UI),tao-dialog-new(cấu trúc dialog),table-combobox-id-only(FK chỉ gửi ID).Base code:
PCDialog.tsx(Phiếu chi) vàPTDialog.tsx(Phiếu thu) — 2 dialog full-screen chuẩn trong KetoanApp.Áp dụng cho: TẤT CẢ dialog full-screen nghiệp vụ: Phiếu thu, Phiếu chi, Tiền gửi, Tiền vay, và mọi dialog có cấu trúc tương tự.
0. Tổng Quan Layout Full-Screen
┌──────────────────────────────────────────────────────────────────┐
│ HEADER (flex-shrink-0, border-b) │
│ [Title + refType Select] [Tổng tiền: 999,999] [X] │
├──────────────────────────────────────────────────────────────────┤
│ BODY (flex-1, min-h-0, overflow-hidden, bg-[#f4f5f7], │
│ flex flex-col) │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ SECTION: Thông tin chung (flex-shrink-0, fixed top) │ │
│ │ ┌───────────────────────────────┐ ┌─────────────────────┐ │ │
│ │ │ CỘT TRÁI (flex-1) │ │ CỘT PHẢI (w-[250px]) │ │ │
│ │ │ • Field 1 • Field 2 │ │ • Ngày hạch toán * │ │ │
│ │ │ • Field 3 (full width) │ │ • Ngày chứng từ * │ │ │
│ │ │ • Field 4 (full width) │ │ • Số phiếu * │ │ │
│ │ │ • Field 5 • Field 6 │ │ │ │ │
│ │ └───────────────────────────────┘ └─────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ SECTION: Hạch toán (flex-1, min-h-0, fill remaining space) │ │
│ │ ┌─ Tab header: "Hạch toán" (flex-shrink-0, fixed) │ │
│ │ ├─ Table scroll area (flex-1, min-h-0, overflow-y-auto) │ │
│ │ ├─ Sum Row (Tổng cộng) │ │
│ │ └─ Error message (nếu có) │ │
│ └──────────────────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────────────────┤
│ FOOTER (flex-shrink-0, border-t) │
│ [placeholder] [Hủy] [Lưu] [Lưu & Ghi sổ] │
└──────────────────────────────────────────────────────────────────┘
0.1 Kích Thước Chuẩn
| Phần | Width | Height | Giải thích |
|---|---|---|---|
| Cột trái (fields) | flex-1 |
— | Chiếm phần còn lại sau khi trừ cột phải + gap |
| Cột phải (ngày/số) | w-[250px] flex-shrink-0 |
— | Cố định 250px, không co giãn |
| Gap giữa 2 cột | gap-6 (24px) |
— | Khoảng cách giữa cột trái và cột phải |
| Input | h-[30px] text-[13px] rounded-lg |
— | Chiều cao & style chuẩn |
| Select | h-8 text-[13px] rounded-lg |
— | Chiều cao select chuẩn |
| Label | text-[13px] font-semibold text-black |
— | Style label chuẩn |
| Header | — | py-3.5 |
Padding top/bottom |
| Footer | — | py-2.5 |
Padding top/bottom |
| Info section (fixed) | — | flex-shrink-0 |
Cố định trên cùng, không scroll |
| Table section (fill) | — | flex-1 min-h-0 |
Fill hết chiều cao còn lại |
| Table scroll area | — | flex-1 min-h-0 overflow-y-auto |
Chỉ bảng scroll, tab header cố định |
1. DialogContent — Full-Screen Pattern (BẮT BUỘC)
<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'
>
{renderHeader()}
{renderBody()}
{renderFooter()}
</DialogContent>
</Dialog>
| Prop | Giá trị | Giải thích |
|---|---|---|
maxWidth |
'none' |
Bỏ giới hạn chiều rộng mặc định |
w-[100vw] max-w-[100vw] |
Full viewport width | Tràn toàn màn hình |
h-[100vh] max-h-[100vh] |
Full viewport height | Tràn toàn màn hình |
p-0 gap-0 rounded-none |
Zero padding/gap/radius | Sát viền, không bo góc |
flex flex-col |
Flex column | Layout Header → Body → Footer |
overflow-hidden |
Ẩn overflow | Body tự scroll riêng |
[&>button:first-child]:hidden |
Ẩn nút X mặc định | Dùng nút X tự tạo trong Header |
2. Header Pattern
const renderHeader = () => (
<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'>
{TITLE} {formData.refNo}
</span>
{/* refType Select (nếu có) */}
<div className='flex items-center gap-1.5'>
<Select
value={String(formData.refType)}
onValueChange={(v) => setField('refType', Number(v))}
disabled={isReadOnly}
>
<SelectTrigger className={cn(
'w-[260px] h-8 text-[13px] font-medium truncate border-[#B7BCC3] rounded-lg bg-white',
isReadOnly && 'opacity-70'
)} data-qa='sel_ref_type'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(TYPE_LABELS).map(([val, label]) => (
<SelectItem key={val} value={val} className='text-[13px] font-medium'>{label}</SelectItem>
))}
</SelectContent>
</Select>
{!isReadOnly && touched.refType && errors.refType && (
<span className='text-xs text-destructive flex items-center gap-1'>
<AlertCircle className='h-3 w-3' />{errors.refType}
</span>
)}
</div>
</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={() => {
if (dirtyRef.current) {
setUnsavedOpen(true)
pendingCloseRef.current = () => onOpenChange(false)
} else {
onOpenChange(false)
}
}}
>
<X className='h-4 w-4' />
</Button>
</div>
</div>
)
Đặc điểm Header:
flex-shrink-0— không bị co lại khi body scrollborder-b border-[#B7BCC3]— đường kẻ phân cách với body- Nút X có unsaved guard: nếu form dirty → mở ConfirmDialog; nếu không → đóng luôn
title: do user truyền vào (VD: "Chi tiền", "Thu tiền")refType Select: tùy chọn, nếu không có refType thì bỏ
2.1 State cần có trong Dialog
const [confirmClearOpen, setConfirmClearOpen] = useState(false)
const [unsavedOpen, setUnsavedOpen] = useState(false)
const dirtyRef = useRef(false)
const pendingCloseRef = useRef<(() => void) | null>(null)
2.2 Theo dõi dirty state
// Reset dirty khi mở dialog
useEffect(() => {
if (!open) return
dirtyRef.current = false
}, [open, editingId])
// Set dirty khi form thay đổi trong mode edit/create
useEffect(() => {
if (open && mode !== 'view') {
dirtyRef.current = true
}
}, [open, mode, formData])
// Cleanup pending close khi unsaved dialog đóng
useEffect(() => {
if (!unsavedOpen && pendingCloseRef.current && !dirtyRef.current) {
pendingCloseRef.current()
pendingCloseRef.current = null
}
}, [unsavedOpen])
3. Body Pattern
QUAN TRỌNG: Body dùng
flex flex-colvớioverflow-hidden. KHÔNG scroll body — chỉ bảng hạch toán được scroll (overflow-y-auto). Phần thông tin chung cố định (flex-shrink-0), bảng hạch toán fill hết chiều cao còn lại (flex-1 min-h-0).
const renderBody = () => (
<div className='flex-1 min-h-0 overflow-hidden bg-[#f4f5f7] flex flex-col'>
{loadingDetail ? (
<div className='flex items-center justify-center h-40'>
<Loader2 className='animate-spin h-6 w-6 text-black' />
</div>
) : (
<>
{/* ── Section: Thông tin chung (fixed top) ──────────────── */}
<div className='p-5 pb-0 flex-shrink-0'>
<div className='flex gap-6'>
{/* Cột trái: flex-1 — các field nghiệp vụ */}
<div className='flex-1 space-y-3'>
{/* Các field do user định nghĩa — xem Section 3.1 */}
</div>
{/* Cột phải: w-[250px] flex-shrink-0 — Ngày + Số phiếu */}
<div className='w-[250px] flex-shrink-0 space-y-3'>
{/* Các field ngày/số do user định nghĩa — xem Section 3.2 */}
</div>
</div>
</div>
{/* ── Section: Bảng hạch toán (fill remaining space) ────── */}
<div className='p-5 pt-4 flex-1 min-h-0 flex flex-col'>
{/* Xem Section 4 */}
</div>
</>
)}
</div>
)
Đặc điểm Body:
flex-1 min-h-0 overflow-hidden bg-[#f4f5f7] flex flex-col— flex column, KHÔNG scroll toàn bộ bodymin-h-0— QUAN TRỌNG: cho phép flex child co lại- Info section:
p-5 pb-0 flex-shrink-0— cố định trên cùng, không scroll - Table section:
p-5 pt-4 flex-1 min-h-0 flex flex-col— fill hết không gian còn lại - Chỉ table scroll (
overflow-y-autoở trong white card — xem Section 4) - Loading: spinner nằm giữa
3.1 Cột Trái (flex-1) — Field Pattern
Các field được tổ chức linh hoạt theo nhu cầu. Pattern phổ biến:
<div className='flex-1 space-y-3'>
{/* Pattern A: 2 field ngang hàng (grid-cols-2) */}
<div className='grid grid-cols-2 gap-4'>
{/* Field 1 */}
<div className='space-y-1'>
<Label className='text-[13px] font-semibold text-black'>Tên field</Label>
<Input ... />
</div>
{/* Field 2 */}
<div className='space-y-1'>
<Label className='text-[13px] font-semibold text-black'>Tên field</Label>
<Input ... />
</div>
</div>
{/* Pattern B: Field full-width */}
<div className='space-y-1'>
<Label className='text-[13px] font-semibold text-black'>Tên field</Label>
<Input ... />
</div>
{/* Pattern C: Field với TableSearchCombobox */}
<div className='space-y-1'>
<Label className='text-[13px] font-semibold text-black'>Đối tượng</Label>
<TableSearchCombobox
value={formData.xxxId}
initialLabel={formData.xxxName}
displayField='name'
onChange={(id, rowData) => {
if (isReadOnly) return
setField('xxxId', id)
setField('xxxName', rowData.name || '')
}}
loadOptions={loadXxx}
columns={xxxColumns}
dataQa='sel_xxx'
showQuickAdd
debounceMs={800}
disabled={isReadOnly}
/>
</div>
</div>
3.2 Cột Phải (w-[250px]) — Ngày & Số Phiếu
Cột phải mặc định chứa: Ngày hạch toán, Ngày chứng từ, Số phiếu. User có thể thêm/bớt field.
<div className='w-[250px] flex-shrink-0 space-y-3'>
{/* Ngày hạch toán */}
<div className='space-y-1'>
<Label className='text-[13px] font-semibold text-black'>
Ngày hạch toán <span className='text-red-500'>*</span>
</Label>
<div className='space-y-0.5'>
<DatePicker
dataQa='dt_posted_date'
value={formData.postedDate}
onChange={v => setField('postedDate', v)}
onBlur={() => handleBlur('postedDate')}
disabled={isReadOnly}
className={cn(
'h-[30px] text-[13px] rounded-lg bg-white w-full',
!isReadOnly && touched.postedDate && errors.postedDate
? 'border-destructive' : 'border-[#B7BCC3]',
)}
/>
{!isReadOnly && touched.postedDate && errors.postedDate && (
<p className='text-xs text-destructive flex items-center gap-1'>
<AlertCircle className='h-3 w-3' />{errors.postedDate}
</p>
)}
</div>
</div>
{/* Ngày chứng từ */}
<div className='space-y-1'>
<Label className='text-[13px] font-semibold text-black'>
Ngày chứng từ <span className='text-red-500'>*</span>
</Label>
<div className='space-y-0.5'>
<DatePicker
dataQa='dt_ref_date'
value={formData.refDate}
onChange={v => setField('refDate', v)}
onBlur={() => handleBlur('refDate')}
disabled={isReadOnly}
className={cn(
'h-[30px] text-[13px] rounded-lg bg-white w-full',
!isReadOnly && touched.refDate && errors.refDate
? 'border-destructive' : 'border-[#B7BCC3]',
)}
/>
{!isReadOnly && touched.refDate && errors.refDate && (
<p className='text-xs text-destructive flex items-center gap-1'>
<AlertCircle className='h-3 w-3' />{errors.refDate}
</p>
)}
</div>
</div>
{/* Số phiếu */}
<div className='space-y-1'>
<Label className='text-[13px] font-semibold text-black'>
Số {TEN_PHIEU} <span className='text-red-500'>*</span>
</Label>
<div className='space-y-0.5'>
<Input
data-qa='i_ref_no'
value={formData.refNo}
onChange={e => setField('refNo', e.target.value)}
onBlur={() => handleBlur('refNo')}
readOnly={isReadOnly}
className={cn(
'h-[30px] text-[13px] rounded-lg bg-white',
!isReadOnly && touched.refNo && errors.refNo
? 'border-destructive' : 'border-[#B7BCC3]',
)}
/>
{!isReadOnly && touched.refNo && errors.refNo && (
<p className='text-xs text-destructive flex items-center gap-1'>
<AlertCircle className='h-3 w-3' />{errors.refNo}
</p>
)}
</div>
</div>
</div>
4. Bảng Dữ Liệu (EditableDataTable) — Hạch Toán
Bảng dữ liệu PHẢI được bọc trong white card với flex layout:
flex flex-col flex-1 min-h-0để fill hết chiều cao còn lại. Tab header cố định (flex-shrink-0), vùng bảng scroll nội bộ (flex-1 min-h-0 overflow-y-auto).
{/* ── Section: Bảng dữ liệu ────────────────────────────────── */}
<div className='bg-white rounded-lg shadow-sm overflow-hidden flex flex-col flex-1 min-h-0'>
{/* Tab header (fixed) */}
<div className='border-b border-[#B7BCC3] px-3 flex-shrink-0'>
<button
type='button'
className='py-2.5 px-4 text-base font-semibold text-primary border-b-2 border-primary mb-3'
data-qa='tab_xxx'
>
{TABLE_TAB_LABEL} {/* Do user truyền vào, VD: "Hạch toán" */}
</button>
</div>
{/* Table scroll area */}
<div className='flex-1 min-h-0 overflow-y-auto'>
<EditableDataTable<Record<string, unknown>>
columns={accountingColumns}
rows={formData.accountingDetails as unknown as Record<string, unknown>[]}
onChange={(newRows) => setAccountingLines(newRows as unknown as XxxLineFormState[])}
isReadonly={isReadOnly}
createEmptyRow={createEmptyLine}
emptyText='Chưa có dòng dữ liệu'
addButtonLabel='Thêm dòng'
data-qa='tbl_xxx'
sumRow={formData.xxxDetails && (formData.xxxDetails as unknown[]).length > 0 ? (
<tr className='bg-[#ECEDEF] border-t border-solid border-[#B7BCC3]'>
<td className='px-2 py-1.5'></td>
<td className='px-2 py-1.5'>
<span className='font-semibold text-black'>Tổng cộng</span>
</td>
<td className='px-2 py-1.5'></td>
<td className='px-2 py-1.5'></td>
<td className='px-2 py-1.5 text-right font-bold text-base text-black'>
{formatCurrency(totalAmount)}
</td>
<td className='px-2 py-1.5'></td>
<td className='px-2 py-1.5'></td>
<td className='px-2 py-1.5'></td>
{!isReadOnly && <td className='px-2 py-1.5'></td>}
</tr>
) : null}
/>
{errors.xxxDetails && (
<p className='text-xs text-destructive flex items-center gap-1 mt-1.5'>
<AlertCircle className='h-3 w-3' />{errors.xxxDetails}
</p>
)}
</div>
</div>
4.1 EditableDataTable Props
| Prop | Mô tả |
|---|---|
columns |
EditableColumnDef<T>[] — định nghĩa cột (key, label, width, render...) |
rows |
T[] — dữ liệu các dòng |
onChange |
Callback khi dữ liệu thay đổi |
isReadonly |
true khi mode='view' |
createEmptyRow |
Factory tạo dòng trống — dùng useCallback để tránh re-render |
emptyText |
Text hiển thị khi bảng trống |
addButtonLabel |
Label nút "Thêm dòng" (mặc định: "Thêm dòng") |
sumRow |
JSX dòng tổng cộng — render dưới cùng bảng |
data-qa |
Attribute test |
4.2 Summit Row Pattern
Sum row có background bg-[#ECEDEF], border-top border-[#B7BCC3]:
sumRow={
<tr className='bg-[#ECEDEF] border-t border-solid border-[#B7BCC3]'>
{/* Các cột trống cho STT + các cột không có tổng */}
<td className='px-2 py-1.5'></td>
<td className='px-2 py-1.5'>
<span className='font-semibold text-black'>Tổng cộng</span>
</td>
{/* ... thêm td trống cho các cột khác ... */}
<td className='px-2 py-1.5 text-right font-bold text-base text-black'>
{formatCurrency(totalAmount)}
</td>
{/* ... thêm td trống cho các cột còn lại ... */}
{!isReadOnly && <td className='px-2 py-1.5'></td>} {/* Cột xóa nếu không readonly */}
</tr>
}
Quy tắc sumRow: Mỗi cột trong
columnscần 1<td>tương ứng. Cột cuối cùng (action/xóa) chỉ hiển thị khi!isReadOnly.
4.3 Column Definition Pattern
const accountingColumns = useMemo<EditableColumnDef<Record<string, unknown>>[]>(() => [
{
key: '_stt', label: '#', type: 'display', width: '40px',
render: (_value, _row, idx) => (
<span className='text-center text-[13px] text-gray-500'>{idx + 1}</span>
),
},
{
key: 'description', label: 'Diễn giải', width: '250px',
render: (value, _row, idx) => (
<Input
value={String(value ?? '')}
onChange={e => updateLine(idx, { description: e.target.value })}
readOnly={isReadOnly}
className='h-[26px] text-[13px] rounded-lg border border-[#B7BCC3] bg-white text-black focus-visible:ring-0'
data-qa={`i_line_description_${idx}`}
/>
),
},
{
key: 'debitAccountId', label: 'TK Nợ', width: '100px',
render: (_value, row, idx) => (
<TableSearchCombobox
value={String(row.debitAccountId ?? '')}
initialLabel={String(row.debitAccountNumber ?? '')}
displayField='accountNumber'
onChange={(id, rowData) => updateLine(idx, {
debitAccountId: id,
debitAccountNumber: rowData.accountNumber || '',
})}
loadOptions={loadTaiKhoan}
columns={taiKhoanColumns}
dataQa={`sel_tk_no_${idx}`}
disabled={isReadOnly}
/>
),
},
{
key: 'amount', label: 'Số tiền',
render: (value, _row, idx) => (
<Input
type='text' inputMode='numeric'
value={value ? formatNumber(Number(value) || 0) : ''}
onChange={e => updateLine(idx, {
amount: e.target.value.replace(/\./g, '').replace(/[^0-9]/g, ''),
})}
readOnly={isReadOnly}
className='h-[26px] p-0 text-[13px] text-right rounded-lg border border-[#B7BCC3] bg-white text-black focus-visible:ring-0'
data-qa={`i_line_amount_${idx}`}
/>
),
},
// ... các cột khác
], [isReadOnly, updateLine])
Style cho input trong bảng:
h-[26px](nhỏ hơn input ngoài formh-[30px])text-[13px] rounded-lg border border-[#B7BCC3] bg-white text-black focus-visible:ring-0- Input số:
p-0 text-right+ formatformatNumber() - TableSearchCombobox trong bảng: không cần
compact, dùng height mặc định
4.4 Empty Line Factory
const createEmptyLine = useCallback((): Record<string, unknown> => {
return {
...XXX_INITIAL_LINE,
description: formData.description,
} as unknown as Record<string, unknown>
}, [formData.description])
5. Footer Pattern
const renderFooter = () => (
<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%]' />
<div className='flex gap-2'>
{isReadOnly ? (
<>
{!formData.isPosted && (
<>
<Button className='btn-secondary h-8 text-[13px] rounded-lg' data-qa='btn_sua' onClick={handleSwitchToEdit}>
<Pencil className='h-3.5 w-3.5 mr-1' />Sửa
</Button>
<Button className='btn-primary h-8 text-[13px] rounded-lg' data-qa='btn_ghi_so' disabled={operating} onClick={() => setPostOpen(true)}>
{operating && <Loader2 className='animate-spin h-3.5 w-3.5 mr-1' />}
<BookOpen className='h-3.5 w-3.5 mr-1' />Ghi sổ
</Button>
</>
)}
{formData.isPosted && (
<Button className='btn-primary h-8 text-[13px] rounded-lg' data-qa='btn_bo_ghi_va_sua' disabled={operating} onClick={() => setUnpostOpen(true)}>
{operating && <Loader2 className='animate-spin h-3.5 w-3.5 mr-1' />}
Bỏ ghi và sửa
</Button>
)}
</>
) : mode === 'edit' ? (
<>
<Button className='btn-secondary h-8 text-[13px] rounded-lg' data-qa='btn_huy' disabled={submitting}
onClick={() => {
if (dirtyRef.current) { setUnsavedOpen(true); pendingCloseRef.current = () => onOpenChange(false) }
else onOpenChange(false)
}}>Hủy</Button>
<Button className='btn-secondary h-8 text-[13px] rounded-lg' data-qa='btn_luu' disabled={submitting} onClick={handleSubmit}>
{submitting && <Loader2 className='animate-spin h-3.5 w-3.5 mr-1' />}Lưu
</Button>
</>
) : (
<>
<Button className='btn-secondary h-8 text-[13px] rounded-lg' data-qa='btn_huy' disabled={submitting}
onClick={() => {
if (dirtyRef.current) { setUnsavedOpen(true); pendingCloseRef.current = () => onOpenChange(false) }
else onOpenChange(false)
}}>Hủy</Button>
<Button className='btn-secondary h-8 text-[13px] rounded-lg' data-qa='btn_luu' disabled={submitting} onClick={handleSubmit}>
{submitting && <Loader2 className='animate-spin h-3.5 w-3.5 mr-1' />}Lưu
</Button>
<Button className='btn-primary h-8 text-[13px] rounded-lg' data-qa='btn_luu_va_them' disabled={submitting} onClick={handleSubmitAndNew}>
{submitting && <Loader2 className='animate-spin h-3.5 w-3.5 mr-1' />}Lưu & Thêm
</Button>
</>
)}
</div>
</div>
)
5.1 Ma Trận Action Footer
| Mode | Điều kiện | Buttons |
|---|---|---|
| View | !isPosted (chưa ghi sổ) |
[Sửa] [Ghi sổ] |
| View | isPosted (đã ghi sổ) |
[Bỏ ghi và sửa] |
| Create | — | [Hủy] [Lưu] [Lưu & Thêm] |
| Edit | — | [Hủy] [Lưu] |
Nút [Lưu & Ghi sổ] (thay cho [Lưu & Thêm]): Nếu nghiệp vụ cần ghi sổ ngay khi tạo, dùng handleCreateAndPost:
{mode === 'create' && (
<Button className='btn-primary h-8 text-[13px] rounded-lg' data-qa='btn_luu_va_ghi_so' disabled={submitting} onClick={handleCreateAndPost}>
{submitting && <Loader2 className='animate-spin h-3.5 w-3.5 mr-1' />}Lưu & Ghi sổ
</Button>
)}
5.2 Button Style Convention
| Class | Dùng cho |
|---|---|
btn-secondary h-8 text-[13px] rounded-lg |
Hủy, Lưu, Sửa |
btn-primary h-8 text-[13px] rounded-lg |
Ghi sổ, Bỏ ghi và sửa, Lưu & Thêm, Lưu & Ghi sổ |
| Icon size trong button | h-3.5 w-3.5 |
❌ KHÔNG dùng
variant='outline'hoặc Button trần.
6. Confirm Dialogs
6.1 Unsaved Changes Guard
<ConfirmDialog
open={unsavedOpen}
onOpenChange={(open) => { if (!open) dirtyRef.current = false; setUnsavedOpen(open) }}
title='Dữ liệu đã bị thay đổi'
message='Bạn có muốn lưu các thay đổi không?'
confirmLabel='Có' cancelLabel='Không'
onConfirm={async () => { setUnsavedOpen(false); dirtyRef.current = false; await handleSubmit() }}
/>
6.2 Clear All Lines
<ConfirmDialog
open={confirmClearOpen}
onOpenChange={setConfirmClearOpen}
title='Xóa hết dòng?'
message='Toàn bộ dòng sẽ bị xóa. Thao tác này không thể hoàn tác.'
onConfirm={() => { clearAllLines(); setConfirmClearOpen(false) }}
variant='destructive'
/>
6.3 Post / Unpost
<ConfirmDialog
open={postOpen}
onOpenChange={setPostOpen}
title='Ghi sổ {TEN_PHIEU}'
message={`Bạn có chắc chắn muốn ghi sổ {TEN_PHIEU} ${formData.refNo}? Sau khi ghi sổ sẽ không thể chỉnh sửa.`}
confirmLabel='Ghi sổ'
onConfirm={async () => {
if (!editingId) return
setOperating(true)
const ok = await handlePost(editingId)
setOperating(false)
if (ok) { setPostOpen(false); onOpenChange(false) }
}}
/>
<ConfirmDialog
open={unpostOpen}
onOpenChange={setUnpostOpen}
title='Bỏ ghi sổ {TEN_PHIEU}'
message={`Bạn có chắc chắn muốn bỏ ghi sổ {TEN_PHIEU} ${formData.refNo}?`}
confirmLabel='Bỏ ghi sổ'
variant='destructive'
onConfirm={handleUnpostAndEdit}
/>
6.4 Validation Error
<ValidationErrorDialog
open={serverErrorOpen}
onOpenChange={setServerErrorOpen}
title='Lỗi từ máy chủ'
message={typeof serverError === 'object' && serverError !== null && 'message' in (serverError as Record<string, unknown>)
? (serverError as Record<string, unknown>).message as string
: undefined}
/>
7. Dialog Props Interface (Chuẩn)
interface XxxDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
/** Nếu truyền → mở ở mode view/edit; không truyền → mode create */
editingId?: string | null
defaultMode?: 'view' | 'edit' | 'create'
onSuccess: () => void
}
7.1 Hook Interface
Hook của dialog cần expose các methods sau:
const {
mode, // 'view' | 'create' | 'edit'
formData, // FormState
errors, // FormErrors
touched, // Record<string, boolean>
submitting, // boolean
loadingDetail, // boolean
totalAmount, // number — tổng tiền từ accounting lines
serverError, // unknown
serverErrorOpen, // boolean
setServerErrorOpen,// (open: boolean) => void
setField, // (field: string, value: any) => void
handleBlur, // (field: string) => void
updateLine, // (idx: number, patch: Partial<LineState>) => void
clearAllLines, // () => void
setAccountingLines,// (lines: LineState[]) => void
handleSubmit, // () => Promise<void>
handleSubmitAndNew,// () => Promise<void> — Lưu & Thêm mới
handlePost, // (id: string) => Promise<boolean>
handleUnpost, // (id: string) => Promise<boolean>
initCreate, // () => Promise<void>
initEdit, // (id: string) => Promise<void>
initView, // (id: string) => Promise<void>
} = useXxxDlgForm({ onSuccess, onClose: () => onOpenChange(false) })
7.2 Init Mode Handler
useEffect(() => {
if (!open) return
dirtyRef.current = false
if (editingId) {
if (defaultMode === 'view') initView(editingId)
else initEdit(editingId)
} else {
initCreate()
}
}, [open, editingId, defaultMode, initCreate, initEdit, initView])
8. Chuyển Đổi Mode
8.1 View → Edit (Unposted)
const handleSwitchToEdit = () => {
if (!editingId) return
setField('isPosted', false)
initEdit(editingId)
}
8.2 View (Posted) → Bỏ ghi sổ → Edit
const handleUnpostAndEdit = async () => {
if (!editingId) return
setOperating(true)
const ok = await handleUnpost(editingId)
setOperating(false)
if (ok) {
setUnpostOpen(false)
setField('isPosted', false)
initEdit(editingId)
}
}
9. CSS Convention Tổng Hợp
| Element | Class |
|---|---|
| DialogContent | w-[100vw] max-w-[100vw] h-[100vh] max-h-[100vh] p-0 gap-0 rounded-none ... [&>button:first-child]:hidden |
| Header | flex items-center justify-between px-5 py-3.5 bg-white flex-shrink-0 border-b border-[#B7BCC3] |
| Body | flex-1 min-h-0 overflow-hidden bg-[#f4f5f7] flex flex-col |
| Info section wrapper | p-5 pb-0 flex-shrink-0 |
| Table section wrapper | p-5 pt-4 flex-1 min-h-0 flex flex-col |
| 2-cột layout | flex gap-6 |
| Cột trái | flex-1 space-y-3 |
| Cột phải | w-[250px] flex-shrink-0 space-y-3 |
| Grid 2 field | grid grid-cols-2 gap-4 |
| Field wrapper | space-y-1 |
| Label | text-[13px] font-semibold text-black |
| Label required | <span className='text-red-500'>*</span> |
| Input | h-[30px] text-[13px] rounded-lg border-[#B7BCC3] bg-white |
| Input error | border-destructive (khi touched + có error) |
| Input trong bảng | h-[26px] text-[13px] rounded-lg border border-[#B7BCC3] bg-white text-black focus-visible:ring-0 |
| Input số | text-right + type='text' inputMode='numeric' |
| Select trigger | h-8 text-[13px] rounded-lg border-[#B7BCC3] bg-white |
| Error message | <p className='text-xs text-destructive flex items-center gap-1'><AlertCircle className='h-3 w-3' />{...}</p> |
| Footer | flex items-center justify-between px-5 py-2.5 bg-white flex-shrink-0 border-t border-[#B7BCC3] |
| Button secondary | btn-secondary h-8 text-[13px] rounded-lg |
| Button primary | btn-primary h-8 text-[13px] rounded-lg |
| Button icon size | h-3.5 w-3.5 |
| Hạch toán card | bg-white rounded-lg shadow-sm overflow-hidden flex flex-col flex-1 min-h-0 |
| Hạch toán tab | border-b border-[#B7BCC3] px-3 flex-shrink-0 |
| Hạch toán tab button | py-2.5 px-4 text-base font-semibold text-primary border-b-2 border-primary mb-3 |
| Hạch toán scroll area | flex-1 min-h-0 overflow-y-auto |
| Summit row | bg-[#ECEDEF] border-t border-solid border-[#B7BCC3] |
10. Imports Cần Thiết
import { useEffect, useRef, useState, useMemo, useCallback } 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, AlertCircle, Pencil, BookOpen } from 'lucide-react'
import { DatePicker } 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'
import { EditableDataTable, type EditableColumnDef } from '@/modules/KetoanApp/components'
11. Checklist Tạo Dialog Full-Screen
- DialogContent có
maxWidth='none'+ full viewport classes - KHÔNG dùng
DialogHeader,DialogTitle,DialogFooter— custom div thay thế - Header:
flex-shrink-0 border-b, title + refType + Tổng tiền + nút X (unsaved guard) - Body:
flex-1 min-h-0 overflow-hidden bg-[#f4f5f7] flex flex-col— KHÔNG scroll body - Info section:
p-5 pb-0 flex-shrink-0— cố định trên cùng - Table section:
p-5 pt-4 flex-1 min-h-0 flex flex-col— fill hết chiều cao còn lại - 2-cột layout: trái
flex-1, phảiw-[250px] flex-shrink-0, gapgap-6 - Cột trái: fields theo yêu cầu (grid-cols-2 hoặc full-width)
- Cột phải: Ngày hạch toán + Ngày chứng từ + Số phiếu (tối thiểu) +
onBlur+*required + error message - Bảng dữ liệu bọc trong white card:
bg-white rounded-lg shadow-sm overflow-hidden flex flex-col flex-1 min-h-0 - Tab header:
flex-shrink-0— cố định không scroll - Table scroll area:
flex-1 min-h-0 overflow-y-auto— chỉ bảng scroll - Sum row:
bg-[#ECEDEF] border-t border-solid border-[#B7BCC3] - Footer:
py-2.5 flex-shrink-0 border-t, buttons theo mode - Nút X ẩn mặc định bằng
[&>button:first-child]:hidden - Loading state: spinner giữa body
- Buttons:
btn-secondary/btn-primary— KHÔNGvariant='outline' - Input:
h-[30px]form,h-[26px]trong bảng - Label:
text-[13px] font-semibold text-black - Error:
<p className='text-xs text-destructive...'><AlertCircle />...</p> - Confirm dialogs: unsaved guard, clear all lines, post/unpost
-
dirtyRef+pendingCloseRefcho unsaved changes tracking