// 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 ( ) })}
setSearch(e.target.value)} placeholder="Tìm theo mã / tên…" className="pl-8" />
{kind !== 'units' && } {kind !== 'units' && } {kind !== 'units' && } {list.isLoading && ( )} {!list.isLoading && (list.data?.length ?? 0) === 0 && ( )} {list.data?.map(row => ( {kind !== 'units' && } {kind !== 'units' && } {kind !== 'units' && ( )} ))}
TênNhóm/LoạiĐVTTrạng thái
Đang tải…
Chưa có dữ liệu — bấm Thêm để tạo mới.
{row.code} {row.name}{(row.category as string) ?? '—'}{(row.defaultUnit as string) ?? '—'} {(row.isActive as boolean) ? ( Đang dùng ) : ( Tạm tắt )}
setOpen(false)} title={`${isEdit ? 'Sửa' : 'Thêm'} ${config.label.toLowerCase()}`} footer={ <> } >
{ e.preventDefault(); save.mutate() }} className="grid grid-cols-2 gap-3" > {config.fields.map(f => (
{f.type === 'text' && ( setForm(s => ({ ...s, [f.key]: e.target.value }))} placeholder={f.placeholder} required={f.required} /> )} {f.type === 'textarea' && (