[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
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:
354
fe-admin/src/components/master/SupplierImportDialog.tsx
Normal file
354
fe-admin/src/components/master/SupplierImportDialog.tsx
Normal 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">Mã</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 có 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 có 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user