"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 { Badge } from "@/components/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Download, Upload, FileSpreadsheet, AlertCircle, CheckCircle2, XCircle, Loader2, ArrowRight, Info, } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import type { SmartExcelUploadConfig, ParseResult, ParsedSheetData, ValidationError, ItemProcessMapping, } from "./types"; // ValidationError is used in customValidator prop signature export type { ValidationError }; import { generateTemplate } from "./templateGenerator"; import type { GenerateTemplateOptions } from "./templateGenerator"; import { parseTemplate } from "./templateParser"; import type { ParseOptions } from "./templateParser"; export interface SmartExcelUploadModalProps { open: boolean; onOpenChange: (open: boolean) => void; config: SmartExcelUploadConfig; /** 참조 데이터 (DB에서 조회한 검사기준 등) */ referenceData?: Record[]; /** 시트별/컬럼별 드롭다운 옵션 */ dropdownOptions?: Record; /** 품목별 공정 매핑 (INDIRECT 동적 드롭다운용) */ itemProcessMappings?: ItemProcessMapping[]; /** 라벨→코드 변환 매핑 */ labelToCodeMap?: Record>; /** 추가 메타 정보 (item_code 등) */ extraMeta?: Record; /** 업로드 완료 콜백 */ onUpload: (data: ParsedSheetData[]) => Promise; /** 파싱 후 추가 검증 (text 타입 카테고리 검증 등 커스텀 로직) — ValidationError[] 반환, 빈 배열이면 통과 */ customValidator?: (data: ParsedSheetData[]) => ValidationError[]; /** 품목명 등 표시용 제목 */ subtitle?: string; /** 데이터 로딩 중 여부 (외부에서 품목 등 로딩 시) */ dataLoading?: boolean; /** 로딩 진행률 */ loadProgress?: { loaded: number; total: number }; } type Step = "download" | "upload" | "validate" | "preview"; export function SmartExcelUploadModal({ open, onOpenChange, config, referenceData = [], dropdownOptions = {}, itemProcessMappings = [], labelToCodeMap = {}, extraMeta = {}, onUpload, customValidator, subtitle, dataLoading = false, loadProgress, }: SmartExcelUploadModalProps) { const [step, setStep] = useState("download"); const [downloading, setDownloading] = useState(false); const [templateDownloaded, setTemplateDownloaded] = useState(false); const [uploading, setUploading] = useState(false); const [saving, setSaving] = useState(false); const [parseResult, setParseResult] = useState(null); const [previewTab, setPreviewTab] = useState(0); const fileInputRef = useRef(null); const reset = useCallback(() => { setStep("download"); setTemplateDownloaded(false); setParseResult(null); setPreviewTab(0); if (fileInputRef.current) fileInputRef.current.value = ""; }, []); const handleClose = (open: boolean) => { if (!open) reset(); onOpenChange(open); }; // ═══════════════════ 템플릿 다운로드 ═══════════════════ const handleDownload = async () => { setDownloading(true); try { const options: GenerateTemplateOptions = { config, referenceData, dropdownOptions, itemProcessMappings: itemProcessMappings.length > 0 ? itemProcessMappings : undefined, extraMeta, }; const buffer = await generateTemplate(options); const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${config.templateName}_템플릿.xlsx`; a.click(); URL.revokeObjectURL(url); setTemplateDownloaded(true); toast.success("템플릿 다운로드 완료"); } catch (err) { console.error("템플릿 생성 실패:", err); toast.error("템플릿 생성에 실패했습니다"); } finally { setDownloading(false); } }; // ═══════════════════ 파일 업로드 + 파싱 ═══════════════════ const handleFileChange = async ( e: React.ChangeEvent ) => { const file = e.target.files?.[0]; if (!file) return; // 같은 파일 재선택 허용 e.target.value = ""; if (!file.name.endsWith(".xlsx") && !file.name.endsWith(".xls")) { toast.error("엑셀 파일(.xlsx)만 업로드 가능합니다"); return; } setUploading(true); setStep("validate"); setPreviewTab(0); try { const parseOptions: ParseOptions = { config, file, currentReferenceData: referenceData, currentDropdownOptions: dropdownOptions, currentItemProcessMappings: itemProcessMappings.length > 0 ? itemProcessMappings : undefined, labelToCodeMap, }; const result = await parseTemplate(parseOptions); // 커스텀 검증 (text 타입 카테고리 등 parseTemplate가 못 잡는 항목) if (customValidator && result.data.length > 0) { const customErrors = customValidator(result.data); if (customErrors.length > 0) { result.errors = [...result.errors, ...customErrors]; result.success = false; } } setParseResult(result); if (result.success) { setStep("preview"); toast.success("검증 통과! 미리보기를 확인해주세요"); } else if (result.warnings.length > 0 && result.errors.length === 0) { // 경고만 있는 경우 setStep("validate"); } else { setStep("validate"); toast.error(`검증 실패: ${result.errors.length}건의 오류`); } } catch (err) { console.error("파싱 실패:", err); toast.error("파일 파싱에 실패했습니다"); setStep("upload"); } finally { setUploading(false); } }; // ═══════════════════ 저장 ═══════════════════ const handleSave = async () => { if (!parseResult?.data?.length) return; setSaving(true); try { await onUpload(parseResult.data); toast.success("업로드 완료"); handleClose(false); } catch (err) { console.error("저장 실패:", err); toast.error("저장에 실패했습니다"); } finally { setSaving(false); } }; // ═══════════════════ 렌더링 ═══════════════════ const totalRows = parseResult?.data?.reduce((sum, d) => sum + d.rows.length, 0) || 0; return ( {config.templateName} 엑셀 업로드 {subtitle || "템플릿을 다운로드하여 데이터를 작성한 후 업로드해주세요"} {/* 스텝 인디케이터 */}
{( [ { key: "download", label: "템플릿 다운로드", icon: Download }, { key: "upload", label: "파일 업로드", icon: Upload }, { key: "validate", label: "검증", icon: AlertCircle }, { key: "preview", label: "미리보기", icon: CheckCircle2 }, ] as const ).map(({ key, label, icon: Icon }, i) => { const stepOrder = ["download", "upload", "validate", "preview"]; const currentIdx = stepOrder.indexOf(step); const thisIdx = stepOrder.indexOf(key); const isActive = key === step; const isDone = thisIdx < currentIdx; return ( {i > 0 && ( )}
{label}
); })}
{/* 컨텐츠 영역 */}
{/* ── 다운로드 단계 ── */} {(step === "download" || step === "upload") && (
{/* 데이터 로딩 진행률 */} {dataLoading && loadProgress && loadProgress.total > 0 && (
데이터 로딩 중... {loadProgress.loaded.toLocaleString()} / {loadProgress.total.toLocaleString()}
)} {/* 템플릿 다운로드 */}
1. 템플릿 다운로드 {templateDownloaded && ( 완료 )}

시트 구성:{" "} {config.sheets.map((s) => s.name).join(", ")}

{config.referenceSheet && (

참조 데이터: {referenceData.length}건 포함

)}
{/* 파일 업로드 */}
2. 파일 업로드
{uploading && ( )}
{dataLoading && (

데이터 로딩 중입니다. 잠시 기다려주세요

)}
)} {/* ── 검증 결과 (에러) ── */} {step === "validate" && parseResult && (
{/* 경고 메시지 */} {parseResult.warnings.map((w, i) => (
{w}
))} {/* 에러 목록 */} {parseResult.errors.length > 0 && (
검증 오류 {parseResult.errors.length}건
시트 컬럼 오류 내용 {parseResult.errors.map((err, i) => ( {err.sheet} {err.row}행 {err.column} {err.message} ))}
)}
)} {/* ── 미리보기 ── */} {step === "preview" && parseResult?.data && (
검증 통과 총 {totalRows}건
{/* 시트 탭 */} {parseResult.data.length > 1 && (
{parseResult.data.map((sheet, i) => ( ))}
)} {/* 데이터 테이블 */} {parseResult.data[previewTab] && (
# {(() => { const sheetData = parseResult.data[previewTab]; const sheetConfig = config.sheets.find( (s) => s.name === sheetData.sheetName ); return (sheetConfig?.columns || []) .filter((c) => !c.readOnly && !c.autoFill && !c.customFormula) .map((col) => ( {col.label} )); })()} {parseResult.data[previewTab].rows.map((row, i) => { const sheetData = parseResult.data[previewTab]; const sheetConfig = config.sheets.find( (s) => s.name === sheetData.sheetName ); return ( {i + 1} {(sheetConfig?.columns || []) .filter((c) => !c.readOnly && !c.autoFill && !c.customFormula) .map((col) => ( {row[`${col.key}_label`] || row[col.key] || "-"} ))} ); })}
)}
)}
{step === "preview" && ( )}
); }