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>
322 lines
13 KiB
TypeScript
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">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
|
|
}
|