diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-14 06:12:13 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-14 06:12:13 +0000 |
| commit | d0d2eaa2de58a0c33e9a21604b126961403cd69e (patch) | |
| tree | f66cd3c8d3a123ff04f800b4b868c573fab2da95 /lib/items-tech/table/import-excel-button.tsx | |
| parent | 21d8148fc5b1234cd4523e66ccaa8971ad104560 (diff) | |
(최겸) 기술영업 조선, 해양Top, 해양 Hull 아이템 리스트 개발(CRUD, excel import/export/template)
Diffstat (limited to 'lib/items-tech/table/import-excel-button.tsx')
| -rw-r--r-- | lib/items-tech/table/import-excel-button.tsx | 298 |
1 files changed, 298 insertions, 0 deletions
diff --git a/lib/items-tech/table/import-excel-button.tsx b/lib/items-tech/table/import-excel-button.tsx new file mode 100644 index 00000000..8c4a9e16 --- /dev/null +++ b/lib/items-tech/table/import-excel-button.tsx @@ -0,0 +1,298 @@ +"use client" + +import * as React from "react" +import { Upload } from "lucide-react" +import { toast } from "sonner" +import * as ExcelJS from 'exceljs' + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Progress } from "@/components/ui/progress" +import { processFileImport } from "./ship/import-item-handler" +import { processTopFileImport } from "./top/import-item-handler" +import { processHullFileImport } from "./hull/import-item-handler" + + +// 선박 아이템 타입 +type ItemType = "ship" | "top" | "hull"; + +const ITEM_TYPE_NAMES = { + ship: "조선 아이템", + top: "해양 TOP 아이템", + hull: "해양 HULL 아이템", +}; + +interface ImportItemButtonProps { + itemType: ItemType; + onSuccess?: () => void; +} + +export function ImportItemButton({ itemType, onSuccess }: ImportItemButtonProps) { + const [open, setOpen] = React.useState(false); + const [file, setFile] = React.useState<File | null>(null); + const [isUploading, setIsUploading] = React.useState(false); + const [progress, setProgress] = React.useState(0); + const [error, setError] = React.useState<string | null>(null); + + const fileInputRef = React.useRef<HTMLInputElement>(null); + + // 파일 선택 처리 + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const selectedFile = e.target.files?.[0]; + if (!selectedFile) return; + + if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) { + setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다."); + return; + } + + setFile(selectedFile); + setError(null); + }; + + + // 데이터 가져오기 처리 + const handleImport = async () => { + if (!file) { + setError("가져올 파일을 선택해주세요."); + return; + } + + try { + setIsUploading(true); + setProgress(0); + setError(null); + + // 파일을 ArrayBuffer로 읽기 + const arrayBuffer = await file.arrayBuffer(); + + // ExcelJS 워크북 로드 + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(arrayBuffer); + + // 첫 번째 워크시트 가져오기 + const worksheet = workbook.worksheets[0]; + if (!worksheet) { + throw new Error("Excel 파일에 워크시트가 없습니다."); + } + + // 헤더 행 찾기 + let headerRowIndex = 1; + let headerRow: ExcelJS.Row | undefined; + let headerValues: (string | null)[] = []; + + worksheet.eachRow((row, rowNumber) => { + const values = row.values as (string | null)[]; + if (!headerRow && values.some(v => v === "아이템 코드" || v === "itemCode" || v === "item_code")) { + headerRowIndex = rowNumber; + headerRow = row; + headerValues = [...values]; + } + }); + + if (!headerRow) { + throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다."); + } + + // 헤더를 기반으로 인덱스 매핑 생성 + const headerMapping: Record<string, number> = {}; + headerValues.forEach((value, index) => { + if (typeof value === 'string') { + headerMapping[value] = index; + } + }); + + // 필수 헤더 확인 (타입별 구분) + let requiredHeaders: string[] = ["아이템 코드", "아이템 명", "기능(공종)"]; + + // 해양 TOP 및 HULL의 경우 선종 헤더는 필요 없음 + if (itemType === "ship") { + requiredHeaders = [...requiredHeaders, "A-MAX", "S-MAX", "LNGC", "VLCC", "CONT"]; + } + + const alternativeHeaders = { + "아이템 코드": ["itemCode", "item_code"], + "아이템 명": ["itemName", "item_name"], + "기능(공종)": ["workType"], + "설명": ["description"], + "항목1": ["itemList1"], + "항목2": ["itemList2"], + "항목3": ["itemList3"], + "항목4": ["itemList4"] + }; + + // 헤더 매핑 확인 (대체 이름 포함) + const missingHeaders = requiredHeaders.filter(header => { + const alternatives = alternativeHeaders[header as keyof typeof alternativeHeaders] || []; + return !(header in headerMapping) && + !alternatives.some(alt => alt in headerMapping); + }); + + if (missingHeaders.length > 0) { + throw new Error(`다음 필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`); + } + + // 데이터 행 추출 (헤더 이후 행부터) + const dataRows: Record<string, any>[] = []; + + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > headerRowIndex) { + const rowData: Record<string, any> = {}; + const values = row.values as (string | null | undefined)[]; + + // 헤더 매핑에 따라 데이터 추출 + Object.entries(headerMapping).forEach(([header, index]) => { + rowData[header] = values[index] || ""; + }); + + // 빈 행이 아닌 경우만 추가 + if (Object.values(rowData).some(value => value && value.toString().trim() !== "")) { + dataRows.push(rowData); + } + } + }); + + if (dataRows.length === 0) { + throw new Error("Excel 파일에 가져올 데이터가 없습니다."); + } + + // 진행 상황 업데이트를 위한 콜백 + const updateProgress = (current: number, total: number) => { + const percentage = Math.round((current / total) * 100); + setProgress(percentage); + }; + + // 선택된 타입에 따라 적절한 프로세스 함수 호출 + let result; + if (itemType === "top") { + result = await processTopFileImport(dataRows, updateProgress); + } else if (itemType === "hull") { + result = await processHullFileImport(dataRows, updateProgress); + } else { + result = await processFileImport(dataRows, updateProgress); + } + + toast.success(`${result.successCount}개의 ${ITEM_TYPE_NAMES[itemType]}이(가) 성공적으로 가져와졌습니다.`); + + if (result.errorCount > 0) { + toast.warning(`${result.errorCount}개의 항목은 처리할 수 없었습니다.`); + } + + // 상태 초기화 및 다이얼로그 닫기 + setFile(null); + setOpen(false); + + // 성공 콜백 호출 + if (onSuccess) { + onSuccess(); + } + } catch (error) { + console.error("Excel 파일 처리 중 오류 발생:", error); + setError(error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다."); + } finally { + setIsUploading(false); + } + }; + + + + // 다이얼로그 열기/닫기 핸들러 + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + // 닫을 때 상태 초기화 + setFile(null); + setError(null); + setProgress(0); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + setOpen(newOpen); + }; + + return ( + <> + <Button + variant="outline" + size="sm" + className="gap-2" + onClick={() => setOpen(true)} + disabled={isUploading} + > + <Upload className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Import</span> + </Button> + + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>{ITEM_TYPE_NAMES[itemType]} 가져오기</DialogTitle> + <DialogDescription> + {ITEM_TYPE_NAMES[itemType]}을 Excel 파일에서 가져옵니다. + <br /> + 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 py-4"> + <div className="flex items-center gap-4"> + <input + type="file" + ref={fileInputRef} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-foreground file:font-medium" + accept=".xlsx,.xls" + onChange={handleFileChange} + disabled={isUploading} + /> + </div> + + {file && ( + <div className="text-sm text-muted-foreground"> + 선택된 파일: <span className="font-medium">{file.name}</span> ({(file.size / 1024).toFixed(1)} KB) + </div> + )} + + {isUploading && ( + <div className="space-y-2"> + <Progress value={progress} /> + <p className="text-sm text-muted-foreground text-center"> + {progress}% 완료 + </p> + </div> + )} + + {error && ( + <div className="text-sm font-medium text-destructive"> + {error} + </div> + )} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => setOpen(false)} + disabled={isUploading} + > + 취소 + </Button> + <Button + onClick={handleImport} + disabled={!file || isUploading} + > + {isUploading ? "처리 중..." : "가져오기"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </> + ); +}
\ No newline at end of file |
