"use client"; import React, { useState, useRef, useCallback } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { toast } from "sonner"; import { Upload, FileSpreadsheet, AlertCircle, CheckCircle2, Download, Loader2, X, } from "lucide-react"; import { cn } from "@/lib/utils"; import { importFromExcel } from "@/lib/utils/excelExport"; import { apiClient } from "@/lib/api/client"; interface BomExcelUploadModalProps { open: boolean; onOpenChange: (open: boolean) => void; onSuccess?: () => void; /** bomId가 있으면 "새 버전 등록" 모드, 없으면 "새 BOM 생성" 모드 */ bomId?: string; bomName?: string; } interface ParsedRow { rowIndex: number; level: number; item_number: string; item_name: string; quantity: number; unit: string; process_type: string; remark: string; valid: boolean; error?: string; isHeader?: boolean; } type UploadStep = "upload" | "preview" | "result"; const EXPECTED_HEADERS = ["레벨", "품번", "품명", "소요량", "단위", "공정구분", "비고"]; const HEADER_MAP: Record = { "레벨": "level", "level": "level", "품번": "item_number", "품목코드": "item_number", "item_number": "item_number", "item_code": "item_number", "품명": "item_name", "품목명": "item_name", "item_name": "item_name", "소요량": "quantity", "수량": "quantity", "quantity": "quantity", "qty": "quantity", "단위": "unit", "unit": "unit", "공정구분": "process_type", "공정": "process_type", "process_type": "process_type", "비고": "remark", "remark": "remark", }; export function BomExcelUploadModal({ open, onOpenChange, onSuccess, bomId, bomName, }: BomExcelUploadModalProps) { const isVersionMode = !!bomId; const [step, setStep] = useState("upload"); const [parsedRows, setParsedRows] = useState([]); const [fileName, setFileName] = useState(""); const [uploading, setUploading] = useState(false); const [uploadResult, setUploadResult] = useState(null); const [downloading, setDownloading] = useState(false); const [versionName, setVersionName] = useState(""); const fileInputRef = useRef(null); const reset = useCallback(() => { setStep("upload"); setParsedRows([]); setFileName(""); setUploadResult(null); setUploading(false); setVersionName(""); if (fileInputRef.current) fileInputRef.current.value = ""; }, []); const handleClose = useCallback(() => { reset(); onOpenChange(false); }, [reset, onOpenChange]); const handleFileSelect = useCallback(async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setFileName(file.name); try { const rawData = await importFromExcel(file); if (!rawData || rawData.length === 0) { toast.error("엑셀 파일에 데이터가 없습니다"); return; } const firstRow = rawData[0]; const excelHeaders = Object.keys(firstRow); const fieldMap: Record = {}; for (const header of excelHeaders) { const normalized = header.trim().toLowerCase(); const mapped = HEADER_MAP[normalized] || HEADER_MAP[header.trim()]; if (mapped) { fieldMap[header] = mapped; } } const hasItemNumber = excelHeaders.some(h => { const n = h.trim().toLowerCase(); return HEADER_MAP[n] === "item_number" || HEADER_MAP[h.trim()] === "item_number"; }); if (!hasItemNumber) { toast.error("품번 컬럼을 찾을 수 없습니다. 컬럼명을 확인해주세요."); return; } const parsed: ParsedRow[] = []; for (let index = 0; index < rawData.length; index++) { const row = rawData[index]; const getField = (fieldName: string): any => { for (const [excelKey, mappedField] of Object.entries(fieldMap)) { if (mappedField === fieldName) return row[excelKey]; } return undefined; }; const levelRaw = getField("level"); const level = typeof levelRaw === "number" ? levelRaw : parseInt(String(levelRaw || "0"), 10); const itemNumber = String(getField("item_number") || "").trim(); const itemName = String(getField("item_name") || "").trim(); const quantityRaw = getField("quantity"); const quantity = typeof quantityRaw === "number" ? quantityRaw : parseFloat(String(quantityRaw || "1")); const unit = String(getField("unit") || "").trim(); const processType = String(getField("process_type") || "").trim(); const remark = String(getField("remark") || "").trim(); let valid = true; let error = ""; const isHeader = level === 0; if (!itemNumber) { valid = false; error = "품번 필수"; } else if (isNaN(level) || level < 0) { valid = false; error = "레벨 오류"; } else if (index > 0) { const prevLevel = parsed[index - 1]?.level ?? 0; if (level > prevLevel + 1) { valid = false; error = `레벨 점프 (이전: ${prevLevel})`; } } parsed.push({ rowIndex: index + 1, isHeader, level, item_number: itemNumber, item_name: itemName, quantity: isNaN(quantity) ? 1 : quantity, unit, process_type: processType, remark, valid, error, }); } const filtered = parsed.filter(r => r.item_number !== ""); // 새 BOM 생성 모드: 레벨 0 필수 if (!isVersionMode) { const hasHeader = filtered.some(r => r.level === 0); if (!hasHeader) { toast.error("레벨 0(BOM 마스터) 행이 필요합니다. 첫 행에 최상위 품목을 입력해주세요."); return; } } setParsedRows(filtered); setStep("preview"); } catch (err: any) { toast.error(`파일 파싱 실패: ${err.message}`); } }, [isVersionMode]); const handleUpload = useCallback(async () => { const invalidRows = parsedRows.filter(r => !r.valid); if (invalidRows.length > 0) { toast.error(`유효하지 않은 행이 ${invalidRows.length}건 있습니다.`); return; } setUploading(true); try { const rowPayload = parsedRows.map(r => ({ level: r.level, item_number: r.item_number, item_name: r.item_name, quantity: r.quantity, unit: r.unit, process_type: r.process_type, remark: r.remark, })); let res; if (isVersionMode) { res = await apiClient.post(`/bom/${bomId}/excel-upload-version`, { rows: rowPayload, versionName: versionName.trim() || undefined, }); } else { res = await apiClient.post("/bom/excel-upload", { rows: rowPayload }); } if (res.data?.success) { setUploadResult(res.data.data); setStep("result"); const msg = isVersionMode ? `새 버전 생성 완료: 하위품목 ${res.data.data.insertedCount}건` : `BOM 생성 완료: 하위품목 ${res.data.data.insertedCount}건`; toast.success(msg); onSuccess?.(); } else { const errData = res.data?.data; if (errData?.unmatchedItems?.length > 0) { toast.error(`매칭 안 되는 품번: ${errData.unmatchedItems.join(", ")}`); setParsedRows(prev => prev.map(r => { if (errData.unmatchedItems.includes(r.item_number)) { return { ...r, valid: false, error: "품번 미등록" }; } return r; })); } else { toast.error(res.data?.message || "업로드 실패"); } } } catch (err: any) { toast.error(`업로드 오류: ${err.response?.data?.message || err.message}`); } finally { setUploading(false); } }, [parsedRows, isVersionMode, bomId, versionName, onSuccess]); const handleDownloadTemplate = useCallback(async () => { setDownloading(true); try { const XLSX = await import("xlsx"); let data: Record[] = []; if (isVersionMode && bomId) { // 기존 BOM 데이터를 템플릿으로 다운로드 try { const res = await apiClient.get(`/bom/${bomId}/excel-download`); if (res.data?.success && res.data.data?.length > 0) { data = res.data.data.map((row: any) => ({ "레벨": row.level, "품번": row.item_number, "품명": row.item_name, "소요량": row.quantity, "단위": row.unit, "공정구분": row.process_type, "비고": row.remark, })); } } catch { /* 데이터 없으면 빈 템플릿 */ } } if (data.length === 0) { if (isVersionMode) { data = [ { "레벨": 1, "품번": "(자품목 품번)", "품명": "(자품목 품명)", "소요량": 2, "단위": "EA", "공정구분": "", "비고": "" }, { "레벨": 2, "품번": "(하위품목 품번)", "품명": "(하위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "" }, ]; } else { data = [ { "레벨": 0, "품번": "(최상위 품번)", "품명": "(최상위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "BOM 마스터" }, { "레벨": 1, "품번": "(자품목 품번)", "품명": "(자품목 품명)", "소요량": 2, "단위": "EA", "공정구분": "", "비고": "" }, { "레벨": 2, "품번": "(하위품목 품번)", "품명": "(하위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "" }, ]; } } const ws = XLSX.utils.json_to_sheet(data); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, "BOM"); ws["!cols"] = [ { wch: 6 }, { wch: 18 }, { wch: 20 }, { wch: 10 }, { wch: 8 }, { wch: 12 }, { wch: 20 }, ]; const filename = bomName ? `BOM_${bomName}.xlsx` : "BOM_template.xlsx"; XLSX.writeFile(wb, filename); toast.success("템플릿 다운로드 완료"); } catch (err: any) { toast.error(`다운로드 실패: ${err.message}`); } finally { setDownloading(false); } }, [isVersionMode, bomId, bomName]); const headerRow = parsedRows.find(r => r.isHeader); const detailRows = parsedRows.filter(r => !r.isHeader); const validCount = parsedRows.filter(r => r.valid).length; const invalidCount = parsedRows.filter(r => !r.valid).length; const title = isVersionMode ? "BOM 새 버전 엑셀 업로드" : "BOM 엑셀 업로드"; const description = isVersionMode ? `${bomName || "선택된 BOM"}의 새 버전을 엑셀 파일로 생성합니다. 레벨 0 행은 건너뜁니다.` : "엑셀 파일로 새 BOM을 생성합니다. 레벨 0 = BOM 마스터, 레벨 1 이상 = 하위품목."; return ( { if (!v) handleClose(); }}> {title} {description} {/* Step 1: 파일 업로드 */} {step === "upload" && (
{/* 새 버전 모드: 버전명 입력 */} {isVersionMode && (
setVersionName(e.target.value)} placeholder="예: 2.0" className="h-8 text-xs sm:h-10 sm:text-sm mt-1" />
)}
fileInputRef.current?.click()} >

엑셀 파일을 선택하세요

.xlsx, .xls, .csv 형식 지원

엑셀 컬럼 형식

{EXPECTED_HEADERS.map((h, i) => ( {h}{i < 2 ? " *" : ""} ))}

{isVersionMode ? "* 레벨 1 = 직접 자품목, 레벨 2 = 자품목의 자품목. 레벨 0 행이 있으면 건너뜁니다." : "* 레벨 0 = BOM 마스터(최상위 품목, 1행), 레벨 1 = 직접 자품목, 레벨 2 = 자품목의 자품목." }

)} {/* Step 2: 미리보기 */} {step === "preview" && (
{fileName} {!isVersionMode && headerRow && ( 마스터: {headerRow.item_number} )} 하위품목 {detailRows.length} {invalidCount > 0 && ( {invalidCount}건 오류 )}
{parsedRows.map((row) => ( ))}
# 구분 레벨 품번 품명 소요량 단위 공정 비고
{row.rowIndex} {row.isHeader ? ( {isVersionMode ? "건너뜀" : "마스터"} ) : row.valid ? ( ) : ( )} {row.level} {row.item_number} {row.item_name} {row.quantity} {row.unit} {row.process_type} {row.remark}
{invalidCount > 0 && (
유효하지 않은 행 ({invalidCount}건)
    {parsedRows.filter(r => !r.valid).slice(0, 5).map(r => (
  • {r.rowIndex}행: {r.error}
  • ))} {invalidCount > 5 &&
  • ...외 {invalidCount - 5}건
  • }
)}
{isVersionMode ? "레벨 1 이상의 하위품목으로 새 버전을 생성합니다." : "레벨 0 품목으로 새 BOM 마스터를 생성하고, 레벨 1 이상은 하위품목으로 등록합니다." }
)} {/* Step 3: 결과 */} {step === "result" && uploadResult && (

{isVersionMode ? "새 버전 생성 완료" : "BOM 생성 완료"}

하위품목 {uploadResult.insertedCount}건이 등록되었습니다.

{!isVersionMode && (
1
BOM 마스터
)}
{uploadResult.insertedCount}
하위품목
)} {step === "upload" && ( )} {step === "preview" && ( <> )} {step === "result" && ( )}
); }