summaryrefslogtreecommitdiff
path: root/lib/vendor-document-list
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendor-document-list')
-rw-r--r--lib/vendor-document-list/enhanced-document-service.ts782
-rw-r--r--lib/vendor-document-list/sync-client.ts28
-rw-r--r--lib/vendor-document-list/sync-service.ts491
-rw-r--r--lib/vendor-document-list/table/bulk-upload-dialog.tsx1162
-rw-r--r--lib/vendor-document-list/table/enhanced-doc-table-columns.tsx612
-rw-r--r--lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx106
-rw-r--r--lib/vendor-document-list/table/enhanced-document-sheet.tsx939
-rw-r--r--lib/vendor-document-list/table/enhanced-documents-table copy.tsx604
-rw-r--r--lib/vendor-document-list/table/enhanced-documents-table.tsx570
-rw-r--r--lib/vendor-document-list/table/revision-upload-dialog.tsx486
-rw-r--r--lib/vendor-document-list/table/send-to-shi-button.tsx342
-rw-r--r--lib/vendor-document-list/table/simplified-document-edit-dialog.tsx287
-rw-r--r--lib/vendor-document-list/table/stage-revision-expanded-content.tsx719
-rw-r--r--lib/vendor-document-list/table/stage-revision-sheet.tsx86
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>
+ )
+}