diff --git a/fe-admin/src/App.tsx b/fe-admin/src/App.tsx index d402f63..3212b79 100644 --- a/fe-admin/src/App.tsx +++ b/fe-admin/src/App.tsx @@ -8,6 +8,7 @@ import { DashboardPage } from '@/pages/DashboardPage' import { SuppliersPage } from '@/pages/master/SuppliersPage' import { ProjectsPage } from '@/pages/master/ProjectsPage' import { DepartmentsPage } from '@/pages/master/DepartmentsPage' +import { CatalogsPage } from '@/pages/master/CatalogsPage' import { PermissionsPage } from '@/pages/system/PermissionsPage' import { WorkflowsPage } from '@/pages/system/WorkflowsPage' import { FormsPage } from '@/pages/forms/FormsPage' @@ -34,6 +35,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/fe-admin/src/components/Layout.tsx b/fe-admin/src/components/Layout.tsx index 1bd7370..ba8dc13 100644 --- a/fe-admin/src/components/Layout.tsx +++ b/fe-admin/src/components/Layout.tsx @@ -40,6 +40,10 @@ function resolvePath(key: string): string | null { Roles: '/system/roles', Permissions: '/system/permissions', 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] diff --git a/fe-admin/src/components/contracts/ContractDetailsTab.tsx b/fe-admin/src/components/contracts/ContractDetailsTab.tsx index 2409628..fe7c75a 100644 --- a/fe-admin/src/components/contracts/ContractDetailsTab.tsx +++ b/fe-admin/src/components/contracts/ContractDetailsTab.tsx @@ -347,6 +347,31 @@ function AddRowFields({ }) { const [form, setForm] = useState>({}) + // Load 4 catalogs cho datalist autocomplete (1 lần, cache TanStack) + const units = useQuery({ + queryKey: ['catalogs', 'units'], + queryFn: async () => (await api.get('/catalogs/units')).data, + }) + const materials = useQuery({ + queryKey: ['catalogs', 'materials'], + queryFn: async () => (await api.get('/catalogs/materials')).data, + }) + const services = useQuery({ + queryKey: ['catalogs', 'services'], + queryFn: async () => (await api.get('/catalogs/services')).data, + }) + const workItems = useQuery({ + queryKey: ['catalogs', 'work-items'], + queryFn: async () => (await api.get('/catalogs/work-items')).data, + }) + + const catalogData: Record, 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 ( { e.preventDefault(); submit.mutate() }} className="space-y-2 rounded-lg border border-brand-200 bg-brand-50/30 p-3" > - {fields.map(f => ( - - {f.label} - setForm(s => ({ ...s, [f.name]: e.target.value }))} - placeholder={f.placeholder} - step={f.type === 'number' ? 'any' : undefined} - required={f.required} - className="text-xs" - /> - - ))} + {fields.map(f => { + const datalistId = f.datalist ? `dl-${f.datalist}-${f.name}` : undefined + const items = f.datalist ? catalogData[f.datalist] : [] + return ( + + {f.label} + 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 && ( + + {items.map(it => ( + + {f.datalistField === 'code' ? it.name : it.code} + + ))} + + )} + + ) + })} Hủy @@ -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 = { - 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 }, ], diff --git a/fe-admin/src/pages/master/CatalogsPage.tsx b/fe-admin/src/pages/master/CatalogsPage.tsx new file mode 100644 index 0000000..5bcdce2 --- /dev/null +++ b/fe-admin/src/pages/master/CatalogsPage.tsx @@ -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 + 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 & { 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>({}) + const isEdit = !!form.id + + const list = useQuery({ + queryKey: ['catalogs', kind, search], + queryFn: async () => (await api.get(`/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 = {} + config.fields.forEach(f => { init[f.key] = f.type === 'checkbox' ? true : '' }) + setForm(init) + setOpen(true) + } + function openEdit(row: CatalogRow) { + const init: Record = { id: row.id } + config.fields.forEach(f => { init[f.key] = row[f.key] ?? (f.type === 'checkbox' ? false : '') }) + setForm(init) + setOpen(true) + } + + return ( + + + + Danh mục chi tiết + + } + description="Master data dùng cho phần Chi tiết HĐ — autocomplete khi user nhập line items." + /> + + {/* Sub-tabs cho 4 kind */} + + {KINDS.map(k => { + const KIcon = KIND_CONFIG[k].icon + const active = k === kind + return ( + 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', + )} + > + + {KIND_CONFIG[k].label} + + ) + })} + + + + + + setSearch(e.target.value)} + placeholder="Tìm theo mã / tên…" + className="pl-8" + /> + + + + Thêm {config.label.toLowerCase()} + + + + + + + + Mã + Tên + {kind !== 'units' && Nhóm/Loại} + {kind !== 'units' && ĐVT} + {kind !== 'units' && Trạng thái} + + + + + {list.isLoading && ( + Đang tải… + )} + {!list.isLoading && (list.data?.length ?? 0) === 0 && ( + Chưa có dữ liệu — bấm Thêm để tạo mới. + )} + {list.data?.map(row => ( + + {row.code} + {row.name} + {kind !== 'units' && {(row.category as string) ?? '—'}} + {kind !== 'units' && {(row.defaultUnit as string) ?? '—'}} + {kind !== 'units' && ( + + {(row.isActive as boolean) ? ( + Đang dùng + ) : ( + Tạm tắt + )} + + )} + + + openEdit(row)} + title="Sửa" + className="rounded p-1 text-slate-500 hover:bg-slate-100 hover:text-brand-600" + > + + + { + 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} + > + + + + + + ))} + + + + + setOpen(false)} + title={`${isEdit ? 'Sửa' : 'Thêm'} ${config.label.toLowerCase()}`} + footer={ + <> + setOpen(false)}>Hủy + { e.preventDefault(); save.mutate() }} + disabled={save.isPending} + > + {save.isPending ? 'Đang lưu…' : (isEdit ? 'Lưu' : 'Thêm')} + + > + } + > + { e.preventDefault(); save.mutate() }} + className="grid grid-cols-2 gap-3" + > + {config.fields.map(f => ( + + {f.label} + {f.type === 'text' && ( + setForm(s => ({ ...s, [f.key]: e.target.value }))} + placeholder={f.placeholder} + required={f.required} + /> + )} + {f.type === 'textarea' && ( + setForm(s => ({ ...s, [f.key]: e.target.value }))} + /> + )} + {f.type === 'checkbox' && ( + + setForm(s => ({ ...s, [f.key]: e.target.checked }))} + className="h-4 w-4 accent-brand-600" + /> + {f.label} + + )} + + ))} + + + + ) +} + +function buildBody(kind: Kind, form: Record): Record { + // 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 = {} + for (const k of fields) { + const v = form[k] + if (v === '' || v === undefined) body[k] = null + else body[k] = v + } + return body +} diff --git a/fe-user/src/components/contracts/ContractDetailsTab.tsx b/fe-user/src/components/contracts/ContractDetailsTab.tsx index 2409628..fe7c75a 100644 --- a/fe-user/src/components/contracts/ContractDetailsTab.tsx +++ b/fe-user/src/components/contracts/ContractDetailsTab.tsx @@ -347,6 +347,31 @@ function AddRowFields({ }) { const [form, setForm] = useState>({}) + // Load 4 catalogs cho datalist autocomplete (1 lần, cache TanStack) + const units = useQuery({ + queryKey: ['catalogs', 'units'], + queryFn: async () => (await api.get('/catalogs/units')).data, + }) + const materials = useQuery({ + queryKey: ['catalogs', 'materials'], + queryFn: async () => (await api.get('/catalogs/materials')).data, + }) + const services = useQuery({ + queryKey: ['catalogs', 'services'], + queryFn: async () => (await api.get('/catalogs/services')).data, + }) + const workItems = useQuery({ + queryKey: ['catalogs', 'work-items'], + queryFn: async () => (await api.get('/catalogs/work-items')).data, + }) + + const catalogData: Record, 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 ( { e.preventDefault(); submit.mutate() }} className="space-y-2 rounded-lg border border-brand-200 bg-brand-50/30 p-3" > - {fields.map(f => ( - - {f.label} - setForm(s => ({ ...s, [f.name]: e.target.value }))} - placeholder={f.placeholder} - step={f.type === 'number' ? 'any' : undefined} - required={f.required} - className="text-xs" - /> - - ))} + {fields.map(f => { + const datalistId = f.datalist ? `dl-${f.datalist}-${f.name}` : undefined + const items = f.datalist ? catalogData[f.datalist] : [] + return ( + + {f.label} + 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 && ( + + {items.map(it => ( + + {f.datalistField === 'code' ? it.name : it.code} + + ))} + + )} + + ) + })} Hủy @@ -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 = { - 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 }, ],