From 2ecdac866c19abea0b5389708fcdf5b3889c969a Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Wed, 29 Oct 2025 15:59:04 +0900 Subject: (김준회) SWP 파일 업로드 취소 기능 추가, 업로드 파일명 검증로직에서 파일명 비필수로 변경 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/swp/actions.ts | 60 +++ lib/swp/api-client.ts | 160 ++++++- lib/swp/document-service.ts | 476 +++++++++++++++++++ lib/swp/table/swp-document-detail-dialog.tsx | 412 ++++++++++++++++ lib/swp/table/swp-help-dialog.tsx | 66 ++- lib/swp/table/swp-table-columns.tsx | 353 +------------- lib/swp/table/swp-table-toolbar.tsx | 634 +++++++++++++------------ lib/swp/table/swp-table.tsx | 198 ++------ lib/swp/table/swp-upload-validation-dialog.tsx | 373 +++++++++++++++ lib/swp/table/swp-uploaded-files-dialog.tsx | 358 ++++++++++++++ lib/swp/vendor-actions.ts | 513 ++++++-------------- 11 files changed, 2427 insertions(+), 1176 deletions(-) create mode 100644 lib/swp/document-service.ts create mode 100644 lib/swp/table/swp-document-detail-dialog.tsx create mode 100644 lib/swp/table/swp-upload-validation-dialog.tsx create mode 100644 lib/swp/table/swp-uploaded-files-dialog.tsx (limited to 'lib/swp') 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 ( + + + + 문서 상세 + {document && ( + + {document.DOC_NO} - {document.DOC_TITLE} + + )} + + + {document && ( +
+ {/* 문서 정보 */} +
+
+ 프로젝트: +
{document.PROJ_NO}
+ {document.PROJ_NM && ( +
{document.PROJ_NM}
+ )} +
+
+ 패키지: +
{document.PKG_NO || "-"}
+
+
+ 업체: +
{document.CPY_NM || "-"}
+ {document.VNDR_CD && ( +
{document.VNDR_CD}
+ )} +
+
+ 최신 리비전: +
{document.LTST_REV_NO || "-"}
+
+
+ + {/* 리비전 및 액티비티 트리 */} + {isLoading ? ( +
+ + 문서 상세 로딩 중... +
+ ) : detail && detail.revisions.length > 0 ? ( +
+ {/* 일괄 열기/닫기 버튼 */} +
+ +
+ {detail.revisions.map((revision) => { + const revKey = `${revision.revNo}|${revision.revSeq}`; + const isRevExpanded = expandedRevisions.has(revKey); + + return ( +
+ {/* 리비전 헤더 */} +
toggleRevision(revKey)} + > +
+ {isRevExpanded ? ( + + ) : ( + + )} + + REV {revision.revNo} + + + {revision.stage} + + + {revision.activities.length}개 액티비티 / {revision.totalFiles}개 파일 + +
+
+ + {/* 액티비티 목록 */} + {isRevExpanded && ( +
+ {revision.activities.map((activity) => { + const actKey = `${revKey}|${activity.actvNo}`; + const isActExpanded = expandedActivities.has(actKey); + + // Activity 타입에 따른 색상 + const activityColor = + activity.type === "Receive" ? "bg-blue-100 text-blue-800" : + activity.type === "Send" ? "bg-green-100 text-green-800" : + "bg-purple-100 text-purple-800"; + + return ( +
+ {/* 액티비티 헤더 */} +
toggleActivity(actKey)} + > +
+ {isActExpanded ? ( + + ) : ( + + )} + + {activity.type} + + + {activity.actvNo} + + + {activity.toFrom} + + + {activity.files.length}개 파일 + +
+
+ + {/* 파일 목록 */} + {isActExpanded && ( +
+ {activity.files.map((file, idx) => ( +
+
+ + {file.fileNm} + {file.fileSz && ( + + ({formatFileSize(file.fileSz)}) + + )} + {file.stat && ( + + {file.statNm || file.stat} + + )} +
+
+ {file.canCancel && file.boxSeq && file.actvSeq && ( + + )} + +
+
+ ))} +
+ )} +
+ ); + })} +
+ )} +
+ ); + })} +
+ ) : ( +
+ +

리비전 정보가 없습니다

+
+ )} +
+ )} +
+
+ ); +} + +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 ( <> + {/* 업로드 검증 다이얼로그 */} + + {/* 업로드 결과 다이얼로그 */} -
+
{/* 상단 액션 바 */} -
-
+ {vendorCode && ( +
+ -
- -
- {/* 벤더만 파일 업로드 기능 사용 가능 */} - {vendorCode && ( - <> - - - - - + + + {userId && ( + )} + +
-
+ )} - {/* 검색 필터 */} -
-
-

검색 필터

- -
+ {/* 검색 필터 */} +
+
+

검색 필터

+ +
-
- {/* 프로젝트 번호 */} -
- - {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, vndrCd: e.target.value }) - } - disabled={!!vendorCode} // 벤더 코드가 제공되면 입력 비활성화 - className={vendorCode ? "bg-muted" : ""} - /> + {/* 스테이지 */} +
+ + + setLocalFilters({ ...localFilters, stage: 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 ( + + + + 파일 업로드 검증 + + 선택한 파일의 파일명 형식을 검증합니다 + + + +
+ {/* 요약 통계 */} +
+
+
전체 파일
+
{validationResults.length}
+
+
+
검증 성공
+
+ {validFiles.length} +
+
+
+
검증 실패
+
+ {invalidFiles.length} +
+
+
+ + {/* 경고 메시지 */} + {invalidFiles.length > 0 && ( + + + + {invalidFiles.length}개 파일의 파일명 형식이 올바르지 않습니다. + 검증에 성공한 {validFiles.length}개 파일만 업로드됩니다. + + + )} + + {validFiles.length === 0 && ( + + + + 업로드 가능한 파일이 없습니다. 파일명 형식을 확인해주세요. + + + )} + + {/* 파일 목록 */} + +
+ {/* 검증 성공 파일 */} + {validFiles.length > 0 && ( +
+

+ + 검증 성공 ({validFiles.length}개) +

+ {validFiles.map((result, index) => ( +
+
+
+
+ {result.file.name} +
+ {result.parsed && ( +
+ + 문서: {result.parsed.ownDocNo} + + + Rev: {result.parsed.revNo} + + + Stage: {result.parsed.stage} + + {result.parsed.fileName && ( + + 파일명: {result.parsed.fileName} + + )} + + 확장자: .{result.parsed.extension} + +
+ )} +
+ +
+
+ ))} +
+ )} + + {/* 검증 실패 파일 */} + {invalidFiles.length > 0 && ( +
+

+ + 검증 실패 ({invalidFiles.length}개) +

+ {invalidFiles.map((result, index) => ( +
+
+
+
+ {result.file.name} +
+ {result.error && ( +
+ ✗ {result.error} +
+ )} +
+ +
+
+ ))} +
+ )} +
+
+ + {/* 형식 안내 */} +
+
+ 올바른 파일명 형식 +
+ + [DOC_NO]_[REV_NO]_[STAGE].[확장자] + +
+ 예: VD-DOC-001_01_IFA.pdf +
+
+ ※ 선택사항: [DOC_NO]_[REV_NO]_[STAGE]_[파일명].[확장자] (파일명 추가 가능) +
+
+ ※ 파일명에는 언더스코어(_)가 포함될 수 있습니다. +
+ {isVendorMode && ( +
+ {availableDocNos.length > 0 ? ( + <>ℹ️ 업로드 가능한 문서: {availableDocNos.length}개 + ) : ( + <>⚠️ 할당된 문서가 없습니다 + )} +
+ )} +
+
+ + + + + +
+
+ ); +} + 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 ( + + + + + + + 업로드한 파일 목록 + + 프로젝트: {projNo} | 업체: {vndrCd} + + + +
+ {/* 액션 바 */} +
+
+ 총 {files.length}개 파일 +
+ +
+ + {/* 파일 트리 */} + + {isLoading && files.length === 0 ? ( +
+ + 파일 목록을 조회하는 중... +
+ ) : files.length === 0 ? ( +
+ 업로드한 파일이 없습니다. +
+ ) : ( +
+ {Array.from(fileTree.entries()).map(([docNo, docNode]) => ( +
+ {/* 문서번호 */} +
toggleDoc(docNo)} + > + {expandedDocs.has(docNo) ? ( + + ) : ( + + )} + + {docNo} + + {docNode.revisions.size}개 리비전 + +
+ + {/* 리비전 목록 */} + {expandedDocs.has(docNo) && ( +
+ {Array.from(docNode.revisions.entries()).map(([revNo, revNode]) => { + const revKey = `${docNo}_${revNo}`; + return ( +
+ {/* 리비전 번호 */} +
toggleRev(docNo, revNo)} + > + {expandedRevs.has(revKey) ? ( + + ) : ( + + )} + Rev: {revNo} + + {revNode.files.files.length}개 파일 + +
+ + {/* 파일 목록 */} + {expandedRevs.has(revKey) && ( +
+ {revNode.files.files.map((file, idx) => { + const fileKey = `${file.BOX_SEQ}_${file.ACTV_SEQ}`; + const isCancellable = file.STAT === "SCW01"; + const isCancelling = cancellingFiles.has(fileKey); + + return ( +
+ +
+
{file.FILE_NM}
+
+ Stage: {file.STAGE} + + 상태: {file.STAT_NM || file.STAT || "알 수 없음"} +
+
+ +
+ ); + })} +
+ )} +
+ ); + })} +
+ )} +
+ ))} +
+ )} +
+ + {/* 안내 메시지 */} +
+
+

ℹ️ 접수 전(SCW01) 상태의 파일만 취소할 수 있습니다.

+

ℹ️ 취소된 파일은 목록에서 제거됩니다.

+
+
+
+
+
+ ); +} 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