From 20800b214145ee6056f94ca18fa1054f145eb977 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 28 May 2025 00:32:31 +0000 Subject: (대표님) lib 파트 커밋 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../enhanced-document-service.ts | 782 +++++++++++++ lib/vendor-document-list/sync-client.ts | 28 + lib/vendor-document-list/sync-service.ts | 491 +++++++++ .../table/bulk-upload-dialog.tsx | 1162 ++++++++++++++++++++ .../table/enhanced-doc-table-columns.tsx | 612 +++++++++++ .../table/enhanced-doc-table-toolbar-actions.tsx | 106 ++ .../table/enhanced-document-sheet.tsx | 939 ++++++++++++++++ .../table/enhanced-documents-table copy.tsx | 604 ++++++++++ .../table/enhanced-documents-table.tsx | 570 ++++++++++ .../table/revision-upload-dialog.tsx | 486 ++++++++ .../table/send-to-shi-button.tsx | 342 ++++++ .../table/simplified-document-edit-dialog.tsx | 287 +++++ .../table/stage-revision-expanded-content.tsx | 719 ++++++++++++ .../table/stage-revision-sheet.tsx | 86 ++ 14 files changed, 7214 insertions(+) create mode 100644 lib/vendor-document-list/enhanced-document-service.ts create mode 100644 lib/vendor-document-list/sync-client.ts create mode 100644 lib/vendor-document-list/sync-service.ts create mode 100644 lib/vendor-document-list/table/bulk-upload-dialog.tsx create mode 100644 lib/vendor-document-list/table/enhanced-doc-table-columns.tsx create mode 100644 lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx create mode 100644 lib/vendor-document-list/table/enhanced-document-sheet.tsx create mode 100644 lib/vendor-document-list/table/enhanced-documents-table copy.tsx create mode 100644 lib/vendor-document-list/table/enhanced-documents-table.tsx create mode 100644 lib/vendor-document-list/table/revision-upload-dialog.tsx create mode 100644 lib/vendor-document-list/table/send-to-shi-button.tsx create mode 100644 lib/vendor-document-list/table/simplified-document-edit-dialog.tsx create mode 100644 lib/vendor-document-list/table/stage-revision-expanded-content.tsx create mode 100644 lib/vendor-document-list/table/stage-revision-sheet.tsx (limited to 'lib/vendor-document-list') diff --git a/lib/vendor-document-list/enhanced-document-service.ts b/lib/vendor-document-list/enhanced-document-service.ts new file mode 100644 index 00000000..00f40ea6 --- /dev/null +++ b/lib/vendor-document-list/enhanced-document-service.ts @@ -0,0 +1,782 @@ +// enhanced-document-service.ts +"use server" + +import { revalidatePath, unstable_cache } from "next/cache" +import { and, asc, desc, eq, ilike, or, count, avg } from "drizzle-orm" +import db from "@/db/db" +import { documentAttachments, documents, enhancedDocumentsView, issueStages, revisions, type EnhancedDocumentsView } from "@/db/schema/vendorDocu" +import { filterColumns } from "@/lib/filter-columns" +import type { + CreateDocumentInput, + UpdateDocumentInput, + CreateStageInput, + UpdateStageInput, + CreateRevisionInput, + UpdateRevisionStatusInput, + ApiResponse, + StageWithRevisions, + FullDocument, + DocumentAttachment, + Revision + } from "@/types/enhanced-documents" + +// 스키마 타입 정의 +export interface GetEnhancedDocumentsSchema { + page: number + perPage: number + search?: string + filters?: Array<{ + id: string + value: string | string[] + operator?: "eq" | "ne" | "like" | "ilike" | "in" | "notin" | "lt" | "lte" | "gt" | "gte" + }> + joinOperator?: "and" | "or" + sort?: Array<{ + id: keyof EnhancedDocumentsView + desc: boolean + }> +} + +// Repository 함수들 +export async function selectEnhancedDocuments( + tx: any, + options: { + where?: any + orderBy?: any + offset?: number + limit?: number + } +) { + const { where, orderBy, offset, limit } = options + + let query = tx.select().from(enhancedDocumentsView) + + if (where) { + query = query.where(where) + } + + if (orderBy) { + query = query.orderBy(...orderBy) + } + + if (offset !== undefined) { + query = query.offset(offset) + } + + if (limit !== undefined) { + query = query.limit(limit) + } + + return await query +} + +export async function countEnhancedDocuments(tx: any, where?: any) { + let query = tx.select({ count: count() }).from(enhancedDocumentsView) + + if (where) { + query = query.where(where) + } + + const result = await query + return result[0]?.count || 0 +} + +// 메인 서버 액션 +export async function getEnhancedDocuments( + input: GetEnhancedDocumentsSchema, + contractId: number +) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage + + // 고급 필터 처리 + const advancedWhere = filterColumns({ + table: enhancedDocumentsView, + filters: input.filters || [], + joinOperator: input.joinOperator || "and", + }) + + // 전역 검색 처리 + let globalWhere + if (input.search) { + const searchTerm = `%${input.search}%` + globalWhere = or( + ilike(enhancedDocumentsView.title, searchTerm), + ilike(enhancedDocumentsView.docNumber, searchTerm), + ilike(enhancedDocumentsView.currentStageName, searchTerm), + ilike(enhancedDocumentsView.currentStageAssigneeName, searchTerm) + ) + } + + // 최종 WHERE 조건 + const finalWhere = and( + advancedWhere, + globalWhere, + eq(enhancedDocumentsView.contractId, contractId) + ) + + // 정렬 처리 + const orderBy = input.sort && input.sort.length > 0 + ? input.sort.map((item) => + item.desc + ? desc(enhancedDocumentsView[item.id]) + : asc(enhancedDocumentsView[item.id]) + ) + : [desc(enhancedDocumentsView.createdAt)] + + // 트랜잭션 실행 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectEnhancedDocuments(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }) + + const total = await countEnhancedDocuments(tx, finalWhere) + + return { data, total } + }) + + const pageCount = Math.ceil(total / input.perPage) + + return { data, pageCount, total } + } catch (err) { + console.error("Error fetching enhanced documents:", err) + return { data: [], pageCount: 0, total: 0 } + } + }, + [JSON.stringify(input), String(contractId)], + { + revalidate: 3600, + tags: [`enhanced-documents-${contractId}`], + } + )() +} + +// 통계 데이터 가져오기 +export async function getDocumentStatistics(contractId: number) { + return unstable_cache( + async () => { + try { + const result = await db + .select({ + total: count(), + overdue: count(enhancedDocumentsView.isOverdue), + dueSoon: count(), // 별도 필터링 필요 + highPriority: count(), + avgProgress: count(), // 별도 계산 필요 + }) + .from(enhancedDocumentsView) + .where(eq(enhancedDocumentsView.contractId, contractId)) + + // 더 정확한 통계를 위한 별도 쿼리들 + const [overdue, dueSoon, highPriority] = await Promise.all([ + db + .select({ count: count() }) + .from(enhancedDocumentsView) + .where( + and( + eq(enhancedDocumentsView.contractId, contractId), + eq(enhancedDocumentsView.isOverdue, true) + ) + ), + db + .select({ count: count() }) + .from(enhancedDocumentsView) + .where( + and( + eq(enhancedDocumentsView.contractId, contractId), + eq(enhancedDocumentsView.isOverdue, false), + // daysUntilDue <= 3 AND daysUntilDue >= 0 조건 추가 필요 + ) + ), + db + .select({ count: count() }) + .from(enhancedDocumentsView) + .where( + and( + eq(enhancedDocumentsView.contractId, contractId), + eq(enhancedDocumentsView.currentStagePriority, "HIGH") + ) + ) + ]) + + // 평균 진행률 계산 + const avgProgressResult = await db + .select({ + avgProgress: avg(enhancedDocumentsView.progressPercentage) + }) + .from(enhancedDocumentsView) + .where(eq(enhancedDocumentsView.contractId, contractId)) + + return { + total: result[0]?.total || 0, + overdue: overdue[0]?.count || 0, + dueSoon: dueSoon[0]?.count || 0, + highPriority: highPriority[0]?.count || 0, + avgProgress: Math.round(avgProgressResult[0]?.avgProgress || 0), + } + } catch (err) { + console.error("Error fetching document statistics:", err) + return { + total: 0, + overdue: 0, + dueSoon: 0, + highPriority: 0, + avgProgress: 0, + } + } + }, + [`document-stats-${contractId}`], + { + revalidate: 1800, // 30분 캐시 + tags: [`document-stats-${contractId}`], + } + )() +} + +// 빠른 필터 데이터 +export async function getQuickFilterData(contractId: number) { + return unstable_cache( + async () => { + try { + const [all, overdue, dueSoon, inProgress, highPriority] = await Promise.all([ + countEnhancedDocuments(db, eq(enhancedDocumentsView.contractId, contractId)), + countEnhancedDocuments(db, and( + eq(enhancedDocumentsView.contractId, contractId), + eq(enhancedDocumentsView.isOverdue, true) + )), + // dueSoon 조건은 SQL에서 직접 처리하거나 별도 뷰 필요 + countEnhancedDocuments(db, and( + eq(enhancedDocumentsView.contractId, contractId), + eq(enhancedDocumentsView.currentStageStatus, "IN_PROGRESS") + )), + countEnhancedDocuments(db, and( + eq(enhancedDocumentsView.contractId, contractId), + eq(enhancedDocumentsView.currentStageStatus, "IN_PROGRESS") + )), + countEnhancedDocuments(db, and( + eq(enhancedDocumentsView.contractId, contractId), + eq(enhancedDocumentsView.currentStagePriority, "HIGH") + )) + ]) + + return { + all, + overdue, + dueSoon: 0, // 별도 계산 필요 + inProgress, + highPriority, + } + } catch (err) { + console.error("Error fetching quick filter data:", err) + return { + all: 0, + overdue: 0, + dueSoon: 0, + inProgress: 0, + highPriority: 0, + } + } + }, + [`quick-filter-${contractId}`], + { + revalidate: 1800, + tags: [`quick-filter-${contractId}`], + } + )() +} + +// 단일 문서 상세 정보 +export async function getDocumentDetails(documentId: number) { + return unstable_cache( + async () => { + try { + const result = await db + .select() + .from(enhancedDocumentsView) + .where(eq(enhancedDocumentsView.documentId, documentId)) + .limit(1) + + return result[0] || null + } catch (err) { + console.error("Error fetching document details:", err) + return null + } + }, + [`document-details-${documentId}`], + { + revalidate: 1800, + tags: [`document-details-${documentId}`], + } + )() +} + + +// 문서 CRUD 작업들 + export async function createDocument(input: CreateDocumentInput): Promise> { + try { + const [newDocument] = await db.insert(documents).values({ + contractId: input.contractId, + docNumber: input.docNumber, + title: input.title, + pic: input.pic, + issuedDate: input.issuedDate, + }).returning({ id: documents.id }) + + revalidatePath("/documents") + return { + success: true, + data: newDocument.id, + message: "문서가 성공적으로 생성되었습니다." + } + } catch (error) { + console.error("Error creating document:", error) + return { + success: false, + error: "문서 생성 중 오류가 발생했습니다." + } + } + } + + export async function updateDocument(input: UpdateDocumentInput): Promise> { + try { + await db.update(documents) + .set({ + ...input, + updatedAt: new Date(), + }) + .where(eq(documents.id, input.id)) + + revalidatePath("/documents") + return { + success: true, + message: "문서가 성공적으로 업데이트되었습니다." + } + } catch (error) { + console.error("Error updating document:", error) + return { + success: false, + error: "문서 업데이트 중 오류가 발생했습니다." + } + } + } + + export async function deleteDocument(id: number): Promise> { + try { + await db.delete(documents).where(eq(documents.id, id)) + + revalidatePath("/documents") + return { + success: true, + message: "문서가 성공적으로 삭제되었습니다." + } + } catch (error) { + console.error("Error deleting document:", error) + return { + success: false, + error: "문서 삭제 중 오류가 발생했습니다." + } + } + } + + // 스테이지 CRUD 작업들 + export async function createStage(input: CreateStageInput): Promise> { + try { + const [newStage] = await db.insert(issueStages).values({ + documentId: input.documentId, + stageName: input.stageName, + planDate: input.planDate, + stageOrder: input.stageOrder ?? 0, + priority: input.priority ?? 'MEDIUM', + assigneeId: input.assigneeId, + assigneeName: input.assigneeName, + description: input.description, + reminderDays: input.reminderDays ?? 3, + }).returning({ id: issueStages.id }) + + revalidatePath("/documents") + return { + success: true, + data: newStage.id, + message: "스테이지가 성공적으로 생성되었습니다." + } + } catch (error) { + console.error("Error creating stage:", error) + return { + success: false, + error: "스테이지 생성 중 오류가 발생했습니다." + } + } + } + + export async function updateStage(input: UpdateStageInput): Promise> { + try { + await db.update(issueStages) + .set({ + ...input, + updatedAt: new Date(), + }) + .where(eq(issueStages.id, input.id)) + + revalidatePath("/documents") + return { + success: true, + message: "스테이지가 성공적으로 업데이트되었습니다." + } + } catch (error) { + console.error("Error updating stage:", error) + return { + success: false, + error: "스테이지 업데이트 중 오류가 발생했습니다." + } + } + } + + export async function updateStageStatus( + stageId: number, + status: string + ): Promise> { + try { + const updateData: any = { + stageStatus: status, + updatedAt: new Date(), + } + + // 상태에 따른 자동 날짜 업데이트 + if (status === 'COMPLETED' || status === 'APPROVED') { + updateData.actualDate = new Date().toISOString().split('T')[0] + } + + await db.update(issueStages) + .set(updateData) + .where(eq(issueStages.id, stageId)) + + revalidatePath("/documents") + return { + success: true, + message: "스테이지 상태가 업데이트되었습니다." + } + } catch (error) { + console.error("Error updating stage status:", error) + return { + success: false, + error: "스테이지 상태 업데이트 중 오류가 발생했습니다." + } + } + } + + // 리비전 CRUD 작업들 + export async function createRevision(input: CreateRevisionInput): Promise> { + try { + const result = await db.transaction(async (tx) => { + // 리비전 생성 + const [newRevision] = await tx.insert(revisions).values({ + issueStageId: input.issueStageId, + revision: input.revision, + uploaderType: input.uploaderType, + uploaderId: input.uploaderId, + uploaderName: input.uploaderName, + comment: input.comment, + submittedDate: new Date().toISOString().split('T')[0], + }).returning({ id: revisions.id }) + + // 첨부파일들 생성 + if (input.attachments && input.attachments.length > 0) { + await tx.insert(documentAttachments).values( + input.attachments.map(attachment => ({ + revisionId: newRevision.id, + fileName: attachment.fileName, + filePath: attachment.filePath, + fileType: attachment.fileType, + fileSize: attachment.fileSize, + })) + ) + } + + // 스테이지 상태를 SUBMITTED로 업데이트 + await tx.update(issueStages) + .set({ + stageStatus: 'SUBMITTED', + updatedAt: new Date(), + }) + .where(eq(issueStages.id, input.issueStageId)) + + return newRevision.id + }) + + revalidatePath("/documents") + return { + success: true, + data: result, + message: "리비전이 성공적으로 업로드되었습니다." + } + } catch (error) { + console.error("Error creating revision:", error) + return { + success: false, + error: "리비전 업로드 중 오류가 발생했습니다." + } + } + } + + export async function updateRevisionStatus(input: UpdateRevisionStatusInput): Promise> { + try { + const updateData: any = { + revisionStatus: input.revisionStatus, + reviewerId: input.reviewerId, + reviewerName: input.reviewerName, + reviewComments: input.reviewComments, + updatedAt: new Date(), + } + + // 상태에 따른 자동 날짜 업데이트 + const today = new Date().toISOString().split('T')[0] + if (input.revisionStatus === 'UNDER_REVIEW') { + updateData.reviewStartDate = today + } else if (input.revisionStatus === 'APPROVED') { + updateData.approvedDate = today + } else if (input.revisionStatus === 'REJECTED') { + updateData.rejectedDate = today + } + + await db.update(revisions) + .set(updateData) + .where(eq(revisions.id, input.id)) + + revalidatePath("/documents") + return { + success: true, + message: "리비전 상태가 업데이트되었습니다." + } + } catch (error) { + console.error("Error updating revision status:", error) + return { + success: false, + error: "리비전 상태 업데이트 중 오류가 발생했습니다." + } + } + } + + // 조회 작업들 + export async function getDocumentWithStages(documentId: number): Promise> { + try { + // 문서 기본 정보 + const [document] = await db.select() + .from(documents) + .where(eq(documents.id, documentId)) + + if (!document) { + return { + success: false, + error: "문서를 찾을 수 없습니다." + } + } + + // 스테이지와 리비전, 첨부파일 조회 + const stagesData = await db.select({ + stage: issueStages, + revision: revisions, + attachment: documentAttachments, + }) + .from(issueStages) + .leftJoin(revisions, eq(issueStages.id, revisions.issueStageId)) + .leftJoin(documentAttachments, eq(revisions.id, documentAttachments.revisionId)) + .where(eq(issueStages.documentId, documentId)) + .orderBy(asc(issueStages.stageOrder), desc(revisions.createdAt)) + + // 데이터 구조화 + const stagesMap = new Map() + + stagesData.forEach(({ stage, revision, attachment }) => { + if (!stagesMap.has(stage.id)) { + stagesMap.set(stage.id, { + ...stage, + revisions: [] + }) + } + + const stageData = stagesMap.get(stage.id)! + + if (revision) { + let revisionData = stageData.revisions.find(r => r.id === revision.id) + if (!revisionData) { + revisionData = { + ...revision, + attachments: [] + } + stageData.revisions.push(revisionData) + } + + if (attachment) { + revisionData.attachments.push(attachment) + } + } + }) + + const stages = Array.from(stagesMap.values()) + + return { + success: true, + data: { + ...document, + stages, + currentStage: stages.find(s => s.stageStatus === 'IN_PROGRESS'), + latestRevision: stages + .flatMap(s => s.revisions) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0] + } + } + } catch (error) { + console.error("Error getting document with stages:", error) + return { + success: false, + error: "문서 조회 중 오류가 발생했습니다." + } + } + } + + // 문서의 스테이지와 리비전만 가져오는 경량화된 함수 + export async function getDocumentStagesWithRevisions(documentId: number): Promise> { + try { + const stagesData = await db.select({ + stage: issueStages, + revision: revisions, + attachment: documentAttachments, + }) + .from(issueStages) + .leftJoin(revisions, eq(issueStages.id, revisions.issueStageId)) + .leftJoin(documentAttachments, eq(revisions.id, documentAttachments.revisionId)) + .where(eq(issueStages.documentId, documentId)) + .orderBy(asc(issueStages.stageOrder), desc(revisions.createdAt)) + + console.log(documentId, stagesData) + + // 데이터 구조화 + const stagesMap = new Map() + + stagesData.forEach(({ stage, revision, attachment }) => { + if (!stagesMap.has(stage.id)) { + stagesMap.set(stage.id, { + ...stage, + revisions: [] + }) + } + + const stageData = stagesMap.get(stage.id)! + + if (revision) { + let revisionData = stageData.revisions.find(r => r.id === revision.id) + if (!revisionData) { + revisionData = { + ...revision, + attachments: [] + } + stageData.revisions.push(revisionData) + } + + if (attachment) { + revisionData.attachments.push(attachment) + } + } + }) + + const stages = Array.from(stagesMap.values()) + + return { + success: true, + data: stages + } + } catch (error) { + console.log(error) + console.error("Error getting document stages with revisions:", error) + return { + success: false, + error: "스테이지 조회 중 오류가 발생했습니다." + } + } + } + + // 특정 스테이지의 리비전들만 가져오는 함수 + export async function getStageRevisions(stageId: number): Promise>> { + try { + const revisionsData = await db.select({ + revision: revisions, + attachment: documentAttachments, + }) + .from(revisions) + .leftJoin(documentAttachments, eq(revisions.id, documentAttachments.revisionId)) + .where(eq(revisions.issueStageId, stageId)) + .orderBy(desc(revisions.createdAt)) + + console.log(stageId, revisionsData) + + // 데이터 구조화 + const revisionsMap = new Map() + + revisionsData.forEach(({ revision, attachment }) => { + if (!revisionsMap.has(revision.id)) { + revisionsMap.set(revision.id, { + ...revision, + attachments: [] + }) + } + + const revisionData = revisionsMap.get(revision.id)! + + if (attachment) { + revisionData.attachments.push(attachment) + } + }) + + return { + success: true, + data: Array.from(revisionsMap.values()) + } + } catch (error) { + console.error("Error getting stage revisions:", error) + return { + success: false, + error: "리비전 조회 중 오류가 발생했습니다." + } + } + } + + export async function bulkUpdateStageStatus( + stageIds: number[], + status: string + ): Promise> { + try { + const updateData: any = { + stageStatus: status, + updatedAt: new Date(), + } + + if (status === 'COMPLETED' || status === 'APPROVED') { + updateData.actualDate = new Date().toISOString().split('T')[0] + } + + await db.update(issueStages) + .set(updateData) + .where( + and( + ...stageIds.map(id => eq(issueStages.id, id)) + ) + ) + + revalidatePath("/documents") + return { + success: true, + message: `${stageIds.length}개 스테이지가 업데이트되었습니다.` + } + } catch (error) { + console.error("Error bulk updating stage status:", error) + return { + success: false, + error: "일괄 상태 업데이트 중 오류가 발생했습니다." + } + } + } \ No newline at end of file diff --git a/lib/vendor-document-list/sync-client.ts b/lib/vendor-document-list/sync-client.ts new file mode 100644 index 00000000..11439dcb --- /dev/null +++ b/lib/vendor-document-list/sync-client.ts @@ -0,0 +1,28 @@ +export class SyncClient { + static async getSyncStatus(contractId: number, targetSystem: string = 'SHI') { + const response = await fetch(`/api/sync/status?contractId=${contractId}&targetSystem=${targetSystem}`) + if (!response.ok) throw new Error('Failed to fetch sync status') + return response.json() + } + + static async triggerSync(contractId: number, targetSystem: string = 'SHI') { + const response = await fetch('/api/sync/trigger', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ contractId, targetSystem }) + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.message || 'Sync failed') + } + + return response.json() + } + + static async getSyncBatches(contractId: number, targetSystem: string = 'SHI', limit: number = 10) { + const response = await fetch(`/api/sync/batches?contractId=${contractId}&targetSystem=${targetSystem}&limit=${limit}`) + if (!response.ok) throw new Error('Failed to fetch sync batches') + return response.json() + } + } \ No newline at end of file diff --git a/lib/vendor-document-list/sync-service.ts b/lib/vendor-document-list/sync-service.ts new file mode 100644 index 00000000..6978c1cc --- /dev/null +++ b/lib/vendor-document-list/sync-service.ts @@ -0,0 +1,491 @@ +// lib/sync-service.ts +import db from "@/db/db" +import { + syncConfigs, + changeLogs, + syncBatches, + syncStatusView, + type SyncConfig, + type ChangeLog, + type SyncBatch +} from "@/db/schema/vendorDocu" +import { documents, revisions, documentAttachments } from "@/db/schema/vendorDocu" +import { eq, and, lt, desc, sql, inArray } from "drizzle-orm" +import { toast } from "sonner" + +export interface SyncableEntity { + entityType: 'document' | 'revision' | 'attachment' + entityId: number + action: 'CREATE' | 'UPDATE' | 'DELETE' + data: any + metadata?: Record +} + +export interface SyncResult { + batchId: number + success: boolean + successCount: number + failureCount: number + errors?: string[] +} + +class SyncService { + + /** + * 변경사항을 change_logs에 기록 + */ + async logChange( + contractId: number, + entityType: 'document' | 'revision' | 'attachment', + entityId: number, + action: 'CREATE' | 'UPDATE' | 'DELETE', + newValues?: any, + oldValues?: any, + userId?: number, + userName?: string + ) { + try { + const changedFields = this.detectChangedFields(oldValues, newValues) + + await db.insert(changeLogs).values({ + contractId, + entityType, + entityId, + action, + changedFields, + oldValues, + newValues, + userId, + userName, + targetSystems: ['SHI'], // 기본적으로 SHI로 동기화 + }) + + console.log(`Change logged: ${entityType}/${entityId} - ${action}`) + } catch (error) { + console.error('Failed to log change:', error) + throw error + } + } + + /** + * 변경된 필드 감지 + */ + private detectChangedFields(oldValues: any, newValues: any): Record | null { + if (!oldValues || !newValues) return null + + const changes: Record = {} + + for (const [key, newValue] of Object.entries(newValues)) { + if (JSON.stringify(oldValues[key]) !== JSON.stringify(newValue)) { + changes[key] = { + from: oldValues[key], + to: newValue + } + } + } + + return Object.keys(changes).length > 0 ? changes : null + } + + /** + * 계약별 동기화 설정 조회 + */ + async getSyncConfig(contractId: number, targetSystem: string = 'SHI'): Promise { + const [config] = await db + .select() + .from(syncConfigs) + .where(and( + eq(syncConfigs.contractId, contractId), + eq(syncConfigs.targetSystem, targetSystem) + )) + .limit(1) + + return config || null + } + + /** + * 동기화 설정 생성/업데이트 + */ + async upsertSyncConfig(config: Partial & { + contractId: number + targetSystem: string + endpointUrl: string + }) { + const existing = await this.getSyncConfig(config.contractId, config.targetSystem) + + if (existing) { + await db + .update(syncConfigs) + .set({ ...config, updatedAt: new Date() }) + .where(eq(syncConfigs.id, existing.id)) + } else { + await db.insert(syncConfigs).values(config) + } + } + + /** + * 동기화할 변경사항 조회 (증분) + */ + async getPendingChanges( + contractId: number, + targetSystem: string = 'SHI', + limit: number = 100 + ): Promise { + return await db + .select() + .from(changeLogs) + .where(and( + eq(changeLogs.contractId, contractId), + eq(changeLogs.isSynced, false), + lt(changeLogs.syncAttempts, 3), // 최대 3회 재시도 + sql`(${changeLogs.targetSystems} IS NULL OR ${changeLogs.targetSystems} @> ${JSON.stringify([targetSystem])})` + )) + .orderBy(changeLogs.createdAt) + .limit(limit) + } + + /** + * 동기화 배치 생성 + */ + async createSyncBatch( + contractId: number, + targetSystem: string, + changeLogIds: number[] + ): Promise { + const [batch] = await db + .insert(syncBatches) + .values({ + contractId, + targetSystem, + batchSize: changeLogIds.length, + changeLogIds, + status: 'PENDING' + }) + .returning({ id: syncBatches.id }) + + return batch.id + } + + /** + * 메인 동기화 실행 함수 + */ + async syncToExternalSystem( + contractId: number, + targetSystem: string = 'SHI', + manualTrigger: boolean = false + ): Promise { + try { + // 1. 동기화 설정 확인 + const config = await this.getSyncConfig(contractId, targetSystem) + if (!config || !config.syncEnabled) { + throw new Error(`Sync not enabled for contract ${contractId} to ${targetSystem}`) + } + + // 2. 대기 중인 변경사항 조회 + const pendingChanges = await this.getPendingChanges( + contractId, + targetSystem, + config.maxBatchSize || 100 + ) + + if (pendingChanges.length === 0) { + return { + batchId: 0, + success: true, + successCount: 0, + failureCount: 0 + } + } + + // 3. 배치 생성 + const batchId = await this.createSyncBatch( + contractId, + targetSystem, + pendingChanges.map(c => c.id) + ) + + // 4. 배치 상태를 PROCESSING으로 업데이트 + await db + .update(syncBatches) + .set({ + status: 'PROCESSING', + startedAt: new Date(), + updatedAt: new Date() + }) + .where(eq(syncBatches.id, batchId)) + + // 5. 실제 데이터 동기화 수행 + const syncResult = await this.performSync(config, pendingChanges) + + // 6. 배치 상태 업데이트 + await db + .update(syncBatches) + .set({ + status: syncResult.success ? 'SUCCESS' : (syncResult.successCount > 0 ? 'PARTIAL' : 'FAILED'), + completedAt: new Date(), + successCount: syncResult.successCount, + failureCount: syncResult.failureCount, + errorMessage: syncResult.errors?.join('; '), + updatedAt: new Date() + }) + .where(eq(syncBatches.id, batchId)) + + // 7. 성공한 변경사항들을 동기화 완료로 표시 + if (syncResult.successCount > 0) { + const successfulChangeIds = pendingChanges + .slice(0, syncResult.successCount) + .map(c => c.id) + + await db + .update(changeLogs) + .set({ + isSynced: true, + syncedAt: new Date() + }) + .where(inArray(changeLogs.id, successfulChangeIds)) + } + + // 8. 실패한 변경사항들의 재시도 횟수 증가 + if (syncResult.failureCount > 0) { + const failedChangeIds = pendingChanges + .slice(syncResult.successCount) + .map(c => c.id) + + await db + .update(changeLogs) + .set({ + syncAttempts: sql`${changeLogs.syncAttempts} + 1`, + lastSyncError: syncResult.errors?.[0] || 'Unknown error' + }) + .where(inArray(changeLogs.id, failedChangeIds)) + } + + // 9. 동기화 설정의 마지막 동기화 시간 업데이트 + await db + .update(syncConfigs) + .set({ + lastSyncAttempt: new Date(), + ...(syncResult.success && { lastSuccessfulSync: new Date() }), + updatedAt: new Date() + }) + .where(eq(syncConfigs.id, config.id)) + + return { + batchId, + success: syncResult.success, + successCount: syncResult.successCount, + failureCount: syncResult.failureCount, + errors: syncResult.errors + } + + } catch (error) { + console.error('Sync failed:', error) + throw error + } + } + + /** + * 실제 외부 시스템으로 데이터 전송 + */ + private async performSync( + config: SyncConfig, + changes: ChangeLog[] + ): Promise<{ success: boolean; successCount: number; failureCount: number; errors?: string[] }> { + const errors: string[] = [] + let successCount = 0 + let failureCount = 0 + + try { + // 변경사항을 외부 시스템 형태로 변환 + const syncData = await this.transformChangesForExternalSystem(changes) + + // 외부 API 호출 + const response = await fetch(config.endpointUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.authToken}`, + 'X-API-Version': config.apiVersion || 'v1' + }, + body: JSON.stringify({ + contractId: changes[0]?.contractId, + changes: syncData, + batchSize: changes.length, + timestamp: new Date().toISOString() + }) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`HTTP ${response.status}: ${errorText}`) + } + + const result = await response.json() + + // 응답에 따라 성공/실패 카운트 처리 + if (result.success) { + successCount = changes.length + } else if (result.partialSuccess) { + successCount = result.successCount || 0 + failureCount = changes.length - successCount + if (result.errors) { + errors.push(...result.errors) + } + } else { + failureCount = changes.length + if (result.error) { + errors.push(result.error) + } + } + + } catch (error) { + console.error('External sync failed:', error) + failureCount = changes.length + errors.push(error instanceof Error ? error.message : 'Unknown error') + } + + return { + success: failureCount === 0, + successCount, + failureCount, + errors: errors.length > 0 ? errors : undefined + } + } + + /** + * 변경사항을 외부 시스템 형태로 변환 + */ + private async transformChangesForExternalSystem(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 + } + + syncData.push({ + entityType: change.entityType as any, + entityId: change.entityId, + action: change.action as any, + data: entityData || change.oldValues, // DELETE의 경우 oldValues 사용 + metadata: { + changeId: change.id, + changedAt: change.createdAt, + changedBy: change.userName, + changedFields: change.changedFields + } + }) + + } catch (error) { + console.error(`Failed to transform change ${change.id}:`, error) + } + } + + return syncData + } + + /** + * 동기화 상태 조회 + */ + async getSyncStatus(contractId: number, targetSystem: string = 'SHI') { + const [status] = await db + .select() + .from(syncStatusView) + .where(and( + eq(syncStatusView.contractId, contractId), + eq(syncStatusView.targetSystem, targetSystem) + )) + .limit(1) + + return status + } + + /** + * 최근 동기화 배치 목록 조회 + */ + async getRecentSyncBatches(contractId: number, targetSystem: string = 'SHI', limit: number = 10) { + return await db + .select() + .from(syncBatches) + .where(and( + eq(syncBatches.contractId, contractId), + eq(syncBatches.targetSystem, targetSystem) + )) + .orderBy(desc(syncBatches.createdAt)) + .limit(limit) + } +} + +export const syncService = new SyncService() + +// 편의 함수들 +export async function logDocumentChange( + contractId: number, + documentId: number, + action: 'CREATE' | 'UPDATE' | 'DELETE', + newValues?: any, + oldValues?: any, + userId?: number, + userName?: string +) { + return syncService.logChange(contractId, 'document', documentId, action, newValues, oldValues, userId, userName) +} + +export async function logRevisionChange( + contractId: number, + revisionId: number, + action: 'CREATE' | 'UPDATE' | 'DELETE', + newValues?: any, + oldValues?: any, + userId?: number, + userName?: string +) { + return syncService.logChange(contractId, 'revision', revisionId, action, newValues, oldValues, userId, userName) +} + +export async function logAttachmentChange( + contractId: number, + attachmentId: number, + action: 'CREATE' | 'UPDATE' | 'DELETE', + newValues?: any, + oldValues?: any, + userId?: number, + userName?: string +) { + return syncService.logChange(contractId, 'attachment', attachmentId, action, newValues, oldValues, userId, userName) +} \ No newline at end of file diff --git a/lib/vendor-document-list/table/bulk-upload-dialog.tsx b/lib/vendor-document-list/table/bulk-upload-dialog.tsx new file mode 100644 index 00000000..b7021985 --- /dev/null +++ b/lib/vendor-document-list/table/bulk-upload-dialog.tsx @@ -0,0 +1,1162 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { toast } from "sonner" +import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" +import ExcelJS from 'exceljs' + +import { +Dialog, +DialogContent, +DialogDescription, +DialogFooter, +DialogHeader, +DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { +Form, +FormControl, +FormField, +FormItem, +FormLabel, +FormMessage, +} from "@/components/ui/form" +import { +Dropzone, +DropzoneDescription, +DropzoneInput, +DropzoneTitle, +DropzoneUploadIcon, +DropzoneZone, +} from "@/components/ui/dropzone" +import { +FileList, +FileListAction, +FileListHeader, +FileListIcon, +FileListInfo, +FileListItem, +FileListName, +FileListSize, +} from "@/components/ui/file-list" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { +Upload, +X, +Loader2, +FileSpreadsheet, +Files, +CheckCircle2, +AlertCircle, +Download +} from "lucide-react" +import prettyBytes from "pretty-bytes" +import type { EnhancedDocument } from "@/types/enhanced-documents" + +// 일괄 업로드 스키마 +const bulkUploadSchema = z.object({ +uploaderName: z.string().optional(), +comment: z.string().optional(), +templateFile: z.instanceof(File).optional(), +attachmentFiles: z.array(z.instanceof(File)).min(1, "최소 1개 파일이 필요합니다"), +}) + +type BulkUploadSchema = z.infer + +interface ParsedUploadItem { +documentId: number +docNumber: string +title: string +stage: string +revision: string +fileNames: string[] // ';'로 구분된 파일명들 +} + +interface FileMatchResult { +matched: { file: File; item: ParsedUploadItem }[] +unmatched: File[] +missingFiles: string[] +} + +interface BulkUploadDialogProps { +open: boolean +onOpenChange: (open: boolean) => void +documents: EnhancedDocument[] +projectType: "ship" | "plant" +contractId: number // ✅ contractId 추가 +} + +export function BulkUploadDialog({ +open, +onOpenChange, +documents, +projectType, +contractId, // ✅ contractId 받기 +}: BulkUploadDialogProps) { +const [selectedFiles, setSelectedFiles] = React.useState([]) +const [templateFile, setTemplateFile] = React.useState(null) +const [parsedData, setParsedData] = React.useState([]) +const [matchResult, setMatchResult] = React.useState(null) +const [isUploading, setIsUploading] = React.useState(false) +const [uploadProgress, setUploadProgress] = React.useState(0) +const [currentStep, setCurrentStep] = React.useState<'template' | 'files' | 'review' | 'upload'>('template') + +const router = useRouter() +const { data: session } = useSession() + +const form = useForm({ + resolver: zodResolver(bulkUploadSchema), + defaultValues: { + uploaderName: session?.user?.name || "", + comment: "", + templateFile: undefined, + attachmentFiles: [], + }, +}) + +React.useEffect(() => { + if (session?.user?.name) { + form.setValue('uploaderName', session.user.name) + } +}, [session?.user?.name, form]) + +// 다이얼로그가 열릴 때마다 업로더명 리프레시 +React.useEffect(() => { + if (open && session?.user?.name) { + form.setValue('uploaderName', session.user.name) + } +}, [open, session?.user?.name, form]) + +// 리비전 정렬 및 최신 리비전 찾기 헬퍼 함수들 +const compareRevisions = (a: string, b: string): number => { + // 알파벳 리비전 (A, B, C, ..., Z, AA, AB, ...) + const aIsAlpha = /^[A-Z]+$/.test(a) + const bIsAlpha = /^[A-Z]+$/.test(b) + + if (aIsAlpha && bIsAlpha) { + // 길이 먼저 비교 (A < AA) + if (a.length !== b.length) { + return a.length - b.length + } + // 같은 길이면 알파벳 순서 + return a.localeCompare(b) + } + + // 숫자 리비전 (0, 1, 2, ...) + const aIsNumber = /^\d+$/.test(a) + const bIsNumber = /^\d+$/.test(b) + + if (aIsNumber && bIsNumber) { + return parseInt(a) - parseInt(b) + } + + // 혼재된 경우 알파벳이 먼저 + if (aIsAlpha && bIsNumber) return -1 + if (aIsNumber && bIsAlpha) return 1 + + // 기타 복잡한 형태는 문자열 비교 + return a.localeCompare(b) +} + +const getLatestRevisionInStage = (document: EnhancedDocument, stageName: string): string => { + const stage = document.allStages?.find(s => s.stageName === stageName) + if (!stage || !stage.revisions || stage.revisions.length === 0) { + return '' + } + + // 리비전들을 정렬해서 최신 것 찾기 + const sortedRevisions = [...stage.revisions].sort((a, b) => + compareRevisions(a.revision, b.revision) + ) + + return sortedRevisions[sortedRevisions.length - 1]?.revision || '' +} + +const getNextRevision = (currentRevision: string): string => { + if (!currentRevision) return "A" + + // 알파벳 리비전 (A, B, C...) + if (/^[A-Z]+$/.test(currentRevision)) { + // 한 글자인 경우 + if (currentRevision.length === 1) { + const charCode = currentRevision.charCodeAt(0) + if (charCode < 90) { // Z가 아닌 경우 + return String.fromCharCode(charCode + 1) + } + return "AA" // Z 다음은 AA + } + + // 여러 글자인 경우 (AA, AB, ... AZ, BA, ...) + let result = currentRevision + let carry = true + let newResult = '' + + for (let i = result.length - 1; i >= 0 && carry; i--) { + let charCode = result.charCodeAt(i) + if (charCode < 90) { // Z가 아닌 경우 + newResult = String.fromCharCode(charCode + 1) + newResult + carry = false + } else { // Z인 경우 + newResult = 'A' + newResult + } + } + + if (carry) { + newResult = 'A' + newResult + } else { + newResult = result.substring(0, result.length - newResult.length) + newResult + } + + return newResult + } + + // 숫자 리비전 (0, 1, 2...) + if (/^\d+$/.test(currentRevision)) { + return String(parseInt(currentRevision) + 1) + } + + // 기타 복잡한 리비전 형태는 그대로 반환 + return currentRevision +} + +// 템플릿 export 함수 +const exportTemplate = async () => { + try { + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet('BulkUploadTemplate') + + // 헤더 정의 + const headers = [ + 'documentId', + 'docNumber', + 'title', + 'currentStage', + 'latestRevision', + 'targetStage', + 'targetRevision', + 'fileNames' + ] + + // 헤더 스타일링 + const headerRow = worksheet.addRow(headers) + headerRow.eachCell((cell, colNumber) => { + cell.font = { bold: true, color: { argb: 'FFFFFF' } } + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: '366092' } + } + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + } + cell.alignment = { horizontal: 'center', vertical: 'middle' } + }) + + // 데이터 추가 + documents.forEach(doc => { + const currentStageName = doc.currentStageName || '' + const latestRevision = getLatestRevisionInStage(doc, currentStageName) + + const row = worksheet.addRow([ + doc.documentId, + doc.docNumber, + doc.title, + currentStageName, + latestRevision, // 현재 스테이지의 최신 리비전 + currentStageName, // 기본값으로 현재 스테이지 설정 + latestRevision, // 기본값으로 현재 최신 리비전 설정 (사용자가 선택) + '', // 사용자가 입력할 파일명들 (';'로 구분) + ]) + + // 데이터 행 스타일링 + row.eachCell((cell, colNumber) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + } + + // 편집 가능한 칼럼 (targetStage, targetRevision, fileNames) 강조 + if (colNumber >= 6) { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFF2CC' } // 연한 노란색 + } + } + }) + }) + + // 칼럼 너비 설정 + worksheet.columns = [ + { width: 12 }, // documentId + { width: 18 }, // docNumber + { width: 35 }, // title + { width: 20 }, // currentStage + { width: 15 }, // latestRevision + { width: 20 }, // targetStage + { width: 15 }, // targetRevision + { width: 60 }, // fileNames + ] + + // 헤더 고정 + worksheet.views = [{ state: 'frozen', ySplit: 1 }] + + // 주석 추가 + const instructionRow = worksheet.insertRow(1, [ + '지침:', + '1. latestRevision: 현재 스테이지의 최신 리비전', + '2. targetStage: 업로드할 스테이지명 (수정 가능)', + '3. targetRevision: 같은 리비전에 파일 추가 시 그대로, 새 리비전 생성 시 수정', + '4. fileNames: 파일명들을 세미콜론(;)으로 구분', + '예: file1.pdf;file2.dwg;file3.xlsx', + '', + '← 이 행은 삭제하고 사용해도 됩니다' + ]) + + instructionRow.eachCell((cell) => { + cell.font = { italic: true, color: { argb: '888888' } } + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'F0F0F0' } + } + }) + + // 파일 다운로드 + 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 = `bulk-upload-template-${new Date().toISOString().split('T')[0]}.xlsx` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.URL.revokeObjectURL(url) + + toast.success("템플릿이 다운로드되었습니다. targetRevision은 기본값(최신 리비전)이 설정되어 있습니다!") + + } catch (error) { + console.error('템플릿 생성 오류:', error) + toast.error('템플릿 생성에 실패했습니다.') + } +} + +// 템플릿 파일 파싱 +const parseTemplateFile = async (file: File) => { + try { + const arrayBuffer = await file.arrayBuffer() + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.load(arrayBuffer) + + const worksheet = workbook.getWorksheet(1) // 첫 번째 워크시트 + if (!worksheet) { + throw new Error('워크시트를 찾을 수 없습니다.') + } + + // 헤더 행 찾기 (지침 행이 있을 수 있으므로) + let headerRowIndex = 1 + let headers: string[] = [] + + // 최대 5행까지 헤더를 찾아본다 + for (let i = 1; i <= 5; i++) { + const row = worksheet.getRow(i) + const firstCell = row.getCell(1).value + + if (firstCell && String(firstCell).includes('documentId')) { + headerRowIndex = i + headers = [] + + // 헤더 추출 + for (let col = 1; col <= 8; col++) { + const cellValue = row.getCell(col).value + headers.push(String(cellValue || '')) + } + break + } + } + + const expectedHeaders = ['documentId', 'docNumber', 'title', 'currentStage', 'latestRevision', 'targetStage', 'targetRevision', 'fileNames'] + const missingHeaders = expectedHeaders.filter(h => !headers.includes(h)) + + if (missingHeaders.length > 0) { + throw new Error(`필수 칼럼이 누락되었습니다: ${missingHeaders.join(', ')}`) + } + + // 데이터 파싱 + const parsed: ParsedUploadItem[] = [] + const rowCount = worksheet.rowCount + + console.log(`📊 파싱 시작: 총 ${rowCount}행, 헤더 행: ${headerRowIndex}`) + + for (let i = headerRowIndex + 1; i <= rowCount; i++) { + const row = worksheet.getRow(i) + + // 빈 행 스킵 + if (!row.hasValues) { + console.log(`행 ${i}: 빈 행 스킵`) + continue + } + + const documentIdCell = row.getCell(headers.indexOf('documentId') + 1).value + const docNumberCell = row.getCell(headers.indexOf('docNumber') + 1).value + const titleCell = row.getCell(headers.indexOf('title') + 1).value + const stageCell = row.getCell(headers.indexOf('targetStage') + 1).value + const revisionCell = row.getCell(headers.indexOf('targetRevision') + 1).value + const fileNamesCell = row.getCell(headers.indexOf('fileNames') + 1).value + + // 값들을 안전하게 변환 + const documentId = Number(documentIdCell) || 0 + const docNumber = String(docNumberCell || '').trim() + const title = String(titleCell || '').trim() + const stage = String(stageCell || '').trim().replace(/[ \s]/g, ' ').trim() // 전각공백 처리 + const revision = String(revisionCell || '').trim() + const fileNamesStr = String(fileNamesCell || '').trim().replace(/[ \s]/g, ' ').trim() // 전각공백 처리 + + console.log(`행 ${i} 파싱 결과:`, { + documentId, docNumber, title, stage, revision, fileNamesStr, + originalCells: { documentIdCell, docNumberCell, titleCell, stageCell, revisionCell, fileNamesCell } + }) + + // 필수 데이터 체크 (documentId와 docNumber만 체크, stage와 revision은 빈 값 허용) + if (!documentId || !docNumber) { + console.warn(`행 ${i}: 필수 데이터 누락 (documentId: ${documentId}, docNumber: ${docNumber})`) + continue + } + + // fileNames가 비어있는 행은 무시 + if (!fileNamesStr || fileNamesStr === '' || fileNamesStr === 'undefined' || fileNamesStr === 'null') { + console.log(`행 ${i}: fileNames가 비어있어 스킵합니다. (${docNumber}) - fileNamesStr: "${fileNamesStr}"`) + continue + } + + // stage와 revision이 비어있는 경우 기본값 설정 + const finalStage = stage || 'Default Stage' + const finalRevision = revision || 'A' + + const fileNames = fileNamesStr.split(';').map(name => name.trim()).filter(Boolean) + if (fileNames.length === 0) { + console.warn(`행 ${i}: 파일명 파싱 실패 (${docNumber}) - 원본: "${fileNamesStr}"`) + continue + } + + console.log(`✅ 행 ${i} 파싱 성공:`, { + documentId, docNumber, stage: finalStage, revision: finalRevision, fileNames + }) + + parsed.push({ + documentId, + docNumber, + title, + stage: finalStage, + revision: finalRevision, + fileNames, + }) + } + + console.log(`📋 파싱 완료: ${parsed.length}개 항목`) + + if (parsed.length === 0) { + console.error('파싱된 데이터:', parsed) + throw new Error('파싱할 수 있는 유효한 데이터가 없습니다. fileNames 칼럼에 파일명이 입력되어 있는지 확인해주세요.') + } + + setParsedData(parsed) + setCurrentStep('files') + toast.success(`템플릿 파싱 완료: ${parsed.length}개 항목, 총 ${parsed.reduce((sum, item) => sum + item.fileNames.length, 0)}개 파일 필요`) + + } catch (error) { + console.error('템플릿 파싱 오류:', error) + toast.error(error instanceof Error ? error.message : '템플릿 파싱에 실패했습니다.') + } +} + +// 파일 매칭 로직 +const matchFiles = (files: File[], uploadItems: ParsedUploadItem[]): FileMatchResult => { + const matched: { file: File; item: ParsedUploadItem }[] = [] + const unmatched: File[] = [] + const missingFiles: string[] = [] + + // 모든 필요한 파일명 수집 + const requiredFileNames = new Set() + uploadItems.forEach(item => { + item.fileNames.forEach(fileName => requiredFileNames.add(fileName)) + }) + + // 파일 매칭 + files.forEach(file => { + let isMatched = false + + for (const item of uploadItems) { + if (item.fileNames.some(fileName => fileName === file.name)) { + matched.push({ file, item }) + isMatched = true + break + } + } + + if (!isMatched) { + unmatched.push(file) + } + }) + + // 누락된 파일 찾기 + const uploadedFileNames = new Set(files.map(f => f.name)) + requiredFileNames.forEach(fileName => { + if (!uploadedFileNames.has(fileName)) { + missingFiles.push(fileName) + } + }) + + return { matched, unmatched, missingFiles } +} + +// 템플릿 드롭 처리 +const handleTemplateDropAccepted = (acceptedFiles: File[]) => { + const file = acceptedFiles[0] + if (!file) return + + if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.xls')) { + toast.error('Excel 파일(.xlsx, .xls)만 업로드 가능합니다.') + return + } + + setTemplateFile(file) + form.setValue('templateFile', file) + parseTemplateFile(file) +} + +// 파일 드롭 처리 +const handleFilesDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...selectedFiles, ...acceptedFiles] + setSelectedFiles(newFiles) + form.setValue('attachmentFiles', newFiles, { shouldValidate: true }) + + // 파일 매칭 수행 + if (parsedData.length > 0) { + const result = matchFiles(newFiles, parsedData) + setMatchResult(result) + setCurrentStep('review') + } +} + +// 파일 제거 +const removeFile = (index: number) => { + const updatedFiles = [...selectedFiles] + updatedFiles.splice(index, 1) + setSelectedFiles(updatedFiles) + form.setValue('attachmentFiles', updatedFiles, { shouldValidate: true }) + + if (parsedData.length > 0) { + const result = matchFiles(updatedFiles, parsedData) + setMatchResult(result) + } +} + +// 일괄 업로드 처리 +const onSubmit = async (data: BulkUploadSchema) => { + if (!matchResult || matchResult.matched.length === 0) { + toast.error('매칭된 파일이 없습니다.') + return + } + + setIsUploading(true) + setUploadProgress(0) + setCurrentStep('upload') + + try { + const formData = new FormData() + + // 메타데이터 + formData.append('uploaderName', data.uploaderName || '') + formData.append('comment', data.comment || '') + formData.append('projectType', projectType) + if (contractId) { + formData.append('contractId', String(contractId)) // ✅ contractId 추가 + } + formData.append('contractId', String(contractId)) // ✅ contractId 추가 + + // 매칭된 파일들과 메타데이터 + const uploadData = matchResult.matched.map(({ file, item }) => ({ + documentId: item.documentId, + stage: item.stage, + revision: item.revision, + fileName: file.name, + })) + + formData.append('uploadData', JSON.stringify(uploadData)) + + // 파일들 추가 + matchResult.matched.forEach(({ file }, index) => { + formData.append(`file_${index}`, file) + }) + + // 진행률 시뮬레이션 + const progressInterval = setInterval(() => { + setUploadProgress(prev => Math.min(prev + 10, 90)) + }, 500) + + const response = await fetch('/api/bulk-upload', { + method: 'POST', + body: formData, + }) + + clearInterval(progressInterval) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || '일괄 업로드에 실패했습니다.') + } + + const result = await response.json() + setUploadProgress(100) + + toast.success(`${result.data?.uploadedCount || 0}개 파일이 성공적으로 업로드되었습니다.`) + + setTimeout(() => { + handleDialogClose() + router.refresh() + }, 1000) + + } catch (error) { + console.error('일괄 업로드 오류:', error) + toast.error(error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다") + } finally { + setIsUploading(false) + setTimeout(() => setUploadProgress(0), 2000) + } +} + +const handleDialogClose = () => { + form.reset({ + uploaderName: session?.user?.name || "", // ✅ 항상 최신 session 값으로 리셋 + comment: "", + templateFile: undefined, + attachmentFiles: [], + }) + setSelectedFiles([]) + setTemplateFile(null) + setParsedData([]) + setMatchResult(null) + setCurrentStep('template') + setIsUploading(false) + setUploadProgress(0) + onOpenChange(false) +} + +const canProceedToUpload = matchResult && matchResult.matched.length > 0 && matchResult.missingFiles.length === 0 + +return ( + + + + + + 일괄 업로드 + + + 템플릿을 다운로드하여 파일명을 입력한 후, 실제 파일들을 업로드하세요. + + +
+ + {projectType === "ship" ? "조선 프로젝트" : "플랜트 프로젝트"} + + + 총 {documents.length}개 문서 + +
+
+ + {/* 단계별 진행 상태 */} +
+ {[ + { key: 'template', label: '템플릿' }, + { key: 'files', label: '파일 업로드' }, + { key: 'review', label: '검토' }, + { key: 'upload', label: '업로드' }, + ].map((step, index) => ( + +
['template', 'files', 'review'].indexOf(step.key) ? 'bg-green-100 text-green-700' : + 'bg-gray-100 text-gray-500' + }`}> + {step.label} +
+ {index < 3 &&
} + + ))} +
+ +
+ + + {/* 1단계: 템플릿 다운로드 및 업로드 */} + {currentStep === 'template' && ( +
+ + + + + 1단계: 템플릿 다운로드 + + + +

+ 현재 문서 목록을 기반으로 업로드 템플릿을 생성합니다. + 마지막 "fileNames" 칼럼에 업로드할 파일명을 ';'로 구분하여 입력하세요. +

+ +
+
+ + + + + + 작성된 템플릿 업로드 + + + + + + + + +
+ +
+ 작성된 Excel 템플릿을 업로드하세요 + + .xlsx, .xls 파일을 지원합니다 + +
+
+
+
+ + {templateFile && ( +
+
+ + + 템플릿 업로드 완료: {templateFile.name} + +
+
+ )} +
+
+
+ )} + + {/* 2단계: 파일 업로드 */} + {currentStep === 'files' && ( +
+ + + + + 2단계: 실제 파일들 업로드 + + + +
+

+ 템플릿에서 {parsedData.length}개 항목, 총 {parsedData.reduce((sum, item) => sum + item.fileNames.length, 0)}개 파일이 필요합니다. +

+
+ + + + + + +
+ +
+ 실제 파일들을 여기에 드롭하세요 + + 또는 클릭하여 파일들을 선택하세요 + +
+
+
+
+ + {selectedFiles.length > 0 && ( +
+
+ 업로드된 파일 ({selectedFiles.length}) +
+ + + {selectedFiles.map((file, index) => ( + + + + + {file.name} + {prettyBytes(file.size)} + + removeFile(index)} + disabled={isUploading} + > + + + + + ))} + + +
+ )} +
+
+
+ )} + + {/* 3단계: 매칭 결과 검토 */} + {currentStep === 'review' && matchResult && ( +
+ + + + + 3단계: 매칭 결과 검토 + + + + + {/* 통합된 매칭 결과 요약 */} +
+
+
{matchResult.matched.length}
+
매칭 성공
+
+
+
{matchResult.unmatched.length}
+
매칭 실패
+
+
+
{matchResult.missingFiles.length}
+
누락된 파일
+
+
+ + {/* 통합된 상세 결과 */} +
+ {/* 매칭 성공 섹션 */} + {matchResult.matched.length > 0 && ( +
+
+
+ + 매칭 성공 ({matchResult.matched.length}개) +
+ +
+ + {/* 미리보기 */} +
+
+ {matchResult.matched.slice(0, 5).map((match, index) => ( +
+ + {match.file.name} + + + → {match.item.docNumber} Rev.{match.item.revision} + +
+ ))} + {matchResult.matched.length > 5 && ( +
+ ... 외 {matchResult.matched.length - 5}개 (상세보기로 확인) +
+ )} +
+ + {/* 펼침 상세 내용 */} +
+
+
+ {matchResult.matched.map((match, index) => ( +
+ + {match.file.name} + + + → {match.item.docNumber} ({match.item.stage} Rev.{match.item.revision}) + +
+ ))} +
+
+
+
+
+ )} + + {/* 매칭 실패 섹션 */} + {matchResult.unmatched.length > 0 && ( +
+
+
+ + 매칭되지 않은 파일 ({matchResult.unmatched.length}개) +
+ +
+ +
+
+ {matchResult.unmatched.slice(0, 3).map((file, index) => ( +
+ {file.name} +
+ ))} + {matchResult.unmatched.length > 3 && ( +
+ ... 외 {matchResult.unmatched.length - 3}개 +
+ )} +
+ +
+
+
+ {matchResult.unmatched.map((file, index) => ( +
+ {file.name} +
+ ))} +
+
+
+
+
+ )} + + {/* 누락된 파일 섹션 */} + {matchResult.missingFiles.length > 0 && ( +
+
+
+ + 누락된 파일 ({matchResult.missingFiles.length}개) +
+ +
+ +
+
+ {matchResult.missingFiles.slice(0, 3).map((fileName, index) => ( +
+ {fileName} +
+ ))} + {matchResult.missingFiles.length > 3 && ( +
+ ... 외 {matchResult.missingFiles.length - 3}개 +
+ )} +
+ +
+
+
+ {matchResult.missingFiles.map((fileName, index) => ( +
+ {fileName} +
+ ))} +
+
+
+
+
+ )} +
+ + {/* 업로드 불가 경고 */} + {!canProceedToUpload && ( +
+
+ + + 누락된 파일이 있어 업로드를 진행할 수 없습니다. 누락된 파일들을 추가해주세요. + +
+
+ )} +
+
+ + {/* 추가 정보 입력 */} +
+ ( + + 업로더명 + + + + + + )} + /> + + ( + + 코멘트 (선택) + +