[CLAUDE] FE: Admin CatalogsPage CRUD + Details form datalist autocomplete
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m36s

## fe-admin

### CatalogsPage.tsx (mới)

1 page generic CRUD cho 4 master catalogs (units / materials / services /
work-items). Dispatch theo URL param :kind:
- /master/catalogs (redirect units)
- /master/catalogs/:kind (units|materials|services|work-items)

Sub-tabs ở top chuyển nhanh giữa 4 kind. Mỗi kind có FIELDS_BY_TYPE config
riêng (3-7 field). Form dialog nested với input/textarea/checkbox theo type.

### Layout.tsx + App.tsx

- resolvePath thêm 4 CatalogXxx → /master/catalogs/{kind}
- Route /master/catalogs/:kind → CatalogsPage

## fe-user + fe-admin (mirror)

### ContractDetailsTab.tsx

Datalist autocomplete cho Add row form:
- Fetch 4 catalogs via TanStack Query (cache shared key 'catalogs')
- Mỗi field config thêm optional `datalist` + `datalistField` ('code'|'name')
- HTML5 <datalist> render options theo type:
  - ThauPhu: hangMuc → work-items, donViTinh → units
  - GiaoKhoan: maCongViec/tenCongViec → work-items, donViTinh → units
  - NhaCungCap/MuaBan: maSP/tenSP → materials, donViTinh → units
  - DichVu: maDichVu/tenDichVu → services, donViTinh → units
  - NguyenTacNcc: tenSP → materials, donViTinh → units
  - NguyenTacDv: tenDichVu → services, donViTinh → units

Smart-fill (handleFieldChange):
- User pick value khớp catalog → autofill sibling fields cùng catalog:
  - Field name 'maXxx' → fill code; 'tenXxx'/'hangMuc' → fill name
  - donViTinh nếu chưa có giá trị → fill từ defaultUnit của catalog item

Vẫn cho user gõ tự do (free text) — datalist chỉ là suggestion.

## Build

- fe-user: tsc + vite pass (5.69s)
- fe-admin: tsc + vite pass (633ms + 15.84s lúc test CatalogsPage lần đầu)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-23 12:27:41 +07:00
parent e27c54702a
commit 16e24ed962
5 changed files with 596 additions and 80 deletions

View File

@ -347,6 +347,31 @@ function AddRowFields({
}) {
const [form, setForm] = useState<Record<string, string>>({})
// Load 4 catalogs cho datalist autocomplete (1 lần, cache TanStack)
const units = useQuery({
queryKey: ['catalogs', 'units'],
queryFn: async () => (await api.get<CatalogItem[]>('/catalogs/units')).data,
})
const materials = useQuery({
queryKey: ['catalogs', 'materials'],
queryFn: async () => (await api.get<CatalogItem[]>('/catalogs/materials')).data,
})
const services = useQuery({
queryKey: ['catalogs', 'services'],
queryFn: async () => (await api.get<CatalogItem[]>('/catalogs/services')).data,
})
const workItems = useQuery({
queryKey: ['catalogs', 'work-items'],
queryFn: async () => (await api.get<CatalogItem[]>('/catalogs/work-items')).data,
})
const catalogData: Record<NonNullable<FieldDef['datalist']>, CatalogItem[]> = {
units: units.data ?? [],
materials: materials.data ?? [],
services: services.data ?? [],
'work-items': workItems.data ?? [],
}
const submit = useMutation({
mutationFn: async () => {
const payload = buildPayload(contractType, nextOrder, form)
@ -358,26 +383,74 @@ function AddRowFields({
const fields = FIELDS_BY_TYPE[contractType] ?? []
// Smart-fill: khi user pick value khớp catalog item, autofill các field
// liên quan (defaultUnit cho donViTinh, name cho codeField siblings).
function handleFieldChange(name: string, value: string) {
setForm(s => {
const next = { ...s, [name]: value }
const fieldDef = fields.find(f => f.name === name)
if (!fieldDef?.datalist) return next
const items = catalogData[fieldDef.datalist]
// Match theo `code` hoặc `name` (user có thể type either)
const match = items.find(it => it.code === value || it.name === value)
if (!match) return next
// Auto-fill sibling fields nếu trống
// - Nếu user pick code → fill name (sibling field same datalist)
// - Nếu sibling field name 'donViTinh' chưa có giá trị → fill defaultUnit
for (const sibling of fields) {
if (sibling.name === name) continue
if (sibling.datalist === fieldDef.datalist && !next[sibling.name]) {
// Sibling cùng catalog — fill code/name correlate
if (sibling.name.startsWith('ma') || sibling.name === 'maSP' || sibling.name === 'maCongViec' || sibling.name === 'maDichVu') {
next[sibling.name] = match.code
} else if (sibling.name.startsWith('ten') || sibling.name === 'tenSP' || sibling.name === 'hangMuc' || sibling.name === 'tenCongViec' || sibling.name === 'tenDichVu') {
next[sibling.name] = match.name
}
}
if (sibling.name === 'donViTinh' && !next.donViTinh && match.defaultUnit) {
next.donViTinh = match.defaultUnit
}
}
return next
})
}
return (
<form
onSubmit={e => { e.preventDefault(); submit.mutate() }}
className="space-y-2 rounded-lg border border-brand-200 bg-brand-50/30 p-3"
>
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4">
{fields.map(f => (
<div key={f.name} className="space-y-1">
<label className="text-[11px] font-medium text-slate-600">{f.label}</label>
<Input
type={f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'}
value={form[f.name] ?? ''}
onChange={e => setForm(s => ({ ...s, [f.name]: e.target.value }))}
placeholder={f.placeholder}
step={f.type === 'number' ? 'any' : undefined}
required={f.required}
className="text-xs"
/>
</div>
))}
{fields.map(f => {
const datalistId = f.datalist ? `dl-${f.datalist}-${f.name}` : undefined
const items = f.datalist ? catalogData[f.datalist] : []
return (
<div key={f.name} className="space-y-1">
<label className="text-[11px] font-medium text-slate-600">{f.label}</label>
<Input
type={f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'}
value={form[f.name] ?? ''}
onChange={e => handleFieldChange(f.name, e.target.value)}
placeholder={f.placeholder}
step={f.type === 'number' ? 'any' : undefined}
required={f.required}
className="text-xs"
list={datalistId}
/>
{datalistId && items.length > 0 && (
<datalist id={datalistId}>
{items.map(it => (
<option key={it.id} value={f.datalistField === 'code' ? it.code : it.name}>
{f.datalistField === 'code' ? it.name : it.code}
</option>
))}
</datalist>
)}
</div>
)
})}
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>Hủy</Button>
@ -389,60 +462,81 @@ function AddRowFields({
)
}
type FieldDef = { name: string; label: string; type: 'text' | 'number' | 'date'; required?: boolean; placeholder?: string }
type CatalogItem = {
id: string
code: string
name: string
defaultUnit?: string | null
category?: string | null
}
type FieldDef = {
name: string
label: string
type: 'text' | 'number' | 'date'
required?: boolean
placeholder?: string
/** Catalog source cho datalist autocomplete */
datalist?: 'units' | 'materials' | 'services' | 'work-items'
/** Field nào của catalog item là value: 'code' hoặc 'name' (default: 'name') */
datalistField?: 'code' | 'name'
}
// Per-type field config + datalist source. User type/pick → smart-fill sibling
// fields qua handleFieldChange (vd pick MaSP từ materials → autofill TenSP +
// donViTinh từ defaultUnit).
const FIELDS_BY_TYPE: Record<number, FieldDef[]> = {
1: [ // ThauPhu
{ name: 'hangMuc', label: 'Hạng mục *', type: 'text', required: true },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, placeholder: 'm2, kg, ngày...' },
1: [ // ThauPhu — autocomplete WorkItems + Units
{ name: 'hangMuc', label: 'Hạng mục *', type: 'text', required: true, datalist: 'work-items', datalistField: 'name' },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, placeholder: 'm2, kg...', datalist: 'units', datalistField: 'code' },
{ name: 'khoiLuong', label: 'Khối lượng *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
{ name: 'thoiGianHoanThanh', label: 'Hoàn thành', type: 'date' },
{ name: 'ghiChu', label: 'Ghi chú', type: 'text' },
],
2: [ // GiaoKhoan
{ name: 'maCongViec', label: 'Mã CV *', type: 'text', required: true },
{ name: 'tenCongViec', label: 'Tên công việc *', type: 'text', required: true },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true },
2: [ // GiaoKhoan — autocomplete WorkItems + Units
{ name: 'maCongViec', label: 'Mã CV *', type: 'text', required: true, datalist: 'work-items', datalistField: 'code' },
{ name: 'tenCongViec', label: 'Tên công việc *', type: 'text', required: true, datalist: 'work-items', datalistField: 'name' },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' },
{ name: 'khoiLuong', label: 'KL *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
{ name: 'thoiGianHoanThanh', label: 'Hoàn thành', type: 'date' },
],
3: [ // NhaCungCap
{ name: 'maSP', label: 'Mã SP *', type: 'text', required: true },
{ name: 'tenSP', label: 'Tên SP *', type: 'text', required: true },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true },
3: [ // NhaCungCap — autocomplete Materials + Units
{ name: 'maSP', label: 'Mã SP *', type: 'text', required: true, datalist: 'materials', datalistField: 'code' },
{ name: 'tenSP', label: 'Tên SP *', type: 'text', required: true, datalist: 'materials', datalistField: 'name' },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' },
{ name: 'soLuong', label: 'SL *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
{ name: 'thoiGianGiao', label: 'Giao hàng', type: 'date' },
{ name: 'xuatXu', label: 'Xuất xứ', type: 'text' },
],
4: [ // DichVu
{ name: 'maDichVu', label: 'Mã DV *', type: 'text', required: true },
{ name: 'tenDichVu', label: 'Tên DV *', type: 'text', required: true },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true },
4: [ // DichVu — autocomplete Services + Units
{ name: 'maDichVu', label: 'Mã DV *', type: 'text', required: true, datalist: 'services', datalistField: 'code' },
{ name: 'tenDichVu', label: 'Tên DV *', type: 'text', required: true, datalist: 'services', datalistField: 'name' },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' },
{ name: 'thoiGian', label: 'Thời gian *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
],
5: [ // MuaBan
{ name: 'maSP', label: 'Mã SP *', type: 'text', required: true },
{ name: 'tenSP', label: 'Tên SP *', type: 'text', required: true },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true },
5: [ // MuaBan — autocomplete Materials + Units
{ name: 'maSP', label: 'Mã SP *', type: 'text', required: true, datalist: 'materials', datalistField: 'code' },
{ name: 'tenSP', label: 'Tên SP *', type: 'text', required: true, datalist: 'materials', datalistField: 'name' },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' },
{ name: 'soLuong', label: 'SL *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
{ name: 'thueVAT', label: 'VAT (%)', type: 'number', placeholder: '10' },
],
6: [ // NguyenTacNcc
6: [ // NguyenTacNcc — autocomplete Materials + Units
{ name: 'nhomSP', label: 'Nhóm SP *', type: 'text', required: true },
{ name: 'tenSP', label: 'Tên SP *', type: 'text', required: true },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true },
{ name: 'tenSP', label: 'Tên SP *', type: 'text', required: true, datalist: 'materials', datalistField: 'name' },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' },
{ name: 'donGiaToiThieu', label: 'Giá min *', type: 'number', required: true },
{ name: 'donGiaToiDa', label: 'Giá max *', type: 'number', required: true },
],
7: [ // NguyenTacDv
7: [ // NguyenTacDv — autocomplete Services + Units
{ name: 'loaiDichVu', label: 'Loại DV *', type: 'text', required: true },
{ name: 'tenDichVu', label: 'Tên DV *', type: 'text', required: true },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true },
{ name: 'tenDichVu', label: 'Tên DV *', type: 'text', required: true, datalist: 'services', datalistField: 'name' },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' },
{ name: 'donGiaToiThieu', label: 'Giá min *', type: 'number', required: true },
{ name: 'donGiaToiDa', label: 'Giá max *', type: 'number', required: true },
],