diff options
| -rw-r--r-- | lib/swp/actions.ts | 542 | ||||
| -rw-r--r-- | lib/swp/api-client.ts | 40 | ||||
| -rw-r--r-- | lib/swp/document-service.ts | 76 | ||||
| -rw-r--r-- | lib/swp/example-usage.ts | 347 | ||||
| -rw-r--r-- | lib/swp/sync-service.ts | 537 | ||||
| -rw-r--r-- | lib/swp/table/swp-uploaded-files-dialog.tsx | 13 | ||||
| -rw-r--r-- | lib/swp/vendor-actions.ts | 100 | ||||
| -rw-r--r-- | lib/vendor-document-list/plant/document-stage-dialogs.tsx | 36 | ||||
| -rw-r--r-- | lib/vendor-document-list/plant/document-stage-toolbar.tsx | 14 | ||||
| -rw-r--r-- | lib/vendor-document-list/plant/document-stages-columns.tsx | 3 | ||||
| -rw-r--r-- | lib/vendor-document-list/plant/document-stages-service.ts | 4 | ||||
| -rw-r--r-- | lib/vendor-document-list/plant/document-stages-table.tsx | 244 | ||||
| -rw-r--r-- | lib/vendor-document-list/plant/excel-import-stage.tsx | 26 | ||||
| -rw-r--r-- | lib/vendor-document-list/plant/shi-buyer-system-api.ts | 37 |
14 files changed, 396 insertions, 1623 deletions
diff --git a/lib/swp/actions.ts b/lib/swp/actions.ts deleted file mode 100644 index 6962884e..00000000 --- a/lib/swp/actions.ts +++ /dev/null @@ -1,542 +0,0 @@ -"use server"; - -import db from "@/db/db"; -import { swpDocuments, swpDocumentRevisions, swpDocumentFiles } from "@/db/schema/SWP/swp-documents"; -import { eq, and, sql, like, desc, asc, type SQL } from "drizzle-orm"; -import { fetchSwpProjectData } from "./api-client"; -import { syncSwpProject } from "./sync-service"; -import * as fs from "fs/promises"; -import * as path from "path"; -import { debugLog, debugError, debugWarn, debugSuccess } from "@/lib/debug-utils"; - -// ============================================================================ -// 타입 정의 -// ============================================================================ - -export interface SwpTableFilters { - projNo?: string; - docNo?: string; - docTitle?: string; - pkgNo?: string; - vndrCd?: string; - stage?: string; -} - -export interface SwpTableParams { - page: number; - pageSize: number; - sortBy?: string; - sortOrder?: "asc" | "desc"; - filters?: SwpTableFilters; -} - -export interface SwpDocumentWithStats { - DOC_NO: string; - DOC_TITLE: string; - PROJ_NO: string; - PROJ_NM: string | null; - PKG_NO: string | null; - VNDR_CD: string | null; - CPY_NM: string | null; - LTST_REV_NO: string | null; - STAGE: string | null; - LTST_ACTV_STAT: string | null; - sync_status: "synced" | "pending" | "error"; - last_synced_at: Date; - revision_count: number; - file_count: number; -} - -// ============================================================================ -// 서버 액션: 문서 목록 조회 (페이지네이션 + 검색) -// ============================================================================ - -export async function fetchSwpDocuments(params: SwpTableParams) { - const { page, pageSize, sortBy = "last_synced_at", sortOrder = "desc", filters } = params; - const offset = (page - 1) * pageSize; - - try { - // WHERE 조건 구성 - const conditions: SQL<unknown>[] = []; - - if (filters?.projNo) { - conditions.push(like(swpDocuments.PROJ_NO, `%${filters.projNo}%`)); - } - if (filters?.docNo) { - conditions.push(like(swpDocuments.DOC_NO, `%${filters.docNo}%`)); - } - if (filters?.docTitle) { - conditions.push(like(swpDocuments.DOC_TITLE, `%${filters.docTitle}%`)); - } - if (filters?.pkgNo) { - conditions.push(like(swpDocuments.PKG_NO, `%${filters.pkgNo}%`)); - } - if (filters?.vndrCd) { - conditions.push(like(swpDocuments.VNDR_CD, `%${filters.vndrCd}%`)); - } - if (filters?.stage) { - conditions.push(eq(swpDocuments.STAGE, filters.stage)); - } - - const whereClause = conditions.length > 0 ? and(...conditions) : undefined; - - // 총 개수 조회 - const totalResult = await db - .select({ count: sql<number>`count(*)::int` }) - .from(swpDocuments) - .where(whereClause); - - const total = totalResult[0]?.count || 0; - - // 정렬 컬럼 결정 - const orderByColumn = - sortBy === "DOC_NO" ? swpDocuments.DOC_NO : - sortBy === "DOC_TITLE" ? swpDocuments.DOC_TITLE : - sortBy === "PROJ_NO" ? swpDocuments.PROJ_NO : - sortBy === "PKG_NO" ? swpDocuments.PKG_NO : - sortBy === "STAGE" ? swpDocuments.STAGE : - swpDocuments.last_synced_at; - - // 데이터 조회 (Drizzle query builder 사용) - const documents = await db - .select({ - DOC_NO: swpDocuments.DOC_NO, - DOC_TITLE: swpDocuments.DOC_TITLE, - PROJ_NO: swpDocuments.PROJ_NO, - PROJ_NM: swpDocuments.PROJ_NM, - PKG_NO: swpDocuments.PKG_NO, - VNDR_CD: swpDocuments.VNDR_CD, - CPY_NM: swpDocuments.CPY_NM, - LTST_REV_NO: swpDocuments.LTST_REV_NO, - STAGE: swpDocuments.STAGE, - LTST_ACTV_STAT: swpDocuments.LTST_ACTV_STAT, - sync_status: swpDocuments.sync_status, - last_synced_at: swpDocuments.last_synced_at, - revision_count: sql<number>`COUNT(DISTINCT ${swpDocumentRevisions.id})::int`, - file_count: sql<number>`COUNT(DISTINCT ${swpDocumentFiles.id})::int`, - }) - .from(swpDocuments) - .leftJoin(swpDocumentRevisions, eq(swpDocuments.DOC_NO, swpDocumentRevisions.DOC_NO)) - .leftJoin(swpDocumentFiles, eq(swpDocumentRevisions.id, swpDocumentFiles.revision_id)) - .where(whereClause) - .groupBy( - swpDocuments.DOC_NO, - swpDocuments.DOC_TITLE, - swpDocuments.PROJ_NO, - swpDocuments.PROJ_NM, - swpDocuments.PKG_NO, - swpDocuments.VNDR_CD, - swpDocuments.CPY_NM, - swpDocuments.LTST_REV_NO, - swpDocuments.STAGE, - swpDocuments.LTST_ACTV_STAT, - swpDocuments.sync_status, - swpDocuments.last_synced_at - ) - .orderBy(sortOrder === "desc" ? desc(orderByColumn) : asc(orderByColumn)) - .limit(pageSize) - .offset(offset); - - return { - data: documents, - total, - page, - pageSize, - totalPages: Math.ceil(total / pageSize), - }; - } catch (error) { - console.error("[fetchSwpDocuments] 오류:", error); - throw new Error("문서 목록 조회 실패 [SWP API에서 실패가 발생했습니다. 담당자에게 문의하세요]"); - } -} - -// ============================================================================ -// 서버 액션: 문서의 리비전 목록 조회 -// ============================================================================ - -export async function fetchDocumentRevisions(docNo: string) { - try { - const revisions = await db - .select({ - id: swpDocumentRevisions.id, - DOC_NO: swpDocumentRevisions.DOC_NO, - REV_NO: swpDocumentRevisions.REV_NO, - STAGE: swpDocumentRevisions.STAGE, - ACTV_NO: swpDocumentRevisions.ACTV_NO, - OFDC_NO: swpDocumentRevisions.OFDC_NO, - sync_status: swpDocumentRevisions.sync_status, - last_synced_at: swpDocumentRevisions.last_synced_at, - file_count: sql<number>`COUNT(DISTINCT ${swpDocumentFiles.id})::int`, - }) - .from(swpDocumentRevisions) - .leftJoin(swpDocumentFiles, eq(swpDocumentRevisions.id, swpDocumentFiles.revision_id)) - .where(eq(swpDocumentRevisions.DOC_NO, docNo)) - .groupBy( - swpDocumentRevisions.id, - swpDocumentRevisions.DOC_NO, - swpDocumentRevisions.REV_NO, - swpDocumentRevisions.STAGE, - swpDocumentRevisions.ACTV_NO, - swpDocumentRevisions.OFDC_NO, - swpDocumentRevisions.sync_status, - swpDocumentRevisions.last_synced_at - ) - .orderBy(desc(swpDocumentRevisions.REV_NO)); - - return revisions; - } catch (error) { - console.error("[fetchDocumentRevisions] 오류:", error); - throw new Error("리비전 목록 조회 실패"); - } -} - -// ============================================================================ -// 서버 액션: 리비전의 파일 목록 조회 -// ============================================================================ - -export async function fetchRevisionFiles(revisionId: number) { - try { - const files = await db - .select({ - id: swpDocumentFiles.id, - FILE_NM: swpDocumentFiles.FILE_NM, - FILE_SEQ: swpDocumentFiles.FILE_SEQ, - FILE_SZ: swpDocumentFiles.FILE_SZ, - FLD_PATH: swpDocumentFiles.FLD_PATH, - STAT: swpDocumentFiles.STAT, - STAT_NM: swpDocumentFiles.STAT_NM, - sync_status: swpDocumentFiles.sync_status, - created_at: swpDocumentFiles.created_at, - }) - .from(swpDocumentFiles) - .where(eq(swpDocumentFiles.revision_id, revisionId)) - .orderBy(asc(swpDocumentFiles.FILE_SEQ)); - - return files; - } catch (error) { - console.error("[fetchRevisionFiles] 오류:", error); - throw new Error("파일 목록 조회 실패"); - } -} - -// ============================================================================ -// 서버 액션: 프로젝트 동기화 -// ============================================================================ - -export async function syncSwpProjectAction(projectNo: string, docGb: "M" | "V" = "V") { - try { - console.log(`[syncSwpProjectAction] 시작: ${projectNo}`); - - // 1. API에서 데이터 조회 - const { documents, files } = await fetchSwpProjectData(projectNo, docGb); - - // 2. 동기화 실행 - const result = await syncSwpProject(projectNo, documents, files); - - console.log(`[syncSwpProjectAction] 완료:`, result.stats); - - return result; - } catch (error) { - console.error("[syncSwpProjectAction] 오류:", error); - throw new Error( - error instanceof Error ? error.message : "동기화 실패" - ); - } -} - -// ============================================================================ -// 서버 액션: 프로젝트 목록 조회 (필터용) -// ============================================================================ - -export async function fetchProjectList() { - try { - const projects = await db - .select({ - PROJ_NO: swpDocuments.PROJ_NO, - PROJ_NM: swpDocuments.PROJ_NM, - doc_count: sql<number>`COUNT(DISTINCT ${swpDocuments.DOC_NO})::int`, - }) - .from(swpDocuments) - .groupBy(swpDocuments.PROJ_NO, swpDocuments.PROJ_NM) - .orderBy(desc(sql`COUNT(DISTINCT ${swpDocuments.DOC_NO})`)); - - return projects; - } catch (error) { - console.error("[fetchProjectList] 오류:", error); - return []; - } -} - -// ============================================================================ -// 서버 액션: 통계 조회 -// ============================================================================ - -export async function fetchSwpStats(projNo?: string) { - try { - const whereClause = projNo ? eq(swpDocuments.PROJ_NO, projNo) : undefined; - - const stats = await db - .select({ - total_documents: sql<number>`COUNT(DISTINCT ${swpDocuments.DOC_NO})::int`, - total_revisions: sql<number>`COUNT(DISTINCT ${swpDocumentRevisions.id})::int`, - total_files: sql<number>`COUNT(DISTINCT ${swpDocumentFiles.id})::int`, - last_sync: sql<Date>`MAX(${swpDocuments.last_synced_at})`, - }) - .from(swpDocuments) - .leftJoin(swpDocumentRevisions, eq(swpDocuments.DOC_NO, swpDocumentRevisions.DOC_NO)) - .leftJoin(swpDocumentFiles, eq(swpDocumentRevisions.id, swpDocumentFiles.revision_id)) - .where(whereClause); - - return stats[0] || { - total_documents: 0, - total_revisions: 0, - total_files: 0, - last_sync: null, - }; - } catch (error) { - console.error("[fetchSwpStats] 오류:", error); - return { - total_documents: 0, - total_revisions: 0, - total_files: 0, - last_sync: null, - }; - } -} - -// ============================================================================ -// 서버 액션: 파일 다운로드 -// ============================================================================ - -export interface DownloadFileResult { - success: boolean; - data?: Uint8Array; - fileName?: string; - mimeType?: string; - error?: string; -} - -export async function downloadSwpFile(fileId: number): Promise<DownloadFileResult> { - try { - debugLog(`[downloadSwpFile] 다운로드 시작`, { fileId }); - - // 1. 파일 정보 조회 - const fileInfo = await db - .select({ - FILE_NM: swpDocumentFiles.FILE_NM, - FLD_PATH: swpDocumentFiles.FLD_PATH, - }) - .from(swpDocumentFiles) - .where(eq(swpDocumentFiles.id, fileId)) - .limit(1); - - if (!fileInfo || fileInfo.length === 0) { - debugError(`[downloadSwpFile] 파일 정보 없음`, { fileId }); - return { - success: false, - error: "파일 정보를 찾을 수 없습니다.", - }; - } - - const { FILE_NM, FLD_PATH } = fileInfo[0]; - debugLog(`[downloadSwpFile] 파일 정보 조회 완료`, { FILE_NM, FLD_PATH }); - - if (!FLD_PATH || !FILE_NM) { - debugError(`[downloadSwpFile] 파일 경로 또는 이름 없음`, { FILE_NM, FLD_PATH }); - return { - success: false, - error: "파일 경로 또는 파일명이 없습니다.", - }; - } - - // 2. NFS 마운트 경로 확인 - const nfsBasePath = process.env.SWP_MOUNT_DIR; - if (!nfsBasePath) { - console.error( - '[downloadSwpFile] SWP_MOUNT_DIR 환경변수가 설정되지 않았습니다.' - ); - return { - success: false, - error: "서버 설정 오류: NFS 경로가 설정되지 않았습니다.", - }; - } - - // 3. 전체 파일 경로 생성 - // FLD_PATH가 절대 경로일 수도 있고 상대 경로일 수도 있으므로 처리 - // Windows 스타일 백슬래시를 리눅스 슬래시로 변환 - const normalizedFldPath = FLD_PATH.replace(/\\/g, '/'); - const fullPath = path.join(nfsBasePath, normalizedFldPath, FILE_NM); - - console.log("[downloadSwpFile] 파일 다운로드 시도:", { - fileId, - FILE_NM, - FLD_PATH, - normalizedFldPath, - fullPath, - }); - - // 4. 파일 존재 여부 확인 - try { - await fs.access(fullPath, fs.constants.R_OK); - } catch (accessError) { - console.error("[downloadSwpFile] 파일 접근 불가:", accessError); - return { - success: false, - error: `파일을 찾을 수 없습니다: ${FILE_NM}`, - }; - } - - // 5. 파일 읽기 - debugLog(`[downloadSwpFile] 파일 읽기 시작`, { fullPath }); - const fileBuffer = await fs.readFile(fullPath); - - debugLog(`[downloadSwpFile] 파일 Buffer 읽기 완료`, { - bufferLength: fileBuffer.length, - isBuffer: Buffer.isBuffer(fileBuffer), - bufferType: typeof fileBuffer, - constructor: fileBuffer.constructor.name, - first20Bytes: fileBuffer.slice(0, 20).toString('hex') - }); - - const fileData = new Uint8Array(fileBuffer); - - debugLog(`[downloadSwpFile] Uint8Array 변환 완료`, { - uint8ArrayLength: fileData.length, - uint8ArrayType: typeof fileData, - constructor: fileData.constructor.name, - first20Bytes: Array.from(fileData.slice(0, 20)), - jsonStringified: JSON.stringify(fileData).substring(0, 100) + '...' - }); - - // 6. MIME 타입 결정 - const mimeType = getMimeType(FILE_NM); - - console.log("[downloadSwpFile] 파일 다운로드 성공:", { - fileName: FILE_NM, - size: fileData.length, - mimeType, - }); - - debugSuccess(`[downloadSwpFile] 다운로드 성공`, { - fileName: FILE_NM, - dataLength: fileData.length, - mimeType, - returnDataType: typeof fileData, - isUint8Array: fileData instanceof Uint8Array - }); - - return { - success: true, - data: fileData, - fileName: FILE_NM, - mimeType, - }; - } catch (error) { - console.error("[downloadSwpFile] 오류:", error); - debugError(`[downloadSwpFile] 다운로드 실패`, { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined - }); - return { - success: false, - error: error instanceof Error ? error.message : "파일 다운로드 실패", - }; - } -} - -// ============================================================================ -// 헬퍼: MIME 타입 결정 -// ============================================================================ - -function getMimeType(fileName: string): string { - const ext = path.extname(fileName).toLowerCase(); - - const mimeTypes: Record<string, string> = { - ".pdf": "application/pdf", - ".doc": "application/msword", - ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ".xls": "application/vnd.ms-excel", - ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ".ppt": "application/vnd.ms-powerpoint", - ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", - ".txt": "text/plain", - ".csv": "text/csv", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - ".zip": "application/zip", - ".rar": "application/x-rar-compressed", - ".7z": "application/x-7z-compressed", - ".dwg": "application/acad", - ".dxf": "application/dxf", - }; - - return mimeTypes[ext] || "application/octet-stream"; -} - -// ============================================================================ -// 서버 액션: 벤더 업로드 파일 목록 조회 -// ============================================================================ - -export async function fetchVendorUploadedFiles(projNo: string, vndrCd: string) { - try { - debugLog(`[fetchVendorUploadedFiles] 조회 시작`, { projNo, vndrCd }); - - // fetchGetExternalInboxList 호출 - const { fetchGetExternalInboxList } = await import("./api-client"); - const files = await fetchGetExternalInboxList({ - projNo, - vndrCd, - }); - - debugLog(`[fetchVendorUploadedFiles] 조회 완료`, { - fileCount: files.length - }); - - return files; - } catch (error) { - debugError(`[fetchVendorUploadedFiles] 조회 실패`, { error }); - throw new Error( - error instanceof Error ? error.message : "업로드 파일 목록 조회 실패" - ); - } -} - -// ============================================================================ -// 서버 액션: 벤더 업로드 파일 취소 -// ============================================================================ - -export interface CancelUploadedFileParams { - boxSeq: string; - actvSeq: string; - userId: string; -} - -export async function cancelVendorUploadedFile(params: CancelUploadedFileParams) { - try { - debugLog(`[cancelVendorUploadedFile] 취소 시작`, params); - - const { callSaveInBoxListCancelStatus } = await import("./api-client"); - const cancelCount = await callSaveInBoxListCancelStatus({ - boxSeq: params.boxSeq, - actvSeq: params.actvSeq, - chgr: `evcp${params.userId}`, - }); - - debugSuccess(`[cancelVendorUploadedFile] 취소 완료`, { - ...params, - cancelCount - }); - - return { - success: true, - cancelCount - }; - } catch (error) { - debugError(`[cancelVendorUploadedFile] 취소 실패`, { error }); - throw new Error( - error instanceof Error ? error.message : "파일 취소 실패" - ); - } -} diff --git a/lib/swp/api-client.ts b/lib/swp/api-client.ts index 4943a42a..1befa217 100644 --- a/lib/swp/api-client.ts +++ b/lib/swp/api-client.ts @@ -1,5 +1,45 @@ "use server"; +/** + * SWP API Client + * + * ## 목적 + * 외부 SWP 시스템의 REST API를 호출하는 저수준 클라이언트입니다. + * + * ## 설계 원칙 + * 1. **단순 API 호출만 담당** - 비즈니스 로직은 document-service.ts에서 처리 + * 2. **타입 안전성** - API 응답 타입을 명확히 정의 + * 3. **에러 핸들링** - API 실패 시 명확한 에러 메시지 제공 + * 4. **디버깅 지원** - 모든 API 호출을 debugLog로 추적 + * + * ## 주요 API + * - `fetchGetVDRDocumentList`: 문서 마스터 조회 (필터링 지원) + * - `fetchGetExternalInboxList`: 파일 정보 조회 (업로드된 파일 포함) + * - `fetchGetActivityFileList`: Rev-Activity-File 계층 구조 조회 + * - `callSaveInBoxList`: 파일 업로드 메타데이터 등록 + * - `callSaveInBoxListCancelStatus`: Standby 파일 취소 + * + * ## 사용 예시 + * ```typescript + * // 문서 목록 조회 + * const documents = await fetchGetVDRDocumentList({ + * proj_no: "SN2190", + * doc_gb: "V", + * vndrCd: "SE00100" + * }); + * + * // 파일 목록 조회 + * const files = await fetchGetExternalInboxList({ + * projNo: "SN2190", + * vndrCd: "SE00100" + * }); + * ``` + * + * @see lib/swp/document-service.ts - 비즈니스 로직 레이어 + * @see lib/swp/vendor-actions.ts - 서버 액션 (권한 체크) + * @see lib/swp/README.md - 전체 시스템 문서 + */ + // ============================================================================ // SWP API 클라이언트 // ============================================================================ diff --git a/lib/swp/document-service.ts b/lib/swp/document-service.ts index 49e4da4c..f83488d9 100644 --- a/lib/swp/document-service.ts +++ b/lib/swp/document-service.ts @@ -1,5 +1,81 @@ "use server"; +/** + * SWP Document Service + * + * ## 목적 + * SWP API 응답을 가공하여 프론트엔드에 최적화된 데이터 구조를 제공합니다. + * + * ## 역할 + * 1. **데이터 변환**: API 응답을 UI 친화적 구조로 변환 + * 2. **데이터 집계**: 여러 API 호출 결과를 조합 (예: 문서 + 파일 개수) + * 3. **비즈니스 로직**: 파일 다운로드, 취소 가능 여부 판단 등 + * 4. **에러 처리**: API 실패 시 사용자 친화적 에러 메시지 반환 + * + * ## 주요 함수 + * + * ### 조회 + * - `getDocumentList(projNo, vndrCd)`: 문서 목록 + 파일 개수 집계 + * - `getDocumentDetail(projNo, docNo)`: Rev → Activity → File 3단계 트리 구조 + * + * ### 액션 + * - `cancelStandbyFile(boxSeq, actvSeq, userId)`: Standby 파일 취소 (SCW01만) + * - `downloadDocumentFile(projNo, ownDocNo, fileName)`: NFS에서 파일 다운로드 + * + * ## 데이터 구조 + * + * ### DocumentListItem (문서 목록) + * ```typescript + * { + * DOC_NO: string, + * DOC_TITLE: string, + * LTST_REV_NO: string, + * STAGE: string, + * fileCount: number, // 전체 파일 개수 + * standbyFileCount: number, // 업로드 대기 중 (SCW01) + * ... + * } + * ``` + * + * ### DocumentDetail (문서 상세) + * ```typescript + * { + * docNo: string, + * revisions: [ + * { + * revNo: "04", + * stage: "IFC", + * activities: [ + * { + * actvNo: "ACTV123", + * type: "External Outbox", + * files: [ + * { + * fileNm: "drawing.pdf", + * stat: "COM01", + * canDownload: true, + * canCancel: false, + * ... + * } + * ] + * } + * ] + * } + * ] + * } + * ``` + * + * ## 설계 원칙 + * 1. **API 호출 최소화**: 필요한 데이터를 한 번에 조회 후 가공 + * 2. **타입 안전성**: 모든 반환 타입을 명확히 정의 + * 3. **UI 친화적**: 프론트엔드가 바로 사용 가능한 구조 + * 4. **에러 복원력**: API 실패 시에도 부분 데이터 제공 시도 + * + * @see lib/swp/api-client.ts - 저수준 API 호출 + * @see lib/swp/vendor-actions.ts - 서버 액션 (권한 체크 추가) + * @see lib/swp/README.md - 전체 시스템 문서 + */ + import { fetchGetVDRDocumentList, fetchGetExternalInboxList, diff --git a/lib/swp/example-usage.ts b/lib/swp/example-usage.ts deleted file mode 100644 index 8e1791f7..00000000 --- a/lib/swp/example-usage.ts +++ /dev/null @@ -1,347 +0,0 @@ -"use server"; - -/* eslint-disable @typescript-eslint/no-explicit-any */ - -/** - * SWP 문서 관리 시스템 사용 예제 - * - * 이 파일은 실제 사용 시나리오를 보여주는 예제입니다. - */ - -import { - fetchSwpProjectData, - analyzeSwpData, - getSwpFileDownloadUrl, -} from "./api-client"; -import { - syncSwpProject, - getProjectDocumentsHierarchy, - getDocumentRevisions, - getRevisionFiles, - getProjectSyncStatus, -} from "./sync-service"; -import db from "@/db/db"; -import { sql } from "drizzle-orm"; - -// ============================================================================ -// 예제 1: 프로젝트 전체 동기화 -// ============================================================================ - -export async function example1_FullProjectSync(projectNo: string) { - console.log("=== 예제 1: 프로젝트 전체 동기화 ===\n"); - - // 1. API에서 데이터 조회 - console.log(`📡 API 호출 중...`); - const { documents, files } = await fetchSwpProjectData(projectNo, "V"); - - // 2. 데이터 분석 - const stats: Awaited<ReturnType<typeof analyzeSwpData>> = await analyzeSwpData(projectNo, documents, files); - console.log(`📊 데이터 분석:`); - console.log(` - 문서: ${stats.documentCount}개`); - console.log(` - 리비전: ${stats.revisionCount}개`); - console.log(` - 파일: ${stats.fileCount}개`); - console.log(` - 평균 파일/문서: ${stats.avgFilesPerDoc.toFixed(2)}개`); - console.log( - ` - 총 용량: ${(stats.totalFileSize / 1024 / 1024).toFixed(2)} MB` - ); - console.log(` - 스테이지:`, stats.stages); - console.log(` - 파일 타입:`, stats.fileTypes); - - // 3. 동기화 실행 - console.log(`\n💾 동기화 시작...`); - const syncResult = await syncSwpProject(projectNo, documents, files); - - if (syncResult.success) { - console.log(`✅ 동기화 완료 (${syncResult.duration}ms)`); - console.log(` - 문서: +${syncResult.stats.documents.inserted}개`); - console.log(` - 리비전: +${syncResult.stats.revisions.inserted}개`); - console.log(` - 파일: +${syncResult.stats.files.inserted}개`); - } else { - console.error(`❌ 동기화 실패:`); - syncResult.errors.forEach((err) => console.error(` - ${err}`)); - } - - return syncResult; -} - -// ============================================================================ -// 예제 2: 계층 구조 조회 및 UI 렌더링 -// ============================================================================ - -export async function example2_HierarchyView(projectNo: string) { - console.log("=== 예제 2: 계층 구조 조회 ===\n"); - - // 1. 계층 뷰 조회 - const result = await getProjectDocumentsHierarchy(projectNo); - const documents = result.rows as any[]; - - console.log(`📁 문서 ${documents.length}개 조회됨\n`); - - // 2. 첫 3개 문서만 출력 (예제) - documents.slice(0, 3).forEach((doc) => { - console.log(`📄 ${doc.doc_no}`); - console.log(` 제목: ${doc.doc_title}`); - console.log(` 최신 리비전: ${doc.ltst_rev_no}`); - console.log(` 리비전 수: ${doc.revision_count}개`); - - const revisions = JSON.parse(doc.revisions || "[]"); - revisions.slice(0, 2).forEach((rev: any) => { - console.log(` 📋 REV ${rev.revNo} (${rev.stage})`); - console.log(` 파일: ${rev.fileCount}개`); - - const files = rev.files || []; - files.forEach((file: any) => { - console.log(` 📎 ${file.fileNm} (${file.fileSz} bytes)`); - }); - }); - console.log(); - }); - - return documents; -} - -// ============================================================================ -// 예제 3: 특정 문서의 리비전 조회 -// ============================================================================ - -export async function example3_DocumentRevisions(docNo: string) { - console.log("=== 예제 3: 문서 리비전 조회 ===\n"); - - // 1. 리비전 목록 조회 - const revisions = await getDocumentRevisions(docNo); - - console.log(`📄 문서: ${docNo}`); - console.log(`📋 리비전: ${revisions.length}개\n`); - - // 2. 각 리비전별 파일 조회 - for (const rev of revisions) { - const files = await getRevisionFiles(rev.id); - - console.log(`REV ${rev.REV_NO} (${rev.STAGE})`); - console.log(` 파일: ${files.length}개`); - console.log(` Activity: ${rev.ACTV_NO || "N/A"}`); - console.log(` OFDC: ${rev.OFDC_NO}`); - console.log(` 동기화: ${rev.sync_status} (${rev.last_synced_at})`); - - files.forEach((file) => { - console.log(` 📎 ${file.FILE_NM}`); - console.log(` 크기: ${file.FILE_SZ} bytes`); - console.log(` 경로: ${file.FLD_PATH}`); - console.log(` 상태: ${file.STAT_NM}`); - }); - console.log(); - } - - return revisions; -} - -// ============================================================================ -// 예제 4: 파일 검색 (플랫 뷰 활용) -// ============================================================================ - -export async function example4_SearchFiles( - projectNo: string, - fileNamePattern: string -) { - console.log("=== 예제 4: 파일 검색 ===\n"); - - // 1. 플랫 뷰에서 검색 - const result = await db.execute(sql` - SELECT - "DOC_NO", - "DOC_TITLE", - "REV_NO", - "STAGE", - "FILE_NM", - "FILE_SZ", - "FLD_PATH", - "STAT_NM" - FROM swp.v_swp_documents_flat - WHERE "PROJ_NO" = ${projectNo} - AND "FILE_NM" ILIKE ${`%${fileNamePattern}%`} - ORDER BY "DOC_NO", "REV_NO" DESC - LIMIT 20 - `); - - console.log(`🔍 검색어: "${fileNamePattern}"`); - console.log(`📊 결과: ${result.rowCount}개\n`); - - result.rows.forEach((row: any) => { - console.log(`📄 ${row.DOC_NO} (${row.DOC_TITLE})`); - console.log(` REV ${row.REV_NO} (${row.STAGE})`); - console.log(` 📎 ${row.FILE_NM} (${row.FILE_SZ} bytes)`); - console.log(` 상태: ${row.STAT_NM}`); - console.log(); - }); - - return result.rows; -} - -// ============================================================================ -// 예제 5: 파일 다운로드 URL 생성 -// ============================================================================ - -export async function example5_FileDownload(revisionId: number) { - console.log("=== 예제 5: 파일 다운로드 ===\n"); - - // 1. 리비전의 파일 조회 - const files = await getRevisionFiles(revisionId); - - console.log(`📋 리비전 ID: ${revisionId}`); - console.log(`📎 파일: ${files.length}개\n`); - - // 2. 다운로드 URL 생성 - const fileUrls = await Promise.all( - files - .filter((file) => file.FLD_PATH && file.FILE_NM) - .map(async (file) => ({ - fileName: file.FILE_NM, - downloadUrl: await getSwpFileDownloadUrl({ - FLD_PATH: file.FLD_PATH!, - FILE_NM: file.FILE_NM, - }), - size: file.FILE_SZ, - })) - ); - - fileUrls.forEach((item) => { - console.log(`📎 ${item.fileName}`); - console.log(` URL: ${item.downloadUrl}`); - console.log(` 크기: ${item.size} bytes`); - console.log(); - }); - - return fileUrls; -} - -// ============================================================================ -// 예제 6: 동기화 상태 모니터링 -// ============================================================================ - -export async function example6_SyncMonitoring(projectNo: string) { - console.log("=== 예제 6: 동기화 상태 모니터링 ===\n"); - - // 1. 프로젝트 동기화 상태 조회 - const result = await getProjectSyncStatus(projectNo); - const status = result.rows[0] as any; - - console.log(`📊 프로젝트: ${status.proj_no} (${status.proj_nm})`); - console.log(`\n📈 통계:`); - console.log(` - 문서: ${status.total_documents}개`); - console.log(` - 리비전: ${status.total_revisions}개`); - console.log(` - 파일: ${status.total_files}개`); - - console.log(`\n✅ 동기화 상태:`); - console.log(` - 문서: ${status.docs_synced}개 완료`); - console.log(` - 대기: ${status.docs_pending}개`); - console.log(` - 오류: ${status.docs_error}개`); - - console.log(`\n🕐 마지막 동기화: ${status.last_sync_time}`); - - return status; -} - -// ============================================================================ -// 예제 7: 스테이지별 문서 통계 -// ============================================================================ - -export async function example7_StageStatistics(projectNo: string) { - console.log("=== 예제 7: 스테이지별 통계 ===\n"); - - const result = await db.execute(sql` - SELECT - "STAGE", - COUNT(DISTINCT "DOC_NO")::int as doc_count, - COUNT(DISTINCT "REV_NO")::int as rev_count, - COUNT(*)::int as file_count - FROM swp.v_swp_documents_flat - WHERE "PROJ_NO" = ${projectNo} - AND "STAGE" IS NOT NULL - GROUP BY "STAGE" - ORDER BY "STAGE" - `); - - console.log(`📊 프로젝트: ${projectNo}\n`); - - result.rows.forEach((row: any) => { - console.log(`📌 ${row.STAGE}`); - console.log(` 문서: ${row.doc_count}개`); - console.log(` 리비전: ${row.rev_count}개`); - console.log(` 파일: ${row.file_count}개`); - console.log(); - }); - - return result.rows; -} - -// ============================================================================ -// 예제 8: 증분 동기화 (변경된 항목만) -// ============================================================================ - -export async function example8_IncrementalSync(projectNo: string) { - console.log("=== 예제 8: 증분 동기화 ===\n"); - - // 1. 마지막 동기화 시간 확인 - const lastSyncResult = await db.execute(sql` - SELECT MAX(last_synced_at) as last_sync - FROM swp.swp_documents - WHERE "PROJ_NO" = ${projectNo} - `); - - const lastSync = lastSyncResult.rows[0] as any; - console.log(`🕐 마지막 동기화: ${lastSync.last_sync || "없음"}`); - - // 2. 전체 동기화 (API는 증분 제공 안하므로) - console.log(`📡 전체 데이터 조회 중...`); - const { documents, files } = await fetchSwpProjectData(projectNo, "V"); - - // 3. 동기화 (upsert로 변경된 항목만 업데이트됨) - console.log(`💾 동기화 시작...`); - const syncResult = await syncSwpProject(projectNo, documents, files); - - console.log(`\n📊 결과:`); - console.log( - ` - 신규 문서: ${syncResult.stats.documents.inserted}개 (기존: ${syncResult.stats.documents.updated}개)` - ); - console.log( - ` - 신규 리비전: ${syncResult.stats.revisions.inserted}개 (기존: ${syncResult.stats.revisions.updated}개)` - ); - console.log( - ` - 신규 파일: ${syncResult.stats.files.inserted}개 (기존: ${syncResult.stats.files.updated}개)` - ); - - return syncResult; -} - -// ============================================================================ -// 전체 시나리오 실행 -// ============================================================================ - -export async function runAllExamples(projectNo: string = "SN2190") { - console.log("╔═══════════════════════════════════════════╗"); - console.log("║ SWP 문서 관리 시스템 사용 예제 ║"); - console.log("╚═══════════════════════════════════════════╝\n"); - - try { - // 예제 1: 전체 동기화 - await example1_FullProjectSync(projectNo); - console.log("\n" + "=".repeat(50) + "\n"); - - // 예제 2: 계층 구조 조회 - await example2_HierarchyView(projectNo); - console.log("\n" + "=".repeat(50) + "\n"); - - // 예제 6: 동기화 상태 - await example6_SyncMonitoring(projectNo); - console.log("\n" + "=".repeat(50) + "\n"); - - // 예제 7: 스테이지별 통계 - await example7_StageStatistics(projectNo); - - console.log("\n✅ 모든 예제 실행 완료!"); - } catch (error) { - console.error("\n❌ 오류 발생:", error); - throw error; - } -} - diff --git a/lib/swp/sync-service.ts b/lib/swp/sync-service.ts deleted file mode 100644 index 787b28ae..00000000 --- a/lib/swp/sync-service.ts +++ /dev/null @@ -1,537 +0,0 @@ -"use server"; - -import db from "@/db/db"; -import { eq, and, sql } from "drizzle-orm"; -import { - swpDocuments, - swpDocumentRevisions, - swpDocumentFiles, - type SwpDocumentInsert, - type SwpDocumentRevisionInsert, - type SwpDocumentFileInsert, -} from "@/db/schema/SWP/swp-documents"; - -// ============================================================================ -// API 응답 타입 정의 -// ============================================================================ - -export interface SwpDocumentApiResponse { - // 필수 필드 - DOC_NO: string; - DOC_TITLE: string; - PROJ_NO: string; - CPY_CD: string; - CPY_NM: string; - PIC_NM: string; - PIC_DEPTNM: string; - SKL_CD: string; - CRTER: string; - CRTE_DTM: string; - CHGR: string; - CHG_DTM: string; - - // 선택적 필드 (null 가능) - DOC_GB: string | null; - DOC_TYPE: string | null; - OWN_DOC_NO: string | null; - SHI_DOC_NO: string | null; - PROJ_NM: string | null; - PKG_NO: string | null; - MAT_CD: string | null; - MAT_NM: string | null; - DISPLN: string | null; - CTGRY: string | null; - VNDR_CD: string | null; - PIC_DEPTCD: string | null; - LTST_REV_NO: string | null; - LTST_REV_SEQ: string | null; - LTST_ACTV_STAT: string | null; - STAGE: string | null; - MOD_TYPE: string | null; - ACT_TYPE_NM: string | null; - USE_YN: string | null; - REV_DTM: string | null; -} - -export interface SwpFileApiResponse { - // 필수 필드 - OWN_DOC_NO: string; - REV_NO: string; - STAGE: string; - FILE_NM: string; - FILE_SEQ: string; - CRTER: string; - CRTE_DTM: string; - CHGR: string; - CHG_DTM: string; - - // 선택적 필드 (null 가능) - FILE_SZ: string | null; - FLD_PATH: string | null; - ACTV_NO: string | null; - ACTV_SEQ: string | null; - BOX_SEQ: string | null; - OFDC_NO: string | null; - PROJ_NO: string | null; - PKG_NO: string | null; - VNDR_CD: string | null; - CPY_CD: string | null; - STAT: string | null; - STAT_NM: string | null; - IDX: string | null; -} - -// ============================================================================ -// 동기화 결과 타입 -// ============================================================================ - -export interface SyncResult { - success: boolean; - projectNo: string; - stats: { - documents: { - total: number; - inserted: number; - updated: number; - }; - revisions: { - total: number; - inserted: number; - updated: number; - }; - files: { - total: number; - inserted: number; - updated: number; - }; - }; - errors: string[]; - duration: number; -} - -// ============================================================================ -// 동기화 메인 함수 -// ============================================================================ - -export async function syncSwpProject( - projectNo: string, - documents: SwpDocumentApiResponse[], - files: SwpFileApiResponse[] -): Promise<SyncResult> { - const startTime = Date.now(); - const errors: string[] = []; - const stats = { - documents: { total: 0, inserted: 0, updated: 0 }, - revisions: { total: 0, inserted: 0, updated: 0 }, - files: { total: 0, inserted: 0, updated: 0 }, - }; - - try { - // 트랜잭션으로 일괄 처리 - await db.transaction(async (tx) => { - // 1. 문서 동기화 - console.log(`[SYNC] 문서 동기화 시작: ${documents.length}개`); - for (const doc of documents) { - try { - const result = await upsertDocument(tx, doc); - stats.documents.total++; - if (result.inserted) stats.documents.inserted++; - if (result.updated) stats.documents.updated++; - } catch (error) { - errors.push( - `문서 ${doc.DOC_NO} 동기화 실패: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - // 2. 리비전별로 파일 그룹핑 - const revisionMap = new Map<string, SwpFileApiResponse[]>(); - for (const file of files) { - const key = `${file.OWN_DOC_NO}|${file.REV_NO}`; - if (!revisionMap.has(key)) { - revisionMap.set(key, []); - } - revisionMap.get(key)!.push(file); - } - - // 3. 리비전 및 파일 동기화 - console.log(`[SYNC] 리비전 동기화 시작: ${revisionMap.size}개`); - for (const [key, revFiles] of revisionMap) { - const [docNo, revNo] = key.split("|"); - const firstFile = revFiles[0]; - - try { - // 리비전 생성/업데이트 - const revisionResult = await upsertRevision(tx, docNo, firstFile); - stats.revisions.total++; - if (revisionResult.inserted) stats.revisions.inserted++; - if (revisionResult.updated) stats.revisions.updated++; - - const revisionId = revisionResult.id; - - // 파일들 생성/업데이트 - for (const file of revFiles) { - try { - const fileResult = await upsertFile(tx, revisionId, docNo, file); - stats.files.total++; - if (fileResult.inserted) stats.files.inserted++; - if (fileResult.updated) stats.files.updated++; - } catch (error) { - errors.push( - `파일 ${file.FILE_NM} 동기화 실패: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - } catch (error) { - errors.push( - `리비전 ${docNo}-${revNo} 동기화 실패: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - console.log( - `[SYNC] 동기화 완료: 문서 ${stats.documents.total}, 리비전 ${stats.revisions.total}, 파일 ${stats.files.total}` - ); - }); - - return { - success: errors.length === 0, - projectNo, - stats, - errors, - duration: Date.now() - startTime, - }; - } catch (error) { - return { - success: false, - projectNo, - stats, - errors: [ - ...errors, - `트랜잭션 실패: ${error instanceof Error ? error.message : String(error)}`, - ], - duration: Date.now() - startTime, - }; - } -} - -// ============================================================================ -// Upsert 헬퍼 함수들 -// ============================================================================ - -async function upsertDocument( - tx: any, - doc: SwpDocumentApiResponse -): Promise<{ id: string; inserted: boolean; updated: boolean }> { - const data: SwpDocumentInsert = { - DOC_NO: doc.DOC_NO, - PROJ_NO: doc.PROJ_NO, - DOC_TITLE: doc.DOC_TITLE, - DOC_GB: doc.DOC_GB || null, - DOC_TYPE: doc.DOC_TYPE || null, - OWN_DOC_NO: doc.OWN_DOC_NO || null, - SHI_DOC_NO: doc.SHI_DOC_NO || null, - PROJ_NM: doc.PROJ_NM || null, - PKG_NO: doc.PKG_NO || null, - MAT_CD: doc.MAT_CD || null, - MAT_NM: doc.MAT_NM || null, - DISPLN: doc.DISPLN || null, - CTGRY: doc.CTGRY || null, - VNDR_CD: doc.VNDR_CD || null, - CPY_CD: doc.CPY_CD, - CPY_NM: doc.CPY_NM, - PIC_NM: doc.PIC_NM, - PIC_DEPTCD: doc.PIC_DEPTCD || null, - PIC_DEPTNM: doc.PIC_DEPTNM, - LTST_REV_NO: doc.LTST_REV_NO || null, - LTST_REV_SEQ: doc.LTST_REV_SEQ || null, - LTST_ACTV_STAT: doc.LTST_ACTV_STAT || null, - STAGE: doc.STAGE || null, - SKL_CD: doc.SKL_CD, - MOD_TYPE: doc.MOD_TYPE || null, - ACT_TYPE_NM: doc.ACT_TYPE_NM || null, - USE_YN: doc.USE_YN || null, - CRTER: doc.CRTER, - CRTE_DTM: doc.CRTE_DTM, - CHGR: doc.CHGR, - CHG_DTM: doc.CHG_DTM, - REV_DTM: doc.REV_DTM || null, - sync_status: "synced", - last_synced_at: new Date(), - updated_at: new Date(), - }; - - // 기존 문서 확인 (복합키: DOC_NO + PROJ_NO) - const existing = await tx - .select() - .from(swpDocuments) - .where( - and( - eq(swpDocuments.DOC_NO, doc.DOC_NO), - eq(swpDocuments.PROJ_NO, doc.PROJ_NO) - ) - ) - .limit(1); - - if (existing.length > 0) { - // 업데이트 - await tx - .update(swpDocuments) - .set(data) - .where( - and( - eq(swpDocuments.DOC_NO, doc.DOC_NO), - eq(swpDocuments.PROJ_NO, doc.PROJ_NO) - ) - ); - return { id: `${doc.DOC_NO}|${doc.PROJ_NO}`, inserted: false, updated: true }; - } else { - // 삽입 - await tx.insert(swpDocuments).values(data); - return { id: `${doc.DOC_NO}|${doc.PROJ_NO}`, inserted: true, updated: false }; - } -} - -async function upsertRevision( - tx: any, - docNo: string, - file: SwpFileApiResponse -): Promise<{ id: number; inserted: boolean; updated: boolean }> { - const data: Omit<SwpDocumentRevisionInsert, "id"> = { - DOC_NO: docNo, - REV_NO: file.REV_NO, - STAGE: file.STAGE, - ACTV_NO: file.ACTV_NO || null, - ACTV_SEQ: file.ACTV_SEQ || null, - BOX_SEQ: file.BOX_SEQ || null, - OFDC_NO: file.OFDC_NO || null, - PROJ_NO: file.PROJ_NO || null, - PKG_NO: file.PKG_NO || null, - VNDR_CD: file.VNDR_CD || null, - CPY_CD: file.CPY_CD || null, - sync_status: "synced", - last_synced_at: new Date(), - updated_at: new Date(), - }; - - // 기존 리비전 확인 - const existing = await tx - .select() - .from(swpDocumentRevisions) - .where( - and( - eq(swpDocumentRevisions.DOC_NO, docNo), - eq(swpDocumentRevisions.REV_NO, file.REV_NO) - ) - ) - .limit(1); - - if (existing.length > 0) { - // 업데이트 - await tx - .update(swpDocumentRevisions) - .set(data) - .where(eq(swpDocumentRevisions.id, existing[0].id)); - return { id: existing[0].id, inserted: false, updated: true }; - } else { - // 삽입 - const result = await tx - .insert(swpDocumentRevisions) - .values(data) - .returning({ id: swpDocumentRevisions.id }); - return { id: result[0].id, inserted: true, updated: false }; - } -} - -async function upsertFile( - tx: any, - revisionId: number, - docNo: string, - file: SwpFileApiResponse -): Promise<{ id: number; inserted: boolean; updated: boolean }> { - const data: Omit<SwpDocumentFileInsert, "id"> = { - revision_id: revisionId, - DOC_NO: docNo, - FILE_NM: file.FILE_NM, - FILE_SEQ: file.FILE_SEQ, - FILE_SZ: file.FILE_SZ || null, - FLD_PATH: file.FLD_PATH || null, - STAT: file.STAT || null, - STAT_NM: file.STAT_NM || null, - IDX: file.IDX || null, - ACTV_NO: file.ACTV_NO || null, - CRTER: file.CRTER, - CRTE_DTM: file.CRTE_DTM, - CHGR: file.CHGR, - CHG_DTM: file.CHG_DTM, - sync_status: "synced", - last_synced_at: new Date(), - updated_at: new Date(), - }; - - // 기존 파일 확인 (revision + fileSeq로 unique) - const existing = await tx - .select() - .from(swpDocumentFiles) - .where( - and( - eq(swpDocumentFiles.revision_id, revisionId), - eq(swpDocumentFiles.FILE_SEQ, file.FILE_SEQ) - ) - ) - .limit(1); - - if (existing.length > 0) { - // 업데이트 - await tx - .update(swpDocumentFiles) - .set(data) - .where(eq(swpDocumentFiles.id, existing[0].id)); - return { id: existing[0].id, inserted: false, updated: true }; - } else { - // 삽입 - const result = await tx - .insert(swpDocumentFiles) - .values(data) - .returning({ id: swpDocumentFiles.id }); - return { id: result[0].id, inserted: true, updated: false }; - } -} - -// ============================================================================ -// 조회 헬퍼 함수들 -// ============================================================================ - -/** - * 프로젝트의 문서 계층 구조 조회 (복잡한 JSON 집계는 SQL 직접 실행) - */ -export async function getProjectDocumentsHierarchy(projectNo: string) { - return db.execute(sql` - SELECT - d."DOC_NO", - d."DOC_TITLE", - d."PROJ_NO", - d."PROJ_NM", - d."PKG_NO", - d."VNDR_CD", - d."CPY_NM", - d."MAT_NM", - d."LTST_REV_NO", - d."LTST_ACTV_STAT", - d.sync_status, - d.last_synced_at, - - COALESCE( - json_agg( - json_build_object( - 'id', r.id, - 'revNo', r."REV_NO", - 'stage', r."STAGE", - 'actvNo', r."ACTV_NO", - 'ofdcNo', r."OFDC_NO", - 'syncStatus', r.sync_status, - 'fileCount', ( - SELECT COUNT(*)::int - FROM swp.swp_document_files f2 - WHERE f2.revision_id = r.id - ), - 'files', ( - SELECT COALESCE(json_agg( - json_build_object( - 'id', f.id, - 'fileNm', f."FILE_NM", - 'fileSeq', f."FILE_SEQ", - 'fileSz', f."FILE_SZ", - 'fldPath', f."FLD_PATH", - 'stat', f."STAT", - 'statNm', f."STAT_NM", - 'syncStatus', f.sync_status, - 'createdAt', f.created_at - ) - ORDER BY f."FILE_SEQ" - ), '[]'::json) - FROM swp.swp_document_files f - WHERE f.revision_id = r.id - ) - ) - ORDER BY r."REV_NO" DESC - ) FILTER (WHERE r.id IS NOT NULL), - '[]'::json - ) as revisions, - - COUNT(DISTINCT r.id)::int as revision_count, - COUNT(f.id)::int as total_file_count - - FROM swp.swp_documents d - LEFT JOIN swp.swp_document_revisions r ON d."DOC_NO" = r."DOC_NO" - LEFT JOIN swp.swp_document_files f ON r.id = f.revision_id - WHERE d."PROJ_NO" = ${projectNo} - GROUP BY - d."DOC_NO", - d."DOC_TITLE", - d."PROJ_NO", - d."PROJ_NM", - d."PKG_NO", - d."VNDR_CD", - d."CPY_NM", - d."MAT_NM", - d."LTST_REV_NO", - d."LTST_ACTV_STAT", - d.sync_status, - d.last_synced_at - ORDER BY d."DOC_NO" - `); -} - -/** - * 특정 문서의 모든 리비전 조회 - */ -export async function getDocumentRevisions(docNo: string) { - return db - .select() - .from(swpDocumentRevisions) - .where(eq(swpDocumentRevisions.DOC_NO, docNo)) - .orderBy(sql`${swpDocumentRevisions.REV_NO} DESC`); -} - -/** - * 특정 리비전의 모든 파일 조회 - */ -export async function getRevisionFiles(revisionId: number) { - return db - .select() - .from(swpDocumentFiles) - .where(eq(swpDocumentFiles.revision_id, revisionId)) - .orderBy(swpDocumentFiles.FILE_SEQ); -} - -/** - * 프로젝트 동기화 상태 조회 - */ -export async function getProjectSyncStatus(projectNo: string) { - return db.execute(sql` - SELECT - d."PROJ_NO", - d."PROJ_NM", - - COUNT(DISTINCT d."DOC_NO")::int as total_documents, - COUNT(DISTINCT r.id)::int as total_revisions, - COUNT(f.id)::int as total_files, - - COUNT(DISTINCT d."DOC_NO") FILTER (WHERE d.sync_status = 'synced')::int as docs_synced, - COUNT(DISTINCT d."DOC_NO") FILTER (WHERE d.sync_status = 'pending')::int as docs_pending, - COUNT(DISTINCT d."DOC_NO") FILTER (WHERE d.sync_status = 'error')::int as docs_error, - - COUNT(DISTINCT r.id) FILTER (WHERE r.sync_status = 'synced')::int as revs_synced, - COUNT(f.id) FILTER (WHERE f.sync_status = 'synced')::int as files_synced, - - MAX(d.last_synced_at) as last_sync_time - - FROM swp.swp_documents d - LEFT JOIN swp.swp_document_revisions r ON d."DOC_NO" = r."DOC_NO" - LEFT JOIN swp.swp_document_files f ON r.id = f.revision_id - WHERE d."PROJ_NO" = ${projectNo} - GROUP BY d."PROJ_NO", d."PROJ_NM" - `); -} - diff --git a/lib/swp/table/swp-uploaded-files-dialog.tsx b/lib/swp/table/swp-uploaded-files-dialog.tsx index 25a798b6..14d69df4 100644 --- a/lib/swp/table/swp-uploaded-files-dialog.tsx +++ b/lib/swp/table/swp-uploaded-files-dialog.tsx @@ -14,12 +14,12 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Badge } from "@/components/ui/badge"; import { useToast } from "@/hooks/use-toast"; import { FileText, ChevronRight, ChevronDown, X, Loader2, RefreshCw } from "lucide-react"; -import { fetchVendorUploadedFiles, cancelVendorUploadedFile } from "../actions"; +import { fetchVendorUploadedFiles, cancelVendorUploadedFile } from "../vendor-actions"; import type { SwpFileApiResponse } from "../api-client"; interface SwpUploadedFilesDialogProps { projNo: string; - vndrCd: string; + vndrCd: string; // UI 표시용으로만 사용 userId: string; } @@ -87,18 +87,19 @@ export function SwpUploadedFilesDialog({ projNo, vndrCd, userId }: SwpUploadedFi // 파일 목록 조회 const loadFiles = () => { - if (!projNo || !vndrCd) { + if (!projNo) { toast({ variant: "destructive", title: "조회 불가", - description: "프로젝트와 업체 정보가 필요합니다.", + description: "프로젝트 정보가 필요합니다.", }); return; } startLoading(async () => { try { - const result = await fetchVendorUploadedFiles(projNo, vndrCd); + // vndrCd는 서버에서 세션으로 자동 조회 + const result = await fetchVendorUploadedFiles(projNo); setFiles(result); toast({ title: "조회 완료", @@ -189,7 +190,7 @@ export function SwpUploadedFilesDialog({ projNo, vndrCd, userId }: SwpUploadedFi return ( <Dialog open={open} onOpenChange={handleOpenChange}> <DialogTrigger asChild> - <Button variant="outline" size="sm" disabled={!projNo || !vndrCd}> + <Button variant="outline" size="sm" disabled={!projNo}> <FileText className="h-4 w-4 mr-2" /> 업로드 파일 관리 </Button> diff --git a/lib/swp/vendor-actions.ts b/lib/swp/vendor-actions.ts index f65ed007..f87c41a8 100644 --- a/lib/swp/vendor-actions.ts +++ b/lib/swp/vendor-actions.ts @@ -1,5 +1,17 @@ "use server"; +/** + * SWP Vendor Actions + * + * 벤더 페이지(제출)에서 사용하는 서버 액션 모음입니다. + * - 다운로드 및 업로드는 서버액션의 데이터 직렬화 문제로, 별도의 API Route로 분리함 + * - 간단한 API 호출은 서버 액션으로 관리 + * + * 1. 파일 메타정보 업로드 + * 2. 파일 업로드 취소 + * + */ + import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import db from "@/db/db"; @@ -145,7 +157,7 @@ export async function fetchVendorDocuments(projNo?: string): Promise<DocumentLis } catch (error) { debugError("문서 목록 조회 실패", error); console.error("[fetchVendorDocuments] 오류:", error); - throw new Error("문서 목록 조회 실패 [담당자에게 문의하세요]"); + throw new Error("문서 목록 조회 실패 [SWP 담당자에게 문의하세요]"); } } @@ -334,6 +346,92 @@ export async function fetchVendorSwpStats(projNo?: string) { } // ============================================================================ +// 벤더가 업로드한 파일 목록 조회 (Inbox) +// ============================================================================ + +export async function fetchVendorUploadedFiles(projNo: string) { + debugProcess("벤더 업로드 파일 목록 조회 시작", { projNo }); + + try { + const vendorInfo = await getVendorSessionInfo(); + + if (!vendorInfo) { + debugError("벤더 정보 없음 - 업로드 파일 조회 실패"); + throw new Error("벤더 정보를 찾을 수 없습니다."); + } + + if (!projNo) { + debugWarn("프로젝트 번호 없음"); + return []; + } + + debugLog("업로드 파일 목록 조회 시작", { + projNo, + vendorCode: vendorInfo.vendorCode + }); + + // api-client의 fetchGetExternalInboxList 사용 + const { fetchGetExternalInboxList } = await import("./api-client"); + const files = await fetchGetExternalInboxList({ + projNo, + vndrCd: vendorInfo.vendorCode, + }); + + debugSuccess("업로드 파일 목록 조회 성공", { count: files.length }); + return files; + } catch (error) { + debugError("업로드 파일 목록 조회 실패", error); + console.error("[fetchVendorUploadedFiles] 오류:", error); + throw new Error("업로드 파일 목록 조회 실패"); + } +} + +// ============================================================================ +// 벤더가 업로드한 파일 취소 (userId 파라미터 버전) +// ============================================================================ + +export interface CancelVendorUploadedFileParams { + boxSeq: string; + actvSeq: string; + userId: string; +} + +export async function cancelVendorUploadedFile(params: CancelVendorUploadedFileParams) { + debugProcess("벤더 업로드 파일 취소 시작", params); + + try { + const vendorInfo = await getVendorSessionInfo(); + + if (!vendorInfo) { + debugError("벤더 정보 없음"); + throw new Error("벤더 정보를 찾을 수 없습니다."); + } + + // api-client의 callSaveInBoxListCancelStatus 사용 + const { callSaveInBoxListCancelStatus } = await import("./api-client"); + const cancelCount = await callSaveInBoxListCancelStatus({ + boxSeq: params.boxSeq, + actvSeq: params.actvSeq, + chgr: `evcp${params.userId}`, + }); + + debugSuccess("업로드 파일 취소 완료", { + ...params, + cancelCount + }); + + return { + success: true, + cancelCount + }; + } catch (error) { + debugError("업로드 파일 취소 실패", error); + console.error("[cancelVendorUploadedFile] 오류:", error); + throw new Error("파일 취소 실패"); + } +} + +// ============================================================================ // 주의: 파일 업로드는 /api/swp/upload 라우트에서 처리됩니다 // ============================================================================ diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx index 8a7dcbc4..6c8fa797 100644 --- a/lib/vendor-document-list/plant/document-stage-dialogs.tsx +++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx @@ -92,7 +92,6 @@ const getStatusText = (status: string) => { // Form validation schema const documentFormSchema = z.object({ documentClassId: z.string().min(1, "Document class is required"), - docClass: z.string().min(1, "Document class is required"), title: z.string().min(1, "Document title is required"), shiFieldValues: z.record(z.string()).optional(), cpyFieldValues: z.record(z.string()).optional(), @@ -133,7 +132,6 @@ export function AddDocumentDialog({ resolver: zodResolver(documentFormSchema), defaultValues: { documentClassId: '', - docClass: '', title: '', shiFieldValues: {}, cpyFieldValues: {}, @@ -376,11 +374,15 @@ export function AddDocumentDialog({ const shiDocNumber = shiType ? generateShiPreview() : '' const cpyDocNumber = cpyType ? generateCpyPreview() : '' + // 선택된 Document Class의 code 값을 가져오기 (SWP API의 DOC_CLASS로 사용) + const selectedDocClass = documentClasses.find(cls => String(cls.id) === data.documentClassId) + const docClassCode = selectedDocClass?.code || '' + try { const submitData = { contractId, documentClassId: Number(data.documentClassId), - docClass: data.docClass, + docClass: docClassCode, // 첫 번째 선택기의 code 값 사용 (A, B, C 등) title: data.title, docNumber: shiDocNumber, vendorDocNumber: cpyDocNumber, @@ -618,34 +620,6 @@ export function AddDocumentDialog({ )} </div> - {/* Document Class Selection (B3, B4, B5) */} - <div className="space-y-2"> - <Label htmlFor="docClass"> - Document Class <span className="text-red-500">*</span> - </Label> - <Controller - name="docClass" - control={form.control} - render={({ field }) => ( - <Select value={field.value} onValueChange={field.onChange}> - <SelectTrigger> - <SelectValue placeholder="Select document class (B3, B4, B5)" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="B3">B3 - Basic Engineering Document</SelectItem> - <SelectItem value="B4">B4 - Detail Engineering Document</SelectItem> - <SelectItem value="B5">B5 - Vendor Document</SelectItem> - </SelectContent> - </Select> - )} - /> - {form.formState.errors.docClass && ( - <p className="text-xs text-red-500"> - {form.formState.errors.docClass.message} - </p> - )} - </div> - {/* Document Class Options with Plan Dates */} {documentClassOptions.length > 0 && ( <Card> diff --git a/lib/vendor-document-list/plant/document-stage-toolbar.tsx b/lib/vendor-document-list/plant/document-stage-toolbar.tsx index 51767528..4a0b32c8 100644 --- a/lib/vendor-document-list/plant/document-stage-toolbar.tsx +++ b/lib/vendor-document-list/plant/document-stage-toolbar.tsx @@ -56,14 +56,24 @@ export function DocumentsTableToolbarActions({ }) async function handleSendToSHI() { + // 선택된 문서 가져오기 + const selectedRows = table.getFilteredSelectedRowModel().rows + const selectedDocumentIds = selectedRows.map(row => row.original.documentId) + + if (selectedDocumentIds.length === 0) { + toast.error("전송할 문서를 선택해주세요.") + return + } + setIsSending(true) try { - const result = await sendDocumentsToSHI(contractId) + const result = await sendDocumentsToSHI(contractId, selectedDocumentIds) if (result.success) { toast.success(result.message) + // 선택 해제 + table.toggleAllRowsSelected(false) router.refresh() - // 테이블 새로고침 } else { toast.error(result.message) } diff --git a/lib/vendor-document-list/plant/document-stages-columns.tsx b/lib/vendor-document-list/plant/document-stages-columns.tsx index f9cde264..c74f2d71 100644 --- a/lib/vendor-document-list/plant/document-stages-columns.tsx +++ b/lib/vendor-document-list/plant/document-stages-columns.tsx @@ -357,10 +357,11 @@ export function getDocumentStagesColumns({ ), cell: ({ row }) => { const doc = row.original + const displayStatus = doc.buyerSystemStatus || 'Before Sync' return ( <div className="flex items-center gap-2"> - {getStatusText(doc.buyerSystemStatus || '')} + {displayStatus} </div> ) }, diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts index ae9ea314..47bc6ff8 100644 --- a/lib/vendor-document-list/plant/document-stages-service.ts +++ b/lib/vendor-document-list/plant/document-stages-service.ts @@ -1165,10 +1165,10 @@ export async function getDocumentsByStageStats(contractId: number) { } // Send to SHI 버튼 핸들러가 이 함수 호출 -export async function sendDocumentsToSHI(contractId: number) { +export async function sendDocumentsToSHI(contractId: number, selectedDocumentIds?: number[]) { try { const api = new ShiBuyerSystemAPI() - const result = await api.sendToSHI(contractId) + const result = await api.sendToSHI(contractId, selectedDocumentIds) // 캐시 무효화 revalidatePath(`/partners/document-list-only/${contractId}`) diff --git a/lib/vendor-document-list/plant/document-stages-table.tsx b/lib/vendor-document-list/plant/document-stages-table.tsx index 63f0eae6..cd23db2d 100644 --- a/lib/vendor-document-list/plant/document-stages-table.tsx +++ b/lib/vendor-document-list/plant/document-stages-table.tsx @@ -11,8 +11,8 @@ import { useDataTable } from "@/hooks/use-data-table" import { getDocumentStagesOnly } from "./document-stages-service" import type { StageDocumentsView } from "@/db/schema" 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 { cn } from "@/lib/utils" import { FileText, Send, @@ -62,7 +62,7 @@ export function DocumentStagesTable({ // 상태 관리 const [rowAction, setRowAction] = React.useState<DataTableRowAction<StageDocumentsView> | null>(null) const [expandedRows, setExpandedRows] = React.useState<Set<string>>(new Set()) - const [quickFilter, setQuickFilter] = React.useState<'all' | 'submitted' | 'under_review' | 'approved' | 'rejected'>('all') + const [quickFilter, setQuickFilter] = React.useState<string>('all') // 다이얼로그 상태들 const [addDocumentOpen, setAddDocumentOpen] = React.useState(false) @@ -115,44 +115,88 @@ export function DocumentStagesTable({ [expandedRows, projectType, currentDomain] ) - // 문서 상태별 통계 계산 + // 문서 상태별 통계 계산 (동적) - buyerSystemStatus 기준 const stats = React.useMemo(() => { const totalDocs = data?.length || 0 - const submitted = data?.filter(doc => doc.status === 'SUBMITTED')?.length || 0 - const underReview = data?.filter(doc => doc.status === 'UNDER_REVIEW')?.length || 0 - const approved = data?.filter(doc => doc.status === 'APPROVED')?.length || 0 - const rejected = data?.filter(doc => doc.status === 'REJECTED')?.length || 0 - const notSubmitted = data?.filter(doc => - !doc.status || !['SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED'].includes(doc.status) - )?.length || 0 + + // 모든 고유한 buyerSystemStatus 값 추출 + const statusCounts = new Map<string, number>() + data?.forEach(doc => { + // buyerSystemStatus가 null이면 'beforeSync'로 처리 + const status = doc.buyerSystemStatus || 'beforeSync' + statusCounts.set(status, (statusCounts.get(status) || 0) + 1) + }) return { total: totalDocs, - submitted, - underReview, - approved, - rejected, - notSubmitted, - approvalRate: totalDocs > 0 - ? Math.round((approved / totalDocs) * 100) - : 0 + statusCounts, } }, [data]) - // 빠른 필터링 + // 상태별 메타 정보 (아이콘, 색상, 레이블) - buyerSystemStatus 기준 + const getStatusMeta = (status: string) => { + const metaMap: Record<string, { + icon: React.ElementType + color: string + textColor: string + label: string + description: string + }> = { + 'beforeSync': { + icon: FileText, + color: 'border-gray-200 dark:border-gray-800', + textColor: 'text-gray-600 dark:text-gray-400', + label: 'Before Sync', + description: '동기화 전' + }, + '생성요청': { + icon: Send, + color: 'border-blue-200 dark:border-blue-800', + textColor: 'text-blue-600 dark:text-blue-400', + label: '생성요청', + description: 'SHI에 생성 요청됨' + }, + '검토중': { + icon: Search, + color: 'border-orange-200 dark:border-orange-800', + textColor: 'text-orange-600 dark:text-orange-400', + label: '검토중', + description: 'SHI 검토 진행중' + }, + '승인(DC)': { + icon: CheckCircle2, + color: 'border-green-200 dark:border-green-800', + textColor: 'text-green-600 dark:text-green-400', + label: '승인(DC)', + description: 'SHI 승인 완료' + }, + '반려': { + icon: XCircle, + color: 'border-red-200 dark:border-red-800', + textColor: 'text-red-600 dark:text-red-400', + label: '반려', + description: '재작업 필요' + } + } + + return metaMap[status] || { + icon: FileText, + color: 'border-gray-200 dark:border-gray-800', + textColor: 'text-gray-600 dark:text-gray-400', + label: status, + description: status + } + } + + // 빠른 필터링 - buyerSystemStatus 기준 const filteredData = React.useMemo(() => { - switch (quickFilter) { - case 'submitted': - return data.filter(doc => doc.status === 'SUBMITTED') - case 'under_review': - return data.filter(doc => doc.status === 'UNDER_REVIEW') - case 'approved': - return data.filter(doc => doc.status === 'APPROVED') - case 'rejected': - return data.filter(doc => doc.status === 'REJECTED') - default: - return data + if (quickFilter === 'all') { + return data } + return data.filter(doc => { + const status = doc.buyerSystemStatus || 'beforeSync' + return status === quickFilter + }) }, [data, quickFilter]) // 핸들러 함수들 @@ -231,112 +275,60 @@ export function DocumentStagesTable({ 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')}> + {/* 전체 문서 카드 (항상 첫 번째) */} + <Card + className={cn( + "cursor-pointer transition-all", + quickFilter === 'all' + ? "shadow-lg border-primary ring-2 ring-primary ring-offset-2" + : "hover:shadow-md" + )} + onClick={() => setQuickFilter('all')} + > <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardTitle className="text-sm font-medium">Total Documents</CardTitle> <FileText 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"> - 전체 등록 문서 - </p> - </CardContent> - </Card> - - {/* 제출됨 */} - <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('submitted')}> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">Submitted</CardTitle> - <Send className="h-4 w-4 text-blue-500" /> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold text-blue-600 dark:text-blue-400">{stats.submitted}</div> - <p className="text-xs text-muted-foreground">제출 대기중</p> - </CardContent> - </Card> - - {/* 검토중 */} - {/* <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('under_review')}> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">Under Review</CardTitle> - <Search className="h-4 w-4 text-orange-500" /> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold text-orange-600 dark:text-orange-400">{stats.underReview}</div> - <p className="text-xs text-muted-foreground">검토 진행중</p> - </CardContent> - </Card> */} - - {/* 승인됨 */} - <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('approved')}> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">Approved</CardTitle> - <CheckCircle2 className="h-4 w-4 text-green-500" /> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold text-green-600 dark:text-green-400">{stats.approved}</div> - <p className="text-xs text-muted-foreground">승인 완료 ({stats.approvalRate}%)</p> + <p className="text-xs text-muted-foreground">전체 등록 문서</p> </CardContent> </Card> - <Card className="cursor-pointer hover:shadow-md transition-shadow border-red-200 dark:border-red-800" - onClick={() => setQuickFilter('rejected')}> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">Rejected</CardTitle> - <XCircle className="h-4 w-4 text-red-500" /> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold text-red-600 dark:text-red-400">{stats.rejected}</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 === 'submitted' ? 'default' : 'outline'} - className="cursor-pointer hover:bg-blue-500 hover:text-white dark:hover:bg-blue-600 whitespace-nowrap" - onClick={() => setQuickFilter('submitted')} - > - <Send className="w-3 h-3 mr-1" /> - 제출됨 ({stats.submitted}) - </Badge> - <Badge - variant={quickFilter === 'under_review' ? 'default' : 'outline'} - className="cursor-pointer hover:bg-orange-500 hover:text-white dark:hover:bg-orange-600 whitespace-nowrap" - onClick={() => setQuickFilter('under_review')} - > - <Search className="w-3 h-3 mr-1" /> - 검토중 ({stats.underReview}) - </Badge> - <Badge - variant={quickFilter === 'approved' ? 'success' : 'outline'} - className="cursor-pointer hover:bg-green-500 hover:text-white dark:hover:bg-green-600 whitespace-nowrap" - onClick={() => setQuickFilter('approved')} - > - <CheckCircle2 className="w-3 h-3 mr-1" /> - 승인됨 ({stats.approved}) - </Badge> - <Badge - variant={quickFilter === 'rejected' ? 'destructive' : 'outline'} - className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap" - onClick={() => setQuickFilter('rejected')} - > - <XCircle className="w-3 h-3 mr-1" /> - 반려됨 ({stats.rejected}) - </Badge> + {/* 동적으로 생성된 상태별 카드 */} + {Array.from(stats.statusCounts.entries()) + .sort(([a], [b]) => a.localeCompare(b)) // 알파벳 순 정렬 + .map(([status, count]) => { + const meta = getStatusMeta(status) + const IconComponent = meta.icon + const isSelected = quickFilter === status + + return ( + <Card + key={status} + className={cn( + "cursor-pointer transition-all", + meta.color, + isSelected + ? "shadow-lg ring-2 ring-offset-2" + : "hover:shadow-md" + )} + onClick={() => setQuickFilter(status)} + > + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">{meta.label}</CardTitle> + <IconComponent className={cn("h-4 w-4", meta.textColor)} /> + </CardHeader> + <CardContent> + <div className={cn("text-2xl font-bold", meta.textColor)}>{count}</div> + <p className="text-xs text-muted-foreground">{meta.description}</p> + </CardContent> + </Card> + ) + }) + } </div> {/* 메인 테이블 */} diff --git a/lib/vendor-document-list/plant/excel-import-stage.tsx b/lib/vendor-document-list/plant/excel-import-stage.tsx index 8dc85c51..53e12eeb 100644 --- a/lib/vendor-document-list/plant/excel-import-stage.tsx +++ b/lib/vendor-document-list/plant/excel-import-stage.tsx @@ -326,10 +326,10 @@ function FileFormatGuide({ projectType }: { projectType: "ship" | "plant" }) { <strong>통합 Documents 시트:</strong> </p> <ul className="ml-4 list-disc"> - <li>Document Number* (문서번호)</li> + <li>SHI Document Number* (SHI 문서번호)</li> <li>Document Name* (문서명)</li> <li>Document Class* (문서클래스 - 드롭다운 선택)</li> - <li>Project Doc No.* (프로젝트 문서번호)</li> + <li>Company / Owner Document Number (회사/소유주 문서번호)</li> <li>각 Stage Name 컬럼 (계획날짜 입력: YYYY-MM-DD)</li> </ul> <p className="mt-2 text-green-600 dark:text-green-400"> @@ -440,10 +440,10 @@ async function createImportTemplate(projectType: "ship" | "plant", contractId: n const worksheet = workbook.addWorksheet("Documents") const headers = [ - "Document Number*", + "SHI Document Number*", "Document Name*", "Document Class*", - ...(projectType === "plant" ? ["Project Doc No.*"] : []), + ...(projectType === "plant" ? ["Company / Owner Document Number"] : []), ...allStageNames, ] const headerRow = worksheet.addRow(headers) @@ -466,7 +466,7 @@ async function createImportTemplate(projectType: "ship" | "plant", contractId: n ] worksheet.addRow(sampleRow) - const docNumberColIndex = 1; // A: Document Number* + const docNumberColIndex = 1; // A: SHI Document Number* const docNameColIndex = 2; // B: Document Name* const docNumberColLetter = getExcelColumnName(docNumberColIndex); const docNameColLetter = getExcelColumnName(docNameColIndex); @@ -569,7 +569,7 @@ worksheet.addConditionalFormatting({ ], }); -// ===== Project Doc No.* (Plant 전용): (이미 작성하신 코드 유지) ===== +// ===== Company / Owner Document Number (Plant 전용): (이미 작성하신 코드 유지) ===== if (projectType === "plant") { const vendorDocColIndex = 4; // D const vendorDocColLetter = getExcelColumnName(vendorDocColIndex); @@ -581,7 +581,7 @@ if (projectType === "plant") { allowBlank: false, showErrorMessage: true, errorTitle: "필수 입력", - error: "Project Doc No.는 필수 항목입니다.", + error: "Company / Owner Document Number는 필수 항목입니다.", }); worksheet.addConditionalFormatting({ @@ -608,7 +608,7 @@ if (projectType === "plant") { } if (projectType === "plant") { - const vendorDocColIndex = 4; // Document Number, Name, Class 다음이 Project Doc No.* + const vendorDocColIndex = 4; // Document Number, Name, Class 다음이 Company / Owner Document Number const vendorDocColLetter = getExcelColumnName(vendorDocColIndex); // 공백 불가: 글자수 > 0 @@ -619,7 +619,7 @@ if (projectType === "plant") { allowBlank: false, showErrorMessage: true, errorTitle: "필수 입력", - error: "Project Doc No.는 필수 항목입니다.", + error: "Company / Owner Document Number는 필수 항목입니다.", }); // UX: 비어있으면 빨간 배경으로 표시 (조건부 서식) @@ -673,10 +673,10 @@ if (projectType === "plant") { ["📋 통합 문서 임포트 가이드"], [""], ["1. 하나의 시트에서 모든 정보 관리"], - [" • Document Number*: 고유한 문서 번호"], + [" • SHI Document Number*: 고유한 문서 번호"], [" • Document Name*: 문서명"], [" • Document Class*: 드롭다운에서 선택"], - ...(projectType === "plant" ? [[" • Project Doc No.: 벤더 문서 번호"]] : []), + ...(projectType === "plant" ? [[" • Company / Owner Document Number: 벤더 문서 번호"]] : []), [" • Stage 컬럼들: 각 스테이지의 계획 날짜 (YYYY-MM-DD)"], [""], ["2. 스마트 검증 기능"], @@ -820,7 +820,7 @@ async function parseDocumentsWithStages( const docNumberIdx = headers.findIndex((h) => h.includes("Document Number")) const docNameIdx = headers.findIndex((h) => h.includes("Document Name")) const docClassIdx = headers.findIndex((h) => h.includes("Document Class")) - const vendorDocNoIdx = projectType === "plant" ? headers.findIndex((h) => h.includes("Project Doc No")) : -1 + const vendorDocNoIdx = projectType === "plant" ? headers.findIndex((h) => h.includes("Company / Owner Document Number")) : -1 if (docNumberIdx === -1 || docNameIdx === -1 || docClassIdx === -1) { errors.push("필수 헤더가 누락되었습니다") @@ -852,7 +852,7 @@ async function parseDocumentsWithStages( return } if (projectType === "plant" && !vendorDocNo) { - errors.push(`행 ${rowNumber}: Project Doc No.가 없습니다`) + errors.push(`행 ${rowNumber}: Company / Owner Document Number가 없습니다`) return } if (seenDocNumbers.has(docNumber)) { diff --git a/lib/vendor-document-list/plant/shi-buyer-system-api.ts b/lib/vendor-document-list/plant/shi-buyer-system-api.ts index b23bd269..21f28fac 100644 --- a/lib/vendor-document-list/plant/shi-buyer-system-api.ts +++ b/lib/vendor-document-list/plant/shi-buyer-system-api.ts @@ -281,10 +281,10 @@ export class ShiBuyerSystemAPI { return `\\\\60.100.91.61\\SBox\\${projNo}\\${cpyCode}\\${timestamp}\\${fileName}`; } - async sendToSHI(contractId: number) { + async sendToSHI(contractId: number, selectedDocumentIds?: number[]) { try { // 1. 전송할 문서 조회 - const documents = await this.getDocumentsToSend(contractId) + const documents = await this.getDocumentsToSend(contractId, selectedDocumentIds) if (documents.length === 0) { return { success: false, message: "전송할 문서가 없습니다." } @@ -317,8 +317,24 @@ export class ShiBuyerSystemAPI { } } - private async getDocumentsToSend(contractId: number): Promise<DocumentWithStages[]> { - // 1. 먼저 문서 목록을 가져옴 + private async getDocumentsToSend(contractId: number, selectedDocumentIds?: number[]): Promise<DocumentWithStages[]> { + // 1. 기본 WHERE 조건 구성 + const whereConditions = [ + eq(stageDocuments.contractId, contractId), + eq(stageDocuments.status, 'ACTIVE'), + // 승인되지 않은 문서만 (null이거나 "승인(DC)"가 아닌 것) + or( + isNull(stageDocuments.buyerSystemStatus), + ne(stageDocuments.buyerSystemStatus, "승인(DC)") + ) + ] + + // 2. 선택된 문서 ID가 있으면 추가 필터링 + if (selectedDocumentIds && selectedDocumentIds.length > 0) { + whereConditions.push(inArray(stageDocuments.id, selectedDocumentIds)) + } + + // 3. 문서 목록을 가져옴 const documents = await db .select({ documentId: stageDocuments.id, @@ -331,19 +347,10 @@ export class ShiBuyerSystemAPI { projectCode: sql<string>`(SELECT code FROM projects WHERE id = ${stageDocuments.projectId})`, vendorCode: sql<string>`(SELECT vendor_code FROM vendors WHERE id = ${stageDocuments.vendorId})`, vendorName: sql<string>`(SELECT vendor_name FROM vendors WHERE id = ${stageDocuments.vendorId})`, + updatedAt: stageDocuments.updatedAt, }) .from(stageDocuments) - .where( - and( - eq(stageDocuments.contractId, contractId), - eq(stageDocuments.status, 'ACTIVE'), - // ne는 null을 포함하지 않음 - or( - isNull(stageDocuments.buyerSystemStatus), - ne(stageDocuments.buyerSystemStatus, "승인(DC)") - ) - ) - ) + .where(and(...whereConditions)) // 2. 각 문서에 대해 스테이지 정보를 별도로 조회 const documentsWithStages = await Promise.all( |
