Files
solution-erp/fe-admin/src/pages/master/CatalogsPage.tsx
pqhuy1987 16e24ed962
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m36s
[CLAUDE] FE: Admin CatalogsPage CRUD + Details form datalist autocomplete
## 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>
2026-04-23 12:27:41 +07:00

322 lines
13 KiB
TypeScript

// 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
}