Files
solution-erp/fe-user/src/components/master/SupplierImportDialog.tsx
pqhuy1987 5fa11b588a
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m14s
[CLAUDE] Supplier: import v2 — Mig 64 publish/draft + dedup-MST + template + file mẫu
- 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>
2026-07-12 19:03:37 +07:00

407 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState, type ChangeEvent, type DragEvent } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
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'
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
// 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 = {
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)),
})
// 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
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>
{/* 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 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">
<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>
)}
<p className="rounded-lg bg-blue-50 px-3 py-2 text-xs text-blue-700">
Nhập <b> NCC</b> cho từng dòng cột "Mã". NCC chưa nhập 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">
<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">
<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(
'space-y-0.5 px-2 py-1.5',
r.status === RowImportStatus.Error ? 'text-red-600' : 'text-amber-600',
)}
>
{r.mstMissing && <div className="text-amber-600"> Chưa MST, thể bị trùng</div>}
{r.messages.length > 0 && <div>{r.messages.join('; ')}</div>}
</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>
)
}