diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-28 00:32:31 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-28 00:32:31 +0000 |
| commit | 20800b214145ee6056f94ca18fa1054f145eb977 (patch) | |
| tree | b5c8b27febe5b126e6d9ece115ea05eace33a020 /lib/vendor-document-list | |
| parent | e1344a5da1aeef8fbf0f33e1dfd553078c064ccc (diff) | |
(대표님) lib 파트 커밋
Diffstat (limited to 'lib/vendor-document-list')
14 files changed, 7214 insertions, 0 deletions
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<ApiResponse<number>> { + 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<ApiResponse<void>> { + 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<ApiResponse<void>> { + 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<ApiResponse<number>> { + 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<ApiResponse<void>> { + 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<ApiResponse<void>> { + 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<ApiResponse<number>> { + 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<ApiResponse<void>> { + 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<ApiResponse<FullDocument>> { + 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<number, StageWithRevisions>() + + 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<ApiResponse<StageWithRevisions[]>> { + 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<number, StageWithRevisions>() + + 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<ApiResponse<Array<Revision & { attachments: DocumentAttachment[] }>>> { + 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<number, Revision & { attachments: DocumentAttachment[] }>() + + 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<ApiResponse<void>> { + 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<string, any> +} + +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<string, any> | null { + if (!oldValues || !newValues) return null + + const changes: Record<string, any> = {} + + 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<SyncConfig | null> { + 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<SyncConfig> & { + 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<ChangeLog[]> { + 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<number> { + 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<SyncResult> { + 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<SyncableEntity[]> { + 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<typeof bulkUploadSchema> + +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<File[]>([]) +const [templateFile, setTemplateFile] = React.useState<File | null>(null) +const [parsedData, setParsedData] = React.useState<ParsedUploadItem[]>([]) +const [matchResult, setMatchResult] = React.useState<FileMatchResult | null>(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<BulkUploadSchema>({ + 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<string>() + 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 ( + <Dialog open={open} onOpenChange={handleDialogClose}> + <DialogContent className="sm:max-w-6xl max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Files className="w-5 h-5" /> + 일괄 업로드 + </DialogTitle> + <DialogDescription> + 템플릿을 다운로드하여 파일명을 입력한 후, 실제 파일들을 업로드하세요. + </DialogDescription> + + <div className="flex items-center gap-2 pt-2"> + <Badge variant={projectType === "ship" ? "default" : "secondary"}> + {projectType === "ship" ? "조선 프로젝트" : "플랜트 프로젝트"} + </Badge> + <Badge variant="outline"> + 총 {documents.length}개 문서 + </Badge> + </div> + </DialogHeader> + + {/* 단계별 진행 상태 */} + <div className="flex items-center gap-2 mb-4"> + {[ + { key: 'template', label: '템플릿' }, + { key: 'files', label: '파일 업로드' }, + { key: 'review', label: '검토' }, + { key: 'upload', label: '업로드' }, + ].map((step, index) => ( + <React.Fragment key={step.key}> + <div className={`flex items-center gap-1 px-2 py-1 rounded text-xs ${ + currentStep === step.key ? 'bg-primary text-primary-foreground' : + ['template', 'files', 'review'].indexOf(currentStep) > ['template', 'files', 'review'].indexOf(step.key) ? 'bg-green-100 text-green-700' : + 'bg-gray-100 text-gray-500' + }`}> + {step.label} + </div> + {index < 3 && <div className="w-2 h-px bg-gray-300" />} + </React.Fragment> + ))} + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + + {/* 1단계: 템플릿 다운로드 및 업로드 */} + {currentStep === 'template' && ( + <div className="space-y-4"> + <Card> + <CardHeader> + <CardTitle className="text-lg flex items-center gap-2"> + <Download className="w-4 h-4" /> + 1단계: 템플릿 다운로드 + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <p className="text-sm text-gray-600"> + 현재 문서 목록을 기반으로 업로드 템플릿을 생성합니다. + 마지막 "fileNames" 칼럼에 업로드할 파일명을 ';'로 구분하여 입력하세요. + </p> + <Button type="button" onClick={exportTemplate} className="gap-2"> + <Download className="w-4 h-4" /> + 템플릿 다운로드 ({documents.length}개 문서) + </Button> + </CardContent> + </Card> + + <Card> + <CardHeader> + <CardTitle className="text-lg flex items-center gap-2"> + <Upload className="w-4 h-4" /> + 작성된 템플릿 업로드 + </CardTitle> + </CardHeader> + <CardContent> + <Dropzone + maxSize={10e6} // 10MB + multiple={false} + accept={{ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'application/vnd.ms-excel': ['.xls'] + }} + onDropAccepted={handleTemplateDropAccepted} + disabled={isUploading} + > + <DropzoneZone> + <FormControl> + <DropzoneInput /> + </FormControl> + <div className="flex items-center gap-6"> + <FileSpreadsheet className="w-8 h-8 text-gray-400" /> + <div className="grid gap-0.5"> + <DropzoneTitle>작성된 Excel 템플릿을 업로드하세요</DropzoneTitle> + <DropzoneDescription> + .xlsx, .xls 파일을 지원합니다 + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + </Dropzone> + + {templateFile && ( + <div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-lg"> + <div className="flex items-center gap-2"> + <CheckCircle2 className="w-4 h-4 text-green-600" /> + <span className="text-sm text-green-700"> + 템플릿 업로드 완료: {templateFile.name} + </span> + </div> + </div> + )} + </CardContent> + </Card> + </div> + )} + + {/* 2단계: 파일 업로드 */} + {currentStep === 'files' && ( + <div className="space-y-4"> + <Card> + <CardHeader> + <CardTitle className="text-lg flex items-center gap-2"> + <Files className="w-4 h-4" /> + 2단계: 실제 파일들 업로드 + </CardTitle> + </CardHeader> + <CardContent> + <div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg"> + <p className="text-sm text-blue-700"> + 템플릿에서 {parsedData.length}개 항목, 총 {parsedData.reduce((sum, item) => sum + item.fileNames.length, 0)}개 파일이 필요합니다. + </p> + </div> + + <Dropzone + maxSize={3e9} // 3GB + multiple={true} + onDropAccepted={handleFilesDropAccepted} + disabled={isUploading} + > + <DropzoneZone> + <FormControl> + <DropzoneInput /> + </FormControl> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>실제 파일들을 여기에 드롭하세요</DropzoneTitle> + <DropzoneDescription> + 또는 클릭하여 파일들을 선택하세요 + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + </Dropzone> + + {selectedFiles.length > 0 && ( + <div className="mt-4 space-y-2"> + <h6 className="text-sm font-semibold"> + 업로드된 파일 ({selectedFiles.length}) + </h6> + <ScrollArea className="max-h-[200px]"> + <FileList> + {selectedFiles.map((file, index) => ( + <FileListItem key={index} className="p-3"> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListSize>{prettyBytes(file.size)}</FileListSize> + </FileListInfo> + <FileListAction + onClick={() => removeFile(index)} + disabled={isUploading} + > + <X className="h-4 w-4" /> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </FileList> + </ScrollArea> + </div> + )} + </CardContent> + </Card> + </div> + )} + + {/* 3단계: 매칭 결과 검토 */} + {currentStep === 'review' && matchResult && ( + <div className="space-y-4"> + <Card> + <CardHeader> + <CardTitle className="text-lg flex items-center gap-2"> + <CheckCircle2 className="w-4 h-4" /> + 3단계: 매칭 결과 검토 + </CardTitle> + </CardHeader> + <CardContent className="space-y-6"> + + {/* 통합된 매칭 결과 요약 */} + <div className="grid grid-cols-3 gap-4"> + <div className="p-4 bg-green-50 border border-green-200 rounded-lg text-center"> + <div className="text-2xl font-bold text-green-600">{matchResult.matched.length}</div> + <div className="text-sm text-green-700">매칭 성공</div> + </div> + <div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg text-center"> + <div className="text-2xl font-bold text-yellow-600">{matchResult.unmatched.length}</div> + <div className="text-sm text-yellow-700">매칭 실패</div> + </div> + <div className="p-4 bg-red-50 border border-red-200 rounded-lg text-center"> + <div className="text-2xl font-bold text-red-600">{matchResult.missingFiles.length}</div> + <div className="text-sm text-red-700">누락된 파일</div> + </div> + </div> + + {/* 통합된 상세 결과 */} + <div className="border border-gray-200 rounded-lg overflow-hidden"> + {/* 매칭 성공 섹션 */} + {matchResult.matched.length > 0 && ( + <div className="border-b border-gray-200"> + <div className="p-4 bg-green-50 flex items-center justify-between"> + <h6 className="font-semibold text-green-700 flex items-center gap-2"> + <CheckCircle2 className="w-4 h-4" /> + 매칭 성공 ({matchResult.matched.length}개) + </h6> + <Button + variant="ghost" + size="sm" + type="button" + onClick={(e) => { + e.preventDefault() + e.stopPropagation() + const element = document.getElementById('matched-details') + if (element) { + element.style.display = element.style.display === 'none' ? 'block' : 'none' + } + }} + > + {matchResult.matched.length <= 5 ? '모두보기' : '상세보기'} + </Button> + </div> + + {/* 미리보기 */} + <div className="p-4 bg-green-25"> + <div className="space-y-2"> + {matchResult.matched.slice(0, 5).map((match, index) => ( + <div key={index} className="flex items-center justify-between text-sm"> + <span className="font-mono text-green-600 truncate max-w-[300px]" title={match.file.name}> + {match.file.name} + </span> + <span className="text-green-700 ml-4 whitespace-nowrap flex-shrink-0"> + → {match.item.docNumber} Rev.{match.item.revision} + </span> + </div> + ))} + {matchResult.matched.length > 5 && ( + <div className="text-gray-500 text-center text-sm py-2 border-t border-green-200"> + ... 외 {matchResult.matched.length - 5}개 (상세보기로 확인) + </div> + )} + </div> + + {/* 펼침 상세 내용 */} + <div id="matched-details" style={{ display: 'none' }} className="mt-4 pt-4 border-t border-green-200"> + <div className="max-h-64 overflow-y-auto"> + <div className="space-y-2"> + {matchResult.matched.map((match, index) => ( + <div key={index} className="flex items-center justify-between text-sm py-1"> + <span className="font-mono text-green-600 truncate max-w-[300px]" title={match.file.name}> + {match.file.name} + </span> + <span className="text-green-700 ml-4 whitespace-nowrap flex-shrink-0"> + → {match.item.docNumber} ({match.item.stage} Rev.{match.item.revision}) + </span> + </div> + ))} + </div> + </div> + </div> + </div> + </div> + )} + + {/* 매칭 실패 섹션 */} + {matchResult.unmatched.length > 0 && ( + <div className="border-b border-gray-200"> + <div className="p-4 bg-yellow-50 flex items-center justify-between"> + <h6 className="font-semibold text-yellow-700 flex items-center gap-2"> + <AlertCircle className="w-4 h-4" /> + 매칭되지 않은 파일 ({matchResult.unmatched.length}개) + </h6> + <Button + variant="ghost" + size="sm" + type="button" + onClick={() => { + const element = document.getElementById('unmatched-details') + if (element) { + element.style.display = element.style.display === 'none' ? 'block' : 'none' + } + }} + > + 상세보기 + </Button> + </div> + + <div className="p-4 bg-yellow-25"> + <div className="space-y-1"> + {matchResult.unmatched.slice(0, 3).map((file, index) => ( + <div key={index} className="text-sm text-yellow-600 font-mono truncate max-w-full" title={file.name}> + {file.name} + </div> + ))} + {matchResult.unmatched.length > 3 && ( + <div className="text-gray-500 text-center text-sm py-2"> + ... 외 {matchResult.unmatched.length - 3}개 + </div> + )} + </div> + + <div id="unmatched-details" style={{ display: 'none' }} className="mt-4 pt-4 border-t border-yellow-200"> + <div className="max-h-40 overflow-y-auto"> + <div className="space-y-1"> + {matchResult.unmatched.map((file, index) => ( + <div key={index} className="text-sm text-yellow-600 font-mono truncate max-w-full" title={file.name}> + {file.name} + </div> + ))} + </div> + </div> + </div> + </div> + </div> + )} + + {/* 누락된 파일 섹션 */} + {matchResult.missingFiles.length > 0 && ( + <div> + <div className="p-4 bg-red-50 flex items-center justify-between"> + <h6 className="font-semibold text-red-700 flex items-center gap-2"> + <X className="w-4 h-4" /> + 누락된 파일 ({matchResult.missingFiles.length}개) + </h6> + <Button + variant="ghost" + size="sm" + type="button" + onClick={() => { + const element = document.getElementById('missing-details') + if (element) { + element.style.display = element.style.display === 'none' ? 'block' : 'none' + } + }} + > + 상세보기 + </Button> + </div> + + <div className="p-4 bg-red-25"> + <div className="space-y-1"> + {matchResult.missingFiles.slice(0, 3).map((fileName, index) => ( + <div key={index} className="text-sm text-red-600 font-mono truncate max-w-full" title={fileName}> + {fileName} + </div> + ))} + {matchResult.missingFiles.length > 3 && ( + <div className="text-gray-500 text-center text-sm py-2"> + ... 외 {matchResult.missingFiles.length - 3}개 + </div> + )} + </div> + + <div id="missing-details" style={{ display: 'none' }} className="mt-4 pt-4 border-t border-red-200"> + <div className="max-h-40 overflow-y-auto"> + <div className="space-y-1"> + {matchResult.missingFiles.map((fileName, index) => ( + <div key={index} className="text-sm text-red-600 font-mono truncate max-w-full" title={fileName}> + {fileName} + </div> + ))} + </div> + </div> + </div> + </div> + </div> + )} + </div> + + {/* 업로드 불가 경고 */} + {!canProceedToUpload && ( + <div className="p-4 bg-red-50 border border-red-200 rounded-lg"> + <div className="flex items-center gap-2"> + <AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0" /> + <span className="text-sm text-red-700"> + 누락된 파일이 있어 업로드를 진행할 수 없습니다. 누락된 파일들을 추가해주세요. + </span> + </div> + </div> + )} + </CardContent> + </Card> + + {/* 추가 정보 입력 */} + <div className="grid grid-cols-1 gap-4"> + <FormField + control={form.control} + name="uploaderName" + render={({ field }) => ( + <FormItem> + <FormLabel>업로더명</FormLabel> + <FormControl> + <Input {...field} placeholder="업로더 이름" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="comment" + render={({ field }) => ( + <FormItem> + <FormLabel>코멘트 (선택)</FormLabel> + <FormControl> + <Textarea {...field} placeholder="일괄 업로드 코멘트" rows={2} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + )} + + {/* 4단계: 업로드 진행 */} + {currentStep === 'upload' && ( + <div className="space-y-4"> + <Card> + <CardHeader> + <CardTitle className="text-lg flex items-center gap-2"> + <Upload className="w-4 h-4" /> + 4단계: 업로드 진행중 + </CardTitle> + </CardHeader> + <CardContent> + <div className="space-y-4"> + <div className="flex items-center gap-2"> + <Loader2 className="h-4 w-4 animate-spin" /> + <span className="text-sm">{uploadProgress}% 업로드 중...</span> + </div> + <div className="h-2 w-full bg-muted rounded-full overflow-hidden"> + <div + className="h-full bg-primary rounded-full transition-all" + style={{ width: `${uploadProgress}%` }} + /> + </div> + {matchResult && ( + <p className="text-sm text-gray-600"> + {matchResult.matched.length}개 파일을 업로드하고 있습니다... + </p> + )} + </div> + </CardContent> + </Card> + </div> + )} + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={handleDialogClose} + disabled={isUploading} + > + 취소 + </Button> + + {currentStep === 'review' && ( + <Button + type="submit" + disabled={!canProceedToUpload || isUploading} + > + <Upload className="mr-2 h-4 w-4" /> + 일괄 업로드 ({matchResult?.matched.length || 0}개 파일) + </Button> + )} + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> +) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/enhanced-doc-table-columns.tsx b/lib/vendor-document-list/table/enhanced-doc-table-columns.tsx new file mode 100644 index 00000000..534a80a0 --- /dev/null +++ b/lib/vendor-document-list/table/enhanced-doc-table-columns.tsx @@ -0,0 +1,612 @@ +// updated-enhanced-doc-table-columns.tsx +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import { formatDate, formatDateTime } from "@/lib/utils" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { DataTableRowAction } from "@/types/table" +import { EnhancedDocumentsView } from "@/db/schema/vendorDocu" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Progress } from "@/components/ui/progress" +import { + Ellipsis, + AlertTriangle, + Clock, + CheckCircle, + Upload, + Calendar, + User, + FileText, + Eye, + Edit, + Trash2 +} from "lucide-react" +import { cn } from "@/lib/utils" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<EnhancedDocumentsView> | null>> + projectType: string | null +} + +// 유틸리티 함수들 +const getStatusColor = (status: string, isOverdue = false) => { + if (isOverdue) return 'destructive' + switch (status) { + case 'COMPLETED': case 'APPROVED': return 'success' + case 'IN_PROGRESS': return 'default' + case 'SUBMITTED': case 'UNDER_REVIEW': return 'secondary' + case 'REJECTED': return 'destructive' + default: return 'outline' + } +} + +const getPriorityColor = (priority: string) => { + switch (priority) { + case 'HIGH': return 'destructive' + case 'MEDIUM': return 'default' + case 'LOW': return 'secondary' + default: return 'outline' + } +} + +const getStatusText = (status: string) => { + switch (status) { + case 'PLANNED': return '계획됨' + case 'IN_PROGRESS': return '진행중' + case 'SUBMITTED': return '제출됨' + case 'UNDER_REVIEW': return '검토중' + case 'APPROVED': return '승인됨' + case 'REJECTED': return '반려됨' + case 'COMPLETED': return '완료됨' + default: return status + } +} + +const getPriorityText = (priority: string) => { + switch (priority) { + case 'HIGH': return '높음' + case 'MEDIUM': return '보통' + case 'LOW': return '낮음' + default: return priority + } +} + +// 마감일 정보 컴포넌트 +const DueDateInfo = ({ + daysUntilDue, + isOverdue, + className = "" +}: { + daysUntilDue: number | null + isOverdue: boolean + className?: string +}) => { + if (isOverdue && daysUntilDue !== null && daysUntilDue < 0) { + return ( + <div className={cn("flex items-center gap-1 text-red-600", className)}> + <AlertTriangle className="w-4 h-4" /> + <span className="text-sm font-medium">{Math.abs(daysUntilDue)}일 지연</span> + </div> + ) + } + + if (daysUntilDue === 0) { + return ( + <div className={cn("flex items-center gap-1 text-orange-600", className)}> + <Clock className="w-4 h-4" /> + <span className="text-sm font-medium">오늘 마감</span> + </div> + ) + } + + if (daysUntilDue && daysUntilDue > 0 && daysUntilDue <= 3) { + return ( + <div className={cn("flex items-center gap-1 text-orange-600", className)}> + <Clock className="w-4 h-4" /> + <span className="text-sm font-medium">{daysUntilDue}일 남음</span> + </div> + ) + } + + if (daysUntilDue && daysUntilDue > 0) { + return ( + <div className={cn("flex items-center gap-1 text-gray-600", className)}> + <Calendar className="w-4 h-4" /> + <span className="text-sm">{daysUntilDue}일 남음</span> + </div> + ) + } + + return ( + <div className={cn("flex items-center gap-1 text-green-600", className)}> + <CheckCircle className="w-4 h-4" /> + <span className="text-sm">완료</span> + </div> + ) +} + +export function getUpdatedEnhancedColumns({ + setRowAction, + projectType +}: GetColumnsProps): ColumnDef<EnhancedDocumentsView>[] { + return [ + // 체크박스 선택 + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + + // 문서번호 + 우선순위 + { + accessorKey: "docNumber", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="문서번호" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <div className="flex flex-col gap-1 items-start"> {/* ✅ items-start 추가 */} + <span className="font-mono text-sm font-medium">{doc.docNumber}</span> + {/* {doc.currentStagePriority && ( + <Badge variant={getPriorityColor(doc.currentStagePriority)} className="self-start inline-flex w-auto shrink-0 whitespace-nowrap text-xs" > + {getPriorityText(doc.currentStagePriority)} + </Badge> + )} */} + </div> + ) + }, + size: 120, + enableResizing: true, + meta: { + excelHeader: "문서번호" + }, + }, + + // 문서명 + 담당자 + { + accessorKey: "title", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="문서명" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <div className="min-w-0 flex-1"> + <div className="font-medium text-gray-900 truncate" title={doc.title}> + {doc.title} + </div> + <div className="flex items-center gap-2 text-sm text-gray-500 mt-1"> + {doc.pic && ( + <span className="text-xs bg-gray-100 px-2 py-0.5 rounded"> + PIC: {doc.pic} + </span> + )} + {doc.currentStageAssigneeName && ( + <div className="flex items-center gap-1"> + <User className="w-3 h-3" /> + <span>{doc.currentStageAssigneeName}</span> + </div> + )} + </div> + </div> + ) + }, + size: 250, + enableResizing: true, + meta: { + excelHeader: "문서명" + }, + }, + + // 현재 스테이지 + { + accessorKey: "currentStageName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="현재 스테이지" /> + ), + cell: ({ row }) => { + const doc = row.original + if (!doc.currentStageName) return <span className="text-gray-400">-</span> + + return ( + <div className="flex flex-col gap-1 items-start"> + <span className="text-sm font-medium">{doc.currentStageName}</span> + <Badge + variant={getStatusColor(doc.currentStageStatus || '', doc.isOverdue || false)} + className="self-start inline-flex w-auto shrink-0 whitespace-nowrap text-xs" + > + {getStatusText(doc.currentStageStatus || '')} + </Badge> + </div> + ) + }, + size: 140, + enableResizing: true, + meta: { + excelHeader: "현재 스테이지" + }, + }, + + // 일정 정보 + { + accessorKey: "currentStagePlanDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="일정" /> + ), + cell: ({ row }) => { + const doc = row.original + if (!doc.currentStagePlanDate) return <span className="text-gray-400">-</span> + + return ( + <div className="flex flex-col gap-1"> + <div className="text-sm"> + <span className="text-gray-500">계획: </span> + <span>{formatDate(doc.currentStagePlanDate)}</span> + </div> + {doc.currentStageActualDate && ( + <div className="text-sm"> + <span className="text-gray-500">실제: </span> + <span>{formatDate(doc.currentStageActualDate)}</span> + </div> + )} + <DueDateInfo + daysUntilDue={doc.daysUntilDue} + isOverdue={doc.isOverdue || false} + /> + </div> + ) + }, + size: 140, + enableResizing: true, + meta: { + excelHeader: "계획일" + }, + }, + + // 진행률 + { + accessorKey: "progressPercentage", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="진행률" /> + ), + cell: ({ row }) => { + const doc = row.original + const progress = doc.progressPercentage || 0 + const completed = doc.completedStages || 0 + const total = doc.totalStages || 0 + + return ( + <div className="flex flex-col gap-2"> + <div className="flex items-center gap-2"> + <Progress value={progress} className="flex-1" /> + <span className="text-sm font-medium text-gray-600 min-w-[3rem]"> + {progress}% + </span> + </div> + <span className="text-xs text-gray-500"> + {completed} / {total} 스테이지 + </span> + </div> + ) + }, + size: 120, + enableResizing: true, + meta: { + excelHeader: "진행률" + }, + }, + + // 최신 리비전 + { + accessorKey: "latestRevision", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="최신 리비전" /> + ), + cell: ({ row }) => { + const doc = row.original + if (!doc.latestRevision) return <span className="text-gray-400">없음</span> + + return ( + <div className="flex flex-col gap-1 items-start"> + <span className="font-mono text-sm font-medium">{doc.latestRevision}</span> + {/* <div className="text-xs text-gray-500">{doc.latestRevisionUploaderName}</div> */} + {doc.latestRevisionStatus && ( + <Badge variant={getStatusColor(doc.latestRevisionStatus)} className="self-start inline-flex w-auto shrink-0 whitespace-nowrap text-xs" > + {getStatusText(doc.latestRevisionStatus)} + </Badge> + )} + {doc.latestSubmittedDate && ( + <div className="text-xs text-gray-500"> + {formatDate(doc.latestSubmittedDate)} + </div> + )} + </div> + ) + }, + size: 140, + enableResizing: true, + meta: { + excelHeader: "최신 리비전" + }, + }, + + // 업데이트 일시 + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="업데이트" /> + ), + cell: ({ cell }) => ( + <span className="text-sm text-gray-600"> + {formatDateTime(cell.getValue() as Date)} + </span> + ), + size: 140, + enableResizing: true, + meta: { + excelHeader: "업데이트" + }, + }, + + // 액션 메뉴 + // 액션 메뉴 + { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const doc = row.original + const canSubmit = doc.currentStageStatus === 'IN_PROGRESS' + const canApprove = doc.currentStageStatus === 'SUBMITTED' + const isPlantProject = projectType === "plant" + + // 메뉴 아이템들을 그룹별로 정의 + const viewActions = [ + { + key: "view", + label: "상세보기", + icon: Eye, + action: () => setRowAction({ row, type: "view" }), + show: true + } + ] + + const editActions = [ + { + key: "update", + label: "편집", + icon: Edit, + action: () => setRowAction({ row, type: "update" }), + show: isPlantProject + } + ] + + const fileActions = [ + { + key: "upload", + label: "리비전 업로드", + icon: Upload, + action: () => setRowAction({ row, type: "upload" }), + show: canSubmit + } + ] + + const dangerActions = [ + { + key: "delete", + label: "삭제", + icon: Trash2, + action: () => setRowAction({ row, type: "delete" }), + show: isPlantProject, + className: "text-red-600", + shortcut: "⌘⌫" + } + ] + + // 각 그룹에서 표시될 아이템이 있는지 확인 + const hasEditActions = editActions.some(action => action.show) + const hasFileActions = fileActions.some(action => action.show) + const hasDangerActions = dangerActions.some(action => action.show) + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-7 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-48"> + {/* 기본 액션 그룹 */} + {viewActions.map(action => action.show && ( + <DropdownMenuItem + key={action.key} + onSelect={action.action} + className={action.className} + > + <action.icon className="mr-2 h-4 w-4" /> + {action.label} + {action.shortcut && ( + <DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut> + )} + </DropdownMenuItem> + ))} + + {/* 편집 액션 그룹 */} + {hasEditActions && ( + <> + <DropdownMenuSeparator /> + {editActions.map(action => action.show && ( + <DropdownMenuItem + key={action.key} + onSelect={action.action} + className={action.className} + > + <action.icon className="mr-2 h-4 w-4" /> + {action.label} + {action.shortcut && ( + <DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut> + )} + </DropdownMenuItem> + ))} + </> + )} + + {/* 파일 액션 그룹 */} + {hasFileActions && ( + <> + <DropdownMenuSeparator /> + {fileActions.map(action => action.show && ( + <DropdownMenuItem + key={action.key} + onSelect={action.action} + className={action.className} + > + <action.icon className="mr-2 h-4 w-4" /> + {action.label} + {action.shortcut && ( + <DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut> + )} + </DropdownMenuItem> + ))} + </> + )} + + {/* 위험한 액션 그룹 */} + {hasDangerActions && ( + <> + <DropdownMenuSeparator /> + {dangerActions.map(action => action.show && ( + <DropdownMenuItem + key={action.key} + onSelect={action.action} + className={action.className} + > + <action.icon className="mr-2 h-4 w-4" /> + {action.label} + {action.shortcut && ( + <DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut> + )} + </DropdownMenuItem> + ))} + </> + )} + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + ] +} + +// 확장된 행 컨텐츠 컴포넌트 (업데이트된 버전) +export const UpdatedExpandedRowContent = ({ + document +}: { + document: EnhancedDocumentsView +}) => { + if (!document.allStages || document.allStages.length === 0) { + return ( + <div className="p-4 text-sm text-gray-500 italic"> + 스테이지 정보가 없습니다. + </div> + ) + } + + return ( + <div className="p-4 w-1/2"> + <h4 className="font-medium mb-3 flex items-center gap-2"> + <FileText className="w-4 h-4" /> + 전체 스테이지 현황 + </h4> + + <div className="grid gap-3"> + {document.allStages.map((stage, index) => ( + <div key={stage.id} className="flex items-center justify-between p-3 bg-white rounded-lg border"> + <div className="flex items-center gap-3"> + <div className="flex items-center gap-2"> + <div className="w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center text-xs font-medium"> + {stage.stageOrder || index + 1} + </div> + <div className={cn( + "w-3 h-3 rounded-full", + stage.stageStatus === 'COMPLETED' ? 'bg-green-500' : + stage.stageStatus === 'IN_PROGRESS' ? 'bg-blue-500' : + stage.stageStatus === 'SUBMITTED' ? 'bg-purple-500' : + 'bg-gray-300' + )} /> + </div> + + <div> + <div className="font-medium text-sm">{stage.stageName}</div> + {stage.assigneeName && ( + <div className="text-xs text-gray-500 flex items-center gap-1 mt-1"> + <User className="w-3 h-3" /> + {stage.assigneeName} + </div> + )} + </div> + </div> + + <div className="flex items-center gap-4 text-sm"> + <div> + <span className="text-gray-500">계획: </span> + <span>{formatDate(stage.planDate)}</span> + </div> + {stage.actualDate && ( + <div> + <span className="text-gray-500">완료: </span> + <span>{formatDate(stage.actualDate)}</span> + </div> + )} + + <div className="flex items-center gap-2"> + <Badge variant={getPriorityColor(stage.priority)} className="text-xs"> + {getPriorityText(stage.priority)} + </Badge> + <Badge variant={getStatusColor(stage.stageStatus)} className="text-xs"> + {getStatusText(stage.stageStatus)} + </Badge> + </div> + </div> + </div> + ))} + </div> + </div> + ) +}
\ No newline at end of file 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 new file mode 100644 index 00000000..f9d4d695 --- /dev/null +++ b/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx @@ -0,0 +1,106 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload, Plus, Files } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { EnhancedDocumentsView } from "@/db/schema/vendorDocu" +import { AddDocumentListDialog } from "./add-doc-dialog" +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" + +interface EnhancedDocTableToolbarActionsProps { + table: Table<EnhancedDocument> + projectType: "ship" | "plant" + selectedPackageId: number + onNewDocument: () => void + onBulkAction: (action: string, selectedRows: any[]) => Promise<void> +} + +export function EnhancedDocTableToolbarActions({ + table, + projectType, + selectedPackageId, + onNewDocument, + onBulkAction +}: EnhancedDocTableToolbarActionsProps) { + const [bulkUploadDialogOpen, setBulkUploadDialogOpen] = React.useState(false) + + // 현재 테이블의 모든 데이터 (필터링된 상태) + const allDocuments = table.getFilteredRowModel().rows.map(row => row.original) + + const handleSyncComplete = () => { + // 동기화 완료 후 테이블 새로고침 + table.resetRowSelection() + // 필요시 추가 액션 수행 + } + + return ( + <div className="flex items-center gap-2"> + {/* 기존 액션들 */} + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeleteDocumentsDialog + documents={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null} + + {/* 메인 액션 버튼들 */} + {projectType === "plant" && ( + <Button onClick={onNewDocument} className="flex items-center gap-2"> + <Plus className="w-4 h-4" /> + 새 문서 + </Button> + )} + + {/* 일괄 업로드 버튼 */} + <Button + variant="outline" + onClick={() => setBulkUploadDialogOpen(true)} + className="flex items-center gap-2" + > + <Files className="w-4 h-4" /> + 일괄 업로드 + </Button> + + {/* Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "Document-list", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + + {/* ✅ 새로운 Send to SHI 버튼으로 교체 */} + <SendToSHIButton + contractId={selectedPackageId} + documents={allDocuments} + onSyncComplete={handleSyncComplete} + /> + + {/* 일괄 업로드 다이얼로그 */} + <BulkUploadDialog + open={bulkUploadDialogOpen} + onOpenChange={setBulkUploadDialogOpen} + documents={allDocuments} + projectType={projectType} + contractId={selectedPackageId} + /> + </div> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/enhanced-document-sheet.tsx b/lib/vendor-document-list/table/enhanced-document-sheet.tsx new file mode 100644 index 00000000..88e342c8 --- /dev/null +++ b/lib/vendor-document-list/table/enhanced-document-sheet.tsx @@ -0,0 +1,939 @@ +// enhanced-document-sheet.tsx +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" +import { useRouter } from "next/navigation" +import { + Loader, + Save, + Upload, + Calendar, + User, + FileText, + AlertTriangle, + CheckCircle, + Clock, + Plus, + X +} from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Calendar as CalendarComponent } from "@/components/ui/calendar" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { cn } from "@/lib/utils" +import { format } from "date-fns" +import { ko } from "date-fns/locale" +import { EnhancedDocumentsView } from "@/db/schema/vendorDocu" + +// 드롭존과 파일 관련 컴포넌트들 +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone" +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list" +import prettyBytes from "pretty-bytes" + +// 스키마 정의 +const enhancedDocumentSchema = z.object({ + // 기본 문서 정보 + docNumber: z.string().min(1, "문서번호는 필수입니다"), + title: z.string().min(1, "제목은 필수입니다"), + pic: z.string().optional(), + status: z.string().min(1, "상태는 필수입니다"), + issuedDate: z.date().optional(), + + // 스테이지 관리 (plant 타입에서만 수정 가능) + stages: z.array(z.object({ + id: z.number().optional(), + stageName: z.string().min(1, "스테이지명은 필수입니다"), + stageOrder: z.number(), + priority: z.enum(["HIGH", "MEDIUM", "LOW"]).default("MEDIUM"), + planDate: z.date().optional(), + assigneeName: z.string().optional(), + description: z.string().optional(), + })).optional(), + + // 리비전 업로드 (현재 스테이지에 대한) + newRevision: z.object({ + stage: z.string().optional(), + revision: z.string().optional(), + uploaderType: z.enum(["vendor", "client", "shi"]).default("vendor"), + uploaderName: z.string().optional(), + comment: z.string().optional(), + attachments: z.array(z.instanceof(File)).optional(), + }).optional(), +}) + +type EnhancedDocumentSchema = z.infer<typeof enhancedDocumentSchema> + +// 상태 옵션 정의 +const statusOptions = [ + { value: "ACTIVE", label: "활성" }, + { value: "INACTIVE", label: "비활성" }, + { value: "COMPLETED", label: "완료" }, + { value: "CANCELLED", label: "취소" }, +] + +const priorityOptions = [ + { value: "HIGH", label: "높음" }, + { value: "MEDIUM", label: "보통" }, + { value: "LOW", label: "낮음" }, +] + +const stageStatusOptions = [ + { value: "PLANNED", label: "계획됨" }, + { value: "IN_PROGRESS", label: "진행중" }, + { value: "SUBMITTED", label: "제출됨" }, + { value: "APPROVED", label: "승인됨" }, + { value: "REJECTED", label: "반려됨" }, + { value: "COMPLETED", label: "완료됨" }, +] + +interface EnhancedDocumentSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + document: EnhancedDocumentsView | null + projectType: "ship" | "plant" + mode: "view" | "edit" | "upload" | "schedule" | "approve" +} + +export function EnhancedDocumentSheet({ + document, + projectType, + mode = "view", + ...props +}: EnhancedDocumentSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]) + const [uploadProgress, setUploadProgress] = React.useState(0) + const [activeTab, setActiveTab] = React.useState("info") + const router = useRouter() + + // 권한 계산 + const permissions = React.useMemo(() => { + const canEdit = projectType === "plant" || mode === "edit" + const canUpload = mode === "upload" || mode === "edit" + const canApprove = mode === "approve" && projectType === "ship" + const canSchedule = mode === "schedule" || (projectType === "plant" && mode === "edit") + + return { canEdit, canUpload, canApprove, canSchedule } + }, [projectType, mode]) + + const form = useForm<EnhancedDocumentSchema>({ + resolver: zodResolver(enhancedDocumentSchema), + defaultValues: { + docNumber: "", + title: "", + pic: "", + status: "ACTIVE", + issuedDate: undefined, + stages: [], + newRevision: { + stage: "", + revision: "", + uploaderType: "vendor", + uploaderName: "", + comment: "", + attachments: [], + }, + }, + }) + + // 폼 초기화 + React.useEffect(() => { + if (document) { + form.reset({ + docNumber: document.docNumber, + title: document.title, + pic: document.pic || "", + status: document.status, + issuedDate: document.issuedDate ? new Date(document.issuedDate) : undefined, + stages: document.allStages?.map((stage, index) => ({ + id: stage.id, + stageName: stage.stageName, + stageOrder: stage.stageOrder || index, + priority: stage.priority as "HIGH" | "MEDIUM" | "LOW" || "MEDIUM", + planDate: stage.planDate ? new Date(stage.planDate) : undefined, + assigneeName: stage.assigneeName || "", + description: "", + })) || [], + newRevision: { + stage: document.currentStageName || "", + revision: "", + uploaderType: "vendor", + uploaderName: "", + comment: "", + attachments: [], + }, + }) + + // 모드에 따른 기본 탭 설정 + if (mode === "upload") { + setActiveTab("upload") + } else if (mode === "schedule") { + setActiveTab("schedule") + } else if (mode === "approve") { + setActiveTab("approve") + } + } + }, [document, form, mode]) + + // 파일 처리 + const handleDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...selectedFiles, ...acceptedFiles] + setSelectedFiles(newFiles) + form.setValue('newRevision.attachments', newFiles) + } + + const removeFile = (index: number) => { + const updatedFiles = [...selectedFiles] + updatedFiles.splice(index, 1) + setSelectedFiles(updatedFiles) + form.setValue('newRevision.attachments', updatedFiles) + } + + // 스테이지 추가/제거 + const addStage = () => { + const currentStages = form.getValues("stages") || [] + const newStage = { + stageName: "", + stageOrder: currentStages.length, + priority: "MEDIUM" as const, + planDate: undefined, + assigneeName: "", + description: "", + } + form.setValue("stages", [...currentStages, newStage]) + } + + const removeStage = (index: number) => { + const currentStages = form.getValues("stages") || [] + const updatedStages = currentStages.filter((_, i) => i !== index) + form.setValue("stages", updatedStages) + } + + // 제출 처리 + function onSubmit(input: EnhancedDocumentSchema) { + startUpdateTransition(async () => { + if (!document) return + + try { + // 모드에 따른 다른 처리 + switch (mode) { + case "edit": + // 문서 정보 업데이트 + 스테이지 관리 + await updateDocumentInfo(input) + break + case "upload": + // 리비전 업로드 + await uploadRevision(input) + break + case "approve": + // 승인 처리 + await approveRevision(input) + break + case "schedule": + // 스케줄 관리 + await updateSchedule(input) + break + } + + form.reset() + setSelectedFiles([]) + props.onOpenChange?.(false) + toast.success("성공적으로 처리되었습니다") + router.refresh() + } catch (error) { + toast.error("처리 중 오류가 발생했습니다") + console.error(error) + } + }) + } + + // 개별 처리 함수들 + const updateDocumentInfo = async (input: EnhancedDocumentSchema) => { + // 문서 기본 정보 업데이트 API 호출 + console.log("문서 정보 업데이트:", input) + } + + const uploadRevision = async (input: EnhancedDocumentSchema) => { + if (!input.newRevision?.attachments?.length) { + throw new Error("파일을 선택해주세요") + } + + // 파일 업로드 처리 + const formData = new FormData() + formData.append("documentId", String(document?.documentId)) + formData.append("stage", input.newRevision.stage || "") + formData.append("revision", input.newRevision.revision || "") + formData.append("uploaderType", input.newRevision.uploaderType) + + input.newRevision.attachments.forEach((file) => { + formData.append("attachments", file) + }) + + // API 호출 + console.log("리비전 업로드:", formData) + } + + const approveRevision = async (input: EnhancedDocumentSchema) => { + // 승인 처리 API 호출 + console.log("리비전 승인:", input) + } + + const updateSchedule = async (input: EnhancedDocumentSchema) => { + // 스케줄 업데이트 API 호출 + console.log("스케줄 업데이트:", input) + } + + // 제목 및 설명 생성 + const getSheetTitle = () => { + switch (mode) { + case "edit": return "문서 정보 수정" + case "upload": return "리비전 업로드" + case "approve": return "문서 승인" + case "schedule": return "일정 관리" + default: return "문서 상세" + } + } + + const getSheetDescription = () => { + const docInfo = document ? `${document.docNumber} - ${document.title}` : "" + switch (mode) { + case "edit": return `문서 정보를 수정합니다. ${docInfo}` + case "upload": return `새 리비전을 업로드합니다. ${docInfo}` + case "approve": return `문서를 검토하고 승인 처리합니다. ${docInfo}` + case "schedule": return `문서의 일정을 관리합니다. ${docInfo}` + default: return docInfo + } + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-2xl w-full"> + <SheetHeader className="text-left"> + <SheetTitle className="flex items-center gap-2"> + {mode === "upload" && <Upload className="w-5 h-5" />} + {mode === "approve" && <CheckCircle className="w-5 h-5" />} + {mode === "schedule" && <Calendar className="w-5 h-5" />} + {mode === "edit" && <FileText className="w-5 h-5" />} + {getSheetTitle()} + </SheetTitle> + <SheetDescription> + {getSheetDescription()} + </SheetDescription> + + {/* 프로젝트 타입 및 권한 표시 */} + <div className="flex items-center gap-2 pt-2"> + <Badge variant={projectType === "ship" ? "default" : "secondary"}> + {projectType === "ship" ? "조선 프로젝트" : "플랜트 프로젝트"} + </Badge> + {document?.isOverdue && ( + <Badge variant="destructive" className="flex items-center gap-1"> + <AlertTriangle className="w-3 h-3" /> + 지연 + </Badge> + )} + {document?.currentStagePriority === "HIGH" && ( + <Badge variant="destructive">높은 우선순위</Badge> + )} + </div> + </SheetHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 flex flex-col"> + <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col"> + <TabsList className="grid w-full grid-cols-4"> + <TabsTrigger value="info">기본정보</TabsTrigger> + <TabsTrigger value="schedule" disabled={!permissions.canSchedule}> + 일정관리 + </TabsTrigger> + <TabsTrigger value="upload" disabled={!permissions.canUpload}> + 리비전업로드 + </TabsTrigger> + <TabsTrigger value="approve" disabled={!permissions.canApprove}> + 승인처리 + </TabsTrigger> + </TabsList> + + {/* 기본 정보 탭 */} + <TabsContent value="info" className="flex-1 space-y-4"> + <ScrollArea className="h-full pr-4"> + <div className="space-y-4"> + <FormField + control={form.control} + name="docNumber" + render={({ field }) => ( + <FormItem> + <FormLabel>문서번호</FormLabel> + <FormControl> + <Input {...field} disabled={!permissions.canEdit} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="title" + render={({ field }) => ( + <FormItem> + <FormLabel>제목</FormLabel> + <FormControl> + <Input {...field} disabled={!permissions.canEdit} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="pic" + render={({ field }) => ( + <FormItem> + <FormLabel>담당자 (PIC)</FormLabel> + <FormControl> + <Input {...field} disabled={!permissions.canEdit} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>상태</FormLabel> + <Select + onValueChange={field.onChange} + value={field.value} + disabled={!permissions.canEdit} + > + <FormControl> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + </FormControl> + <SelectContent> + {statusOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <FormField + control={form.control} + name="issuedDate" + render={({ field }) => ( + <FormItem> + <FormLabel>발행일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + disabled={!permissions.canEdit} + > + {field.value ? ( + format(field.value, "yyyy년 MM월 dd일", { locale: ko }) + ) : ( + <span>날짜를 선택하세요</span> + )} + <Calendar className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <CalendarComponent + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => date > new Date()} + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + {/* 현재 상태 정보 표시 */} + {document && ( + <div className="space-y-3 p-4 bg-gray-50 rounded-lg"> + <h4 className="font-medium flex items-center gap-2"> + <Clock className="w-4 h-4" /> + 현재 진행 상황 + </h4> + <div className="grid grid-cols-2 gap-4 text-sm"> + <div> + <span className="text-gray-500">현재 스테이지:</span> + <p className="font-medium">{document.currentStageName || "-"}</p> + </div> + <div> + <span className="text-gray-500">진행률:</span> + <p className="font-medium">{document.progressPercentage || 0}%</p> + </div> + <div> + <span className="text-gray-500">최신 리비전:</span> + <p className="font-medium">{document.latestRevision || "-"}</p> + </div> + <div> + <span className="text-gray-500">담당자:</span> + <p className="font-medium">{document.currentStageAssigneeName || "-"}</p> + </div> + </div> + </div> + )} + </div> + </ScrollArea> + </TabsContent> + + {/* 일정 관리 탭 */} + <TabsContent value="schedule" className="flex-1 space-y-4"> + <ScrollArea className="h-full pr-4"> + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <h4 className="font-medium">스테이지 일정 관리</h4> + {projectType === "plant" && ( + <Button + type="button" + variant="outline" + size="sm" + onClick={addStage} + className="flex items-center gap-1" + > + <Plus className="w-4 h-4" /> + 스테이지 추가 + </Button> + )} + </div> + + {form.watch("stages")?.map((stage, index) => ( + <div key={index} className="p-4 border rounded-lg space-y-3"> + <div className="flex items-center justify-between"> + <h5 className="font-medium">스테이지 {index + 1}</h5> + {projectType === "plant" && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeStage(index)} + > + <X className="w-4 h-4" /> + </Button> + )} + </div> + + <div className="grid grid-cols-2 gap-3"> + <FormField + control={form.control} + name={`stages.${index}.stageName`} + render={({ field }) => ( + <FormItem> + <FormLabel>스테이지명</FormLabel> + <FormControl> + <Input {...field} disabled={projectType === "ship"} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name={`stages.${index}.priority`} + render={({ field }) => ( + <FormItem> + <FormLabel>우선순위</FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + </FormControl> + <SelectContent> + {priorityOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name={`stages.${index}.planDate`} + render={({ field }) => ( + <FormItem> + <FormLabel>계획일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + format(field.value, "MM/dd", { locale: ko }) + ) : ( + <span>날짜 선택</span> + )} + <Calendar className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <CalendarComponent + mode="single" + selected={field.value} + onSelect={field.onChange} + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name={`stages.${index}.assigneeName`} + render={({ field }) => ( + <FormItem> + <FormLabel>담당자</FormLabel> + <FormControl> + <Input {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + ))} + </div> + </ScrollArea> + </TabsContent> + + {/* 리비전 업로드 탭 */} + <TabsContent value="upload" className="flex-1 space-y-4"> + <ScrollArea className="h-full pr-4"> + <div className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="newRevision.stage" + render={({ field }) => ( + <FormItem> + <FormLabel>스테이지</FormLabel> + <FormControl> + <Input {...field} placeholder="예: Issued for Review" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="newRevision.revision" + render={({ field }) => ( + <FormItem> + <FormLabel>리비전</FormLabel> + <FormControl> + <Input {...field} placeholder="예: A, B, 1, 2..." /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <FormField + control={form.control} + name="newRevision.uploaderName" + render={({ field }) => ( + <FormItem> + <FormLabel>업로더명 (선택)</FormLabel> + <FormControl> + <Input {...field} placeholder="업로더 이름을 입력하세요" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="newRevision.comment" + render={({ field }) => ( + <FormItem> + <FormLabel>코멘트 (선택)</FormLabel> + <FormControl> + <Textarea {...field} placeholder="코멘트를 입력하세요" rows={3} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 파일 업로드 드롭존 */} + <FormField + control={form.control} + name="newRevision.attachments" + render={() => ( + <FormItem> + <FormLabel>파일 첨부</FormLabel> + <Dropzone + maxSize={3e9} // 3GB + multiple={true} + onDropAccepted={handleDropAccepted} + disabled={isUpdatePending} + > + <DropzoneZone className="flex justify-center"> + <FormControl> + <DropzoneInput /> + </FormControl> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle> + <DropzoneDescription> + 또는 클릭하여 파일을 선택하세요 + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + </Dropzone> + <FormMessage /> + </FormItem> + )} + /> + + {/* 선택된 파일 목록 */} + {selectedFiles.length > 0 && ( + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <h6 className="text-sm font-semibold"> + 선택된 파일 ({selectedFiles.length}) + </h6> + </div> + <FileList className="max-h-[200px]"> + {selectedFiles.map((file, index) => ( + <FileListItem key={index} className="p-3"> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListDescription> + {prettyBytes(file.size)} + </FileListDescription> + </FileListInfo> + <FileListAction + onClick={() => removeFile(index)} + disabled={isUpdatePending} + > + <X className="h-4 w-4" /> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </FileList> + </div> + )} + + {/* 업로드 진행 상태 */} + {isUpdatePending && uploadProgress > 0 && ( + <div className="space-y-2"> + <div className="flex items-center gap-2"> + <Loader className="h-4 w-4 animate-spin" /> + <span className="text-sm">{uploadProgress}% 업로드 중...</span> + </div> + <div className="h-2 w-full bg-muted rounded-full overflow-hidden"> + <div + className="h-full bg-primary rounded-full transition-all" + style={{ width: `${uploadProgress}%` }} + /> + </div> + </div> + )} + </div> + </ScrollArea> + </TabsContent> + + {/* 승인 처리 탭 */} + <TabsContent value="approve" className="flex-1 space-y-4"> + <ScrollArea className="h-full pr-4"> + <div className="space-y-4"> + <div className="p-4 bg-blue-50 rounded-lg"> + <h4 className="font-medium mb-2 flex items-center gap-2"> + <CheckCircle className="w-4 h-4 text-blue-600" /> + 승인 대상 문서 + </h4> + <div className="text-sm space-y-1"> + <p><span className="font-medium">문서:</span> {document?.docNumber} - {document?.title}</p> + <p><span className="font-medium">현재 스테이지:</span> {document?.currentStageName}</p> + <p><span className="font-medium">최신 리비전:</span> {document?.latestRevision}</p> + <p><span className="font-medium">업로더:</span> {document?.latestRevisionUploaderName}</p> + </div> + </div> + + <div className="space-y-3"> + <div className="flex gap-3"> + <Button + type="button" + className="flex-1 bg-green-600 hover:bg-green-700" + onClick={() => { + // 승인 처리 로직 + console.log("승인 처리") + }} + > + <CheckCircle className="w-4 h-4 mr-2" /> + 승인 + </Button> + <Button + type="button" + variant="destructive" + className="flex-1" + onClick={() => { + // 반려 처리 로직 + console.log("반려 처리") + }} + > + <X className="w-4 h-4 mr-2" /> + 반려 + </Button> + </div> + + <FormField + control={form.control} + name="newRevision.comment" + render={({ field }) => ( + <FormItem> + <FormLabel>검토 의견</FormLabel> + <FormControl> + <Textarea + {...field} + placeholder="승인/반려 사유를 입력하세요" + rows={4} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + </ScrollArea> + </TabsContent> + </Tabs> + + <Separator /> + + <SheetFooter className="gap-2 pt-4"> + <SheetClose asChild> + <Button type="button" variant="outline"> + 취소 + </Button> + </SheetClose> + <Button + type="submit" + disabled={isUpdatePending} + className={mode === "approve" ? "bg-green-600 hover:bg-green-700" : ""} + > + {isUpdatePending && <Loader className="mr-2 size-4 animate-spin" />} + {mode === "upload" && <Upload className="mr-2 size-4" />} + {mode === "approve" && <CheckCircle className="mr-2 size-4" />} + {mode === "schedule" && <Calendar className="mr-2 size-4" />} + {mode === "edit" && <Save className="mr-2 size-4" />} + + {mode === "upload" ? "업로드" : + mode === "approve" ? "승인 처리" : + mode === "schedule" ? "일정 저장" : "저장"} + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/enhanced-documents-table copy.tsx b/lib/vendor-document-list/table/enhanced-documents-table copy.tsx new file mode 100644 index 00000000..2ac871db --- /dev/null +++ b/lib/vendor-document-list/table/enhanced-documents-table copy.tsx @@ -0,0 +1,604 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { StageRevisionExpandedContent } from "./stage-revision-expanded-content" +import { RevisionUploadDialog } from "./revision-upload-dialog" +import { SimplifiedDocumentEditDialog } from "./simplified-document-edit-dialog" +import { getEnhancedDocuments } from "../enhanced-document-service" +import type { EnhancedDocument } from "@/types/enhanced-documents" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { + AlertTriangle, + Clock, + TrendingUp, + Target, + Users, + Plus, + Upload, + CheckCircle, + Edit, + Eye, + Settings +} from "lucide-react" +import { getUpdatedEnhancedColumns } from "./enhanced-doc-table-columns" +import { ExpandableDataTable } from "@/components/data-table/expandable-data-table" +import { toast } from "sonner" + +interface FinalIntegratedDocumentsTableProps { + promises: Promise<[Awaited<ReturnType<typeof getEnhancedDocuments>>]> + selectedPackageId: number + projectType: "ship" | "plant" +} + +export function EnhancedDocumentsTable({ + promises, + selectedPackageId, + projectType, +}: FinalIntegratedDocumentsTableProps) { + // 데이터 로딩 + const [{ data, pageCount, total }] = React.use(promises) + + // 상태 관리 + const [rowAction, setRowAction] = React.useState<DataTableRowAction<EnhancedDocument> | null>(null) + const [expandedRows, setExpandedRows] = React.useState<Set<string>>(new Set()) + const [quickFilter, setQuickFilter] = React.useState<'all' | 'overdue' | 'due_soon' | 'in_progress' | 'high_priority'>('all') + + // ✅ 스테이지 확장 상태 관리 (문서별로 관리) + const [expandedStages, setExpandedStages] = React.useState<Record<string, Record<number, boolean>>>({}) + + // 다이얼로그 상태들 + const [uploadDialogOpen, setUploadDialogOpen] = React.useState(false) + const [editDialogOpen, setEditDialogOpen] = React.useState(false) + const [viewDialogOpen, setViewDialogOpen] = React.useState(false) + const [selectedDocument, setSelectedDocument] = React.useState<EnhancedDocument | null>(null) + const [selectedStage, setSelectedStage] = React.useState<string>("") + const [selectedRevision, setSelectedRevision] = React.useState<string>("") + const [selectedRevisions, setSelectedRevisions] = React.useState<any[]>([]) + const [uploadMode, setUploadMode] = React.useState<'new' | 'append'>('new') + + // 다음 리비전 계산 함수 + const getNextRevision = React.useCallback((currentRevision: string): string => { + if (!currentRevision) return "A" + + // 알파벳 리비전 (A, B, C...) + if (/^[A-Z]$/.test(currentRevision)) { + const charCode = currentRevision.charCodeAt(0) + if (charCode < 90) { // Z가 아닌 경우 + return String.fromCharCode(charCode + 1) + } + return "AA" // Z 다음은 AA + } + + // 숫자 리비전 (1, 2, 3...) + if (/^\d+$/.test(currentRevision)) { + return String(parseInt(currentRevision) + 1) + } + + // 기타 복잡한 리비전 형태는 그대로 반환 + return currentRevision + }, []) + + // 컬럼 정의 + const columns = React.useMemo( + () => getUpdatedEnhancedColumns({ + setRowAction: (action) => { + setRowAction(action) + if (action) { + setSelectedDocument(action.row.original) + + // 액션 타입에 따른 다이얼로그 열기 + switch (action.type) { + case "update": + setEditDialogOpen(true) + break + case "upload": + setSelectedStage(action.row.original.currentStageName || "") + setUploadDialogOpen(true) + break + case "view": + // 상세보기는 확장된 행으로 대체 + const rowId = action.row.id + const newExpanded = new Set(expandedRows) + if (newExpanded.has(rowId)) { + newExpanded.delete(rowId) + } else { + newExpanded.add(rowId) + } + setExpandedRows(newExpanded) + break + } + } + } + }), + [expandedRows] + ) + + // 통계 계산 + const stats = React.useMemo(() => { + const totalDocs = data.length + const overdue = data.filter(doc => doc.isOverdue).length + const dueSoon = data.filter(doc => + doc.daysUntilDue !== null && + doc.daysUntilDue >= 0 && + doc.daysUntilDue <= 3 + ).length + const inProgress = data.filter(doc => doc.currentStageStatus === 'IN_PROGRESS').length + const highPriority = data.filter(doc => doc.currentStagePriority === 'HIGH').length + const avgProgress = totalDocs > 0 + ? Math.round(data.reduce((sum, doc) => sum + (doc.progressPercentage || 0), 0) / totalDocs) + : 0 + + return { + total: totalDocs, + overdue, + dueSoon, + inProgress, + highPriority, + avgProgress + } + }, [data]) + + // 빠른 필터링 + const filteredData = React.useMemo(() => { + switch (quickFilter) { + case 'overdue': + return data.filter(doc => doc.isOverdue) + case 'due_soon': + return data.filter(doc => + doc.daysUntilDue !== null && + doc.daysUntilDue >= 0 && + doc.daysUntilDue <= 3 + ) + case 'in_progress': + return data.filter(doc => doc.currentStageStatus === 'IN_PROGRESS') + case 'high_priority': + return data.filter(doc => doc.currentStagePriority === 'HIGH') + default: + return data + } + }, [data, quickFilter]) + + // ✅ 핸들러 함수 수정: 모드 매개변수 추가 + const handleUploadRevision = React.useCallback((document: EnhancedDocument, stageName?: string, currentRevision?: string, mode: 'new' | 'append' = 'new') => { + setSelectedDocument(document) + setSelectedStage(stageName || document.currentStageName || "") + setUploadMode(mode) // ✅ 모드 설정 + + if (mode === 'new') { + // 새 리비전 생성: currentRevision이 있으면 다음 리비전을 자동 계산 + if (currentRevision) { + const nextRevision = getNextRevision(currentRevision) + setSelectedRevision(nextRevision) + } else { + // 스테이지의 최신 리비전을 찾아서 다음 리비전 계산 + const latestRevision = findLatestRevisionInStage(document, stageName || document.currentStageName || "") + if (latestRevision) { + setSelectedRevision(getNextRevision(latestRevision)) + } else { + setSelectedRevision("A") // 첫 번째 리비전 + } + } + } else { + // 기존 리비전에 파일 추가: 같은 리비전 번호 사용 + setSelectedRevision(currentRevision || "") + } + + setUploadDialogOpen(true) + }, [getNextRevision]) + + // ✅ 스테이지에서 최신 리비전을 찾는 헬퍼 함수 + const findLatestRevisionInStage = React.useCallback((document: EnhancedDocument, stageName: string) => { + const stage = document.allStages?.find(s => s.stageName === stageName) + if (!stage || !stage.revisions || stage.revisions.length === 0) { + return null + } + + // 리비전들을 정렬해서 최신 것 찾기 (간단한 알파벳/숫자 정렬) + const sortedRevisions = [...stage.revisions].sort((a, b) => { + // 알파벳과 숫자를 구분해서 정렬 + const aIsAlpha = /^[A-Z]+$/.test(a.revision) + const bIsAlpha = /^[A-Z]+$/.test(b.revision) + + if (aIsAlpha && bIsAlpha) { + return a.revision.localeCompare(b.revision) + } else if (!aIsAlpha && !bIsAlpha) { + return parseInt(a.revision) - parseInt(b.revision) + } else { + return aIsAlpha ? -1 : 1 // 알파벳이 숫자보다 먼저 + } + }) + + return sortedRevisions[sortedRevisions.length - 1]?.revision || null + }, []) + + const handleEditDocument = (document: EnhancedDocument) => { + setSelectedDocument(document) + setEditDialogOpen(true) + } + + const handleViewRevisions = (revisions: any[]) => { + setSelectedRevisions(revisions) + setViewDialogOpen(true) + } + + const handleNewDocument = () => { + setSelectedDocument(null) + setEditDialogOpen(true) + } + + // ✅ 스테이지 토글 핸들러 추가 + const handleStageToggle = React.useCallback((documentId: string, stageId: number) => { + setExpandedStages(prev => ({ + ...prev, + [documentId]: { + ...prev[documentId], + [stageId]: !prev[documentId]?.[stageId] + } + })) + }, []) + + const handleBulkAction = async (action: string, selectedRows: any[]) => { + try { + if (action === 'bulk_approve') { + // 일괄 승인 로직 + const stageIds = selectedRows + .map(row => row.original.currentStageId) + .filter(Boolean) + + if (stageIds.length > 0) { + // await bulkUpdateStageStatus(stageIds, 'APPROVED') + toast.success(`${stageIds.length}개 항목이 승인되었습니다.`) + } + } else if (action === 'bulk_upload') { + // 일괄 업로드 로직 + toast.info("일괄 업로드 기능은 준비 중입니다.") + } + } catch (error) { + toast.error("일괄 작업 중 오류가 발생했습니다.") + } + } + + // 다이얼로그 닫기 + const closeAllDialogs = () => { + setUploadDialogOpen(false) + setEditDialogOpen(false) + setViewDialogOpen(false) + setSelectedDocument(null) + setSelectedStage("") + setSelectedRevision("") + setSelectedRevisions([]) + setUploadMode('new') // ✅ 모드 초기화 + setRowAction(null) + } + + // 필터 필드 정의 + const filterFields: DataTableFilterField<EnhancedDocument>[] = [ + { + label: "문서번호", + value: "docNumber", + placeholder: "문서번호로 검색...", + }, + { + label: "제목", + value: "title", + placeholder: "제목으로 검색...", + }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<EnhancedDocument>[] = [ + { + id: "docNumber", + label: "문서번호", + type: "text", + }, + { + id: "title", + label: "문서제목", + type: "text", + }, + { + id: "currentStageStatus", + label: "스테이지 상태", + type: "select", + options: [ + { label: "계획됨", value: "PLANNED" }, + { label: "진행중", value: "IN_PROGRESS" }, + { label: "제출됨", value: "SUBMITTED" }, + { label: "승인됨", value: "APPROVED" }, + { label: "완료됨", value: "COMPLETED" }, + ], + }, + { + id: "currentStagePriority", + label: "우선순위", + type: "select", + options: [ + { label: "높음", value: "HIGH" }, + { label: "보통", value: "MEDIUM" }, + { label: "낮음", value: "LOW" }, + ], + }, + { + id: "isOverdue", + label: "지연 여부", + type: "select", + options: [ + { label: "지연됨", value: "true" }, + { label: "정상", value: "false" }, + ], + }, + { + id: "currentStageAssigneeName", + label: "담당자", + type: "text", + }, + { + id: "createdAt", + label: "생성일", + type: "date", + }, + ] + + // 데이터 테이블 훅 + const { table } = useDataTable({ + data: filteredData, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.documentId), + shallow: false, + clearOnDefault: true, + columnResizeMode: "onEnd", + }) + + return ( + <div className="space-y-6"> + {/* 통계 대시보드 */} + <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> + <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('all')}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">전체 문서</CardTitle> + <TrendingUp className="h-4 w-4 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{stats.total}</div> + <p className="text-xs text-muted-foreground"> + 총 {total}개 중 {stats.total}개 표시 + </p> + </CardContent> + </Card> + + <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('overdue')}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">지연 문서</CardTitle> + <AlertTriangle className="h-4 w-4 text-red-500" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-red-600">{stats.overdue}</div> + <p className="text-xs text-muted-foreground">즉시 확인 필요</p> + </CardContent> + </Card> + + <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('due_soon')}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">마감 임박</CardTitle> + <Clock className="h-4 w-4 text-orange-500" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-orange-600">{stats.dueSoon}</div> + <p className="text-xs text-muted-foreground">3일 이내 마감</p> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">평균 진행률</CardTitle> + <Target className="h-4 w-4 text-green-500" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-green-600">{stats.avgProgress}%</div> + <p className="text-xs text-muted-foreground">전체 프로젝트 진행도</p> + </CardContent> + </Card> + </div> + + {/* 빠른 필터 및 액션 버튼 */} + <div className="flex flex-col sm:flex-row gap-4 justify-between"> + {/* 빠른 필터 */} + <div className="flex gap-2 overflow-x-auto pb-2"> + <Badge + variant={quickFilter === 'all' ? 'default' : 'outline'} + className="cursor-pointer hover:bg-primary hover:text-primary-foreground whitespace-nowrap" + onClick={() => setQuickFilter('all')} + > + 전체 ({stats.total}) + </Badge> + <Badge + variant={quickFilter === 'overdue' ? 'destructive' : 'outline'} + className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap" + onClick={() => setQuickFilter('overdue')} + > + <AlertTriangle className="w-3 h-3 mr-1" /> + 지연 ({stats.overdue}) + </Badge> + <Badge + variant={quickFilter === 'due_soon' ? 'default' : 'outline'} + className="cursor-pointer hover:bg-orange-500 hover:text-white whitespace-nowrap" + onClick={() => setQuickFilter('due_soon')} + > + <Clock className="w-3 h-3 mr-1" /> + 마감임박 ({stats.dueSoon}) + </Badge> + <Badge + variant={quickFilter === 'in_progress' ? 'default' : 'outline'} + className="cursor-pointer hover:bg-blue-500 hover:text-white whitespace-nowrap" + onClick={() => setQuickFilter('in_progress')} + > + <Users className="w-3 h-3 mr-1" /> + 진행중 ({stats.inProgress}) + </Badge> + <Badge + variant={quickFilter === 'high_priority' ? 'destructive' : 'outline'} + className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap" + onClick={() => setQuickFilter('high_priority')} + > + <Target className="w-3 h-3 mr-1" /> + 높은우선순위 ({stats.highPriority}) + </Badge> + </div> + + {/* 메인 액션 버튼들 */} + <div className="flex gap-2 flex-shrink-0"> + {projectType === "plant" && ( + <Button onClick={handleNewDocument} className="flex items-center gap-2"> + <Plus className="w-4 h-4" /> + 새 문서 + </Button> + )} + + <Button variant="outline" onClick={() => { + const selectedRows = table.getFilteredSelectedRowModel().rows + if (selectedRows.length > 0) { + handleBulkAction('bulk_approve', selectedRows) + } else { + toast.info("승인할 항목을 선택해주세요.") + } + }}> + <CheckCircle className="w-4 h-4 mr-2" /> + 일괄 승인 + </Button> + + <Button variant="outline" onClick={() => { + const selectedRows = table.getFilteredSelectedRowModel().rows + if (selectedRows.length > 0) { + handleBulkAction('bulk_upload', selectedRows) + } else { + toast.info("업로드할 항목을 선택해주세요.") + } + }}> + <Upload className="w-4 h-4 mr-2" /> + 일괄 업로드 + </Button> + </div> + </div> + + {/* 메인 테이블 - 가로스크롤 문제 해결을 위한 구조 개선 */} + <div className="space-y-4"> + <div className="rounded-md border bg-white overflow-hidden"> + <ExpandableDataTable + table={table} + expandable={true} + expandedRows={expandedRows} + setExpandedRows={setExpandedRows} + renderExpandedContent={(document) => ( + <div className="w-full bg-gray-50 border-t"> + {/* 👇 새 래퍼: 뷰포트 폭을 상한으로, 내부에만 스크롤 */} + <div className="max-w-full overflow-x-auto"> + <StageRevisionExpandedContent + document={document} + onUploadRevision={handleUploadRevision} + onViewRevision={handleViewRevisions} + projectType={projectType} + expandedStages={expandedStages[String(document.documentId)] || {}} + onStageToggle={(stageId) => + handleStageToggle(String(document.documentId), stageId) + } + /> + </div> + </div> + )} + expandedRowClassName="!p-0" + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + /> + </ExpandableDataTable> + </div> + + {/* 선택된 항목 정보 */} + {/* {table.getFilteredSelectedRowModel().rows.length > 0 && ( + <div className="flex items-center justify-between p-4 bg-blue-50 border border-blue-200 rounded-lg"> + <span className="text-sm text-blue-700"> + {table.getFilteredSelectedRowModel().rows.length}개 항목이 선택되었습니다 + </span> + <div className="flex gap-2"> + <Button + size="sm" + variant="outline" + onClick={() => table.toggleAllRowsSelected(false)} + > + 선택 해제 + </Button> + <Button + size="sm" + onClick={() => { + const selectedRows = table.getFilteredSelectedRowModel().rows + handleBulkAction('bulk_approve', selectedRows) + }} + > + 선택 항목 승인 + </Button> + </div> + </div> + )} */} + </div> + + {/* 분리된 다이얼로그들 */} + + {/* ✅ 리비전 업로드 다이얼로그 - mode props 추가 */} + <RevisionUploadDialog + open={uploadDialogOpen} + onOpenChange={(open) => { + if (!open) closeAllDialogs() + else setUploadDialogOpen(open) + }} + document={selectedDocument} + projectType={projectType} + presetStage={selectedStage} + presetRevision={selectedRevision} + mode={uploadMode} + /> + + {/* 문서 편집 다이얼로그 */} + <SimplifiedDocumentEditDialog + open={editDialogOpen} + onOpenChange={(open) => { + if (!open) closeAllDialogs() + else setEditDialogOpen(open) + }} + document={selectedDocument} + projectType={projectType} + /> + + {/* PDF 뷰어 다이얼로그 (기존 ViewDocumentDialog 재사용) */} + {/* + <ViewDocumentDialog + open={viewDialogOpen} + onOpenChange={(open) => { + if (!open) closeAllDialogs() + else setViewDialogOpen(open) + }} + revisions={selectedRevisions} + /> + */} + </div> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/enhanced-documents-table.tsx b/lib/vendor-document-list/table/enhanced-documents-table.tsx new file mode 100644 index 00000000..3b623193 --- /dev/null +++ b/lib/vendor-document-list/table/enhanced-documents-table.tsx @@ -0,0 +1,570 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { StageRevisionExpandedContent } from "./stage-revision-expanded-content" +import { RevisionUploadDialog } from "./revision-upload-dialog" +import { SimplifiedDocumentEditDialog } from "./simplified-document-edit-dialog" +import { EnhancedDocTableToolbarActions } from "./enhanced-doc-table-toolbar-actions" +import { getEnhancedDocuments } from "../enhanced-document-service" +import type { EnhancedDocument } from "@/types/enhanced-documents" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + AlertTriangle, + Clock, + TrendingUp, + Target, + Users, +} from "lucide-react" +import { getUpdatedEnhancedColumns } from "./enhanced-doc-table-columns" +import { ExpandableDataTable } from "@/components/data-table/expandable-data-table" +import { toast } from "sonner" +// import { ViewDocumentDialog } from "@/components/documents/view-document-dialog" + +interface FinalIntegratedDocumentsTableProps { + promises: Promise<[Awaited<ReturnType<typeof getEnhancedDocuments>>]> + selectedPackageId: number + projectType: "ship" | "plant" +} + +export function EnhancedDocumentsTable({ + promises, + selectedPackageId, + projectType, +}: FinalIntegratedDocumentsTableProps) { + // 데이터 로딩 + const [{ data, pageCount, total }] = React.use(promises) + + // 상태 관리 + const [rowAction, setRowAction] = React.useState<DataTableRowAction<EnhancedDocument> | null>(null) + const [expandedRows, setExpandedRows] = React.useState<Set<string>>(new Set()) + const [quickFilter, setQuickFilter] = React.useState<'all' | 'overdue' | 'due_soon' | 'in_progress' | 'high_priority'>('all') + + // ✅ 스테이지 확장 상태 관리 (문서별로 관리) + const [expandedStages, setExpandedStages] = React.useState<Record<string, Record<number, boolean>>>({}) + + // 다이얼로그 상태들 + const [uploadDialogOpen, setUploadDialogOpen] = React.useState(false) + const [editDialogOpen, setEditDialogOpen] = React.useState(false) + // const [viewDialogOpen, setViewDialogOpen] = React.useState(false) + const [selectedDocument, setSelectedDocument] = React.useState<EnhancedDocument | null>(null) + const [selectedStage, setSelectedStage] = React.useState<string>("") + const [selectedRevision, setSelectedRevision] = React.useState<string>("") + // const [selectedRevisions, setSelectedRevisions] = React.useState<any[]>([]) + const [uploadMode, setUploadMode] = React.useState<'new' | 'append'>('new') + + // 다음 리비전 계산 함수 + const getNextRevision = React.useCallback((currentRevision: string): string => { + if (!currentRevision) return "A" + + // 알파벳 리비전 (A, B, C...) + if (/^[A-Z]$/.test(currentRevision)) { + const charCode = currentRevision.charCodeAt(0) + if (charCode < 90) { // Z가 아닌 경우 + return String.fromCharCode(charCode + 1) + } + return "AA" // Z 다음은 AA + } + + // 숫자 리비전 (1, 2, 3...) + if (/^\d+$/.test(currentRevision)) { + return String(parseInt(currentRevision) + 1) + } + + // 기타 복잡한 리비전 형태는 그대로 반환 + return currentRevision + }, []) + + // 컬럼 정의 + const columns = React.useMemo( + () => getUpdatedEnhancedColumns({ + setRowAction: (action) => { + setRowAction(action) + if (action) { + setSelectedDocument(action.row.original) + + // 액션 타입에 따른 다이얼로그 열기 + switch (action.type) { + case "update": + setEditDialogOpen(true) + break + case "upload": + setSelectedStage(action.row.original.currentStageName || "") + setUploadDialogOpen(true) + break + case "view": + // 상세보기는 확장된 행으로 대체 + const rowId = action.row.id + const newExpanded = new Set(expandedRows) + if (newExpanded.has(rowId)) { + newExpanded.delete(rowId) + } else { + newExpanded.add(rowId) + } + setExpandedRows(newExpanded) + break + } + } + }, + + projectType + + }), + [expandedRows, projectType] + ) + + // 통계 계산 + const stats = React.useMemo(() => { + const totalDocs = data.length + const overdue = data.filter(doc => doc.isOverdue).length + const dueSoon = data.filter(doc => + doc.daysUntilDue !== null && + doc.daysUntilDue >= 0 && + doc.daysUntilDue <= 3 + ).length + const inProgress = data.filter(doc => doc.currentStageStatus === 'IN_PROGRESS').length + const highPriority = data.filter(doc => doc.currentStagePriority === 'HIGH').length + const avgProgress = totalDocs > 0 + ? Math.round(data.reduce((sum, doc) => sum + (doc.progressPercentage || 0), 0) / totalDocs) + : 0 + + return { + total: totalDocs, + overdue, + dueSoon, + inProgress, + highPriority, + avgProgress + } + }, [data]) + + // 빠른 필터링 + const filteredData = React.useMemo(() => { + switch (quickFilter) { + case 'overdue': + return data.filter(doc => doc.isOverdue) + case 'due_soon': + return data.filter(doc => + doc.daysUntilDue !== null && + doc.daysUntilDue >= 0 && + doc.daysUntilDue <= 3 + ) + case 'in_progress': + return data.filter(doc => doc.currentStageStatus === 'IN_PROGRESS') + case 'high_priority': + return data.filter(doc => doc.currentStagePriority === 'HIGH') + default: + return data + } + }, [data, quickFilter]) + + // ✅ 핸들러 함수 수정: 모드 매개변수 추가 + const handleUploadRevision = React.useCallback((document: EnhancedDocument, stageName?: string, currentRevision?: string, mode: 'new' | 'append' = 'new') => { + setSelectedDocument(document) + setSelectedStage(stageName || document.currentStageName || "") + setUploadMode(mode) // ✅ 모드 설정 + + if (mode === 'new') { + // 새 리비전 생성: currentRevision이 있으면 다음 리비전을 자동 계산 + if (currentRevision) { + const nextRevision = getNextRevision(currentRevision) + setSelectedRevision(nextRevision) + } else { + // 스테이지의 최신 리비전을 찾아서 다음 리비전 계산 + const latestRevision = findLatestRevisionInStage(document, stageName || document.currentStageName || "") + if (latestRevision) { + setSelectedRevision(getNextRevision(latestRevision)) + } else { + setSelectedRevision("A") // 첫 번째 리비전 + } + } + } else { + // 기존 리비전에 파일 추가: 같은 리비전 번호 사용 + setSelectedRevision(currentRevision || "") + } + + setUploadDialogOpen(true) + }, [getNextRevision]) + + // ✅ 스테이지에서 최신 리비전을 찾는 헬퍼 함수 + const findLatestRevisionInStage = React.useCallback((document: EnhancedDocument, stageName: string) => { + const stage = document.allStages?.find(s => s.stageName === stageName) + if (!stage || !stage.revisions || stage.revisions.length === 0) { + return null + } + + // 리비전들을 정렬해서 최신 것 찾기 (간단한 알파벳/숫자 정렬) + const sortedRevisions = [...stage.revisions].sort((a, b) => { + // 알파벳과 숫자를 구분해서 정렬 + const aIsAlpha = /^[A-Z]+$/.test(a.revision) + const bIsAlpha = /^[A-Z]+$/.test(b.revision) + + if (aIsAlpha && bIsAlpha) { + return a.revision.localeCompare(b.revision) + } else if (!aIsAlpha && !bIsAlpha) { + return parseInt(a.revision) - parseInt(b.revision) + } else { + return aIsAlpha ? -1 : 1 // 알파벳이 숫자보다 먼저 + } + }) + + return sortedRevisions[sortedRevisions.length - 1]?.revision || null + }, []) + + // const handleEditDocument = (document: EnhancedDocument) => { + // setSelectedDocument(document) + // setEditDialogOpen(true) + // } + + // const handleViewRevisions = (revisions: any[]) => { + // setSelectedRevisions(revisions) + // setViewDialogOpen(true) + // } + + const handleNewDocument = () => { + setSelectedDocument(null) + setEditDialogOpen(true) + } + + // ✅ 스테이지 토글 핸들러 추가 + const handleStageToggle = React.useCallback((documentId: string, stageId: number) => { + setExpandedStages(prev => ({ + ...prev, + [documentId]: { + ...prev[documentId], + [stageId]: !prev[documentId]?.[stageId] + } + })) + }, []) + + const handleBulkAction = async (action: string, selectedRows: any[]) => { + try { + if (action === 'bulk_approve') { + // 일괄 승인 로직 + const stageIds = selectedRows + .map(row => row.original.currentStageId) + .filter(Boolean) + + if (stageIds.length > 0) { + // await bulkUpdateStageStatus(stageIds, 'APPROVED') + toast.success(`${stageIds.length}개 항목이 승인되었습니다.`) + } + } else if (action === 'bulk_upload') { + // 일괄 업로드 로직 + toast.info("일괄 업로드 기능은 준비 중입니다.") + } + } catch (error) { + toast.error("일괄 작업 중 오류가 발생했습니다.") + } + } + + // 다이얼로그 닫기 + const closeAllDialogs = () => { + setUploadDialogOpen(false) + setEditDialogOpen(false) + // setViewDialogOpen(false) + setSelectedDocument(null) + setSelectedStage("") + setSelectedRevision("") + // setSelectedRevisions([]) + setUploadMode('new') // ✅ 모드 초기화 + setRowAction(null) + } + + // 필터 필드 정의 + const filterFields: DataTableFilterField<EnhancedDocument>[] = [ + { + label: "문서번호", + value: "docNumber", + placeholder: "문서번호로 검색...", + }, + { + label: "제목", + value: "title", + placeholder: "제목으로 검색...", + }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<EnhancedDocument>[] = [ + { + id: "docNumber", + label: "문서번호", + type: "text", + }, + { + id: "title", + label: "문서제목", + type: "text", + }, + { + id: "currentStageStatus", + label: "스테이지 상태", + type: "select", + options: [ + { label: "계획됨", value: "PLANNED" }, + { label: "진행중", value: "IN_PROGRESS" }, + { label: "제출됨", value: "SUBMITTED" }, + { label: "승인됨", value: "APPROVED" }, + { label: "완료됨", value: "COMPLETED" }, + ], + }, + { + id: "currentStagePriority", + label: "우선순위", + type: "select", + options: [ + { label: "높음", value: "HIGH" }, + { label: "보통", value: "MEDIUM" }, + { label: "낮음", value: "LOW" }, + ], + }, + { + id: "isOverdue", + label: "지연 여부", + type: "select", + options: [ + { label: "지연됨", value: "true" }, + { label: "정상", value: "false" }, + ], + }, + { + id: "currentStageAssigneeName", + label: "담당자", + type: "text", + }, + { + id: "createdAt", + label: "생성일", + type: "date", + }, + ] + + // 데이터 테이블 훅 + const { table } = useDataTable({ + data: filteredData, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.documentId), + shallow: false, + clearOnDefault: true, + columnResizeMode: "onEnd", + }) + + return ( + <div className="space-y-6"> + {/* 통계 대시보드 */} + <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> + <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('all')}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">전체 문서</CardTitle> + <TrendingUp className="h-4 w-4 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{stats.total}</div> + <p className="text-xs text-muted-foreground"> + 총 {total}개 중 {stats.total}개 표시 + </p> + </CardContent> + </Card> + + <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('overdue')}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">지연 문서</CardTitle> + <AlertTriangle className="h-4 w-4 text-red-500" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-red-600">{stats.overdue}</div> + <p className="text-xs text-muted-foreground">즉시 확인 필요</p> + </CardContent> + </Card> + + <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('due_soon')}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">마감 임박</CardTitle> + <Clock className="h-4 w-4 text-orange-500" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-orange-600">{stats.dueSoon}</div> + <p className="text-xs text-muted-foreground">3일 이내 마감</p> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">평균 진행률</CardTitle> + <Target className="h-4 w-4 text-green-500" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-green-600">{stats.avgProgress}%</div> + <p className="text-xs text-muted-foreground">전체 프로젝트 진행도</p> + </CardContent> + </Card> + </div> + + {/* 빠른 필터 */} + <div className="flex gap-2 overflow-x-auto pb-2"> + <Badge + variant={quickFilter === 'all' ? 'default' : 'outline'} + className="cursor-pointer hover:bg-primary hover:text-primary-foreground whitespace-nowrap" + onClick={() => setQuickFilter('all')} + > + 전체 ({stats.total}) + </Badge> + <Badge + variant={quickFilter === 'overdue' ? 'destructive' : 'outline'} + className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap" + onClick={() => setQuickFilter('overdue')} + > + <AlertTriangle className="w-3 h-3 mr-1" /> + 지연 ({stats.overdue}) + </Badge> + <Badge + variant={quickFilter === 'due_soon' ? 'default' : 'outline'} + className="cursor-pointer hover:bg-orange-500 hover:text-white whitespace-nowrap" + onClick={() => setQuickFilter('due_soon')} + > + <Clock className="w-3 h-3 mr-1" /> + 마감임박 ({stats.dueSoon}) + </Badge> + <Badge + variant={quickFilter === 'in_progress' ? 'default' : 'outline'} + className="cursor-pointer hover:bg-blue-500 hover:text-white whitespace-nowrap" + onClick={() => setQuickFilter('in_progress')} + > + <Users className="w-3 h-3 mr-1" /> + 진행중 ({stats.inProgress}) + </Badge> + <Badge + variant={quickFilter === 'high_priority' ? 'destructive' : 'outline'} + className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap" + onClick={() => setQuickFilter('high_priority')} + > + <Target className="w-3 h-3 mr-1" /> + 높은우선순위 ({stats.highPriority}) + </Badge> + </div> + + {/* 메인 테이블 - 가로스크롤 문제 해결을 위한 구조 개선 */} + <div className="space-y-4"> + <div className="rounded-md border bg-white overflow-hidden"> + <ExpandableDataTable + table={table} + expandable={true} + expandedRows={expandedRows} + setExpandedRows={setExpandedRows} + renderExpandedContent={(document) => ( + // ✅ 확장된 내용을 별도 컨테이너로 분리하여 가로스크롤 영향 차단 + <div className=""> + <StageRevisionExpandedContent + document={document} + onUploadRevision={handleUploadRevision} + // onViewRevision={handleViewRevisions} + projectType={projectType} + expandedStages={expandedStages[String(document.documentId)] || {}} + onStageToggle={(stageId) => handleStageToggle(String(document.documentId), stageId)} + /> + </div> + )} + // 확장된 행에 대한 특별한 스타일링 + expandedRowClassName="!p-0" + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <EnhancedDocTableToolbarActions + table={table} + projectType={projectType} + selectedPackageId={selectedPackageId} + onNewDocument={handleNewDocument} + onBulkAction={handleBulkAction} + /> + </DataTableAdvancedToolbar> + </ExpandableDataTable> + </div> + + {/* 선택된 항목 정보 */} + {/* {table.getFilteredSelectedRowModel().rows.length > 0 && ( + <div className="flex items-center justify-between p-4 bg-blue-50 border border-blue-200 rounded-lg"> + <span className="text-sm text-blue-700"> + {table.getFilteredSelectedRowModel().rows.length}개 항목이 선택되었습니다 + </span> + <div className="flex gap-2"> + <Button + size="sm" + variant="outline" + onClick={() => table.toggleAllRowsSelected(false)} + > + 선택 해제 + </Button> + <Button + size="sm" + onClick={() => { + const selectedRows = table.getFilteredSelectedRowModel().rows + handleBulkAction('bulk_approve', selectedRows) + }} + > + 선택 항목 승인 + </Button> + </div> + </div> + )} */} + </div> + + {/* 분리된 다이얼로그들 */} + + {/* ✅ 리비전 업로드 다이얼로그 - mode props 추가 */} + <RevisionUploadDialog + open={uploadDialogOpen} + onOpenChange={(open) => { + if (!open) closeAllDialogs() + else setUploadDialogOpen(open) + }} + document={selectedDocument} + projectType={projectType} + presetStage={selectedStage} + presetRevision={selectedRevision} + mode={uploadMode} + /> + + {/* 문서 편집 다이얼로그 */} + <SimplifiedDocumentEditDialog + open={editDialogOpen} + onOpenChange={(open) => { + if (!open) closeAllDialogs() + else setEditDialogOpen(open) + }} + document={selectedDocument} + projectType={projectType} + /> + + {/* PDF 뷰어 다이얼로그 (기존 ViewDocumentDialog 재사용) */} + + {/* <ViewDocumentDialog + open={viewDialogOpen} + onOpenChange={(open) => { + if (!open) closeAllDialogs() + else setViewDialogOpen(open) + }} + revisions={selectedRevisions} + /> + */} + </div> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/revision-upload-dialog.tsx b/lib/vendor-document-list/table/revision-upload-dialog.tsx new file mode 100644 index 00000000..ac58b974 --- /dev/null +++ b/lib/vendor-document-list/table/revision-upload-dialog.tsx @@ -0,0 +1,486 @@ +"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 { + 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +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 { Upload, X, Loader2 } from "lucide-react" +import prettyBytes from "pretty-bytes" +import { EnhancedDocumentsView } from "@/db/schema/vendorDocu" + +// 리비전 업로드 스키마 +const revisionUploadSchema = z.object({ + stage: z.string().min(1, "스테이지는 필수입니다"), + revision: z.string().min(1, "리비전은 필수입니다"), + uploaderName: z.string().optional(), + comment: z.string().optional(), + attachments: z.array(z.instanceof(File)).min(1, "최소 1개 파일이 필요합니다"), +}) + +type RevisionUploadSchema = z.infer<typeof revisionUploadSchema> + +interface RevisionUploadDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + document: EnhancedDocumentsView | null + projectType: "ship" | "plant" + presetStage?: string + presetRevision?: string + mode?: 'new' | 'append' +} + +export function RevisionUploadDialog({ + open, + onOpenChange, + document, + projectType, + presetStage, + presetRevision, + mode = 'new', +}: RevisionUploadDialogProps) { + const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]) + const [isUploading, setIsUploading] = React.useState(false) + const [uploadProgress, setUploadProgress] = React.useState(0) + const router = useRouter() + + // ✅ next-auth session 가져오기 + const { data: session } = useSession() + + // 사용 가능한 스테이지 옵션 + const stageOptions = React.useMemo(() => { + if (document?.allStages) { + return document.allStages.map(stage => stage.stageName) + } + return ["Issued for Review", "AFC", "Final Issue"] + }, [document]) + + const form = useForm<RevisionUploadSchema>({ + resolver: zodResolver(revisionUploadSchema), + defaultValues: { + stage: presetStage || document?.currentStageName || "", + revision: presetRevision || "", + uploaderName: session?.user?.name || "", // ✅ session.user.name 사용 + comment: "", + attachments: [], + }, + }) + + // ✅ session이 로드되면 uploaderName 업데이트 + React.useEffect(() => { + if (session?.user?.name) { + form.setValue('uploaderName', session.user.name) + } + }, [session?.user?.name, form]) + + // ✅ presetStage와 presetRevision이 변경될 때 폼 값 업데이트 + React.useEffect(() => { + if (presetStage) { + form.setValue('stage', presetStage) + } + if (presetRevision) { + form.setValue('revision', presetRevision) + } + }, [presetStage, presetRevision, form]) + + // 파일 드롭 처리 + const handleDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...selectedFiles, ...acceptedFiles] + setSelectedFiles(newFiles) + form.setValue('attachments', newFiles, { shouldValidate: true }) + } + + const removeFile = (index: number) => { + const updatedFiles = [...selectedFiles] + updatedFiles.splice(index, 1) + setSelectedFiles(updatedFiles) + form.setValue('attachments', updatedFiles, { shouldValidate: true }) + } + + // 업로드 처리 + async function onSubmit(data: RevisionUploadSchema) { + if (!document) return + + setIsUploading(true) + setUploadProgress(0) + + try { + const formData = new FormData() + formData.append("documentId", String(document.documentId)) + formData.append("stage", data.stage) + formData.append("revision", data.revision) + formData.append("mode", mode) // 'new' 또는 'append' + + if (data.uploaderName) { + formData.append("uploaderName", data.uploaderName) + } + + if (data.comment) { + formData.append("comment", data.comment) + } + + // 파일들 추가 + data.attachments.forEach((file) => { + formData.append("attachments", file) + }) + + // 진행률 업데이트 시뮬레이션 + const updateProgress = (progress: number) => { + setUploadProgress(Math.min(progress, 95)) // 95%까지만 진행률 표시 + } + + // 파일 크기에 따른 진행률 시뮬레이션 + const totalSize = data.attachments.reduce((sum, file) => sum + file.size, 0) + let uploadedSize = 0 + + const progressInterval = setInterval(() => { + uploadedSize += totalSize * 0.1 // 10%씩 증가 시뮬레이션 + const progress = Math.min((uploadedSize / totalSize) * 100, 90) + updateProgress(progress) + }, 300) + + // ✅ 실제 API 호출 + const response = await fetch('/api/revision-upload', { + method: 'POST', + body: formData, + }) + + clearInterval(progressInterval) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || errorData.details || '업로드에 실패했습니다.') + } + + const result = await response.json() + setUploadProgress(100) + + toast.success( + result.message || + `리비전 ${data.revision}이 성공적으로 업로드되었습니다. (${result.data?.uploadedFiles?.length || 0}개 파일)` + ) + + console.log('✅ 업로드 성공:', result) + + // 잠시 대기 후 다이얼로그 닫기 + setTimeout(() => { + handleDialogClose() + router.refresh() + }, 1000) + + } catch (error) { + console.error('❌ 업로드 오류:', error) + toast.error(error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다") + } finally { + setIsUploading(false) + setTimeout(() => setUploadProgress(0), 2000) // 2초 후 진행률 리셋 + } + } + + const handleDialogClose = () => { + form.reset({ + stage: presetStage || document?.currentStageName || "", + revision: presetRevision || "", + uploaderName: session?.user?.name || "", // ✅ 다이얼로그 닫을 때도 session 값으로 리셋 + comment: "", + attachments: [], + }) + setSelectedFiles([]) + setIsUploading(false) + setUploadProgress(0) + onOpenChange(false) + } + + return ( + <Dialog open={open} onOpenChange={handleDialogClose}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Upload className="w-5 h-5" /> + {mode === 'new' ? '새 리비전 업로드' : '파일 추가'} + </DialogTitle> + <DialogDescription> + {document ? `${document.docNumber} - ${document.title}` : + mode === 'new' ? "문서에 새 리비전을 업로드합니다." : "기존 리비전에 파일을 추가합니다."} + </DialogDescription> + + <div className="flex items-center gap-2 pt-2"> + <Badge variant={projectType === "ship" ? "default" : "secondary"}> + {projectType === "ship" ? "조선 프로젝트" : "플랜트 프로젝트"} + </Badge> + {/* ✅ 현재 사용자 정보 표시 */} + {session?.user?.name && ( + <Badge variant="outline" className="text-xs"> + 업로더: {session.user.name} + </Badge> + )} + {/* ✅ 모드에 따른 정보 표시 */} + {mode === 'append' && presetRevision && ( + <Badge variant="outline" className="text-xs"> + 리비전 {presetRevision}에 파일 추가 + </Badge> + )} + {mode === 'new' && presetRevision && ( + <Badge variant="outline" className="text-xs"> + 다음 리비전: {presetRevision} + </Badge> + )} + </div> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="stage" + render={({ field }) => ( + <FormItem> + <FormLabel>스테이지</FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="스테이지 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {stageOptions.map((stage) => ( + <SelectItem key={stage} value={stage}> + {stage} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="revision" + render={({ field }) => ( + <FormItem> + <FormLabel>리비전</FormLabel> + <FormControl> + <Input + {...field} + placeholder="예: A, B, 1, 2..." + readOnly={mode === 'append'} + className={mode === 'append' ? 'bg-gray-50' : ''} + /> + </FormControl> + <FormMessage /> + {/* ✅ 모드에 따른 도움말 표시 */} + {mode === 'new' && presetRevision && ( + <p className="text-xs text-gray-500"> + 자동으로 계산된 다음 리비전입니다. + </p> + )} + {mode === 'append' && ( + <p className="text-xs text-gray-500"> + 기존 리비전에 파일을 추가합니다. + </p> + )} + </FormItem> + )} + /> + </div> + + <FormField + control={form.control} + name="uploaderName" + render={({ field }) => ( + <FormItem> + <FormLabel>업로더명</FormLabel> + <FormControl> + <Input + {...field} + placeholder="업로더 이름을 입력하세요" + className="bg-gray-50" // ✅ session 값이므로 읽기 전용 느낌으로 스타일링 + /> + </FormControl> + <FormMessage /> + <p className="text-xs text-gray-500"> + 로그인된 사용자 정보가 자동으로 입력됩니다. + </p> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="comment" + render={({ field }) => ( + <FormItem> + <FormLabel>코멘트 (선택)</FormLabel> + <FormControl> + <Textarea {...field} placeholder="코멘트를 입력하세요" rows={3} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 파일 업로드 영역 */} + <FormField + control={form.control} + name="attachments" + render={() => ( + <FormItem> + <FormLabel>파일 첨부</FormLabel> + <Dropzone + maxSize={3e9} // 3GB + multiple={true} + onDropAccepted={handleDropAccepted} + disabled={isUploading} + > + <DropzoneZone className="flex justify-center"> + <FormControl> + <DropzoneInput /> + </FormControl> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle> + <DropzoneDescription> + 또는 클릭하여 파일을 선택하세요 + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + </Dropzone> + <FormMessage /> + </FormItem> + )} + /> + + {/* 선택된 파일 목록 */} + {selectedFiles.length > 0 && ( + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <h6 className="text-sm font-semibold"> + 선택된 파일 ({selectedFiles.length}) + </h6> + </div> + <ScrollArea className="max-h-[200px]"> + <FileList> + {selectedFiles.map((file, index) => ( + <FileListItem key={index} className="p-3"> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListSize>{prettyBytes(file.size)}</FileListSize> + </FileListInfo> + <FileListAction + onClick={() => removeFile(index)} + disabled={isUploading} + > + <X className="h-4 w-4" /> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </FileList> + </ScrollArea> + </div> + )} + + {/* 업로드 진행 상태 */} + {isUploading && ( + <div className="space-y-2"> + <div className="flex items-center gap-2"> + <Loader2 className="h-4 w-4 animate-spin" /> + <span className="text-sm">{uploadProgress}% 업로드 중...</span> + </div> + <div className="h-2 w-full bg-muted rounded-full overflow-hidden"> + <div + className="h-full bg-primary rounded-full transition-all" + style={{ width: `${uploadProgress}%` }} + /> + </div> + </div> + )} + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={handleDialogClose} + disabled={isUploading} + > + 취소 + </Button> + <Button + type="submit" + disabled={isUploading || selectedFiles.length === 0} + > + {isUploading ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 업로드 중... + </> + ) : ( + <> + <Upload className="mr-2 h-4 w-4" /> + 업로드 + </> + )} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/send-to-shi-button.tsx b/lib/vendor-document-list/table/send-to-shi-button.tsx new file mode 100644 index 00000000..e0360144 --- /dev/null +++ b/lib/vendor-document-list/table/send-to-shi-button.tsx @@ -0,0 +1,342 @@ +// components/sync/send-to-shi-button.tsx (최종 버전) +"use client" + +import * as React from "react" +import { Send, Loader2, CheckCircle, AlertTriangle, Settings } 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" +import { useSyncStatus, useTriggerSync } from "@/hooks/use-sync-status" +import type { EnhancedDocument } from "@/types/enhanced-documents" + +interface SendToSHIButtonProps { + contractId: number + documents?: EnhancedDocument[] + onSyncComplete?: () => void +} + +export function SendToSHIButton({ + contractId, + documents = [], + onSyncComplete +}: SendToSHIButtonProps) { + const [isDialogOpen, setIsDialogOpen] = React.useState(false) + const [syncProgress, setSyncProgress] = React.useState(0) + + const { + syncStatus, + isLoading: statusLoading, + error: statusError, + refetch: refetchStatus + } = useSyncStatus(contractId, 'SHI') + + const { + triggerSync, + isLoading: isSyncing, + error: syncError + } = useTriggerSync() + + // 에러 상태 표시 + React.useEffect(() => { + if (statusError) { + console.warn('Failed to load sync status:', statusError) + } + }, [statusError]) + + const handleSync = async () => { + if (!contractId) return + + setSyncProgress(0) + + try { + // 진행률 시뮬레이션 + const progressInterval = setInterval(() => { + setSyncProgress(prev => Math.min(prev + 10, 90)) + }, 200) + + const result = await triggerSync({ + contractId, + targetSystem: 'SHI' + }) + + clearInterval(progressInterval) + setSyncProgress(100) + + setTimeout(() => { + setSyncProgress(0) + setIsDialogOpen(false) + + if (result?.success) { + toast.success( + `동기화 완료: ${result.successCount || 0}건 성공`, + { + description: result.successCount > 0 + ? `${result.successCount}개 항목이 SHI 시스템으로 전송되었습니다.` + : '전송할 새로운 변경사항이 없습니다.' + } + ) + } else { + toast.error( + `동기화 부분 실패: ${result?.successCount || 0}건 성공, ${result?.failureCount || 0}건 실패`, + { + description: result?.errors?.[0] || '일부 항목 전송에 실패했습니다.' + } + ) + } + + refetchStatus() // SWR 캐시 갱신 + onSyncComplete?.() + }, 500) + + } catch (error) { + setSyncProgress(0) + + toast.error('동기화 실패', { + description: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.' + }) + } + } + + const getSyncStatusBadge = () => { + if (statusLoading) { + return <Badge variant="secondary">확인 중...</Badge> + } + + if (statusError) { + return <Badge variant="destructive">오류</Badge> + } + + if (!syncStatus) { + return <Badge variant="secondary">데이터 없음</Badge> + } + + if (syncStatus.pendingChanges > 0) { + return ( + <Badge variant="destructive" className="gap-1"> + <AlertTriangle className="w-3 h-3" /> + {syncStatus.pendingChanges}건 대기 + </Badge> + ) + } + + if (syncStatus.syncedChanges > 0) { + return ( + <Badge variant="default" className="gap-1 bg-green-500 hover:bg-green-600"> + <CheckCircle className="w-3 h-3" /> + 동기화됨 + </Badge> + ) + } + + return <Badge variant="secondary">변경사항 없음</Badge> + } + + const canSync = !statusError && syncStatus?.syncEnabled && syncStatus?.pendingChanges > 0 + + return ( + <> + <Popover> + <PopoverTrigger asChild> + <Button + variant="default" + size="sm" + className="gap-2 relative bg-blue-600 hover:bg-blue-700" + disabled={isSyncing || statusLoading} + > + {isSyncing ? ( + <Loader2 className="w-4 h-4 animate-spin" /> + ) : ( + <Send className="w-4 h-4" /> + )} + <span className="hidden sm:inline">Send to SHI</span> + {syncStatus?.pendingChanges > 0 && ( + <Badge + variant="destructive" + className="absolute -top-2 -right-2 h-5 w-5 p-0 text-xs flex items-center justify-center" + > + {syncStatus.pendingChanges} + </Badge> + )} + </Button> + </PopoverTrigger> + + <PopoverContent className="w-80"> + <div className="space-y-4"> + <div className="space-y-2"> + <h4 className="font-medium">SHI 동기화 상태</h4> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">현재 상태</span> + {getSyncStatusBadge()} + </div> + </div> + + {syncStatus && !statusError && ( + <div className="space-y-3"> + <Separator /> + + <div className="grid grid-cols-2 gap-4 text-sm"> + <div> + <div className="text-muted-foreground">대기 중</div> + <div className="font-medium">{syncStatus.pendingChanges || 0}건</div> + </div> + <div> + <div className="text-muted-foreground">동기화됨</div> + <div className="font-medium">{syncStatus.syncedChanges || 0}건</div> + </div> + </div> + + {syncStatus.failedChanges > 0 && ( + <div className="text-sm"> + <div className="text-muted-foreground">실패</div> + <div className="font-medium text-red-600">{syncStatus.failedChanges}건</div> + </div> + )} + + {syncStatus.lastSyncAt && ( + <div className="text-sm"> + <div className="text-muted-foreground">마지막 동기화</div> + <div className="font-medium"> + {new Date(syncStatus.lastSyncAt).toLocaleString()} + </div> + </div> + )} + </div> + )} + + {statusError && ( + <div className="space-y-2"> + <Separator /> + <div className="text-sm text-red-600"> + <div className="font-medium">연결 오류</div> + <div className="text-xs">동기화 상태를 확인할 수 없습니다.</div> + </div> + </div> + )} + + <Separator /> + + <div className="flex gap-2"> + <Button + onClick={() => setIsDialogOpen(true)} + disabled={!canSync || isSyncing} + className="flex-1" + size="sm" + > + {isSyncing ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + 동기화 중... + </> + ) : ( + <> + <Send className="w-4 h-4 mr-2" /> + 지금 동기화 + </> + )} + </Button> + + <Button + variant="outline" + size="sm" + onClick={() => refetchStatus()} + disabled={statusLoading} + > + {statusLoading ? ( + <Loader2 className="w-4 h-4 animate-spin" /> + ) : ( + <Settings className="w-4 h-4" /> + )} + </Button> + </div> + </div> + </PopoverContent> + </Popover> + + {/* 동기화 진행 다이얼로그 */} + <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle>SHI 시스템으로 동기화</DialogTitle> + <DialogDescription> + 변경된 문서 데이터를 SHI 시스템으로 전송합니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {syncStatus && !statusError && ( + <div className="rounded-lg border p-4 space-y-3"> + <div className="flex items-center justify-between text-sm"> + <span>전송 대상</span> + <span className="font-medium">{syncStatus.pendingChanges || 0}건</span> + </div> + + <div className="text-xs text-muted-foreground"> + 문서, 리비전, 첨부파일의 변경사항이 포함됩니다. + </div> + + {isSyncing && ( + <div className="space-y-2"> + <div className="flex items-center justify-between text-sm"> + <span>진행률</span> + <span>{syncProgress}%</span> + </div> + <Progress value={syncProgress} className="h-2" /> + </div> + )} + </div> + )} + + {statusError && ( + <div className="rounded-lg border border-red-200 p-4"> + <div className="text-sm text-red-600"> + 동기화 상태를 확인할 수 없습니다. 네트워크 연결을 확인해주세요. + </div> + </div> + )} + + <div className="flex justify-end gap-2"> + <Button + variant="outline" + onClick={() => setIsDialogOpen(false)} + disabled={isSyncing} + > + 취소 + </Button> + <Button + onClick={handleSync} + disabled={isSyncing || !canSync} + > + {isSyncing ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + 동기화 중... + </> + ) : ( + <> + <Send className="w-4 h-4 mr-2" /> + 동기화 시작 + </> + )} + </Button> + </div> + </div> + </DialogContent> + </Dialog> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/simplified-document-edit-dialog.tsx b/lib/vendor-document-list/table/simplified-document-edit-dialog.tsx new file mode 100644 index 00000000..933df263 --- /dev/null +++ b/lib/vendor-document-list/table/simplified-document-edit-dialog.tsx @@ -0,0 +1,287 @@ +"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 { + 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Calendar as CalendarComponent } from "@/components/ui/calendar" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { Calendar, Edit, Loader2 } from "lucide-react" +import { format } from "date-fns" +import { ko } from "date-fns/locale" +import { cn } from "@/lib/utils" +import { EnhancedDocumentsView } from "@/db/schema/vendorDocu" + +// 단순화된 문서 편집 스키마 +const documentEditSchema = z.object({ + docNumber: z.string().min(1, "문서번호는 필수입니다"), + title: z.string().min(1, "제목은 필수입니다"), + pic: z.string().optional(), + status: z.string().min(1, "상태는 필수입니다"), + issuedDate: z.date().optional(), + description: z.string().optional(), +}) + +type DocumentEditSchema = z.infer<typeof documentEditSchema> + +const statusOptions = [ + { value: "ACTIVE", label: "활성" }, + { value: "INACTIVE", label: "비활성" }, + { value: "COMPLETED", label: "완료" }, + { value: "CANCELLED", label: "취소" }, +] + +interface SimplifiedDocumentEditDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + document: EnhancedDocumentsView | null + projectType: "ship" | "plant" +} + +export function SimplifiedDocumentEditDialog({ + open, + onOpenChange, + document, + projectType, +}: SimplifiedDocumentEditDialogProps) { + const [isUpdating, setIsUpdating] = React.useState(false) + + const form = useForm<DocumentEditSchema>({ + resolver: zodResolver(documentEditSchema), + defaultValues: { + docNumber: "", + title: "", + pic: "", + status: "ACTIVE", + issuedDate: undefined, + description: "", + }, + }) + + // 폼 초기화 + React.useEffect(() => { + if (document) { + form.reset({ + docNumber: document.docNumber, + title: document.title, + pic: document.pic || "", + status: document.status, + issuedDate: document.issuedDate ? new Date(document.issuedDate) : undefined, + description: "", + }) + } + }, [document, form]) + + async function onSubmit(data: DocumentEditSchema) { + if (!document) return + + setIsUpdating(true) + try { + // 실제 업데이트 API 호출 (구현 필요) + // await updateDocumentInfo({ documentId: document.documentId, ...data }) + + toast.success("문서 정보가 업데이트되었습니다") + onOpenChange(false) + } catch (error) { + toast.error("업데이트 중 오류가 발생했습니다") + console.error(error) + } finally { + setIsUpdating(false) + } + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Edit className="w-5 h-5" /> + 문서 정보 수정 + </DialogTitle> + <DialogDescription> + {document ? `${document.docNumber}의 기본 정보를 수정합니다.` : "문서 기본 정보를 수정합니다."} + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="docNumber" + render={({ field }) => ( + <FormItem> + <FormLabel>문서번호</FormLabel> + <FormControl> + <Input {...field} disabled={projectType === "ship"} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="title" + render={({ field }) => ( + <FormItem> + <FormLabel>제목</FormLabel> + <FormControl> + <Input {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="pic" + render={({ field }) => ( + <FormItem> + <FormLabel>담당자 (PIC)</FormLabel> + <FormControl> + <Input {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>상태</FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + </FormControl> + <SelectContent> + {statusOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <FormField + control={form.control} + name="issuedDate" + render={({ field }) => ( + <FormItem> + <FormLabel>발행일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + format(field.value, "yyyy년 MM월 dd일", { locale: ko }) + ) : ( + <span>날짜를 선택하세요</span> + )} + <Calendar className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <CalendarComponent + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => date > new Date()} + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명 (선택)</FormLabel> + <FormControl> + <Textarea {...field} placeholder="문서에 대한 설명을 입력하세요" rows={3} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isUpdating} + > + 취소 + </Button> + <Button type="submit" disabled={isUpdating}> + {isUpdating ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 저장 중... + </> + ) : ( + <> + <Edit className="mr-2 h-4 w-4" /> + 저장 + </> + )} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/stage-revision-expanded-content.tsx b/lib/vendor-document-list/table/stage-revision-expanded-content.tsx new file mode 100644 index 00000000..c2395aa8 --- /dev/null +++ b/lib/vendor-document-list/table/stage-revision-expanded-content.tsx @@ -0,0 +1,719 @@ +"use client" + +import * as React from "react" +import { WebViewerInstance } from "@pdftron/webviewer" +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + FileText, + User, + Calendar, + Clock, + CheckCircle, + AlertTriangle, + ChevronDown, + ChevronRight, + Upload, + Eye, + Download, + FileIcon, + MoreHorizontal, + Loader2 +} from "lucide-react" +import { cn } from "@/lib/utils" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import type { EnhancedDocument } from "@/types/enhanced-documents" + +// 유틸리티 함수들 +const getStatusColor = (status: string) => { + switch (status) { + case 'COMPLETED': case 'APPROVED': return 'bg-green-100 text-green-800' + case 'IN_PROGRESS': return 'bg-blue-100 text-blue-800' + case 'SUBMITTED': case 'UNDER_REVIEW': return 'bg-purple-100 text-purple-800' + case 'REJECTED': return 'bg-red-100 text-red-800' + default: return 'bg-gray-100 text-gray-800' + } +} + +const getPriorityColor = (priority: string) => { + switch (priority) { + case 'HIGH': return 'bg-red-100 text-red-800 border-red-200' + case 'MEDIUM': return 'bg-yellow-100 text-yellow-800 border-yellow-200' + case 'LOW': return 'bg-green-100 text-green-800 border-green-200' + default: return 'bg-gray-100 text-gray-800 border-gray-200' + } +} + +const getStatusText = (status: string) => { + switch (status) { + case 'PLANNED': return '계획됨' + case 'IN_PROGRESS': return '진행중' + case 'SUBMITTED': return '제출됨' + case 'UNDER_REVIEW': return '검토중' + case 'APPROVED': return '승인됨' + case 'REJECTED': return '반려됨' + case 'COMPLETED': return '완료됨' + default: return status + } +} + +const getPriorityText = (priority: string) => { + switch (priority) { + case 'HIGH': return '높음' + case 'MEDIUM': return '보통' + case 'LOW': return '낮음' + default: return priority + } +} + +const getFileIconColor = (fileName: string) => { + const ext = fileName.split('.').pop()?.toLowerCase() + switch(ext) { + case 'pdf': return 'text-red-500' + case 'doc': case 'docx': return 'text-blue-500' + case 'xls': case 'xlsx': return 'text-green-500' + case 'dwg': return 'text-amber-500' + default: return 'text-gray-500' + } +} + +interface StageRevisionExpandedContentProps { + document: EnhancedDocument + onUploadRevision: (documentData: EnhancedDocument, stageName?: string, currentRevision?: string, mode?: 'new' | 'append') => void + onStageStatusUpdate?: (stageId: number, status: string) => void + onRevisionStatusUpdate?: (revisionId: number, status: string) => void + projectType: "ship" | "plant" + expandedStages?: Record<number, boolean> + onStageToggle?: (stageId: number) => void +} + +export const StageRevisionExpandedContent = ({ + document: documentData, + onUploadRevision, + onStageStatusUpdate, + onRevisionStatusUpdate, + projectType, + expandedStages = {}, + onStageToggle, +}: StageRevisionExpandedContentProps) => { + // 로컬 상태 관리 + const [localExpandedStages, setLocalExpandedStages] = React.useState<Record<number, boolean>>({}) + const [expandedRevisions, setExpandedRevisions] = React.useState<Set<number>>(new Set()) + + // ✅ 문서 뷰어 상태 관리 + const [viewerOpen, setViewerOpen] = React.useState(false) + const [selectedRevisions, setSelectedRevisions] = React.useState<any[]>([]) + const [instance, setInstance] = React.useState<WebViewerInstance | null>(null) + const [viewerLoading, setViewerLoading] = React.useState(true) + const [fileSetLoading, setFileSetLoading] = React.useState(true) + const viewer = React.useRef<HTMLDivElement>(null) + const initialized = React.useRef(false) + const isCancelled = React.useRef(false) + + // 상위에서 관리하는지 로컬에서 관리하는지 결정 + const isExternallyManaged = onStageToggle !== undefined + const currentExpandedStages = isExternallyManaged ? expandedStages : localExpandedStages + + const handleStageToggle = React.useCallback((stageId: number) => { + if (isExternallyManaged && onStageToggle) { + onStageToggle(stageId) + } else { + setLocalExpandedStages(prev => ({ + ...prev, + [stageId]: !prev[stageId] + })) + } + }, [isExternallyManaged, onStageToggle]) + + const toggleRevisionFiles = React.useCallback((revisionId: number) => { + setExpandedRevisions(prev => { + const newSet = new Set(prev) + if (newSet.has(revisionId)) { + newSet.delete(revisionId) + } else { + newSet.add(revisionId) + } + return newSet + }) + }, []) + + // ✅ PDF 뷰어 정리 함수 + const cleanupHtmlStyle = React.useCallback(() => { + const htmlElement = window.document.documentElement + const originalStyle = htmlElement.getAttribute("style") || "" + const colorSchemeStyle = originalStyle + .split(";") + .map((s) => s.trim()) + .find((s) => s.startsWith("color-scheme:")) + + if (colorSchemeStyle) { + htmlElement.setAttribute("style", colorSchemeStyle + ";") + } else { + htmlElement.removeAttribute("style") + } + }, []) + + // ✅ 문서 뷰어 열기 함수 + const handleViewRevision = React.useCallback((revisions: any[]) => { + setSelectedRevisions(revisions) + setViewerOpen(true) + setViewerLoading(true) + setFileSetLoading(true) + initialized.current = false + }, []) + + // ✅ 파일 다운로드 함수 - 새로운 document-download API 사용 + const handleDownloadFile = React.useCallback(async (attachment: any) => { + console.log(attachment) + try { + // ID를 우선으로 사용, 없으면 filePath 사용 + const queryParam = attachment.id + ? `id=${encodeURIComponent(attachment.id)}` + : `path=${encodeURIComponent(attachment.filePath)}` + + const response = await fetch(`/api/document-download?${queryParam}`) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || '파일 다운로드에 실패했습니다.') + } + + const blob = await response.blob() + const url = window.URL.createObjectURL(blob) + const link = window.document.createElement('a') + link.href = url + link.download = attachment.fileName + window.document.body.appendChild(link) + link.click() + window.document.body.removeChild(link) + window.URL.revokeObjectURL(url) + + console.log('✅ 파일 다운로드 완료:', attachment.fileName) + } catch (error) { + console.error('❌ 파일 다운로드 오류:', error) + // 실제 앱에서는 toast나 alert로 에러 표시 + alert(`파일 다운로드 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + } + }, []) + + // ✅ WebViewer 초기화 + React.useEffect(() => { + if (viewerOpen && !initialized.current) { + initialized.current = true + isCancelled.current = false + + requestAnimationFrame(() => { + if (viewer.current && !isCancelled.current) { + import("@pdftron/webviewer").then(({ default: WebViewer }) => { + if (isCancelled.current) { + console.log("📛 WebViewer 초기화 취소됨 (Dialog 닫힘)") + return + } + + WebViewer( + { + path: "/pdftronWeb", + licenseKey: "demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd", + fullAPI: true, + css: "/globals.css", + }, + viewer.current as HTMLDivElement + ).then(async (instance: WebViewerInstance) => { + if (!isCancelled.current) { + setInstance(instance) + instance.UI.enableFeatures([instance.UI.Feature.MultiTab]) + instance.UI.disableElements(["addTabButton", "multiTabsEmptyPage"]) + setViewerLoading(false) + } + }) + }) + } + }) + } + + return () => { + if (instance) { + instance.UI.dispose() + } + setTimeout(() => cleanupHtmlStyle(), 500) + } + }, [viewerOpen, cleanupHtmlStyle]) + + // ✅ 문서 로드 + React.useEffect(() => { + const loadDocument = async () => { + if (instance && selectedRevisions.length > 0) { + const { UI } = instance + const optionsArray: any[] = [] + + selectedRevisions.forEach((revision) => { + const { attachments } = revision + attachments?.forEach((attachment: any) => { + const { fileName, filePath, fileType } = attachment + const fileTypeCur = fileType ?? "" + + const options = { + filename: fileName, + ...(fileTypeCur.includes("xlsx") && { + officeOptions: { + formatOptions: { + applyPageBreaksToSheet: true, + }, + }, + }), + } + + optionsArray.push({ filePath, options }) + }) + }) + + const tabIds = [] + for (const option of optionsArray) { + const { filePath, options } = option + try { + const response = await fetch(filePath) + const blob = await response.blob() + const tab = await UI.TabManager.addTab(blob, options) + tabIds.push(tab) + } catch (error) { + console.error("파일 로드 실패:", filePath, error) + } + } + + if (tabIds.length > 0) { + await UI.TabManager.setActiveTab(tabIds[0]) + } + + setFileSetLoading(false) + } + } + loadDocument() + }, [instance, selectedRevisions]) + + // ✅ 뷰어 닫기 + const handleCloseViewer = React.useCallback(async () => { + if (!fileSetLoading) { + isCancelled.current = true + + if (instance) { + try { + await instance.UI.dispose() + setInstance(null) + } catch (e) { + console.warn("dispose error", e) + } + } + + setViewerLoading(false) + setViewerOpen(false) + setTimeout(() => cleanupHtmlStyle(), 1000) + } + }, [fileSetLoading, instance, cleanupHtmlStyle]) + + // 뷰에서 가져온 allStages 데이터를 바로 사용 + const stagesWithRevisions = documentData.allStages || [] + + if (stagesWithRevisions.length === 0) { + return ( + <div className="p-6 text-center text-gray-500"> + <FileText className="w-12 h-12 mx-auto mb-4 text-gray-300" /> + <h4 className="font-medium mb-2">스테이지 정보가 없습니다</h4> + <p className="text-sm">이 문서에 대한 스테이지를 먼저 설정해주세요.</p> + </div> + ) + } + + return ( + <> + <div className="w-full max-w-none bg-gray-50" onClick={(e) => e.stopPropagation()}> + <div className="p-4"> + <div className="flex items-center justify-between mb-4"> + <div> + <h4 className="font-semibold flex items-center gap-2"> + <FileText className="w-4 h-4" /> + 스테이지별 리비전 현황 + </h4> + <p className="text-xs text-gray-600 mt-1"> + 총 {stagesWithRevisions.length}개 스테이지, {stagesWithRevisions.reduce((acc, stage) => acc + (stage.revisions?.length || 0), 0)}개 리비전 + </p> + </div> + {/* <Button + size="sm" + onClick={() => onUploadRevision(document, undefined, undefined, 'new')} + className="flex items-center gap-2" + > + <Upload className="w-3 h-3" /> + 새 리비전 업로드 + </Button> */} + </div> + + <ScrollArea className="h-[400px] w-full"> + <div className="space-y-3 pr-4"> + {stagesWithRevisions.map((stage) => { + const isExpanded = currentExpandedStages[stage.id] || false + const revisions = stage.revisions || [] + + return ( + <div key={stage.id} className="bg-white rounded border shadow-sm overflow-hidden"> + {/* 스테이지 헤더 */} + <div className="py-2 px-3 bg-gray-50 border-b"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-3"> + <button + className="flex items-center gap-2 hover:bg-gray-100 p-1 rounded transition-colors" + onClick={(e) => { + e.preventDefault() + e.stopPropagation() + handleStageToggle(stage.id) + }} + > + <div className="flex items-center gap-2"> + <div className="w-6 h-6 rounded-full bg-white border-2 border-gray-300 flex items-center justify-center text-xs font-medium"> + {stage.stageOrder || 1} + </div> + <div className={cn( + "w-2 h-2 rounded-full", + stage.stageStatus === 'COMPLETED' ? 'bg-green-500' : + stage.stageStatus === 'IN_PROGRESS' ? 'bg-blue-500' : + stage.stageStatus === 'SUBMITTED' ? 'bg-purple-500' : + 'bg-gray-300' + )} /> + {isExpanded ? + <ChevronDown className="w-3 h-3 text-gray-500" /> : + <ChevronRight className="w-3 h-3 text-gray-500" /> + } + </div> + </button> + + <div className="flex-1"> + <div className="flex items-center gap-2"> + <div className="font-medium text-sm">{stage.stageName}</div> + <Badge className={cn("text-xs", getStatusColor(stage.stageStatus))}> + {getStatusText(stage.stageStatus)} + </Badge> + <span className="text-xs text-gray-500"> + {revisions.length}개 리비전 + </span> + </div> + </div> + </div> + + <div className="flex items-center gap-4"> + <div className="grid grid-cols-2 gap-2 text-xs"> + <div> + <span className="text-gray-500">계획: </span> + <span className="font-medium">{stage.planDate ? formatDate(stage.planDate) : '-'}</span> + </div> + {stage.actualDate && ( + <div> + <span className="text-gray-500">완료: </span> + <span className="font-medium">{formatDate(stage.actualDate)}</span> + </div> + )} + {stage.assigneeName && ( + <div className="col-span-2 flex items-center gap-1 text-gray-600"> + <User className="w-3 h-3" /> + <span className="text-xs">{stage.assigneeName}</span> + </div> + )} + </div> + + {/* 스테이지 액션 메뉴 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="ghost" + size="sm" + className="h-7 w-7 p-0" + > + <MoreHorizontal className="h-3 w-3" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + {onStageStatusUpdate && ( + <> + <DropdownMenuItem onClick={() => onStageStatusUpdate(stage.id, 'IN_PROGRESS')}> + 진행 시작 + </DropdownMenuItem> + <DropdownMenuItem onClick={() => onStageStatusUpdate(stage.id, 'COMPLETED')}> + 완료 처리 + </DropdownMenuItem> + </> + )} + <DropdownMenuItem onClick={() => onUploadRevision(documentData, stage.stageName)}> + 리비전 업로드 + </DropdownMenuItem> + {/* ✅ 스테이지에 첨부파일이 있는 리비전이 있을 때만 문서 보기 버튼 표시 */} + {revisions.some(rev => rev.attachments && rev.attachments.length > 0) && ( + <DropdownMenuItem onClick={() => handleViewRevision(revisions.filter(rev => rev.attachments && rev.attachments.length > 0))}> + <Eye className="w-3 h-3 mr-1" /> + 스테이지 문서 보기 + </DropdownMenuItem> + )} + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> + </div> + + {/* 리비전 목록 - 테이블 형태 */} + {isExpanded && ( + <div className="max-h-72 overflow-y-auto"> + {revisions.length > 0 ? ( + <div className="border-t"> + <Table> + <TableHeader> + <TableRow className="bg-gray-50/50 h-8"> + <TableHead className="w-16 py-1 px-2 text-xs"></TableHead> + <TableHead className="w-16 py-1 px-2 text-xs">리비전</TableHead> + <TableHead className="w-20 py-1 px-2 text-xs">상태</TableHead> + <TableHead className="w-24 py-1 px-2 text-xs">업로더</TableHead> + <TableHead className="w-32 py-1 px-2 text-xs">제출일</TableHead> + <TableHead className="w-32 py-1 px-2 text-xs">승인/반려일</TableHead> + <TableHead className="min-w-[120px] py-1 px-2 text-xs">첨부파일</TableHead> + <TableHead className="w-16 py-1 px-2 text-xs">액션</TableHead> + <TableHead className="min-w-0 py-1 px-2 text-xs">코멘트</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {revisions.map((revision) => { + const hasAttachments = revision.attachments && revision.attachments.length > 0 + + return ( + <TableRow key={revision.id} className="hover:bg-gray-50 h-10"> + {/* 리비전 */} + <TableCell className="py-1 px-2"> + <span className="text-xs font-semibold"> + {revision.uploaderType ==="vendor"?"To SHI":"From SHI"} + </span> + </TableCell> + + <TableCell className="py-1 px-2"> + <span className="font-mono text-xs font-semibold bg-gray-100 px-1.5 py-0.5 rounded"> + {revision.revision} + </span> + </TableCell> + + {/* 상태 */} + <TableCell className="py-1 px-2"> + <Badge className={cn("text-xs px-1.5 py-0.5", getStatusColor(revision.revisionStatus))}> + {getStatusText(revision.revisionStatus)} + </Badge> + </TableCell> + + {/* 업로더 */} + <TableCell className="py-1 px-2"> + <div className="flex items-center gap-1"> + <User className="w-3 h-3 text-gray-400" /> + <span className="text-xs truncate max-w-[60px]">{revision.uploaderName || '-'}</span> + </div> + </TableCell> + + {/* 제출일 */} + <TableCell className="py-1 px-2"> + <span className="text-xs text-gray-600"> + {revision.submittedDate ? formatDate(revision.submittedDate) : '-'} + </span> + </TableCell> + + {/* 승인/반려일 */} + <TableCell className="py-1 px-2"> + <div className="text-xs text-gray-600"> + {revision.approvedDate && ( + <div className="flex items-center gap-1 text-green-600"> + <CheckCircle className="w-3 h-3" /> + <span className="text-xs">{formatDate(revision.approvedDate)}</span> + </div> + )} + {revision.rejectedDate && ( + <div className="flex items-center gap-1 text-red-600"> + <AlertTriangle className="w-3 h-3" /> + <span className="text-xs">{formatDate(revision.rejectedDate)}</span> + </div> + )} + {revision.reviewStartDate && !revision.approvedDate && !revision.rejectedDate && ( + <div className="flex items-center gap-1 text-blue-600"> + <Clock className="w-3 h-3" /> + <span className="text-xs">{formatDate(revision.reviewStartDate)}</span> + </div> + )} + {!revision.approvedDate && !revision.rejectedDate && !revision.reviewStartDate && ( + <span className="text-gray-400 text-xs">-</span> + )} + </div> + </TableCell> + + {/* ✅ 첨부파일 - 클릭 시 다운로드, 별도 뷰어 버튼 */} + <TableCell className="py-1 px-2"> + {hasAttachments ? ( + <div className="flex items-center gap-1 flex-wrap"> + {/* 파일 아이콘들 - 클릭 시 다운로드 */} + {revision.attachments.slice(0, 4).map((file: any) => ( + <Button + key={file.id} + variant="ghost" + size="sm" + onClick={() => handleDownloadFile(file)} + className="p-0.5 h-auto hover:bg-blue-50 rounded" + title={`${file.fileName} - 클릭해서 다운로드`} + > + <FileIcon className={cn("w-3 h-3", getFileIconColor(file.fileName))} /> + </Button> + ))} + {revision.attachments.length > 4 && ( + <span + className="text-xs text-gray-500 ml-0.5" + title={`총 ${revision.attachments.length}개 파일`} + > + +{revision.attachments.length - 4} + </span> + )} + {/* ✅ 모든 파일 보기 버튼 - 뷰어 열기 */} + <Button + variant="ghost" + size="sm" + onClick={() => handleViewRevision([revision])} + className="p-0.5 h-auto hover:bg-green-50 rounded ml-1" + title="모든 파일 보기" + > + <Eye className="w-3 h-3 text-green-600" /> + </Button> + </div> + ) : ( + <span className="text-gray-400 text-xs">-</span> + )} + </TableCell> + + {/* 액션 */} + <TableCell className="py-1 px-2"> + <div className="flex gap-0.5"> + {revision.revisionStatus === 'UNDER_REVIEW' && onRevisionStatusUpdate && ( + <> + <Button + size="sm" + variant="ghost" + onClick={() => onRevisionStatusUpdate(revision.id, 'APPROVED')} + className="text-green-600 hover:bg-green-50 h-6 px-1" + title="승인" + > + <CheckCircle className="w-3 h-3" /> + </Button> + <Button + size="sm" + variant="ghost" + onClick={() => onRevisionStatusUpdate(revision.id, 'REJECTED')} + className="text-red-600 hover:bg-red-50 h-6 px-1" + title="반려" + > + <AlertTriangle className="w-3 h-3" /> + </Button> + </> + )} + <Button + size="sm" + variant="ghost" + onClick={() => onUploadRevision(documentData, stage.stageName, revision.revision, 'append')} + className="text-blue-600 hover:bg-blue-50 h-6 px-1" + title="파일 추가" + > + <Upload className="w-3 h-3" /> + </Button> + </div> + </TableCell> + + {/* 코멘트 */} + <TableCell className="py-1 px-2"> + {revision.comment ? ( + <div className="max-w-24"> + <p className="text-xs text-gray-700 bg-gray-50 p-1 rounded truncate" title={revision.comment}> + {revision.comment} + </p> + </div> + ) : ( + <span className="text-gray-400 text-xs">-</span> + )} + </TableCell> + </TableRow> + ) + })} + </TableBody> + </Table> + </div> + ) : ( + <div className="p-6 text-center"> + <div className="flex flex-col items-center gap-3"> + <div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center"> + <FileText className="w-6 h-6 text-gray-300" /> + </div> + <div> + <h5 className="font-medium text-gray-700 mb-1 text-sm">리비전이 없습니다</h5> + <p className="text-xs text-gray-500 mb-3">아직 이 스테이지에 업로드된 리비전이 없습니다</p> + <Button + size="sm" + onClick={() => onUploadRevision(documentData, stage.stageName, undefined, 'new')} + className="text-xs" + > + <Upload className="w-3 h-3 mr-1" /> + 첫 리비전 업로드 + </Button> + </div> + </div> + </div> + )} + </div> + )} + </div> + ) + })} + </div> + </ScrollArea> + </div> + </div> + + {/* ✅ 통합된 문서 뷰어 다이얼로그 */} + <Dialog open={viewerOpen} onOpenChange={handleCloseViewer}> + <DialogContent className="w-[90vw] h-[90vh]" style={{ maxWidth: "none" }}> + <DialogHeader className="h-[38px]"> + <DialogTitle>문서 미리보기</DialogTitle> + <DialogDescription> + {selectedRevisions.length === 1 + ? `리비전 ${selectedRevisions[0]?.revision} 첨부파일` + : `${selectedRevisions.length}개 리비전 첨부파일` + } + </DialogDescription> + </DialogHeader> + <div + ref={viewer} + style={{ height: "calc(90vh - 20px - 38px - 1rem - 48px)" }} + > + {viewerLoading && ( + <div className="flex flex-col items-center justify-center py-12"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground"> + 문서 뷰어 로딩 중... + </p> + </div> + )} + </div> + </DialogContent> + </Dialog> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/stage-revision-sheet.tsx b/lib/vendor-document-list/table/stage-revision-sheet.tsx new file mode 100644 index 00000000..2cc22cce --- /dev/null +++ b/lib/vendor-document-list/table/stage-revision-sheet.tsx @@ -0,0 +1,86 @@ +// StageRevisionDrawer.tsx +// Slide‑up drawer (bottom) that shows StageRevisionExpandedContent. +// Requires shadcn/ui Drawer primitives already installed. + +"use client" + +import * as React from "react" +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, + DrawerDescription, +} from "@/components/ui/drawer" + +import type { EnhancedDocument } from "@/types/enhanced-documents" +import { StageRevisionExpandedContent } from "./stage-revision-expanded-content" + +export interface StageRevisionDrawerProps { + /** whether the drawer is open */ + open: boolean + /** callback invoked when the open state should change */ + onOpenChange: (open: boolean) => void + /** the document whose stages / revisions are displayed */ + document: EnhancedDocument | null + /** project type to propagate further */ + projectType: "ship" | "plant" + /** callbacks forwarded to StageRevisionExpandedContent */ + onUploadRevision: ( + doc: EnhancedDocument, + stageName?: string, + currentRevision?: string, + mode?: "new" | "append" + ) => void + onViewRevision: (revisions: any[]) => void + onStageStatusUpdate?: (stageId: number, status: string) => void + onRevisionStatusUpdate?: (revisionId: number, status: string) => void +} + +/** + * Bottom‑anchored Drawer that presents Stage / Revision details. + * Fills up to 85 vh and slides up from the bottom edge. + */ +export const StageRevisionDrawer: React.FC<StageRevisionDrawerProps> = ({ + open, + onOpenChange, + document, + projectType, + onUploadRevision, + onViewRevision, + onStageStatusUpdate, + onRevisionStatusUpdate, +}) => { + return ( + <Drawer open={open} onOpenChange={onOpenChange}> + {/* No trigger – controlled by parent */} + <DrawerContent className="h-[85vh] flex flex-col p-0"> + <DrawerHeader className="border-b p-4"> + <DrawerTitle>스테이지 / 리비전 상세</DrawerTitle> + {document && ( + <DrawerDescription className="text-xs text-muted-foreground truncate"> + {document.docNumber} — {document.title} + </DrawerDescription> + )} + </DrawerHeader> + + <div className="flex-1 overflow-auto"> + {document ? ( + <StageRevisionExpandedContent + document={document} + projectType={projectType} + onUploadRevision={onUploadRevision} + onViewRevision={onViewRevision} + onStageStatusUpdate={onStageStatusUpdate} + onRevisionStatusUpdate={onRevisionStatusUpdate} + /> + ) : ( + <div className="flex h-full items-center justify-center text-sm text-gray-500"> + 문서가 선택되지 않았습니다. + </div> + )} + </div> + </DrawerContent> + </Drawer> + ) +} |
