tao-phieu-thu
Tạo hoặc chỉnh sửa trang Phiếu thu/Phiếu chi/Tiền gửi/Tiền vay trong KetoanApp. Extend từ tao-master-page, thêm rule: action trên dòng phụ thuộc trạng thái ghi sổ (isPosted), màu sắc phân biệt chưa/đã
SKILL.md.claude/skills/_archived-sasuco/tao-phieu-thu/SKILL.md
GitHubTạo Trang Phiếu Thu / Chi / Tiền Gửi — KetoanApp
Kế thừa:
tao-master-page(toàn bộ cấu trúc page),tao-dialog-full(dialog full-screen),tao-ui-giao-dien-new(foundation UI),filter-phan-trang(logic phân trang),dat-ten(quy tắc đặt tên).Áp dụng cho: TẤT CẢ các trang nghiệp vụ có trạng thái ghi sổ:
- Phiếu thu tiền mặt (
phieu-thu)- Phiếu chi tiền mặt (
phieu-chi)- Tiền gửi ngân hàng (
tien-gui)- Tiền vay (
tien-vay)- Và các trang tương tự có field
isPosted: boolean
⚠️ NGUYÊN TẮC SỐ 0: Giá Trị Rỗng → Để Trống, KHÔNG Dùng -
Đây là lỗi phổ biến nhất. TUYỆT ĐỐI CẤM dùng
'-'cho giá trị null/undefined/rỗng.
// ❌ SAI — hiển thị dấu gạch ngang gây rối mắt
refNo: (i) => i.refNo || '-'
description: (i) => i.description || '-'
postedDate: (i) => i.postedDate ? formatDate(i.postedDate) : '-'
// ✅ ĐÚNG — để trống, giao diện sạch
refNo: (i) => i.refNo || ''
description: (i) => i.description || ''
postedDate: (i) => i.postedDate ? formatDate(i.postedDate) : ''
Quy tắc cho MỌI field trong CELL_VALUE:
|| ''thay vì|| '-'? formatDate(x) : ''thay vì? formatDate(x) : '-'- Fallback render:
(CELL_VALUE[col.field] ?? (() => ''))(item)thay vì() => '-'
0. Quy Tắc Chung Kế Thừa Từ tao-master-page
Tải toàn bộ tao-master-page/SKILL.md làm nền tảng. Skill này CHỈ bổ sung các quy tắc đặc thù cho trang nghiệp vụ có trạng thái ghi sổ.
Những gì đã có từ tao-master-page (không lặp lại):
- Cấu trúc page:
DmPageHeader→ Card →DmSearchToolbar→ Header cố định → Body scrollable → Tổng cộng cố định →DmTablePagination→ Ghost scrollbar - Header & Tổng cộng cố định (BẮT BUỘC): Header và hàng tổng cộng nằm NGOÀI vùng scroll, mỗi phần trong một
<DmTable>riêng vớishrink-0 overflow-hidden. Chỉ body mới scroll. Scroll ngang được đồng bộ quaheaderScrollRef,totalScrollRef,handleTableScroll. Xem Section 0.0 bên dưới. - Ghost column (width=0, sticky right) cho cột thao tác
onDoubleClickmở View (xem chi tiết,setDialogMode('view'))- Click vào cột định danh (refNo/refNoManagement/invoiceNumber...) → mở View (BẮT BUỘC): Xem
tao-master-pageSection 2.1a.PhieuThuPage.tsxlà reference implementation chuẩn cho pattern này. - Dialog dùng chung cho cả Tạo & Sửa
- Map DTO → FormState thủ công (cấm
as unknown as) - Pattern
useTableLayout,useTableSettings,useBulkSelection,useExcelExport - Quy tắc hiển thị & format field: alignment theo loại dữ liệu,
formatCurrencyvsformatNumber,truncate, độ rộng cột → xemtao-ui-giao-dien-newSection 12 ALL_COLUMNSmặc địnhpinned: null— KHÔNG setpinned: 'right'hay'left'làm mặc định. Chỉ user chủ động ghim quaTableSettingsPanelmới tạo pin icon.ALL_COLUMNS— HIỂN THỊ ĐẦY ĐỦ, KHÔNG TỰ Ý BỚT CỘT (BẮT BUỘC): Khi đã khai báo các cột trongALL_COLUMNS, TẤT CẢ các cột đó PHẢI có mặt trong bảng hiển thị. TUYỆT ĐỐI CẤM tự ý lược bỏ, ẩn, hoặc không include bất kỳ cột nào đã được chỉ định. Việc ẩn/hiện cột chỉ được thực hiện quaTableSettingsPanelbởi người dùng, không phải do developer tự quyết định.- Dialog footer action: tuân thủ quy tắc bên dưới (Section 3.4)
DmTableCellmặc địnhtruncate— tự động cắt text dài bằng ellipsis. Không cần thêmtruncatekhi dùngDmTableCell. Nội dung sẽ không tràn sang cột khác nhờtableLayout: fixed+overflow: hidden.- Ghost column cần
overflow: visible— cột action sticky right phải cóstyle={{ overflow: 'visible' }}để button nổi không bị cắt. - Resize cột: kéo cạnh phải header để thay đổi độ rộng, lưu vào localStorage. Dùng
useColumnResizehook +getColumnWidthcho cả 3 bảng. Xem Section 13. - BulkActionBar: Khi có ít nhất 1 dòng được chọn, hiển thị
BulkActionBarthay choDmSearchToolbar. ImportBulkActionBartừ@/shared/components/common. Xem Section 0.0.3 bên dưới.
0.0 Layout Ba Phần: Header Cố Định + Body Scroll + Tổng Cộng Cố Định (BẮT BUỘC)
Header và hàng Tổng cộng PHẢI luôn hiển thị, không bị trôi khi scroll dọc.
Bảng được chia thành 3 <DmTable> riêng biệt:
- Header —
<div ref={headerScrollRef} className='shrink-0 overflow-hidden'> - Body —
<div ref={tableRef} className='flex-1 min-h-0 overflow-auto scrollbar-hidden'> - Tổng cộng —
<div ref={totalScrollRef} className='shrink-0 overflow-hidden'>
Scroll ngang được đồng bộ qua handleTableScroll.
0.0.1 Ref & Scroll Sync
import React, { useState, useMemo, useRef, useCallback } from 'react'
// ── Ref cho header & tổng cộng (để đồng bộ scroll ngang với bảng chính) ──
const headerScrollRef = useRef<HTMLDivElement>(null)
const totalScrollRef = useRef<HTMLDivElement>(null)
// ── Wrap onTableScroll để đồng bộ scroll ngang cho header & hàng tổng cộng ──
const handleTableScroll = useCallback(() => {
onTableScroll()
const sl = tableRef.current?.scrollLeft ?? 0
if (headerScrollRef.current) headerScrollRef.current.scrollLeft = sl
if (totalScrollRef.current) totalScrollRef.current.scrollLeft = sl
}, [onTableScroll])
tableReftừuseTableLayout().onTableScrolllà callback gốc của hook.
0.0.2 Layout JSX
<div className='h-full flex flex-col bg-white rounded-xl overflow-hidden'>
{/* Search toolbar */}
<DmSearchToolbar ... />
{/* ── 1. Header — cố định trên ── */}
<div ref={headerScrollRef} className='shrink-0 overflow-hidden'>
<DmTable style={{ minWidth: minTableWidth }}>
<DmTableHeader>
<DmTableHeaderRow>
<DmTableHead ... /> {/* checkbox */}
{colsForRender.map(col => <DmTableHead ... />)}
<DmTableHead ... /> {/* ghost */}
</DmTableHeaderRow>
</DmTableHeader>
</DmTable>
</div>
{/* ── 2. Body — scrollable ── */}
<div ref={tableRef} className='flex-1 min-h-0 overflow-auto scrollbar-hidden' onScroll={handleTableScroll}>
<DmTable style={{ minWidth: minTableWidth }}>
<DmTableBody>
{/* CHỈ chứa data rows, KHÔNG header, KHÔNG total */}
</DmTableBody>
</DmTable>
</div>
{/* ── 3. Tổng cộng — cố định dưới ── */}
{list.items.length > 0 && (
<div ref={totalScrollRef} className='shrink-0 overflow-hidden'>
<DmTable style={{ minWidth: minTableWidth }}>
<DmTableBody>
<DmTableRow className='bg-[#ECEDEF] border-t-2 border-[#ced1d6] font-semibold'>
...
</DmTableRow>
</DmTableBody>
</DmTable>
</div>
)}
{/* Pagination */}
<DmTablePagination ... />
{/* Ghost scrollbar */}
<div ref={ghostRef} ... />
</div>
⚠️ Mỗi
<DmTable>phải có cùngminWidth: minTableWidthđể các cột khớp nhau theo chiều ngang.
0.0.3 Conditional: BulkActionBar vs DmSearchToolbar
Khi có ít nhất 1 dòng được chọn → hiển thị BulkActionBar; ngược lại → hiển thị DmSearchToolbar:
{/* Search Toolbar HOẶC BulkActionBar (khi có dòng được chọn) */}
{selection.selectedCount > 0 ? (
<BulkActionBar
selectedCount={selection.selectedCount}
onClear={selection.clear}
onDelete={() => setBulkDeleteOpen(true)}
/>
) : (
<DmSearchToolbar
searchPlaceholder='Tìm theo số chứng từ, diễn giải...'
searchValue={searchText}
onSearchChange={setSearchText}
onRefresh={doSearch}
rightSection={
<div className='flex items-center gap-2'>
<TableSettingsPanel ... />
<ExportButton type='excel' onClick={exportExcel} />
<Button className='btn-primary h-8 text-[13px] rounded-lg' data-qa='btn_them' onClick={() => { setSelectedId(null); setDialogMode('create'); setDialogOpen(true) }}>
<Plus className='h-3.5 w-3.5 mr-1' />Thêm
</Button>
</div>
}
/>
)}
Import:
import { BulkActionBar } from '@/shared/components/common'
⚠️ NGUYÊN TẮC 0.1: Dialog PHẢI Là Full-Screen — Dùng tao-dialog-full
Áp dụng cho TẤT CẢ dialog của phiếu thu, phiếu chi, tiền gửi, tiền vay. Không dùng dialog popup giới hạn (
max-w-4xl,max-h-[90vh]).Đọc toàn bộ
tao-dialog-full/SKILL.mdđể biết chi tiết layout, CSS, component. Skill này CHỈ bổ sung các quy tắc đặc thù cho dialog nghiệp vụ có ghi sổ.
0.1.1 Điểm Khác Biệt So Với tao-dialog-full Cơ Bản
| Thành phần | tao-dialog-full cơ bản |
Phiếu thu/chi (có ghi sổ) |
|---|---|---|
| Footer Create | [Hủy] [Lưu] [Lưu & Thêm] |
[Hủy] [Lưu] [Lưu & Ghi sổ] |
| Footer View | Không có | [Sửa] [Ghi sổ] hoặc [Bỏ ghi và sửa] |
| Footer Edit | [Hủy] [Lưu] |
[Hủy] [Lưu] (giống) |
| Hook method | handleSubmitAndNew |
handleCreateAndPost (Lưu & Ghi sổ) |
isPosted field |
Không bắt buộc | BẮT BUỘC có |
0.1.2 Footer Đặc Thù Cho Phiếu Thu/Chi
{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)}>
<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)}>
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={() => 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_ghi_so' disabled={submitting} onClick={handleCreateAndPost}>
{submitting && <Loader2 className='animate-spin h-3.5 w-3.5 mr-1' />}Lưu & Ghi sổ
</Button>
</>
)}
0.1.3 Post/Unpost Handlers
// View (unposted) → Edit
const handleSwitchToEdit = () => {
if (!editingId) return
setField('isPosted', false)
initEdit(editingId)
}
// 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)
}
}
0.1.4 Confirm Post/Unpost Dialogs
<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} />
0.1.5 Confirm Đóng Khi Có Thay Đổi Chưa Lưu (BẮT BUỘC)
Nguyên tắc: Khi mở form tạo mới, đóng trực tiếp không cần confirm. Khi mở form sửa và có thay đổi thực sự từ user, mới hiển thị popup xác nhận.
| Mode | Có dirty? | Hành vi khi bấm [Hủy] hoặc nút X |
|---|---|---|
| Create | Bất kể | Đóng trực tiếp, không confirm |
| Edit | dirtyRef.current === true |
Mở popup "Bạn có thay đổi chưa lưu" |
| Edit | dirtyRef.current === false |
Đóng trực tiếp |
| View | — | Đóng trực tiếp |
Lý do: Khi tạo mới, form auto-fill các giá trị mặc định (số phiếu, ngày, TK Nợ/Có) từ initCreate → dirtyRef bị set true ngay lập tức dù user chưa làm gì. Nếu hiển thị confirm, user sẽ thấy popup không cần thiết.
0.1.5.1 Code nút X (Header) & nút Hủy (Footer)
// ── Nút X trong Header ───────────────────────────────────────────
<Button
variant='ghost' size='sm'
className='h-7 w-7 p-0 text-black hover:text-black hover:bg-gray-100'
onClick={() => {
// Create / View → đóng luôn, không confirm
if (mode === 'create' || mode === 'view') {
onOpenChange(false)
return
}
// Edit → chỉ confirm nếu có thay đổi thực sự
if (dirtyRef.current) {
setUnsavedOpen(true)
pendingCloseRef.current = () => onOpenChange(false)
} else {
onOpenChange(false)
}
}}
>
<X className='h-4 w-4' />
</Button>
// ── Nút [Hủy] trong Footer ────────────────────────────────────────
const handleCancel = () => {
// Create → đóng luôn, không confirm
if (mode === 'create') {
onOpenChange(false)
return
}
// Edit → chỉ confirm nếu có thay đổi thực sự
if (dirtyRef.current) {
setUnsavedOpen(true)
pendingCloseRef.current = () => onOpenChange(false)
} else {
onOpenChange(false)
}
}
// Dùng trong footer:
<Button onClick={handleCancel}>Hủy</Button>
0.1.5.2 Footer Create — Không check dirtyRef ở nút Hủy
{/* Create mode footer */}
<>
<Button className='btn-secondary h-8 text-[13px] rounded-lg' data-qa='btn_huy' disabled={submitting}
onClick={() => 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_ghi_so' disabled={submitting} onClick={handleCreateAndPost}>
{submitting && <Loader2 className='animate-spin h-3.5 w-3.5 mr-1' />}Lưu & Ghi sổ
</Button>
</>
0.1.5.3 Footer Edit — Chỉ check dirtyRef ở nút Hủy
{/* Edit mode footer */}
<>
<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>
</>
0.1.5.4 ConfirmDialog "Thay đổi chưa lưu"
<ConfirmDialog
open={unsavedOpen}
onOpenChange={setUnsavedOpen}
title='Thay đổi chưa lưu'
message='Bạn có thay đổi chưa lưu. Bạn có chắc chắn muốn thoát?'
confirmLabel='Thoát'
variant='destructive'
onConfirm={() => {
setUnsavedOpen(false)
dirtyRef.current = false
// pendingCloseRef đã được set trước khi mở dialog
if (pendingCloseRef.current) {
pendingCloseRef.current()
pendingCloseRef.current = null
}
}}
/>
1. Quy Tắc Màu Sắc Dòng Dữ Liệu
1.1 Màu theo trạng thái ghi sổ
| Trạng thái | Màu chữ toàn hàng | Class |
|---|---|---|
Đã ghi sổ (isPosted: true) |
Đen (text-black) |
text-black |
Chưa ghi sổ (isPosted: false) |
Amber (text-amber-600) |
text-amber-600 |
1.2 Cột số chứng từ (refNo) — Luôn màu primary
Bất kể trạng thái ghi sổ, cột refNo luôn có màu xanh dương (primary):
refNo: 'font-medium text-blue-700'
1.3 Hàm getCellClass chuẩn
/** Trả về class cho cell dựa trên field & trạng thái ghi sổ */
const getCellClass = (field: string, item: XxxListItem): string => {
// Số chứng từ luôn xanh primary
if (field === 'refNo') return 'font-medium text-blue-700'
// Chưa ghi sổ → màu amber toàn bộ các cột còn lại
if (!item.isPosted) return 'text-amber-600'
// Đã ghi sổ → dùng class mặc định từ CELL_CLASS, fallback về đen
return (CELL_CLASS[field] ? `${CELL_CLASS[field]}` : 'text-black')
}
1.4 Cột trạng thái (isPosted) — Badge
Cột isPosted hiển thị dạng badge (pill), không phải text thuần:
{col.field === 'isPosted' ? (
<span className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
item.isPosted
? 'bg-green-50 text-green-700 border border-green-200'
: 'bg-amber-50 text-amber-700 border border-amber-200',
)}>
{item.isPosted ? 'Đã ghi sổ' : 'Chưa ghi sổ'}
</span>
) : (
(CELL_VALUE[col.field] ?? (() => '-'))(item)
)}
1.5 Cột "Số tiền" (totalAmount) — Canh phải (BẮT BUỘC)
Cả header và value của cột totalAmount đều canh phải (text-right):
// Trong DmTableHead:
className={cn(
...,
col.field === 'totalAmount' && 'text-right',
)}
// Trong DmTableCell:
className={cn(
getCellClass(col.field, item),
...,
col.field === 'totalAmount' && 'text-right',
)}
Áp dụng cho TẤT CẢ các cột có tiêu đề "Số tiền" / "Tổng tiền" / "Số dư" — bất kỳ cột nào hiển thị giá trị tiền tệ.
1.6 Cột Định Danh (refNo) — Link Mở Xem Chi Tiết (BẮT BUỘC)
Cột định danh (số chứng từ, số đơn hàng, mã...) là cột đầu tiên có nội dung đại diện cho bản ghi. Cột này PHẢI hiển thị dạng link: chữ xanh, hover gạch chân, click mở dialog View.
1.6.1 Quy tắc hiển thị
| Thuộc tính | Giá trị |
|---|---|
| Màu chữ | text-blue-700 (primary) |
| Font weight | font-medium |
| Con trỏ | cursor-pointer |
| Hover | hover:underline |
| Click | e.stopPropagation() → setSelectedId(item.id); setDialogMode('view'); setDialogOpen(true) |
1.6.2 getCellClass — thêm cursor-pointer hover:underline
const getCellClass = (field: string, item: XxxListItem): string => {
// Cột định danh (số chứng từ) luôn xanh + link style
if (field === 'refNo') return 'font-medium text-blue-700 cursor-pointer hover:underline'
// Chưa ghi sổ → màu amber
if (!item.isPosted) return 'text-amber-600'
// Đã ghi sổ → dùng class mặc định
return (CELL_CLASS[field] ? `${CELL_CLASS[field]}` : 'text-black')
}
1.6.3 Render cell — <span> clickable cho cột định danh
Trong colsForRender.map(), thêm điều kiện cho cột định danh trước các điều kiện khác:
{col.field === 'isPosted' ? (
<span className={cn(...)}>...</span> // Badge trạng thái
) : col.field === 'refNo' ? (
<span
className='cursor-pointer hover:underline'
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
setSelectedId(item.id)
setDialogMode('view')
setDialogOpen(true)
}}
data-qa={`link_refno_${item.id}`}
>
{item.refNo || ''}
</span>
) : (
(CELL_VALUE[col.field] ?? (() => ''))(item)
)}
1.6.4 Cột định danh cho từng loại chứng từ
| Loại trang | Field định danh | Tên hiển thị |
|---|---|---|
| Phiếu thu | refNo |
Số phiếu thu |
| Phiếu chi | refNo |
Số phiếu chi |
| Tiền gửi | refNo |
Số tiền gửi |
| Tiền vay | refNo |
Số tiền vay |
| Đơn hàng | orderNo |
Số đơn hàng |
| Danh mục (CRUD) | code |
Mã |
Nguyên tắc chung: Cột đầu tiên chứa mã/số đại diện cho bản ghi (không phải ngày tháng, không phải tên/diễn giải) → render dạng link xanh, click mở View.
2. Quy Tắc Hàng Tổng Cộng Cuối Bảng (BẮT BUỘC)
Mọi bảng phiếu thu/chi/tiền gửi PHẢI có hàng tổng cộng ở cuối bảng.
2.1 Cấu trúc hàng tổng cộng
| Vị trí | Nội dung |
|---|---|
| Cột checkbox | Để trống |
| Cột đầu tiên (idx=0) | Chữ "Tổng cộng" in đậm |
| Các cột text/không phải số | Để trống |
Cột totalAmount |
formatNumber(totalSum) — canh phải, in đậm |
| Các cột số khác (nếu có) | Tính tổng tương ứng |
| Cột ghost action | Để trống |
2.2 Code mẫu — Hàng tổng cộng tách riêng, luôn hiển thị
Hàng tổng cộng PHẢI nằm NGOÀI vùng scroll, trong một
<DmTable>riêng, để luôn hiển thị khi bảng có scrollbar dọc. Không đặt trongDmTableBodycủa bảng chính.
2.2.1 Ref & scroll sync
// ── Ref cho hàng tổng cộng (để đồng bộ scroll ngang với bảng chính) ──
const totalScrollRef = useRef<HTMLDivElement>(null)
// ── Wrap onTableScroll để đồng bộ scroll ngang cho hàng tổng cộng ──
const handleTableScroll = useCallback(() => {
onTableScroll()
if (totalScrollRef.current && tableRef.current) {
totalScrollRef.current.scrollLeft = tableRef.current.scrollLeft
}
}, [onTableScroll])
Dùng
handleTableScrollthay choonTableScrolltrongonScrollcủa div bảng chính.
2.2.2 Tính tổng
const totalSum = useMemo(() => list.items.reduce((s, i) => s + i.totalAmount, 0), [list.items])
2.2.3 Layout — hàng tổng cộng nằm NGOÀI vùng scroll
{/* Vùng bảng chính (scrollable) */}
<div ref={tableRef} className='flex-1 min-h-0 overflow-auto scrollbar-hidden' onScroll={handleTableScroll}>
<DmTable style={{ minWidth: minTableWidth }}>
<DmTableHeader>...</DmTableHeader>
<DmTableBody>
{/* CHỈ chứa data rows, KHÔNG có total row */}
</DmTableBody>
</DmTable>
</div>
{/* ── Hàng tổng cộng — luôn hiển thị, nằm NGOÀI vùng scroll ── */}
{list.items.length > 0 && (
<div ref={totalScrollRef} className='shrink-0 overflow-hidden'>
<DmTable style={{ minWidth: minTableWidth }}>
<DmTableBody>
<DmTableRow className='bg-[#ECEDEF] border-t-2 border-[#ced1d6] font-semibold'>
{/* Checkbox rỗng */}
<DmTableCell
className='text-center sticky left-0 z-20 bg-[#ECEDEF]'
style={{ width: 40, minWidth: 40 }}
/>
{colsForRender.map((col, idx) => (
<DmTableCell
key={col.id}
style={{
width: col.width,
minWidth: col.width,
...(col.stickyLeft !== undefined ? { left: col.stickyLeft } : {}),
...(col.stickyRight !== undefined ? { right: col.stickyRight } : {}),
}}
className={cn(
'text-black',
col.stickyLeft !== undefined && 'sticky z-20 bg-[#ECEDEF]',
col.stickyRight !== undefined && 'sticky z-20 bg-[#ECEDEF]',
col.pinned === 'left' && 'shadow-[2px_0_4px_-2px_rgba(0,0,0,0.06)]',
col.pinned === 'right' && 'shadow-[-2px_0_4px_-2px_rgba(0,0,0,0.06)]',
col.field === 'totalAmount' && 'text-right',
)}
>
{idx === 0 ? 'Tổng cộng' : col.field === 'totalAmount' ? formatNumber(totalSum) : ''}
</DmTableCell>
))}
{/* Ghost rỗng */}
<DmTableCell className='sticky right-0 z-20 bg-[#ECEDEF]'
style={{ width: 0, minWidth: 0, padding: 0, border: 'none' }} />
</DmTableRow>
</DmTableBody>
</DmTable>
</div>
)}
2.3 Quy tắc hiển thị
- Vị trí: Nằm NGOÀI vùng scroll của bảng chính, trong một
<DmTable>riêng. Luôn hiển thị, không bị cuốn theo scroll dọc. totalScrollRef: Ref trên wrapperdivcủa hàng tổng cộng. Scroll ngang được đồng bộ với bảng chính quahandleTableScroll.- Wrapper class:
shrink-0 overflow-hidden— không co giãn, ẩn scrollbar dư thừa. - Background:
bg-[#ECEDEF]— cùng màu với header - Border-top:
border-t-2 border-[#ced1d6]— đường kẻ đậm phân tách với dữ liệu - Font:
font-semibold— in đậm toàn hàng - Chữ "Tổng cộng": nằm ở cột dữ liệu đầu tiên (
idx === 0), canh trái - Số tổng: format qua
formatNumber(), canh phải - Chỉ hiển thị khi có dữ liệu:
list.items.length > 0 - Sticky columns: tổng cộng row cũng phải sticky cùng vị trí với data rows
2.1 Ma trận action theo trạng thái
Quy tắc quan trọng: Khi đã ghi sổ → KHÔNG thể sửa trực tiếp. Phải "Bỏ ghi và sửa" (gọi API unpost → mở dialog edit).
| Action | Chưa ghi sổ | Đã ghi sổ | Vị trí |
|---|---|---|---|
| Xem (View) | ✅ | ✅ | Button riêng |
| Xóa (Delete) | ✅ | ❌ | Button riêng |
| Bỏ ghi và sửa (Unpost & Edit) | ❌ | ✅ | Button riêng |
| Ghi sổ (Post) | ✅ (trong dropdown) | ❌ | Dropdown "Chức năng khác" |
| Nhân bản (Clone) | ✅ (trong dropdown) | ✅ (trong dropdown) | Dropdown "Chức năng khác" |
2.2 Pattern Ghost Column — Button + DropdownMenu (Pattern B)
Dùng Pattern B từ
update-actionvới điều kiệnisPosted.Nguyên tắc: Đã ghi sổ → KHÔNG có nút Sửa. Thay vào đó là nút "Bỏ ghi và sửa" (gọi unpost API → mở edit dialog).
{/* Cột neo sticky phải — width=0, action hiện khi hover row */}
<DmTableCell className='sticky right-0 z-20 bg-transparent'
style={{ width: 0, minWidth: 0, padding: 0, border: 'none', overflow: 'visible' }}>
<div className='absolute right-0 top-0 bottom-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity duration-150 bg-gradient-to-l from-white via-white/90 to-transparent pl-16 pr-2'>
{/* [1] Xem — luôn có */}
<Button variant='ghost' size='sm'
className='border rounded-lg bg-white'
title='Xem' data-qa={`btn_xem_${item.id}`}
onClick={(e) => { e.stopPropagation(); setSelectedId(item.id); setDialogMode('view'); setDialogOpen(true) }}>
<Eye className='h-4 w-4' />
</Button>
{/* [2] Xóa — CHỈ khi chưa ghi sổ */}
{!item.isPosted && (
<Button variant='ghost' size='sm'
className='icon-danger border rounded-lg bg-white'
title='Xóa' data-qa={`btn_xoa_${item.id}`}
onClick={(e) => { e.stopPropagation(); setDeleteItem(item); setDeleteOpen(true) }}>
<Trash2 className='h-4 w-4' />
</Button>
)}
{/* [3] Bỏ ghi và sửa — CHỈ khi đã ghi sổ (gọi thẳng API unpost → mở edit dialog, KHÔNG popup xác nhận) */}
{item.isPosted && (
<Button variant='ghost' size='sm'
className='icon-warning border rounded-lg bg-white'
title='Bỏ ghi và sửa' data-qa={`btn_bo_ghi_va_sua_${item.id}`}
onClick={(e) => { e.stopPropagation(); handleUnpostAndEdit(item) }}>
<Pencil className='h-4 w-4' />
</Button>
)}
{/* [4] DropdownMenu — Chức năng khác */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='ghost' size='sm'
className='border rounded-lg bg-white'
title='Chức năng khác' data-qa={`btn_khac_${item.id}`}>
<MoreHorizontal className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-44'>
{!item.isPosted ? (
<>
{/* Chưa ghi sổ: Ghi sổ + Nhân bản */}
<DropdownMenuItem onSelect={() => { setPostItem(item); setPostOpen(true) }}
data-qa={`btn_ghi_so_${item.id}`}>
<BookOpen className='h-4 w-4' /> Ghi sổ
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => { setSelectedId(null); setDialogMode('create'); setDialogOpen(true) }}
data-qa={`btn_nhan_ban_${item.id}`}>
<Copy className='h-4 w-4' /> Nhân bản
</DropdownMenuItem>
</>
) : (
<>
{/* Đã ghi sổ: Chỉ còn Nhân bản (KHÔNG có Bỏ ghi sổ riêng — đã có nút "Bỏ ghi và sửa" bên ngoài) */}
<DropdownMenuItem onSelect={() => { setSelectedId(null); setDialogMode('create'); setDialogOpen(true) }}
data-qa={`btn_nhan_ban_${item.id}`}>
<Copy className='h-4 w-4' /> Nhân bản
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</DmTableCell>
2.3 Imports cần thiết cho Pattern
import { Eye, Pencil, Trash2, MoreHorizontal, Copy, BookOpen } from 'lucide-react'
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem,
} from '@/shared/components/ui/dropdown-menu'
⚠️ KHÔNG import
DmRowActionshoặcuseDmRowActions— Pattern này dùng Button thủ công hoàn toàn.
3. State & Confirm Handlers
3.1 State cần có
const [dialogOpen, setDialogOpen] = useState(false)
const [dialogMode, setDialogMode] = useState<'view' | 'edit' | 'create'>('create')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [deleteItem, setDeleteItem] = useState<XxxListItem | null>(null)
const [deleteOpen, setDeleteOpen] = useState(false)
const [postItem, setPostItem] = useState<XxxListItem | null>(null)
const [postOpen, setPostOpen] = useState(false)
const [unpostItem, setUnpostItem] = useState<XxxListItem | null>(null)
const [unpostOpen, setUnpostOpen] = useState(false)
const [operating, setOperating] = useState(false)
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
3.2 Confirm handlers
const formHook = useXxxDlgForm({
onSuccess: list.refetch,
onClose: () => setDialogOpen(false),
})
const handleDeleteConfirm = async () => {
if (!deleteItem) return
setOperating(true)
try {
await formHook.handleDelete(deleteItem.id)
setDeleteOpen(false)
list.refetch()
} finally { setOperating(false) }
}
const handlePostConfirm = async () => {
if (!postItem) return
setOperating(true)
try {
await formHook.handlePost(postItem.id)
setPostOpen(false)
list.refetch()
} finally { setOperating(false) }
}
const handleUnpostConfirm = async () => {
if (!unpostItem) return
setOperating(true)
try {
await formHook.handleUnpost(unpostItem.id)
setUnpostOpen(false)
list.refetch()
} finally { setOperating(false) }
}
/** Gọi thẳng API bỏ ghi sổ rồi mở form sửa (không popup xác nhận) */
const handleUnpostAndEdit = async (item: XxxListItem) => {
setOperating(true)
try {
await formHook.handleUnpost(item.id)
// Sau khi bỏ ghi sổ thành công → mở dialog edit luôn
setDialogMode('edit')
setSelectedId(item.id)
setDialogOpen(true)
} finally { setOperating(false) }
}
3.3 Các ConfirmDialog cần có
{/* Confirm xóa */}
<ConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title='Xóa phiếu thu'
message={`Bạn có chắc chắn muốn xóa phiếu thu ${deleteItem?.refNo ?? ''}? Thao tác này không thể hoàn tác.`}
onConfirm={handleDeleteConfirm}
variant='destructive'
loading={operating}
/>
{/* Confirm ghi sổ */}
<ConfirmDialog
open={postOpen}
onOpenChange={setPostOpen}
title='Ghi sổ phiếu thu'
message={`Xác nhận ghi sổ phiếu thu ${postItem?.refNo ?? ''}?`}
onConfirm={handlePostConfirm}
loading={operating}
/>
{/* Confirm bỏ ghi sổ (nếu page có nút bỏ ghi sổ riêng trong dropdown) */}
<ConfirmDialog
open={unpostOpen}
onOpenChange={setUnpostOpen}
title='Bỏ ghi sổ phiếu thu'
message={`Xác nhận bỏ ghi sổ phiếu thu ${unpostItem?.refNo ?? ''}? Bút toán đã sinh sẽ bị gỡ.`}
onConfirm={handleUnpostConfirm}
variant='destructive'
loading={operating}
/>
{/* Confirm xóa hàng loạt */}
<ConfirmDialog
open={bulkDeleteOpen}
onOpenChange={setBulkDeleteOpen}
title='Xóa hàng loạt'
message={`Bạn có chắc chắn muốn xóa ${selection.selectedCount} phiếu thu đã chọn?`}
onConfirm={handleBulkDeleteConfirm}
variant='destructive'
loading={operating}
/>
3.4 Dialog Footer Actions (BẮT BUỘC)
Action trong footer dialog PHỤ THUỘC vào
modevàisPosted.⚠️ QUY TẮC QUAN TRỌNG:
handleSubmit(nút "Lưu") CHỈ gọi API tạo/cập nhật, KHÔNG gọi API ghi sổ. ChỉhandleCreateAndPost(nút "Lưu & Ghi sổ") mới gọi đồng thời cả tạo + ghi sổ. TUYỆT ĐỐI CẤM auto-post tronghandleSubmithoặchandleSubmitAndNew.
| 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 & Ghi sổ] |
| Edit | — | [Hủy] [Lưu] |
{isReadOnly ? (
<>
{!formData.isPosted && (
<>
<Button onClick={handleSwitchToEdit}><Pencil /> Sửa</Button>
<Button onClick={() => setPostOpen(true)}><BookOpen /> Ghi sổ</Button>
</>
)}
{formData.isPosted && (
<Button
className='btn-primary'
disabled={operating}
onClick={() => setUnpostOpen(true)}
>
{operating && <Loader2 />} Bỏ ghi và sửa
</Button>
)}
</>
) : mode === 'edit' ? (
<>
<Button onClick={handleCancel}>Hủy</Button>
<Button onClick={handleSubmit}>Lưu</Button>
</>
) : (
<>
<Button onClick={handleCancel}>Hủy</Button>
<Button onClick={handleSubmit}>Lưu</Button>
<Button onClick={handleCreateAndPost}>Lưu & Ghi sổ</Button>
</>
)}
Giải thích:
- View unposted:
[Sửa]để chuyển sang edit mode (không cần bỏ ghi vì chưa ghi sổ).[Ghi sổ]để post rồi đóng dialog. - View posted:
[Bỏ ghi và sửa]— gọihandleUnpost()→ nếu OK →initEdit()để chuyển sang edit mode. Lý do: bản ghi đã ghi sổ không thể update trực tiếp, phải bỏ ghi trước. - Create
[Lưu]: GọihandleSubmit→ CHỈ gọi API create, KHÔNG gọi API post. Bản ghi được tạo với trạng thái chưa ghi sổ. - Create
[Lưu & Ghi sổ]: GọihandleCreateAndPost→ create() → post() (tạo mới rồi ghi sổ ngay). - Edit
[Lưu]: GọihandleSubmit→ CHỈ gọi API update, KHÔNG gọi API post. - Edit
[Hủy]: Đóng dialog (có xác nhận nếu unsaved).
⚠️ TUYỆT ĐỐI CẤM:
handleSubmit(nút "Lưu") không được tự động gọipost()sau khi create/update. Nếu user muốn ghi sổ, họ phải bấm nút "Lưu & Ghi sổ" (create) hoặc "Ghi sổ" (view).
Hook bắt buộc phải có:
const { handleSubmit, handleCreateAndPost, handlePost, handleUnpost, initEdit } = useXxxDlgForm(...)
Trong đó handleCreateAndPost = create() → post() (gọi liên tiếp, chỉ thành công khi cả 2 OK).
3.5 TableSearchCombobox Trong Bảng Hạch Toán
KHÔNG dùng
compactprop — để input height tự nhiênh-8 rounded-[8px]giống các input khác.
Tất cả TableSearchCombobox nằm trong bảng hạch toán (EditableDataTable) của dialog KHÔNG có compact prop:
// ✅ ĐÚNG — không compact, input height tự nhiên
<TableSearchCombobox
value={String(value ?? '')}
initialLabel={String(value ?? '')}
displayField='accountNumber'
onChange={(id) => updateLine(idx, { debitAccountId: id } as Partial<PTAccountingLineFormState>)}
loadOptions={loadTaiKhoanTable}
columns={taiKhoanColumns}
dataQa={`sel_tk_no_${idx}`}
disabled={isReadOnly}
/>
// ❌ SAI — compact làm input quá thấp, khó thao tác
<TableSearchCombobox compact ... />
Áp dụng cho các cột sau trong bảng hạch toán:
- TK Nợ (
debitAccountId) - TK Có (
creditAccountId) - Đối tượng (
accountObjectCode)
3.5.1 Input Trong Bảng Data — Bắt Buộc Có px-2 (BẮT BUỘC)
Tất cả
<Input>nằm trongEditableDataTablePHẢI có padding ngang (px-2).
// ❌ SAI — p-0 làm text dính sát viền, khó đọc
<Input
type='text' inputMode='numeric'
value={String(value ?? '0')}
onChange={e => updateLine(idx, { quantity: e.target.value } as any)}
readOnly={isReadOnly}
className='h-[26px] p-0 text-[13px] text-right rounded-lg border border-[#B7BCC3] bg-white text-black focus-visible:ring-0'
/>
// ✅ ĐÚNG — px-2 tạo khoảng cách giữa text và viền
<Input
type='text' inputMode='numeric'
value={String(value ?? '0')}
onChange={e => updateLine(idx, { quantity: e.target.value } as any)}
readOnly={isReadOnly}
className='h-[26px] px-2 text-[13px] text-right rounded-lg border border-[#B7BCC3] bg-white text-black focus-visible:ring-0'
/>
Áp dụng cho TẤT CẢ các <Input> trong EditableDataTable:
- Số lượng —
text-right - Đơn giá —
text-right - Thành tiền (nếu là input) —
text-right - Tỉ lệ CK (%) —
text-right - Tiền chiết khấu —
text-right - Tên hàng / Diễn giải — text bình thường (không
text-right) - Lệnh SX — text bình thường
- Ghi chú — text bình thường
3.5.2 Bảng Data Không Bị Co Cột Khi Thu Nhỏ (BẮT BUỘC)
EditableDataTablePHẢI dùngtable-fixed+style={{ width }}trên<td>để cột giữ nguyên kích thước, không bị co khi container thu nhỏ.
Đã fix trong component dùng chung EditableDataTable.tsx:
// ✅ ĐÚNG — table-fixed giữ nguyên width cột, scroll ngang khi container hẹp
<table className='w-full table-fixed text-[13px]'>
// <td> khớp width với <th>
<td style={col.width ? { width: col.width } : undefined}>
Kết quả:
- Các cột giữ nguyên
widthđã khai báo trongEditableColumnDef TableSearchComboboxkhông bị shrink- Khi container thu nhỏ → scroll ngang (
overflow-x-autocó sẵn)
Quy tắc:
p-0→px-2. Giữ nguyên các class khác (h-[26px],text-[13px],rounded-lg,border-[#B7BCC3], ...).
3.6 Payload POST/PUT — Dùng ID Cho Tài Khoản Nợ/Có (BẮT BUỘC)
KHÔNG truyền
debitAccount/creditAccount(số tài khoản) lên API.
DùngdebitAccountId/creditAccountId(UUID của tài khoản).
API expectation:
- POST/PUT payload:
debitAccountId,creditAccountId(ID, không phải số) - GET response:
debitAccountId,debitAccountNumber,debitAccountName,creditAccountId,creditAccountNumber,creditAccountName
TableSearchCombobox (TK Nợ / TK Có): loadOptions trả về value: tk.id, cells.accountNumber = tk.accountNumber. Combobox dùng value={row.debitAccountId}, initialLabel={row.debitAccountNumber}, displayField='accountNumber'.
Payload gửi lên: debitAccountId: line.debitAccountId || null, creditAccountId: line.creditAccountId || null.
GET → FormState mapping: debitAccountNumber: line.debitAccountNumber ?? line.debitAccount ?? '' (fallback debitAccount nếu BE cũ chưa có debitAccountNumber).
3.6.0 Kiểm Tra Interface — debitAccountId & creditAccountId Bắt Buộc Có (BẮT BUỘC)
Trước khi tạo payload, PHẢI kiểm tra interface request DTO đã có
debitAccountIdvàcreditAccountIdchưa.
B1: Kiểm tra interface request DTO (CreateXxxRequest / UpdateXxxRequest)
B2: Có field debitAccountId và creditAccountId?
├── CÓ → Tiếp tục, gửi payload với debitAccountId / creditAccountId
└── KHÔNG → DỪNG LẠI, báo user: "Interface {TenDTO} chưa có debitAccountId/creditAccountId.
Vui lòng yêu cầu BE bổ sung các field này vào request DTO."
Format báo cáo khi interface thiếu:
| # | Field | API Endpoint | Method | Vị trí | DTO hiện tại | Hành động |
|---|---|---|---|---|---|---|
| 1 | debitAccountId |
POST/PUT /api/accounting/v1/... |
Request | CreateXxxRequest KHÔNG có debitAccountId |
BE thêm debitAccountId (UUID) vào request DTO |
|
| 2 | creditAccountId |
POST/PUT /api/accounting/v1/... |
Request | CreateXxxRequest KHÔNG có creditAccountId |
BE thêm creditAccountId (UUID) vào request DTO |
⚠️ TUYỆT ĐỐI KHÔNG tự ý thêm
debitAccountId/creditAccountIdvào payload khi interface chưa có — API sẽ từ chối hoặc bỏ qua field. Phải yêu cầu BE bổ sung vào DTO trước.
3.6.1 Default TK Nợ / TK Có — Chỉ Hiển Thị Khi Có ID (BẮT BUỘC)
Nguyên tắc: Nếu field TK Nợ / TK Có tồn tại nhưng KHÔNG được chỉ định default → KHÔNG có giá trị mặc định, để trống hoàn toàn. Chỉ khi có chỉ định default cụ thể (từ
getDefault()hoặc cấu hình) thì mới resolve ID và hiển thị. TUYỆT ĐỐI KHÔNG hiển thị số tài khoản khi chưa có ID — gây hiểu lầm cho user là đã chọn.
Quy tắc resolve default: Khi có default, dùng API
getById(dùngTKApiService.getById(accountId)) để lấy thông tin tài khoản, KHÔNG dùng pattern list+filter 9999 records. Nếu chưa cógetByIdendpoint, dùnglistvới keyword chính xác để tìm kiếm.
3.6.1.1 XXX_INITIAL_LINE — KHÔNG hardcode default số tài khoản
// ❌ SAI — hiển thị số '1111' nhưng debitAccountId rỗng → user tưởng đã chọn
export const PT_INITIAL_LINE: PTAccountingLineFormState = {
debitAccountId: '',
debitAccountNumber: '1111', // ← SAI: hiển thị số ảo
creditAccountId: '',
creditAccountNumber: '1388', // ← SAI: hiển thị số ảo
}
// ✅ ĐÚNG — để trống, chỉ fill khi resolve được ID
export const PT_INITIAL_LINE: PTAccountingLineFormState = {
debitAccountId: '',
debitAccountNumber: '', // ← để trống
creditAccountId: '',
creditAccountNumber: '', // ← để trống
}
3.6.1.2 Hook resolveAccountNumber — Resolve số TK → ID (dùng getById hoặc list+filter)
Trong hook useXxxDlgForm, thêm hàm resolveAccountNumber để resolve số tài khoản từ default config:
/** Resolve số tài khoản → ID.
* Ưu tiên dùng getById nếu BE hỗ trợ.
* Nếu chỉ có accountNumber (string), dùng list + filter FE (tối đa 200 records). */
const resolveAccountNumber = useCallback(async (accountNumber: string): Promise<{ id: string; number: string } | null> => {
if (!accountNumber) return null
try {
// Cách 1 (ƯU TIÊN): Nếu có accountId từ default → dùng getById
// const tk = await TKApiService.getById(accountId)
// Cách 2: Nếu chỉ có accountNumber → list + filter (pageSize vừa đủ, KHÔNG 9999)
const tkRes = await TKApiService.list({ pageIndex: 1, pageSize: 100, keyword: accountNumber })
if (tkRes.success && tkRes.data?.items) {
const found = tkRes.data.items.find(tk => tk.accountNumber === accountNumber)
if (found) return { id: found.id, number: found.accountNumber }
}
} catch { /* ignore */ }
return null
}, [])
3.6.1.3 initCreate — Chỉ set accountNumber khi có ID
const initCreate = useCallback(async () => {
// ...
try {
const r = await XxxApiService.getDefault()
if (r.success && r.data) {
const cashAccountNumber = fields.find(f => f.columnName === 'cashAccountNumber')?.defaultValue
// ↑ KHÔNG fallback '1111' — nếu BE không trả default thì để trống
// Resolve default TK → chỉ hiển thị number khi có ID
let debitAccountId = ''
let debitAccountNumber = ''
if (cashAccountNumber) {
const resolved = await resolveAccountNumber(cashAccountNumber)
if (resolved) {
debitAccountId = resolved.id
debitAccountNumber = resolved.number // ← chỉ set khi có ID
}
}
const defaultLine: XxxLineFormState = {
...XXX_INITIAL_LINE,
debitAccountId,
debitAccountNumber, // ← rỗng nếu không resolve được
}
// ...
}
} catch {
// Không có default → để trống hoàn toàn
setFormData({ ...XXX_INITIAL_FORM, accountingDetails: [{ ...XXX_INITIAL_LINE }] })
}
}, [resolveAccountNumber])
3.6.1.4 Dialog — KHÔNG set accountNumber/accountObjectId trong onChange Đối tượng
// ❌ SAI — sync accountObjectId từ parent xuống line (2 field độc lập)
onChange={(id, rowData) => {
updateLine(0, {
debitAccountNumber: formData.cashAccountNumber || '1111', // ← SAI
creditAccountNumber: '1388', // ← SAI
accountObjectId: id, // ← SAI
})
}}
// ✅ ĐÚNG — chỉ sync description & accountObjectName, không đụng TK & accountObjectId
onChange={(id, rowData) => {
updateLine(0, {
description: `Thu tiền của ${rowData.name || ''}`,
accountObjectName: rowData.name || '',
// ← KHÔNG set debitAccountNumber / creditAccountNumber / accountObjectId
})
}}
3.6.1.5 Flow Tổng Quan — Default TK Nợ / TK Có
Có field debitAccountId / creditAccountId trong form?
├── KHÔNG → KHÔNG có default, để trống hoàn toàn
└── CÓ
↓
Có được chỉ định default (getDefault() hoặc config)?
├── KHÔNG → KHÔNG có giá trị mặc định, TK Nợ/Có rỗng (cả ID & number)
└── CÓ
↓
resolveAccountNumber(defaultAccountNumber)
├── KHÔNG tìm thấy → TK Nợ/Có rỗng (KHÔNG hiển thị số ảo)
└── TÌM THẤY → set debitAccountId + debitAccountNumber
TableSearchCombobox hiển thị đúng số TK
Payload gửi lên BE:
→ CHỈ gửi debitAccountId / creditAccountId (UUID)
→ KHÔNG gửi debitAccountNumber / creditAccountNumber
→ Nếu interface request DTO chưa có debitAccountId/creditAccountId
→ DỪNG, báo user yêu cầu BE bổ sung
⚠️ QUY TẮC QUAN TRỌNG:
- Không có chỉ định default → form trống, không cố gắng tự suy đoán STK mặc định
- Có chỉ định default → resolve qua API, chỉ hiển thị khi có ID
- Payload → chỉ gửi
debitAccountId,creditAccountId- Interface thiếu → báo user, không tự ý thêm field vào payload
4. Quy Tắc Header — Biểu Tượng Ghim
4.1 Chỉ hiển thị ghim khi user đã chọn ghim trong Table Settings
DmTableHead component đã hỗ trợ prop pinned. CHỈ truyền pinned=true khi cột có col.pinned === 'left' || col.pinned === 'right':
{colsForRender.map(col => (
<DmTableHead
key={col.id}
pinned={col.pinned === 'left' || col.pinned === 'right'}
// ... các style khác
>
{col.displayName ?? col.title}
</DmTableHead>
))}
⚠️ KHÔNG truyền
pinneddựa trêncol.stickyLeft/col.stickyRight— đó là vị trí sticky do layout tính toán, không phải do user chủ động ghim.
Giải thích:
col.pinnedđến từuseTableSettings→ phản ánh lựa chọn của user trongTableSettingsPanelcol.stickyLeft/col.stickyRightđến từuseTableLayout→ vị trí sticky được tính toán tự động- Pin icon (
<Pin>) chỉ hiển thị khi user CHỦ ĐỘNG ghim cột, không phải khi cột vô tình ở vị trí sticky
5. PAGE_FEATURES Chuẩn
export const PAGE_FEATURES = [
{ label: 'Làm mới', code: 'btn-refresh' },
{ label: 'Thêm mới', code: 'btn-create' },
{ label: 'Ghi sổ', code: 'btn-post' },
{ label: 'Bỏ ghi sổ', code: 'btn-unpost' },
{ label: 'Xem chi tiết', code: 'row-view' },
{ label: 'Chỉnh sửa', code: 'row-edit' },
{ label: 'Nhân bản', code: 'row-clone' },
{ label: 'Xóa', code: 'row-delete' },
{ label: 'Thiết lập bảng', code: 'btn-table-settings' },
]
6. Quy Tắc Đặt Tên File
| Thành phần | Pattern | Ví dụ (Phiếu thu) |
|---|---|---|
| Page | {TenNghiepVu}Page.tsx |
PhieuThuPage.tsx |
| Dialog | {VietTat}Dialog.tsx |
PTDialog.tsx |
| Hook page list | use{VietTat}.page.list.ts |
usePT.page.list.ts |
| Hook dialog form | use{VietTat}.dlg.form.ts |
usePT.dlg.form.ts |
| Types API | {VietTat}.types.api.ts |
PT.types.api.ts |
| Types UI | {VietTat}.types.ui.ts |
PT.types.ui.ts |
| Service | {VietTat}ApiService.ts |
PTApiService.ts |
| ALL_COLUMNS constant | {VIET_TAT}_ALL_COLUMNS |
PT_ALL_COLUMNS |
| tableId | kebab-case | 'phieu-thu' |
| PAGE_ID | kebab-case | 'phieu-thu' |
7. Checklist Sau Khi Code
- 🚫 TUYỆT ĐỐI KHÔNG có
'-'trong CELL_VALUE — tất cả dùng|| ''hoặc? ... : '' - Fallback render không dùng
() => '-'— dùng() => '' - Action đúng theo ma trận: View luôn có, Xóa chỉ khi chưa ghi sổ, Bỏ ghi và sửa (button riêng) chỉ khi đã ghi sổ
- Dropdown "Chức năng khác": Ghi sổ + Nhân bản (chưa ghi sổ), Nhân bản (đã ghi sổ — KHÔNG có Bỏ ghi sổ vì đã có nút "Bỏ ghi và sửa" bên ngoài)
-
DmTableHeadchỉ cópinnedprop khicol.pinned === 'left' || col.pinned === 'right' - Có đủ 5
ConfirmDialog: thay đổi chưa lưu (chỉ cho Edit), xóa, ghi sổ, bỏ ghi sổ, xóa hàng loạt - Bỏ ghi và sửa (page): gọi thẳng API
formHook.handleUnpost(item.id)→ mở dialog edit, KHÔNG popup xác nhận, iconPencil,data-qa='btn_bo_ghi_va_sua_' -
formHookcóhandleDelete,handlePost,handleUnpost -
PAGE_FEATUREScó đủbtn-post,btn-unpost,row-view,row-edit,row-clone,row-delete -
data-qađúng format:btn_xem_,btn_xoa_,btn_ghi_so_,btn_bo_ghi_va_sua_,btn_nhan_ban_,btn_khac_ - ALL_COLUMNS hiển thị đầy đủ: TẤT CẢ cột khai báo trong
ALL_COLUMNSđều có mặt trong bảng, KHÔNG tự ý bỏ bớt cột nào
8. Quy Tắc FK — Select Chỉ Gửi ID, BE Resolve & Trả Display Fields
Nguyên tắc: TableSearchCombobox select đối tượng → CHỈ gửi FK ID lên BE, KHÔNG gửi code/name/display. BE tự resolve FK và trả về đầy đủ display fields trong response.
8.1 Payload POST/PUT — Chỉ Gửi ID
// ✅ ĐÚNG: Chỉ gửi ID
{ "fromBankAccountID": "uuid-abc", "toBankAccountID": "uuid-def" }
// ❌ SAI: Gửi cả bankName (dư thừa, BE tự resolve)
{ "fromBankAccountID": "uuid-abc", "fromBankName": "Vietcombank CN HCM" }
| Field | Gửi lên BE | BE trả về trong response | UI hiển thị |
|---|---|---|---|
| TK ngân hàng đi | fromBankAccountID ✅ |
fromBankName, fromBankAccountNumber |
ctnb_sel_tk_di, ctnb_i_ten_nh_di |
| TK ngân hàng đến | toBankAccountID ✅ |
toBankName, toBankAccountNumber |
ctnb_sel_tk_den, ctnb_i_ten_nh_den |
| TK Nợ (hạch toán) | debitAccountId ✅ |
debitAccount, debitAccountNumber |
ctnb_sel_tk_no_0 |
| TK Có (hạch toán) | creditAccountId ✅ |
creditAccount, creditAccountNumber |
ctnb_sel_tk_co_0 |
8.2 Response Phải Có Display Fields Để UI Hiển Thị
Khi edit/view, dialog cần hiển thị lại đúng giá trị đã chọn. Tất cả display fields phải có trong response DTO.
8.2.1 Phương pháp kiểm tra: Lấy UI làm chuẩn, đối chiếu DTO response
B1: Liệt kê mọi field UI hiển thị trong dialog (data-qa → formData key)
B2: Đối chiếu formData key → DTO response field
B3: Field nào UI có mà DTO KHÔNG có → BE THIẾU
8.2.2 Các field BE thường xuyên thiếu trong response — Báo Cáo Chi Tiết
Quy tắc báo cáo: Mỗi field thiếu PHẢI chỉ rõ:
- Field name — field bị thiếu trong DTO
- API endpoint — đường dẫn API bị ảnh hưởng
- HTTP method — GET/POST/PUT
- Vị trí — Response (GET response thiếu) hay Request (POST/PUT không chấp nhận)
- DTO hiện tại — tên interface/class BE đang dùng
- So sánh — DTO tương tự có field này không (làm bằng chứng BE đã có pattern)
Format mẫu:
| # | Field | API Endpoint | Method | Vị trí | DTO hiện tại | So sánh | Hành động |
|---|---|---|---|---|---|---|---|
| 1 | postedDate |
/api/accounting/v1/bank/transfers |
GET | Response | BankTransferDetail KHÔNG có postedDate |
BankDepositDetail (receipt/payment) CÓ postedDate |
BE thêm postedDate vào BankTransferDetail response |
| 2 | postedDate |
/api/accounting/v1/bank/transfers |
POST/PUT | Request | CreateBankTransferRequest KHÔNG có postedDate |
CreateBankReceiptRequest CÓ postedDate? |
BE thêm postedDate? vào CreateBankTransferRequest |
| 3 | refNoManagement |
/api/accounting/v1/bank/transfers |
GET | Response | BankTransferDetail KHÔNG có refNoManagement |
BankDepositDetail CÓ refNoManagement |
BE thêm refNoManagement vào BankTransferDetail response |
| 4 | refNoManagement |
/api/accounting/v1/bank/transfers |
POST/PUT | Request | CreateBankTransferRequest KHÔNG có refNoManagement |
CreateBankReceiptRequest CÓ refNoManagement? |
BE thêm refNoManagement? vào CreateBankTransferRequest |
| 5 | accountingDetails[].debitAccountId |
/api/accounting/v1/bank/transfers |
GET | Response | BADepositDetailLineDto.debitAccountId? (optional) |
UI cần biết để hiển thị combobox | BE đổi debitAccountId thành required (không optional) trong response |
| 6 | accountingDetails[].creditAccountId |
/api/accounting/v1/bank/transfers |
GET | Response | BADepositDetailLineDto.creditAccountId? (optional) |
UI cần biết để hiển thị combobox | BE đổi creditAccountId thành required (không optional) trong response |
Ví dụ báo cáo cụ thể cho BE:
BE cần thêm `postedDate` vào: - GET /api/accounting/v1/bank/transfers/{id} response (BankTransferDetail) - POST/PUT /api/accounting/v1/bank/transfers request (CreateBankTransferRequest) Lý do: UI có field `ctnb_dt_posted_date` (DatePicker Ngày hạch toán). Khi edit/view, FE map `d.postedDate` → formData.postedDate để hiển thị. Nhưng BankTransferDetail KHÔNG có field này → UI luôn trống. BankDepositDetail (receipt/payment) ĐÃ CÓ → pattern BE đã có.
Cách FE workaround khi BE thiếu: Dialog dùng
useEffectgọi API danh mục để resolve số tài khoản → ID. Pattern này TỐN KÉM (fetch toàn bộ 9999 records), nên ưu tiên yêu cầu BE bổ sung field vào response.
8.3 TableSearchCombobox — Cách Map Dữ Liệu
// ── loadOptions: gọi API danh mục, trả về TableComboboxRow[] ──
const loadTaiKhoanNganHang = async (keyword: string): Promise<TableComboboxRow[]> => {
const r = await TKNHApiService.list({ pageIndex: 1, pageSize: 20, keyword })
if (r.success && r.data) {
return r.data.items.map((tk) => ({
value: tk.id, // ← FK ID gửi lên BE
cells: {
accountNumber: tk.accountNumber ?? '', // ← display field
bankName: tk.bankName ?? '', // ← display field
bankBranch: (tk as any).bankBranch ?? '', // ← display field
},
}))
}
return []
}
// ── onChange: CHỈ set ID (FK) + display fields cho UI ──
const handleTKDiChange = (id: string, rowData: Record<string, string>) => {
setField('fromBankAccountID', id) // ← FK gửi lên BE
setField('fromBankAccountNumber', rowData.accountNumber ?? '') // ← để UI hiển thị
setField('fromBankName', rowData.bankName ?? '') // ← để UI hiển thị
}
8.4 Flow Khi Edit/View — Map Response → FormState
// initEdit: Map BankTransferDetail → FormState
setFormData({
// FK IDs — BE phải trả về
fromBankAccountID: d.fromBankAccountID ?? '', // ✅ BE có
toBankAccountID: d.toBankAccountID ?? '', // ✅ BE có
// Display fields — BE phải trả về từ FK ID
fromBankName: d.fromBankName ?? '', // ✅ BE có
toBankName: d.toBankName ?? '', // ✅ BE có
// ❌ BE KHÔNG trả về → formData rỗng → UI trống
postedDate: '', // ← BE thiếu postedDate
refNoManagement: '', // ← BE thiếu refNoManagement
// Accounting lines:
accountingDetails: d.accountingDetails?.map(line => ({
debitAccountId: (line as any).debitAccountId ?? '', // 🟡 optional
debitAccount: line.debitAccount ?? '', // ✅ BE có
creditAccountId: (line as any).creditAccountId ?? '', // 🟡 optional
creditAccount: line.creditAccount ?? '', // ✅ BE có
}))
})
8.5 Workaround Khi BE Thiếu Display Fields
// Pattern: useEffect resolve FK khi edit/view
// Dùng khi BE response không có display field (ví dụ: fromBankAccountNumber)
useEffect(() => {
if (!open || mode === 'create') return
if (formData.fromBankAccountID && !formData.fromBankAccountNumber) {
TKNHApiService.getById(formData.fromBankAccountID)
.then(r => {
if (r.success && r.data) {
setField('fromBankAccountNumber', (r.data as any).accountNumber ?? '')
}
})
.catch(() => {})
}
}, [open, mode, formData.fromBankAccountID, formData.fromBankAccountNumber])
⚠️ Workaround chỉ dùng cho display fields phụ (số tài khoản đầy đủ, địa chỉ...).
Các field chính (bankName, postedDate, refNoManagement) → PHẢI yêu cầu BE bổ sung vào DTO.
Nếu BE không bổ sung, gửi inbox task cho BE với danh sách field thiếu kèm route + DTO hiện tại.
9. Quy Tắc Form — Default API, Init Create & Payload (BẮT BUỘC)
9.1 Gọi API Default Để Lấy Số Chứng Từ & Ngày Chứng Từ
Khi mở form tạo mới, PHẢI gọi API
getDefault()để lấy số chứng từ tiếp theo và ngày chứng từ mặc định.
const initCreate = useCallback(async () => {
setMode('create')
setEditingId(null)
setErrors({})
setTouched({})
try {
const r = await XxxApiService.getDefault()
if (r.success && r.data) {
const { nextVoucherCode, defaultVoucherDate } = r.data
setFormData({
...XXX_INITIAL_FORM,
voucherCode: nextVoucherCode ?? '', // ← Số chứng từ từ BE
voucherDate: (defaultVoucherDate ?? '').slice(0, 10), // ← Ngày chứng từ từ BE
})
return
}
} catch {
// fallthrough — để form trống nếu API default lỗi
}
setFormData({ ...XXX_INITIAL_FORM })
}, [])
9.1.1 Nếu Không Biết Cấu Trúc Default API → Yêu Cầu Cung Cấp
TUYỆT ĐỐI KHÔNG tự đoán structure của
getDefault()response.
Nếu chưa có docs về response củaGET /api/accounting/v1/xxx/default:
⚠️ GIẢI PHÁP CẦN XÁC NHẬN:
Chưa biết cấu trúc response của API getDefault() cho module này.
Vui lòng cung cấp response schema (các field trả về) của:
GET /api/accounting/v1/{route}/default
Ví dụ mẫu (từ module khác):
{
"nextDiscountCode": "GGHM00001", // Số chứng từ tiếp theo
"defaultDiscountDate": "2026-07-07" // Ngày chứng từ mặc định
}
9.1.2 Default API Có Thể Trả Về Nhiều Field Khác
Ngoài nextVoucherCode và defaultVoucherDate, API default có thể trả về thêm:
defaultPostedDate— ngày hạch toán mặc địnhdefaultCurrencyId— loại tiền mặc địnhdefaultExchangeRate— tỷ giá mặc định- Các field tùy chỉnh khác theo từng nghiệp vụ
Quy tắc: Nếu API default trả về field nào có trên UI → fill vào form field đó.
Nếu không chắc field nào có thể có → kiểm tra response thực tế hoặc hỏi BE.
9.2 TẤT CẢ Field Trên UI Phải Có Trong Payload (Trừ Readonly)
Nguyên tắc: Mọi field hiển thị trên UI (form/dialog) mà user có thể nhập liệu → PHẢI có mặt trong payload gửi lên BE.
Ngoại lệ: Các field được đánh dấureadOnlyhoặcdisabled(do fill tự động từ dữ liệu khác).
Quy trình kiểm tra:
B1: Liệt kê tất cả field trong FormState (types UI)
B2: Đối chiếu từng field với CreateRequest / UpdateRequest (types API)
B3: Field có trong FormState mà KHÔNG có trong Request DTO:
├── Là readonly/disabled? → Bỏ qua (field hiển thị tham khảo)
└── User nhập được? → BÁO LỖI: Interface thiếu field này
Ví dụ báo cáo field thiếu:
| # | FormState Field | Request Field | Trạng thái | Hành động |
|---|---|---|---|---|
| 1 | employeeId |
employeeId |
❌ Thiếu | BE thêm employeeId vào CreateXxxRequest |
| 2 | bankAccountId |
— | ❌ Thiếu | BE thêm bankAccountId vào CreateXxxRequest |
| 3 | invoiceTemplateCode |
— | readonly (fill từ CTMH) | Bỏ qua |
9.3 TK Fields — Gửi ID, Không Gửi AccountNumber
⚠️ TUYỆT ĐỐI CẤM gửi
debitAccountNumber/creditAccountNumberlên payload. Payload CHỈ chứadebitAccountId/creditAccountId(UUID).debitAccountNumber/creditAccountNumberlà display-only, DTO cho phép nhưng setnullkhi gửi.Quy tắc: Với các field tài khoản (debitAccount, creditAccount, bankAccount...):
- Combobox hiển thị
accountNumbercho user dễ nhận diện- Payload CHỈ gửi
debitAccountId/creditAccountId/bankAccountId(UUID)- BE tự resolve ID → accountNumber và trả về trong response
// ✅ ĐÚNG: Payload build — CHỈ gửi ID, number = null
details: formData.details.map(l => ({
debitAccountId: l.debitAccountId || null,
debitAccountNumber: null, // ← KHÔNG gửi, BE tự resolve
creditAccountId: l.creditAccountId || null,
creditAccountNumber: null, // ← KHÔNG gửi, BE tự resolve
}))
// ❌ SAI: Gửi accountNumber — BE không resolve được, dễ lỗi
details: formData.details.map(l => ({
debitAccountNumber: l.debitAccountNumber || null, // ← SAI
creditAccountNumber: l.creditAccountNumber || null, // ← SAI
}))
9.3.1 Không Chỉ Định TK → Hiển Thị Combobox Cho User Chọn
Nếu KHÔNG có default TK (không có
getDefault()trả về TK, không có config), form KHÔNG tự điền số TK mặc định. Combobox sẽ để trống và user tự chọn.
9.3.2 Có Chỉ Định AccountNumber → Dùng Hook useAccountId Để Lấy ID
Khi default API hoặc config trả về số tài khoản (string, không phải UUID), PHẢI resolve sang ID qua hook
useAccountId:
// Hook useAccountId — resolve accountNumber → { id, accountNumber }
const useAccountId = () => {
const resolveAccountNumber = useCallback(async (accountNumber: string): Promise<{ id: string; number: string } | null> => {
if (!accountNumber) return null
try {
// ƯU TIÊN: dùng getById nếu BE hỗ trợ
// Hoặc: list + filter với keyword chính xác (pageSize vừa đủ, KHÔNG 9999)
const tkRes = await TKApiService.list({ pageIndex: 1, pageSize: 100, keyword: accountNumber })
if (tkRes.success && tkRes.data?.items) {
const found = tkRes.data.items.find(tk => tk.accountNumber === accountNumber)
if (found) return { id: found.id, number: found.accountNumber }
}
} catch { /* ignore */ }
return null
}, [])
return { resolveAccountNumber }
}
9.3.3 Field Khác Có Sẵn AccountNumber → Cũng Dùng Hook Để Lấy ID
Nếu một field khác (VD:
bankAccountNumbertừ chứng từ gốc) có sẵnaccountNumber, cũng gọiresolveAccountNumber()để lấybankAccountIdtrước khi gửi payload:
// Khi chọn chứng từ gốc → fill bankAccountNumber → resolve ID
if (d.bankAccountNumber) {
const resolved = await resolveAccountNumber(d.bankAccountNumber)
if (resolved) {
setField('bankAccountId', resolved.id)
setField('bankAccountNumber', resolved.number)
}
}
9.3.4 Gửi ID Lên BE → Expect BE Trả Về AccountNumber
Payload:
debitAccountId: "uuid"
Response:debitAccountId: "uuid",debitAccountNumber: "1111",debitAccountName: "Tiền mặt"
Khi edit/view, mapdebitAccountNumbertừ response vào form để combobox hiển thị đúng.
9.3.5 Kiểm Tra Interface Trước Khi Gửi ID
Trước khi dùng
debitAccountId/creditAccountIdtrong payload, PHẢI kiểm tra request DTO đã có field đó chưa.
Nếu chưa có → DỪNG, báo cáo yêu cầu BE bổ sung (theo format Section 3.6.0).
10. Quy Tắc Resolve FK Ngược Khi Edit/View (BẮT BUỘC)
Khi mở form Edit/View, các field FK chỉ có ID → PHẢI resolve ngược lại để hiển thị display value (code/name) cho user.
10.1 Nguyên Tắc: Resolve Đúng Những Gì Combobox Hiển Thị
Combobox displayField |
Khi chọn → lưu gì? | Khi edit → resolve hiển thị gì? |
|---|---|---|
displayField='code' |
accountObjectCode: rd.code, accountObjectName: rd.name |
Resolve và set code vào initialLabel |
displayField='name' |
employeeName: rd.name |
Resolve và set name vào initialLabel |
displayField='accountNumber' |
bankAccountNumber: rd.accountNumber |
Resolve và set accountNumber vào initialLabel |
Quy tắc: Combobox hiển thị field gì khi select → khi edit phải resolve và hiển thị đúng field đó.
10.2 Pattern Resolve — Dùng useEffect Trong Dialog
// ── Resolve accountObjectId → accountObjectCode khi edit/view ──────────
useEffect(() => {
if (!open || mode === 'create') return
if (formData.accountObjectId && !formData.accountObjectCode) {
XxxApiService.getById(formData.accountObjectId)
.then(r => {
if (r.success && r.data) {
// displayField='code' → set code vào initialLabel
setField('accountObjectCode', r.data.code ?? '')
// Các field phụ khác (chỉ set nếu chưa có)
if (!formData.accountObjectName) setField('accountObjectName', r.data.name ?? '')
}
})
.catch(() => {})
}
}, [open, mode, formData.accountObjectId, formData.accountObjectCode])
10.3 Code Mẫu Cho Từng Loại Combobox
// displayField='code' (VD: Nhà cung cấp)
<TableSearchCombobox
value={formData.accountObjectId || ''}
initialLabel={formData.accountObjectCode || formData.accountObjectName || ''}
displayField='code' // ← Hiển thị code
onChange={(id, rd) => {
setField('accountObjectId', id)
setField('accountObjectCode', rd.code || '') // ← Lưu code
setField('accountObjectName', rd.name || '') // ← Lưu name (phụ)
}}
/>
// Khi edit → resolve API → setField('accountObjectCode', r.data.code)
// displayField='name' (VD: Nhân viên)
<TableSearchCombobox
value={formData.employeeId || ''}
initialLabel={formData.employeeName || ''}
displayField='name' // ← Hiển thị name
onChange={(id, rd) => {
setField('employeeId', id)
setField('employeeName', rd.name || '') // ← Lưu name
}}
/>
// Khi edit → resolve API → setField('employeeName', r.data.name)
// displayField='accountNumber' (VD: TK ngân hàng)
<TableSearchCombobox
value={formData.bankAccountId || ''}
initialLabel={formData.bankAccountNumber || ''}
displayField='accountNumber' // ← Hiển thị accountNumber
onChange={(id, rd) => {
setField('bankAccountId', id)
setField('bankAccountNumber', rd.accountNumber || '')
}}
/>
// Khi edit → resolve API → setField('bankAccountNumber', r.data.accountNumber)
10.4 BE Phải Trả Về Display Fields Trong Response
CLOSE THE LOOP: BE response cho GET detail PHẢI chứa tất cả display fields mà combobox cần hiển thị.
| Combobox | value (FK ID) | Cần trong response |
|---|---|---|
| Nhà cung cấp | accountObjectId |
accountObjectCode, accountObjectName |
| Nhân viên | employeeId |
employeeCode, employeeName |
| TK ngân hàng | bankAccountId |
bankAccountNumber, bankName |
| TK Nợ/Có | debitAccountId / creditAccountId |
debitAccountNumber, debitAccountName |
Nếu response thiếu field → báo cáo yêu cầu BE bổ sung (dùng format Section 8.2.2).
11. Quy Tắc Gọi API Tài Khoản — TUYỆT ĐỐI KHÔNG Truyền isActive
Khi gọi API danh mục tài khoản (TKApiService, TKNHApiService...) để lấy danh sách hoặc resolve: TUYỆT ĐỐI KHÔNG truyền tham số
isActive.
// ❌ SAI — truyền isActive làm giới hạn kết quả, user có thể không chọn được TK đã ngừng
const r = await TKApiService.list({ pageIndex: 1, pageSize: 20, keyword, isActive: true })
// ✅ ĐÚNG — không truyền isActive, để BE tự quyết định
const r = await TKApiService.list({ pageIndex: 1, pageSize: 20, keyword })
Lý do: TK có thể đã bị vô hiệu hóa (isActive=false) nhưng vẫn được dùng trong các phiếu cũ. Khi edit các phiếu cũ, cần resolve được cả TK đã ngừng. BE sẽ tự xử lý logic lọc phù hợp.
Áp dụng cho TẤT CẢ API danh mục liên quan đến tài khoản:
TKApiService.list()— Danh mục tài khoản kế toánTKNHApiService.list()— Tài khoản ngân hàng- Các API danh mục khác có field FK tương tự
12. Cập Nhật Checklist
Bổ sung các mục sau vào checklist:
- Ghost column dùng Button thủ công +
DropdownMenu, KHÔNG dùngDmRowActions - Icon đúng:
Eyecho Xem,Trash2cho Xóa,BookOpencho Ghi sổ,Pencilcho Bỏ ghi và sửa,Copycho Nhân bản,MoreHorizontalcho dropdown trigger - Class variant đúng:
icon-warningcho Bỏ ghi và sửa,icon-dangercho Xóa - Dialog footer đúng action: View unposted → [Sửa][Ghi sổ], View posted → [Bỏ ghi và sửa] (1 button, unpost rồi edit), Create → [Hủy][Lưu][Lưu & Ghi sổ], Edit → [Hủy][Lưu]
- Confirm đóng dialog: Create → đóng trực tiếp không confirm. Edit → confirm nếu
dirtyRef.current === true. View → đóng trực tiếp. - Nút [Hủy] Create footer:
onClick={() => onOpenChange(false)}— KHÔNG checkdirtyRef, KHÔNG mởunsavedOpen - Nút X Header: check
mode === 'create' || mode === 'view'→ đóng luôn. Edit → checkdirtyRef. - Hook
handleSubmitKHÔNG auto-post:handleSubmit(nút "Lưu") chỉ gọi API create/update, TUYỆT ĐỐI KHÔNG gọipost()bên trong. Không có auto-post tronghandleSubmithoặchandleSubmitAndNew. - Hook có
handleCreateAndPost:create()→post()liên tiếp. Đây là nơi DUY NHẤT gọi đồng thời cả tạo và ghi sổ. - Cột "Số tiền" canh phải: header
text-right+ celltext-right - Có hàng tổng cộng:
bg-[#ECEDEF],border-t-2, chữ "Tổng cộng" ở cột đầu,formatNumber(totalSum)ở cột số tiền - TK Nợ/Có dùng ID: payload gửi
debitAccountId/creditAccountId, UI hiển thịdebitAccountNumber/creditAccountNumber, comboboxvalue=tk.id - Default STK resolve ID:
initCreategọiresolveAccountNumber()→ chỉ setaccountNumberkhi cóaccountId. Không hardcode số mặc định trongXXX_INITIAL_LINE -
XXX_INITIAL_LINEkhông hardcode số TK:debitAccountNumber: '',creditAccountNumber: ''— để trống hoàn toàn - Dialog onChange Đối tượng không set TK Nợ/Có: không gọi
updateLinevớidebitAccountNumber/creditAccountNumbercứng - Default
itemsPerPage= 50 & lưu localStorage:getPageSizeFromStorage(50)thay vìgetPageSizeFromStorage(10).handleItemsPerPageChangePHẢI gọisavePageSizeToStorage(size)để lưu lại khi user đổi số dòng/trang. - 🖥️ Dialog full-screen: DialogContent
maxWidth='none'+w-[100vw] h-[100vh] p-0 gap-0 rounded-none+flex flex-col overflow-hidden+[&>button:first-child]:hidden - 🖥️ KHÔNG dùng
DialogHeader/DialogTitle/DialogFooter— thay bằng custom div - 🖥️ Body gray bg + min-h-0:
<div className='flex-1 min-h-0 overflow-y-auto bg-[#f4f5f7]'>+ inner<div className='p-5 space-y-4'> - 🖥️ Header: refNoManagement + refType Select + Tổng tiền + Nút X (unsaved guard chỉ cho Edit, Create/View đóng trực tiếp)
- 🖥️ Footer py-2.5:
<div className='flex items-center justify-between px-5 py-2.5 bg-white flex-shrink-0 border-t border-[#B7BCC3]'> - 🖥️ Buttons
btn-secondary/btn-primary: KHÔNG dùngvariant='outline'— dùngbtn-secondary h-8 text-[13px] rounded-lghoặcbtn-primary h-8 text-[13px] rounded-lg - 🖥️ Input chuẩn:
h-[30px] text-[13px] rounded-lg border-[#B7BCC3] bg-white - 🖥️ Input trong bảng data có
px-2: KHÔNG dùngp-0— dùngpx-2cho tất cả<Input>trongEditableDataTable - 🖥️ Label chuẩn:
text-[13px] font-semibold text-black - 🖥️ Hạch toán trong card:
bg-white rounded-lg shadow-sm overflow-hidden+ tab headerborder-b-2 border-primary - 🖥️ Error inline:
<p className='text-xs text-destructive flex items-center gap-1'><AlertCircle className='h-3 w-3' />{...}</p> - 🌐 Gọi API getDefault() trong initCreate: lấy
nextVoucherCode+defaultVoucherDate, fill vào form. Nếu API default trả thêm field khác (postedDate, currencyId...) → fill tương ứng. - 🌐 Không biết cấu trúc default API → DỪNG & hỏi user: "Chưa biết cấu trúc response của API getDefault(). Vui lòng cung cấp response schema."
- 🌐 TẤT CẢ field trên UI (có thể nhập) → có trong payload: kiểm tra từng FormState field → CreateRequest/UpdateRequest. Field user nhập được mà thiếu trong DTO → báo cáo.
- 🌐 TK fields gửi ID:
debitAccountId/creditAccountId/bankAccountIdtrong payload, KHÔNG gửidebitAccountNumber/bankAccountNumber. - 🌐 Combobox để trống nếu không có default TK: không tự điền STK mặc định khi chưa có ID.
- 🌐 Có accountNumber default → resolve ID qua
useAccountId: dùngTKApiService.list({ keyword })với pageSize vừa đủ, KHÔNG dùngpageSize: 9999. - 🌐 TK API list KHÔNG truyền
isActive:TKApiService.list({ pageIndex, pageSize, keyword })— không cóisActive: true. - 🌐 Edit/View → resolve FK ngược:
useEffectresolveaccountObjectId→accountObjectCode,employeeId→employeeName,bankAccountId→bankAccountNumber. - 🌐 Resolve hiển thị đúng displayField: combobox
displayField='code'→ resolve setcode.displayField='name'→ resolve setname.displayField='accountNumber'→ resolve setaccountNumber. - 🌐 Response DTO phải có display fields cho FK:
accountObjectCode,accountObjectName,employeeName,bankAccountNumber,debitAccountNumber,creditAccountNumber.
8. Hook Dlg Form — Các Hàm Bắt Buộc
Hook useXxxDlgForm phải export các hàm sau để page sử dụng:
export function useXxxDlgForm(options?: { onSuccess?: () => void; onClose?: () => void }) {
// ... state, validate, submit ...
/** Lưu (create/update) — CHỈ gọi API tạo/cập nhật, KHÔNG gọi API ghi sổ */
const handleSubmit = async () => { /* validate → create() hoặc update() → toast → onSuccess */ }
/** Xóa bản ghi */
const handleDelete = async (id: string) => { /* gọi API delete */ }
/** Ghi sổ */
const handlePost = async (id: string) => { /* gọi API post */ }
/** Bỏ ghi sổ */
const handleUnpost = async (id: string) => { /* gọi API unpost */ }
/** Tạo mới & Ghi sổ — gọi create() → post() liên tiếp. ĐÂY LÀ NƠI DUY NHẤT gọi đồng thời cả tạo và ghi sổ. */
const handleCreateAndPost = async () => { /* validate → create() → post() */ }
return {
// ... form state & handlers ...
handleSubmit,
handleDelete,
handlePost,
handleUnpost,
handleCreateAndPost,
serverErrorOpen, setServerErrorOpen,
serverError,
}
}
⚠️ QUY TẮC SỐ 1:
handleSubmit(nút "Lưu") CHỈ gọi API create/update — KHÔNG BAO GIỜ gọi API post.
⚠️ QUY TẮC SỐ 2:handleCreateAndPost(nút "Lưu & Ghi sổ") gọi create() → post() liên tiếp. Đây là nơi DUY NHẤT kết hợp cả 2 thao tác.
⚠️ QUY TẮC SỐ 3: Không có hàmhandleSubmitAndNewcho dialog nghiệp vụ có ghi sổ — dialog nghiệp vụ dùnghandleCreateAndPostthay vì "Lưu & Thêm".
13. Quy Tắc Resize Cột — Kéo Thay Đổi Độ Rộng & Lưu localStorage (BẮT BUỘC)
Các cột trên page phải thay đổi được độ rộng bằng cách kéo cạnh phải của header, và lưu độ rộng mới vào localStorage.
13.1 Hook useColumnResize
Hook được đặt tại src/modules/KetoanApp/hooks/useColumnResize.ts. Nhận columns (từ useTableSettings) và onColumnsChange (chính là saveSettings).
import { useColumnResize } from '@/modules/KetoanApp/hooks/useColumnResize'
const { handleResizeStart, getColumnWidth } = useColumnResize(visibleColumns, saveSettings)
handleResizeStart(colId, mouseEvent)— gắn vàoonMouseDowncủa resize handle trongDmTableHeadgetColumnWidth(colId, defaultWidth)— trả về width thực tế (đang resize hoặc mặc định), dùng thay chocol.widthtrong style- Tự động set
cursor: col-resizevàuserSelect: nonetrên body trong khi kéo - Khi thả chuột, gọi
saveSettingsđể lưu vào localStorage thông quauseTableSettings
13.2 Sửa DmTableHead — Thêm Resize Handle
Component DmTableHead (src/modules/KetoanApp/components/DmTable.tsx) có thêm prop onResizeMouseDown. Khi có prop này, một thanh kéo mỏng (w-1.5 cursor-col-resize) sẽ xuất hiện ở cạnh phải của header.
export function DmTableHead({ className, children, pinned, onResizeMouseDown, ...props }:
React.ComponentProps<'th'> & { pinned?: boolean; onResizeMouseDown?: (e: React.MouseEvent) => void }
) {
// ...
return (
<th className={cn('relative ...', className)} {...props}>
{/* ... existing content ... */}
{onResizeMouseDown && (
<div
className="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-blue-400/30 transition-colors z-10"
onMouseDown={onResizeMouseDown}
/>
)}
</th>
)
}
Chú ý: th phải có relative để resize handle absolute hoạt động đúng.
13.3 Tích Hợp Vào Page — Cả 3 Bảng Phải Dùng getColumnWidth
Vì layout dùng 3 <DmTable> riêng biệt (header / body / total), cả 3 bảng PHẢI dùng getColumnWidth để độ rộng cột đồng bộ khi đang kéo:
// ── Trong header table (CHỈ header mới có onResizeMouseDown) ──
{colsForRender.map(col => {
const w = getColumnWidth(col.id, col.width)
return (
<DmTableHead
key={col.id}
onResizeMouseDown={(e) => handleResizeStart(col.id, e)}
style={{ width: w, minWidth: w, ... }}
>
{col.displayName ?? col.title}
</DmTableHead>
)
})}
// ── Trong body table (KHÔNG có onResizeMouseDown) ──
{colsForRender.map(col => {
const w = getColumnWidth(col.id, col.width)
return (
<DmTableCell style={{ width: w, ... }}>...</DmTableCell>
)
})}
// ── Trong total table (KHÔNG có onResizeMouseDown) ──
{colsForRender.map((col, idx) => {
const w = getColumnWidth(col.id, col.width)
return (
<DmTableCell style={{ width: w, minWidth: w, ... }}>...</DmTableCell>
)
})}
13.4 Nguyên Lý Hoạt Động
- User kéo cạnh phải header →
handleResizeStartđược gọi useColumnResizetheo dõimousemove→ cập nhậtresizeState.currentWidth→getColumnWidthtrả về width mới → cả 3 bảng re-render với width mới- User thả chuột →
saveSettings(columns)được gọi →useTableSettingslưu vào localStorage keytable_settings_phieu-thu - Lần sau mở trang,
useTableSettingsđọc từ localStorage → column widths được khôi phục
13.5 Lưu Ý
- Độ rộng tối thiểu mỗi cột: 50px (
MIN_COL_WIDTH) - Chỉ header mới có resize handle — body và total chỉ dùng
getColumnWidthđể sync width DmTableđã cótableLayout: 'fixed'nên width được tôn trọng chính xác- Resize handle chỉ hiển thị khi có prop
onResizeMouseDown— an toàn khi dùng cho các bảng không cần resize
13. Quy Tắc Chiết Khấu — Dialog Nghiệp Vụ
Áp dụng cho: Mọi dialog nghiệp vụ có bảng hàng hóa và hỗ trợ chiết khấu (Chứng từ bán hàng, Đơn mua, Trả lại hàng bán...).
13.1 Các Loại Chiết Khấu
| Key | Label | Mô tả |
|---|---|---|
none |
Không chiết khấu | Default — không hiển thị cột chiết khấu trên bảng |
byItem |
Theo mặt hàng | User tự nhập tỷ lệ CK (%) trên từng dòng. Cột TK chiết khấu hiển thị trên bảng |
percent |
Theo % hóa đơn | Nhập 1 tỷ lệ % chung → onBlur apply xuống tất cả dòng |
amount |
Theo số tiền | Nhập 1 số tiền chung → onBlur chia theo tỷ lệ thành tiền từng dòng |
13.2 DISCOUNT_TYPE_LABELS Chuẩn
export const DISCOUNT_TYPE_LABELS: Record<string, string> = {
none: 'Không chiết khấu',
byItem: 'Theo mặt hàng',
percent: 'Theo % hóa đơn',
amount: 'Theo số tiền',
}
13.3 Khi Chọn Khác none → Bảng Data Thêm Cột
Khi discountType !== 'none', bảng EditableDataTable PHẢI thêm các cột sau (theo thứ tự):
| Cột | Key | Width | Editable | Ghi chú |
|---|---|---|---|---|
| Tỷ lệ CK (%) | discountRate |
100px | ✅ (khi byItem) / ❌ readonly (khi percent) |
text-right, inputMode='numeric' |
| Tiền chiết khấu | discountAmount |
150px | ❌ display | Tự tính = amount * discountRate / 100 |
| TK chiết khấu | discountAccountId |
180px | ✅ TableSearchCombobox |
Default 5111, resolve ID qua TKApiService |
13.4 Code Mẫu Cho Từng Loại Chiết Khấu
13.4.1 Select Chiết Khấu + Input Đi Kèm
<div className='flex items-center gap-2'>
Chiết khấu
<Select value={formData.discountType} onValueChange={(v) => { setField('discountType', v); setDiscountInput('') }} disabled={isReadOnly}>
<SelectTrigger className='w-[200px] h-8 text-[13px] border-[#B7BCC3] rounded-lg bg-white' data-qa='sel_chiet_khau'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(DISCOUNT_TYPE_LABELS).map(([val, label]) => (
<SelectItem key={val} value={val}>{label}</SelectItem>
))}
</SelectContent>
</Select>
{/* Theo % hóa đơn — input % bên cạnh */}
{formData.discountType === 'percent' && (
<div className='flex items-center gap-1'>
<Input type='text' inputMode='numeric'
value={discountInput}
onChange={e => setDiscountInput(e.target.value.replace(/[^0-9.]/g, ''))}
onBlur={() => applyDiscountPercent()}
className='h-[30px] w-[80px] text-[13px] text-right rounded-lg border-[#B7BCC3] bg-white'
data-qa='i_discount_percent' placeholder='0' />
<span className='text-[13px] text-gray-500'>%</span>
</div>
)}
{/* Theo số tiền — input số tiền bên cạnh */}
{formData.discountType === 'amount' && (
<Input type='text' inputMode='numeric'
value={discountInput ? formatNumber(Number(discountInput) || 0) : ''}
onChange={e => setDiscountInput(e.target.value.replace(/\./g, '').replace(/[^0-9]/g, ''))}
onBlur={() => applyDiscountAmount()}
className='h-[30px] w-[140px] text-[13px] text-right rounded-lg border-[#B7BCC3] bg-white'
data-qa='i_discount_amount' placeholder='0' />
)}
</div>
13.4.2 applyDiscountPercent — % Hóa Đơn
const applyDiscountPercent = useCallback(() => {
const rate = discountInput || '0'
const updated = formData.details.map(line => ({
...line,
discountRate: rate,
discountAmount: String(Math.round(toNum(line.amount) * Number(rate)) / 100),
}))
setDetails(updated)
}, [discountInput, formData.details, setDetails])
13.4.3 applyDiscountAmount — Số Tiền Trên Tổng
const applyDiscountAmount = useCallback(() => {
const total = formData.details.reduce((s, l) => s + toNum(l.amount), 0)
const discountVal = Number(discountInput) || 0
const updated = formData.details.map(line => {
const proportion = total > 0 ? toNum(line.amount) / total : 0
const lineDiscount = Math.round(discountVal * proportion)
return {
...line,
discountRate: total > 0 ? String(Math.round((lineDiscount / toNum(line.amount)) * 100 * 100) / 100) : '0',
discountAmount: String(lineDiscount),
}
})
setDetails(updated)
}, [discountInput, formData.details, setDetails])
13.5 Cột Tỷ Lệ CK (%) & Tiền CK Trong Bảng
// Chỉ hiển thị khi discountType !== 'none'
...(formData.discountType !== 'none' ? [
{
key: 'discountRate', label: 'Tỷ lệ CK (%)', width: '100px',
render: (v, _r, idx) => {
const readOnly = isReadOnly || formData.discountType !== 'byItem'
return readOnly
? <span className='block text-right text-[13px]'>{String(v || '0')}</span>
: <Input type='text' inputMode='numeric'
value={String(v ?? '')}
onChange={e => {
const rate = e.target.value.replace(/[^0-9.]/g, '')
const line = formData.details[idx]
updateLineField(idx, 'discountRate', rate)
updateLineField(idx, 'discountAmount', String(Math.round(toNum(line.amount) * Number(rate || '0')) / 100))
}}
className='h-[26px] px-2 text-[13px] text-right rounded-lg border border-[#B7BCC3] bg-white text-black focus-visible:ring-0'
data-qa={`i_line_discount_rate_${idx}`} />
},
},
{
key: 'discountAmount', label: 'Tiền chiết khấu', width: '150px', type: 'display',
render: (v) => <span className='block text-right text-[13px]'>{formatNumber(Number(v) || 0)}</span>,
},
{
key: 'discountAccountId', label: 'TK chiết khấu', width: '180px',
render: (_v, row, idx) => isReadOnly
? <span className='text-[13px]'>{String(row.discountAccountNumber ?? '')}</span>
: <TableSearchCombobox
value={String(row.discountAccountId ?? '')}
initialLabel={String(row.discountAccountNumber ?? '')}
displayField='accountNumber'
onChange={(id, rd) => {
updateLine(idx, {
discountAccountId: id,
discountAccountNumber: rd.accountNumber || '',
})
}}
loadOptions={loadTaiKhoan}
columns={taiKhoanColumns}
dataQa={`sel_line_discount_account_${idx}`} />,
},
] : []),
13.6 TK Chiết Khấu — Default 5111, Resolve ID Qua TKApiService
// Trong hook useXxxDlgForm:
import { TKApiService } from '@/modules/KetoanApp/features/danh-muc/tai-khoan/services'
const resolveAccountNumber = useCallback(async (accountNumber: string): Promise<{ id: string; number: string } | null> => {
if (!accountNumber) return null
try {
const tkRes = await TKApiService.list({ pageIndex: 1, pageSize: 100, keyword: accountNumber })
if (tkRes.success && tkRes.data?.items) {
const found = tkRes.data.items.find(tk => tk.accountNumber === accountNumber)
if (found) return { id: found.id, number: found.accountNumber }
}
} catch { /* ignore */ }
return null
}, [])
// Trong initCreate:
const discountAccount = await resolveAccountNumber('5111')
const defaultLine: XxxLineFormState = {
...XXX_INITIAL_LINE,
discountAccountId: discountAccount?.id ?? '',
discountAccountNumber: discountAccount?.number ?? '',
}
// Trong dialog — loadTaiKhoan cho TableSearchCombobox:
const taiKhoanColumns: TableComboboxColumn[] = [
{ field: 'accountNumber', title: 'Số TK', width: 100 },
{ field: 'accountName', title: 'Tên tài khoản', width: 200 },
]
const loadTaiKhoan = async (keyword: string): Promise<TableComboboxRow[]> => {
try {
const r = await TKApiService.list({ pageIndex: 1, pageSize: 20, keyword })
if (r.success && r.data) {
return r.data.items.map(tk => ({
value: tk.id,
cells: {
accountNumber: tk.accountNumber ?? '',
accountName: tk.accountName ?? '',
},
}))
}
} catch { /* ignore */ }
return []
}
13.7 Checklist Chiết Khấu
-
DISCOUNT_TYPE_LABELScó đủ 4 option:none,byItem,percent,amount - Default
discountType = 'none'trongXXX_INITIAL_FORM - Khi
discountType !== 'none'→ bảng hiển thị thêm cột: Tỷ lệ CK (%), Tiền chiết khấu, TK chiết khấu - Cột
discountRatereadonly khipercenthoặcamount, editable khibyItem -
percent— input % bên cạnh select, onBlur apply xuống toàn bộ dòng -
amount— input số tiền bên cạnh select, onBlur chia theo tỷ lệ thành tiền từng dòng - TK chiết khấu default
5111, resolve ID quaTKApiService.list({ keyword: '5111' }) -
TKApiService.listKHÔNG truyềnisActive -
discountAccountIdvàdiscountAccountNumbercó trongCTBHLineFormState -
discountAccountIdcó default trongXXX_INITIAL_LINEsau khi resolve -
discountAccountIdcó trongbuildFormDatakhi edit - Dòng "Chiết khấu" hiển thị dưới "Tổng tiền hàng" khi
discountType !== 'none'(xem 13.8)
13.8 Dòng Chiết Khấu Dưới Tổng Tiền Hàng
Áp dụng cho: Mọi dialog có chiết khấu hóa đơn — khi
discountType !== 'none', PHẢI hiển thị dòng "Chiết khấu" bên dưới "Tổng tiền hàng" trong khu vực tổng kết bên phải.
{/* Tổng tiền hàng / Chiết khấu / Tổng tiền thanh toán */}
<div className='w-[260px] flex-shrink-0 space-y-1.5 pt-1'>
<div className='flex items-center justify-between text-[13px]'>
<span className='text-black'>Tổng tiền hàng</span>
<span className='text-black'>{formatNumber(totalGoods)}</span>
</div>
{formData.discountType !== 'none' && (
<div className='flex items-center justify-between text-[13px]'>
<span className='text-black'>Chiết khấu</span>
<span className='text-black'>-{formatNumber(totalDiscount)}</span>
</div>
)}
<div className='flex items-center justify-between text-[13px]'>
<span className='font-semibold text-black'>Tổng tiền thanh toán</span>
<span className='font-bold text-black'>{formatNumber(totalAmount)}</span>
</div>
</div>
Công thức tính trong hook:
const totalGoods = formData.details.reduce((s, l) => s + num(l.amount), 0)
const totalTax = formData.details.reduce((s, l) => s + num(l.taxAmount), 0)
const totalDiscount = formData.details.reduce((s, l) => s + num(l.discountAmount), 0)
const totalAmount = totalGoods + totalTax - totalDiscount
Quy tắc:
- Dòng "Chiết khấu" CHỈ hiển thị khi
discountType !== 'none' - Số tiền chiết khấu hiển thị dạng
-{formatNumber(totalDiscount)}(có dấu trừ) - Tổng tiền thanh toán = Tổng tiền hàng + Tổng thuế - Tổng chiết khấu
- Vị trí: giữa dòng "Tổng tiền hàng" và "Tổng tiền thanh toán"
14. Quy Tắc Payload — Chỉ Gửi Field Có Giá Trị & Đúng Tab Ngữ Cảnh
Nguyên tắc cốt lõi: Payload gửi lên BE CHỈ chứa các field thực sự có giá trị (non-null, non-empty) và thuộc về tab/card đang active. Field null/rỗng hoặc thuộc tab không active → KHÔNG có trong payload.
14.1 Hai Lớp Lọc
| Lớp | Quy tắc | Ví dụ |
|---|---|---|
| Lớp 1: Null/Empty | Field có giá trị null, undefined, '', 0 (với string) → không gửi |
description: '' → omit |
| Lớp 2: Tab ngữ cảnh | Field chỉ tồn tại trên tab/card KHÔNG active → không gửi | Tab "Chứng từ ghi nợ" không active → không gửi description |
14.2 Pattern buildPayload Chuẩn
const buildPayload = (): CreateXxxRequest => {
// ── Các field chung (luôn có) ──
const payload: CreateXxxRequest = {
refNo: formData.refNo || undefined,
refDate: formData.refDate,
// ...
}
// ── Field theo tab ngữ cảnh ──
// Tab A (VD: Chứng từ ghi nợ)
if (formData.paymentStatus === 'unpaid') {
payload.description = formData.description || undefined
payload.paymentTermId = formData.paymentTermId || undefined
}
// Tab B (VD: Phiếu thu)
if (formData.paymentStatus === 'paid') {
if (formData.paymentMethod === BankTransfer) {
payload.bankAccountId = formData.bankAccountId || undefined
}
}
return payload
}
⚠️ TUYỆT ĐỐI KHÔNG gửi field từ 2 tab xung đột trong cùng 1 payload.
VD: Không thể gửi đồng thờidescription(Chứng từ ghi nợ) vàbankAccountId(Thu tiền gửi) vì chúng thuộc về 2 ngữ cảnhpaymentStatuskhác nhau.
14.3 Ví Dụ CTBHDialog — Ma Trận Field Theo Tab
| Tab | Điều kiện | Field đặc thù |
|---|---|---|
| Chứng từ ghi nợ | paymentStatus === 'unpaid' |
description, paymentTermId |
| Phiếu thu | paymentStatus === 'paid' && paymentMethod === Cash |
(dùng field chung) |
| Thu tiền gửi | paymentStatus === 'paid' && paymentMethod === BankTransfer |
bankAccountId |
| Phiếu xuất | checkOutward === true |
(field trong outwardReferences[]) |
| Hóa đơn | attachInvoice === true |
invoiceStatus, invoiceNumber, invoiceSerial, invoiceDate, eInvoiceType |
14.4 Logic Xung Đột Tab — Mutual Exclusivity
Quy tắc: Các tab thay đổi nội dung dựa trên cùng 1 biến trạng thái thì field của chúng xung đột — không thể cùng tồn tại trong payload.
Tab thứ nhất (slot chính) thay đổi theo paymentStatus:
paymentStatus === 'unpaid' → "Chứng từ ghi nợ"
paymentStatus === 'paid' → "Phiếu thu" hoặc "Thu tiền gửi"
→ Field của "Chứng từ ghi nợ" và "Phiếu thu" KHÔNG thể cùng tồn tại
→ Payload CHỈ chứa field của tab hiện tại
14.5 Checklist Payload
- Field null/empty → omit: dùng
|| undefinedcho string, kiểm tra truthy trước khi gán - Field tab-specific → condition: mỗi field có điều kiện tab rõ ràng trước khi đưa vào payload
- Tab xung đột → không trộn: field từ 2 tab dùng chung 1 slot KHÔNG cùng xuất hiện
- Field UI-only → không gửi: field chỉ có trong FormState nhưng không có trong Request DTO → bỏ qua
15. Quy Tắc Resolve TK Từ Mã Hàng Trong Bảng Data (BẮT BUỘC)
Áp dụng cho: Mọi dialog nghiệp vụ có bảng hàng hóa (
EditableDataTable) với cột TK (Nợ/Có/Trả lại/Công nợ/Chiết khấu...). Khi user chọn mã hàng, hệ thống có thể biết được số TK (number) từ thông tin hàng hóa (VD:returnAccountCode,saleAccountCode...). Chỉ biết NUMBER, KHÔNG biết ID → PHẢI resolve number → ID quaresolveAccountNumber.
15.1 Nguyên Tắc Cốt Lõi
| Bước | Mô tả |
|---|---|
| 1. Chọn mã hàng | TableSearchCombobox onChange → nhận rd chứa returnAccountCode, saleAccountCode... |
| 2. Resolve number → ID | Gọi resolveAccountNumber(accountNumber) → lấy { id, number } |
| 3. Chỉ hiển thị number khi có ID | Combobox initialLabel chỉ set accountNumber khi đã resolve được accountId |
| 4. Payload chỉ gửi ID | debitAccountId / creditAccountId / discountAccountId — KHÔNG gửi debitAccountNumber / creditAccountNumber |
15.2 resolveAccountNumber — Bắt Buộc Có Trong Hook
// Trong useXxxDlgForm:
import { TKApiService } from '@/modules/KetoanApp/features/danh-muc/tai-khoan/services'
/** Resolve số tài khoản → ID. KHÔNG truyền isActive. */
const resolveAccountNumber = useCallback(async (accountNumber: string): Promise<{ id: string; number: string } | null> => {
if (!accountNumber) return null
try {
const tkRes = await TKApiService.list({ pageIndex: 1, pageSize: 100, keyword: accountNumber })
if (tkRes.success && tkRes.data?.items) {
const found = tkRes.data.items.find(tk => tk.accountNumber === accountNumber)
if (found) return { id: found.id, number: found.accountNumber }
}
} catch { /* ignore */ }
return null
}, [])
⚠️ TUYỆT ĐỐI KHÔNG truyền
isActivevàoTKApiService.list— TK đã ngừng vẫn có thể được dùng trong các phiếu cũ.
15.3 Dialog — onChange Chọn Mã Hàng (Resolve Async)
// Trong dialog, onChange của TableSearchCombobox chọn mã hàng:
onChange={(id, rd) => {
// ── Set các field cơ bản từ hàng hóa ──
updateLine(idx, {
inventoryItemId: id,
itemCode: rd.code || '',
inventoryItemName: rd.name || '',
unit: rd.unit || '',
unitPrice: rd.unitPrice || String(row.unitPrice ?? ''),
industryGroupCode: rd.industryGroupCode || '',
// ── Xóa TK cũ khi chọn hàng mới (đợi resolve xong mới fill) ──
debitAccountId: '',
debitAccountNumber: '',
creditAccountId: '',
creditAccountNumber: '',
})
// ── Resolve TK trả lại (returnAccountCode) từ mã hàng ──
if (rd.returnAccountCode) {
resolveAccountNumber(rd.returnAccountCode).then(resolved => {
if (resolved) {
updateLine(idx, {
debitAccountId: resolved.id,
debitAccountNumber: resolved.number,
})
}
// Nếu resolve thất bại → để trống (KHÔNG hiển thị number khi chưa có ID)
})
}
// ── Nếu có thêm TK khác từ hàng hóa (VD: saleAccountCode, purchaseAccountCode...) ──
// Resolve tương tự như trên
}}
⚠️ QUY TẮC QUAN TRỌNG: Phải xóa
debitAccountId/debitAccountNumbercũ trước khi resolve mới. Nếu resolve thất bại → để trống, KHÔNG hiển thị số TK cũ hoặc số ảo.
15.4 Combobox TK Trong Bảng Data — Chỉ Hiển Thị Number Khi Có ID
// TableSearchCombobox cho cột TK trong bảng data:
<TableSearchCombobox
value={String(row.debitAccountId ?? '')}
initialLabel={String(row.debitAccountNumber ?? '')}
displayField='accountNumber'
onChange={(id, rd) => updateLine(idx, {
debitAccountId: id,
debitAccountNumber: rd.accountNumber || '',
})}
loadOptions={loadTaiKhoanTable}
columns={taiKhoanColumns}
dataQa={`sel_tk_${idx}`}
/>
Logic:
value=debitAccountId(ID rỗng → combobox rỗng).initialLabel=debitAccountNumber(chỉ có giá trị khi ID đã được resolve). Khi user tự chọn TK từ combobox,onChangecập nhật cả ID và number.
15.5 Payload — Chỉ Gửi ID, Không Gửi Number
// ✅ ĐÚNG: buildCreatePayload — CHỈ gửi debitAccountId / creditAccountId
details: formData.details.filter(line => !line.isNote).map(line => ({
inventoryItemId: line.inventoryItemId || null,
// ...các field khác...
debitAccountId: line.debitAccountId || null, // ← Gửi ID
creditAccountId: line.creditAccountId || null, // ← Gửi ID
// KHÔNG gửi debitAccountNumber / creditAccountNumber
}))
// ❌ SAI: Gửi accountNumber
details: formData.details.map(line => ({
debitAccountNumber: line.debitAccountNumber || null, // ← SAI
creditAccountNumber: line.creditAccountNumber || null, // ← SAI
}))
15.6 BuildFormData Khi Edit — Resolve TK Number → ID
// Trong buildFormData của hook:
const buildFormData = useCallback(async (d: XxxDetail): Promise<XxxFormState> => {
// ── Thu thập tất cả accountNumber cần resolve ──
const accountNumbers = [...new Set(
(d.details ?? []).flatMap(line => [
line.debitAccountNumber,
line.creditAccountNumber,
line.discountAccountNumber,
].filter((n): n is string => !!n))
)]
// Resolve song song
const resolutions = await Promise.all(
accountNumbers.map(num => resolveAccountNumber(num).catch(() => null))
)
const accountIdMap = new Map<string, string>()
accountNumbers.forEach((num, i) => {
if (resolutions[i]) accountIdMap.set(num, resolutions[i]!.id)
})
return {
// ...
details: (d.details ?? []).map(line => ({
// ...
debitAccountNumber: line.debitAccountNumber ?? '',
debitAccountId: line.debitAccountNumber
? (accountIdMap.get(line.debitAccountNumber) ?? '')
: '',
creditAccountNumber: line.creditAccountNumber ?? '',
creditAccountId: line.creditAccountNumber
? (accountIdMap.get(line.creditAccountNumber) ?? '')
: '',
})),
}
}, [resolveAccountNumber])
15.7 Kiểm Tra Interface Trước Khi Gửi ID
Trước khi dùng
debitAccountId/creditAccountIdtrong payload, PHẢI kiểm tra request DTO đã có field đó chưa.
B1: Kiểm tra interface request DTO (CreateXxxRequest / UpdateXxxRequest)
B2: Có field debitAccountId và creditAccountId trong details?
├── CÓ → Tiếp tục, gửi payload với debitAccountId / creditAccountId
└── KHÔNG → DỪNG LẠI, báo user:
"Interface {TenDTO} chưa có debitAccountId/creditAccountId.
Vui lòng yêu cầu BE bổ sung các field này vào request DTO."
→ Sau đó THÊM field optional vào types FE để chuẩn bị sẵn.
15.8 Checklist TK Từ Mã Hàng
- Hook có
resolveAccountNumberdùngTKApiService.list(KHÔNG truyềnisActive) -
onChangechọn mã hàng: xóa TK cũ, resolve asyncreturnAccountCode→ ID - Chỉ set
debitAccountNumberkhidebitAccountIdđã được resolve thành công - Nếu resolve thất bại → để trống cả ID và number, KHÔNG hiển thị số ảo
- Combobox TK:
value={debitAccountId},initialLabel={debitAccountNumber},displayField='accountNumber' - Payload
buildCreatePayload: CHỈ gửidebitAccountId/creditAccountId, KHÔNG gửi number -
buildFormData: resolve tất cả accountNumber → ID khi edit - Interface DTO đã có
debitAccountId?/creditAccountId?trong details - Nếu DTO chưa có → báo user + thêm optional field vào types FE
9. File Tham Khảo
| File | Mô tả |
|---|---|
src/modules/KetoanApp/features/nghiep-vu/tien-mat/phieu-thu/pages/PhieuThuPage.tsx |
Pattern chuẩn cho Phiếu thu |
src/modules/KetoanApp/features/nghiep-vu/tien-mat/phieu-chi/pages/PhieuChiPage.tsx |
Pattern chuẩn cho Phiếu chi |
src/modules/KetoanApp/features/nghiep-vu/tien-mat/phieu-thu/dialogs/PTDialog.tsx |
Dialog full-screen mẫu (Phiếu thu) |
src/modules/KetoanApp/features/nghiep-vu/tien-mat/phieu-thu/hooks/usePT.dlg.form.ts |
Hook form mẫu |
src/modules/KetoanApp/features/nghiep-vu/tien-gui/dialogs/TGFormDialog.tsx |
Dialog full-screen mẫu (Tiền gửi: thu/chi/chuyển nội bộ) |
src/modules/KetoanApp/features/nghiep-vu/tien-gui/phieu-chi/pages/TGChiPage.tsx |
Pattern chuẩn cho Tiền gửi phiếu chi |
src/shared/constants/table-settings.const.ts |
Định nghĩa PT_ALL_COLUMNS, PC_ALL_COLUMNS |