From 04bd1965c3699a4b29ed9c9627574bfeedd3d6c6 Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Thu, 23 Oct 2025 18:44:19 +0900
Subject: (김준회) SWP 문서 업로드 (Submisssion) 초기 개발건
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../evcp/(evcp)/(eng)/swp-document-upload/page.tsx | 58 +++
.../swp-document-upload/swp-document-page.tsx | 231 +++++++++
.../(partners)/swp-document-upload/page.tsx | 57 +++
.../swp-document-upload/vendor-document-page.tsx | 230 +++++++++
components/login/login-form-shi.tsx | 41 +-
db/schema/SWP/swp-documents.ts | 219 +++++++++
db/schema/index.ts | 5 +-
lib/swp/actions.ts | 293 ++++++++++++
lib/swp/api-client.ts | 304 ++++++++++++
lib/swp/example-usage.ts | 347 ++++++++++++++
lib/swp/sync-service.ts | 522 +++++++++++++++++++++
lib/swp/table/swp-table-columns.tsx | 394 ++++++++++++++++
lib/swp/table/swp-table-toolbar.tsx | 340 ++++++++++++++
lib/swp/table/swp-table.tsx | 394 ++++++++++++++++
lib/swp/vendor-actions.ts | 273 +++++++++++
15 files changed, 3683 insertions(+), 25 deletions(-)
create mode 100644 app/[lng]/evcp/(evcp)/(eng)/swp-document-upload/page.tsx
create mode 100644 app/[lng]/evcp/(evcp)/(eng)/swp-document-upload/swp-document-page.tsx
create mode 100644 app/[lng]/partners/(partners)/swp-document-upload/page.tsx
create mode 100644 app/[lng]/partners/(partners)/swp-document-upload/vendor-document-page.tsx
create mode 100644 db/schema/SWP/swp-documents.ts
create mode 100644 lib/swp/actions.ts
create mode 100644 lib/swp/api-client.ts
create mode 100644 lib/swp/example-usage.ts
create mode 100644 lib/swp/sync-service.ts
create mode 100644 lib/swp/table/swp-table-columns.tsx
create mode 100644 lib/swp/table/swp-table-toolbar.tsx
create mode 100644 lib/swp/table/swp-table.tsx
create mode 100644 lib/swp/vendor-actions.ts
diff --git a/app/[lng]/evcp/(evcp)/(eng)/swp-document-upload/page.tsx b/app/[lng]/evcp/(evcp)/(eng)/swp-document-upload/page.tsx
new file mode 100644
index 00000000..25a0bfe6
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/(eng)/swp-document-upload/page.tsx
@@ -0,0 +1,58 @@
+import { Suspense } from "react";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import SwpDocumentPage from "./swp-document-page";
+
+export const metadata = {
+ title: "SWP 문서 관리",
+ description: "SWP 시스템 문서 조회 및 동기화",
+};
+
+// ============================================================================
+// 로딩 스켈레톤
+// ============================================================================
+
+function SwpDocumentSkeleton() {
+ return (
+
-“{t("blockquote")}”
+ {/* Right 영상 캐러셀 영역 - 확장된 영역 (2/3 비율) */} +++ +diff --git a/db/schema/SWP/swp-documents.ts b/db/schema/SWP/swp-documents.ts new file mode 100644 index 00000000..2c7d06b0 --- /dev/null +++ b/db/schema/SWP/swp-documents.ts @@ -0,0 +1,219 @@ +import { + varchar, + timestamp, + serial, + uniqueIndex, + index, + pgEnum, + pgSchema, +} from "drizzle-orm/pg-core"; + +// ============================================================================ +// 스키마 +// ============================================================================ + +export const swpSchema = pgSchema("swp"); + +// ============================================================================ +// ENUMS +// ============================================================================ + +export const syncStatusEnum = pgEnum("swp_sync_status", [ + "synced", + "pending", + "error", +]); + +// ============================================================================ +// 문서 마스터 (GetVDRDocumentList) +// 컬럼명: API 필드명과 동일하게 유지 (UPPER_SNAKE_CASE) +// ============================================================================ + +export const swpDocuments = swpSchema.table( + "swp_documents", + { + // Primary Key + DOC_NO: varchar("DOC_NO", { length: 1000 }).primaryKey(), + + // 문서 기본 정보 + DOC_TITLE: varchar("DOC_TITLE", { length: 1000 }).notNull(), + DOC_GB: varchar("DOC_GB", { length: 1000 }), + DOC_TYPE: varchar("DOC_TYPE", { length: 1000 }), + OWN_DOC_NO: varchar("OWN_DOC_NO", { length: 1000 }), + SHI_DOC_NO: varchar("SHI_DOC_NO", { length: 1000 }), + + // 프로젝트 정보 + PROJ_NO: varchar("PROJ_NO", { length: 1000 }).notNull(), + PROJ_NM: varchar("PROJ_NM", { length: 1000 }), + PKG_NO: varchar("PKG_NO", { length: 1000 }), + + // 자재/기술 정보 + MAT_CD: varchar("MAT_CD", { length: 1000 }), + MAT_NM: varchar("MAT_NM", { length: 1000 }), + DISPLN: varchar("DISPLN", { length: 1000 }), + CTGRY: varchar("CTGRY", { length: 1000 }), + + // 업체 정보 + VNDR_CD: varchar("VNDR_CD", { length: 1000 }), + CPY_CD: varchar("CPY_CD", { length: 1000 }), + CPY_NM: varchar("CPY_NM", { length: 1000 }), + + // 담당자 정보 + PIC_NM: varchar("PIC_NM", { length: 1000 }), + PIC_DEPTCD: varchar("PIC_DEPTCD", { length: 1000 }), + PIC_DEPTNM: varchar("PIC_DEPTNM", { length: 1000 }), + + // 최신 리비전 정보 (빠른 조회용) + LTST_REV_NO: varchar("LTST_REV_NO", { length: 1000 }), + LTST_REV_SEQ: varchar("LTST_REV_SEQ", { length: 1000 }), + LTST_ACTV_STAT: varchar("LTST_ACTV_STAT", { length: 1000 }), + + // 기타 + STAGE: varchar("STAGE", { length: 1000 }), + SKL_CD: varchar("SKL_CD", { length: 1000 }), + MOD_TYPE: varchar("MOD_TYPE", { length: 1000 }), + ACT_TYPE_NM: varchar("ACT_TYPE_NM", { length: 1000 }), + USE_YN: varchar("USE_YN", { length: 1000 }), + + // 이력 정보 (SWP) + CRTER: varchar("CRTER", { length: 1000 }), + CRTE_DTM: varchar("CRTE_DTM", { length: 1000 }), + CHGR: varchar("CHGR", { length: 1000 }), + CHG_DTM: varchar("CHG_DTM", { length: 1000 }), + REV_DTM: varchar("REV_DTM", { length: 1000 }), + + // 동기화 메타데이터 + sync_status: syncStatusEnum("sync_status").default("synced").notNull(), + last_synced_at: timestamp("last_synced_at").defaultNow().notNull(), + created_at: timestamp("created_at").defaultNow().notNull(), + updated_at: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => ({ + projNoIdx: index("swp_documents_proj_no_idx").on(table.PROJ_NO), + vndrCdIdx: index("swp_documents_vndr_cd_idx").on(table.VNDR_CD), + pkgNoIdx: index("swp_documents_pkg_no_idx").on(table.PKG_NO), + syncStatusIdx: index("swp_documents_sync_status_idx").on(table.sync_status), + }) +); + +// ============================================================================ +// 문서 리비전 (GetExternalInboxList에서 추출) +// 컬럼명: API 필드명과 동일하게 유지 (UPPER_SNAKE_CASE) +// ============================================================================ + +export const swpDocumentRevisions = swpSchema.table( + "swp_document_revisions", + { + // Primary Key + id: serial("id").primaryKey(), + + // Foreign Key + DOC_NO: varchar("DOC_NO", { length: 1000 }) + .notNull() + .references(() => swpDocuments.DOC_NO, { onDelete: "cascade" }), + + // 리비전 정보 + REV_NO: varchar("REV_NO", { length: 1000 }).notNull(), + STAGE: varchar("STAGE", { length: 1000 }).notNull(), + + // Activity 정보 + ACTV_NO: varchar("ACTV_NO", { length: 1000 }), + ACTV_SEQ: varchar("ACTV_SEQ", { length: 1000 }), + BOX_SEQ: varchar("BOX_SEQ", { length: 1000 }), + OFDC_NO: varchar("OFDC_NO", { length: 1000 }), + + // 프로젝트/패키지 정보 (파일 API에서만 제공) + PROJ_NO: varchar("PROJ_NO", { length: 1000 }), + PKG_NO: varchar("PKG_NO", { length: 1000 }), + VNDR_CD: varchar("VNDR_CD", { length: 1000 }), + CPY_CD: varchar("CPY_CD", { length: 1000 }), + + // 동기화 메타데이터 + sync_status: syncStatusEnum("sync_status").default("synced").notNull(), + last_synced_at: timestamp("last_synced_at").defaultNow().notNull(), + created_at: timestamp("created_at").defaultNow().notNull(), + updated_at: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => ({ + // Unique constraint: 문서당 리비전은 유일 + docRevUnique: uniqueIndex("swp_doc_rev_unique_idx").on( + table.DOC_NO, + table.REV_NO + ), + docNoIdx: index("swp_revisions_doc_no_idx").on(table.DOC_NO), + revNoIdx: index("swp_revisions_rev_no_idx").on(table.REV_NO), + stageIdx: index("swp_revisions_stage_idx").on(table.STAGE), + }) +); + +// ============================================================================ +// 첨부파일 (GetExternalInboxList) +// 컬럼명: API 필드명과 동일하게 유지 (UPPER_SNAKE_CASE) +// ============================================================================ + +export const swpDocumentFiles = swpSchema.table( + "swp_document_files", + { + // Primary Key + id: serial("id").primaryKey(), + + // Foreign Key + revision_id: serial("revision_id") + .notNull() + .references(() => swpDocumentRevisions.id, { onDelete: "cascade" }), + + // 파일 정보 + FILE_NM: varchar("FILE_NM", { length: 1000 }).notNull(), + FILE_SEQ: varchar("FILE_SEQ", { length: 1000 }).notNull(), + FILE_SZ: varchar("FILE_SZ", { length: 1000 }), + FLD_PATH: varchar("FLD_PATH", { length: 1000 }), + + // 문서 참조 (조회 편의용, 비정규화) + DOC_NO: varchar("DOC_NO", { length: 1000 }).notNull(), + + // 상태 정보 + STAT: varchar("STAT", { length: 1000 }), + STAT_NM: varchar("STAT_NM", { length: 1000 }), + IDX: varchar("IDX", { length: 1000 }), + + // Activity 정보 + ACTV_NO: varchar("ACTV_NO", { length: 1000 }), + + // 이력 정보 (SWP) + CRTER: varchar("CRTER", { length: 1000 }), + CRTE_DTM: varchar("CRTE_DTM", { length: 1000 }), + CHGR: varchar("CHGR", { length: 1000 }), + CHG_DTM: varchar("CHG_DTM", { length: 1000 }), + + // 동기화 메타데이터 + sync_status: syncStatusEnum("sync_status").default("synced").notNull(), + last_synced_at: timestamp("last_synced_at").defaultNow().notNull(), + created_at: timestamp("created_at").defaultNow().notNull(), + updated_at: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => ({ + // Unique constraint: 리비전당 파일 시퀀스는 유일 + revFileUnique: uniqueIndex("swp_rev_file_unique_idx").on( + table.revision_id, + table.FILE_SEQ + ), + revisionIdIdx: index("swp_files_revision_id_idx").on(table.revision_id), + docNoIdx: index("swp_files_doc_no_idx").on(table.DOC_NO), + fileNmIdx: index("swp_files_file_nm_idx").on(table.FILE_NM), + }) +); + +// ============================================================================ +// TYPES +// ============================================================================ + +export type SwpDocument = typeof swpDocuments.$inferSelect; +export type SwpDocumentInsert = typeof swpDocuments.$inferInsert; + +export type SwpDocumentRevision = typeof swpDocumentRevisions.$inferSelect; +export type SwpDocumentRevisionInsert = + typeof swpDocumentRevisions.$inferInsert; + +export type SwpDocumentFile = typeof swpDocumentFiles.$inferSelect; +export type SwpDocumentFileInsert = typeof swpDocumentFiles.$inferInsert; + diff --git a/db/schema/index.ts b/db/schema/index.ts index dbbb90a1..ea39ae8c 100644 --- a/db/schema/index.ts +++ b/db/schema/index.ts @@ -83,4 +83,7 @@ export * from './avl/avl'; export * from './avl/vendor-pool'; // === Email Logs 스키마 === export * from './emailLogs'; -export * from './emailWhitelist'; \ No newline at end of file +export * from './emailWhitelist'; + +// SWP 문서/첨부파일 테이블 및 뷰 스키마 +export * from './SWP/swp-documents'; \ No newline at end of file diff --git a/lib/swp/actions.ts b/lib/swp/actions.ts new file mode 100644 index 00000000..79c0bafe --- /dev/null +++ b/lib/swp/actions.ts @@ -0,0 +1,293 @@ +"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"; + +// ============================================================================ +// 타입 정의 +// ============================================================================ + +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; + PKG_NO: string | null; + VNDR_CD: string | null; + CPY_NM: string | null; + LTST_REV_NO: string | null; + STAGE: 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+“{t("blockquote")}”
{/* */}[] = []; + + 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 `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, + sync_status: swpDocuments.sync_status, + last_synced_at: swpDocuments.last_synced_at, + revision_count: sql `COUNT(DISTINCT ${swpDocumentRevisions.id})::int`, + file_count: sql `COUNT(${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.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("문서 목록 조회 실패"); + } +} + +// ============================================================================ +// 서버 액션: 문서의 리비전 목록 조회 +// ============================================================================ + +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 `( + SELECT COUNT(*)::int + FROM swp.swp_document_files f + WHERE f.revision_id = ${swpDocumentRevisions.id} + )`, + }) + .from(swpDocumentRevisions) + .where(eq(swpDocumentRevisions.DOC_NO, docNo)) + .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 `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 `COUNT(DISTINCT ${swpDocuments.DOC_NO})::int`, + total_revisions: sql `COUNT(DISTINCT ${swpDocumentRevisions.id})::int`, + total_files: sql `COUNT(${swpDocumentFiles.id})::int`, + last_sync: sql `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, + }; + } +} + diff --git a/lib/swp/api-client.ts b/lib/swp/api-client.ts new file mode 100644 index 00000000..9ce8c5c1 --- /dev/null +++ b/lib/swp/api-client.ts @@ -0,0 +1,304 @@ +"use server"; + +import type { + SwpDocumentApiResponse, + SwpFileApiResponse, +} from "./sync-service"; + +// ============================================================================ +// SWP API 클라이언트 +// ============================================================================ + +const SWP_BASE_URL = process.env.SWP_API_URL || "http://60.100.99.217/DDC"; + +// ============================================================================ +// API 요청 타입 정의 +// ============================================================================ + +/** + * 문서 리스트 조회 필터 + */ +export interface GetVDRDocumentListFilter { + proj_no: string; // 필수 + doc_gb: "M" | "V"; // 필수 (M=MDR, V=VDR) + ctgry?: string; // HULL or TOP + pkgNo?: string; + vndrCd?: string; + pic_deptcd?: string; + doc_type?: string; + displn?: string; + mat_cd?: string; + proj_nm?: string; + stage?: string; + own_doc_no?: string; + doc_title?: string; + lang_gb?: string; +} + +/** + * 첨부파일 리스트 조회 필터 + */ +export interface GetExternalInboxListFilter { + projNo: string; // 필수 + pkgNo?: string; + vndrCd?: string; + stage?: string; + owndocno?: string; + doctitle?: string; +} + +// ============================================================================ +// 공통 API 호출 함수 +// ============================================================================ + +async function callSwpApi ( + endpoint: string, + body: Record , + resultKey: string +): Promise { + const url = `${SWP_BASE_URL}/Services/WebService.svc/${endpoint}`; + + console.log(`[SWP API] 호출: ${endpoint}`, body); + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(30000), // 30초 + }); + + if (!response.ok) { + throw new Error( + `SWP API 오류: ${response.status} ${response.statusText}` + ); + } + + const data = await response.json(); + + if (!data[resultKey]) { + throw new Error(`API 응답 형식 오류: ${resultKey} 없음`); + } + + console.log(`[SWP API] 성공: ${data[resultKey].length}개 조회`); + + return data[resultKey] as T[]; + } catch (error) { + if (error instanceof Error) { + if (error.name === "AbortError") { + throw new Error(`API 타임아웃: 30초 초과`); + } + throw new Error(`${endpoint} 실패: ${error.message}`); + } + throw error; + } +} + +// ============================================================================ +// 서버 액션: 문서 리스트 조회 +// ============================================================================ + +/** + * 문서 리스트 조회 (GetVDRDocumentList) + * @param filter 조회 필터 + */ +export async function fetchGetVDRDocumentList( + filter: GetVDRDocumentListFilter +): Promise { + // doc_gb 기본값 설정 + const body = { + proj_no: filter.proj_no, + doc_gb: filter.doc_gb || "V", // 기본값 V + ctgry: filter.ctgry || "", + pkgNo: filter.pkgNo || "", + vndrCd: filter.vndrCd || "", + pic_deptcd: filter.pic_deptcd || "", + doc_type: filter.doc_type || "", + displn: filter.displn || "", + mat_cd: filter.mat_cd || "", + proj_nm: filter.proj_nm || "", + stage: filter.stage || "", + own_doc_no: filter.own_doc_no || "", + doc_title: filter.doc_title || "", + lang_gb: filter.lang_gb || "", + }; + + return callSwpApi ( + "GetVDRDocumentList", + body, + "GetVDRDocumentListResult" + ); +} + +// ============================================================================ +// 서버 액션: 첨부파일 리스트 조회 +// ============================================================================ + +/** + * 첨부파일 리스트 조회 (GetExternalInboxList) + * @param filter 조회 필터 + */ +export async function fetchGetExternalInboxList( + filter: GetExternalInboxListFilter +): Promise { + const body = { + projNo: filter.projNo, + pkgNo: filter.pkgNo || "", + vndrCd: filter.vndrCd || "", + stage: filter.stage || "", + owndocno: filter.owndocno || "", + doctitle: filter.doctitle || "", + }; + + return callSwpApi ( + "GetExternalInboxList", + body, + "GetExternalInboxListResult" + ); +} + +// ============================================================================ +// 서버 액션: 프로젝트 데이터 일괄 조회 +// ============================================================================ + +/** + * 프로젝트의 문서 + 파일 리스트 동시 조회 + * @param projectNo 프로젝트 번호 (예: "SN2190") + * @param docGb 문서 구분 (M=MDR, V=VDR, 기본값: V) + */ +export async function fetchSwpProjectData( + projectNo: string, + docGb: "M" | "V" = "V" +): Promise<{ + documents: SwpDocumentApiResponse[]; + files: SwpFileApiResponse[]; +}> { + console.log(`[SWP API] 프로젝트 ${projectNo} 데이터 조회 시작`); + const startTime = Date.now(); + + try { + // 병렬 호출 + const [documents, files] = await Promise.all([ + fetchGetVDRDocumentList({ + proj_no: projectNo, + doc_gb: docGb, + }), + fetchGetExternalInboxList({ + projNo: projectNo, + }), + ]); + + const duration = Date.now() - startTime; + console.log( + `[SWP API] 조회 완료: 문서 ${documents.length}개, 파일 ${files.length}개 (${duration}ms)` + ); + + return { documents, files }; + } catch (error) { + console.error(`[SWP API] 조회 실패:`, error); + throw error; + } +} + +// ============================================================================ +// 파일 다운로드 URL 생성 +// ============================================================================ + +/** + * SWP 파일 다운로드 URL 생성 + */ +export async function getSwpFileDownloadUrl(file: { + FLD_PATH: string; + FILE_NM: string; +}): Promise { + // FLD_PATH: "\SN2190\C00035\\20170217180135" + // FILE_NM: "C168-SH-SBN08-XG-20118-01_04_IFC_20170216.pdf" + + const encodedPath = encodeURIComponent(file.FLD_PATH); + const encodedName = encodeURIComponent(file.FILE_NM); + + return `${SWP_BASE_URL}/Files/${encodedPath}/${encodedName}`; +} + +/** + * SWP 파일 직접 다운로드 (Blob) + */ +export async function downloadSwpFile(file: { + FLD_PATH: string; + FILE_NM: string; +}): Promise { + const url = await getSwpFileDownloadUrl(file); + + const response = await fetch(url, { + method: "GET", + signal: AbortSignal.timeout(60000), // 1분 + }); + + if (!response.ok) { + throw new Error(`파일 다운로드 실패: ${response.status}`); + } + + return response.blob(); +} + +// ============================================================================ +// 통계 및 유틸리티 +// ============================================================================ + +/** + * API 응답 통계 + */ +export interface SwpDataStats { + projectNo: string; + documentCount: number; + fileCount: number; + revisionCount: number; + avgFilesPerDoc: number; + stages: Record ; + fileTypes: Record ; + totalFileSize: number; +} + +export async function analyzeSwpData( + projectNo: string, + documents: SwpDocumentApiResponse[], + files: SwpFileApiResponse[] +): Promise { + // 리비전 카운트 + const revisionSet = new Set (); + files.forEach((f) => revisionSet.add(`${f.OWN_DOC_NO}|${f.REV_NO}`)); + + // 스테이지별 카운트 + const stages: Record = {}; + files.forEach((f) => { + stages[f.STAGE] = (stages[f.STAGE] || 0) + 1; + }); + + // 파일 타입별 카운트 + const fileTypes: Record = {}; + files.forEach((f) => { + const ext = f.FILE_NM.split(".").pop()?.toLowerCase() || "unknown"; + fileTypes[ext] = (fileTypes[ext] || 0) + 1; + }); + + // 총 파일 사이즈 (숫자로 변환 가능한 것만) + const totalFileSize = files.reduce((sum, f) => { + const size = parseInt(f.FILE_SZ, 10); + return sum + (isNaN(size) ? 0 : size); + }, 0); + + return { + projectNo, + documentCount: documents.length, + fileCount: files.length, + revisionCount: revisionSet.size, + avgFilesPerDoc: + documents.length > 0 ? files.length / documents.length : 0, + stages, + fileTypes, + totalFileSize, + }; +} + diff --git a/lib/swp/example-usage.ts b/lib/swp/example-usage.ts new file mode 100644 index 00000000..8e1791f7 --- /dev/null +++ b/lib/swp/example-usage.ts @@ -0,0 +1,347 @@ +"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 > = 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 new file mode 100644 index 00000000..0a801bd8 --- /dev/null +++ b/lib/swp/sync-service.ts @@ -0,0 +1,522 @@ +"use server"; + +import db from "@/db/db"; +import { eq, and, sql } from "drizzle-orm"; +import { + swpDocuments, + swpDocumentRevisions, + swpDocumentFiles, + type SwpDocumentInsert, + type SwpDocumentRevisionInsert, + type SwpDocumentFileInsert, + swpSchema, +} from "@/db/schema/SWP/swp-documents"; + +// ============================================================================ +// API 응답 타입 정의 +// ============================================================================ + +export interface SwpDocumentApiResponse { + DOC_NO: string; + DOC_TITLE: string; + DOC_GB: string; + DOC_TYPE: string; + OWN_DOC_NO: string; + SHI_DOC_NO: string; + PROJ_NO: string; + PROJ_NM: string; + PKG_NO: string; + MAT_CD: string; + MAT_NM: string; + DISPLN: string; + CTGRY: string; + VNDR_CD: string; + CPY_CD: string; + CPY_NM: string; + PIC_NM: string; + PIC_DEPTCD: string; + PIC_DEPTNM: string; + LTST_REV_NO: string; + LTST_REV_SEQ: string; + LTST_ACTV_STAT: string; + STAGE: string; + SKL_CD: string; + MOD_TYPE: string; + ACT_TYPE_NM: string; + USE_YN: string; + CRTER: string; + CRTE_DTM: string; + CHGR: string; + CHG_DTM: string; + REV_DTM: string | null; +} + +export interface SwpFileApiResponse { + OWN_DOC_NO: string; + REV_NO: string; + STAGE: string; + FILE_NM: string; + FILE_SEQ: string; + FILE_SZ: string; + FLD_PATH: string; + ACTV_NO: string | null; + ACTV_SEQ: string; + BOX_SEQ: string; + OFDC_NO: string; + PROJ_NO: string; + PKG_NO: string; + VNDR_CD: string; + CPY_CD: string; + STAT: string; + STAT_NM: string; + IDX: string; + CRTER: string; + CRTE_DTM: string; + CHGR: string; + CHG_DTM: string; +} + +// ============================================================================ +// 동기화 결과 타입 +// ============================================================================ + +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 { + 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 (); + 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, + DOC_TITLE: doc.DOC_TITLE, + DOC_GB: doc.DOC_GB || null, + DOC_TYPE: doc.DOC_TYPE || null, + OWN_DOC_NO: doc.OWN_DOC_NO, + SHI_DOC_NO: doc.SHI_DOC_NO, + PROJ_NO: doc.PROJ_NO, + PROJ_NM: doc.PROJ_NM, + 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(), + }; + + // 기존 문서 확인 + const existing = await tx + .select() + .from(swpDocuments) + .where(eq(swpDocuments.DOC_NO, doc.DOC_NO)) + .limit(1); + + if (existing.length > 0) { + // 업데이트 + await tx + .update(swpDocuments) + .set(data) + .where(eq(swpDocuments.DOC_NO, doc.DOC_NO)); + return { id: doc.DOC_NO, inserted: false, updated: true }; + } else { + // 삽입 + await tx.insert(swpDocuments).values(data); + return { id: doc.DOC_NO, inserted: true, updated: false }; + } +} + +async function upsertRevision( + tx: any, + docNo: string, + file: SwpFileApiResponse +): Promise<{ id: number; inserted: boolean; updated: boolean }> { + const data: Omit = { + DOC_NO: docNo, + REV_NO: file.REV_NO, + STAGE: file.STAGE, + ACTV_NO: file.ACTV_NO || null, + ACTV_SEQ: file.ACTV_SEQ, + BOX_SEQ: file.BOX_SEQ, + OFDC_NO: file.OFDC_NO, + PROJ_NO: file.PROJ_NO, + PKG_NO: file.PKG_NO || null, + VNDR_CD: file.VNDR_CD || null, + CPY_CD: file.CPY_CD, + 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 = { + revision_id: revisionId, + DOC_NO: docNo, + FILE_NM: file.FILE_NM, + FILE_SEQ: file.FILE_SEQ, + FILE_SZ: file.FILE_SZ, + FLD_PATH: file.FLD_PATH, + STAT: file.STAT, + STAT_NM: file.STAT_NM, + IDX: file.IDX, + 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-table-columns.tsx b/lib/swp/table/swp-table-columns.tsx new file mode 100644 index 00000000..dd605453 --- /dev/null +++ b/lib/swp/table/swp-table-columns.tsx @@ -0,0 +1,394 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ChevronDown, ChevronRight, FileIcon } from "lucide-react"; +import { formatDistanceToNow } from "date-fns"; +import { ko } from "date-fns/locale"; +import type { SwpDocumentWithStats } from "../actions"; + +export const swpDocumentColumns: ColumnDef [] = [ + { + id: "expander", + header: () => null, + cell: ({ row }) => { + return row.getCanExpand() ? ( + + ) : null; + }, + size: 50, + }, + { + accessorKey: "DOC_NO", + header: "문서번호", + cell: ({ row }) => ( + {row.original.DOC_NO}+ ), + size: 250, + }, + { + accessorKey: "DOC_TITLE", + header: "문서제목", + cell: ({ row }) => ( ++ {row.original.DOC_TITLE} ++ ), + size: 300, + }, + { + accessorKey: "PROJ_NO", + header: "프로젝트", + cell: ({ row }) => ( +++ ), + size: 150, + }, + { + accessorKey: "PKG_NO", + header: "패키지", + cell: ({ row }) => row.original.PKG_NO || "-", + size: 100, + }, + { + accessorKey: "VNDR_CD", + header: "업체", + cell: ({ row }) => ( +{row.original.PROJ_NO}+ {row.original.PROJ_NM && ( ++ {row.original.PROJ_NM} ++ )} ++ {row.original.VNDR_CD && ( ++ ), + size: 120, + }, + { + accessorKey: "STAGE", + header: "스테이지", + cell: ({ row }) => { + const stage = row.original.STAGE; + if (!stage) return "-"; + + const color = + stage === "IFC" ? "bg-green-100 text-green-800" : + stage === "IFA" ? "bg-blue-100 text-blue-800" : + "bg-gray-100 text-gray-800"; + + return ( +{row.original.VNDR_CD}+ )} + {row.original.CPY_NM && ( ++ {row.original.CPY_NM} ++ )} ++ {stage} + + ); + }, + size: 80, + }, + { + accessorKey: "LTST_REV_NO", + header: "최신 REV", + cell: ({ row }) => row.original.LTST_REV_NO || "-", + size: 80, + }, + { + id: "stats", + header: "REV/파일", + cell: ({ row }) => ( +++ ), + size: 100, + }, + { + accessorKey: "sync_status", + header: "상태", + cell: ({ row }) => { + const status = row.original.sync_status; + const color = + status === "synced" ? "bg-green-100 text-green-800" : + status === "pending" ? "bg-yellow-100 text-yellow-800" : + "bg-red-100 text-red-800"; + + return ( ++ {row.original.revision_count} / {row.original.file_count} +++ {status} + + ); + }, + size: 80, + }, + { + accessorKey: "last_synced_at", + header: "동기화", + cell: ({ row }) => ( ++ {formatDistanceToNow(new Date(row.original.last_synced_at), { + addSuffix: true, + locale: ko, + })} ++ ), + size: 100, + }, +]; + +// ============================================================================ +// 리비전 컬럼 (서브 테이블용) +// ============================================================================ + +export interface RevisionRow { + id: number; + DOC_NO: string; + REV_NO: string; + STAGE: string; + ACTV_NO: string | null; + OFDC_NO: string | null; + sync_status: "synced" | "pending" | "error"; + last_synced_at: Date; + file_count: number; +} + +export const swpRevisionColumns: ColumnDef[] = [ + { + id: "expander", + header: () => null, + cell: ({ row }) => { + return row.getCanExpand() ? ( + + ) : null; + }, + size: 100, + }, + { + accessorKey: "REV_NO", + header: "리비전", + cell: ({ row }) => ( + + REV {row.original.REV_NO} + + ), + size: 100, + }, + { + accessorKey: "STAGE", + header: "스테이지", + cell: ({ row }) => { + const stage = row.original.STAGE; + const color = + stage === "IFC" ? "bg-green-100 text-green-800" : + stage === "IFA" ? "bg-blue-100 text-blue-800" : + "bg-gray-100 text-gray-800"; + + return ( ++ {stage} + + ); + }, + size: 100, + }, + { + accessorKey: "OFDC_NO", + header: "OFDC 번호", + cell: ({ row }) => ( +{row.original.OFDC_NO || "-"}+ ), + size: 200, + }, + { + accessorKey: "ACTV_NO", + header: "Activity", + cell: ({ row }) => ( ++ {row.original.ACTV_NO || "-"} ++ ), + size: 250, + }, + { + id: "file_count", + header: "파일 수", + cell: ({ row }) => ( +++ ), + size: 100, + }, + { + accessorKey: "sync_status", + header: "상태", + cell: ({ row }) => { + const status = row.original.sync_status; + const color = + status === "synced" ? "bg-green-100 text-green-800" : + status === "pending" ? "bg-yellow-100 text-yellow-800" : + "bg-red-100 text-red-800"; + + return ( ++ {row.original.file_count} + + {status} + + ); + }, + size: 80, + }, + { + accessorKey: "last_synced_at", + header: "동기화", + cell: ({ row }) => ( ++ {formatDistanceToNow(new Date(row.original.last_synced_at), { + addSuffix: true, + locale: ko, + })} ++ ), + size: 100, + }, +]; + +// ============================================================================ +// 파일 컬럼 (서브 서브 테이블용) +// ============================================================================ + +export interface FileRow { + id: number; + FILE_NM: string; + FILE_SEQ: string; + FILE_SZ: string | null; + FLD_PATH: string | null; + STAT: string | null; + STAT_NM: string | null; + sync_status: "synced" | "pending" | "error"; + created_at: Date; +} + +export const swpFileColumns: ColumnDef[] = [ + { + id: "spacer", + header: () => null, + cell: () => , + size: 150, + }, + { + accessorKey: "FILE_SEQ", + header: "순서", + cell: ({ row }) => ( + + #{row.original.FILE_SEQ} + + ), + size: 80, + }, + { + accessorKey: "FILE_NM", + header: "파일명", + cell: ({ row }) => ( +++ ), + size: 400, + }, + { + accessorKey: "FILE_SZ", + header: "크기", + cell: ({ row }) => { + const size = row.original.FILE_SZ; + if (!size) return "-"; + + const bytes = parseInt(size, 10); + if (isNaN(bytes)) return size; + + const kb = bytes / 1024; + const mb = kb / 1024; + + return mb >= 1 + ? `${mb.toFixed(2)} MB` + : `${kb.toFixed(2)} KB`; + }, + size: 100, + }, + { + accessorKey: "STAT_NM", + header: "상태", + cell: ({ row }) => { + const status = row.original.STAT_NM; + if (!status) return "-"; + + const color = status === "Complete" + ? "bg-green-100 text-green-800" + : "bg-gray-100 text-gray-800"; + + return ( ++ {row.original.FILE_NM} + + {status} + + ); + }, + size: 100, + }, + { + accessorKey: "FLD_PATH", + header: "경로", + cell: ({ row }) => ( ++ {row.original.FLD_PATH || "-"} ++ ), + size: 200, + }, + { + accessorKey: "created_at", + header: "생성일", + cell: ({ row }) => ( ++ {formatDistanceToNow(new Date(row.original.created_at), { + addSuffix: true, + locale: ko, + })} ++ ), + size: 100, + }, +]; + diff --git a/lib/swp/table/swp-table-toolbar.tsx b/lib/swp/table/swp-table-toolbar.tsx new file mode 100644 index 00000000..656dfd4a --- /dev/null +++ b/lib/swp/table/swp-table-toolbar.tsx @@ -0,0 +1,340 @@ +"use client"; + +import { useState, useTransition, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Label } from "@/components/ui/label"; +import { RefreshCw, Download, Search, X, Check, ChevronsUpDown } from "lucide-react"; +import { syncSwpProjectAction, type SwpTableFilters } from "../actions"; +import { useToast } from "@/hooks/use-toast"; +import { useRouter } from "next/navigation"; +import { cn } from "@/lib/utils"; + +interface SwpTableToolbarProps { + filters: SwpTableFilters; + onFiltersChange: (filters: SwpTableFilters) => void; + projects?: Array<{ PROJ_NO: string; PROJ_NM: string }>; + mode?: "admin" | "vendor"; // admin: SWP 동기화 가능, vendor: 읽기 전용 +} + +export function SwpTableToolbar({ + filters, + onFiltersChange, + projects = [], + mode = "admin", +}: SwpTableToolbarProps) { + const [isSyncing, startSync] = useTransition(); + const [localFilters, setLocalFilters] = useState(filters); + const { toast } = useToast(); + const router = useRouter(); + const [projectSearchOpen, setProjectSearchOpen] = useState(false); + const [projectSearch, setProjectSearch] = useState(""); + + // 동기화 핸들러 + const handleSync = () => { + const projectNo = localFilters.projNo; + + if (!projectNo) { + toast({ + variant: "destructive", + title: "프로젝트 선택 필요", + description: "동기화할 프로젝트를 먼저 선택해주세요.", + }); + return; + } + + startSync(async () => { + try { + toast({ + title: "동기화 시작", + description: `프로젝트 ${projectNo} 동기화를 시작합니다...`, + }); + + const result = await syncSwpProjectAction(projectNo, "V"); + + if (result.success) { + toast({ + title: "동기화 완료", + description: `문서 ${result.stats.documents.total}개, 파일 ${result.stats.files.total}개 동기화 완료`, + }); + + // 페이지 새로고침 + router.refresh(); + } else { + throw new Error(result.errors.join(", ")); + } + } catch (error) { + console.error("동기화 실패:", error); + toast({ + variant: "destructive", + title: "동기화 실패", + description: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + }); + }; + + // 검색 적용 + const handleSearch = () => { + onFiltersChange(localFilters); + }; + + // 검색 초기화 + const handleReset = () => { + const resetFilters: SwpTableFilters = {}; + setLocalFilters(resetFilters); + onFiltersChange(resetFilters); + }; + + // 프로젝트 필터링 + const filteredProjects = useMemo(() => { + if (!projectSearch) return projects; + + const search = projectSearch.toLowerCase(); + return projects.filter( + (proj) => + proj.PROJ_NO.toLowerCase().includes(search) || + proj.PROJ_NM.toLowerCase().includes(search) + ); + }, [projects, projectSearch]); + + return ( + + {/* 상단 액션 바 */} ++ ); +} + diff --git a/lib/swp/table/swp-table.tsx b/lib/swp/table/swp-table.tsx new file mode 100644 index 00000000..4024c711 --- /dev/null +++ b/lib/swp/table/swp-table.tsx @@ -0,0 +1,394 @@ +"use client"; + +import { useState } from "react"; +import { + useReactTable, + getCoreRowModel, + getExpandedRowModel, + flexRender, + ExpandedState, +} from "@tanstack/react-table"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { Loader2 } from "lucide-react"; +import { swpDocumentColumns, swpRevisionColumns, swpFileColumns, type RevisionRow, type FileRow } from "./swp-table-columns"; +import { fetchDocumentRevisions, fetchRevisionFiles, type SwpDocumentWithStats } from "../actions"; + +interface SwpTableProps { + initialData: SwpDocumentWithStats[]; + total: number; + page: number; + pageSize: number; + totalPages: number; + onPageChange: (page: number) => void; + mode?: "admin" | "vendor"; +} + +export function SwpTable({ + initialData, + total, + page, + pageSize, + totalPages, + onPageChange, + mode = "admin", +}: SwpTableProps) { + const [expanded, setExpanded] = useState++ + {/* 검색 필터 */} ++ {mode === "admin" && ( + + )} + + ++ ++ {mode === "vendor" ? "문서 조회 및 업로드" : "SWP 문서 관리 시스템"} ++++++ +검색 필터
+ ++ {/* 프로젝트 번호 */} ++ ++ + {projects.length > 0 ? ( ++ + {/* 문서 번호 */} ++ + ) : ( + + setLocalFilters({ ...localFilters, projNo: e.target.value }) + } + /> + )} ++ + ++ ++++++ setProjectSearch(e.target.value)} + className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0" + /> + +++ + {filteredProjects.map((proj) => ( + + ))} + {filteredProjects.length === 0 && ( +++ 검색 결과가 없습니다. ++ )} ++ + + setLocalFilters({ ...localFilters, docNo: e.target.value }) + } + /> ++ + {/* 문서 제목 */} ++ + + setLocalFilters({ ...localFilters, docTitle: e.target.value }) + } + /> ++ + {/* 패키지 번호 */} ++ + + setLocalFilters({ ...localFilters, pkgNo: e.target.value }) + } + /> ++ + {/* 업체 코드 */} ++ + + setLocalFilters({ ...localFilters, vndrCd: e.target.value }) + } + /> ++ + {/* 스테이지 */} ++ + +++ ++({}); + const [revisionData, setRevisionData] = useState >({}); + const [fileData, setFileData] = useState >({}); + const [loadingRevisions, setLoadingRevisions] = useState >(new Set()); + const [loadingFiles, setLoadingFiles] = useState >(new Set()); + + const table = useReactTable({ + data: initialData, + columns: swpDocumentColumns, + state: { + expanded, + }, + onExpandedChange: setExpanded, + getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: getExpandedRowModel(), + getRowCanExpand: (row) => true, // 모든 문서는 확장 가능 + }); + + // 리비전 로드 + const loadRevisions = async (docNo: string) => { + if (revisionData[docNo]) return; // 이미 로드됨 + + setLoadingRevisions((prev) => { + const newSet = new Set(prev); + newSet.add(docNo); + return newSet; + }); + + try { + const revisions = await fetchDocumentRevisions(docNo); + setRevisionData((prev) => ({ ...prev, [docNo]: revisions })); + } catch (error) { + console.error("리비전 로드 실패:", error); + } finally { + setLoadingRevisions((prev) => { + const next = new Set(prev); + next.delete(docNo); + return next; + }); + } + }; + + // 파일 로드 + const loadFiles = async (revisionId: number) => { + if (fileData[revisionId]) return; // 이미 로드됨 + + setLoadingFiles((prev) => { + const newSet = new Set(prev); + newSet.add(revisionId); + return newSet; + }); + + try { + const files = await fetchRevisionFiles(revisionId); + setFileData((prev) => ({ ...prev, [revisionId]: files })); + } catch (error) { + console.error("파일 로드 실패:", error); + } finally { + setLoadingFiles((prev) => { + const next = new Set(prev); + next.delete(revisionId); + return next; + }); + } + }; + + // 문서 행 확장 핸들러 + const handleDocumentExpand = (docNo: string, isExpanded: boolean) => { + if (!isExpanded) { + loadRevisions(docNo); + } + }; + + return ( + + {/* 테이블 */} ++ ); +} + +// ============================================================================ +// 리비전 서브 테이블 +// ============================================================================ + +interface RevisionSubTableProps { + revisions: RevisionRow[]; + fileData: Record++ + {/* 페이지네이션 */} ++
++ {table.getHeaderGroups().map((headerGroup) => ( + ++ {headerGroup.headers.map((header) => ( + + ))} ++ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} ++ {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + <> + {/* 문서 행 */} + ++ {row.getVisibleCells().map((cell) => ( + + + {/* 리비전 행들 (확장 시) */} + {row.getIsExpanded() && ( ++ {cell.column.id === "expander" ? ( + + ))} +{ + row.toggleExpanded(); + handleDocumentExpand(row.original.DOC_NO, row.getIsExpanded()); + }} + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} ++ ) : ( + flexRender(cell.column.columnDef.cell, cell.getContext()) + )} ++ + )} + > + )) + ) : ( ++ {loadingRevisions.has(row.original.DOC_NO) ? ( + +++ ) : revisionData[row.original.DOC_NO]?.length ? ( ++ 리비전 로딩 중... + + ) : ( + + 리비전 없음 ++ )} ++ + )} ++ 데이터 없음 + ++++ 총 {total}개 중 {(page - 1) * pageSize + 1}- + {Math.min(page * pageSize, total)}개 표시 +++ +++ {page} / {totalPages} ++ +; + loadingFiles: Set ; + onLoadFiles: (revisionId: number) => void; +} + +function RevisionSubTable({ + revisions, + fileData, + loadingFiles, + onLoadFiles, +}: RevisionSubTableProps) { + const [expandedRevisions, setExpandedRevisions] = useState ({}); + + const revisionTable = useReactTable({ + data: revisions, + columns: swpRevisionColumns, + state: { + expanded: expandedRevisions, + }, + onExpandedChange: setExpandedRevisions, + getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: getExpandedRowModel(), + getRowCanExpand: () => true, + }); + + const handleRevisionExpand = (revisionId: number, isExpanded: boolean) => { + if (!isExpanded) { + onLoadFiles(revisionId); + } + }; + + return ( + ++ ); +} + +// ============================================================================ +// 파일 서브 테이블 +// ============================================================================ + +interface FileSubTableProps { + files: FileRow[]; +} + +function FileSubTable({ files }: FileSubTableProps) { + const fileTable = useReactTable({ + data: files, + columns: swpFileColumns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( ++
++ {revisionTable.getHeaderGroups().map((headerGroup) => ( + ++ {headerGroup.headers.map((header) => ( + + ))} ++ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} ++ {revisionTable.getRowModel().rows.map((row) => ( + <> + {/* 리비전 행 */} + ++ {row.getVisibleCells().map((cell) => ( + + + {/* 파일 행들 (확장 시) */} + {row.getIsExpanded() && ( ++ {cell.column.id === "expander" ? ( + + ))} +{ + row.toggleExpanded(); + handleRevisionExpand(row.original.id, row.getIsExpanded()); + }} + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} ++ ) : ( + flexRender(cell.column.columnDef.cell, cell.getContext()) + )} ++ + )} + > + ))} ++ {loadingFiles.has(row.original.id) ? ( + +++ ) : fileData[row.original.id]?.length ? ( ++ 파일 로딩 중... + + ) : ( + + 파일 없음 ++ )} +++ ); +} diff --git a/lib/swp/vendor-actions.ts b/lib/swp/vendor-actions.ts new file mode 100644 index 00000000..7d6dfa85 --- /dev/null +++ b/lib/swp/vendor-actions.ts @@ -0,0 +1,273 @@ +"use server"; + +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import db from "@/db/db"; +import { vendors } from "@/db/schema/vendors"; +import { contracts } from "@/db/schema/contract"; +import { projects } from "@/db/schema/projects"; +import { swpDocumentFiles, swpDocumentRevisions } from "@/db/schema/SWP/swp-documents"; +import { eq, and, sql } from "drizzle-orm"; +import { fetchSwpDocuments, type SwpTableParams } from "./actions"; + +// ============================================================================ +// 벤더 세션 정보 조회 +// ============================================================================ + +interface VendorSessionInfo { + vendorId: number; + vendorCode: string; + vendorName: string; + companyId: number; +} + +export async function getVendorSessionInfo(): Promise+
++ {fileTable.getHeaderGroups().map((headerGroup) => ( + ++ {headerGroup.headers.map((header) => ( + + ))} ++ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} ++ {fileTable.getRowModel().rows.map((row) => ( + ++ {row.getVisibleCells().map((cell) => ( + + ))} ++ {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} +{ + const session = await getServerSession(authOptions); + + if (!session?.user?.companyId) { + return null; + } + + const companyId = typeof session.user.companyId === 'string' + ? parseInt(session.user.companyId, 10) + : session.user.companyId as number; + + // vendors 테이블에서 companyId로 벤더 정보 조회 + const vendor = await db + .select({ + id: vendors.id, + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName, + }) + .from(vendors) + .where(eq(vendors.id, companyId)) + .limit(1); + + if (!vendor[0] || !vendor[0].vendorCode) { + return null; + } + + return { + vendorId: vendor[0].id, + vendorCode: vendor[0].vendorCode, + vendorName: vendor[0].vendorName, + companyId, + }; +} + +// ============================================================================ +// 벤더의 프로젝트 목록 조회 +// ============================================================================ + +export async function fetchVendorProjects() { + try { + const vendorInfo = await getVendorSessionInfo(); + + if (!vendorInfo) { + throw new Error("벤더 정보를 찾을 수 없습니다."); + } + + // contracts 테이블에서 해당 벤더의 계약들의 프로젝트 조회 + const vendorProjects = await db + .selectDistinct({ + PROJ_NO: projects.code, + PROJ_NM: projects.name, + contract_count: sql `COUNT(DISTINCT ${contracts.id})::int`, + }) + .from(contracts) + .innerJoin(projects, eq(contracts.projectId, projects.id)) + .where(eq(contracts.vendorId, vendorInfo.vendorId)) + .groupBy(projects.id, projects.code, projects.name) + .orderBy(sql`COUNT(DISTINCT ${contracts.id}) DESC`); + + return vendorProjects; + } catch (error) { + console.error("[fetchVendorProjects] 오류:", error); + return []; + } +} + +// ============================================================================ +// 벤더 필터링된 문서 목록 조회 +// ============================================================================ + +export async function fetchVendorDocuments(params: SwpTableParams) { + try { + const vendorInfo = await getVendorSessionInfo(); + + if (!vendorInfo) { + throw new Error("벤더 정보를 찾을 수 없습니다."); + } + + // 벤더 코드를 필터에 자동 추가 + const vendorParams: SwpTableParams = { + ...params, + filters: { + ...params.filters, + vndrCd: vendorInfo.vendorCode, // 벤더 코드 필터 강제 적용 + }, + }; + + // 기존 fetchSwpDocuments 재사용 + return await fetchSwpDocuments(vendorParams); + } catch (error) { + console.error("[fetchVendorDocuments] 오류:", error); + throw new Error("문서 목록 조회 실패"); + } +} + +// ============================================================================ +// 파일 업로드 +// ============================================================================ + +export interface FileUploadParams { + revisionId: number; + file: { + FILE_NM: string; + FILE_SEQ: string; + FILE_SZ: string; + FLD_PATH: string; + STAT?: string; + STAT_NM?: string; + }; +} + +export async function uploadFileToRevision(params: FileUploadParams) { + try { + const vendorInfo = await getVendorSessionInfo(); + + if (!vendorInfo) { + throw new Error("벤더 정보를 찾을 수 없습니다."); + } + + const { revisionId, file } = params; + + // 1. 해당 리비전이 벤더에게 제공된 문서인지 확인 + const revisionCheck = await db + .select({ + DOC_NO: swpDocumentRevisions.DOC_NO, + VNDR_CD: sql `( + SELECT d."VNDR_CD" + FROM swp.swp_documents d + WHERE d."DOC_NO" = ${swpDocumentRevisions.DOC_NO} + )`, + }) + .from(swpDocumentRevisions) + .where(eq(swpDocumentRevisions.id, revisionId)) + .limit(1); + + if (!revisionCheck[0]) { + throw new Error("리비전을 찾을 수 없습니다."); + } + + // 벤더 코드가 일치하는지 확인 + if (revisionCheck[0].VNDR_CD !== vendorInfo.vendorCode) { + throw new Error("이 문서에 대한 권한이 없습니다."); + } + + // 2. 파일 정보 저장 (upsert) + const existingFile = await db + .select({ id: swpDocumentFiles.id }) + .from(swpDocumentFiles) + .where( + and( + eq(swpDocumentFiles.revision_id, revisionId), + eq(swpDocumentFiles.FILE_SEQ, file.FILE_SEQ) + ) + ) + .limit(1); + + if (existingFile[0]) { + // 업데이트 + await db.execute(sql` + UPDATE swp.swp_document_files + SET + "FILE_NM" = ${file.FILE_NM}, + "FILE_SZ" = ${file.FILE_SZ}, + "FLD_PATH" = ${file.FLD_PATH}, + "STAT" = ${file.STAT || null}, + "STAT_NM" = ${file.STAT_NM || null}, + sync_status = 'synced', + updated_at = NOW() + WHERE id = ${existingFile[0].id} + `); + + return { success: true, fileId: existingFile[0].id, action: "updated" }; + } else { + // 삽입 + const result = await db.execute<{ id: number }>(sql` + INSERT INTO swp.swp_document_files + (revision_id, "FILE_NM", "FILE_SEQ", "FILE_SZ", "FLD_PATH", "STAT", "STAT_NM", sync_status) + VALUES + (${revisionId}, ${file.FILE_NM}, ${file.FILE_SEQ}, ${file.FILE_SZ}, ${file.FLD_PATH}, ${file.STAT || null}, ${file.STAT_NM || null}, 'synced') + RETURNING id + `); + + return { success: true, fileId: result.rows[0].id, action: "created" }; + } + } catch (error) { + console.error("[uploadFileToRevision] 오류:", error); + throw new Error( + error instanceof Error ? error.message : "파일 업로드 실패" + ); + } +} + +// ============================================================================ +// 벤더 통계 조회 +// ============================================================================ + +export async function fetchVendorSwpStats(projNo?: string) { + try { + const vendorInfo = await getVendorSessionInfo(); + + if (!vendorInfo) { + throw new Error("벤더 정보를 찾을 수 없습니다."); + } + + const whereConditions = [ + sql`d."VNDR_CD" = ${vendorInfo.vendorCode}`, + ]; + + if (projNo) { + whereConditions.push(sql`d."PROJ_NO" = ${projNo}`); + } + + const stats = await db.execute<{ + total_documents: number; + total_revisions: number; + total_files: number; + uploaded_files: number; + last_sync: Date | null; + }>(sql` + SELECT + 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(CASE WHEN f."FLD_PATH" IS NOT NULL AND f."FLD_PATH" != '' THEN 1 END)::int as uploaded_files, + MAX(d.last_synced_at) as last_sync + 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 ${sql.join(whereConditions, sql` AND `)} + `); + + return stats.rows[0] || { + total_documents: 0, + total_revisions: 0, + total_files: 0, + uploaded_files: 0, + last_sync: null, + }; + } catch (error) { + console.error("[fetchVendorSwpStats] 오류:", error); + return { + total_documents: 0, + total_revisions: 0, + total_files: 0, + uploaded_files: 0, + last_sync: null, + }; + } +} + -- cgit v1.2.3