[CLAUDE] Contract: K1 SP-002 danh muc loai HD - ContractCatalogEntries (Mig 70) + seed 86 + CQRS + FE kind-5 x2 app + 8 test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m31s

Gate: reviewer PASS-WITH-FLAGS 9f (sub-reviewer-gate-k1.md). Xu ly: F4
IncludeInactive mac-dinh-loai (spec K1.c) + test T5b; F7 comment 5-kind;
F1 OG-6 doi nhan soat-SAU @PAUSE-2 (seeder skip-if-exists giu sua-tay CRUD
qua restart - update-branch co y KHONG co); F3 authz mirror khuon Catalogs,
gioi-han khai: 4 policy ContractCatalog.* sinh ra nhung chua endpoint nao
tieu thu (display-layer khac API-layer, #82); F6 ten index IX_ giu EF-default
(NIT). Suite 598/0 (45D+553I). Mig 70 ap SQL Server that OK. Drift DELTA:
MenuKeys.All 55->56, policies 220->224, bang 96->97.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-31 15:45:50 +07:00
parent c0a96e54eb
commit 50e6d8c382
20 changed files with 8566 additions and 80 deletions

View File

@ -64,6 +64,10 @@ function resolvePath(key: string): string | null {
CatalogMaterials: '/master/catalogs/materials',
CatalogServices: '/master/catalogs/services',
CatalogWorkItems: '/master/catalogs/work-items',
// [K1 — S164, Mig 70] Danh mục Hợp đồng SP-002 — "chỗ thứ 4" Pattern 16-bis:
// BE seed leaf `ContractCatalog` dưới group Catalogs, thiếu dòng này thì
// resolvePath trả null → MenuLeaf DROP IM LẶNG khỏi sidebar (gotcha #50).
ContractCatalog: '/master/catalogs/contract-catalog',
PurchaseEvaluations: '/purchase-evaluations',
PeWorkflows: '/system/pe-workflows',
// [Phase 10.1 G-H1 S33 2026-05-26] Module Hồ sơ Nhân sự (Mig 34). LESSON

View File

@ -11,6 +11,10 @@ export const MenuKeys = {
CatalogMaterials: 'CatalogMaterials',
CatalogServices: 'CatalogServices',
CatalogWorkItems: 'CatalogWorkItems',
// [K1 — S164, Mig 70] Danh mục Hợp đồng SOL-CCM-SP-002 (86 dòng × 8 nhóm duyệt).
// Mirror BE MenuKeys.cs:28 — leaf thứ 5 dưới group `Catalogs`, page dùng chung
// CatalogsPage qua `:kind` = `contract-catalog`.
ContractCatalog: 'ContractCatalog',
Contracts: 'Contracts',
Forms: 'Forms',
Reports: 'Reports',

View File

@ -1,10 +1,15 @@
// 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.
// Generic master catalogs CRUD — 1 page handle 5 kind: units / materials /
// services / work-items / contract-catalog. URL `/master/catalogs/:kind` driven,
// mỗi kind có fields config riêng. Sub-tabs hiển thị 5 kind trên cùng để chuyển nhanh.
//
// [K1 — S164, Mig 70] kind thứ 5 `contract-catalog` = "Danh mục Hợp đồng"
// (SOL-CCM-SP-002, 86 dòng × 8 nhóm duyệt N1..N8). Endpoint BE
// `api/catalogs/contract-catalog` (ContractCatalogController) — cùng tiền tố
// `/catalogs/` nên tái dùng nguyên đường `/catalogs/${kind}` sẵn có, KHÔNG route mới.
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 { Library, Pencil, Plus, Ruler, Package, Wrench, ListChecks, FileSpreadsheet, Trash2, Search } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button'
@ -16,9 +21,36 @@ import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
type Kind = 'units' | 'materials' | 'services' | 'work-items'
type Kind = 'units' | 'materials' | 'services' | 'work-items' | 'contract-catalog'
type FieldDef = { key: string; label: string; type: 'text' | 'textarea' | 'checkbox'; required?: boolean; placeholder?: string }
type FieldDef = {
key: string
label: string
type: 'text' | 'textarea' | 'checkbox' | 'number' | 'select'
required?: boolean
placeholder?: string
options?: { value: number; label: string }[]
// Ép Number khi build payload — BE nhận `int` (ApprovalGroup / SortOrder),
// gửi chuỗi "3" thì System.Text.Json bind fail.
numeric?: boolean
}
// 8 nhóm duyệt N1..N8 (SOL-CCM-SP-002 — mục "Nhóm duyệt (8)").
const APPROVAL_GROUPS = [1, 2, 3, 4, 5, 6, 7, 8] as const
const APPROVAL_GROUP_OPTIONS = APPROVAL_GROUPS.map(n => ({ value: n, label: `N${n}` }))
// Pattern 14 (Tailwind JIT): KHÔNG interpolate `bg-${x}-50` — class bị purge.
// Full class literal, cycle theo (nhóm - 1) % length.
const APPROVAL_GROUP_BADGE = [
'bg-sky-50 text-sky-700 border-sky-200',
'bg-emerald-50 text-emerald-700 border-emerald-200',
'bg-amber-50 text-amber-700 border-amber-200',
'bg-violet-50 text-violet-700 border-violet-200',
'bg-rose-50 text-rose-700 border-rose-200',
'bg-teal-50 text-teal-700 border-teal-200',
'bg-indigo-50 text-indigo-700 border-indigo-200',
'bg-orange-50 text-orange-700 border-orange-200',
] as const
const KIND_CONFIG: Record<Kind, {
label: string
@ -76,17 +108,44 @@ const KIND_CONFIG: Record<Kind, {
],
columns: ['Mã', 'Tên', 'Nhóm', 'ĐVT', 'Trạng thái'],
},
// [K1 — S164] Danh mục Hợp đồng SOL-CCM-SP-002. Shape KHÁC 4 kind cũ
// (tenVi/tenEn thay `name`, thêm groupCode + approvalGroup + 2 cột vai ký)
// ⇒ header + cell có nhánh riêng bên dưới, `columns` dưới đây là nguồn header.
'contract-catalog': {
label: 'Danh mục Hợp đồng',
icon: FileSpreadsheet,
fields: [
{ key: 'code', label: 'Mã *', type: 'text', required: true, placeholder: 'A1-01, B2-07, CEO-01…' },
{ key: 'groupCode', label: 'Nhóm mục *', type: 'text', required: true, placeholder: 'A1, A2, B1, C3, CEO…' },
{ key: 'tenVi', label: 'Tên tiếng Việt *', type: 'text', required: true, placeholder: 'Hợp đồng thi công xây dựng…' },
{ key: 'tenEn', label: 'Tên tiếng Anh', type: 'text', placeholder: 'Construction contract…' },
{ key: 'approvalGroup', label: 'Nhóm duyệt (N1N8) *', type: 'select', required: true, numeric: true, options: APPROVAL_GROUP_OPTIONS },
{ key: 'sortOrder', label: 'Thứ tự trong nhóm', type: 'number', numeric: true, placeholder: '0' },
{ key: 'signerRole', label: 'Vai ký chính *', type: 'text', required: true, placeholder: 'TGĐ / GĐ Khối / TP…' },
{ key: 'initialsChain', label: 'Chuỗi ký nháy', type: 'text', placeholder: 'TBP.CCM→NV.CCM→TBP.EQU→NV.EQU' },
{ key: 'nduqNote', label: 'Ghi chú NĐUQ', type: 'textarea' },
{ key: 'isActive', label: 'Đang dùng', type: 'checkbox' },
],
columns: ['Mã', 'Tên tiếng Việt', 'Tên tiếng Anh', 'Nhóm', 'Nhóm duyệt', 'Vai ký chính', 'Ký nháy', 'Trạng thái'],
},
}
const KINDS: Kind[] = ['units', 'materials', 'services', 'work-items']
const KINDS: Kind[] = ['units', 'materials', 'services', 'work-items', 'contract-catalog']
type CatalogRow = Record<string, unknown> & { id: string; code: string; name: string }
type CatalogRow = Record<string, unknown> & { id: string; code: string; name?: string }
// Nhãn dòng dùng cho confirm xóa — kind `contract-catalog` không có `name`.
function rowLabel(row: CatalogRow): string {
return (row.name as string) || (row.tenVi as string) || row.code
}
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 isContractCatalog = kind === 'contract-catalog'
const colCount = (isContractCatalog ? config.columns.length : kind === 'units' ? 2 : 5) + 1
const qc = useQueryClient()
const [search, setSearch] = useState('')
@ -96,7 +155,10 @@ export function CatalogsPage() {
const list = useQuery({
queryKey: ['catalogs', kind, search],
queryFn: async () => (await api.get<CatalogRow[]>(`/catalogs/${kind}`, { params: { q: search || undefined } })).data,
// kind-5: trang quản trị cần thấy CẢ dòng ngừng hiệu lực — BE List mặc định LOẠI (spec K1.c, gate-K1 F4)
queryFn: async () => (await api.get<CatalogRow[]>(`/catalogs/${kind}`, {
params: { q: search || undefined, includeInactive: isContractCatalog ? true : undefined },
})).data,
})
const save = useMutation({
@ -125,7 +187,11 @@ export function CatalogsPage() {
function openCreate() {
const init: Record<string, unknown> = {}
config.fields.forEach(f => { init[f.key] = f.type === 'checkbox' ? true : '' })
config.fields.forEach(f => {
// select mặc định option đầu (nhóm duyệt N1) — tránh gửi rỗng → BE
// InclusiveBetween(1,8) reject.
init[f.key] = f.type === 'checkbox' ? true : f.type === 'select' ? (f.options?.[0]?.value ?? '') : ''
})
setForm(init)
setOpen(true)
}
@ -145,10 +211,14 @@ export function CatalogsPage() {
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."
description={
isContractCatalog
? 'Danh mục Hợp đồng (SOL-CCM-SP-002) — tên VN/EN, nhóm mục và nhóm duyệt N1N8 dùng cho phiếu Kế hoạch ký kết.'
: 'Master data dùng cho phần Chi tiết HĐ — autocomplete khi user nhập line items.'
}
/>
{/* Sub-tabs cho 4 kind */}
{/* Sub-tabs cho 5 kind */}
<div className="mb-4 flex gap-1 border-b border-slate-200">
{KINDS.map(k => {
const KIcon = KIND_CONFIG[k].icon
@ -191,35 +261,64 @@ export function CatalogsPage() {
<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>}
{isContractCatalog ? (
config.columns.map(c => <th key={c} className="px-3 py-2 text-left">{c}</th>)
) : (
<>
<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>
<tr><td colSpan={colCount} 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>
<tr><td colSpan={colCount} 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>
{isContractCatalog ? (
<>
<td className="px-3 py-2 font-mono text-xs">{row.code}</td>
<td className="px-3 py-2">{(row.tenVi as string) || '—'}</td>
<td className="px-3 py-2 text-xs text-slate-600">{(row.tenEn as string) || '—'}</td>
<td className="px-3 py-2 font-mono text-xs text-slate-600">{(row.groupCode as string) || '—'}</td>
<td className="px-3 py-2">
<ApprovalGroupBadge value={row.approvalGroup as number} />
</td>
<td className="px-3 py-2 text-xs text-slate-600">{(row.signerRole as string) || '—'}</td>
<td className="px-3 py-2 text-[11px] text-slate-500">{(row.initialsChain as string) || '—'}</td>
<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 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>
</>
)}
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
@ -232,7 +331,7 @@ export function CatalogsPage() {
</button>
<button
onClick={() => {
if (confirm(`Xóa "${row.name}"?`)) remove.mutate(row.id)
if (confirm(`Xóa "${rowLabel(row)}"?`)) remove.mutate(row.id)
}}
title="Xóa"
className="rounded p-1 text-slate-500 hover:bg-slate-100 hover:text-red-600"
@ -282,6 +381,28 @@ export function CatalogsPage() {
required={f.required}
/>
)}
{f.type === 'number' && (
<Input
type="number"
value={(form[f.key] as string | number) ?? ''}
onChange={e => setForm(s => ({ ...s, [f.key]: e.target.value }))}
placeholder={f.placeholder}
required={f.required}
/>
)}
{f.type === 'select' && (
<select
value={String(form[f.key] ?? '')}
onChange={e => setForm(s => ({ ...s, [f.key]: e.target.value }))}
required={f.required}
className="h-9 w-full rounded-md border border-slate-300 bg-white px-2 text-sm text-slate-800 focus:border-brand-600 focus:outline-none"
>
<option value=""> Chọn </option>
{f.options?.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
)}
{f.type === 'textarea' && (
<Textarea
rows={3}
@ -310,12 +431,27 @@ export function CatalogsPage() {
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
for (const f of KIND_CONFIG[kind].fields) {
const v = form[f.key]
// Field `numeric` map sang `int` NON-nullable ở BE (ApprovalGroup / SortOrder)
// ⇒ rỗng phải là 0, KHÔNG được null (System.Text.Json ném khi null → int).
if (f.numeric) {
const n = Number(v)
body[f.key] = v === '' || v === null || v === undefined || Number.isNaN(n) ? 0 : n
} else if (v === '' || v === undefined) body[f.key] = null
else body[f.key] = v
}
return body
}
// Badge nhóm duyệt N1..N8 — màu literal (Pattern 14), cycle theo chỉ số nhóm.
function ApprovalGroupBadge({ value }: { value: number | null | undefined }) {
if (typeof value !== 'number' || value < 1) return <span className="text-xs text-slate-400"></span>
const tone = APPROVAL_GROUP_BADGE[(value - 1) % APPROVAL_GROUP_BADGE.length]
return (
<span className={cn('rounded-full border px-2 py-0.5 text-[10px] font-semibold', tone)}>
N{value}
</span>
)
}