[CLAUDE] Supplier: import v2 — Mig 64 publish/draft + dedup-MST + template + file mẫu
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m14s

- Mig 64 AddSupplierPublishState: +IsPublic (draft/public); backfill 22 prod->public; filtered-unique doi [Code]<>'' (cho phep nhieu nhap Code=''); no new table (89).
- Import v2: dedup MST-primary + Code-backstop (chong 500 unique-violation); re-bake 30 token header THAT byte-exact (LayoutValid khop file that); nhap Ma NCC per-row (=Code); thieu Ma NCC->nhap (IsPublic=false); MST thieu->canh bao mem (MstMissing).
- PublishSupplierCommand rieng (ne #73 clobber); GET /suppliers/import/template (BE-gen xlsx 30-col).
- FE 2-app SHA-mirror: badge Public/Nhap; nut Cong bo; filter; nut Tai file mau; cot Ma editable; canh bao MST. Picker PE + tao HD loc published=true (an nhap -> ma HD khong dinh Code rong). CreateSupplier set IsPublic=true.
- authz D3: import/preview/confirm/template/publish = Policy Suppliers.Update (khop FE PermissionGuard, het 403).
- Tests +19 (477 PASS): dedup T1-T7, publish-guard, list-filter, all-or-nothing, LayoutValid. Fix null-Code NRE (path R4 loi).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-12 19:03:37 +07:00
parent 41f29acf68
commit 5fa11b588a
28 changed files with 7659 additions and 105 deletions

View File

@ -318,7 +318,7 @@ function ContractHeaderForm({
const suppliers = useQuery({
queryKey: ['suppliers-all'],
queryFn: async () => (await api.get<Paged<Supplier>>('/suppliers', { params: { page: 1, pageSize: 200 } })).data.items,
queryFn: async () => (await api.get<Paged<Supplier>>('/suppliers', { params: { page: 1, pageSize: 200, published: true } })).data.items, // S113 R4: chỉ NCC đã công bố (ẩn nháp → mã HĐ không dính Code rỗng)
})
const projects = useQuery({
queryKey: ['projects-all'],

View File

@ -1,6 +1,6 @@
import { useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Pencil, Plus, Trash2, Upload } from 'lucide-react'
import { EyeOff, Globe, Pencil, Plus, Trash2, Upload } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { DataTable, Pagination, type Column } from '@/components/DataTable'
@ -77,11 +77,24 @@ export function SuppliersPage() {
const [form, setForm] = useState<FormState>(emptyForm)
const isEdit = !!form.id
// Bộ lọc công bố: '' = tất cả · 'true' = đã công bố · 'false' = nháp (ẩn)
const [published, setPublished] = useState('')
// Dialog công bố: nhập Mã NCC trước khi NCC hiển thị ra ngoài
const [publishTarget, setPublishTarget] = useState<Supplier | null>(null)
const [maNcc, setMaNcc] = useState('')
const list = useQuery({
queryKey: ['suppliers', { page, search, sortBy, sortDesc }],
queryKey: ['suppliers', { page, search, sortBy, sortDesc, published }],
queryFn: async () => {
const res = await api.get<Paged<Supplier>>('/suppliers', {
params: { page, pageSize: 20, search: search || undefined, sortBy, sortDesc },
params: {
page,
pageSize: 20,
search: search || undefined,
sortBy,
sortDesc,
published: published === '' ? undefined : published === 'true',
},
})
return res.data
},
@ -143,6 +156,24 @@ export function SuppliersPage() {
onError: err => toast.error(getErrorMessage(err)),
})
// Công bố / ẩn NCC — KHÔNG qua UpdateSupplier (chỉ đổi Mã NCC + IsPublic).
const publish = useMutation({
mutationFn: async ({ id, maNcc, doPublish }: { id: string; maNcc?: string; doPublish: boolean }) =>
await api.post(`/suppliers/${id}/publish`, { maNcc: maNcc || undefined, publish: doPublish }),
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: ['suppliers'] })
toast.success(vars.doPublish ? 'Đã công bố NCC' : 'Đã ẩn NCC')
setPublishTarget(null)
setMaNcc('')
},
onError: err => toast.error(getErrorMessage(err)),
})
function openPublish(s: Supplier) {
setPublishTarget(s)
setMaNcc(s.code ?? '')
}
function openNew() {
setForm(emptyForm)
setOpen(true)
@ -177,13 +208,47 @@ export function SuppliersPage() {
{ key: 'type', header: 'Loại', sortable: true, width: 'w-36', render: s => SupplierTypeLabel[s.type] },
{ key: 'taxCode', header: 'MST', render: s => s.taxCode ?? '—' },
{ key: 'phone', header: 'Điện thoại', render: s => s.phone ?? '—' },
{
key: 'isPublic',
header: 'Trạng thái',
width: 'w-32',
render: s =>
s.isPublic ? (
<span className="inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold text-emerald-700 ring-1 ring-inset ring-emerald-600/20">
Đã công bố
</span>
) : (
<span className="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-semibold text-slate-600 ring-1 ring-inset ring-slate-500/20">
Nháp (n)
</span>
),
},
{
key: 'actions',
header: '',
align: 'right',
width: 'w-32',
width: 'w-40',
render: s => (
<div className="flex justify-end gap-1">
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Update">
{s.isPublic ? (
<Button
size="sm"
variant="ghost"
title="Ẩn NCC (bỏ công bố)"
onClick={() => {
if (confirm(`Ẩn NCC "${s.name}"? NCC sẽ không hiển thị ra ngoài.`))
publish.mutate({ id: s.id, doPublish: false })
}}
>
<EyeOff className="h-3.5 w-3.5 text-slate-500" />
</Button>
) : (
<Button size="sm" variant="ghost" title="Công bố NCC" onClick={() => openPublish(s)}>
<Globe className="h-3.5 w-3.5 text-emerald-600" />
</Button>
)}
</PermissionGuard>
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Update">
<Button size="sm" variant="ghost" onClick={() => openEdit(s)}>
<Pencil className="h-3.5 w-3.5" />
@ -212,7 +277,7 @@ export function SuppliersPage() {
description="Quản lý NCC / Thầu phụ / Tổ đội / Đơn vị dịch vụ / Chủ đầu tư"
actions={
<div className="flex items-center gap-2">
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Create">
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Update">
<Button variant="outline" onClick={() => setImportOpen(true)}>
<Upload className="h-4 w-4" />
Import Excel NCC
@ -238,6 +303,18 @@ export function SuppliersPage() {
}}
className="max-w-sm"
/>
<Select
value={published}
onChange={e => {
setPublished(e.target.value)
setPage(1)
}}
className="max-w-[190px]"
>
<option value="">Tất cả trạng thái</option>
<option value="true">Đã công bố</option>
<option value="false">Nháp (n)</option>
</Select>
</div>
<DataTable
@ -424,6 +501,40 @@ export function SuppliersPage() {
</form>
</Dialog>
<Dialog
open={publishTarget !== null}
onClose={() => setPublishTarget(null)}
title="Công bố nhà cung cấp"
size="sm"
footer={
<>
<Button variant="outline" onClick={() => setPublishTarget(null)}>
Hủy
</Button>
<Button
onClick={() =>
publishTarget && publish.mutate({ id: publishTarget.id, maNcc: maNcc.trim(), doPublish: true })
}
disabled={!maNcc.trim() || publish.isPending}
>
{publish.isPending ? 'Đang công bố…' : 'Công bố'}
</Button>
</>
}
>
<div className="space-y-3">
<p className="text-sm text-slate-600">
Nhập <b> NCC</b> đ công bố "{publishTarget?.name}". Sau khi công bố, NCC sẽ hiển thị các màn chọn
NCC (duyệt NCC, hợp đng).
</p>
<div className="space-y-1.5">
<Label> NCC *</Label>
<Input value={maNcc} onChange={e => setMaNcc(e.target.value)} placeholder="VD: NCC001" autoFocus />
</div>
<p className="text-xs text-slate-400">NCC chưa NCC sẽ trạng thái Nháp (n), chưa hiển thị ra ngoài.</p>
</div>
</Dialog>
<SupplierImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
</div>
)