+
+
+ {/* 드래그 오버레이 */}
+ {isDragActive && (
+
+
+
+
+
파일을 여기에 드롭하세요
+
+ 여러 파일을 한 번에 업로드할 수 있습니다
+
+
+
+
)}
- {/* 통계 카드 */}
-
-
-
- 할당된 문서
- {stats.total_documents.toLocaleString()}
-
-
-
-
- 총 리비전
- {stats.total_revisions.toLocaleString()}
-
-
-
-
- 총 파일
- {stats.total_files.toLocaleString()}
-
-
+
+ {/* 에러 메시지 */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* 통계 카드 */}
+
+
+
+ 할당된 문서
+ {stats.total_documents.toLocaleString()}
+
+
+
+
+ 총 리비전
+ {stats.total_revisions.toLocaleString()}
+
+
+
+
+ 총 파일
+ {stats.total_files.toLocaleString()}
+
+
+
+
+ 업로드한 파일
+
+ {stats.uploaded_files.toLocaleString()}
+
+
+
+
+
+ {/* 안내 메시지 */}
+ {documents.length === 0 && !projNo && (
+
+
+
+ 프로젝트를 선택하여 할당된 문서를 확인하세요.
+
+
+ )}
+
+ {/* 메인 테이블 */}
-
- 업로드한 파일
-
- {stats.uploaded_files.toLocaleString()}
-
+
+ setDroppedFiles([])}
+ documents={filteredDocuments}
+ userId={String(vendorInfo?.vendorId || "")}
+ />
+
+
+
-
- {/* 안내 메시지 */}
- {documents.length === 0 && !filters.projNo && (
-
-
-
- 프로젝트를 선택하여 할당된 문서를 확인하세요.
-
-
- )}
-
- {/* 메인 테이블 */}
-
-
-
-
-
-
-
-
);
}
-
diff --git a/app/api/swp/upload/route.ts b/app/api/swp/upload/route.ts
index d17fcff7..b38c4ff4 100644
--- a/app/api/swp/upload/route.ts
+++ b/app/api/swp/upload/route.ts
@@ -26,7 +26,8 @@ interface InBoxFileInfo {
}
/**
- * 파일명 파싱: [OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDDhhmmss].[확장자]
+ * 파일명 파싱: [DOC_NO]_[REV_NO]_[STAGE]_[자유-파일명].[확장자]
+ * 자유 파일명에는 언더스코어가 포함될 수 있음
*/
function parseFileName(fileName: string) {
const lastDotIndex = fileName.lastIndexOf(".");
@@ -35,23 +36,38 @@ function parseFileName(fileName: string) {
const parts = nameWithoutExt.split("_");
- if (parts.length !== 4) {
+ // 최소 4개 파트 필요: docNo, revNo, stage, fileName
+ if (parts.length < 4) {
throw new Error(
`잘못된 파일명 형식입니다: ${fileName}. ` +
- `형식: [OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDDhhmmss].확장자`
+ `형식: [DOC_NO]_[REV_NO]_[STAGE]_[파일명].확장자 (언더스코어 최소 3개 필요)`
);
}
- const [ownDocNo, revNo, stage, timestamp] = parts;
+ // 앞에서부터 3개는 고정: docNo, revNo, stage
+ const ownDocNo = parts[0];
+ const revNo = parts[1];
+ const stage = parts[2];
+
+ // 나머지는 자유 파일명 (언더스코어 포함 가능)
+ const customFileName = parts.slice(3).join("_");
- if (!/^\d{14}$/.test(timestamp)) {
- throw new Error(
- `잘못된 타임스탬프 형식입니다: ${timestamp}. ` +
- `YYYYMMDDhhmmss 형식이어야 합니다.`
- );
- }
+ return { ownDocNo, revNo, stage, fileName: customFileName, extension };
+}
- return { ownDocNo, revNo, stage, timestamp, extension };
+/**
+ * 현재 시간을 YYYYMMDDhhmmss 형식으로 반환
+ */
+function generateTimestamp(): string {
+ const now = new Date();
+ const year = now.getFullYear().toString();
+ const month = (now.getMonth() + 1).toString().padStart(2, "0");
+ const day = now.getDate().toString().padStart(2, "0");
+ const hours = now.getHours().toString().padStart(2, "0");
+ const minutes = now.getMinutes().toString().padStart(2, "0");
+ const seconds = now.getSeconds().toString().padStart(2, "0");
+
+ return `${year}${month}${day}${hours}${minutes}${seconds}`;
}
/**
@@ -171,6 +187,10 @@ export async function POST(request: NextRequest) {
const inBoxFileInfos: InBoxFileInfo[] = [];
const swpMountDir = process.env.SWP_MOUNT_DIR || "/mnt/swp-smb-dir/";
+
+ // 업로드 시점의 timestamp 생성 (모든 파일에 동일한 timestamp 사용)
+ const uploadTimestamp = generateTimestamp();
+ console.log(`[upload] 업로드 타임스탬프 생성: ${uploadTimestamp}`);
for (const file of files) {
try {
@@ -178,8 +198,8 @@ export async function POST(request: NextRequest) {
const parsed = parseFileName(file.name);
console.log(`[upload] 파일명 파싱:`, parsed);
- // 네트워크 경로 생성
- const networkPath = path.join(swpMountDir, projNo, cpyCd, parsed.timestamp, file.name);
+ // 네트워크 경로 생성 (timestamp를 경로에만 사용)
+ const networkPath = path.join(swpMountDir, projNo, cpyCd, uploadTimestamp, file.name);
// 파일 중복 체크
try {
@@ -245,8 +265,8 @@ export async function POST(request: NextRequest) {
matchesOriginal: buffer.slice(0, 20).equals(verifyBuffer.slice(0, 20))
});
- // InBox 파일 정보 준비
- const fldPath = `\\\\${projNo}\\\\${cpyCd}\\\\${parsed.timestamp}`;
+ // InBox 파일 정보 준비 (FLD_PATH에 업로드 timestamp 사용)
+ const fldPath = `\\\\${projNo}\\\\${cpyCd}\\\\${uploadTimestamp}`;
inBoxFileInfos.push({
CPY_CD: cpyCd,
@@ -339,12 +359,19 @@ export async function POST(request: NextRequest) {
console.log(`[upload] 완료:`, { success, message, result });
+ // 동기화 완료 정보 추가
+ const syncCompleted = result.successCount > 0;
+ const syncTimestamp = new Date().toISOString();
+
return NextResponse.json({
success,
message,
successCount: result.successCount,
failedCount: result.failedCount,
details: result.details,
+ syncCompleted,
+ syncTimestamp,
+ affectedVndrCd: vndrCd,
});
} catch (error) {
console.error("[upload] 오류:", error);
diff --git a/hooks/use-swp-documents.ts b/hooks/use-swp-documents.ts
new file mode 100644
index 00000000..dca0ec9e
--- /dev/null
+++ b/hooks/use-swp-documents.ts
@@ -0,0 +1,163 @@
+"use client";
+
+import useSWR, { mutate } from "swr";
+import {
+ getDocumentList,
+ getDocumentDetail,
+ cancelStandbyFile,
+ downloadDocumentFile,
+ type DocumentListItem,
+ type DocumentDetail,
+ type DownloadFileResult,
+} from "@/lib/swp/document-service";
+
+// ============================================================================
+// SWR Hooks
+// ============================================================================
+
+/**
+ * 문서 목록 조회 Hook
+ * @param projNo 프로젝트 번호
+ * @param vndrCd 벤더 코드 (선택)
+ */
+export function useDocumentList(projNo: string | null, vndrCd?: string) {
+ const key = projNo ? ["swp-documents", projNo, vndrCd] : null;
+
+ return useSWR
(
+ key,
+ async () => {
+ if (!projNo) return [];
+ return getDocumentList(projNo, vndrCd);
+ },
+ {
+ revalidateOnFocus: false, // 포커스시 재검증 안함
+ revalidateOnReconnect: true, // 재연결시 재검증
+ dedupingInterval: 5000, // 5초간 중복 요청 방지
+ }
+ );
+}
+
+/**
+ * 문서 상세 조회 Hook (Rev-Activity-File 트리)
+ * @param projNo 프로젝트 번호
+ * @param docNo 문서 번호
+ */
+export function useDocumentDetail(
+ projNo: string | null,
+ docNo: string | null
+) {
+ const key = projNo && docNo ? ["swp-document-detail", projNo, docNo] : null;
+
+ return useSWR(
+ key,
+ async () => {
+ if (!projNo || !docNo) throw new Error("projNo and docNo required");
+ return getDocumentDetail(projNo, docNo);
+ },
+ {
+ revalidateOnFocus: false,
+ revalidateOnReconnect: true,
+ dedupingInterval: 2000, // 2초간 중복 요청 방지
+ shouldRetryOnError: false,
+ }
+ );
+}
+
+// ============================================================================
+// Mutation Helpers
+// ============================================================================
+
+/**
+ * 파일 취소
+ */
+export async function useCancelFile(
+ boxSeq: string,
+ actvSeq: string,
+ userId: string,
+ options?: {
+ onSuccess?: () => void;
+ onError?: (error: Error) => void;
+ }
+) {
+ try {
+ await cancelStandbyFile(boxSeq, actvSeq, userId);
+
+ // 문서 상세 캐시 무효화 (재조회)
+ await mutate(
+ (key: unknown) => Array.isArray(key) && key[0] === "swp-document-detail",
+ undefined,
+ { revalidate: true }
+ );
+
+ // 문서 목록 캐시도 무효화
+ await mutate(
+ (key: unknown) => Array.isArray(key) && key[0] === "swp-documents",
+ undefined,
+ { revalidate: true }
+ );
+
+ options?.onSuccess?.();
+ } catch (error) {
+ options?.onError?.(
+ error instanceof Error ? error : new Error("파일 취소 실패")
+ );
+ throw error;
+ }
+}
+
+/**
+ * 파일 다운로드
+ */
+export async function useDownloadFile(
+ projNo: string,
+ ownDocNo: string,
+ fileName: string,
+ options?: {
+ onSuccess?: () => void;
+ onError?: (error: string) => void;
+ }
+) {
+ try {
+ const result: DownloadFileResult = await downloadDocumentFile(
+ projNo,
+ ownDocNo,
+ fileName
+ );
+
+ if (!result.success || !result.data) {
+ const errorMsg = result.error || "파일 다운로드 실패";
+ options?.onError?.(errorMsg);
+ throw new Error(errorMsg);
+ }
+
+ // Blob을 다운로드
+ const blob = new Blob([Buffer.from(result.data)], { type: result.mimeType });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = result.fileName || fileName;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ options?.onSuccess?.();
+ } catch (error) {
+ const errorMsg =
+ error instanceof Error ? error.message : "파일 다운로드 실패";
+ options?.onError?.(errorMsg);
+ throw error;
+ }
+}
+
+/**
+ * 수동 새로고침 헬퍼
+ */
+export function refreshDocumentList(projNo: string, vndrCd?: string) {
+ return mutate(["swp-documents", projNo, vndrCd]);
+}
+
+export function refreshDocumentDetail(projNo: string, docNo: string) {
+ return mutate(["swp-document-detail", projNo, docNo]);
+}
+
diff --git a/lib/swp/actions.ts b/lib/swp/actions.ts
index a7b4d3a3..eea8c9c2 100644
--- a/lib/swp/actions.ts
+++ b/lib/swp/actions.ts
@@ -474,3 +474,63 @@ function getMimeType(fileName: string): string {
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");
+ await callSaveInBoxListCancelStatus({
+ boxSeq: params.boxSeq,
+ actvSeq: params.actvSeq,
+ chgr: `evcp${params.userId}`,
+ });
+
+ debugSuccess(`[cancelVendorUploadedFile] 취소 완료`, params);
+
+ return { success: true };
+ } 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 9ce8c5c1..3ac980fb 100644
--- a/lib/swp/api-client.ts
+++ b/lib/swp/api-client.ts
@@ -1,10 +1,5 @@
"use server";
-import type {
- SwpDocumentApiResponse,
- SwpFileApiResponse,
-} from "./sync-service";
-
// ============================================================================
// SWP API 클라이언트
// ============================================================================
@@ -47,6 +42,72 @@ export interface GetExternalInboxListFilter {
doctitle?: string;
}
+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;
+}
+
// ============================================================================
// 공통 API 호출 함수
// ============================================================================
@@ -302,3 +363,92 @@ export async function analyzeSwpData(
};
}
+// ============================================================================
+// 서버 액션: Activity 및 파일 리스트 조회 (GetActivityFileList)
+// ============================================================================
+
+/**
+ * Activity 파일 리스트 조회 필터
+ */
+export interface GetActivityFileListFilter {
+ proj_no: string;
+ doc_no: string;
+ rev_seq?: string; // 선택적
+}
+
+/**
+ * Activity 파일 API 응답
+ */
+export interface ActivityFileApiResponse {
+ ACTV_NO: string;
+ ACT_TYPE: string;
+ DOC_NO: string;
+ DOC_TITLE: string;
+ REV_NO: string;
+ REV_SEQ: string;
+ STAGE: string;
+ STAT: string; // R00=Receive, S30=Send, V00=Review
+ FILE_TYPE: string; // "Receive", "Send", "Review"
+ FILE_NM: string;
+ FILE_SEQ: string;
+ FILE_SZ: string;
+ FILE_FMT: string;
+ OWN_DOC_NO: string;
+ TO_FROM: string; // 업체명
+ OBJT_ID: string;
+ DSC: string | null;
+ BATCHUPLOAD_ID: string | null;
+ TRNS_DTM: string | null;
+ CRTER: string;
+ CRTE_DTM: string;
+}
+
+/**
+ * Activity 파일 리스트 조회 (GetActivityFileList)
+ * @param filter 조회 필터
+ */
+export async function fetchGetActivityFileList(
+ filter: GetActivityFileListFilter
+): Promise {
+ const body = {
+ proj_no: filter.proj_no,
+ doc_no: filter.doc_no,
+ rev_seq: filter.rev_seq || "",
+ };
+
+ return callSwpApi(
+ "GetActivityFileList",
+ body,
+ "GetActivityFileListResult"
+ );
+}
+
+// ============================================================================
+// 서버 액션: 파일 취소 (SaveInBoxListCancelStatus)
+// ============================================================================
+
+export interface CancelFileParams {
+ boxSeq: string;
+ actvSeq: string;
+ chgr: string; // 취소 요청자 (evcp${userId})
+}
+
+/**
+ * 파일 취소 API (SaveInBoxListCancelStatus)
+ */
+export async function callSaveInBoxListCancelStatus(
+ params: CancelFileParams
+): Promise {
+ const body = {
+ boxSeq: params.boxSeq,
+ actvSeq: params.actvSeq,
+ chgr: params.chgr,
+ };
+
+ await callSwpApi(
+ "SaveInBoxListCancelStatus",
+ body,
+ "SaveInBoxListCancelStatusResult"
+ );
+}
+
diff --git a/lib/swp/document-service.ts b/lib/swp/document-service.ts
new file mode 100644
index 00000000..49e4da4c
--- /dev/null
+++ b/lib/swp/document-service.ts
@@ -0,0 +1,476 @@
+"use server";
+
+import {
+ fetchGetVDRDocumentList,
+ fetchGetExternalInboxList,
+ fetchGetActivityFileList,
+ callSaveInBoxListCancelStatus,
+ type SwpDocumentApiResponse,
+ type SwpFileApiResponse,
+ type ActivityFileApiResponse,
+} from "./api-client";
+import { debugLog, debugError, debugSuccess, debugWarn } from "@/lib/debug-utils";
+import * as fs from "fs/promises";
+import * as path from "path";
+
+// ============================================================================
+// 타입 정의
+// ============================================================================
+
+/**
+ * 파일 정보 (Activity 파일 + Inbox 파일 결합)
+ */
+export interface DocumentFile {
+ fileNm: string;
+ fileSeq: string;
+ fileSz: string;
+ fileFmt: string;
+ fldPath?: string;
+ stat?: string; // SCW01=Standby, SCW03=Complete 등
+ statNm?: string;
+ canCancel: boolean; // STAT=SCW01인 경우만 취소 가능
+ canDownload: boolean; // FLD_PATH가 있으면 다운로드 가능
+ boxSeq?: string;
+ actvSeq?: string;
+ objId: string;
+ crteDate: string;
+}
+
+/**
+ * Activity 정보
+ */
+export interface Activity {
+ actvNo: string;
+ type: "Receive" | "Send" | "Review"; // STAT 첫 글자로 판단
+ stat: string; // R00, S30 등
+ toFrom: string; // 업체명
+ createDate: string;
+ files: DocumentFile[];
+}
+
+/**
+ * Revision 정보
+ */
+export interface Revision {
+ revNo: string;
+ revSeq: string;
+ stage: string;
+ activities: Activity[];
+ totalFiles: number;
+}
+
+/**
+ * 문서 상세 (Rev-Activity-File 트리)
+ */
+export interface DocumentDetail {
+ docNo: string;
+ docTitle: string;
+ projNo: string;
+ revisions: Revision[];
+}
+
+/**
+ * 문서 목록 아이템 (통계 포함)
+ */
+export interface DocumentListItem extends SwpDocumentApiResponse {
+ fileCount: number;
+ standbyFileCount: number; // STAT=SCW01
+ latestFiles: SwpFileApiResponse[];
+}
+
+// ============================================================================
+// 문서 목록 조회 (시나리오 1)
+// ============================================================================
+
+/**
+ * 문서 목록 조회
+ * - GetVDRDocumentList + GetExternalInboxList 병합
+ * - 파일 통계 계산
+ */
+export async function getDocumentList(
+ projNo: string,
+ vndrCd?: string
+): Promise {
+ debugLog("[getDocumentList] 시작", { projNo, vndrCd });
+
+ try {
+ // 병렬 API 호출
+ const [documents, allFiles] = await Promise.all([
+ fetchGetVDRDocumentList({
+ proj_no: projNo,
+ doc_gb: "V",
+ vndrCd: vndrCd,
+ }),
+ fetchGetExternalInboxList({
+ projNo: projNo,
+ vndrCd: vndrCd,
+ }),
+ ]);
+
+ debugLog("[getDocumentList] API 조회 완료", {
+ documents: documents.length,
+ files: allFiles.length,
+ });
+
+ // 파일을 문서별로 그룹핑
+ const filesByDoc = new Map();
+ for (const file of allFiles) {
+ const docNo = file.OWN_DOC_NO;
+ if (!filesByDoc.has(docNo)) {
+ filesByDoc.set(docNo, []);
+ }
+ filesByDoc.get(docNo)!.push(file);
+ }
+
+ // 문서에 파일 통계 추가
+ const result = documents.map((doc) => {
+ const files = filesByDoc.get(doc.DOC_NO) || [];
+ const standbyFiles = files.filter((f) => f.STAT === "SCW01");
+
+ return {
+ ...doc,
+ fileCount: files.length,
+ standbyFileCount: standbyFiles.length,
+ latestFiles: files
+ .sort((a, b) => b.CRTE_DTM.localeCompare(a.CRTE_DTM))
+ .slice(0, 5), // 최신 5개만
+ };
+ });
+
+ debugSuccess("[getDocumentList] 완료", { count: result.length });
+ return result;
+ } catch (error) {
+ debugError("[getDocumentList] 실패", error);
+ throw new Error(
+ error instanceof Error ? error.message : "문서 목록 조회 실패"
+ );
+ }
+}
+
+// ============================================================================
+// 문서 상세 조회 (Rev-Activity-File 트리) (시나리오 1 상세)
+// ============================================================================
+
+/**
+ * 문서 상세 조회
+ * - GetActivityFileList + GetExternalInboxList 결합
+ * - Rev → Activity → File 트리 구성
+ */
+export async function getDocumentDetail(
+ projNo: string,
+ docNo: string
+): Promise {
+ debugLog("[getDocumentDetail] 시작", { projNo, docNo });
+
+ try {
+ // 병렬 API 호출
+ const [activityFiles, inboxFiles] = await Promise.all([
+ fetchGetActivityFileList({ proj_no: projNo, doc_no: docNo }),
+ fetchGetExternalInboxList({ projNo: projNo, owndocno: docNo }),
+ ]);
+
+ debugLog("[getDocumentDetail] API 조회 완료", {
+ activityFiles: activityFiles.length,
+ inboxFiles: inboxFiles.length,
+ });
+
+ // Inbox 파일을 빠른 조회를 위해 Map으로 변환
+ const inboxFileMap = new Map();
+ for (const file of inboxFiles) {
+ const key = `${file.OWN_DOC_NO}|${file.FILE_NM}`;
+ inboxFileMap.set(key, file);
+ }
+
+ // 트리 구조 빌드
+ const tree = buildDocumentTree(activityFiles, inboxFileMap);
+
+ debugSuccess("[getDocumentDetail] 완료", {
+ docNo: tree.docNo,
+ revisions: tree.revisions.length,
+ });
+
+ return tree;
+ } catch (error) {
+ debugError("[getDocumentDetail] 실패", error);
+ throw new Error(
+ error instanceof Error ? error.message : "문서 상세 조회 실패"
+ );
+ }
+}
+
+/**
+ * Rev-Activity-File 트리 빌더
+ */
+function buildDocumentTree(
+ activityFiles: ActivityFileApiResponse[],
+ inboxFileMap: Map
+): DocumentDetail {
+ if (activityFiles.length === 0) {
+ return {
+ docNo: "",
+ docTitle: "",
+ projNo: "",
+ revisions: [],
+ };
+ }
+
+ const first = activityFiles[0];
+
+ // REV_NO로 그룹핑
+ const revisionMap = new Map();
+ for (const item of activityFiles) {
+ const revKey = `${item.REV_NO}|${item.REV_SEQ}`;
+ if (!revisionMap.has(revKey)) {
+ revisionMap.set(revKey, []);
+ }
+ revisionMap.get(revKey)!.push(item);
+ }
+
+ // 각 리비전 처리
+ const revisions: Revision[] = [];
+ for (const [revKey, revFiles] of revisionMap) {
+ const [revNo, revSeq] = revKey.split("|");
+ const stage = revFiles[0].STAGE;
+
+ // ACTV_NO로 그룹핑
+ const activityMap = new Map();
+ for (const item of revFiles) {
+ if (!activityMap.has(item.ACTV_NO)) {
+ activityMap.set(item.ACTV_NO, []);
+ }
+ activityMap.get(item.ACTV_NO)!.push(item);
+ }
+
+ // 각 액티비티 처리
+ const activities: Activity[] = [];
+ for (const [actvNo, actvFiles] of activityMap) {
+ const firstActvFile = actvFiles[0];
+
+ // 파일 정보에 Inbox 데이터 결합
+ const files: DocumentFile[] = actvFiles.map((af) => {
+ const inboxFile = inboxFileMap.get(`${af.OWN_DOC_NO}|${af.FILE_NM}`);
+
+ return {
+ fileNm: af.FILE_NM,
+ fileSeq: af.FILE_SEQ,
+ fileSz: af.FILE_SZ,
+ fileFmt: af.FILE_FMT,
+ fldPath: inboxFile?.FLD_PATH,
+ stat: inboxFile?.STAT,
+ statNm: inboxFile?.STAT_NM,
+ canCancel: inboxFile?.STAT === "SCW01", // Standby만 취소 가능
+ canDownload: !!inboxFile?.FLD_PATH,
+ boxSeq: inboxFile?.BOX_SEQ,
+ actvSeq: inboxFile?.ACTV_SEQ,
+ objId: af.OBJT_ID,
+ crteDate: af.CRTE_DTM,
+ };
+ });
+
+ activities.push({
+ actvNo: actvNo,
+ type: getActivityType(firstActvFile.STAT),
+ stat: firstActvFile.STAT,
+ toFrom: firstActvFile.TO_FROM,
+ createDate: firstActvFile.CRTE_DTM,
+ files: files,
+ });
+ }
+
+ revisions.push({
+ revNo: revNo,
+ revSeq: revSeq,
+ stage: stage,
+ activities: activities.sort((a, b) =>
+ b.createDate.localeCompare(a.createDate)
+ ),
+ totalFiles: revFiles.length,
+ });
+ }
+
+ return {
+ docNo: first.DOC_NO,
+ docTitle: first.DOC_TITLE,
+ projNo: first.OWN_DOC_NO.split("-")[0] || "", // 프로젝트 코드 추출
+ revisions: revisions.sort((a, b) => b.revNo.localeCompare(a.revNo)),
+ };
+}
+
+/**
+ * STAT 코드로 Activity 타입 판단
+ */
+function getActivityType(stat: string): "Receive" | "Send" | "Review" {
+ const firstChar = stat.charAt(0).toUpperCase();
+ if (firstChar === "R") return "Receive";
+ if (firstChar === "S") return "Send";
+ if (firstChar === "V") return "Review";
+ return "Send"; // 기본값
+}
+
+// ============================================================================
+// 파일 취소 (시나리오 1-1)
+// ============================================================================
+
+/**
+ * Standby 상태 파일 취소
+ */
+export async function cancelStandbyFile(
+ boxSeq: string,
+ actvSeq: string,
+ userId: string
+): Promise {
+ debugLog("[cancelStandbyFile] 시작", { boxSeq, actvSeq, userId });
+
+ try {
+ // varchar(13) 제한
+ const chgr = `evcp${userId}`.substring(0, 13);
+
+ await callSaveInBoxListCancelStatus({
+ boxSeq: boxSeq,
+ actvSeq: actvSeq,
+ chgr: chgr,
+ });
+
+ debugSuccess("[cancelStandbyFile] 완료", { boxSeq, actvSeq });
+ } catch (error) {
+ debugError("[cancelStandbyFile] 실패", error);
+ throw new Error(
+ error instanceof Error ? error.message : "파일 취소 실패"
+ );
+ }
+}
+
+// ============================================================================
+// 파일 다운로드 (시나리오 1-2)
+// ============================================================================
+
+export interface DownloadFileResult {
+ success: boolean;
+ data?: Uint8Array;
+ fileName?: string;
+ mimeType?: string;
+ error?: string;
+}
+
+/**
+ * 문서 파일 다운로드
+ * - GetExternalInboxList에서 FLD_PATH 조회
+ * - 네트워크 드라이브에서 파일 읽기
+ */
+export async function downloadDocumentFile(
+ projNo: string,
+ ownDocNo: string,
+ fileName: string
+): Promise {
+ debugLog("[downloadDocumentFile] 시작", { projNo, ownDocNo, fileName });
+
+ try {
+ // 1. GetExternalInboxList에서 파일 정보 찾기
+ const files = await fetchGetExternalInboxList({
+ projNo: projNo,
+ owndocno: ownDocNo,
+ });
+
+ const targetFile = files.find((f) => f.FILE_NM === fileName);
+
+ if (!targetFile || !targetFile.FLD_PATH) {
+ debugWarn("[downloadDocumentFile] 파일 없음", { fileName });
+ return {
+ success: false,
+ error: "파일을 찾을 수 없습니다",
+ };
+ }
+
+ debugLog("[downloadDocumentFile] 파일 정보 조회 완료", {
+ fileName: targetFile.FILE_NM,
+ fldPath: targetFile.FLD_PATH,
+ });
+
+ // 2. NFS 마운트 경로 확인
+ const nfsBasePath = process.env.SWP_MOUNT_DIR;
+ if (!nfsBasePath) {
+ debugError("[downloadDocumentFile] SWP_MOUNT_DIR 미설정");
+ return {
+ success: false,
+ error: "서버 설정 오류: NFS 경로가 설정되지 않았습니다",
+ };
+ }
+
+ // 3. 전체 파일 경로 생성
+ const normalizedFldPath = targetFile.FLD_PATH.replace(/\\/g, "/");
+ const fullPath = path.join(nfsBasePath, normalizedFldPath, targetFile.FILE_NM);
+
+ debugLog("[downloadDocumentFile] 파일 경로", { fullPath });
+
+ // 4. 파일 존재 여부 확인
+ try {
+ await fs.access(fullPath, fs.constants.R_OK);
+ } catch (accessError) {
+ debugError("[downloadDocumentFile] 파일 접근 불가", accessError);
+ return {
+ success: false,
+ error: `파일을 찾을 수 없습니다: ${targetFile.FILE_NM}`,
+ };
+ }
+
+ // 5. 파일 읽기
+ const fileBuffer = await fs.readFile(fullPath);
+ const fileData = new Uint8Array(fileBuffer);
+
+ // 6. MIME 타입 결정
+ const mimeType = getMimeType(targetFile.FILE_NM);
+
+ debugSuccess("[downloadDocumentFile] 완료", {
+ fileName: targetFile.FILE_NM,
+ size: fileData.length,
+ mimeType,
+ });
+
+ return {
+ success: true,
+ data: fileData,
+ fileName: targetFile.FILE_NM,
+ mimeType,
+ };
+ } catch (error) {
+ debugError("[downloadDocumentFile] 실패", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "파일 다운로드 실패",
+ };
+ }
+}
+
+/**
+ * MIME 타입 결정
+ */
+function getMimeType(fileName: string): string {
+ const ext = path.extname(fileName).toLowerCase();
+
+ const mimeTypes: Record = {
+ ".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";
+}
+
diff --git a/lib/swp/table/swp-document-detail-dialog.tsx b/lib/swp/table/swp-document-detail-dialog.tsx
new file mode 100644
index 00000000..418ddea9
--- /dev/null
+++ b/lib/swp/table/swp-document-detail-dialog.tsx
@@ -0,0 +1,412 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ Loader2,
+ ChevronDown,
+ ChevronRight,
+ Download,
+ FileIcon,
+ XCircle,
+ AlertCircle,
+} from "lucide-react";
+import {
+ fetchVendorDocumentDetail,
+ cancelVendorFile,
+ downloadVendorFile,
+} from "@/lib/swp/vendor-actions";
+import type { DocumentListItem, DocumentDetail } from "@/lib/swp/document-service";
+import { toast } from "sonner";
+
+interface SwpDocumentDetailDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ document: DocumentListItem | null;
+ projNo: string;
+ vendorCode: string;
+ userId: string;
+}
+
+export function SwpDocumentDetailDialog({
+ open,
+ onOpenChange,
+ document,
+ projNo,
+}: SwpDocumentDetailDialogProps) {
+ const [detail, setDetail] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [expandedRevisions, setExpandedRevisions] = useState>(new Set());
+ const [expandedActivities, setExpandedActivities] = useState>(new Set());
+ const [isAllExpanded, setIsAllExpanded] = useState(true); // 기본값 true
+
+ // 문서 상세 로드
+ useEffect(() => {
+ if (open && document) {
+ loadDocumentDetail();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open, document?.DOC_NO]);
+
+ const loadDocumentDetail = async () => {
+ if (!document) return;
+
+ setIsLoading(true);
+ try {
+ const detailData = await fetchVendorDocumentDetail(projNo, document.DOC_NO);
+ setDetail(detailData);
+
+ // 모든 리비전 자동 펼치기
+ const allRevKeys = new Set();
+ const allActKeys = new Set();
+
+ detailData.revisions.forEach((revision) => {
+ const revKey = `${revision.revNo}|${revision.revSeq}`;
+ allRevKeys.add(revKey);
+
+ // 모든 액티비티도 자동 펼치기
+ revision.activities.forEach((activity) => {
+ const actKey = `${revKey}|${activity.actvNo}`;
+ allActKeys.add(actKey);
+ });
+ });
+
+ setExpandedRevisions(allRevKeys);
+ setExpandedActivities(allActKeys);
+ setIsAllExpanded(true);
+ } catch (error) {
+ console.error("문서 상세 조회 실패:", error);
+ toast.error("문서 상세 정보를 불러오는데 실패했습니다");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const toggleRevision = (revKey: string) => {
+ setExpandedRevisions((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(revKey)) {
+ newSet.delete(revKey);
+ } else {
+ newSet.add(revKey);
+ }
+ return newSet;
+ });
+ };
+
+ const toggleActivity = (actKey: string) => {
+ setExpandedActivities((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(actKey)) {
+ newSet.delete(actKey);
+ } else {
+ newSet.add(actKey);
+ }
+ return newSet;
+ });
+ };
+
+ // 일괄 열기/닫기
+ const handleToggleAll = () => {
+ if (!detail) return;
+
+ if (isAllExpanded) {
+ // 모두 닫기
+ setExpandedRevisions(new Set());
+ setExpandedActivities(new Set());
+ setIsAllExpanded(false);
+ } else {
+ // 모두 열기
+ const allRevKeys = new Set();
+ const allActKeys = new Set();
+
+ detail.revisions.forEach((revision) => {
+ const revKey = `${revision.revNo}|${revision.revSeq}`;
+ allRevKeys.add(revKey);
+
+ revision.activities.forEach((activity) => {
+ const actKey = `${revKey}|${activity.actvNo}`;
+ allActKeys.add(actKey);
+ });
+ });
+
+ setExpandedRevisions(allRevKeys);
+ setExpandedActivities(allActKeys);
+ setIsAllExpanded(true);
+ }
+ };
+
+ const handleCancelFile = async (boxSeq: string, actvSeq: string, fileName: string) => {
+ try {
+ await cancelVendorFile(boxSeq, actvSeq);
+ toast.success(`파일 취소 완료: ${fileName}`);
+
+ // 문서 상세 재로드
+ await loadDocumentDetail();
+ } catch (error) {
+ console.error("파일 취소 실패:", error);
+ toast.error("파일 취소에 실패했습니다");
+ }
+ };
+
+ const handleDownloadFile = async (fileName: string, ownDocNo: string) => {
+ try {
+ toast.info("파일 다운로드 중...");
+ const result = await downloadVendorFile(projNo, ownDocNo, fileName);
+
+ if (!result.success || !result.data) {
+ toast.error(result.error || "파일 다운로드 실패");
+ return;
+ }
+
+ // Blob 생성 및 다운로드
+ const blob = new Blob([Buffer.from(result.data)], { type: result.mimeType });
+ const url = URL.createObjectURL(blob);
+ const link = window.document.createElement("a");
+ link.href = url;
+ link.download = result.fileName || fileName;
+ window.document.body.appendChild(link);
+ link.click();
+ window.document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+
+ toast.success(`파일 다운로드 완료: ${fileName}`);
+ } catch (error) {
+ console.error("파일 다운로드 실패:", error);
+ toast.error("파일 다운로드에 실패했습니다");
+ }
+ };
+
+ return (
+
+ );
+}
+
+function formatFileSize(sizeStr: string): string {
+ const bytes = parseInt(sizeStr, 10);
+ if (isNaN(bytes)) return sizeStr;
+
+ const kb = bytes / 1024;
+ const mb = kb / 1024;
+
+ return mb >= 1 ? `${mb.toFixed(2)} MB` : `${kb.toFixed(2)} KB`;
+}
+
diff --git a/lib/swp/table/swp-help-dialog.tsx b/lib/swp/table/swp-help-dialog.tsx
index 6880a8c7..c6c5296b 100644
--- a/lib/swp/table/swp-help-dialog.tsx
+++ b/lib/swp/table/swp-help-dialog.tsx
@@ -21,7 +21,7 @@ export function SwpUploadHelpDialog() {
업로드 가이드
-
+
파일 업로드 가이드
@@ -34,10 +34,13 @@ export function SwpUploadHelpDialog() {
파일명 형식
- [OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDDhhmmss].[확장자]
+ [DOC_NO]_[REV_NO]_[STAGE].[확장자]
- ⚠️ 언더스코어(_)가 정확히 3개 있어야 합니다
+ ⚠️ 언더스코어(_)가 최소 2개 이상 있어야 합니다
+
+
+ ℹ️ 선택사항: 4번째 항목으로 파일명을 추가할 수 있습니다 (예: [DOC_NO]_[REV_NO]_[STAGE]_[파일명].[확장자])
@@ -47,7 +50,7 @@ export function SwpUploadHelpDialog() {
- OWN_DOC_NO
+ DOC_NO
벤더의 문서번호
@@ -77,11 +80,11 @@ export function SwpUploadHelpDialog() {
- YYYYMMDDhhmmss
+ 파일명
- 날짜 및 시간
- - 업로드 날짜 정보를 기입합니다 (14자리 숫자)
+ 자유 파일명 (선택사항)
+ - 문서를 식별할 수 있는 이름 (언더스코어 포함 가능, 생략 가능)
@@ -92,13 +95,35 @@ export function SwpUploadHelpDialog() {
- VD-DOC-001_01_IFA_20250124143000.pdf
+ VD-DOC-001_01_IFA.pdf
+
+
+ ✓ 기본 형식 (파일명 생략)
+
+
+
+
+ VD-DOC-001_01_IFA_drawing_final.pdf
+
+
+ ✓ 파일명 추가 (파일명에 언더스코어 포함 가능)
+
+
+
+
+ TECH-SPEC-002_02_IFC.dwg
+
+ ✓ 기본 형식 사용
+
- TECH-SPEC-002_02_IFC_20250124150000.dwg
+ DOC-003_03_IFA_test_result_data.xlsx
+
+ ✓ 파일명 추가 (여러 단어 조합 가능)
+
@@ -109,7 +134,7 @@ export function SwpUploadHelpDialog() {
- VD-DOC-001-01-IFA-20250124.pdf
+ VD-DOC-001-01-IFA.pdf
✗ 언더스코어(_) 대신 하이픈(-) 사용
@@ -117,18 +142,26 @@ export function SwpUploadHelpDialog() {
- VD-DOC-001_01_IFA.pdf
+ VD-DOC-001_01.pdf
+
+
+ ✗ STAGE 정보 누락 (최소 3개 항목 필요)
+
+
+
+
+ VD DOC 001_01_IFA.pdf
- ✗ 날짜/시간 정보 누락
+ ✗ 공백 포함 (언더스코어 사용 필요)
- VD-DOC-001_01_IFA_20250124.pdf
+ VD-DOC-001__IFA.pdf
- ✗ 시간 정보 누락 (14자리가 아님)
+ ✗ REV_NO 비어있음 (빈 항목 불가)
@@ -140,7 +173,10 @@ export function SwpUploadHelpDialog() {
⚠️ 주의사항
- - 파일명 형식이 올바르지 않으면 업로드가 실패합니다
+ - 파일명은 최소 [DOC_NO]_[REV_NO]_[STAGE].[확장자] 형식이어야 합니다
+ - DOC_NO는 현재 프로젝트에 할당된 문서번호여야 합니다
+ - 4번째 항목(파일명)은 선택사항으로 생략 가능합니다
+ - 업로드 날짜/시간은 시스템에서 자동으로 생성됩니다
- 같은 파일명으로 이미 업로드된 파일이 있으면 덮어쓰지 않고 오류 처리됩니다
- 프로젝트와 업체 코드를 먼저 선택해야 업로드 버튼이 활성화됩니다
diff --git a/lib/swp/table/swp-table-columns.tsx b/lib/swp/table/swp-table-columns.tsx
index 9aecea96..e6abd2a0 100644
--- a/lib/swp/table/swp-table-columns.tsx
+++ b/lib/swp/table/swp-table-columns.tsx
@@ -2,43 +2,26 @@
import { ColumnDef } from "@tanstack/react-table";
import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import { ChevronDown, ChevronRight, FileIcon, Download, Loader2 } from "lucide-react";
-import { formatDistanceToNow } from "date-fns";
-import { ko } from "date-fns/locale";
-import type { SwpDocumentWithStats } from "../actions";
-import { useState } from "react";
-import { toast } from "sonner";
+import type { DocumentListItem } from "@/lib/swp/document-service";
-export const swpDocumentColumns: ColumnDef[] = [
+export const swpDocumentColumns: ColumnDef[] = [
{
id: "expander",
header: () => null,
- cell: () => {
- return (
-
- );
- },
+ cell: () => null,
size: 50,
},
{
accessorKey: "LTST_ACTV_STAT",
- header: "상태 (최신 액티비티)",
+ header: "상태",
cell: ({ row }) => {
const status = row.original.LTST_ACTV_STAT;
if (!status) return "-";
- // 상태에 따른 색상 설정 (필요에 따라 조정 가능)
const color =
- status === "Complete" ? "bg-green-100 text-green-800" :
- status === "In Progress" ? "bg-blue-100 text-blue-800" :
- status === "Pending" ? "bg-yellow-100 text-yellow-800" :
+ status.includes("Complete") ? "bg-green-100 text-green-800" :
+ status.includes("Progress") ? "bg-blue-100 text-blue-800" :
+ status.includes("Pending") || status.includes("Ready") ? "bg-yellow-100 text-yellow-800" :
"bg-gray-100 text-gray-800";
return (
@@ -47,7 +30,7 @@ export const swpDocumentColumns: ColumnDef[] = [
);
},
- size: 100,
+ size: 120,
},
{
accessorKey: "DOC_NO",
@@ -61,7 +44,7 @@ export const swpDocumentColumns: ColumnDef[] = [
accessorKey: "DOC_TITLE",
header: "문서제목",
cell: ({ row }) => (
-
+
{row.original.DOC_TITLE}
),
@@ -74,7 +57,7 @@ export const swpDocumentColumns: ColumnDef
[] = [
{row.original.PROJ_NO}
{row.original.PROJ_NM && (
-
+
{row.original.PROJ_NM}
)}
@@ -127,325 +110,25 @@ export const swpDocumentColumns: ColumnDef
[] = [
},
{
accessorKey: "LTST_REV_NO",
- header: "마지막 REV NO",
+ header: "최신 REV",
cell: ({ row }) => row.original.LTST_REV_NO || "-",
size: 80,
},
{
id: "stats",
- header: "REV/파일",
+ header: "파일",
cell: ({ row }) => (
- {row.original.revision_count} / {row.original.file_count}
+ {row.original.fileCount}개
+ {row.original.standbyFileCount > 0 && (
+
+ 대기중 {row.original.standbyFileCount}
+
+ )}
),
size: 100,
},
- {
- 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 }) => (
-
-
- {row.original.file_count}
-
- ),
- size: 100,
- },
- {
- 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 }) => (
-
-
- {row.original.FILE_NM}
-
- ),
- 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 (
-
- {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,
- },
- {
- id: "actions",
- header: "작업",
- cell: ({ row }) => (
-
- ),
- size: 120,
- },
];
-
-// ============================================================================
-// 다운로드 버튼 컴포넌트: 임시 구성. Download.aspx 동작 안해서 일단 네트워크드라이브 사용하도록 처리
-// ============================================================================
-
-interface DownloadButtonProps {
- fileId: number;
- fileName: string;
-}
-
-function DownloadButton({ fileId, fileName }: DownloadButtonProps) {
- const [isDownloading, setIsDownloading] = useState(false);
-
- const handleDownload = async () => {
- try {
- setIsDownloading(true);
-
- // API Route 호출 (바이너리 직접 전송)
- const response = await fetch(`/api/swp/download/${fileId}`);
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({ error: "다운로드 실패" }));
- toast.error(errorData.error || "파일 다운로드 실패");
- return;
- }
-
- // Blob 생성 및 다운로드
- const blob = await response.blob();
- const url = window.URL.createObjectURL(blob);
- const link = document.createElement("a");
- link.href = url;
- link.download = fileName;
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- window.URL.revokeObjectURL(url);
-
- toast.success(`파일 다운로드 완료: ${fileName}`);
- } catch (error) {
- console.error("다운로드 오류:", error);
- toast.error("파일 다운로드 중 오류가 발생했습니다.");
- } finally {
- setIsDownloading(false);
- }
- };
-
- return (
-
- );
-}
-
diff --git a/lib/swp/table/swp-table-toolbar.tsx b/lib/swp/table/swp-table-toolbar.tsx
index fefff091..0fd29fd3 100644
--- a/lib/swp/table/swp-table-toolbar.tsx
+++ b/lib/swp/table/swp-table-toolbar.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useTransition, useMemo } from "react";
+import { useState, useTransition, useMemo, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -9,94 +9,137 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { Label } from "@/components/ui/label";
-import { RefreshCw, Search, X, Check, ChevronsUpDown, Upload } from "lucide-react";
-import { syncSwpProjectAction, type SwpTableFilters } from "../actions";
+import { Search, X, Check, ChevronsUpDown, Upload, RefreshCw } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
-import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
-import { useRef } from "react";
import { SwpUploadHelpDialog } from "./swp-help-dialog";
import { SwpUploadResultDialog } from "./swp-upload-result-dialog";
+import {
+ SwpUploadValidationDialog,
+ validateFileName
+} from "./swp-upload-validation-dialog";
+import { SwpUploadedFilesDialog } from "./swp-uploaded-files-dialog";
+
+interface SwpTableFilters {
+ docNo?: string;
+ docTitle?: string;
+ pkgNo?: string;
+ stage?: string;
+}
interface SwpTableToolbarProps {
+ projNo: string;
filters: SwpTableFilters;
+ onProjNoChange: (projNo: string) => void;
onFiltersChange: (filters: SwpTableFilters) => void;
- projects?: Array<{ PROJ_NO: string; PROJ_NM: string }>;
- vendorCode?: string; // 벤더가 접속했을 때 고정할 벤더 코드
+ onRefresh: () => void;
+ isRefreshing: boolean;
+ projects?: Array<{ PROJ_NO: string; PROJ_NM: string | null }>;
+ vendorCode?: string;
+ droppedFiles?: File[];
+ onFilesProcessed?: () => void;
+ documents?: Array<{ DOC_NO: string }>; // 업로드 권한 검증용 문서 목록
+ userId?: string; // 파일 취소 시 필요
}
export function SwpTableToolbar({
+ projNo,
filters,
+ onProjNoChange,
onFiltersChange,
+ onRefresh,
+ isRefreshing,
projects = [],
vendorCode,
+ droppedFiles = [],
+ onFilesProcessed,
+ documents = [],
+ userId,
}: SwpTableToolbarProps) {
- const [isSyncing, startSync] = useTransition();
const [isUploading, startUpload] = useTransition();
- const [localFilters, setLocalFilters] = useState(filters);
+ const [localFilters, setLocalFilters] = useState(filters);
const { toast } = useToast();
- const router = useRouter();
const [projectSearchOpen, setProjectSearchOpen] = useState(false);
const [projectSearch, setProjectSearch] = useState("");
const fileInputRef = useRef(null);
const [uploadResults, setUploadResults] = useState>([]);
const [showResultDialog, setShowResultDialog] = useState(false);
+
+ // 검증 다이얼로그 상태
+ const [validationResults, setValidationResults] = useState>([]);
+ const [showValidationDialog, setShowValidationDialog] = useState(false);
- // 동기화 핸들러
- const handleSync = () => {
- const projectNo = localFilters.projNo;
-
- if (!projectNo) {
- toast({
- variant: "destructive",
- title: "프로젝트 선택 필요",
- description: "동기화할 프로젝트를 먼저 선택해주세요.",
- });
- return;
- }
+ /**
+ * 업로드 가능한 문서번호 목록 추출
+ */
+ const availableDocNos = useMemo(() => {
+ return documents.map(doc => doc.DOC_NO);
+ }, [documents]);
- startSync(async () => {
- try {
+ /**
+ * 벤더 모드 여부 (벤더 코드가 있으면 벤더 모드)
+ */
+ const isVendorMode = !!vendorCode;
+
+ /**
+ * 드롭된 파일 처리 - useEffect로 감지하여 자동 검증
+ */
+ useEffect(() => {
+ if (droppedFiles.length > 0) {
+ // 프로젝트와 벤더 코드 검증
+ if (!projNo) {
toast({
- title: "동기화 시작",
- description: `프로젝트 ${projectNo} 동기화를 시작합니다...`,
+ variant: "destructive",
+ title: "프로젝트 선택 필요",
+ description: "파일을 업로드할 프로젝트를 먼저 선택해주세요.",
});
+ onFilesProcessed?.();
+ return;
+ }
- 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);
+ if (!vendorCode) {
toast({
variant: "destructive",
- title: "동기화 실패",
- description: error instanceof Error ? error.message : "알 수 없는 오류",
+ title: "업체 코드 오류",
+ description: "벤더 정보를 가져올 수 없습니다.",
});
+ onFilesProcessed?.();
+ return;
}
- });
- };
+
+ // 파일명 검증 (문서번호 권한 포함)
+ const results = droppedFiles.map((file) => {
+ const validation = validateFileName(file.name, availableDocNos, isVendorMode);
+ return {
+ file,
+ valid: validation.valid,
+ parsed: validation.parsed,
+ error: validation.error,
+ };
+ });
+
+ setValidationResults(results);
+ setShowValidationDialog(true);
+ onFilesProcessed?.();
+ }
+ }, [droppedFiles, projNo, vendorCode, toast, onFilesProcessed, availableDocNos, isVendorMode]);
/**
* 파일 업로드 핸들러
- * 1) 네트워크 드라이브에 정해진 규칙대로, 파일이름 기반으로 파일 업로드하기
- * 2) 1~N개 파일 받아서, 파일 이름 기준으로 파싱해서 SaveInBoxList API를 통해 업로드 처리
- */
+ */
const handleUploadFiles = () => {
- // 프로젝트와 벤더 코드 체크
- const projectNo = localFilters.projNo;
- const vndrCd = vendorCode || localFilters.vndrCd;
-
- if (!projectNo) {
+ if (!projNo) {
toast({
variant: "destructive",
title: "프로젝트 선택 필요",
@@ -105,48 +148,66 @@ export function SwpTableToolbar({
return;
}
- if (!vndrCd) {
+ if (!vendorCode) {
toast({
variant: "destructive",
- title: "업체 코드 입력 필요",
- description: "파일을 업로드할 업체 코드를 입력해주세요.",
+ title: "업체 코드 오류",
+ description: "벤더 정보를 가져올 수 없습니다.",
});
return;
}
- // 파일 선택 다이얼로그 열기
fileInputRef.current?.click();
};
/**
- * 파일 선택 핸들러
+ * 파일 선택 핸들러 - 검증만 수행
*/
- const handleFileChange = async (event: React.ChangeEvent) => {
+ const handleFileChange = (event: React.ChangeEvent) => {
const selectedFiles = event.target.files;
if (!selectedFiles || selectedFiles.length === 0) {
return;
}
- const projectNo = localFilters.projNo!;
- const vndrCd = vendorCode || localFilters.vndrCd!;
+ // 각 파일의 파일명 검증 (문서번호 권한 포함)
+ const results = Array.from(selectedFiles).map((file) => {
+ const validation = validateFileName(file.name, availableDocNos, isVendorMode);
+ return {
+ file,
+ valid: validation.valid,
+ parsed: validation.parsed,
+ error: validation.error,
+ };
+ });
+
+ setValidationResults(results);
+ setShowValidationDialog(true);
+
+ // input 초기화 (같은 파일 재선택 가능하도록)
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ };
+ /**
+ * 검증 완료 후 실제 업로드 실행
+ */
+ const handleConfirmUpload = async (validFiles: File[]) => {
startUpload(async () => {
try {
toast({
title: "파일 업로드 시작",
- description: `${selectedFiles.length}개 파일을 업로드합니다...`,
+ description: `${validFiles.length}개 파일을 업로드합니다...`,
});
- // FormData 생성 (바이너리 직접 전송)
const formData = new FormData();
- formData.append("projNo", projectNo);
- formData.append("vndrCd", vndrCd);
-
- Array.from(selectedFiles).forEach((file) => {
+ formData.append("projNo", projNo);
+ formData.append("vndrCd", vendorCode!);
+
+ validFiles.forEach((file) => {
formData.append("files", file);
});
- // API Route 호출
const response = await fetch("/api/swp/upload", {
method: "POST",
body: formData,
@@ -158,31 +219,31 @@ export function SwpTableToolbar({
const result = await response.json();
- // 결과 저장 및 다이얼로그 표시
+ // 검증 다이얼로그 닫기
+ setShowValidationDialog(false);
+
+ // 결과 다이얼로그 표시
setUploadResults(result.details || []);
setShowResultDialog(true);
- // 성공한 파일이 있으면 페이지 새로고침
- if (result.successCount > 0) {
- router.refresh();
- }
+ toast({
+ title: result.success ? "업로드 완료" : "일부 업로드 실패",
+ description: result.message,
+ });
} catch (error) {
console.error("파일 업로드 실패:", error);
-
- // 예외 발생 시에도 결과 다이얼로그 표시
- const errorResults = Array.from(selectedFiles).map((file) => ({
+
+ // 검증 다이얼로그 닫기
+ setShowValidationDialog(false);
+
+ const errorResults = validFiles.map((file) => ({
fileName: file.name,
success: false,
error: error instanceof Error ? error.message : "알 수 없는 오류",
}));
-
+
setUploadResults(errorResults);
setShowResultDialog(true);
- } finally {
- // 파일 입력 초기화
- if (fileInputRef.current) {
- fileInputRef.current.value = "";
- }
}
});
};
@@ -194,7 +255,12 @@ export function SwpTableToolbar({
// 검색 초기화
const handleReset = () => {
- const resetFilters: SwpTableFilters = {};
+ const resetFilters: SwpTableFilters = {
+ docNo: "",
+ docTitle: "",
+ pkgNo: "",
+ stage: "",
+ };
setLocalFilters(resetFilters);
onFiltersChange(resetFilters);
};
@@ -202,17 +268,28 @@ export function SwpTableToolbar({
// 프로젝트 필터링
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)
+ (proj.PROJ_NM?.toLowerCase().includes(search) ?? false)
);
}, [projects, projectSearch]);
return (
<>
+ {/* 업로드 검증 다이얼로그 */}
+
+
{/* 업로드 결과 다이얼로그 */}
-
+
{/* 상단 액션 바 */}
-
-
+ )}
- {/* 검색 필터 */}
-
-
-
검색 필터
-
-
+ {/* 검색 필터 */}
+
+
+
검색 필터
+
+
-
- {/* 프로젝트 번호 */}
-
-
- {projects.length > 0 ? (
-
-
-
-
-
-
-
-
- setProjectSearch(e.target.value)}
- className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0"
- />
-
-
-
-
-
- {filteredProjects.map((proj) => (
-
- ))}
- {filteredProjects.length === 0 && (
-
- 검색 결과가 없습니다.
-
+
+ {/* 프로젝트 번호 */}
+
+
+ {projects.length > 0 ? (
+
+
+
+
+
+
+
+
+ setProjectSearch(e.target.value)}
+ className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0"
+ />
+
+
+
+
+ {filteredProjects.map((proj) => (
+
+ ))}
+ {filteredProjects.length === 0 && (
+
+ 검색 결과가 없습니다.
+
+ )}
+
-
-
-
- ) : (
+
+
+ ) : (
+
+ )}
+
+
+ {/* 문서 번호 */}
+
+
- setLocalFilters({ ...localFilters, projNo: e.target.value })
+ setLocalFilters({ ...localFilters, docNo: e.target.value })
}
- disabled
- className="bg-muted"
/>
- )}
-
-
- {/* 문서 번호 */}
-
-
-
- setLocalFilters({ ...localFilters, docNo: e.target.value })
- }
- />
-
+
- {/* 문서 제목 */}
-
-
-
- setLocalFilters({ ...localFilters, docTitle: e.target.value })
- }
- />
-
+ {/* 문서 제목 */}
+
+
+
+ setLocalFilters({ ...localFilters, docTitle: e.target.value })
+ }
+ />
+
- {/* 패키지 번호 */}
-
-
-
- setLocalFilters({ ...localFilters, pkgNo: e.target.value })
- }
- />
-
+ {/* 패키지 번호 */}
+
+
+
+ setLocalFilters({ ...localFilters, pkgNo: e.target.value })
+ }
+ />
+
- {/* 업체 코드 */}
-
- {/* 스테이지 */}
-
-
-
- setLocalFilters({ ...localFilters, stage: e.target.value })
- }
- />
+
+
-
-
-
-
-
>
);
}
-
diff --git a/lib/swp/table/swp-table.tsx b/lib/swp/table/swp-table.tsx
index 47c9905a..7918c07e 100644
--- a/lib/swp/table/swp-table.tsx
+++ b/lib/swp/table/swp-table.tsx
@@ -4,9 +4,7 @@ import React, { useState } from "react";
import {
useReactTable,
getCoreRowModel,
- getExpandedRowModel,
flexRender,
- ExpandedState,
} from "@tanstack/react-table";
import {
Table,
@@ -17,116 +15,37 @@ import {
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
-import { swpDocumentColumns, type RevisionRow, type FileRow } from "./swp-table-columns";
-import { fetchDocumentRevisions, fetchRevisionFiles, type SwpDocumentWithStats } from "../actions";
-import { SwpRevisionListDialog } from "./swp-revision-list-dialog";
+import { ChevronRight } from "lucide-react";
+import { swpDocumentColumns } from "./swp-table-columns";
+import { SwpDocumentDetailDialog } from "./swp-document-detail-dialog";
+import type { DocumentListItem } from "@/lib/swp/document-service";
interface SwpTableProps {
- initialData: SwpDocumentWithStats[];
- total: number;
- page: number;
- pageSize: number;
- totalPages: number;
- onPageChange: (page: number) => void;
+ documents: DocumentListItem[];
+ projNo: string;
+ vendorCode: string;
+ userId: string;
}
export function SwpTable({
- initialData,
- total,
- page,
- pageSize,
- totalPages,
- onPageChange,
+ documents,
+ projNo,
+ vendorCode,
+ userId,
}: SwpTableProps) {
- const [expanded, setExpanded] = useState
({});
- const [revisionData, setRevisionData] = useState>({});
- const [fileData, setFileData] = useState>({});
- const [loadingRevisions, setLoadingRevisions] = useState>(new Set());
- const [loadingFiles, setLoadingFiles] = useState>(new Set());
const [dialogOpen, setDialogOpen] = useState(false);
- const [selectedDocument, setSelectedDocument] = useState(null);
+ const [selectedDocument, setSelectedDocument] = useState(null);
const table = useReactTable({
- data: initialData,
+ data: documents,
columns: swpDocumentColumns,
- state: {
- expanded,
- },
- onExpandedChange: setExpanded,
getCoreRowModel: getCoreRowModel(),
- getExpandedRowModel: getExpandedRowModel(),
- getRowCanExpand: () => 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;
- });
- }
- };
-
// 문서 클릭 핸들러 - Dialog 열기
- const handleDocumentClick = async (document: SwpDocumentWithStats) => {
+ const handleDocumentClick = (document: DocumentListItem) => {
setSelectedDocument(document);
setDialogOpen(true);
-
- // 리비전 데이터 로드
- if (!revisionData[document.DOC_NO]) {
- await loadRevisions(document.DOC_NO);
- }
- };
-
- // 모든 리비전의 파일을 로드
- const loadAllFiles = async (docNo: string) => {
- const revisions = revisionData[docNo];
- if (!revisions) return;
-
- for (const revision of revisions) {
- if (!fileData[revision.id]) {
- await loadFiles(revision.id);
- }
- }
};
return (
@@ -153,31 +72,28 @@ export function SwpTable({
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
-
- {/* 문서 행 */}
-
- {row.getVisibleCells().map((cell) => (
-
- {cell.column.id === "expander" ? (
- handleDocumentClick(row.original)}
- className="cursor-pointer"
- >
- {flexRender(
- cell.column.columnDef.cell,
- cell.getContext()
- )}
-
- ) : (
- flexRender(cell.column.columnDef.cell, cell.getContext())
- )}
-
- ))}
-
-
+ handleDocumentClick(row.original)}
+ >
+ {row.getVisibleCells().map((cell) => (
+
+ {cell.column.id === "expander" ? (
+
+ ) : (
+ flexRender(cell.column.columnDef.cell, cell.getContext())
+ )}
+
+ ))}
+
))
) : (
@@ -190,46 +106,14 @@ export function SwpTable({
- {/* 페이지네이션 */}
-
-
- 총 {total}개 중 {(page - 1) * pageSize + 1}-
- {Math.min(page * pageSize, total)}개 표시
-
-
-
-
- {page} / {totalPages}
-
-
-
-
-
{/* 문서 상세 Dialog */}
-
selectedDocument && loadAllFiles(selectedDocument.DOC_NO)}
+ projNo={projNo}
+ vendorCode={vendorCode}
+ userId={userId}
/>
);
diff --git a/lib/swp/table/swp-upload-validation-dialog.tsx b/lib/swp/table/swp-upload-validation-dialog.tsx
new file mode 100644
index 00000000..2d17e041
--- /dev/null
+++ b/lib/swp/table/swp-upload-validation-dialog.tsx
@@ -0,0 +1,373 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
+import { CheckCircle2, XCircle, AlertCircle, Upload } from "lucide-react";
+import { ScrollArea } from "@/components/ui/scroll-area";
+
+interface FileValidationResult {
+ file: File;
+ valid: boolean;
+ parsed?: {
+ ownDocNo: string;
+ revNo: string;
+ stage: string;
+ fileName: string;
+ extension: string;
+ };
+ error?: string;
+}
+
+interface SwpUploadValidationDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ validationResults: FileValidationResult[];
+ onConfirmUpload: (validFiles: File[]) => void;
+ isUploading: boolean;
+ availableDocNos?: string[]; // 업로드 가능한 문서번호 목록
+ isVendorMode?: boolean; // 벤더 모드인지 여부 (문서번호 검증 필수)
+}
+
+/**
+ * 파일명 검증 함수 (클라이언트 사이드)
+ * 형식: [DOC_NO]_[REV_NO]_[STAGE].[확장자] 또는 [DOC_NO]_[REV_NO]_[STAGE]_[자유-파일명].[확장자]
+ * 자유 파일명은 선택사항이며, 포함될 경우 언더스코어를 포함할 수 있음
+ * @param fileName 검증할 파일명
+ * @param availableDocNos 업로드 가능한 문서번호 목록 (선택)
+ * @param isVendorMode 벤더 모드인지 여부 (true인 경우 문서번호 검증 필수)
+ */
+export function validateFileName(
+ fileName: string,
+ availableDocNos?: string[],
+ isVendorMode?: boolean
+): {
+ valid: boolean;
+ parsed?: {
+ ownDocNo: string;
+ revNo: string;
+ stage: string;
+ fileName: string;
+ extension: string;
+ };
+ error?: string;
+} {
+ try {
+ // 확장자 분리
+ const lastDotIndex = fileName.lastIndexOf(".");
+ if (lastDotIndex === -1) {
+ return {
+ valid: false,
+ error: "파일 확장자가 없습니다",
+ };
+ }
+
+ const extension = fileName.substring(lastDotIndex + 1);
+ const nameWithoutExt = fileName.substring(0, lastDotIndex);
+
+ // 언더스코어로 분리
+ const parts = nameWithoutExt.split("_");
+
+ // 최소 3개 파트 필요: docNo, revNo, stage (fileName은 선택사항)
+ if (parts.length < 3) {
+ return {
+ valid: false,
+ error: `언더스코어(_)가 최소 2개 있어야 합니다 (현재: ${parts.length - 1}개). 형식: [DOC_NO]_[REV_NO]_[STAGE].[확장자]`,
+ };
+ }
+
+ // 앞에서부터 3개는 고정: docNo, revNo, stage
+ const ownDocNo = parts[0];
+ const revNo = parts[1];
+ const stage = parts[2];
+
+ // 나머지는 자유 파일명 (선택사항, 언더스코어 포함 가능)
+ const customFileName = parts.length > 3 ? parts.slice(3).join("_") : "";
+
+ // 필수 항목이 비어있지 않은지 확인
+ if (!ownDocNo || ownDocNo.trim() === "") {
+ return {
+ valid: false,
+ error: "문서번호(DOC_NO)가 비어있습니다",
+ };
+ }
+
+ if (!revNo || revNo.trim() === "") {
+ return {
+ valid: false,
+ error: "리비전 번호(REV_NO)가 비어있습니다",
+ };
+ }
+
+ if (!stage || stage.trim() === "") {
+ return {
+ valid: false,
+ error: "스테이지(STAGE)가 비어있습니다",
+ };
+ }
+
+ // 문서번호 검증 (벤더 모드에서는 필수)
+ if (isVendorMode) {
+ const trimmedDocNo = ownDocNo.trim();
+
+ // 벤더 모드에서 문서 목록이 비어있으면 에러
+ if (!availableDocNos || availableDocNos.length === 0) {
+ return {
+ valid: false,
+ error: "할당된 문서가 없거나 문서 목록 로드에 실패했습니다. 페이지를 새로고침하거나 관리자에게 문의하세요.",
+ };
+ }
+
+ // 문서번호가 목록에 없으면 에러
+ if (!availableDocNos.includes(trimmedDocNo)) {
+ return {
+ valid: false,
+ error: `문서번호 '${trimmedDocNo}'는 업로드 권한이 없습니다. 할당된 문서번호를 확인해주세요.`,
+ };
+ }
+ }
+
+ return {
+ valid: true,
+ parsed: {
+ ownDocNo: ownDocNo.trim(),
+ revNo: revNo.trim(),
+ stage: stage.trim(),
+ fileName: customFileName.trim(),
+ extension,
+ },
+ };
+ } catch (error) {
+ return {
+ valid: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류",
+ };
+ }
+}
+
+/**
+ * 업로드 전 파일 검증 다이얼로그
+ */
+export function SwpUploadValidationDialog({
+ open,
+ onOpenChange,
+ validationResults,
+ onConfirmUpload,
+ isUploading,
+ availableDocNos = [],
+ isVendorMode = false,
+}: SwpUploadValidationDialogProps) {
+ const validFiles = validationResults.filter((r) => r.valid);
+ const invalidFiles = validationResults.filter((r) => !r.valid);
+
+ const handleUpload = () => {
+ if (validFiles.length > 0) {
+ onConfirmUpload(validFiles.map((r) => r.file));
+ }
+ };
+
+ const handleCancel = () => {
+ if (!isUploading) {
+ onOpenChange(false);
+ }
+ };
+
+ return (
+
+ );
+}
+
diff --git a/lib/swp/table/swp-uploaded-files-dialog.tsx b/lib/swp/table/swp-uploaded-files-dialog.tsx
new file mode 100644
index 00000000..25a798b6
--- /dev/null
+++ b/lib/swp/table/swp-uploaded-files-dialog.tsx
@@ -0,0 +1,358 @@
+"use client";
+
+import { useState, useTransition, useMemo } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+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 type { SwpFileApiResponse } from "../api-client";
+
+interface SwpUploadedFilesDialogProps {
+ projNo: string;
+ vndrCd: string;
+ userId: string;
+}
+
+interface FileTreeNode {
+ files: SwpFileApiResponse[];
+}
+
+interface RevisionTreeNode {
+ revNo: string;
+ files: FileTreeNode;
+}
+
+interface DocumentTreeNode {
+ docNo: string;
+ revisions: Map
;
+}
+
+export function SwpUploadedFilesDialog({ projNo, vndrCd, userId }: SwpUploadedFilesDialogProps) {
+ const [open, setOpen] = useState(false);
+ const [files, setFiles] = useState([]);
+ const [isLoading, startLoading] = useTransition();
+ const [expandedDocs, setExpandedDocs] = useState>(new Set());
+ const [expandedRevs, setExpandedRevs] = useState>(new Set());
+ const [cancellingFiles, setCancellingFiles] = useState>(new Set());
+ const { toast } = useToast();
+
+ // 파일 목록을 트리 구조로 변환
+ const fileTree = useMemo(() => {
+ const tree = new Map();
+
+ files.forEach((file) => {
+ const docNo = file.OWN_DOC_NO;
+ const revNo = file.REV_NO;
+
+ if (!tree.has(docNo)) {
+ tree.set(docNo, {
+ docNo,
+ revisions: new Map(),
+ });
+ }
+
+ const docNode = tree.get(docNo)!;
+
+ if (!docNode.revisions.has(revNo)) {
+ docNode.revisions.set(revNo, {
+ revNo,
+ files: { files: [] },
+ });
+ }
+
+ const revNode = docNode.revisions.get(revNo)!;
+ revNode.files.files.push(file);
+ });
+
+ return tree;
+ }, [files]);
+
+ // 다이얼로그 열릴 때 파일 목록 조회
+ const handleOpenChange = (newOpen: boolean) => {
+ setOpen(newOpen);
+ if (newOpen) {
+ loadFiles();
+ }
+ };
+
+ // 파일 목록 조회
+ const loadFiles = () => {
+ if (!projNo || !vndrCd) {
+ toast({
+ variant: "destructive",
+ title: "조회 불가",
+ description: "프로젝트와 업체 정보가 필요합니다.",
+ });
+ return;
+ }
+
+ startLoading(async () => {
+ try {
+ const result = await fetchVendorUploadedFiles(projNo, vndrCd);
+ setFiles(result);
+ toast({
+ title: "조회 완료",
+ description: `${result.length}개의 파일을 조회했습니다.`,
+ });
+ } catch (error) {
+ console.error("파일 목록 조회 실패:", error);
+ toast({
+ variant: "destructive",
+ title: "조회 실패",
+ description: error instanceof Error ? error.message : "알 수 없는 오류",
+ });
+ }
+ });
+ };
+
+ // 파일 취소
+ const handleCancelFile = async (file: SwpFileApiResponse) => {
+ if (!file.BOX_SEQ || !file.ACTV_SEQ) {
+ toast({
+ variant: "destructive",
+ title: "취소 불가",
+ description: "파일 정보가 올바르지 않습니다.",
+ });
+ return;
+ }
+
+ const fileKey = `${file.BOX_SEQ}_${file.ACTV_SEQ}`;
+ setCancellingFiles((prev) => new Set(prev).add(fileKey));
+
+ try {
+ await cancelVendorUploadedFile({
+ boxSeq: file.BOX_SEQ,
+ actvSeq: file.ACTV_SEQ,
+ userId,
+ });
+
+ toast({
+ title: "취소 완료",
+ description: `${file.FILE_NM} 파일이 취소되었습니다.`,
+ });
+
+ // 목록 새로고침
+ loadFiles();
+ } catch (error) {
+ console.error("파일 취소 실패:", error);
+ toast({
+ variant: "destructive",
+ title: "취소 실패",
+ description: error instanceof Error ? error.message : "알 수 없는 오류",
+ });
+ } finally {
+ setCancellingFiles((prev) => {
+ const newSet = new Set(prev);
+ newSet.delete(fileKey);
+ return newSet;
+ });
+ }
+ };
+
+ // 문서 토글
+ const toggleDoc = (docNo: string) => {
+ setExpandedDocs((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(docNo)) {
+ newSet.delete(docNo);
+ } else {
+ newSet.add(docNo);
+ }
+ return newSet;
+ });
+ };
+
+ // 리비전 토글
+ const toggleRev = (docNo: string, revNo: string) => {
+ const key = `${docNo}_${revNo}`;
+ setExpandedRevs((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(key)) {
+ newSet.delete(key);
+ } else {
+ newSet.add(key);
+ }
+ return newSet;
+ });
+ };
+
+ return (
+
+ );
+}
diff --git a/lib/swp/vendor-actions.ts b/lib/swp/vendor-actions.ts
index c96cf055..f65ed007 100644
--- a/lib/swp/vendor-actions.ts
+++ b/lib/swp/vendor-actions.ts
@@ -6,13 +6,16 @@ import db from "@/db/db";
import { vendors } from "@/db/schema/vendors";
import { contracts } from "@/db/schema/contract";
import { projects } from "@/db/schema/projects";
-import { swpDocumentRevisions, swpDocumentFiles } from "@/db/schema/SWP/swp-documents";
-import { eq, sql, and } from "drizzle-orm";
-import { fetchSwpDocuments, type SwpTableParams } from "./actions";
-import { fetchGetExternalInboxList } from "./api-client";
-import type { SwpFileApiResponse } from "./sync-service";
-import fs from "fs/promises";
-import path from "path";
+import { eq } from "drizzle-orm";
+import {
+ getDocumentList,
+ getDocumentDetail,
+ cancelStandbyFile,
+ downloadDocumentFile,
+ type DocumentListItem,
+ type DocumentDetail,
+ type DownloadFileResult
+} from "./document-service";
import { debugLog, debugError, debugSuccess, debugProcess, debugWarn } from "@/lib/debug-utils";
// ============================================================================
@@ -110,11 +113,11 @@ export async function fetchVendorProjects() {
}
// ============================================================================
-// 벤더 필터링된 문서 목록 조회
+// 벤더 필터링된 문서 목록 조회 (Full API 기반)
// ============================================================================
-export async function fetchVendorDocuments(params: SwpTableParams) {
- debugProcess("벤더 문서 목록 조회 시작", { page: params.page, pageSize: params.pageSize });
+export async function fetchVendorDocuments(projNo?: string): Promise {
+ debugProcess("벤더 문서 목록 조회 시작", { projNo });
try {
const vendorInfo = await getVendorSessionInfo();
@@ -124,22 +127,21 @@ export async function fetchVendorDocuments(params: SwpTableParams) {
throw new Error("벤더 정보를 찾을 수 없습니다.");
}
- // 벤더 코드를 필터에 자동 추가
- const vendorParams: SwpTableParams = {
- ...params,
- filters: {
- ...params.filters,
- vndrCd: vendorInfo.vendorCode, // 벤더 코드 필터 강제 적용
- },
- };
+ if (!projNo) {
+ debugWarn("프로젝트 번호 없음");
+ return [];
+ }
- debugLog("SWP 문서 조회 호출", { vendorCode: vendorInfo.vendorCode, filters: vendorParams.filters });
+ debugLog("문서 목록 조회 시작", {
+ projNo,
+ vendorCode: vendorInfo.vendorCode
+ });
- // 기존 fetchSwpDocuments 재사용
- const result = await fetchSwpDocuments(vendorParams);
+ // document-service의 getDocumentList 사용
+ const documents = await getDocumentList(projNo, vendorInfo.vendorCode);
- debugSuccess("문서 목록 조회 성공", { total: result.total, dataCount: result.data.length });
- return result;
+ debugSuccess("문서 목록 조회 성공", { count: documents.length });
+ return documents;
} catch (error) {
debugError("문서 목록 조회 실패", error);
console.error("[fetchVendorDocuments] 오류:", error);
@@ -148,104 +150,114 @@ export async function fetchVendorDocuments(params: SwpTableParams) {
}
// ============================================================================
-// 파일 업로드
+// 문서 상세 조회 (Rev-Activity-File 트리)
// ============================================================================
-export interface FileUploadParams {
- revisionId: number;
- file: {
- FILE_NM: string;
- FILE_SEQ: string;
- FILE_SZ: string;
- FLD_PATH: string;
- STAT?: string;
- STAT_NM?: string;
- };
- fileBuffer?: Buffer; // 실제 파일 데이터 추가
-}
-
-export async function uploadFileToRevision(params: FileUploadParams) {
- debugProcess("파일 업로드 시작", { revisionId: params.revisionId, fileName: params.file.FILE_NM });
+export async function fetchVendorDocumentDetail(
+ projNo: string,
+ docNo: string
+): Promise {
+ debugProcess("벤더 문서 상세 조회 시작", { projNo, docNo });
try {
const vendorInfo = await getVendorSessionInfo();
if (!vendorInfo) {
- debugError("벤더 정보 없음 - 파일 업로드 실패");
+ debugError("벤더 정보 없음");
throw new Error("벤더 정보를 찾을 수 없습니다.");
}
- const { revisionId } = params;
- debugLog("리비전 권한 확인 시작", { revisionId });
-
- // 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);
+ debugLog("문서 상세 조회 시작", { projNo, docNo });
- debugLog("리비전 조회 결과", { found: !!revisionCheck[0], docNo: revisionCheck[0]?.DOC_NO });
+ // document-service의 getDocumentDetail 사용
+ const detail = await getDocumentDetail(projNo, docNo);
- if (!revisionCheck[0]) {
- debugError("리비전 없음", { revisionId });
- throw new Error("리비전을 찾을 수 없습니다.");
- }
+ debugSuccess("문서 상세 조회 성공", {
+ docNo: detail.docNo,
+ revisions: detail.revisions.length,
+ });
- // 벤더 코드가 일치하는지 확인
- if (revisionCheck[0].VNDR_CD !== vendorInfo.vendorCode) {
- debugError("권한 없음", {
- expected: vendorInfo.vendorCode,
- actual: revisionCheck[0].VNDR_CD,
- docNo: revisionCheck[0].DOC_NO
- });
- throw new Error("이 문서에 대한 권한이 없습니다.");
+ return detail;
+ } catch (error) {
+ debugError("문서 상세 조회 실패", error);
+ console.error("[fetchVendorDocumentDetail] 오류:", error);
+ throw new Error("문서 상세 조회 실패");
+ }
+}
+
+// ============================================================================
+// 파일 취소
+// ============================================================================
+
+export async function cancelVendorFile(
+ boxSeq: string,
+ actvSeq: string
+): Promise {
+ debugProcess("벤더 파일 취소 시작", { boxSeq, actvSeq });
+
+ try {
+ const vendorInfo = await getVendorSessionInfo();
+
+ if (!vendorInfo) {
+ debugError("벤더 정보 없음");
+ throw new Error("벤더 정보를 찾을 수 없습니다.");
}
- debugSuccess("리비전 권한 확인 성공");
+ // vendorId를 문자열로 변환하여 사용
+ await cancelStandbyFile(boxSeq, actvSeq, String(vendorInfo.vendorId));
- const { revisionId: revId, file, fileBuffer } = params;
+ debugSuccess("파일 취소 완료", { boxSeq, actvSeq });
+ } catch (error) {
+ debugError("파일 취소 실패", error);
+ console.error("[cancelVendorFile] 오류:", error);
+ throw new Error("파일 취소 실패");
+ }
+}
- // 1. SWP 마운트 경로에 파일 저장
- debugProcess("파일 저장 단계 시작");
- await saveFileToSwpNetwork(revId, {
- FILE_NM: file.FILE_NM,
- fileBuffer: fileBuffer,
- });
+// ============================================================================
+// 파일 다운로드
+// ============================================================================
+
+export async function downloadVendorFile(
+ projNo: string,
+ ownDocNo: string,
+ fileName: string
+): Promise {
+ debugProcess("벤더 파일 다운로드 시작", { projNo, ownDocNo, fileName });
- // 2. 파일 저장 API 호출 (메타데이터 전송)
- debugProcess("API 호출 단계 시작");
- await callSwpFileSaveApi(revId, file);
+ try {
+ const vendorInfo = await getVendorSessionInfo();
+
+ if (!vendorInfo) {
+ debugError("벤더 정보 없음");
+ return {
+ success: false,
+ error: "벤더 정보를 찾을 수 없습니다.",
+ };
+ }
- // 3. 파일 리스트 API 호출 (업데이트된 파일 목록 조회)
- debugProcess("파일 목록 조회 단계 시작");
- const updatedFiles = await fetchUpdatedFileList(revId);
- debugLog("업데이트된 파일 목록", { count: updatedFiles.length });
+ // document-service의 downloadDocumentFile 사용
+ const result = await downloadDocumentFile(projNo, ownDocNo, fileName);
- // 4. 파일 목록 DB 동기화 (새 파일들 추가)
- debugProcess("DB 동기화 단계 시작");
- await syncSwpDocumentFiles(revId, updatedFiles);
+ if (result.success) {
+ debugSuccess("파일 다운로드 완료", { fileName });
+ } else {
+ debugWarn("파일 다운로드 실패", { fileName, error: result.error });
+ }
- debugSuccess("파일 업로드 완료", { fileName: file.FILE_NM, revisionId });
- return { success: true, fileId: 0, action: "uploaded" };
+ return result;
} catch (error) {
- debugError("파일 업로드 실패", error);
- console.error("[uploadFileToRevision] 오류:", error);
- throw new Error(
- error instanceof Error ? error.message : "파일 업로드 실패"
- );
+ debugError("파일 다운로드 실패", error);
+ console.error("[downloadVendorFile] 오류:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "파일 다운로드 실패",
+ };
}
}
// ============================================================================
-// 벤더 통계 조회
+// 벤더 통계 조회 (Full API 기반)
// ============================================================================
export async function fetchVendorSwpStats(projNo?: string) {
@@ -259,48 +271,52 @@ export async function fetchVendorSwpStats(projNo?: string) {
throw new Error("벤더 정보를 찾을 수 없습니다.");
}
- const whereConditions = [
- sql`d."VNDR_CD" = ${vendorInfo.vendorCode}`,
- ];
+ if (!projNo) {
+ debugWarn("프로젝트 번호 없음");
+ return {
+ total_documents: 0,
+ total_revisions: 0,
+ total_files: 0,
+ uploaded_files: 0,
+ last_sync: null,
+ };
+ }
- if (projNo) {
- whereConditions.push(sql`d."PROJ_NO" = ${projNo}`);
+ // API에서 문서 목록 조회
+ const documents = await getDocumentList(projNo, vendorInfo.vendorCode);
+
+ // 통계 계산
+ let totalRevisions = 0;
+ let totalFiles = 0;
+ let uploadedFiles = 0;
+
+ for (const doc of documents) {
+ totalFiles += doc.fileCount;
+ // standbyFileCount가 0이 아니면 업로드된 것으로 간주
+ uploadedFiles += doc.fileCount - doc.standbyFileCount;
+
+ // 리비전 수 추정 (LTST_REV_NO 기반)
+ if (doc.LTST_REV_NO) {
+ const revNum = parseInt(doc.LTST_REV_NO, 10);
+ if (!isNaN(revNum)) {
+ totalRevisions += revNum + 1; // Rev 00부터 시작이므로 +1
+ }
+ }
}
- debugLog("통계 SQL 실행", { vendorCode: vendorInfo.vendorCode, 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 `)}
- `);
-
- const result = stats.rows[0] || {
- total_documents: 0,
- total_revisions: 0,
- total_files: 0,
- uploaded_files: 0,
- last_sync: null,
+ const result = {
+ total_documents: documents.length,
+ total_revisions: totalRevisions,
+ total_files: totalFiles,
+ uploaded_files: uploadedFiles,
+ last_sync: new Date(), // API 기반이므로 항상 최신
};
debugSuccess("통계 조회 성공", {
documents: result.total_documents,
revisions: result.total_revisions,
files: result.total_files,
- uploaded: result.uploaded_files
+ uploaded: result.uploaded_files,
});
return result;
@@ -318,245 +334,6 @@ export async function fetchVendorSwpStats(projNo?: string) {
}
// ============================================================================
-// SWP 파일 업로드 헬퍼 함수들
+// 주의: 파일 업로드는 /api/swp/upload 라우트에서 처리됩니다
// ============================================================================
-/**
- * 1. SWP 마운트 경로에 파일 저장
- */
-async function saveFileToSwpNetwork(
- revisionId: number,
- fileInfo: { FILE_NM: string; fileBuffer?: Buffer }
-): Promise {
- debugProcess("네트워크 파일 저장 시작", { revisionId, fileName: fileInfo.FILE_NM });
-
- // 리비전 정보 조회
- const revisionInfo = await db
- .select({
- DOC_NO: swpDocumentRevisions.DOC_NO,
- REV_NO: swpDocumentRevisions.REV_NO,
- PROJ_NO: sql`(
- SELECT d."PROJ_NO" FROM swp.swp_documents d
- WHERE d."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);
-
- debugLog("리비전 정보 조회", { found: !!revisionInfo[0], docNo: revisionInfo[0]?.DOC_NO });
-
- if (!revisionInfo[0]) {
- debugError("리비전 정보 없음");
- throw new Error("리비전 정보를 찾을 수 없습니다");
- }
-
- const { PROJ_NO, VNDR_CD, DOC_NO, REV_NO } = revisionInfo[0];
-
- // SWP 마운트 경로 생성
- const mountDir = process.env.SWP_MOUNT_DIR || "/mnt/swp-smb-dir";
- const targetDir = path.join(mountDir, PROJ_NO, VNDR_CD, DOC_NO, REV_NO);
-
- debugLog("파일 저장 경로 생성", { mountDir, targetDir });
-
- // 디렉토리 생성
- await fs.mkdir(targetDir, { recursive: true });
- debugLog("디렉토리 생성 완료");
-
- // 파일 저장
- const targetPath = path.join(targetDir, fileInfo.FILE_NM);
-
- if (fileInfo.fileBuffer) {
- await fs.writeFile(targetPath, fileInfo.fileBuffer);
- debugSuccess("파일 저장 완료", { fileName: fileInfo.FILE_NM, targetPath, size: fileInfo.fileBuffer.length });
- } else {
- debugWarn("파일 버퍼 없음", { fileName: fileInfo.FILE_NM });
- }
-
- return targetPath;
-}
-
-/**
- * 2. 파일 저장 API 호출 (메타데이터 전송)
- */
-async function callSwpFileSaveApi(
- revisionId: number,
- fileInfo: FileUploadParams['file']
-): Promise {
- debugProcess("SWP 파일 저장 API 호출 시작", { revisionId, fileName: fileInfo.FILE_NM });
-
- // TODO: SWP 파일 저장 API 구현
- // buyer-system의 sendToInBox 패턴 참고
- debugLog("메타데이터 전송", {
- fileName: fileInfo.FILE_NM,
- fileSeq: fileInfo.FILE_SEQ,
- filePath: fileInfo.FLD_PATH
- });
-
- // 임시 구현: 실제로는 SWP SaveFile API 등을 호출해야 함
- // 예: SaveFile, UploadFile API 등
- debugWarn("SWP 파일 저장 API가 아직 구현되지 않음 - 임시 스킵");
-}
-
-/**
- * 3. 파일 리스트 API 호출 (업데이트된 파일 목록 조회)
- */
-async function fetchUpdatedFileList(revisionId: number): Promise {
- debugProcess("업데이트된 파일 목록 조회 시작", { revisionId });
-
- // 리비전 정보 조회
- const revisionInfo = await db
- .select({
- DOC_NO: swpDocumentRevisions.DOC_NO,
- PROJ_NO: sql`(
- SELECT d."PROJ_NO" FROM swp.swp_documents d
- WHERE d."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);
-
- debugLog("리비전 정보 조회", { found: !!revisionInfo[0], docNo: revisionInfo[0]?.DOC_NO });
-
- if (!revisionInfo[0]) {
- debugError("리비전 정보 없음");
- throw new Error("리비전 정보를 찾을 수 없습니다");
- }
-
- const { PROJ_NO, VNDR_CD } = revisionInfo[0];
-
- debugLog("SWP 파일 목록 API 호출", { projNo: PROJ_NO, vndrCd: VNDR_CD });
-
- // SWP API에서 업데이트된 파일 목록 조회
- const files = await fetchGetExternalInboxList({
- projNo: PROJ_NO,
- vndrCd: VNDR_CD,
- });
-
- debugSuccess("파일 목록 조회 완료", { count: files.length });
- return files;
-}
-
-/**
- * 4. 파일 목록 DB 동기화 (새 파일들 추가)
- */
-async function syncSwpDocumentFiles(
- revisionId: number,
- apiFiles: SwpFileApiResponse[]
-): Promise {
- debugProcess("DB 동기화 시작", { revisionId, fileCount: apiFiles.length });
-
- // 리비전 정보에서 DOC_NO 가져오기
- const revisionInfo = await db
- .select({
- DOC_NO: swpDocumentRevisions.DOC_NO,
- })
- .from(swpDocumentRevisions)
- .where(eq(swpDocumentRevisions.id, revisionId))
- .limit(1);
-
- debugLog("리비전 DOC_NO 조회", { found: !!revisionInfo[0], docNo: revisionInfo[0]?.DOC_NO });
-
- if (!revisionInfo[0]) {
- debugError("리비전 정보 없음");
- throw new Error("리비전 정보를 찾을 수 없습니다");
- }
-
- const { DOC_NO } = revisionInfo[0];
- let processedCount = 0;
- let updatedCount = 0;
- let insertedCount = 0;
-
- for (const apiFile of apiFiles) {
- try {
- processedCount++;
-
- // 기존 파일 확인
- const existingFile = await db
- .select({ id: swpDocumentFiles.id })
- .from(swpDocumentFiles)
- .where(
- and(
- eq(swpDocumentFiles.revision_id, revisionId),
- eq(swpDocumentFiles.FILE_SEQ, apiFile.FILE_SEQ || "1")
- )
- )
- .limit(1);
-
- const fileData = {
- DOC_NO: DOC_NO,
- FILE_NM: apiFile.FILE_NM,
- FILE_SEQ: apiFile.FILE_SEQ || "1",
- FILE_SZ: apiFile.FILE_SZ || "0",
- FLD_PATH: apiFile.FLD_PATH,
- STAT: apiFile.STAT || null,
- STAT_NM: apiFile.STAT_NM || null,
- ACTV_NO: apiFile.ACTV_NO || null,
- IDX: apiFile.IDX || null,
- CRTER: apiFile.CRTER || null,
- CRTE_DTM: apiFile.CRTE_DTM || null,
- CHGR: apiFile.CHGR || null,
- CHG_DTM: apiFile.CHG_DTM || null,
- sync_status: 'synced' as const,
- last_synced_at: new Date(),
- updated_at: new Date(),
- };
-
- if (existingFile[0]) {
- // 기존 파일 업데이트
- await db
- .update(swpDocumentFiles)
- .set({
- ...fileData,
- updated_at: new Date(),
- })
- .where(eq(swpDocumentFiles.id, existingFile[0].id));
- updatedCount++;
- debugLog("파일 업데이트", { fileName: apiFile.FILE_NM, fileSeq: apiFile.FILE_SEQ });
- } else {
- // 새 파일 추가
- await db.insert(swpDocumentFiles).values({
- revision_id: revisionId,
- DOC_NO: DOC_NO,
- FILE_NM: apiFile.FILE_NM,
- FILE_SEQ: apiFile.FILE_SEQ || "1",
- FILE_SZ: apiFile.FILE_SZ || "0",
- FLD_PATH: apiFile.FLD_PATH,
- STAT: apiFile.STAT || null,
- STAT_NM: apiFile.STAT_NM || null,
- ACTV_NO: apiFile.ACTV_NO || null,
- IDX: apiFile.IDX || null,
- CRTER: apiFile.CRTER || null,
- CRTE_DTM: apiFile.CRTE_DTM || null,
- CHGR: apiFile.CHGR || null,
- CHG_DTM: apiFile.CHG_DTM || null,
- sync_status: 'synced' as const,
- last_synced_at: new Date(),
- updated_at: new Date(),
- });
- insertedCount++;
- debugLog("파일 추가", { fileName: apiFile.FILE_NM, fileSeq: apiFile.FILE_SEQ });
- }
- } catch (error) {
- debugError("파일 동기화 실패", { fileName: apiFile.FILE_NM, error });
- console.error(`파일 동기화 실패: ${apiFile.FILE_NM}`, error);
- // 개별 파일 실패는 전체 프로세스를 중단하지 않음
- }
- }
-
- debugSuccess("DB 동기화 완료", {
- processed: processedCount,
- updated: updatedCount,
- inserted: insertedCount
- });
-}
-
--
cgit v1.2.3