// excel-import-export.ts "use client" import ExcelJS from 'exceljs' import { excelDocumentRowSchema, excelStageRowSchema, type ExcelDocumentRow, type ExcelStageRow, type ExcelImportResult, type CreateDocumentInput } from './document-stage-validations' import { StageDocumentsView } from '@/db/schema' // ============================================================================= // 1. 엑셀 템플릿 생성 및 다운로드 // ============================================================================= // 문서 템플릿 생성 export async function createDocumentTemplate(projectType: "ship" | "plant") { const workbook = new ExcelJS.Workbook() const worksheet = workbook.addWorksheet("문서목록", { properties: { defaultColWidth: 15 } }) const baseHeaders = [ "문서번호*", "문서명*", "문서종류*", "PIC", "발행일", "설명" ] const plantHeaders = [ "벤더문서번호", "벤더명", "벤더코드" ] const b4Headers = [ "C구분", "D구분", "Degree구분", "부서구분", "S구분", "J구분" ] const headers = [ ...baseHeaders, ...(projectType === "plant" ? plantHeaders : []), ...b4Headers ] // 헤더 행 추가 및 스타일링 const headerRow = worksheet.addRow(headers) headerRow.eachCell((cell, colNumber) => { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4472C4' } } cell.font = { color: { argb: 'FFFFFFFF' }, bold: true, size: 11 } cell.alignment = { horizontal: 'center', vertical: 'middle' } cell.border = { top: { style: 'thin' }, left: { style: 'thin' }, bottom: { style: 'thin' }, right: { style: 'thin' } } // 필수 필드 표시 if (cell.value && String(cell.value).includes('*')) { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE74C3C' } } } }) // 샘플 데이터 추가 const sampleData = projectType === "ship" ? [ "SH-2024-001", "기본 설계 도면", "B3", "김철수", new Date("2024-01-15"), "선박 기본 설계 관련 문서", "", "", "", "", "", "" // B4 필드들 ] : [ "PL-2024-001", "공정 설계 도면", "B4", "이영희", new Date("2024-01-15"), "플랜트 공정 설계 관련 문서", "V-001", // 벤더문서번호 "삼성엔지니어링", // 벤더명 "SENG", // 벤더코드 "C1", "D1", "DEG1", "DEPT1", "S1", "J1" // B4 필드들 ] const sampleRow = worksheet.addRow(sampleData) sampleRow.eachCell((cell, colNumber) => { cell.border = { top: { style: 'thin' }, left: { style: 'thin' }, bottom: { style: 'thin' }, right: { style: 'thin' } } // 날짜 형식 설정 if (cell.value instanceof Date) { cell.numFmt = 'yyyy-mm-dd' } }) // 컬럼 너비 자동 조정 worksheet.columns.forEach((column, index) => { if (index < 6) { column.width = headers[index].length + 5 } else { column.width = 12 } }) // 문서종류 드롭다운 설정 const docTypeCol = headers.indexOf("문서종류*") + 1 worksheet.dataValidations.add(`${String.fromCharCode(64 + docTypeCol)}2:${String.fromCharCode(64 + docTypeCol)}1000`, { type: 'list', allowBlank: false, formulae: ['"B3,B4,B5"'] }) // Plant 프로젝트의 경우 우선순위 드롭다운 추가 if (projectType === "plant") { // 여기에 추가적인 드롭다운들을 설정할 수 있습니다 } return workbook } // 스테이지 템플릿 생성 export async function createStageTemplate() { const workbook = new ExcelJS.Workbook() const worksheet = workbook.addWorksheet("스테이지목록", { properties: { defaultColWidth: 15 } }) const headers = [ "문서번호*", "스테이지명*", "계획일", "우선순위", "담당자", "설명", "스테이지순서" ] // 헤더 행 추가 및 스타일링 const headerRow = worksheet.addRow(headers) headerRow.eachCell((cell, colNumber) => { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF27AE60' } } cell.font = { color: { argb: 'FFFFFFFF' }, bold: true, size: 11 } cell.alignment = { horizontal: 'center', vertical: 'middle' } cell.border = { top: { style: 'thin' }, left: { style: 'thin' }, bottom: { style: 'thin' }, right: { style: 'thin' } } // 필수 필드 표시 if (cell.value && String(cell.value).includes('*')) { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE74C3C' } } } }) // 샘플 데이터 추가 const sampleData = [ [ "SH-2024-001", "초기 설계 검토", new Date("2024-02-15"), "HIGH", "김철수", "초기 설계안 검토 및 승인", 0 ], [ "SH-2024-001", "상세 설계", new Date("2024-03-15"), "MEDIUM", "이영희", "상세 설계 작업 수행", 1 ] ] sampleData.forEach(rowData => { const row = worksheet.addRow(rowData) row.eachCell((cell, colNumber) => { cell.border = { top: { style: 'thin' }, left: { style: 'thin' }, bottom: { style: 'thin' }, right: { style: 'thin' } } // 날짜 형식 설정 if (cell.value instanceof Date) { cell.numFmt = 'yyyy-mm-dd' } }) }) // 컬럼 너비 설정 worksheet.columns = [ { width: 15 }, // 문서번호 { width: 20 }, // 스테이지명 { width: 12 }, // 계획일 { width: 10 }, // 우선순위 { width: 15 }, // 담당자 { width: 30 }, // 설명 { width: 12 }, // 스테이지순서 ] // 우선순위 드롭다운 설정 worksheet.dataValidations.add('D2:D1000', { type: 'list', allowBlank: true, formulae: ['"HIGH,MEDIUM,LOW"'] }) return workbook } // 템플릿 다운로드 함수 export async function downloadTemplate(type: "documents" | "stages", projectType: "ship" | "plant") { const workbook = await (type === "documents" ? createDocumentTemplate(projectType) : createStageTemplate()) const filename = type === "documents" ? `문서_임포트_템플릿_${projectType}.xlsx` : `스테이지_임포트_템플릿.xlsx` // 브라우저에서 다운로드 const buffer = await workbook.xlsx.writeBuffer() const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) const url = window.URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = filename link.click() // 메모리 정리 window.URL.revokeObjectURL(url) } // ============================================================================= // 2. 엑셀 파일 읽기 및 파싱 // ============================================================================= // 엑셀 파일을 읽어서 JSON으로 변환 export async function readExcelFile(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = async (e) => { try { const buffer = e.target?.result as ArrayBuffer const workbook = new ExcelJS.Workbook() await workbook.xlsx.load(buffer) const worksheet = workbook.getWorksheet(1) // 첫 번째 워크시트 if (!worksheet) { throw new Error('워크시트를 찾을 수 없습니다') } const jsonData: any[] = [] worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => { const rowData: any[] = [] row.eachCell({ includeEmpty: true }, (cell, colNumber) => { let value = cell.value // 날짜 처리 if (cell.type === ExcelJS.ValueType.Date) { value = cell.value as Date } // 수식 결과값 처리 else if (cell.type === ExcelJS.ValueType.Formula && cell.result) { value = cell.result } // 하이퍼링크 처리 else if (cell.type === ExcelJS.ValueType.Hyperlink) { value = cell.value?.text || cell.value } rowData[colNumber - 1] = value || "" }) jsonData.push(rowData) }) resolve(jsonData) } catch (error) { reject(new Error('엑셀 파일을 읽는 중 오류가 발생했습니다: ' + error)) } } reader.onerror = () => { reject(new Error('파일을 읽을 수 없습니다')) } reader.readAsArrayBuffer(file) }) } // 문서 데이터 유효성 검사 및 변환 export function validateDocumentRows( rawData: any[], contractId: number, projectType: "ship" | "plant" ): { validData: CreateDocumentInput[], errors: any[] } { if (rawData.length < 2) { throw new Error('데이터가 없습니다. 최소 헤더와 1개 행이 필요합니다.') } const headers = rawData[0] as string[] const rows = rawData.slice(1) const validData: CreateDocumentInput[] = [] const errors: any[] = [] // 필수 헤더 검사 const requiredHeaders = ["문서번호", "문서명", "문서종류"] const missingHeaders = requiredHeaders.filter(h => !headers.some(header => header.includes(h.replace("*", ""))) ) if (missingHeaders.length > 0) { throw new Error(`필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`) } // 헤더 인덱스 매핑 const headerMap: Record = {} headers.forEach((header, index) => { const cleanHeader = header.replace("*", "").trim() headerMap[cleanHeader] = index }) // 각 행 처리 rows.forEach((row: any[], rowIndex) => { try { // 빈 행 스킵 if (row.every(cell => !cell || String(cell).trim() === "")) { return } const rowData: any = { contractId, docNumber: String(row[headerMap["문서번호"]] || "").trim(), title: String(row[headerMap["문서명"]] || "").trim(), drawingKind: String(row[headerMap["문서종류"]] || "").trim(), pic: String(row[headerMap["PIC"]] || "").trim() || undefined, issuedDate: row[headerMap["발행일"]] ? formatExcelDate(row[headerMap["발행일"]]) : undefined, } // Plant 프로젝트 전용 필드 if (projectType === "plant") { rowData.vendorDocNumber = String(row[headerMap["벤더문서번호"]] || "").trim() || undefined } // B4 전용 필드들 const b4Fields = ["C구분", "D구분", "Degree구분", "부서구분", "S구분", "J구분"] const b4FieldMap = { "C구분": "cGbn", "D구분": "dGbn", "Degree구분": "degreeGbn", "부서구분": "deptGbn", "S구분": "sGbn", "J구분": "jGbn" } b4Fields.forEach(field => { if (headerMap[field] !== undefined) { const value = String(row[headerMap[field]] || "").trim() if (value) { rowData[b4FieldMap[field as keyof typeof b4FieldMap]] = value } } }) // 유효성 검사 const validatedData = excelDocumentRowSchema.parse({ "문서번호": rowData.docNumber, "문서명": rowData.title, "문서종류": rowData.drawingKind, "벤더문서번호": rowData.vendorDocNumber, "PIC": rowData.pic, "발행일": rowData.issuedDate, "C구분": rowData.cGbn, "D구분": rowData.dGbn, "Degree구분": rowData.degreeGbn, "부서구분": rowData.deptGbn, "S구분": rowData.sGbn, "J구분": rowData.jGbn, }) // CreateDocumentInput 형태로 변환 const documentInput: CreateDocumentInput = { contractId, docNumber: validatedData["문서번호"], title: validatedData["문서명"], drawingKind: validatedData["문서종류"], vendorDocNumber: validatedData["벤더문서번호"], pic: validatedData["PIC"], issuedDate: validatedData["발행일"], cGbn: validatedData["C구분"], dGbn: validatedData["D구분"], degreeGbn: validatedData["Degree구분"], deptGbn: validatedData["부서구분"], sGbn: validatedData["S구분"], jGbn: validatedData["J구분"], } validData.push(documentInput) } catch (error) { errors.push({ row: rowIndex + 2, // 엑셀 행 번호 (헤더 포함) message: error instanceof Error ? error.message : "알 수 없는 오류", data: row }) } }) return { validData, errors } } // 엑셀 날짜 형식 변환 function formatExcelDate(value: any): string | undefined { if (!value) return undefined // ExcelJS에서 Date 객체로 처리된 경우 if (value instanceof Date) { return value.toISOString().split('T')[0] } // 이미 문자열 날짜 형식인 경우 if (typeof value === 'string') { const dateMatch = value.match(/^\d{4}-\d{2}-\d{2}$/) if (dateMatch) return value // 다른 형식 시도 const date = new Date(value) if (!isNaN(date.getTime())) { return date.toISOString().split('T')[0] } } // 엑셀 시리얼 날짜인 경우 if (typeof value === 'number') { // ExcelJS는 이미 Date 객체로 변환해주므로 이 경우는 드물지만 // 1900년 1월 1일부터의 일수로 계산 const excelEpoch = new Date(1900, 0, 1) const date = new Date(excelEpoch.getTime() + (value - 2) * 24 * 60 * 60 * 1000) if (!isNaN(date.getTime())) { return date.toISOString().split('T')[0] } } return undefined } // ============================================================================= // 3. 데이터 익스포트 // ============================================================================= // 문서 데이터를 엑셀로 익스포트 export function exportDocumentsToExcel( documents: StageDocumentsView[], projectType: "ship" | "plant" ) { const headers = [ "문서번호", "문서명", "문서종류", "PIC", "발행일", "현재스테이지", "스테이지상태", "계획일", "담당자", "우선순위", "진행률(%)", "완료스테이지", "전체스테이지", "지연여부", "남은일수", "생성일", "수정일" ] // Plant 프로젝트 전용 헤더 추가 if (projectType === "plant") { headers.splice(3, 0, "벤더문서번호", "벤더명", "벤더코드") } const data = documents.map(doc => { const baseData = [ doc.docNumber, doc.title, doc.drawingKind || "", doc.pic || "", doc.issuedDate || "", doc.currentStageName || "", getStatusText(doc.currentStageStatus || ""), doc.currentStagePlanDate || "", doc.currentStageAssigneeName || "", getPriorityText(doc.currentStagePriority || ""), doc.progressPercentage || 0, doc.completedStages || 0, doc.totalStages || 0, doc.isOverdue ? "예" : "아니오", doc.daysUntilDue || "", doc.createdAt ? new Date(doc.createdAt).toLocaleDateString() : "", doc.updatedAt ? new Date(doc.updatedAt).toLocaleDateString() : "" ] // Plant 프로젝트 데이터 추가 if (projectType === "plant") { baseData.splice(3, 0, doc.vendorDocNumber || "", doc.vendorName || "", doc.vendorCode || "" ) } return baseData }) const worksheet = XLSX.utils.aoa_to_sheet([headers, ...data]) // 컬럼 너비 설정 const colWidths = [ { wch: 15 }, // 문서번호 { wch: 30 }, // 문서명 { wch: 10 }, // 문서종류 ...(projectType === "plant" ? [ { wch: 15 }, // 벤더문서번호 { wch: 20 }, // 벤더명 { wch: 10 }, // 벤더코드 ] : []), { wch: 10 }, // PIC { wch: 12 }, // 발행일 { wch: 15 }, // 현재스테이지 { wch: 10 }, // 스테이지상태 { wch: 12 }, // 계획일 { wch: 10 }, // 담당자 { wch: 8 }, // 우선순위 { wch: 8 }, // 진행률 { wch: 8 }, // 완료스테이지 { wch: 8 }, // 전체스테이지 { wch: 8 }, // 지연여부 { wch: 8 }, // 남은일수 { wch: 12 }, // 생성일 { wch: 12 }, // 수정일 ] worksheet['!cols'] = colWidths const workbook = XLSX.utils.book_new() XLSX.utils.book_append_sheet(workbook, worksheet, "문서목록") const filename = `문서목록_${new Date().toISOString().split('T')[0]}.xlsx` XLSX.writeFile(workbook, filename) } // 스테이지 상세 데이터를 엑셀로 익스포트 export function exportStageDetailsToExcel(documents: StageDocumentsView[]) { const headers = [ "문서번호", "문서명", "스테이지명", "스테이지상태", "스테이지순서", "계획일", "담당자", "우선순위", "설명", "노트", "알림일수" ] const data: any[] = [] documents.forEach(doc => { if (doc.allStages && doc.allStages.length > 0) { doc.allStages.forEach(stage => { data.push([ doc.docNumber, doc.title, stage.stageName, getStatusText(stage.stageStatus), stage.stageOrder, stage.planDate || "", stage.assigneeName || "", getPriorityText(stage.priority), stage.description || "", stage.notes || "", stage.reminderDays || "" ]) }) } else { // 스테이지가 없는 문서도 포함 data.push([ doc.docNumber, doc.title, "", "", "", "", "", "", "", "", "" ]) } }) const worksheet = XLSX.utils.aoa_to_sheet([headers, ...data]) // 컬럼 너비 설정 worksheet['!cols'] = [ { wch: 15 }, // 문서번호 { wch: 30 }, // 문서명 { wch: 20 }, // 스테이지명 { wch: 12 }, // 스테이지상태 { wch: 8 }, // 스테이지순서 { wch: 12 }, // 계획일 { wch: 10 }, // 담당자 { wch: 8 }, // 우선순위 { wch: 25 }, // 설명 { wch: 25 }, // 노트 { wch: 8 }, // 알림일수 ] const workbook = XLSX.utils.book_new() XLSX.utils.book_append_sheet(workbook, worksheet, "스테이지상세") const filename = `스테이지상세_${new Date().toISOString().split('T')[0]}.xlsx` XLSX.writeFile(workbook, filename) } // ============================================================================= // 4. 유틸리티 함수들 // ============================================================================= function getStatusText(status: string): string { switch (status) { case 'PLANNED': return '계획됨' case 'IN_PROGRESS': return '진행중' case 'SUBMITTED': return '제출됨' case 'UNDER_REVIEW': return '검토중' case 'APPROVED': return '승인됨' case 'REJECTED': return '반려됨' case 'COMPLETED': return '완료됨' default: return status } } function getPriorityText(priority: string): string { switch (priority) { case 'HIGH': return '높음' case 'MEDIUM': return '보통' case 'LOW': return '낮음' default: return priority } } // 파일 크기 검증 export function validateFileSize(file: File, maxSizeMB: number = 10): boolean { const maxSizeBytes = maxSizeMB * 1024 * 1024 return file.size <= maxSizeBytes } // 파일 확장자 검증 export function validateFileExtension(file: File): boolean { const allowedExtensions = ['.xlsx', '.xls'] const fileName = file.name.toLowerCase() return allowedExtensions.some(ext => fileName.endsWith(ext)) } // ExcelJS 워크북의 유효성 검사 export async function validateExcelWorkbook(file: File): Promise<{ isValid: boolean error?: string worksheetCount?: number firstWorksheetName?: string }> { try { const buffer = await file.arrayBuffer() const workbook = new ExcelJS.Workbook() await workbook.xlsx.load(buffer) const worksheets = workbook.worksheets if (worksheets.length === 0) { return { isValid: false, error: '워크시트가 없는 파일입니다' } } const firstWorksheet = worksheets[0] if (firstWorksheet.rowCount < 2) { return { isValid: false, error: '데이터가 없습니다. 최소 헤더와 1개 행이 필요합니다' } } return { isValid: true, worksheetCount: worksheets.length, firstWorksheetName: firstWorksheet.name } } catch (error) { return { isValid: false, error: `파일을 읽을 수 없습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}` } } } // 셀 값을 안전하게 문자열로 변환 export function getCellValueAsString(cell: ExcelJS.Cell): string { if (!cell.value) return "" if (cell.value instanceof Date) { return cell.value.toISOString().split('T')[0] } if (typeof cell.value === 'object' && 'text' in cell.value) { return cell.value.text || "" } if (typeof cell.value === 'object' && 'result' in cell.value) { return String(cell.value.result || "") } return String(cell.value) } // 엑셀 컬럼 인덱스를 문자로 변환 (A, B, C, ... Z, AA, AB, ...) export function getExcelColumnName(index: number): string { let result = "" while (index > 0) { index-- result = String.fromCharCode(65 + (index % 26)) + result index = Math.floor(index / 26) } return result }