[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

@ -8,6 +8,7 @@ import { DashboardPage } from '@/pages/DashboardPage'
import { SuppliersPage } from '@/pages/master/SuppliersPage' import { SuppliersPage } from '@/pages/master/SuppliersPage'
import { ProjectsPage } from '@/pages/master/ProjectsPage' import { ProjectsPage } from '@/pages/master/ProjectsPage'
import { DepartmentsPage } from '@/pages/master/DepartmentsPage' import { DepartmentsPage } from '@/pages/master/DepartmentsPage'
import { CatalogsPage } from '@/pages/master/CatalogsPage'
import { PermissionsPage } from '@/pages/system/PermissionsPage' import { PermissionsPage } from '@/pages/system/PermissionsPage'
import { WorkflowsPage } from '@/pages/system/WorkflowsPage' import { WorkflowsPage } from '@/pages/system/WorkflowsPage'
import { FormsPage } from '@/pages/forms/FormsPage' import { FormsPage } from '@/pages/forms/FormsPage'
@ -34,6 +35,8 @@ function App() {
<Route path="/master/suppliers" element={<SuppliersPage />} /> <Route path="/master/suppliers" element={<SuppliersPage />} />
<Route path="/master/projects" element={<ProjectsPage />} /> <Route path="/master/projects" element={<ProjectsPage />} />
<Route path="/master/departments" element={<DepartmentsPage />} /> <Route path="/master/departments" element={<DepartmentsPage />} />
<Route path="/master/catalogs" element={<Navigate to="/master/catalogs/units" replace />} />
<Route path="/master/catalogs/:kind" element={<CatalogsPage />} />
<Route path="/system/users" element={<UsersPage />} /> <Route path="/system/users" element={<UsersPage />} />
<Route path="/system/permissions" element={<PermissionsPage />} /> <Route path="/system/permissions" element={<PermissionsPage />} />
<Route path="/system/workflows" element={<WorkflowsPage />} /> <Route path="/system/workflows" element={<WorkflowsPage />} />

View File

@ -40,6 +40,10 @@ function resolvePath(key: string): string | null {
Roles: '/system/roles', Roles: '/system/roles',
Permissions: '/system/permissions', Permissions: '/system/permissions',
Workflows: '/system/workflows', Workflows: '/system/workflows',
CatalogUnits: '/master/catalogs/units',
CatalogMaterials: '/master/catalogs/materials',
CatalogServices: '/master/catalogs/services',
CatalogWorkItems: '/master/catalogs/work-items',
} }
if (staticMap[key]) return staticMap[key] if (staticMap[key]) return staticMap[key]

View File

@ -347,6 +347,31 @@ function AddRowFields({
}) { }) {
const [form, setForm] = useState<Record<string, string>>({}) 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({ const submit = useMutation({
mutationFn: async () => { mutationFn: async () => {
const payload = buildPayload(contractType, nextOrder, form) const payload = buildPayload(contractType, nextOrder, form)
@ -358,26 +383,74 @@ function AddRowFields({
const fields = FIELDS_BY_TYPE[contractType] ?? [] 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 ( return (
<form <form
onSubmit={e => { e.preventDefault(); submit.mutate() }} onSubmit={e => { e.preventDefault(); submit.mutate() }}
className="space-y-2 rounded-lg border border-brand-200 bg-brand-50/30 p-3" 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"> <div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4">
{fields.map(f => ( {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"> <div key={f.name} className="space-y-1">
<label className="text-[11px] font-medium text-slate-600">{f.label}</label> <label className="text-[11px] font-medium text-slate-600">{f.label}</label>
<Input <Input
type={f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'} type={f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'}
value={form[f.name] ?? ''} value={form[f.name] ?? ''}
onChange={e => setForm(s => ({ ...s, [f.name]: e.target.value }))} onChange={e => handleFieldChange(f.name, e.target.value)}
placeholder={f.placeholder} placeholder={f.placeholder}
step={f.type === 'number' ? 'any' : undefined} step={f.type === 'number' ? 'any' : undefined}
required={f.required} required={f.required}
className="text-xs" className="text-xs"
list={datalistId}
/> />
</div> {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>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>Hủy</Button> <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[]> = { const FIELDS_BY_TYPE: Record<number, FieldDef[]> = {
1: [ // ThauPhu 1: [ // ThauPhu — autocomplete WorkItems + Units
{ name: 'hangMuc', label: 'Hạng mục *', type: 'text', required: true }, { 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, ngày...' }, { 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: 'khoiLuong', label: 'Khối lượng *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true }, { name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
{ name: 'thoiGianHoanThanh', label: 'Hoàn thành', type: 'date' }, { name: 'thoiGianHoanThanh', label: 'Hoàn thành', type: 'date' },
{ name: 'ghiChu', label: 'Ghi chú', type: 'text' }, { name: 'ghiChu', label: 'Ghi chú', type: 'text' },
], ],
2: [ // GiaoKhoan 2: [ // GiaoKhoan — autocomplete WorkItems + Units
{ name: 'maCongViec', label: 'Mã CV *', type: 'text', required: true }, { 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 }, { 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 }, { name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' },
{ name: 'khoiLuong', label: 'KL *', type: 'number', required: true }, { name: 'khoiLuong', label: 'KL *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true }, { name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
{ name: 'thoiGianHoanThanh', label: 'Hoàn thành', type: 'date' }, { name: 'thoiGianHoanThanh', label: 'Hoàn thành', type: 'date' },
], ],
3: [ // NhaCungCap 3: [ // NhaCungCap — autocomplete Materials + Units
{ name: 'maSP', label: 'Mã SP *', type: 'text', required: true }, { name: 'maSP', label: 'Mã SP *', type: 'text', required: true, datalist: 'materials', datalistField: 'code' },
{ name: 'tenSP', label: 'Tên SP *', 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 }, { name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' },
{ name: 'soLuong', label: 'SL *', type: 'number', required: true }, { name: 'soLuong', label: 'SL *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true }, { name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
{ name: 'thoiGianGiao', label: 'Giao hàng', type: 'date' }, { name: 'thoiGianGiao', label: 'Giao hàng', type: 'date' },
{ name: 'xuatXu', label: 'Xuất xứ', type: 'text' }, { name: 'xuatXu', label: 'Xuất xứ', type: 'text' },
], ],
4: [ // DichVu 4: [ // DichVu — autocomplete Services + Units
{ name: 'maDichVu', label: 'Mã DV *', type: 'text', required: true }, { name: 'maDichVu', label: 'Mã DV *', type: 'text', required: true, datalist: 'services', datalistField: 'code' },
{ name: 'tenDichVu', label: 'Tên DV *', 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 }, { name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' },
{ name: 'thoiGian', label: 'Thời gian *', type: 'number', required: true }, { name: 'thoiGian', label: 'Thời gian *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true }, { name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
], ],
5: [ // MuaBan 5: [ // MuaBan — autocomplete Materials + Units
{ name: 'maSP', label: 'Mã SP *', type: 'text', required: true }, { name: 'maSP', label: 'Mã SP *', type: 'text', required: true, datalist: 'materials', datalistField: 'code' },
{ name: 'tenSP', label: 'Tên SP *', 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 }, { name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' },
{ name: 'soLuong', label: 'SL *', type: 'number', required: true }, { name: 'soLuong', label: 'SL *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true }, { name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
{ name: 'thueVAT', label: 'VAT (%)', type: 'number', placeholder: '10' }, { 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: 'nhomSP', label: 'Nhóm SP *', type: 'text', required: true },
{ name: 'tenSP', label: 'Tên SP *', 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 }, { name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' },
{ name: 'donGiaToiThieu', label: 'Giá min *', type: 'number', required: true }, { name: 'donGiaToiThieu', label: 'Giá min *', type: 'number', required: true },
{ name: 'donGiaToiDa', label: 'Giá max *', 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: 'loaiDichVu', label: 'Loại DV *', type: 'text', required: true },
{ name: 'tenDichVu', label: 'Tên DV *', 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 }, { name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' },
{ name: 'donGiaToiThieu', label: 'Giá min *', type: 'number', required: true }, { name: 'donGiaToiThieu', label: 'Giá min *', type: 'number', required: true },
{ name: 'donGiaToiDa', label: 'Giá max *', type: 'number', required: true }, { name: 'donGiaToiDa', label: 'Giá max *', type: 'number', required: true },
], ],

View File

@ -0,0 +1,321 @@
// Generic master catalogs CRUD — 1 page handle 4 kind: units / materials /
// services / work-items. URL `/master/catalogs/:kind` driven, mỗi kind có
// fields config riêng. Sub-tabs hiển thị 4 kind trên cùng để chuyển nhanh.
import { useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useParams, useNavigate } from 'react-router-dom'
import { Library, Pencil, Plus, Ruler, Package, Wrench, ListChecks, Trash2, Search } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Textarea } from '@/components/ui/Textarea'
import { Dialog } from '@/components/ui/Dialog'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
type Kind = 'units' | 'materials' | 'services' | 'work-items'
type FieldDef = { key: string; label: string; type: 'text' | 'textarea' | 'checkbox'; required?: boolean; placeholder?: string }
const KIND_CONFIG: Record<Kind, {
label: string
icon: React.ComponentType<{ className?: string }>
fields: FieldDef[]
columns: string[]
}> = {
'units': {
label: 'Đơn vị tính',
icon: Ruler,
fields: [
{ key: 'code', label: 'Mã *', type: 'text', required: true, placeholder: 'm2, kg, ngc...' },
{ key: 'name', label: 'Tên *', type: 'text', required: true, placeholder: 'Mét vuông, Kilogram...' },
{ key: 'description', label: 'Mô tả', type: 'textarea' },
],
columns: ['Mã', 'Tên', 'Mô tả'],
},
'materials': {
label: 'Vật tư / SP',
icon: Package,
fields: [
{ key: 'code', label: 'Mã SP *', type: 'text', required: true, placeholder: 'XM-PCB40' },
{ key: 'name', label: 'Tên SP *', type: 'text', required: true, placeholder: 'Xi măng PCB40' },
{ key: 'category', label: 'Nhóm', type: 'text', placeholder: 'Xi măng, Sắt thép...' },
{ key: 'defaultUnit', label: 'ĐVT mặc định', type: 'text', placeholder: 'kg, m3...' },
{ key: 'specification', label: 'Thông số kỹ thuật', type: 'textarea' },
{ key: 'originCountry', label: 'Xuất xứ', type: 'text' },
{ key: 'isActive', label: 'Đang dùng', type: 'checkbox' },
],
columns: ['Mã', 'Tên', 'Nhóm', 'ĐVT', 'Trạng thái'],
},
'services': {
label: 'Dịch vụ',
icon: Wrench,
fields: [
{ key: 'code', label: 'Mã DV *', type: 'text', required: true, placeholder: 'VC-OTO' },
{ key: 'name', label: 'Tên DV *', type: 'text', required: true, placeholder: 'Vận chuyển ô tô tải' },
{ key: 'category', label: 'Loại', type: 'text', placeholder: 'Vận chuyển, Bảo trì...' },
{ key: 'defaultUnit', label: 'ĐVT mặc định', type: 'text', placeholder: 'ca, h, lan...' },
{ key: 'description', label: 'Mô tả', type: 'textarea' },
{ key: 'isActive', label: 'Đang dùng', type: 'checkbox' },
],
columns: ['Mã', 'Tên', 'Loại', 'ĐVT', 'Trạng thái'],
},
'work-items': {
label: 'Hạng mục công việc',
icon: ListChecks,
fields: [
{ key: 'code', label: 'Mã CV *', type: 'text', required: true, placeholder: 'DAO-MONG' },
{ key: 'name', label: 'Tên CV *', type: 'text', required: true, placeholder: 'Đào móng công trình' },
{ key: 'category', label: 'Nhóm', type: 'text', placeholder: 'Phần thô, Hoàn thiện...' },
{ key: 'defaultUnit', label: 'ĐVT mặc định', type: 'text', placeholder: 'm3, m2, kg...' },
{ key: 'description', label: 'Mô tả + spec yêu cầu', type: 'textarea' },
{ key: 'isActive', label: 'Đang dùng', type: 'checkbox' },
],
columns: ['Mã', 'Tên', 'Nhóm', 'ĐVT', 'Trạng thái'],
},
}
const KINDS: Kind[] = ['units', 'materials', 'services', 'work-items']
type CatalogRow = Record<string, unknown> & { id: string; code: string; name: string }
export function CatalogsPage() {
const navigate = useNavigate()
const params = useParams<{ kind?: string }>()
const kind = (KINDS.includes(params.kind as Kind) ? params.kind : 'units') as Kind
const config = KIND_CONFIG[kind]
const qc = useQueryClient()
const [search, setSearch] = useState('')
const [open, setOpen] = useState(false)
const [form, setForm] = useState<Record<string, unknown>>({})
const isEdit = !!form.id
const list = useQuery({
queryKey: ['catalogs', kind, search],
queryFn: async () => (await api.get<CatalogRow[]>(`/catalogs/${kind}`, { params: { q: search || undefined } })).data,
})
const save = useMutation({
mutationFn: async () => {
const body = buildBody(kind, form)
if (isEdit) await api.put(`/catalogs/${kind}/${form.id}`, { id: form.id, ...body })
else await api.post(`/catalogs/${kind}`, body)
},
onSuccess: () => {
toast.success(isEdit ? 'Đã lưu' : 'Đã thêm')
qc.invalidateQueries({ queryKey: ['catalogs', kind] })
setOpen(false)
setForm({})
},
onError: err => toast.error(getErrorMessage(err)),
})
const remove = useMutation({
mutationFn: async (id: string) => { await api.delete(`/catalogs/${kind}/${id}`) },
onSuccess: () => {
toast.success('Đã xóa')
qc.invalidateQueries({ queryKey: ['catalogs', kind] })
},
onError: err => toast.error(getErrorMessage(err)),
})
function openCreate() {
const init: Record<string, unknown> = {}
config.fields.forEach(f => { init[f.key] = f.type === 'checkbox' ? true : '' })
setForm(init)
setOpen(true)
}
function openEdit(row: CatalogRow) {
const init: Record<string, unknown> = { id: row.id }
config.fields.forEach(f => { init[f.key] = row[f.key] ?? (f.type === 'checkbox' ? false : '') })
setForm(init)
setOpen(true)
}
return (
<div className="p-6">
<PageHeader
title={
<span className="flex items-center gap-2">
<Library className="h-5 w-5" />
Danh mục chi tiết
</span>
}
description="Master data dùng cho phần Chi tiết HĐ — autocomplete khi user nhập line items."
/>
{/* Sub-tabs cho 4 kind */}
<div className="mb-4 flex gap-1 border-b border-slate-200">
{KINDS.map(k => {
const KIcon = KIND_CONFIG[k].icon
const active = k === kind
return (
<button
key={k}
onClick={() => navigate(`/master/catalogs/${k}`)}
className={cn(
'-mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm transition',
active
? 'border-brand-600 font-semibold text-brand-700'
: 'border-transparent text-slate-500 hover:text-slate-700',
)}
>
<KIcon className="h-4 w-4" />
{KIND_CONFIG[k].label}
</button>
)
})}
</div>
<div className="mb-3 flex gap-2">
<div className="relative max-w-md flex-1">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Tìm theo mã / tên…"
className="pl-8"
/>
</div>
<Button onClick={openCreate}>
<Plus className="h-4 w-4" />
Thêm {config.label.toLowerCase()}
</Button>
</div>
<div className="overflow-x-auto rounded-lg border border-slate-200 bg-white">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 text-[11px] uppercase tracking-wider text-slate-500">
<tr>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left">Tên</th>
{kind !== 'units' && <th className="px-3 py-2 text-left">Nhóm/Loại</th>}
{kind !== 'units' && <th className="px-3 py-2 text-left">ĐVT</th>}
{kind !== 'units' && <th className="px-3 py-2 text-left">Trạng thái</th>}
<th className="w-20 px-3 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{list.isLoading && (
<tr><td colSpan={6} className="p-6 text-center text-slate-400">Đang tải</td></tr>
)}
{!list.isLoading && (list.data?.length ?? 0) === 0 && (
<tr><td colSpan={6} className="p-6 text-center text-slate-400">Chưa dữ liệu bấm Thêm đ tạo mới.</td></tr>
)}
{list.data?.map(row => (
<tr key={row.id} className="hover:bg-slate-50">
<td className="px-3 py-2 font-mono text-xs">{row.code}</td>
<td className="px-3 py-2">{row.name}</td>
{kind !== 'units' && <td className="px-3 py-2 text-xs text-slate-600">{(row.category as string) ?? '—'}</td>}
{kind !== 'units' && <td className="px-3 py-2 text-xs text-slate-600">{(row.defaultUnit as string) ?? '—'}</td>}
{kind !== 'units' && (
<td className="px-3 py-2">
{(row.isActive as boolean) ? (
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] text-emerald-700">Đang dùng</span>
) : (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-500">Tạm tắt</span>
)}
</td>
)}
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
<button
onClick={() => openEdit(row)}
title="Sửa"
className="rounded p-1 text-slate-500 hover:bg-slate-100 hover:text-brand-600"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => {
if (confirm(`Xóa "${row.name}"?`)) remove.mutate(row.id)
}}
title="Xóa"
className="rounded p-1 text-slate-500 hover:bg-slate-100 hover:text-red-600"
disabled={remove.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Dialog
open={open}
onClose={() => setOpen(false)}
title={`${isEdit ? 'Sửa' : 'Thêm'} ${config.label.toLowerCase()}`}
footer={
<>
<Button variant="outline" onClick={() => setOpen(false)}>Hủy</Button>
<Button
onClick={(e: FormEvent) => { e.preventDefault(); save.mutate() }}
disabled={save.isPending}
>
{save.isPending ? 'Đang lưu…' : (isEdit ? 'Lưu' : 'Thêm')}
</Button>
</>
}
>
<form
onSubmit={(e: FormEvent) => { e.preventDefault(); save.mutate() }}
className="grid grid-cols-2 gap-3"
>
{config.fields.map(f => (
<div
key={f.key}
className={cn('space-y-1', f.type === 'textarea' && 'col-span-2')}
>
<Label>{f.label}</Label>
{f.type === 'text' && (
<Input
value={(form[f.key] as string) ?? ''}
onChange={e => setForm(s => ({ ...s, [f.key]: e.target.value }))}
placeholder={f.placeholder}
required={f.required}
/>
)}
{f.type === 'textarea' && (
<Textarea
rows={3}
value={(form[f.key] as string) ?? ''}
onChange={e => setForm(s => ({ ...s, [f.key]: e.target.value }))}
/>
)}
{f.type === 'checkbox' && (
<div className="flex items-center gap-2 pt-2">
<input
type="checkbox"
checked={!!form[f.key]}
onChange={e => setForm(s => ({ ...s, [f.key]: e.target.checked }))}
className="h-4 w-4 accent-brand-600"
/>
<span className="text-sm text-slate-600">{f.label}</span>
</div>
)}
</div>
))}
</form>
</Dialog>
</div>
)
}
function buildBody(kind: Kind, form: Record<string, unknown>): Record<string, unknown> {
// Build payload tương ứng BE Create/Update command — strip empty strings → null
const fields = KIND_CONFIG[kind].fields.map(f => f.key)
const body: Record<string, unknown> = {}
for (const k of fields) {
const v = form[k]
if (v === '' || v === undefined) body[k] = null
else body[k] = v
}
return body
}

View File

@ -347,6 +347,31 @@ function AddRowFields({
}) { }) {
const [form, setForm] = useState<Record<string, string>>({}) 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({ const submit = useMutation({
mutationFn: async () => { mutationFn: async () => {
const payload = buildPayload(contractType, nextOrder, form) const payload = buildPayload(contractType, nextOrder, form)
@ -358,26 +383,74 @@ function AddRowFields({
const fields = FIELDS_BY_TYPE[contractType] ?? [] 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 ( return (
<form <form
onSubmit={e => { e.preventDefault(); submit.mutate() }} onSubmit={e => { e.preventDefault(); submit.mutate() }}
className="space-y-2 rounded-lg border border-brand-200 bg-brand-50/30 p-3" 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"> <div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4">
{fields.map(f => ( {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"> <div key={f.name} className="space-y-1">
<label className="text-[11px] font-medium text-slate-600">{f.label}</label> <label className="text-[11px] font-medium text-slate-600">{f.label}</label>
<Input <Input
type={f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'} type={f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'}
value={form[f.name] ?? ''} value={form[f.name] ?? ''}
onChange={e => setForm(s => ({ ...s, [f.name]: e.target.value }))} onChange={e => handleFieldChange(f.name, e.target.value)}
placeholder={f.placeholder} placeholder={f.placeholder}
step={f.type === 'number' ? 'any' : undefined} step={f.type === 'number' ? 'any' : undefined}
required={f.required} required={f.required}
className="text-xs" className="text-xs"
list={datalistId}
/> />
</div> {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>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>Hủy</Button> <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[]> = { const FIELDS_BY_TYPE: Record<number, FieldDef[]> = {
1: [ // ThauPhu 1: [ // ThauPhu — autocomplete WorkItems + Units
{ name: 'hangMuc', label: 'Hạng mục *', type: 'text', required: true }, { 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, ngày...' }, { 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: 'khoiLuong', label: 'Khối lượng *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true }, { name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
{ name: 'thoiGianHoanThanh', label: 'Hoàn thành', type: 'date' }, { name: 'thoiGianHoanThanh', label: 'Hoàn thành', type: 'date' },
{ name: 'ghiChu', label: 'Ghi chú', type: 'text' }, { name: 'ghiChu', label: 'Ghi chú', type: 'text' },
], ],
2: [ // GiaoKhoan 2: [ // GiaoKhoan — autocomplete WorkItems + Units
{ name: 'maCongViec', label: 'Mã CV *', type: 'text', required: true }, { 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 }, { 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 }, { name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' },
{ name: 'khoiLuong', label: 'KL *', type: 'number', required: true }, { name: 'khoiLuong', label: 'KL *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true }, { name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
{ name: 'thoiGianHoanThanh', label: 'Hoàn thành', type: 'date' }, { name: 'thoiGianHoanThanh', label: 'Hoàn thành', type: 'date' },
], ],
3: [ // NhaCungCap 3: [ // NhaCungCap — autocomplete Materials + Units
{ name: 'maSP', label: 'Mã SP *', type: 'text', required: true }, { name: 'maSP', label: 'Mã SP *', type: 'text', required: true, datalist: 'materials', datalistField: 'code' },
{ name: 'tenSP', label: 'Tên SP *', 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 }, { name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' },
{ name: 'soLuong', label: 'SL *', type: 'number', required: true }, { name: 'soLuong', label: 'SL *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true }, { name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
{ name: 'thoiGianGiao', label: 'Giao hàng', type: 'date' }, { name: 'thoiGianGiao', label: 'Giao hàng', type: 'date' },
{ name: 'xuatXu', label: 'Xuất xứ', type: 'text' }, { name: 'xuatXu', label: 'Xuất xứ', type: 'text' },
], ],
4: [ // DichVu 4: [ // DichVu — autocomplete Services + Units
{ name: 'maDichVu', label: 'Mã DV *', type: 'text', required: true }, { name: 'maDichVu', label: 'Mã DV *', type: 'text', required: true, datalist: 'services', datalistField: 'code' },
{ name: 'tenDichVu', label: 'Tên DV *', 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 }, { name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' },
{ name: 'thoiGian', label: 'Thời gian *', type: 'number', required: true }, { name: 'thoiGian', label: 'Thời gian *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true }, { name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
], ],
5: [ // MuaBan 5: [ // MuaBan — autocomplete Materials + Units
{ name: 'maSP', label: 'Mã SP *', type: 'text', required: true }, { name: 'maSP', label: 'Mã SP *', type: 'text', required: true, datalist: 'materials', datalistField: 'code' },
{ name: 'tenSP', label: 'Tên SP *', 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 }, { name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' },
{ name: 'soLuong', label: 'SL *', type: 'number', required: true }, { name: 'soLuong', label: 'SL *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true }, { name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
{ name: 'thueVAT', label: 'VAT (%)', type: 'number', placeholder: '10' }, { 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: 'nhomSP', label: 'Nhóm SP *', type: 'text', required: true },
{ name: 'tenSP', label: 'Tên SP *', 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 }, { name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' },
{ name: 'donGiaToiThieu', label: 'Giá min *', type: 'number', required: true }, { name: 'donGiaToiThieu', label: 'Giá min *', type: 'number', required: true },
{ name: 'donGiaToiDa', label: 'Giá max *', 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: 'loaiDichVu', label: 'Loại DV *', type: 'text', required: true },
{ name: 'tenDichVu', label: 'Tên DV *', 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 }, { name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' },
{ name: 'donGiaToiThieu', label: 'Giá min *', type: 'number', required: true }, { name: 'donGiaToiThieu', label: 'Giá min *', type: 'number', required: true },
{ name: 'donGiaToiDa', label: 'Giá max *', type: 'number', required: true }, { name: 'donGiaToiDa', label: 'Giá max *', type: 'number', required: true },
], ],