diff options
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, 0 insertions, 788 deletions
diff --git a/lib/vendor-document-list/plant/excel-import-export.ts b/lib/vendor-document-list/plant/excel-import-export.ts deleted file mode 100644 index c1409205..00000000 --- a/lib/vendor-document-list/plant/excel-import-export.ts +++ /dev/null @@ -1,788 +0,0 @@ -// 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<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: 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 -}
\ No newline at end of file |
