diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-29 11:48:59 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-29 11:48:59 +0000 |
| commit | 10f90dc68dec42e9a64e081cc0dce6a484447290 (patch) | |
| tree | 5bc8bb30e03b09a602e7d414d943d0e7f24b1a0f /lib/vendor-document-list/plant/excel-import-export.ts | |
| parent | 792fb0c21136eededecf52b5b4aa1a252bdc4bfb (diff) | |
(대표님, 박서영, 최겸) document-list-only, gtc, vendorDocu, docu-list-rule
Diffstat (limited to 'lib/vendor-document-list/plant/excel-import-export.ts')
| -rw-r--r-- | lib/vendor-document-list/plant/excel-import-export.ts | 788 |
1 files changed, 788 insertions, 0 deletions
diff --git a/lib/vendor-document-list/plant/excel-import-export.ts b/lib/vendor-document-list/plant/excel-import-export.ts new file mode 100644 index 00000000..3ddb7195 --- /dev/null +++ b/lib/vendor-document-list/plant/excel-import-export.ts @@ -0,0 +1,788 @@ +// 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 { DocumentStagesOnlyView } 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<any[]> { + 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<string, number> = {} + 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: DocumentStagesOnlyView[], + 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: DocumentStagesOnlyView[]) { + 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 +}
\ No newline at end of file |
