[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
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:
@ -1,9 +1,10 @@
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type DragEvent } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { AlertTriangle, FileSpreadsheet, Loader2, Upload } from 'lucide-react'
|
||||
import { AlertTriangle, Download, FileSpreadsheet, Loader2, Upload } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
@ -58,6 +59,8 @@ export type SupplierImportRow = {
|
||||
status: RowImportStatus
|
||||
messages: string[]
|
||||
existingSupplierId: string | null
|
||||
// Thiếu MST → cảnh báo mềm (không kiểm được trùng), KHÔNG chặn nhập (S113)
|
||||
mstMissing: boolean
|
||||
}
|
||||
|
||||
export type SupplierImportPreview = {
|
||||
@ -145,6 +148,27 @@ export function SupplierImportDialog({ open, onClose }: { open: boolean; onClose
|
||||
onError: err => toast.error(getErrorMessage(err)),
|
||||
})
|
||||
|
||||
// Tải file mẫu .xlsx trống (BE dựng bằng ClosedXML — single-source layout, 0 drift).
|
||||
const templateMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await api.get('/suppliers/import/template', { responseType: 'blob' })
|
||||
const url = window.URL.createObjectURL(res.data as Blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'Mau-Database-NCC.xlsx'
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
},
|
||||
onError: err => toast.error(getErrorMessage(err)),
|
||||
})
|
||||
|
||||
// Sửa Mã NCC per-dòng ngay trên lưới preview (round-trip verbatim sang confirm).
|
||||
function updateRow(rowIndex: number, patch: Partial<SupplierImportRow>) {
|
||||
setPreview(p =>
|
||||
p ? { ...p, rows: p.rows.map(x => (x.rowIndex === rowIndex ? { ...x, ...patch } : x)) } : p,
|
||||
)
|
||||
}
|
||||
|
||||
function pickFile(files: FileList | null) {
|
||||
const file = files?.[0]
|
||||
if (!file) return
|
||||
@ -228,6 +252,21 @@ export function SupplierImportDialog({ open, onClose }: { open: boolean; onClose
|
||||
<input ref={inputRef} type="file" accept=".xlsx" onChange={onPick} className="hidden" />
|
||||
</div>
|
||||
|
||||
{/* Tải file mẫu — lấy layout chuẩn trước khi nhập */}
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg bg-slate-50 px-3 py-2">
|
||||
<p className="text-xs text-slate-500">Chưa có file? Tải mẫu chuẩn rồi điền dữ liệu NCC theo đúng cột.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => templateMut.mutate()}
|
||||
disabled={templateMut.isPending}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{templateMut.isPending ? 'Đang tải…' : 'Tải file mẫu'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Đang phân tích */}
|
||||
{previewMut.isPending && (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-sm text-slate-500">
|
||||
@ -281,6 +320,11 @@ export function SupplierImportDialog({ open, onClose }: { open: boolean; onClose
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="rounded-lg bg-blue-50 px-3 py-2 text-xs text-blue-700">
|
||||
Nhập <b>Mã NCC</b> cho từng dòng ở cột "Mã". NCC chưa nhập Mã NCC sẽ lưu ở trạng thái{' '}
|
||||
<b>Nháp (ẩn)</b>, chưa hiển thị ra ngoài.
|
||||
</p>
|
||||
|
||||
<div className="max-h-[380px] overflow-auto rounded-lg border border-slate-200">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead className="sticky top-0 z-10 bg-slate-50 text-slate-500">
|
||||
@ -300,16 +344,24 @@ export function SupplierImportDialog({ open, onClose }: { open: boolean; onClose
|
||||
<td className="px-2 py-1.5">
|
||||
<StatusBadge status={r.status} />
|
||||
</td>
|
||||
<td className="px-2 py-1.5 font-mono text-slate-700">{r.code || '—'}</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Input
|
||||
value={r.code ?? ''}
|
||||
onChange={e => updateRow(r.rowIndex, { code: e.target.value })}
|
||||
placeholder="Mã NCC"
|
||||
className="h-7 font-mono text-xs"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-slate-700">{r.name || '—'}</td>
|
||||
<td className="px-2 py-1.5 text-slate-600">{SupplierTypeLabel[r.type] ?? '—'}</td>
|
||||
<td
|
||||
className={cn(
|
||||
'px-2 py-1.5',
|
||||
'space-y-0.5 px-2 py-1.5',
|
||||
r.status === RowImportStatus.Error ? 'text-red-600' : 'text-amber-600',
|
||||
)}
|
||||
>
|
||||
{r.messages.length > 0 ? r.messages.join('; ') : ''}
|
||||
{r.mstMissing && <div className="text-amber-600">⚠️ Chưa có MST, có thể bị trùng</div>}
|
||||
{r.messages.length > 0 && <div>{r.messages.join('; ')}</div>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@ -2219,7 +2219,7 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
|
||||
const qc = useQueryClient()
|
||||
const suppliers = useQuery({
|
||||
queryKey: ['all-suppliers'],
|
||||
queryFn: async () => (await api.get<{ items: Supplier[] }>('/suppliers', { params: { pageSize: 1000 } })).data.items,
|
||||
queryFn: async () => (await api.get<{ items: Supplier[] }>('/suppliers', { params: { pageSize: 1000, published: true } })).data.items, // S113 R4: chỉ NCC đã công bố (ẩn nháp)
|
||||
})
|
||||
const [form, setForm] = useState({
|
||||
supplierId: '',
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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>Mã 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>Mã 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 có Mã 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>
|
||||
)
|
||||
|
||||
@ -75,6 +75,8 @@ export type Supplier = {
|
||||
ownerPmh: string | null
|
||||
// Tình trạng
|
||||
status: SupplierStatus | null
|
||||
// Công bố: true = hiển thị ra ngoài (picker NCC), false = nháp/ẩn (S113)
|
||||
isPublic: boolean
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user