From 610d3bccf1cb640e2a21df28d8d2a954c2bf337e Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 5 Jun 2025 01:53:35 +0000 Subject: (대표님) 변경사항 0604 - OCR 관련 및 drizzle generated sqls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/vendor-document-list/dolce-upload-service.ts | 655 +++++++++++++++++++++ .../enhanced-document-service.ts | 2 - lib/vendor-document-list/import-service.ts | 487 +++++++++++++++ lib/vendor-document-list/sync-service.ts | 265 ++------- .../table/enhanced-doc-table-toolbar-actions.tsx | 57 +- .../table/enhanced-documents-table.tsx | 2 - .../table/import-from-dolce-button.tsx | 356 +++++++++++ .../table/swp-workflow-panel.tsx | 370 ++++++++++++ lib/vendor-document-list/workflow-service.ts | 195 ++++++ lib/welding/repository.ts | 49 ++ lib/welding/service.ts | 87 +++ lib/welding/table/ocr-table-columns.tsx | 312 ++++++++++ lib/welding/table/ocr-table-toolbar-actions.tsx | 297 ++++++++++ lib/welding/table/ocr-table.tsx | 143 +++++ lib/welding/validation.ts | 36 ++ 15 files changed, 3091 insertions(+), 222 deletions(-) create mode 100644 lib/vendor-document-list/dolce-upload-service.ts create mode 100644 lib/vendor-document-list/import-service.ts create mode 100644 lib/vendor-document-list/table/import-from-dolce-button.tsx create mode 100644 lib/vendor-document-list/table/swp-workflow-panel.tsx create mode 100644 lib/vendor-document-list/workflow-service.ts create mode 100644 lib/welding/repository.ts create mode 100644 lib/welding/service.ts create mode 100644 lib/welding/table/ocr-table-columns.tsx create mode 100644 lib/welding/table/ocr-table-toolbar-actions.tsx create mode 100644 lib/welding/table/ocr-table.tsx create mode 100644 lib/welding/validation.ts (limited to 'lib') diff --git a/lib/vendor-document-list/dolce-upload-service.ts b/lib/vendor-document-list/dolce-upload-service.ts new file mode 100644 index 00000000..0396e819 --- /dev/null +++ b/lib/vendor-document-list/dolce-upload-service.ts @@ -0,0 +1,655 @@ +// lib/vendor-document-list/dolce-upload-service.ts +import db from "@/db/db" +import { documents, revisions, documentAttachments, contracts, projects, vendors, issueStages } from "@/db/schema" +import { eq, and, desc, sql, inArray, min } from "drizzle-orm" +import { v4 as uuidv4 } from "uuid" + +export interface DOLCEUploadResult { + success: boolean + uploadedDocuments: number + uploadedFiles: number + errors?: string[] + results?: { + documentResults?: any[] + fileResults?: any[] + mappingResults?: any[] + } +} + +interface DOLCEDocument { + Mode: "ADD" | "MOD" + Status: string + RegisterId: number + ProjectNo: string + Discipline: string + DrawingKind: string + DrawingNo: string + DrawingName: string + RegisterGroupId: number + RegisterSerialNo: number + RegisterKind: string + DrawingRevNo: string + Category: string + Receiver: string | null + Manager: string + RegisterDesc: string + UploadId?: string + RegCompanyCode: string +} + +interface DOLCEFileMapping { + CGbn?: string + Category?: string + CheckBox: string + DGbn?: string + DegreeGbn?: string + DeptGbn?: string + Discipline: string + DrawingKind: string + DrawingMoveGbn: string + DrawingName: string + DrawingNo: string + DrawingUsage: string + FileNm: string + JGbn?: string + Manager: string + MappingYN: string + NewOrNot: string + ProjectNo: string + RegisterGroup: number + RegisterGroupId: number + RegisterKindCode: string + RegisterSerialNo: number + RevNo?: string + SGbn?: string + UploadId: string +} + +class DOLCEUploadService { + private readonly BASE_URL = process.env.DOLCE_API_URL || 'http://60.100.99.217:1111' + private readonly UPLOAD_SERVICE_URL = process.env.DOLCE_UPLOAD_URL || 'http://60.100.99.217:1111/PWPUploadService.ashx' + + /** + * 메인 업로드 함수: 변경된 문서와 파일을 DOLCE로 업로드 + */ + async uploadToDoLCE( + contractId: number, + revisionIds: number[], + userId: string, + userName?: string + ): Promise { + try { + console.log(`Starting DOLCE upload for contract ${contractId}, revisions: ${revisionIds.join(', ')}`) + + // 1. 계약 정보 조회 (프로젝트 코드, 벤더 코드 등) + const contractInfo = await this.getContractInfo(contractId) + if (!contractInfo) { + throw new Error(`Contract info not found for ID: ${contractId}`) + } + + // 2. 업로드할 리비전 정보 조회 + const revisionsToUpload = await this.getRevisionsForUpload(revisionIds) + if (revisionsToUpload.length === 0) { + return { + success: true, + uploadedDocuments: 0, + uploadedFiles: 0 + } + } + + // 3. 각 issueStageId별로 첫 번째 revision 정보를 미리 조회 (Mode 결정용) + const firstRevisionMap = await this.getFirstRevisionMap(revisionsToUpload.map(r => r.issueStageId)) + + let uploadedDocuments = 0 + let uploadedFiles = 0 + const errors: string[] = [] + const results: any = { + documentResults: [], + fileResults: [], + mappingResults: [] + } + + // 4. 각 리비전별로 처리 + for (const revision of revisionsToUpload) { + try { + console.log(`Processing revision ${revision.revision} for document ${revision.documentNo}`) + + // 4-1. 파일이 있는 경우 먼저 업로드 + let uploadId: string | undefined + if (revision.attachments && revision.attachments.length > 0) { + const fileUploadResults = await this.uploadFiles(revision.attachments) + + if (fileUploadResults.length > 0) { + uploadId = fileUploadResults[0].uploadId // 첫 번째 파일의 UploadId 사용 + uploadedFiles += fileUploadResults.length + results.fileResults.push(...fileUploadResults) + } + } + + // 4-2. 문서 정보 업로드 + const dolceDoc = this.transformToDoLCEDocument( + revision, + contractInfo, + uploadId, + contractInfo.vendorCode, + firstRevisionMap + ) + + const docResult = await this.uploadDocument([dolceDoc], userId) + if (docResult.success) { + uploadedDocuments++ + results.documentResults.push(docResult) + + // 4-3. 파일이 있는 경우 매핑 정보 전송 + if (uploadId && revision.attachments && revision.attachments.length > 0) { + const mappingData = this.transformToFileMapping( + revision, + contractInfo, + uploadId, + revision.attachments[0].fileName + ) + + const mappingResult = await this.uploadFileMapping([mappingData], userId) + if (mappingResult.success) { + results.mappingResults.push(mappingResult) + } else { + errors.push(`File mapping failed for ${revision.documentNo}: ${mappingResult.error}`) + } + } + + // 4-4. 성공한 리비전의 상태 업데이트 + await this.updateRevisionStatus(revision.id, 'SUBMITTED', uploadId) + + } else { + errors.push(`Document upload failed for ${revision.documentNo}: ${docResult.error}`) + } + + } catch (error) { + const errorMessage = `Failed to process revision ${revision.revision}: ${error instanceof Error ? error.message : 'Unknown error'}` + errors.push(errorMessage) + console.error(errorMessage, error) + } + } + + return { + success: errors.length === 0, + uploadedDocuments, + uploadedFiles, + errors: errors.length > 0 ? errors : undefined, + results + } + + } catch (error) { + console.error('DOLCE upload failed:', error) + throw error + } + } + + /** + * 계약 정보 조회 + */ + private async getContractInfo(contractId: number) { + const [result] = await db + .select({ + projectCode: projects.code, + vendorCode: vendors.vendorCode, + contractNo: contracts.contractNo + }) + .from(contracts) + .innerJoin(projects, eq(contracts.projectId, projects.id)) + .innerJoin(vendors, eq(contracts.vendorId, vendors.id)) + .where(eq(contracts.id, contractId)) + .limit(1) + + return result + } + + /** + * 각 issueStageId별로 첫 번째 revision 정보를 조회 + */ + private async getFirstRevisionMap(issueStageIds: number[]): Promise> { + const firstRevisions = await db + .select({ + issueStageId: revisions.issueStageId, + firstRevision: min(revisions.revision) + }) + .from(revisions) + .where(inArray(revisions.issueStageId, issueStageIds)) + .groupBy(revisions.issueStageId) + + const map = new Map() + firstRevisions.forEach(item => { + if (item.firstRevision) { + map.set(item.issueStageId, item.firstRevision) + } + }) + + return map + } + + /** + * 업로드할 리비전 정보 조회 (문서 정보 및 첨부파일 포함) + */ + private async getRevisionsForUpload(revisionIds: number[]) { + // revisions → issueStages → documents 순서로 join하여 정보 조회 + const revisionResults = await db + .select({ + // revision 테이블 정보 + id: revisions.id, + revision: revisions.revision, // revisionNo가 아니라 revision + revisionStatus: revisions.revisionStatus, + uploaderId: revisions.uploaderId, + uploaderName: revisions.uploaderName, + submittedDate: revisions.submittedDate, + comment: revisions.comment, + + // ✅ DOLCE 연동 필드들 (새로 추가) + externalUploadId: revisions.externalUploadId, + externalRegisterId: revisions.id, + externalSentAt: revisions.submittedDate, + + // issueStages 테이블 정보 + issueStageId: issueStages.id, + stageName: issueStages.stageName, + documentId: issueStages.documentId, + + // documents 테이블 정보 (DOLCE 업로드에 필요한 모든 필드) + documentNo: documents.docNumber, + documentName: documents.title, + drawingKind: documents.drawingKind, + drawingMoveGbn: documents.drawingMoveGbn, + discipline: documents.discipline, + registerGroupId: documents.registerGroupId, + + // DOLCE B4 전용 필드들 + cGbn: documents.cGbn, + dGbn: documents.dGbn, + degreeGbn: documents.degreeGbn, + deptGbn: documents.deptGbn, + jGbn: documents.jGbn, + sGbn: documents.sGbn, + + // DOLCE 추가 정보 + manager: documents.manager, + managerENM: documents.managerENM, + managerNo: documents.managerNo, + shiDrawingNo: documents.shiDrawingNo, + + // 외부 시스템 연동 정보 + externalDocumentId: documents.externalDocumentId, + externalSystemType: documents.externalSystemType, + externalSyncedAt: documents.externalSyncedAt + }) + .from(revisions) + .innerJoin(issueStages, eq(revisions.issueStageId, issueStages.id)) + .innerJoin(documents, eq(issueStages.documentId, documents.id)) + .where(inArray(revisions.id, revisionIds)) + + // 각 리비전의 첨부파일 정보도 조회 + const revisionsWithAttachments = [] + for (const revision of revisionResults) { + const attachments = await db + .select({ + id: documentAttachments.id, + fileName: documentAttachments.fileName, + filePath: documentAttachments.filePath, + fileType: documentAttachments.fileType, + fileSize: documentAttachments.fileSize, + createdAt: documentAttachments.createdAt + }) + .from(documentAttachments) + .where(eq(documentAttachments.revisionId, revision.id)) + + revisionsWithAttachments.push({ + ...revision, + attachments + }) + } + + return revisionsWithAttachments + } + + /** + * 파일 업로드 (PWPUploadService.ashx) + */ + private async uploadFiles(attachments: any[]): Promise> { + const uploadResults = [] + + for (const attachment of attachments) { + try { + // UploadId와 FileId 생성 (UUID 형태) + const uploadId = uuidv4() + const fileId = uuidv4() + + // 파일 데이터 읽기 (실제 구현에서는 파일 시스템이나 S3에서 읽어옴) + const fileBuffer = await this.getFileBuffer(attachment.filePath) + + const uploadUrl = `${this.UPLOAD_SERVICE_URL}?UploadId=${uploadId}&FileId=${fileId}` + + const response = await fetch(uploadUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream', + }, + body: fileBuffer + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`File upload failed: HTTP ${response.status} - ${errorText}`) + } + + const filePath = await response.text() // DOLCE에서 반환하는 파일 경로 + + uploadResults.push({ + uploadId, + fileId, + filePath + }) + + console.log(`✅ File uploaded successfully: ${attachment.fileName} -> ${filePath}`) + + } catch (error) { + console.error(`❌ File upload failed for ${attachment.fileName}:`, error) + throw error + } + } + + return uploadResults + } + + /** + * 문서 정보 업로드 (DetailDwgReceiptMgmtEdit) + */ + private async uploadDocument(dwgList: DOLCEDocument[], userId: string): Promise<{success: boolean, error?: string, data?: any}> { + try { + const endpoint = `${this.BASE_URL}/Services/VDCSWebService.svc/DetailDwgReceiptMgmtEdit` + + const requestBody = { + DwgList: dwgList, + UserID: userId + } + + console.log('Uploading documents to DOLCE:', JSON.stringify(requestBody, null, 2)) + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`HTTP ${response.status} - ${errorText}`) + } + + const result = await response.json() + + return { + success: true, + data: result + } + + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } + } + } + + /** + * 파일 매핑 정보 업로드 (MatchBatchFileDwgEdit) + */ + private async uploadFileMapping(mappingList: DOLCEFileMapping[], userId: string): Promise<{success: boolean, error?: string, data?: any}> { + try { + const endpoint = `${this.BASE_URL}/Services/VDCSWebService.svc/MatchBatchFileDwgEdit` + + const requestBody = { + mappingSaveLists: mappingList, + UserID: userId + } + + console.log('Uploading file mapping to DOLCE:', JSON.stringify(requestBody, null, 2)) + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`HTTP ${response.status} - ${errorText}`) + } + + const result = await response.json() + + return { + success: true, + data: result + } + + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } + } + } + + /** + * 리비전 데이터를 DOLCE 문서 형태로 변환 (업데이트된 스키마 사용) + */ + private transformToDoLCEDocument( + revision: any, + contractInfo: any, + uploadId?: string, + vendorCode?: string, + firstRevisionMap?: Map + ): DOLCEDocument { + // Mode 결정: 해당 issueStageId의 첫 번째 revision인지 확인 + let mode: "ADD" | "MOD" = "MOD" // 기본값은 MOD + + if (firstRevisionMap && firstRevisionMap.has(revision.issueStageId)) { + const firstRevision = firstRevisionMap.get(revision.issueStageId) + if (revision.revision === firstRevision) { + mode = "ADD" + } + } + + // RegisterKind 결정: stageName에 따라 설정 + let registerKind = "APPC" // 기본값 + if (revision.stageName) { + const stageNameLower = revision.stageName.toLowerCase() + if (stageNameLower.includes("pre")) { + registerKind = "RECP" + } else if (stageNameLower.includes("working")) { + registerKind = "RECW" + } + } + + const getSerialNumber = (revisionValue: string): number => { + // 먼저 숫자인지 확인 + const numericValue = parseInt(revisionValue) + if (!isNaN(numericValue)) { + return numericValue + } + + // 문자인 경우 (a=1, b=2, c=3, ...) + if (typeof revisionValue === 'string' && revisionValue.length === 1) { + const charCode = revisionValue.toLowerCase().charCodeAt(0) + if (charCode >= 97 && charCode <= 122) { // a-z + return charCode - 96 // a=1, b=2, c=3, ... + } + } + + // 기본값 + return 1 + } + + + return { + Mode: mode, + Status: revision.revisionStatus || "Standby", + RegisterId: revision.externalRegisterId, // 업데이트된 필드 사용 + ProjectNo: contractInfo.projectCode, + Discipline: revision.discipline, + DrawingKind: revision.drawingKind, + DrawingNo: revision.documentNo, + DrawingName: revision.documentName, + RegisterGroupId: revision.registerGroupId || 0, + RegisterSerialNo: getSerialNumber(revision.revision || "1"), + RegisterKind: registerKind, // stageName에 따라 동적 설정 + DrawingRevNo: revision.revision || "-", + Category: revision.category || "TS", + Receiver: null, + Manager: revision.managerNo || "202206", // 담당자 번호 사용 + RegisterDesc: revision.comment || "System upload", + UploadId: uploadId, + RegCompanyCode: vendorCode || "A0005531" // 벤더 코드 + } + } + /** + * 파일 매핑 데이터 변환 + */ + private transformToFileMapping( + revision: any, + contractInfo: any, + uploadId: string, + fileName: string + ): DOLCEFileMapping { + return { + CGbn: revision.cGbn, + Category: revision.category, + CheckBox: "0", + DGbn: revision.dGbn, + DegreeGbn: revision.degreeGbn, + DeptGbn: revision.deptGbn, + Discipline: revision.discipline || "DL", + DrawingKind: revision.drawingKind || "B4", + DrawingMoveGbn: revision.drawingMoveGbn || "도면입수", + DrawingName: revision.documentName, + DrawingNo: revision.documentNo, + DrawingUsage: "입수용", + FileNm: fileName, + JGbn: revision.jGbn, + Manager: revision.managerNo || "970043", + MappingYN: "Y", + NewOrNot: "N", + ProjectNo: contractInfo.projectCode, + RegisterGroup: 0, + RegisterGroupId: revision.registerGroupId || 0, + RegisterKindCode: "RECW", + RegisterSerialNo: parseInt(revision.revision) || 1, + RevNo: revision.revision, + SGbn: revision.sGbn, + UploadId: uploadId + } + } + + /** + * 파일 버퍼 읽기 (실제 파일 시스템 기반) - 타입 에러 수정 + */ + private async getFileBuffer(filePath: string): Promise { + try { + console.log(`Reading file from path: ${filePath}`) + + if (filePath.startsWith('http')) { + // URL인 경우 직접 다운로드 + const response = await fetch(filePath) + if (!response.ok) { + throw new Error(`Failed to download file: ${response.status}`) + } + return await response.arrayBuffer() + } else { + // 로컬 파일 경로인 경우 + const fs = await import('fs') + const path = await import('path') + + let actualFilePath: string + + if (filePath.startsWith('/documents/')) { + // DB에 저장된 경로 형태: "/documents/[uuid].ext" + // 실제 파일 시스템 경로로 변환: "public/documents/[uuid].ext" + actualFilePath = path.join(process.cwd(), 'public', filePath) + } else if (filePath.startsWith('/')) { + // 절대 경로인 경우 public 디렉토리 기준으로 변환 + actualFilePath = path.join(process.cwd(), 'public', filePath) + } else { + // 상대 경로인 경우 그대로 사용 + actualFilePath = filePath + } + + // 파일 존재 여부 확인 + if (!fs.existsSync(actualFilePath)) { + throw new Error(`File not found: ${actualFilePath}`) + } + + // 파일 읽기 + const fileBuffer = fs.readFileSync(actualFilePath) + console.log(`✅ File read successfully: ${actualFilePath} (${fileBuffer.length} bytes)`) + + // Buffer를 ArrayBuffer로 변환 (타입 안전성 보장) + return new ArrayBuffer(fileBuffer.length).slice(0).constructor(fileBuffer) + } + } catch (error) { + console.error(`❌ Failed to read file: ${filePath}`, error) + throw error + } + } + + /** + * 리비전 상태 업데이트 (업데이트된 스키마 사용) + */ + private async updateRevisionStatus(revisionId: number, status: string, uploadId?: string) { + const updateData: any = { + revisionStatus: status, + updatedAt: new Date() + } + + // 업로드 성공 시 관련 날짜 설정 + if (status === 'SUBMITTED') { + updateData.submittedDate = new Date().toISOString().slice(0, 10) + // updateData.externalSentAt = new Date().toISOString().slice(0, 10) + } else if (status === 'APPROVED') { + updateData.approvedDate = new Date().toISOString().slice(0, 10) + } + + // DOLCE 업로드 ID 저장 + if (uploadId) { + updateData.externalUploadId = uploadId + } + + await db + .update(revisions) + .set(updateData) + .where(eq(revisions.id, revisionId)) + + console.log(`✅ Updated revision ${revisionId} status to ${status}${uploadId ? ` with upload ID: ${uploadId}` : ''}`) + } + + /** + * 업로드 가능 여부 확인 + */ + isUploadEnabled(): boolean { + const enabled = process.env.DOLCE_UPLOAD_ENABLED + return enabled === 'true' || enabled === '1' + } +} + +export const dolceUploadService = new DOLCEUploadService() + +// 편의 함수 +export async function uploadRevisionsToDOLCE( + contractId: number, + revisionIds: number[], + userId: string, + userName?: string +): Promise { + return dolceUploadService.uploadToDoLCE(contractId, revisionIds, userId, userName) +} \ No newline at end of file diff --git a/lib/vendor-document-list/enhanced-document-service.ts b/lib/vendor-document-list/enhanced-document-service.ts index d39dfaa4..66758b89 100644 --- a/lib/vendor-document-list/enhanced-document-service.ts +++ b/lib/vendor-document-list/enhanced-document-service.ts @@ -140,8 +140,6 @@ export async function getEnhancedDocuments( return { data, total } }) - console.log(data) - const pageCount = Math.ceil(total / input.perPage) return { data, pageCount, total } diff --git a/lib/vendor-document-list/import-service.ts b/lib/vendor-document-list/import-service.ts new file mode 100644 index 00000000..4a152299 --- /dev/null +++ b/lib/vendor-document-list/import-service.ts @@ -0,0 +1,487 @@ +// lib/vendor-document-list/import-service.ts - DOLCE API 연동 버전 + +import db from "@/db/db" +import { documents, issueStages, contracts, projects, vendors } from "@/db/schema" +import { eq, and, desc, sql } from "drizzle-orm" + +export interface ImportResult { + success: boolean + newCount: number + updatedCount: number + skippedCount: number + errors?: string[] + message?: string +} + +export interface ImportStatus { + lastImportAt?: string + availableDocuments: number + newDocuments: number + updatedDocuments: number + importEnabled: boolean +} + +interface DOLCEDocument { + CGbn?: string + CreateDt: string + CreateUserENM: string + CreateUserId: string + CreateUserNo: string + DGbn?: string + DegreeGbn?: string + DeptGbn?: string + Discipline: string + DrawingKind: string // B3, B4, B5 + DrawingMoveGbn: string + DrawingName: string + DrawingNo: string + GTTInput_PlanDate?: string + GTTInput_ResultDate?: string + GTTPreDwg_PlanDate?: string + GTTPreDwg_ResultDate?: string + GTTWorkingDwg_PlanDate?: string + GTTWorkingDwg_ResultDate?: string + JGbn?: string + Manager: string + ManagerENM: string + ManagerNo: string + ProjectNo: string + RegisterGroup: number + RegisterGroupId: number + SGbn?: string + SHIDrawingNo?: string +} + +class ImportService { + /** + * DOLCE 시스템에서 문서 목록 가져오기 + */ + async importFromExternalSystem( + contractId: number, + sourceSystem: string = 'DOLCE' + ): Promise { + try { + console.log(`Starting import from ${sourceSystem} for contract ${contractId}`) + + // 1. 계약 정보를 통해 프로젝트 코드와 벤더 코드 조회 + const contractInfo = await this.getContractInfoById(contractId) + if (!contractInfo?.projectCode || !contractInfo?.vendorCode) { + throw new Error(`Project code or vendor code not found for contract ${contractId}`) + } + + // 2. 각 drawingKind별로 데이터 조회 + const allDocuments: DOLCEDocument[] = [] + const drawingKinds = ['B3', 'B4', 'B5'] + + for (const drawingKind of drawingKinds) { + try { + const documents = await this.fetchFromDOLCE( + contractInfo.projectCode, + contractInfo.vendorCode, + drawingKind + ) + allDocuments.push(...documents) + console.log(`Fetched ${documents.length} documents for ${drawingKind}`) + } catch (error) { + console.warn(`Failed to fetch ${drawingKind} documents:`, error) + // 개별 drawingKind 실패는 전체 실패로 처리하지 않음 + } + } + + if (allDocuments.length === 0) { + return { + success: true, + newCount: 0, + updatedCount: 0, + skippedCount: 0, + message: '가져올 새로운 데이터가 없습니다.' + } + } + + let newCount = 0 + let updatedCount = 0 + let skippedCount = 0 + const errors: string[] = [] + + // 3. 각 문서 동기화 처리 + for (const dolceDoc of allDocuments) { + try { + const result = await this.syncSingleDocument(contractId, dolceDoc, sourceSystem) + + if (result === 'NEW') { + newCount++ + // B4 문서의 경우 이슈 스테이지 자동 생성 + if (dolceDoc.DrawingKind === 'B4') { + await this.createIssueStagesForB4Document(dolceDoc.DrawingNo, contractId, dolceDoc) + } + } else if (result === 'UPDATED') { + updatedCount++ + } else { + skippedCount++ + } + + } catch (error) { + errors.push(`Document ${dolceDoc.DrawingNo}: ${error instanceof Error ? error.message : 'Unknown error'}`) + skippedCount++ + } + } + + console.log(`Import completed: ${newCount} new, ${updatedCount} updated, ${skippedCount} skipped`) + + return { + success: errors.length === 0, + newCount, + updatedCount, + skippedCount, + errors: errors.length > 0 ? errors : undefined, + message: `가져오기 완료: 신규 ${newCount}건, 업데이트 ${updatedCount}건` + } + + } catch (error) { + console.error('Import failed:', error) + throw error + } + } + + /** + * 계약 ID로 프로젝트 코드와 벤더 코드 조회 + */ + private async getContractInfoById(contractId: number): Promise<{ + projectCode: string; + vendorCode: string; + } | null> { + const [result] = await db + .select({ + projectCode: projects.code, + vendorCode: vendors.vendorCode + }) + .from(contracts) + .innerJoin(projects, eq(contracts.projectId, projects.id)) + .innerJoin(vendors, eq(contracts.vendorId, vendors.id)) + .where(eq(contracts.id, contractId)) + .limit(1) + + + return result?.projectCode && result?.vendorCode + ? { projectCode: result.projectCode, vendorCode: result.vendorCode } + : null + } + + /** + * DOLCE API에서 데이터 조회 + */ + private async fetchFromDOLCE( + projectCode: string, + vendorCode: string, + drawingKind: string + ): Promise { + const endpoint = process.env.DOLCE_DOC_LIST_API_URL || 'http://60.100.99.217:1111/Services/VDCSWebService.svc/DwgReceiptMgmt' + + const requestBody = { + project: projectCode, + drawingKind: drawingKind, // B3, B4, B5 + drawingMoveGbn: "", + drawingNo: "", + drawingName: "", + drawingVendor: vendorCode + } + + console.log(`Fetching from DOLCE: ${projectCode} - ${drawingKind} = ${vendorCode}`) + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // DOLCE API에 특별한 인증이 필요하다면 여기에 추가 + }, + body: JSON.stringify(requestBody) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`DOLCE API failed: HTTP ${response.status} - ${errorText}`) + } + + const data = await response.json() + + // 응답 구조에 따라 조정 필요 (실제 API 응답 구조 확인 후) + if (Array.isArray(data)) { + return data as DOLCEDocument[] + } else if (data.documents && Array.isArray(data.documents)) { + return data.documents as DOLCEDocument[] + } else if (data.data && Array.isArray(data.data)) { + return data.data as DOLCEDocument[] + } else { + console.warn(`Unexpected DOLCE response structure:`, data) + return [] + } + + } catch (error) { + console.error(`DOLCE API call failed for ${projectCode}/${drawingKind}:`, error) + throw error + } + } + + /** + * 단일 문서 동기화 + */ + private async syncSingleDocument( + contractId: number, + dolceDoc: DOLCEDocument, + sourceSystem: string + ): Promise<'NEW' | 'UPDATED' | 'SKIPPED'> { + // 기존 문서 조회 (문서 번호로) + const existingDoc = await db + .select() + .from(documents) + .where(and( + eq(documents.contractId, contractId), + eq(documents.docNumber, dolceDoc.DrawingNo) + )) + .limit(1) + + // DOLCE 문서를 DB 스키마에 맞게 변환 + const documentData = { + contractId, + docNumber: dolceDoc.DrawingNo, + title: dolceDoc.DrawingName, + status: 'ACTIVE', + + // DOLCE 전용 필드들 + drawingKind: dolceDoc.DrawingKind, + drawingMoveGbn: dolceDoc.DrawingMoveGbn, + discipline: dolceDoc.Discipline, + + // 외부 시스템 정보 + externalDocumentId: dolceDoc.DrawingNo, // DOLCE에서는 DrawingNo가 ID 역할 + externalSystemType: sourceSystem, + externalSyncedAt: new Date(), + + // B4 전용 필드들 + cGbn: dolceDoc.CGbn, + dGbn: dolceDoc.DGbn, + degreeGbn: dolceDoc.DegreeGbn, + deptGbn: dolceDoc.DeptGbn, + jGbn: dolceDoc.JGbn, + sGbn: dolceDoc.SGbn, + + // 추가 정보 + shiDrawingNo: dolceDoc.SHIDrawingNo, + manager: dolceDoc.Manager, + managerENM: dolceDoc.ManagerENM, + managerNo: dolceDoc.ManagerNo, + registerGroup: dolceDoc.RegisterGroup, + registerGroupId: dolceDoc.RegisterGroupId, + + // 생성자 정보 + createUserNo: dolceDoc.CreateUserNo, + createUserId: dolceDoc.CreateUserId, + createUserENM: dolceDoc.CreateUserENM, + + updatedAt: new Date() + } + + if (existingDoc.length > 0) { + // 업데이트 필요 여부 확인 + const existing = existingDoc[0] + const hasChanges = + existing.title !== documentData.title || + existing.drawingMoveGbn !== documentData.drawingMoveGbn || + existing.manager !== documentData.manager + + if (hasChanges) { + await db + .update(documents) + .set(documentData) + .where(eq(documents.id, existing.id)) + + console.log(`Updated document: ${dolceDoc.DrawingNo}`) + return 'UPDATED' + } else { + return 'SKIPPED' + } + } else { + // 새 문서 생성 + const [newDoc] = await db + .insert(documents) + .values({ + ...documentData, + createdAt: new Date() + }) + .returning({ id: documents.id }) + + console.log(`Created new document: ${dolceDoc.DrawingNo}`) + return 'NEW' + } + } + + /** + * B4 문서용 이슈 스테이지 자동 생성 + */ + private async createIssueStagesForB4Document( + drawingNo: string, + contractId: number, + dolceDoc: DOLCEDocument + ): Promise { + try { + // 문서 ID 조회 + const [document] = await db + .select({ id: documents.id }) + .from(documents) + .where(and( + eq(documents.contractId, contractId), + eq(documents.docNumber, drawingNo) + )) + .limit(1) + + if (!document) { + throw new Error(`Document not found: ${drawingNo}`) + } + + const documentId = document.id + + // 기존 이슈 스테이지 확인 + const existingStages = await db + .select() + .from(issueStages) + .where(eq(issueStages.documentId, documentId)) + + const existingStageNames = existingStages.map(stage => stage.stageName) + + // For Pre 스테이지 생성 (GTTPreDwg) + if (!existingStageNames.includes('For Pre')) { + await db.insert(issueStages).values({ + documentId: documentId, + stageName: 'For Pre', + planDate: dolceDoc.GTTPreDwg_PlanDate ? dolceDoc.GTTPreDwg_PlanDate : null, + actualDate: dolceDoc.GTTPreDwg_ResultDate ? dolceDoc.GTTPreDwg_ResultDate : null, + stageStatus: dolceDoc.GTTPreDwg_ResultDate ? 'COMPLETED' : 'PLANNED', + stageOrder: 1, + description: 'GTT 예비 도면 단계' + }) + } + + // For Working 스테이지 생성 (GTTWorkingDwg) + if (!existingStageNames.includes('For Working')) { + await db.insert(issueStages).values({ + documentId: documentId, + stageName: 'For Working', + planDate: dolceDoc.GTTWorkingDwg_PlanDate ? dolceDoc.GTTWorkingDwg_PlanDate : null, + actualDate: dolceDoc.GTTWorkingDwg_ResultDate ? dolceDoc.GTTWorkingDwg_ResultDate : null, + stageStatus: dolceDoc.GTTWorkingDwg_ResultDate ? 'COMPLETED' : 'PLANNED', + stageOrder: 2, + description: 'GTT 작업 도면 단계' + }) + } + + console.log(`Created issue stages for B4 document: ${drawingNo}`) + + } catch (error) { + console.error(`Failed to create issue stages for ${drawingNo}:`, error) + // 이슈 스테이지 생성 실패는 전체 문서 생성을 막지 않음 + } + } + + /** + * 가져오기 상태 조회 + */ + async getImportStatus( + contractId: number, + sourceSystem: string = 'DOLCE' + ): Promise { + try { + // 마지막 가져오기 시간 조회 + const [lastImport] = await db + .select({ + lastSynced: sql`MAX(${documents.externalSyncedAt})` + }) + .from(documents) + .where(and( + eq(documents.contractId, contractId), + eq(documents.externalSystemType, sourceSystem) + )) + + // 프로젝트 코드와 벤더 코드 조회 + const contractInfo = await this.getContractInfoById(contractId) + + console.log(contractInfo,"contractInfo") + + if (!contractInfo?.projectCode || !contractInfo?.vendorCode) { + throw new Error(`Project code or vendor code not found for contract ${contractId}`) + } + + let availableDocuments = 0 + let newDocuments = 0 + let updatedDocuments = 0 + + try { + // 각 drawingKind별로 확인 + const drawingKinds = ['B3', 'B4', 'B5'] + + for (const drawingKind of drawingKinds) { + try { + const externalDocs = await this.fetchFromDOLCE( + contractInfo.projectCode, + contractInfo.vendorCode, + drawingKind + ) + availableDocuments += externalDocs.length + + // 신규/업데이트 문서 수 계산 + for (const externalDoc of externalDocs) { + const existing = await db + .select({ id: documents.id, updatedAt: documents.updatedAt }) + .from(documents) + .where(and( + eq(documents.contractId, contractId), + eq(documents.docNumber, externalDoc.DrawingNo) + )) + .limit(1) + + if (existing.length === 0) { + newDocuments++ + } else { + // DOLCE의 CreateDt와 로컬 updatedAt 비교 + if (externalDoc.CreateDt && existing[0].updatedAt) { + const externalModified = new Date(externalDoc.CreateDt) + const localModified = new Date(existing[0].updatedAt) + if (externalModified > localModified) { + updatedDocuments++ + } + } + } + } + } catch (error) { + console.warn(`Failed to check ${drawingKind} for status:`, error) + } + } + } catch (error) { + console.warn(`Failed to fetch external data for status: ${error}`) + } + + return { + lastImportAt: lastImport?.lastSynced ? new Date(lastImport.lastSynced).toISOString() : undefined, + availableDocuments, + newDocuments, + updatedDocuments, + importEnabled: this.isImportEnabled(sourceSystem) + } + + } catch (error) { + console.error('Failed to get import status:', error) + throw error + } + } + + /** + * 가져오기 활성화 여부 확인 + */ + private isImportEnabled(sourceSystem: string): boolean { + const upperSystem = sourceSystem.toUpperCase() + const enabled = process.env[`IMPORT_${upperSystem}_ENABLED`] + return enabled === 'true' || enabled === '1' + } +} + +export const importService = new ImportService() \ No newline at end of file diff --git a/lib/vendor-document-list/sync-service.ts b/lib/vendor-document-list/sync-service.ts index 1f2872c4..4c1f5786 100644 --- a/lib/vendor-document-list/sync-service.ts +++ b/lib/vendor-document-list/sync-service.ts @@ -1,4 +1,4 @@ -// lib/sync-service.ts (시스템별 분리 버전) +// lib/sync-service.ts (시스템별 분리 버전 - DOLCE 업로드 통합) import db from "@/db/db" import { changeLogs, @@ -29,8 +29,6 @@ export interface SyncResult { class SyncService { private readonly CHUNK_SIZE = 50 - - /** * 동기화 활성화 여부 확인 */ @@ -294,7 +292,7 @@ class SyncService { } /** - * DOLCE 시스템 전용 동기화 수행 + * DOLCE 시스템 전용 동기화 수행 - 실제 업로드 서비스 사용 */ private async performSyncDOLCE( changes: ChangeLog[], @@ -302,143 +300,72 @@ class SyncService { ): Promise<{ success: boolean; successCount: number; failureCount: number; errors?: string[]; endpointResults?: Record }> { const errors: string[] = [] const endpointResults: Record = {} - let overallSuccess = true - - // 변경사항을 DOLCE 시스템 형태로 변환 - const syncData = await this.transformChangesForDOLCE(changes) - - // DOLCE 엔드포인트 호출들을 직접 정의 - const endpointPromises = [] - - // 1. DOLCE 메인 엔드포인트 - const mainUrl = process.env.SYNC_DOLCE_URL - if (mainUrl) { - endpointPromises.push( - (async () => { - try { - console.log(`Sending to DOLCE main: ${mainUrl}`) - - const transformedData = { - contractId, - systemType: 'DOLCE', - changes: syncData, - batchSize: changes.length, - timestamp: new Date().toISOString(), - source: 'EVCP', - version: '1.0' - } - - // 헤더 구성 (토큰이 있을 때만 Authorization 포함) - const headers: Record = { - 'Content-Type': 'application/json', - 'X-API-Version': process.env.SYNC_DOLCE_VERSION || 'v1', - 'X-System': 'DOLCE' - } - - if (process.env.SYNC_DOLCE_TOKEN) { - headers['Authorization'] = `Bearer ${process.env.SYNC_DOLCE_TOKEN}` - } - - const response = await fetch(mainUrl, { - method: 'POST', - headers, - body: JSON.stringify(transformedData) - }) - - if (!response.ok) { - const errorText = await response.text() - throw new Error(`DOLCE main: HTTP ${response.status} - ${errorText}`) - } - - const result = await response.json() - endpointResults['dolce_main'] = result - - console.log(`✅ DOLCE main sync successful`) - return { success: true, endpoint: 'dolce_main', result } - } catch (error) { - const errorMessage = `DOLCE main: ${error instanceof Error ? error.message : 'Unknown error'}` - errors.push(errorMessage) - overallSuccess = false - - console.error(`❌ DOLCE main sync failed:`, error) - return { success: false, endpoint: 'dolce_main', error: errorMessage } - } - })() - ) - } - - // 2. DOLCE 문서 전용 엔드포인트 (선택사항) - const docUrl = process.env.SYNC_DOLCE_DOCUMENT_URL - if (docUrl) { - endpointPromises.push( - (async () => { - try { - console.log(`Sending to DOLCE documents: ${docUrl}`) - - const documentData = { - documents: syncData.filter(item => item.entityType === 'document'), - source: 'EVCP_DOLCE', - timestamp: new Date().toISOString() - } - - // 헤더 구성 (토큰이 있을 때만 Authorization 포함) - const headers: Record = { - 'Content-Type': 'application/json' - } - - if (process.env.SYNC_DOLCE_TOKEN) { - headers['Authorization'] = `Bearer ${process.env.SYNC_DOLCE_TOKEN}` - } - - const response = await fetch(docUrl, { - method: 'PUT', - headers, - body: JSON.stringify(documentData) - }) - - if (!response.ok) { - const errorText = await response.text() - throw new Error(`DOLCE documents: HTTP ${response.status} - ${errorText}`) - } + try { + // DOLCE 업로드 서비스 동적 임포트 + const { dolceUploadService } = await import('./dolce-upload-service') + + if (!dolceUploadService.isUploadEnabled()) { + throw new Error('DOLCE upload is not enabled') + } - const result = await response.json() - endpointResults['dolce_documents'] = result + // 변경사항에서 리비전 ID들 추출 + const revisionIds = changes + .filter(change => change.entityType === 'revision') + .map(change => change.entityId) - console.log(`✅ DOLCE documents sync successful`) - return { success: true, endpoint: 'dolce_documents', result } + if (revisionIds.length === 0) { + return { + success: true, + successCount: 0, + failureCount: 0, + endpointResults: { message: 'No revisions to upload' } + } + } - } catch (error) { - const errorMessage = `DOLCE documents: ${error instanceof Error ? error.message : 'Unknown error'}` - errors.push(errorMessage) - overallSuccess = false - - console.error(`❌ DOLCE documents sync failed:`, error) - return { success: false, endpoint: 'dolce_documents', error: errorMessage } - } - })() + // DOLCE 업로드 실행 + const uploadResult = await dolceUploadService.uploadToDoLCE( + contractId, + revisionIds, + 'system_user', // 시스템 사용자 ID + 'System Upload' ) - } - if (endpointPromises.length === 0) { - throw new Error('No DOLCE sync endpoints configured') - } + endpointResults['dolce_upload'] = uploadResult - // 모든 엔드포인트 요청 완료 대기 - const results = await Promise.allSettled(endpointPromises) - - // 결과 집계 - const successfulEndpoints = results.filter(r => r.status === 'fulfilled' && r.value.success).length - const totalEndpoints = endpointPromises.length + if (uploadResult.success) { + console.log(`✅ DOLCE upload successful: ${uploadResult.uploadedDocuments} documents, ${uploadResult.uploadedFiles} files`) + + return { + success: true, + successCount: changes.length, + failureCount: 0, + endpointResults + } + } else { + console.error(`❌ DOLCE upload failed:`, uploadResult.errors) + + return { + success: false, + successCount: 0, + failureCount: changes.length, + errors: uploadResult.errors, + endpointResults + } + } - console.log(`DOLCE endpoint results: ${successfulEndpoints}/${totalEndpoints} successful`) + } catch (error) { + const errorMessage = `DOLCE upload failed: ${error instanceof Error ? error.message : 'Unknown error'}` + errors.push(errorMessage) + console.error(`❌ DOLCE upload error:`, error) - return { - success: overallSuccess && errors.length === 0, - successCount: overallSuccess ? changes.length : 0, - failureCount: overallSuccess ? 0 : changes.length, - errors: errors.length > 0 ? errors : undefined, - endpointResults + return { + success: false, + successCount: 0, + failureCount: changes.length, + errors, + endpointResults + } } } @@ -559,78 +486,6 @@ class SyncService { } } - /** - * DOLCE 시스템용 데이터 변환 - */ - private async transformChangesForDOLCE(changes: ChangeLog[]): Promise { - const syncData: SyncableEntity[] = [] - - for (const change of changes) { - try { - let entityData = null - - // 엔티티 타입별로 현재 데이터 조회 - switch (change.entityType) { - case 'document': - if (change.action !== 'DELETE') { - const [document] = await db - .select() - .from(documents) - .where(eq(documents.id, change.entityId)) - .limit(1) - entityData = document - } - break - - case 'revision': - if (change.action !== 'DELETE') { - const [revision] = await db - .select() - .from(revisions) - .where(eq(revisions.id, change.entityId)) - .limit(1) - entityData = revision - } - break - - case 'attachment': - if (change.action !== 'DELETE') { - const [attachment] = await db - .select() - .from(documentAttachments) - .where(eq(documentAttachments.id, change.entityId)) - .limit(1) - entityData = attachment - } - break - } - - // DOLCE 특화 데이터 구조 - syncData.push({ - entityType: change.entityType as any, - entityId: change.entityId, - action: change.action as any, - data: entityData || change.oldValues, - metadata: { - changeId: change.id, - changedAt: change.createdAt, - changedBy: change.userName, - changedFields: change.changedFields, - // DOLCE 전용 메타데이터 - dolceVersion: '2.0', - processingPriority: change.entityType === 'revision' ? 'HIGH' : 'NORMAL', - requiresApproval: change.action === 'DELETE' - } - }) - - } catch (error) { - console.error(`Failed to transform change ${change.id} for DOLCE:`, error) - } - } - - return syncData - } - /** * SWP 시스템용 데이터 변환 */ @@ -759,7 +614,7 @@ class SyncService { await db.update(revisions) .set({ revisionStatus: "SUBMITTED", - externalSentAt: new Date().toISOString().slice(0, 10) + submittedDate: new Date().toISOString().slice(0, 10) }) .where(inArray(revisions.id, revisionIds)) } diff --git a/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx b/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx index fa1b957b..3960bbce 100644 --- a/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx +++ b/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx @@ -1,8 +1,7 @@ "use client" - import * as React from "react" import { type Table } from "@tanstack/react-table" -import { Download, Upload, Plus, Files } from "lucide-react" +import { Download, Upload, Plus, Files, RefreshCw } from "lucide-react" import { toast } from "sonner" import { exportTableToExcel } from "@/lib/export" @@ -13,6 +12,8 @@ import { DeleteDocumentsDialog } from "./delete-docs-dialog" import { BulkUploadDialog } from "./bulk-upload-dialog" import type { EnhancedDocument } from "@/types/enhanced-documents" import { SendToSHIButton } from "./send-to-shi-button" +import { ImportFromDOLCEButton } from "./import-from-dolce-button" +import { SWPWorkflowPanel } from "./swp-workflow-panel" interface EnhancedDocTableToolbarActionsProps { table: Table @@ -50,9 +51,17 @@ export function EnhancedDocTableToolbarActions({ }, 500) } + const handleImportComplete = () => { + // 가져오기 완료 후 테이블 새로고침 + table.resetRowSelection() + setTimeout(() => { + window.location.reload() + }, 500) + } + return (
- {/* 기존 액션들 */} + {/* 삭제 버튼 */} {table.getFilteredSelectedRowModel().rows.length > 0 ? ( ) : null} - {/* ✅ AddDocumentListDialog에 필요한 props 전달 */} - - - {/* 일괄 업로드 버튼 */} + {/* projectType에 따른 조건부 렌더링 */} + {projectType === "ship" ? ( + <> + {/* SHIP: DOLCE에서 목록 가져오기 */} + + + ) : ( + <> + {/* PLANT: 수동 문서 추가 */} + + + )} + + {/* 일괄 업로드 버튼 (공통) */} - {/* Export 버튼 */} + {/* Export 버튼 (공통) */} - {/* ✅ 새로운 Send to SHI 버튼으로 교체 */} + {/* Send to SHI 버튼 (공통) - 내부 → 외부로 보내기 */} + {/* SWP 전용 워크플로우 패널 */} + {projectType === "plant" && ( + + )} + {/* 일괄 업로드 다이얼로그 */} | null>(null) diff --git a/lib/vendor-document-list/table/import-from-dolce-button.tsx b/lib/vendor-document-list/table/import-from-dolce-button.tsx new file mode 100644 index 00000000..519d40cb --- /dev/null +++ b/lib/vendor-document-list/table/import-from-dolce-button.tsx @@ -0,0 +1,356 @@ +"use client" + +import * as React from "react" +import { RefreshCw, Download, Loader2, CheckCircle, AlertTriangle } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Badge } from "@/components/ui/badge" +import { Progress } from "@/components/ui/progress" +import { Separator } from "@/components/ui/separator" + +interface ImportFromDOLCEButtonProps { + contractId: number + onImportComplete?: () => void +} + +interface ImportStatus { + lastImportAt?: string + availableDocuments: number + newDocuments: number + updatedDocuments: number + importEnabled: boolean +} + +export function ImportFromDOLCEButton({ + contractId, + onImportComplete +}: ImportFromDOLCEButtonProps) { + const [isDialogOpen, setIsDialogOpen] = React.useState(false) + const [importProgress, setImportProgress] = React.useState(0) + const [isImporting, setIsImporting] = React.useState(false) + const [importStatus, setImportStatus] = React.useState(null) + const [statusLoading, setStatusLoading] = React.useState(false) + + // DOLCE 상태 조회 + const fetchImportStatus = async () => { + setStatusLoading(true) + try { + const response = await fetch(`/api/sync/import/status?contractId=${contractId}&sourceSystem=DOLCE`) + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.message || 'Failed to fetch import status') + } + + const status = await response.json() + setImportStatus(status) + + // 프로젝트 코드가 없는 경우 에러 처리 + if (status.error) { + toast.error(`상태 확인 실패: ${status.error}`) + setImportStatus(null) + } + } catch (error) { + console.error('Failed to fetch import status:', error) + toast.error('DOLCE 상태를 확인할 수 없습니다. 프로젝트 설정을 확인해주세요.') + setImportStatus(null) + } finally { + setStatusLoading(false) + } + } + + // 컴포넌트 마운트 시 상태 조회 + React.useEffect(() => { + fetchImportStatus() + }, [contractId]) + + const handleImport = async () => { + if (!contractId) return + + setImportProgress(0) + setIsImporting(true) + + try { + // 진행률 시뮬레이션 + const progressInterval = setInterval(() => { + setImportProgress(prev => Math.min(prev + 15, 90)) + }, 300) + + const response = await fetch('/api/sync/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contractId, + sourceSystem: 'DOLCE' + }) + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.message || 'Import failed') + } + + const result = await response.json() + + clearInterval(progressInterval) + setImportProgress(100) + + setTimeout(() => { + setImportProgress(0) + setIsDialogOpen(false) + setIsImporting(false) + + if (result?.success) { + const { newCount = 0, updatedCount = 0, skippedCount = 0 } = result + toast.success( + `DOLCE 가져오기 완료`, + { + description: `신규 ${newCount}건, 업데이트 ${updatedCount}건, 건너뜀 ${skippedCount}건 (B3/B4/B5 포함)` + } + ) + } else { + toast.error( + `DOLCE 가져오기 부분 실패`, + { + description: result?.message || '일부 DrawingKind에서 가져오기에 실패했습니다.' + } + ) + } + + fetchImportStatus() // 상태 갱신 + onImportComplete?.() + }, 500) + + } catch (error) { + setImportProgress(0) + setIsImporting(false) + + toast.error('DOLCE 가져오기 실패', { + description: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.' + }) + } + } + + const getStatusBadge = () => { + if (statusLoading) { + return DOLCE 연결 확인 중... + } + + if (!importStatus) { + return DOLCE 연결 오류 + } + + if (!importStatus.importEnabled) { + return DOLCE 가져오기 비활성화 + } + + if (importStatus.newDocuments > 0 || importStatus.updatedDocuments > 0) { + return ( + + + 업데이트 가능 (B3/B4/B5) + + ) + } + + return ( + + + DOLCE와 동기화됨 + + ) + } + + const canImport = importStatus?.importEnabled && + (importStatus?.newDocuments > 0 || importStatus?.updatedDocuments > 0) + + return ( + <> + + +
+ +
+
+ + +
+
+

DOLCE 가져오기 상태

+
+ 현재 상태 + {getStatusBadge()} +
+
+ + {importStatus && ( +
+ + +
+
+
신규 문서
+
{importStatus.newDocuments || 0}건
+
+
+
업데이트
+
{importStatus.updatedDocuments || 0}건
+
+
+ +
+
DOLCE 전체 문서 (B3/B4/B5)
+
{importStatus.availableDocuments || 0}건
+
+ + {importStatus.lastImportAt && ( +
+
마지막 가져오기
+
+ {new Date(importStatus.lastImportAt).toLocaleString()} +
+
+ )} +
+ )} + + + +
+ + + +
+
+
+
+ + {/* 가져오기 진행 다이얼로그 */} + + + + DOLCE에서 문서 목록 가져오기 + + 삼성중공업 DOLCE 시스템에서 최신 문서 목록을 가져옵니다. + + + +
+ {importStatus && ( +
+
+ 가져올 항목 + + {(importStatus.newDocuments || 0) + (importStatus.updatedDocuments || 0)}건 + +
+ +
+ 신규 문서와 업데이트된 문서가 포함됩니다. (B3, B4, B5) +
+ B4 문서의 경우 GTTPreDwg, GTTWorkingDwg 이슈 스테이지가 자동 생성됩니다. +
+ + {isImporting && ( +
+
+ 진행률 + {importProgress}% +
+ +
+ )} +
+ )} + +
+ + +
+
+
+
+ + ) +} \ No newline at end of file diff --git a/lib/vendor-document-list/table/swp-workflow-panel.tsx b/lib/vendor-document-list/table/swp-workflow-panel.tsx new file mode 100644 index 00000000..ded306e7 --- /dev/null +++ b/lib/vendor-document-list/table/swp-workflow-panel.tsx @@ -0,0 +1,370 @@ +"use client" + +import * as React from "react" +import { Send, Eye, CheckCircle, Clock, RefreshCw, AlertTriangle, Loader2 } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" +import { Progress } from "@/components/ui/progress" +import type { EnhancedDocument } from "@/types/enhanced-documents" + +interface SWPWorkflowPanelProps { + contractId: number + documents: EnhancedDocument[] + onWorkflowUpdate?: () => void +} + +type WorkflowStatus = + | 'IDLE' // 대기 상태 + | 'SUBMITTED' // 목록 전송됨 + | 'UNDER_REVIEW' // 검토 중 + | 'CONFIRMED' // 컨펌됨 + | 'REVISION_REQUIRED' // 수정 요청됨 + | 'RESUBMITTED' // 재전송됨 + | 'APPROVED' // 최종 승인됨 + +interface WorkflowState { + status: WorkflowStatus + lastUpdatedAt?: string + pendingActions: string[] + confirmationData?: any + revisionComments?: string[] + approvalData?: any +} + +export function SWPWorkflowPanel({ + contractId, + documents, + onWorkflowUpdate +}: SWPWorkflowPanelProps) { + const [workflowState, setWorkflowState] = React.useState(null) + const [isLoading, setIsLoading] = React.useState(false) + const [actionProgress, setActionProgress] = React.useState(0) + + // 워크플로우 상태 조회 + const fetchWorkflowStatus = async () => { + setIsLoading(true) + try { + const response = await fetch(`/api/sync/workflow/status?contractId=${contractId}&targetSystem=SWP`) + if (!response.ok) throw new Error('Failed to fetch workflow status') + + const status = await response.json() + setWorkflowState(status) + } catch (error) { + console.error('Failed to fetch workflow status:', error) + toast.error('워크플로우 상태를 확인할 수 없습니다') + } finally { + setIsLoading(false) + } + } + + // 컴포넌트 마운트 시 상태 조회 + React.useEffect(() => { + fetchWorkflowStatus() + }, [contractId]) + + // 워크플로우 액션 실행 + const executeWorkflowAction = async (action: string) => { + setActionProgress(0) + setIsLoading(true) + + try { + // 진행률 시뮬레이션 + const progressInterval = setInterval(() => { + setActionProgress(prev => Math.min(prev + 20, 90)) + }, 200) + + const response = await fetch('/api/sync/workflow/action', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contractId, + targetSystem: 'SWP', + action, + documents: documents.map(doc => ({ id: doc.id, documentNo: doc.documentNo })) + }) + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.message || 'Workflow action failed') + } + + const result = await response.json() + + clearInterval(progressInterval) + setActionProgress(100) + + setTimeout(() => { + setActionProgress(0) + + if (result?.success) { + toast.success( + `${getActionLabel(action)} 완료`, + { description: result?.message || '워크플로우가 성공적으로 진행되었습니다.' } + ) + } else { + toast.error( + `${getActionLabel(action)} 실패`, + { description: result?.message || '워크플로우 실행에 실패했습니다.' } + ) + } + + fetchWorkflowStatus() // 상태 갱신 + onWorkflowUpdate?.() + }, 500) + + } catch (error) { + setActionProgress(0) + + toast.error(`${getActionLabel(action)} 실패`, { + description: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.' + }) + } finally { + setIsLoading(false) + } + } + + const getActionLabel = (action: string): string => { + switch (action) { + case 'SUBMIT_LIST': return '목록 전송' + case 'CHECK_CONFIRMATION': return '컨펌 확인' + case 'RESUBMIT_REVISED': return '수정본 재전송' + case 'CHECK_APPROVAL': return '승인 확인' + default: return action + } + } + + const getStatusBadge = () => { + if (isLoading) { + return 확인 중... + } + + if (!workflowState) { + return 오류 + } + + switch (workflowState.status) { + case 'IDLE': + return 대기 + case 'SUBMITTED': + return ( + + + 전송됨 + + ) + case 'UNDER_REVIEW': + return ( + + + 검토 중 + + ) + case 'CONFIRMED': + return ( + + + 컨펌됨 + + ) + case 'REVISION_REQUIRED': + return ( + + + 수정 요청 + + ) + case 'RESUBMITTED': + return ( + + + 재전송됨 + + ) + case 'APPROVED': + return ( + + + 승인 완료 + + ) + default: + return 알 수 없음 + } + } + + const getAvailableActions = (): string[] => { + if (!workflowState) return [] + + switch (workflowState.status) { + case 'IDLE': + return ['SUBMIT_LIST'] + case 'SUBMITTED': + return ['CHECK_CONFIRMATION'] + case 'UNDER_REVIEW': + return ['CHECK_CONFIRMATION'] + case 'CONFIRMED': + return [] // 컨펌되면 자동으로 다음 단계로 + case 'REVISION_REQUIRED': + return ['RESUBMIT_REVISED'] + case 'RESUBMITTED': + return ['CHECK_APPROVAL'] + case 'APPROVED': + return [] // 완료 상태 + default: + return [] + } + } + + const availableActions = getAvailableActions() + + return ( + + +
+ +
+
+ + +
+
+

SWP 워크플로우 상태

+
+ 현재 상태 + {getStatusBadge()} +
+
+ + {workflowState && ( +
+ + + {/* 대기 중인 액션들 */} + {workflowState.pendingActions && workflowState.pendingActions.length > 0 && ( +
+
대기 중인 작업
+ {workflowState.pendingActions.map((action, index) => ( + + {getActionLabel(action)} + + ))} +
+ )} + + {/* 수정 요청 사항 */} + {workflowState.revisionComments && workflowState.revisionComments.length > 0 && ( +
+
수정 요청 사항
+
+ {workflowState.revisionComments.map((comment, index) => ( +
+ {comment} +
+ ))} +
+
+ )} + + {/* 마지막 업데이트 시간 */} + {workflowState.lastUpdatedAt && ( +
+
마지막 업데이트
+
+ {new Date(workflowState.lastUpdatedAt).toLocaleString()} +
+
+ )} + + {/* 진행률 표시 */} + {isLoading && actionProgress > 0 && ( +
+
+ 진행률 + {actionProgress}% +
+ +
+ )} +
+ )} + + + + {/* 액션 버튼들 */} +
+ {availableActions.length > 0 ? ( + availableActions.map((action) => ( + + )) + ) : ( +
+ {workflowState?.status === 'APPROVED' + ? '워크플로우가 완료되었습니다.' + : '실행 가능한 작업이 없습니다.'} +
+ )} + + {/* 상태 새로고침 버튼 */} + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/lib/vendor-document-list/workflow-service.ts b/lib/vendor-document-list/workflow-service.ts new file mode 100644 index 00000000..6efff012 --- /dev/null +++ b/lib/vendor-document-list/workflow-service.ts @@ -0,0 +1,195 @@ +// lib/vendor-document-list/workflow-service.ts +class WorkflowService { + /** + * 워크플로우 상태 조회 + */ + async getWorkflowStatus( + contractId: number, + targetSystem: string = 'SWP' + ): Promise { + try { + // 워크플로우 상태를 DB나 외부 시스템에서 조회 + // 실제 구현에서는 workflow_states 테이블이나 외부 API를 사용 + + const mockStatus = { + status: 'IDLE', + lastUpdatedAt: new Date().toISOString(), + pendingActions: ['SUBMIT_LIST'], + confirmationData: null, + revisionComments: [], + approvalData: null + } + + return mockStatus + + } catch (error) { + console.error('Failed to get workflow status:', error) + throw error + } + } + + /** + * 워크플로우 액션 실행 + */ + async executeWorkflowAction( + contractId: number, + targetSystem: string, + action: string, + documents: any[], + userId: number, + userName: string + ): Promise { + try { + console.log(`Executing workflow action: ${action} for contract ${contractId}`) + + switch (action) { + case 'SUBMIT_LIST': + return await this.submitDocumentList(contractId, targetSystem, documents, userId, userName) + + case 'CHECK_CONFIRMATION': + return await this.checkConfirmation(contractId, targetSystem) + + case 'RESUBMIT_REVISED': + return await this.resubmitRevisedList(contractId, targetSystem, documents, userId, userName) + + case 'CHECK_APPROVAL': + return await this.checkApproval(contractId, targetSystem) + + default: + throw new Error(`Unknown workflow action: ${action}`) + } + + } catch (error) { + console.error('Workflow action failed:', error) + throw error + } + } + + /** + * 문서 목록 전송 + */ + private async submitDocumentList( + contractId: number, + targetSystem: string, + documents: any[], + userId: number, + userName: string + ): Promise { + // SWP 시스템으로 문서 목록 전송 + const endpoint = process.env.SWP_SUBMIT_URL + if (!endpoint) { + throw new Error('SWP submit endpoint not configured') + } + + const payload = { + contractId, + documents: documents.map(doc => ({ + documentNo: doc.documentNo, + documentName: doc.documentName, + documentType: doc.documentType, + status: doc.documentStatus + })), + submittedBy: userName, + submittedAt: new Date().toISOString() + } + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${Buffer.from(`${process.env.SWP_USER}:${process.env.SWP_PASSWORD}`).toString('base64')}` + }, + body: JSON.stringify(payload) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`SWP submit failed: HTTP ${response.status} - ${errorText}`) + } + + const result = await response.json() + + // 워크플로우 상태 업데이트 (DB 저장) + // await this.updateWorkflowStatus(contractId, targetSystem, 'SUBMITTED', result) + + return { + success: true, + message: '문서 목록이 성공적으로 전송되었습니다.', + submissionId: result.submissionId + } + } + + /** + * 컨펌 상태 확인 + */ + private async checkConfirmation( + contractId: number, + targetSystem: string + ): Promise { + // SWP 시스템에서 컨펌 상태 조회 + const endpoint = process.env.SWP_STATUS_URL + if (!endpoint) { + throw new Error('SWP status endpoint not configured') + } + + const response = await fetch(`${endpoint}?contractId=${contractId}`, { + method: 'GET', + headers: { + 'Authorization': `Basic ${Buffer.from(`${process.env.SWP_USER}:${process.env.SWP_PASSWORD}`).toString('base64')}` + } + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`SWP status check failed: HTTP ${response.status} - ${errorText}`) + } + + const result = await response.json() + + // 상태에 따른 다음 액션 결정 + let nextStatus = 'UNDER_REVIEW' + let message = '검토가 진행 중입니다.' + + if (result.status === 'CONFIRMED') { + nextStatus = 'CONFIRMED' + message = '문서 목록이 컨펌되었습니다.' + } else if (result.status === 'REVISION_REQUIRED') { + nextStatus = 'REVISION_REQUIRED' + message = '수정이 요청되었습니다.' + } + + return { + success: true, + message, + status: nextStatus, + confirmationData: result + } + } + + /** + * 수정된 목록 재전송 + */ + private async resubmitRevisedList( + contractId: number, + targetSystem: string, + documents: any[], + userId: number, + userName: string + ): Promise { + // 수정된 문서 목록 재전송 로직 + return await this.submitDocumentList(contractId, targetSystem, documents, userId, userName) + } + + /** + * 최종 승인 확인 + */ + private async checkApproval( + contractId: number, + targetSystem: string + ): Promise { + // 최종 승인 상태 확인 로직 + return await this.checkConfirmation(contractId, targetSystem) + } +} + +export const workflowService = new WorkflowService() \ No newline at end of file diff --git a/lib/welding/repository.ts b/lib/welding/repository.ts new file mode 100644 index 00000000..10e64f58 --- /dev/null +++ b/lib/welding/repository.ts @@ -0,0 +1,49 @@ +// src/lib/tasks/repository.ts +import db from "@/db/db"; +import { ocrRows } from "@/db/schema"; +import { + eq, + inArray, + not, + asc, + desc, + and, + ilike, + gte, + lte, + count, + gt, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; + +/** + * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시 + * - 트랜잭션(tx)을 받아서 사용하도록 구현 + */ +export async function selectOcrRows( + tx: PgTransaction, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType | ReturnType)[]; + offset?: number; + limit?: number; + } + ) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(ocrRows) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); + } +/** 총 개수 count */ +export async function countOcrRows( + tx: PgTransaction, + where?: any +) { + const res = await tx.select({ count: count() }).from(ocrRows).where(where); + return res[0]?.count ?? 0; +} diff --git a/lib/welding/service.ts b/lib/welding/service.ts new file mode 100644 index 00000000..3dce07f8 --- /dev/null +++ b/lib/welding/service.ts @@ -0,0 +1,87 @@ +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) + +import { revalidateTag, unstable_noStore } from "next/cache"; +import db from "@/db/db"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { filterColumns } from "@/lib/filter-columns"; +import { tagClasses } from "@/db/schema/vendorData"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm"; +import { GetOcrRowSchema } from "./validation"; +import { ocrRows } from "@/db/schema"; +import { countOcrRows, selectOcrRows } from "./repository"; + +export async function getOcrRows(input: GetOcrRowSchema) { + // return unstable_cache( + // async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // const advancedTable = input.flags.includes("advancedTable"); + const advancedTable = true; + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: ocrRows, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or(ilike(ocrRows.reportNo, s), + ilike(ocrRows.identificationNo, s), + ilike(ocrRows.tagNo, s), + ilike(ocrRows.jointNo, s) + ) + // 필요시 여러 칼럼 OR조건 (status, priority, etc) + } + + const conditions = []; + if (advancedWhere) conditions.push(advancedWhere); + if (globalWhere) conditions.push(globalWhere); + + let finalWhere; + if (conditions.length > 0) { + finalWhere = conditions.length > 1 ? and(...conditions) : conditions[0]; + } + + // 아니면 ilike, inArray, gte 등으로 where 절 구성 + const where = finalWhere + + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(ocrRows[item.id]) : asc(ocrRows[item.id]) + ) + : [asc(ocrRows.createdAt)]; + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectOcrRows(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countOcrRows(tx, where); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + // }, + // [JSON.stringify(input)], // 캐싱 키 + // { + // revalidate: 3600, + // tags: ["equip-class"], // revalidateTag("items") 호출 시 무효화 + // } + // )(); + } \ No newline at end of file diff --git a/lib/welding/table/ocr-table-columns.tsx b/lib/welding/table/ocr-table-columns.tsx new file mode 100644 index 00000000..85830405 --- /dev/null +++ b/lib/welding/table/ocr-table-columns.tsx @@ -0,0 +1,312 @@ +"use client" + +import * as React from "react" +import { type ColumnDef } from "@tanstack/react-table" +import { ArrowUpDown, Copy, MoreHorizontal } from "lucide-react" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" +import { toast } from "sonner" +import { formatDate } from "@/lib/utils" +import { OcrRow } from "@/db/schema" +import { type DataTableRowAction } from "@/types/table" + +interface GetColumnsProps { + setRowAction: React.Dispatch | null>> +} + +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef[] { + return [ + // 체크박스 컬럼 + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + enableSorting: false, + enableHiding: false, + }, + + // Report No 컬럼 + { + accessorKey: "reportNo", + header: ({ column }) => ( + + ), + cell: ({ getValue }) => { + const reportNo = getValue() as string + return ( +
+ + {reportNo || "N/A"} + + +
+ ) + }, + enableSorting: true, + enableHiding: false, + }, + + // No 컬럼 + { + accessorKey: "no", + header: ({ column }) => ( + + ), + cell: ({ getValue }) => { + const no = getValue() as string + return ( +
+ {no || "-"} +
+ ) + }, + enableSorting: true, + }, + + // Identification No 컬럼 + { + accessorKey: "identificationNo", + header: ({ column }) => ( + + ), + cell: ({ getValue }) => { + const identificationNo = getValue() as string + return ( +
+ {identificationNo || "-"} +
+ ) + }, + enableSorting: true, + }, + + // Tag No 컬럼 + { + accessorKey: "tagNo", + header: ({ column }) => ( + + ), + cell: ({ getValue }) => { + const tagNo = getValue() as string + return ( +
+ {tagNo || "-"} +
+ ) + }, + enableSorting: true, + }, + + // Joint No 컬럼 + { + accessorKey: "jointNo", + header: ({ column }) => ( + + ), + cell: ({ getValue }) => { + const jointNo = getValue() as string + return ( +
+ {jointNo || "-"} +
+ ) + }, + enableSorting: true, + }, + + // Joint Type 컬럼 + { + accessorKey: "jointType", + header: ({ column }) => ( + + ), + cell: ({ getValue }) => { + const jointType = getValue() as string + return ( + + {jointType || "N/A"} + + ) + }, + enableSorting: true, + }, + + // Welding Date 컬럼 + { + accessorKey: "weldingDate", + header: ({ column }) => ( + + ), + cell: ({ getValue }) => { + const weldingDate = getValue() as string + return ( +
+ {weldingDate || "-"} +
+ ) + }, + enableSorting: true, + }, + + // Confidence 컬럼 + { + accessorKey: "confidence", + header: ({ column }) => ( + + ), + cell: ({ getValue }) => { + const confidence = parseFloat(getValue() as string) || 0 + const percentage = Math.round(confidence * 100) + + let variant: "default" | "secondary" | "destructive" = "default" + if (percentage < 70) variant = "destructive" + else if (percentage < 90) variant = "secondary" + + return ( + + {percentage}% + + ) + }, + enableSorting: true, + }, + + // Source Table 컬럼 + { + accessorKey: "sourceTable", + header: ({ column }) => ( + + ), + cell: ({ getValue }) => { + const sourceTable = getValue() as number + return ( +
+ + T{sourceTable} + +
+ ) + }, + enableSorting: true, + }, + + // Source Row 컬럼 + { + accessorKey: "sourceRow", + header: ({ column }) => ( + + ), + cell: ({ getValue }) => { + const sourceRow = getValue() as number + return ( +
+ {sourceRow} +
+ ) + }, + enableSorting: true, + }, + + // Created At 컬럼 + { + accessorKey: "createdAt", + header: ({ column }) => ( + + ), + cell: ({ cell }) => { + const date = cell.getValue() as Date + return ( +
+ {formatDate(date)} +
+ ) + }, + enableSorting: true, + }, + + // Actions 컬럼 + { + id: "actions", + cell: ({ row }) => ( + + + + + + { + const rowData = row.original + navigator.clipboard.writeText(JSON.stringify(rowData, null, 2)) + toast.success("Row data copied to clipboard") + }} + > + + + { + setRowAction({ type: "view", row }) + }} + > + View Details + + { + setRowAction({ type: "delete", row }) + }} + className="text-destructive focus:text-destructive" + > + Delete + + + + ), + enableSorting: false, + enableHiding: false, + }, + ] +} \ No newline at end of file diff --git a/lib/welding/table/ocr-table-toolbar-actions.tsx b/lib/welding/table/ocr-table-toolbar-actions.tsx new file mode 100644 index 00000000..001b21cb --- /dev/null +++ b/lib/welding/table/ocr-table-toolbar-actions.tsx @@ -0,0 +1,297 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, RefreshCcw, Upload, FileText, Loader2 } from "lucide-react" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Progress } from "@/components/ui/progress" +import { Badge } from "@/components/ui/badge" +import { toast } from "sonner" +import { OcrRow } from "@/db/schema" + +interface OcrTableToolbarActionsProps { + table: Table +} + +interface UploadProgress { + stage: string + progress: number + message: string +} + +export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [isUploading, setIsUploading] = React.useState(false) + const [uploadProgress, setUploadProgress] = React.useState(null) + const [isUploadDialogOpen, setIsUploadDialogOpen] = React.useState(false) + const [selectedFile, setSelectedFile] = React.useState(null) + const fileInputRef = React.useRef(null) + + + const handleFileSelect = (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (file) { + setSelectedFile(file) + } + } + + const validateFile = (file: File): string | null => { + // 파일 크기 체크 (10MB) + if (file.size > 10 * 1024 * 1024) { + return "File size must be less than 10MB" + } + + // 파일 타입 체크 + const allowedTypes = [ + 'application/pdf', + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/tiff', + 'image/bmp' + ] + + if (!allowedTypes.includes(file.type)) { + return "Only PDF and image files (JPG, PNG, TIFF, BMP) are supported" + } + + return null + } + + const uploadFile = async () => { + if (!selectedFile) { + toast.error("Please select a file first") + return + } + + const validationError = validateFile(selectedFile) + if (validationError) { + toast.error(validationError) + return + } + + try { + setIsUploading(true) + setUploadProgress({ + stage: "preparing", + progress: 10, + message: "Preparing file upload..." + }) + + const formData = new FormData() + formData.append('file', selectedFile) + + setUploadProgress({ + stage: "uploading", + progress: 30, + message: "Uploading file and processing..." + }) + + const response = await fetch('/api/ocr/enhanced', { + method: 'POST', + body: formData, + }) + + setUploadProgress({ + stage: "processing", + progress: 70, + message: "Analyzing document with OCR..." + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'OCR processing failed') + } + + const result = await response.json() + + setUploadProgress({ + stage: "saving", + progress: 90, + message: "Saving results to database..." + }) + + if (result.success) { + setUploadProgress({ + stage: "complete", + progress: 100, + message: "OCR processing completed successfully!" + }) + + toast.success( + `OCR completed! Extracted ${result.metadata.totalRows} rows from ${result.metadata.totalTables} tables`, + { + description: result.warnings?.length + ? `Warnings: ${result.warnings.join(', ')}` + : undefined + } + ) + + // 성공 후 다이얼로그 닫기 및 상태 초기화 + setTimeout(() => { + setIsUploadDialogOpen(false) + setSelectedFile(null) + setUploadProgress(null) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + + // 테이블 새로고침 + window.location.reload() + }, 2000) + + } else { + throw new Error(result.error || 'Unknown error occurred') + } + + } catch (error) { + console.error('Error uploading file:', error) + toast.error( + error instanceof Error + ? error.message + : 'An error occurred while processing the file' + ) + setUploadProgress(null) + } finally { + setIsUploading(false) + } + } + + const resetUpload = () => { + setSelectedFile(null) + setUploadProgress(null) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + + return ( +
+ {/* OCR 업로드 다이얼로그 */} + + + + + + + Upload Document for OCR + + Upload a PDF or image file to extract table data using OCR technology. + + + +
+ {/* 파일 선택 */} +
+ + +

+ Supported formats: PDF, JPG, PNG, TIFF, BMP (Max 10MB) +

+
+ + {/* 선택된 파일 정보 */} + {selectedFile && ( +
+
+ + {selectedFile.name} +
+
+ Size: {(selectedFile.size / 1024 / 1024).toFixed(2)} MB + Type: {selectedFile.type} +
+
+ )} + + {/* 업로드 진행상황 */} + {uploadProgress && ( +
+
+ Processing... + + {uploadProgress.stage} + +
+ +

+ {uploadProgress.message} +

+
+ )} + + {/* 액션 버튼들 */} +
+ + +
+
+
+
+ {/* Export 버튼 */} + +
+ ) +} \ No newline at end of file diff --git a/lib/welding/table/ocr-table.tsx b/lib/welding/table/ocr-table.tsx new file mode 100644 index 00000000..91af1c67 --- /dev/null +++ b/lib/welding/table/ocr-table.tsx @@ -0,0 +1,143 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" + +import { OcrTableToolbarActions } from "./ocr-table-toolbar-actions" +import { getColumns } from "./ocr-table-columns" +import { OcrRow } from "@/db/schema" +import { getOcrRows } from "../service" + +interface ItemsTableProps { + promises: Promise< + [ + Awaited>, + ] + > +} + +export function OcrTable({ promises }: ItemsTableProps) { + const [{ data, pageCount }] = + React.use(promises) + + const [rowAction, setRowAction] = + React.useState | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + /** + * This component can render either a faceted filter or a search filter based on the `options` prop. + * + * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered. + * + * Each `option` object has the following properties: + * @prop {string} label - The label for the filter option. + * @prop {string} value - The value for the filter option. + * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. + * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. + */ + const filterFields: DataTableFilterField[] = [ + + ] + + /** + * Advanced filter fields for the data table. + * These fields provide more complex filtering options compared to the regular filterFields. + * + * Key differences from regular filterFields: + * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'. + * 2. Enhanced flexibility: Allows for more precise and varied filtering options. + * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. + * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. + */ + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { + id: "reportNo", + label: "Report No", + type: "text", + // group: "Basic Info", + }, + { + id: "no", + label: "No", + type: "text", + // group: "Basic Info", + }, + + + { + id: "identificationNo", + label: "Identification No", + type: "text", + // group: "Metadata",a + }, + { + id: "tagNo", + label: "Tag No", + type: "text", + // group: "Metadata", + }, + { + id: "jointNo", + label: "Joint No", + type: "text", + // group: "Metadata", + }, + { + id: "weldingDate", + label: "Welding Date", + type: "date", + // group: "Metadata", + }, + { + id: "createdAt", + label: "생성일", + type: "date", + // group: "Metadata", + }, + ] + + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + + + + + + + ) +} diff --git a/lib/welding/validation.ts b/lib/welding/validation.ts new file mode 100644 index 00000000..fe5b2cbb --- /dev/null +++ b/lib/welding/validation.ts @@ -0,0 +1,36 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { OcrRow } from "@/db/schema"; + + +// 검색 파라미터 캐시 정의 +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 확장된 타입으로 정렬 파서 사용 + sort: getSortingStateParser().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // 기존 필터 옵션들 + code: parseAsString.withDefault(""), + label: parseAsString.withDefault(""), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}); + +// 타입 내보내기 +export type GetOcrRowSchema = Awaited>; -- cgit v1.2.3