[CLAUDE] Supplier: Excel-import Phase B (upload NCC preview/confirm) + Mig 63
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m25s

Import Excel 'Database NCC': upload→preview classify (New/Update/Skip/Error)→confirm all-or-nothing upsert-by-Code (OrdinalIgnoreCase dedup, fill-nulls-safe). Mig 63 +SourceUpdatedAt/By (2 nullable). Parser layout-locked header-fingerprint NFC-normalized + absolute-cell-index + #REF!-null + NAS-backslash-raw. Type-lạ→NhaCungCap. FE dialog fe-admin+fe-user byte-identical. 10 test SupplierExcelImportServiceTests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-12 16:13:46 +07:00
parent a829d0df99
commit e100ef065b
20 changed files with 8268 additions and 14 deletions

View File

@ -0,0 +1,354 @@
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 { toast } from 'sonner'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import { SupplierTypeLabel, type SupplierStatus, type SupplierType } from '@/types/master'
// ============================================================================
// Supplier Phase B — Import Excel "Database NCC" (Approach A, layout-locked).
// 2 bước: preview (parse + phân loại, KHÔNG ghi DB) → confirm (ALL-OR-NOTHING).
// Round-trip preview.rows verbatim sang confirm (BE bind { rows: [...] }).
// Field camelCase mirror SupplierImportDtos.cs (30 field + classify).
// ============================================================================
export const RowImportStatus = {
New: 0,
Update: 1,
Skip: 2,
Error: 3,
} as const
export type RowImportStatus = typeof RowImportStatus[keyof typeof RowImportStatus]
export type SupplierImportRow = {
rowIndex: number
packageCategory: string | null
type: SupplierType
code: string | null
name: string | null
address: string | null
officeAddress: string | null
phone: string | null
fax: string | null
bankAccount: string | null
secondaryBankAccount: string | null
taxCode: string | null
legalRepresentative: string | null
legalRepTitle: string | null
authorizationNote: string | null
linkGuq: string | null
linkGpkd: string | null
linkHsnl: string | null
contactPerson: string | null
contactTitle: string | null
contactPhone: string | null
email: string | null
mailingAddress: string | null
mailRecipient: string | null
referralSource: string | null
ownerPmh: string | null
supplierStatus: SupplierStatus | null
note: string | null
sourceUpdatedAt: string | null
sourceUpdatedBy: string | null
status: RowImportStatus
messages: string[]
existingSupplierId: string | null
}
export type SupplierImportPreview = {
rows: SupplierImportRow[]
newCount: number
updateCount: number
skipCount: number
errorCount: number
warnings: string[]
layoutValid: boolean
}
export type SupplierImportResult = {
inserted: number
updated: number
skipped: number
errors: string[]
committed: boolean
}
// Full class literal (Pattern 14 — Tailwind JIT không purge dynamic string).
const STATUS_META: Record<RowImportStatus, { label: string; cls: string }> = {
[RowImportStatus.New]: { label: 'Thêm mới', cls: 'bg-emerald-100 text-emerald-700 ring-emerald-600/20' },
[RowImportStatus.Update]: { label: 'Cập nhật', cls: 'bg-blue-100 text-blue-700 ring-blue-600/20' },
[RowImportStatus.Skip]: { label: 'Bỏ qua', cls: 'bg-slate-100 text-slate-600 ring-slate-500/20' },
[RowImportStatus.Error]: { label: 'Lỗi', cls: 'bg-red-100 text-red-700 ring-red-600/20' },
}
function StatusBadge({ status }: { status: RowImportStatus }) {
const meta = STATUS_META[status] ?? STATUS_META[RowImportStatus.Skip]
return (
<span className={cn('inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold ring-1 ring-inset', meta.cls)}>
{meta.label}
</span>
)
}
export function SupplierImportDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const qc = useQueryClient()
const inputRef = useRef<HTMLInputElement>(null)
const [dragging, setDragging] = useState(false)
const [fileName, setFileName] = useState('')
const [preview, setPreview] = useState<SupplierImportPreview | null>(null)
const [result, setResult] = useState<SupplierImportResult | null>(null)
// Reset toàn bộ state mỗi lần mở dialog (tránh giữ preview cũ từ lần import trước).
useEffect(() => {
if (open) {
setFileName('')
setPreview(null)
setResult(null)
setDragging(false)
}
}, [open])
const previewMut = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData()
fd.append('file', file)
const res = await api.post<SupplierImportPreview>('/suppliers/import/preview', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return res.data
},
onSuccess: data => setPreview(data),
onError: err => {
setPreview(null)
toast.error(getErrorMessage(err))
},
})
const confirmMut = useMutation({
mutationFn: async (rows: SupplierImportRow[]) => {
const res = await api.post<SupplierImportResult>('/suppliers/import/confirm', { rows })
return res.data
},
onSuccess: data => {
setResult(data)
if (data.committed) {
qc.invalidateQueries({ queryKey: ['suppliers'] })
toast.success(`Đã nhập: ${data.inserted} mới, ${data.updated} cập nhật, ${data.skipped} bỏ qua`)
onClose()
}
},
onError: err => toast.error(getErrorMessage(err)),
})
function pickFile(files: FileList | null) {
const file = files?.[0]
if (!file) return
setFileName(file.name)
setPreview(null)
setResult(null)
previewMut.mutate(file)
}
function onDrop(e: DragEvent<HTMLDivElement>) {
e.preventDefault()
setDragging(false)
pickFile(e.dataTransfer.files)
}
function onPick(e: ChangeEvent<HTMLInputElement>) {
pickFile(e.target.files)
e.target.value = '' // reset để chọn lại cùng file nếu cần
}
const counts = preview
? [
{ label: 'Thêm mới', value: preview.newCount, cls: 'bg-emerald-100 text-emerald-700' },
{ label: 'Cập nhật', value: preview.updateCount, cls: 'bg-blue-100 text-blue-700' },
{ label: 'Bỏ qua', value: preview.skipCount, cls: 'bg-slate-100 text-slate-600' },
{ label: 'Lỗi', value: preview.errorCount, cls: 'bg-red-100 text-red-700' },
]
: []
return (
<Dialog
open={open}
onClose={onClose}
title="Import Excel — Database NCC"
size="lg"
footer={
<>
<Button variant="outline" onClick={onClose}>
Đóng
</Button>
{preview && preview.layoutValid && (
<Button
onClick={() => confirmMut.mutate(preview.rows)}
disabled={preview.errorCount > 0 || confirmMut.isPending}
>
{confirmMut.isPending ? 'Đang nhập…' : 'Xác nhận nhập'}
</Button>
)}
</>
}
>
<div className="space-y-4">
{/* Vùng chọn / kéo-thả file .xlsx */}
<div
onDragOver={e => {
e.preventDefault()
setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
className={cn(
'cursor-pointer rounded-lg border-2 border-dashed px-4 py-6 text-center transition',
dragging ? 'border-brand-500 bg-brand-50' : 'border-slate-300 bg-slate-50/50 hover:bg-slate-50',
)}
>
<FileSpreadsheet className="mx-auto h-6 w-6 text-slate-400" />
<div className="mt-2 text-sm font-medium text-slate-600">
{fileName ? (
<span className="inline-flex items-center gap-1.5 text-brand-600">
<Upload className="h-3.5 w-3.5" />
{fileName}
</span>
) : (
<>
Kéo thả file Excel vào đây hoặc <span className="text-brand-600">chọn file</span>
</>
)}
</div>
<div className="mt-0.5 text-xs text-slate-400">Chỉ nhận file .xlsx đúng layout "Database NCC"</div>
<input ref={inputRef} type="file" accept=".xlsx" onChange={onPick} className="hidden" />
</div>
{/* Đang phân tích */}
{previewMut.isPending && (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-slate-500">
<Loader2 className="h-4 w-4 animate-spin" />
Đang phân tích file
</div>
)}
{/* Sai layout → từ chối, ẩn nút xác nhận */}
{preview && !preview.layoutValid && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
<div className="flex items-center gap-2 font-semibold">
<AlertTriangle className="h-4 w-4 shrink-0" />
Sai layout file Excel không nhập đưc
</div>
{preview.warnings.length > 0 && (
<ul className="mt-2 list-disc space-y-0.5 pl-5 text-xs">
{preview.warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
)}
</div>
)}
{/* Layout hợp lệ → tóm tắt + cảnh báo + bảng */}
{preview && preview.layoutValid && (
<>
<div className="flex flex-wrap gap-2">
{counts.map(c => (
<span
key={c.label}
className={cn('inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold', c.cls)}
>
{c.label}: {c.value}
</span>
))}
</div>
{preview.warnings.length > 0 && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-700">
<div className="mb-1 flex items-center gap-1.5 font-semibold">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
Cảnh báo
</div>
<ul className="list-disc space-y-0.5 pl-5">
{preview.warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
</div>
)}
<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">
<tr>
<th className="w-12 px-2 py-1.5 text-left font-medium">Dòng</th>
<th className="w-24 px-2 py-1.5 text-left font-medium">Trạng thái</th>
<th className="w-32 px-2 py-1.5 text-left font-medium"></th>
<th className="px-2 py-1.5 text-left font-medium">Tên NCC</th>
<th className="w-36 px-2 py-1.5 text-left font-medium">Loại</th>
<th className="px-2 py-1.5 text-left font-medium">Thông báo</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{preview.rows.map(r => (
<tr key={r.rowIndex} className="align-top">
<td className="px-2 py-1.5 font-mono text-slate-400">{r.rowIndex}</td>
<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 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',
r.status === RowImportStatus.Error ? 'text-red-600' : 'text-amber-600',
)}
>
{r.messages.length > 0 ? r.messages.join('; ') : ''}
</td>
</tr>
))}
{preview.rows.length === 0 && (
<tr>
<td colSpan={6} className="px-2 py-4 text-center text-slate-400">
Không dòng dữ liệu nào trong file.
</td>
</tr>
)}
</tbody>
</table>
</div>
{preview.errorCount > 0 && (
<div className="text-xs text-red-600">
Còn {preview.errorCount} dòng lỗi sửa file Excel rồi tải lại trước khi nhập.
</div>
)}
</>
)}
{/* Confirm trả hard-error → ALL-OR-NOTHING, chưa lưu gì */}
{result && !result.committed && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
<div className="flex items-center gap-2 font-semibold">
<AlertTriangle className="h-4 w-4 shrink-0" />
Không thể nhập chưa dữ liệu nào đưc lưu
</div>
{result.errors.length > 0 && (
<ul className="mt-2 list-disc space-y-0.5 pl-5 text-xs">
{result.errors.map((e, i) => (
<li key={i}>{e}</li>
))}
</ul>
)}
</div>
)}
</div>
</Dialog>
)
}

View File

@ -1,6 +1,6 @@
import { useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { Pencil, Plus, Trash2, Upload } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { DataTable, Pagination, type Column } from '@/components/DataTable'
@ -12,6 +12,7 @@ import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { Dialog } from '@/components/ui/Dialog'
import { PathLink } from '@/components/ui/PathLink'
import { SupplierImportDialog } from '@/components/master/SupplierImportDialog'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { MenuKeys } from '@/lib/menuKeys'
@ -72,6 +73,7 @@ export function SuppliersPage() {
const [sortDesc, setSortDesc] = useState(true)
const [open, setOpen] = useState(false)
const [importOpen, setImportOpen] = useState(false)
const [form, setForm] = useState<FormState>(emptyForm)
const isEdit = !!form.id
@ -209,12 +211,20 @@ export function SuppliersPage() {
title="Nhà cung cấp"
description="Quản lý NCC / Thầu phụ / Tổ đội / Đơn vị dịch vụ / Chủ đầu tư"
actions={
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Create">
<Button onClick={openNew}>
<Plus className="h-4 w-4" />
Thêm NCC
</Button>
</PermissionGuard>
<div className="flex items-center gap-2">
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Create">
<Button variant="outline" onClick={() => setImportOpen(true)}>
<Upload className="h-4 w-4" />
Import Excel NCC
</Button>
</PermissionGuard>
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Create">
<Button onClick={openNew}>
<Plus className="h-4 w-4" />
Thêm NCC
</Button>
</PermissionGuard>
</div>
}
/>
@ -413,6 +423,8 @@ export function SuppliersPage() {
</div>
</form>
</Dialog>
<SupplierImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
</div>
)
}