[CLAUDE] FE: Admin CatalogsPage CRUD + Details form datalist autocomplete
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m36s
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:
@ -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() {
|
||||
<Route path="/master/suppliers" element={<SuppliersPage />} />
|
||||
<Route path="/master/projects" element={<ProjectsPage />} />
|
||||
<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/permissions" element={<PermissionsPage />} />
|
||||
<Route path="/system/workflows" element={<WorkflowsPage />} />
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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 },
|
||||
],
|
||||
|
||||
321
fe-admin/src/pages/master/CatalogsPage.tsx
Normal file
321
fe-admin/src/pages/master/CatalogsPage.tsx
Normal 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">Mã</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 có 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
|
||||
}
|
||||
Reference in New Issue
Block a user