diff options
| author | joonhoekim <26rote@gmail.com> | 2025-10-29 15:59:04 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-10-29 15:59:04 +0900 |
| commit | 2ecdac866c19abea0b5389708fcdf5b3889c969a (patch) | |
| tree | e02a02cfa0890691fb28a7df3a96ef495b3d4b79 /lib | |
| parent | 2fc9e5492e220041ba322d9a1479feb7803228cf (diff) | |
(김준회) SWP 파일 업로드 취소 기능 추가, 업로드 파일명 검증로직에서 파일명 비필수로 변경
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/swp/actions.ts | 60 | ||||
| -rw-r--r-- | lib/swp/api-client.ts | 160 | ||||
| -rw-r--r-- | lib/swp/document-service.ts | 476 | ||||
| -rw-r--r-- | lib/swp/table/swp-document-detail-dialog.tsx | 412 | ||||
| -rw-r--r-- | lib/swp/table/swp-help-dialog.tsx | 66 | ||||
| -rw-r--r-- | lib/swp/table/swp-table-columns.tsx | 353 | ||||
| -rw-r--r-- | lib/swp/table/swp-table-toolbar.tsx | 634 | ||||
| -rw-r--r-- | lib/swp/table/swp-table.tsx | 198 | ||||
| -rw-r--r-- | lib/swp/table/swp-upload-validation-dialog.tsx | 373 | ||||
| -rw-r--r-- | lib/swp/table/swp-uploaded-files-dialog.tsx | 358 | ||||
| -rw-r--r-- | lib/swp/vendor-actions.ts | 513 |
11 files changed, 2427 insertions, 1176 deletions
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<ActivityFileApiResponse[]> { + const body = { + proj_no: filter.proj_no, + doc_no: filter.doc_no, + rev_seq: filter.rev_seq || "", + }; + + return callSwpApi<ActivityFileApiResponse>( + "GetActivityFileList", + body, + "GetActivityFileListResult" + ); +} + +// ============================================================================ +// 서버 액션: 파일 취소 (SaveInBoxListCancelStatus) +// ============================================================================ + +export interface CancelFileParams { + boxSeq: string; + actvSeq: string; + chgr: string; // 취소 요청자 (evcp${userId}) +} + +/** + * 파일 취소 API (SaveInBoxListCancelStatus) + */ +export async function callSaveInBoxListCancelStatus( + params: CancelFileParams +): Promise<void> { + 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<DocumentListItem[]> { + 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<string, SwpFileApiResponse[]>(); + 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<DocumentDetail> { + 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<string, SwpFileApiResponse>(); + 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<string, SwpFileApiResponse> +): DocumentDetail { + if (activityFiles.length === 0) { + return { + docNo: "", + docTitle: "", + projNo: "", + revisions: [], + }; + } + + const first = activityFiles[0]; + + // REV_NO로 그룹핑 + const revisionMap = new Map<string, ActivityFileApiResponse[]>(); + 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<string, ActivityFileApiResponse[]>(); + 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<void> { + 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<DownloadFileResult> { + 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<string, string> = { + ".pdf": "application/pdf", + ".doc": "application/msword", + ".docx": + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xls": "application/vnd.ms-excel", + ".xlsx": + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".ppt": "application/vnd.ms-powerpoint", + ".pptx": + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".txt": "text/plain", + ".csv": "text/csv", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".zip": "application/zip", + ".rar": "application/x-rar-compressed", + ".7z": "application/x-7z-compressed", + ".dwg": "application/acad", + ".dxf": "application/dxf", + }; + + return mimeTypes[ext] || "application/octet-stream"; +} + 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<DocumentDetail | null>(null); + const [isLoading, setIsLoading] = useState(false); + const [expandedRevisions, setExpandedRevisions] = useState<Set<string>>(new Set()); + const [expandedActivities, setExpandedActivities] = useState<Set<string>>(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<string>(); + const allActKeys = new Set<string>(); + + 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<string>(); + const allActKeys = new Set<string>(); + + 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 ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>문서 상세</DialogTitle> + {document && ( + <DialogDescription> + {document.DOC_NO} - {document.DOC_TITLE} + </DialogDescription> + )} + </DialogHeader> + + {document && ( + <div className="space-y-4"> + {/* 문서 정보 */} + <div className="grid grid-cols-1 md:grid-cols-4 gap-4 p-4 bg-muted/30 rounded-lg"> + <div> + <span className="text-sm font-semibold">프로젝트:</span> + <div className="text-sm">{document.PROJ_NO}</div> + {document.PROJ_NM && ( + <div className="text-xs text-muted-foreground">{document.PROJ_NM}</div> + )} + </div> + <div> + <span className="text-sm font-semibold">패키지:</span> + <div className="text-sm">{document.PKG_NO || "-"}</div> + </div> + <div> + <span className="text-sm font-semibold">업체:</span> + <div className="text-sm">{document.CPY_NM || "-"}</div> + {document.VNDR_CD && ( + <div className="text-xs text-muted-foreground">{document.VNDR_CD}</div> + )} + </div> + <div> + <span className="text-sm font-semibold">최신 리비전:</span> + <div className="text-sm">{document.LTST_REV_NO || "-"}</div> + </div> + </div> + + {/* 리비전 및 액티비티 트리 */} + {isLoading ? ( + <div className="flex items-center justify-center p-8"> + <Loader2 className="h-6 w-6 animate-spin" /> + <span className="ml-2">문서 상세 로딩 중...</span> + </div> + ) : detail && detail.revisions.length > 0 ? ( + <div className="space-y-2"> + {/* 일괄 열기/닫기 버튼 */} + <div className="flex justify-end"> + <Button + variant="outline" + size="sm" + onClick={handleToggleAll} + > + {isAllExpanded ? ( + <> + <ChevronDown className="h-4 w-4 mr-2" /> + 일괄 닫기 + </> + ) : ( + <> + <ChevronRight className="h-4 w-4 mr-2" /> + 일괄 열기 + </> + )} + </Button> + </div> + {detail.revisions.map((revision) => { + const revKey = `${revision.revNo}|${revision.revSeq}`; + const isRevExpanded = expandedRevisions.has(revKey); + + return ( + <div key={revKey} className="border rounded-lg"> + {/* 리비전 헤더 */} + <div + className="flex items-center justify-between p-3 bg-muted/50 cursor-pointer hover:bg-muted" + onClick={() => toggleRevision(revKey)} + > + <div className="flex items-center gap-3"> + {isRevExpanded ? ( + <ChevronDown className="h-4 w-4" /> + ) : ( + <ChevronRight className="h-4 w-4" /> + )} + <Badge variant="secondary" className="font-mono"> + REV {revision.revNo} + </Badge> + <Badge variant="outline" className={ + revision.stage === "IFC" ? "bg-green-100 text-green-800" : + revision.stage === "IFA" ? "bg-blue-100 text-blue-800" : + "bg-gray-100 text-gray-800" + }> + {revision.stage} + </Badge> + <span className="text-sm text-muted-foreground"> + {revision.activities.length}개 액티비티 / {revision.totalFiles}개 파일 + </span> + </div> + </div> + + {/* 액티비티 목록 */} + {isRevExpanded && ( + <div className="p-2 space-y-2"> + {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 ( + <div key={actKey} className="border rounded-md"> + {/* 액티비티 헤더 */} + <div + className="flex items-center justify-between p-2 bg-muted/30 cursor-pointer hover:bg-muted/50" + onClick={() => toggleActivity(actKey)} + > + <div className="flex items-center gap-2"> + {isActExpanded ? ( + <ChevronDown className="h-4 w-4" /> + ) : ( + <ChevronRight className="h-4 w-4" /> + )} + <Badge variant="outline" className={activityColor}> + {activity.type} + </Badge> + <span className="text-xs text-muted-foreground font-mono"> + {activity.actvNo} + </span> + <span className="text-sm text-muted-foreground"> + {activity.toFrom} + </span> + <span className="text-xs text-muted-foreground"> + {activity.files.length}개 파일 + </span> + </div> + </div> + + {/* 파일 목록 */} + {isActExpanded && ( + <div className="p-2 space-y-1"> + {activity.files.map((file, idx) => ( + <div + key={idx} + className="flex items-center justify-between p-2 border rounded bg-background hover:bg-muted/30" + > + <div className="flex items-center gap-2 flex-1"> + <FileIcon className="h-4 w-4 text-blue-500" /> + <span className="text-sm font-mono">{file.fileNm}</span> + {file.fileSz && ( + <span className="text-xs text-muted-foreground"> + ({formatFileSize(file.fileSz)}) + </span> + )} + {file.stat && ( + <Badge variant="outline" className={ + file.stat === "SCW01" ? "bg-yellow-100 text-yellow-800" : + file.stat === "SCW03" ? "bg-green-100 text-green-800" : + file.stat === "SCW09" ? "bg-gray-100 text-gray-800" : + "bg-gray-100 text-gray-800" + }> + {file.statNm || file.stat} + </Badge> + )} + </div> + <div className="flex items-center gap-1"> + {file.canCancel && file.boxSeq && file.actvSeq && ( + <Button + variant="outline" + size="sm" + onClick={() => handleCancelFile(file.boxSeq!, file.actvSeq!, file.fileNm)} + > + <XCircle className="h-4 w-4 mr-1" /> + 취소 + </Button> + )} + <Button + variant="outline" + size="sm" + onClick={() => handleDownloadFile(file.fileNm, document.DOC_NO)} + > + <Download className="h-4 w-4 mr-1" /> + 다운로드 + </Button> + </div> + </div> + ))} + </div> + )} + </div> + ); + })} + </div> + )} + </div> + ); + })} + </div> + ) : ( + <div className="p-8 text-center text-muted-foreground"> + <AlertCircle className="h-12 w-12 mx-auto mb-2 opacity-50" /> + <p>리비전 정보가 없습니다</p> + </div> + )} + </div> + )} + </DialogContent> + </Dialog> + ); +} + +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() { 업로드 가이드 </Button> </DialogTrigger> - <DialogContent className="max-w-2xl" opacityControl={false}> + <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> <DialogHeader> <DialogTitle>파일 업로드 가이드</DialogTitle> <DialogDescription> @@ -34,10 +34,13 @@ export function SwpUploadHelpDialog() { <div className="space-y-2"> <h3 className="text-sm font-semibold">파일명 형식</h3> <div className="rounded-lg bg-muted p-4 font-mono text-sm"> - [OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDDhhmmss].[확장자] + [DOC_NO]_[REV_NO]_[STAGE].[확장자] </div> <p className="text-xs text-muted-foreground"> - ⚠️ 언더스코어(_)가 정확히 3개 있어야 합니다 + ⚠️ 언더스코어(_)가 최소 2개 이상 있어야 합니다 + </p> + <p className="text-xs text-muted-foreground"> + ℹ️ 선택사항: 4번째 항목으로 파일명을 추가할 수 있습니다 (예: [DOC_NO]_[REV_NO]_[STAGE]_[파일명].[확장자]) </p> </div> @@ -47,7 +50,7 @@ export function SwpUploadHelpDialog() { <div className="flex items-center gap-3 rounded-lg border p-3"> <Badge variant="secondary" className="font-mono shrink-0"> - OWN_DOC_NO + DOC_NO </Badge> <div className="text-sm"> <span className="font-medium">벤더의 문서번호</span> @@ -77,11 +80,11 @@ export function SwpUploadHelpDialog() { <div className="flex items-center gap-3 rounded-lg border p-3"> <Badge variant="secondary" className="font-mono shrink-0"> - YYYYMMDDhhmmss + 파일명 </Badge> <div className="text-sm"> - <span className="font-medium">날짜 및 시간</span> - <span className="text-muted-foreground"> - 업로드 날짜 정보를 기입합니다 (14자리 숫자)</span> + <span className="font-medium">자유 파일명 (선택사항)</span> + <span className="text-muted-foreground"> - 문서를 식별할 수 있는 이름 (언더스코어 포함 가능, 생략 가능)</span> </div> </div> </div> @@ -92,13 +95,35 @@ export function SwpUploadHelpDialog() { <div className="space-y-2"> <div className="rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 p-3"> <code className="text-xs font-mono text-green-700 dark:text-green-300"> - VD-DOC-001_01_IFA_20250124143000.pdf + VD-DOC-001_01_IFA.pdf + </code> + <p className="text-xs text-green-600 dark:text-green-400 mt-1"> + ✓ 기본 형식 (파일명 생략) + </p> + </div> + <div className="rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 p-3"> + <code className="text-xs font-mono text-green-700 dark:text-green-300"> + VD-DOC-001_01_IFA_drawing_final.pdf + </code> + <p className="text-xs text-green-600 dark:text-green-400 mt-1"> + ✓ 파일명 추가 (파일명에 언더스코어 포함 가능) + </p> + </div> + <div className="rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 p-3"> + <code className="text-xs font-mono text-green-700 dark:text-green-300"> + TECH-SPEC-002_02_IFC.dwg </code> + <p className="text-xs text-green-600 dark:text-green-400 mt-1"> + ✓ 기본 형식 사용 + </p> </div> <div className="rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 p-3"> <code className="text-xs font-mono text-green-700 dark:text-green-300"> - TECH-SPEC-002_02_IFC_20250124150000.dwg + DOC-003_03_IFA_test_result_data.xlsx </code> + <p className="text-xs text-green-600 dark:text-green-400 mt-1"> + ✓ 파일명 추가 (여러 단어 조합 가능) + </p> </div> </div> </div> @@ -109,7 +134,7 @@ export function SwpUploadHelpDialog() { <div className="space-y-2"> <div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3"> <code className="text-xs font-mono text-red-700 dark:text-red-300"> - VD-DOC-001-01-IFA-20250124.pdf + VD-DOC-001-01-IFA.pdf </code> <p className="text-xs text-red-600 dark:text-red-400 mt-1"> ✗ 언더스코어(_) 대신 하이픈(-) 사용 @@ -117,18 +142,26 @@ export function SwpUploadHelpDialog() { </div> <div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3"> <code className="text-xs font-mono text-red-700 dark:text-red-300"> - VD-DOC-001_01_IFA.pdf + VD-DOC-001_01.pdf + </code> + <p className="text-xs text-red-600 dark:text-red-400 mt-1"> + ✗ STAGE 정보 누락 (최소 3개 항목 필요) + </p> + </div> + <div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3"> + <code className="text-xs font-mono text-red-700 dark:text-red-300"> + VD DOC 001_01_IFA.pdf </code> <p className="text-xs text-red-600 dark:text-red-400 mt-1"> - ✗ 날짜/시간 정보 누락 + ✗ 공백 포함 (언더스코어 사용 필요) </p> </div> <div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3"> <code className="text-xs font-mono text-red-700 dark:text-red-300"> - VD-DOC-001_01_IFA_20250124.pdf + VD-DOC-001__IFA.pdf </code> <p className="text-xs text-red-600 dark:text-red-400 mt-1"> - ✗ 시간 정보 누락 (14자리가 아님) + ✗ REV_NO 비어있음 (빈 항목 불가) </p> </div> </div> @@ -140,7 +173,10 @@ export function SwpUploadHelpDialog() { ⚠️ 주의사항 </h3> <ul className="text-xs text-amber-800 dark:text-amber-200 space-y-1 list-disc list-inside"> - <li>파일명 형식이 올바르지 않으면 업로드가 실패합니다</li> + <li>파일명은 최소 [DOC_NO]_[REV_NO]_[STAGE].[확장자] 형식이어야 합니다</li> + <li>DOC_NO는 현재 프로젝트에 할당된 문서번호여야 합니다</li> + <li>4번째 항목(파일명)은 선택사항으로 생략 가능합니다</li> + <li>업로드 날짜/시간은 시스템에서 자동으로 생성됩니다</li> <li>같은 파일명으로 이미 업로드된 파일이 있으면 덮어쓰지 않고 오류 처리됩니다</li> <li>프로젝트와 업체 코드를 먼저 선택해야 업로드 버튼이 활성화됩니다</li> </ul> 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<SwpDocumentWithStats>[] = [ +export const swpDocumentColumns: ColumnDef<DocumentListItem>[] = [ { id: "expander", header: () => null, - cell: () => { - return ( - <Button - variant="ghost" - size="sm" - className="h-8 w-8 p-0" - > - <ChevronRight className="h-4 w-4" /> - </Button> - ); - }, + 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<SwpDocumentWithStats>[] = [ </Badge> ); }, - size: 100, + size: 120, }, { accessorKey: "DOC_NO", @@ -61,7 +44,7 @@ export const swpDocumentColumns: ColumnDef<SwpDocumentWithStats>[] = [ accessorKey: "DOC_TITLE", header: "문서제목", cell: ({ row }) => ( - <div className="max-w-md" title={row.original.DOC_TITLE}> + <div className="max-w-md truncate" title={row.original.DOC_TITLE}> {row.original.DOC_TITLE} </div> ), @@ -74,7 +57,7 @@ export const swpDocumentColumns: ColumnDef<SwpDocumentWithStats>[] = [ <div> <div className="font-medium">{row.original.PROJ_NO}</div> {row.original.PROJ_NM && ( - <div className="text-xs text-muted-foreground max-w-[150px]"> + <div className="text-xs text-muted-foreground max-w-[150px] truncate"> {row.original.PROJ_NM} </div> )} @@ -127,325 +110,25 @@ export const swpDocumentColumns: ColumnDef<SwpDocumentWithStats>[] = [ }, { 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 }) => ( <div className="text-center"> <div className="text-sm font-medium"> - {row.original.revision_count} / {row.original.file_count} + {row.original.fileCount}개 </div> + {row.original.standbyFileCount > 0 && ( + <div className="text-xs text-yellow-600"> + 대기중 {row.original.standbyFileCount} + </div> + )} </div> ), size: 100, }, - { - accessorKey: "last_synced_at", - header: "동기화", - cell: ({ row }) => ( - <div className="text-xs text-muted-foreground"> - {formatDistanceToNow(new Date(row.original.last_synced_at), { - addSuffix: true, - locale: ko, - })} - </div> - ), - 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<RevisionRow>[] = [ - { - id: "expander", - header: () => null, - cell: ({ row }) => { - return row.getCanExpand() ? ( - <Button - variant="ghost" - size="sm" - className="h-8 w-8 p-0 ml-8" - > - {row.getIsExpanded() ? ( - <ChevronDown className="h-4 w-4" /> - ) : ( - <ChevronRight className="h-4 w-4" /> - )} - </Button> - ) : null; - }, - size: 100, - }, - { - accessorKey: "REV_NO", - header: "리비전", - cell: ({ row }) => ( - <Badge variant="secondary" className="font-mono"> - REV {row.original.REV_NO} - </Badge> - ), - 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 ( - <Badge variant="outline" className={color}> - {stage} - </Badge> - ); - }, - size: 100, - }, - { - accessorKey: "OFDC_NO", - header: "OFDC 번호", - cell: ({ row }) => ( - <div className="font-mono text-sm">{row.original.OFDC_NO || "-"}</div> - ), - size: 200, - }, - { - accessorKey: "ACTV_NO", - header: "Activity", - cell: ({ row }) => ( - <div className="font-mono text-xs text-muted-foreground"> - {row.original.ACTV_NO || "-"} - </div> - ), - size: 250, - }, - { - id: "file_count", - header: "파일 수", - cell: ({ row }) => ( - <div className="flex items-center gap-2"> - <FileIcon className="h-4 w-4 text-muted-foreground" /> - <span className="font-medium">{row.original.file_count}</span> - </div> - ), - size: 100, - }, - { - accessorKey: "last_synced_at", - header: "동기화", - cell: ({ row }) => ( - <div className="text-xs text-muted-foreground"> - {formatDistanceToNow(new Date(row.original.last_synced_at), { - addSuffix: true, - locale: ko, - })} - </div> - ), - 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<FileRow>[] = [ - { - id: "spacer", - header: () => null, - cell: () => <div className="w-16" />, - size: 150, - }, - { - accessorKey: "FILE_SEQ", - header: "순서", - cell: ({ row }) => ( - <Badge variant="outline" className="font-mono"> - #{row.original.FILE_SEQ} - </Badge> - ), - size: 80, - }, - { - accessorKey: "FILE_NM", - header: "파일명", - cell: ({ row }) => ( - <div className="flex items-center gap-2"> - <FileIcon className="h-4 w-4 text-blue-500" /> - <span className="font-mono text-sm">{row.original.FILE_NM}</span> - </div> - ), - 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 ( - <Badge variant="outline" className={color}> - {status} - </Badge> - ); - }, - size: 100, - }, - { - accessorKey: "FLD_PATH", - header: "경로", - cell: ({ row }) => ( - <div className="font-mono text-xs text-muted-foreground truncate max-w-[200px]" title={row.original.FLD_PATH || ""}> - {row.original.FLD_PATH || "-"} - </div> - ), - size: 200, - }, - { - accessorKey: "created_at", - header: "생성일", - cell: ({ row }) => ( - <div className="text-xs text-muted-foreground"> - {formatDistanceToNow(new Date(row.original.created_at), { - addSuffix: true, - locale: ko, - })} - </div> - ), - size: 100, - }, - { - id: "actions", - header: "작업", - cell: ({ row }) => ( - <DownloadButton fileId={row.original.id} fileName={row.original.FILE_NM} /> - ), - 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 ( - <Button - variant="outline" - size="sm" - onClick={handleDownload} - disabled={isDownloading} - > - {isDownloading ? ( - <> - <Loader2 className="h-4 w-4 mr-1 animate-spin" /> - 다운로드 중... - </> - ) : ( - <> - <Download className="h-4 w-4 mr-1" /> - 다운로드 - </> - )} - </Button> - ); -} - 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<SwpTableFilters>(filters); + const [localFilters, setLocalFilters] = useState(filters); const { toast } = useToast(); - const router = useRouter(); const [projectSearchOpen, setProjectSearchOpen] = useState(false); const [projectSearch, setProjectSearch] = useState(""); const fileInputRef = useRef<HTMLInputElement>(null); const [uploadResults, setUploadResults] = useState<Array<{ fileName: string; success: boolean; error?: string }>>([]); const [showResultDialog, setShowResultDialog] = useState(false); + + // 검증 다이얼로그 상태 + const [validationResults, setValidationResults] = useState<Array<{ + file: File; + valid: boolean; + parsed?: { + ownDocNo: string; + revNo: string; + stage: string; + fileName: string; + extension: string; + }; + error?: string; + }>>([]); + 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<HTMLInputElement>) => { + const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { 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 ( <> + {/* 업로드 검증 다이얼로그 */} + <SwpUploadValidationDialog + open={showValidationDialog} + onOpenChange={setShowValidationDialog} + validationResults={validationResults} + onConfirmUpload={handleConfirmUpload} + isUploading={isUploading} + availableDocNos={availableDocNos} + isVendorMode={isVendorMode} + /> + {/* 업로드 결과 다이얼로그 */} <SwpUploadResultDialog open={showResultDialog} @@ -220,240 +297,205 @@ export function SwpTableToolbar({ results={uploadResults} /> - <div className="space-y-4"> + <div className="space-y-4 w-full"> {/* 상단 액션 바 */} - <div className="flex items-center justify-between"> - <div className="flex items-center gap-2"> + {vendorCode && ( + <div className="flex items-center justify-end gap-2"> + <input + ref={fileInputRef} + type="file" + multiple + className="hidden" + onChange={handleFileChange} + accept="*/*" + /> <Button - onClick={handleSync} - disabled={isSyncing || !localFilters.projNo} + variant="outline" size="sm" + onClick={onRefresh} + disabled={isRefreshing || !projNo} > - <RefreshCw className={`h-4 w-4 mr-2 ${isSyncing ? "animate-spin" : ""}`} /> - {isSyncing ? "동기화 중..." : "SWP 동기화"} + <RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} /> + 새로고침 </Button> - </div> - - <div className="flex items-center gap-2"> - {/* 벤더만 파일 업로드 기능 사용 가능 */} - {vendorCode && ( - <> - <input - ref={fileInputRef} - type="file" - multiple - className="hidden" - onChange={handleFileChange} - accept="*/*" - /> - <Button - variant="outline" - size="sm" - onClick={handleUploadFiles} - disabled={isUploading || !localFilters.projNo || (!vendorCode && !localFilters.vndrCd)} - > - <Upload className={`h-4 w-4 mr-2 ${isUploading ? "animate-pulse" : ""}`} /> - {isUploading ? "업로드 중..." : "파일 업로드"} - </Button> - - <SwpUploadHelpDialog /> - </> + <Button + variant="outline" + size="sm" + onClick={handleUploadFiles} + disabled={isUploading || !projNo} + > + <Upload className={`h-4 w-4 mr-2 ${isUploading ? "animate-pulse" : ""}`} /> + {isUploading ? "업로드 중..." : "파일 업로드"} + </Button> + + {userId && ( + <SwpUploadedFilesDialog + projNo={projNo} + vndrCd={vendorCode} + userId={userId} + /> )} + + <SwpUploadHelpDialog /> </div> - </div> + )} - {/* 검색 필터 */} - <div className="rounded-lg border p-4 space-y-4"> - <div className="flex items-center justify-between"> - <h3 className="text-sm font-semibold">검색 필터</h3> - <Button - variant="ghost" - size="sm" - onClick={handleReset} - className="h-8" - > - <X className="h-4 w-4 mr-1" /> - 초기화 - </Button> - </div> + {/* 검색 필터 */} + <div className="rounded-lg border p-4 space-y-4"> + <div className="flex items-center justify-between"> + <h3 className="text-sm font-semibold">검색 필터</h3> + <Button + variant="ghost" + size="sm" + onClick={handleReset} + className="h-8" + > + <X className="h-4 w-4 mr-1" /> + 초기화 + </Button> + </div> - <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> - {/* 프로젝트 번호 */} - <div className="space-y-2"> - <Label htmlFor="projNo">프로젝트 번호</Label> - {projects.length > 0 ? ( - <Popover open={projectSearchOpen} onOpenChange={setProjectSearchOpen}> - <PopoverTrigger asChild> - <Button - variant="outline" - role="combobox" - aria-expanded={projectSearchOpen} - className="w-full justify-between" - > - {localFilters.projNo ? ( - <span> - {projects.find((p) => p.PROJ_NO === localFilters.projNo)?.PROJ_NO || localFilters.projNo} - {" ["} - {projects.find((p) => p.PROJ_NO === localFilters.projNo)?.PROJ_NM} - {"]"} - </span> - ) : ( - <span className="text-muted-foreground">프로젝트 선택</span> - )} - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> - </Button> - </PopoverTrigger> - <PopoverContent className="w-[400px] p-0" align="start"> - <div className="p-2"> - <div className="flex items-center border rounded-md px-3"> - <Search className="h-4 w-4 mr-2 opacity-50" /> - <Input - placeholder="프로젝트 번호 또는 이름으로 검색..." - value={projectSearch} - onChange={(e) => setProjectSearch(e.target.value)} - className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0" - /> - </div> - </div> - <div className="max-h-[300px] overflow-y-auto"> - <div className="p-1"> - <Button - variant="ghost" - className="w-full justify-start font-normal" - onClick={() => { - setLocalFilters({ ...localFilters, projNo: undefined }); - setProjectSearchOpen(false); - setProjectSearch(""); - }} - > - <Check - className={cn( - "mr-2 h-4 w-4", - !localFilters.projNo ? "opacity-100" : "opacity-0" - )} - /> - 전체 - </Button> - {filteredProjects.map((proj) => ( - <Button - key={proj.PROJ_NO} - variant="ghost" - className="w-full justify-start font-normal" - onClick={() => { - setLocalFilters({ ...localFilters, projNo: proj.PROJ_NO }); - setProjectSearchOpen(false); - setProjectSearch(""); - }} - > - <Check - className={cn( - "mr-2 h-4 w-4", - localFilters.projNo === proj.PROJ_NO ? "opacity-100" : "opacity-0" - )} - /> - <span className="font-mono text-sm">{proj.PROJ_NO} [{proj.PROJ_NM}]</span> - </Button> - ))} - {filteredProjects.length === 0 && ( - <div className="py-6 text-center text-sm text-muted-foreground"> - 검색 결과가 없습니다. - </div> + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> + {/* 프로젝트 번호 */} + <div className="space-y-2"> + <Label htmlFor="projNo">프로젝트 번호</Label> + {projects.length > 0 ? ( + <Popover open={projectSearchOpen} onOpenChange={setProjectSearchOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={projectSearchOpen} + className="w-full justify-between" + > + {projNo ? ( + <span> + {projects.find((p) => p.PROJ_NO === projNo)?.PROJ_NO || projNo} + {" ["} + {projects.find((p) => p.PROJ_NO === projNo)?.PROJ_NM} + {"]"} + </span> + ) : ( + <span className="text-muted-foreground">프로젝트 선택</span> )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0" align="start"> + <div className="p-2"> + <div className="flex items-center border rounded-md px-3"> + <Search className="h-4 w-4 mr-2 opacity-50" /> + <Input + placeholder="프로젝트 번호 또는 이름으로 검색..." + value={projectSearch} + onChange={(e) => setProjectSearch(e.target.value)} + className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0" + /> + </div> + </div> + <div className="max-h-[300px] overflow-y-auto"> + <div className="p-1"> + {filteredProjects.map((proj) => ( + <Button + key={proj.PROJ_NO} + variant="ghost" + className="w-full justify-start font-normal" + onClick={() => { + onProjNoChange(proj.PROJ_NO); + setProjectSearchOpen(false); + setProjectSearch(""); + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + projNo === proj.PROJ_NO ? "opacity-100" : "opacity-0" + )} + /> + <span className="font-mono text-sm">{proj.PROJ_NO} [{proj.PROJ_NM || ""}]</span> + </Button> + ))} + {filteredProjects.length === 0 && ( + <div className="py-6 text-center text-sm text-muted-foreground"> + 검색 결과가 없습니다. + </div> + )} + </div> </div> - </div> - </PopoverContent> - </Popover> - ) : ( + </PopoverContent> + </Popover> + ) : ( + <Input + id="projNo" + placeholder="계약된 프로젝트가 없습니다" + value={projNo} + disabled + className="bg-muted" + /> + )} + </div> + + {/* 문서 번호 */} + <div className="space-y-2"> + <Label htmlFor="docNo">문서 번호</Label> <Input - id="projNo" - placeholder="계약된 프로젝트가 없습니다" - value={localFilters.projNo || ""} + id="docNo" + placeholder="문서 번호 검색" + value={localFilters.docNo || ""} onChange={(e) => - setLocalFilters({ ...localFilters, projNo: e.target.value }) + setLocalFilters({ ...localFilters, docNo: e.target.value }) } - disabled - className="bg-muted" /> - )} - </div> - - {/* 문서 번호 */} - <div className="space-y-2"> - <Label htmlFor="docNo">문서 번호</Label> - <Input - id="docNo" - placeholder="문서 번호 검색" - value={localFilters.docNo || ""} - onChange={(e) => - setLocalFilters({ ...localFilters, docNo: e.target.value }) - } - /> - </div> + </div> - {/* 문서 제목 */} - <div className="space-y-2"> - <Label htmlFor="docTitle">문서 제목</Label> - <Input - id="docTitle" - placeholder="제목 검색" - value={localFilters.docTitle || ""} - onChange={(e) => - setLocalFilters({ ...localFilters, docTitle: e.target.value }) - } - /> - </div> + {/* 문서 제목 */} + <div className="space-y-2"> + <Label htmlFor="docTitle">문서 제목</Label> + <Input + id="docTitle" + placeholder="제목 검색" + value={localFilters.docTitle || ""} + onChange={(e) => + setLocalFilters({ ...localFilters, docTitle: e.target.value }) + } + /> + </div> - {/* 패키지 번호 */} - <div className="space-y-2"> - <Label htmlFor="pkgNo">패키지</Label> - <Input - id="pkgNo" - placeholder="패키지 번호" - value={localFilters.pkgNo || ""} - onChange={(e) => - setLocalFilters({ ...localFilters, pkgNo: e.target.value }) - } - /> - </div> + {/* 패키지 번호 */} + <div className="space-y-2"> + <Label htmlFor="pkgNo">패키지</Label> + <Input + id="pkgNo" + placeholder="패키지 번호" + value={localFilters.pkgNo || ""} + onChange={(e) => + setLocalFilters({ ...localFilters, pkgNo: e.target.value }) + } + /> + </div> - {/* 업체 코드 */} - <div className="space-y-2"> - <Label htmlFor="vndrCd">업체 코드</Label> - <Input - id="vndrCd" - placeholder="업체 코드" - value={vendorCode || localFilters.vndrCd || ""} - onChange={(e) => - setLocalFilters({ ...localFilters, vndrCd: e.target.value }) - } - disabled={!!vendorCode} // 벤더 코드가 제공되면 입력 비활성화 - className={vendorCode ? "bg-muted" : ""} - /> + {/* 스테이지 */} + <div className="space-y-2"> + <Label htmlFor="stage">스테이지</Label> + <Input + id="stage" + placeholder="스테이지 입력 (예: IFC, IFA)" + value={localFilters.stage || ""} + onChange={(e) => + setLocalFilters({ ...localFilters, stage: e.target.value }) + } + /> + </div> </div> - {/* 스테이지 */} - <div className="space-y-2"> - <Label htmlFor="stage">스테이지</Label> - <Input - id="stage" - placeholder="스테이지 입력" - value={localFilters.stage || ""} - onChange={(e) => - setLocalFilters({ ...localFilters, stage: e.target.value }) - } - /> + <div className="flex justify-end"> + <Button onClick={handleSearch} size="sm"> + <Search className="h-4 w-4 mr-2" /> + 검색 + </Button> </div> </div> - - <div className="flex justify-end"> - <Button onClick={handleSearch} size="sm"> - <Search className="h-4 w-4 mr-2" /> - 검색 - </Button> - </div> - </div> </div> </> ); } - 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<ExpandedState>({}); - const [revisionData, setRevisionData] = useState<Record<string, RevisionRow[]>>({}); - const [fileData, setFileData] = useState<Record<number, FileRow[]>>({}); - const [loadingRevisions, setLoadingRevisions] = useState<Set<string>>(new Set()); - const [loadingFiles, setLoadingFiles] = useState<Set<number>>(new Set()); const [dialogOpen, setDialogOpen] = useState(false); - const [selectedDocument, setSelectedDocument] = useState<SwpDocumentWithStats | null>(null); + const [selectedDocument, setSelectedDocument] = useState<DocumentListItem | null>(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({ <TableBody> {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( - <React.Fragment key={row.id}> - {/* 문서 행 */} - <TableRow - data-state={row.getIsSelected() && "selected"} - className="hover:bg-muted/50" - > - {row.getVisibleCells().map((cell) => ( - <TableCell key={cell.id}> - {cell.column.id === "expander" ? ( - <div - onClick={() => handleDocumentClick(row.original)} - className="cursor-pointer" - > - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - </div> - ) : ( - flexRender(cell.column.columnDef.cell, cell.getContext()) - )} - </TableCell> - ))} - </TableRow> - </React.Fragment> + <TableRow + key={row.id} + data-state={row.getIsSelected() && "selected"} + className="hover:bg-muted/50 cursor-pointer" + onClick={() => handleDocumentClick(row.original)} + > + {row.getVisibleCells().map((cell) => ( + <TableCell key={cell.id}> + {cell.column.id === "expander" ? ( + <Button + variant="ghost" + size="sm" + className="h-8 w-8 p-0" + > + <ChevronRight className="h-4 w-4" /> + </Button> + ) : ( + flexRender(cell.column.columnDef.cell, cell.getContext()) + )} + </TableCell> + ))} + </TableRow> )) ) : ( <TableRow> @@ -190,46 +106,14 @@ export function SwpTable({ </Table> </div> - {/* 페이지네이션 */} - <div className="flex items-center justify-between"> - <div className="text-sm text-muted-foreground"> - 총 {total}개 중 {(page - 1) * pageSize + 1}- - {Math.min(page * pageSize, total)}개 표시 - </div> - <div className="flex items-center space-x-2"> - <Button - variant="outline" - size="sm" - onClick={() => onPageChange(page - 1)} - disabled={page === 1} - > - 이전 - </Button> - <div className="text-sm"> - {page} / {totalPages} - </div> - <Button - variant="outline" - size="sm" - onClick={() => onPageChange(page + 1)} - disabled={page === totalPages} - > - 다음 - </Button> - </div> - </div> - {/* 문서 상세 Dialog */} - <SwpRevisionListDialog + <SwpDocumentDetailDialog open={dialogOpen} onOpenChange={setDialogOpen} document={selectedDocument} - revisions={selectedDocument ? revisionData[selectedDocument.DOC_NO] || [] : []} - fileData={fileData} - loadingRevisions={selectedDocument ? loadingRevisions.has(selectedDocument.DOC_NO) : false} - loadingFiles={loadingFiles} - onLoadFiles={loadFiles} - onLoadAllFiles={() => selectedDocument && loadAllFiles(selectedDocument.DOC_NO)} + projNo={projNo} + vendorCode={vendorCode} + userId={userId} /> </div> ); 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 ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-3xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>파일 업로드 검증</DialogTitle> + <DialogDescription> + 선택한 파일의 파일명 형식을 검증합니다 + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 요약 통계 */} + <div className="grid grid-cols-3 gap-4"> + <div className="rounded-lg border p-3"> + <div className="text-sm text-muted-foreground">전체 파일</div> + <div className="text-2xl font-bold">{validationResults.length}</div> + </div> + <div className="rounded-lg border p-3 bg-green-50 dark:bg-green-950/30"> + <div className="text-sm text-green-600 dark:text-green-400">검증 성공</div> + <div className="text-2xl font-bold text-green-600 dark:text-green-400"> + {validFiles.length} + </div> + </div> + <div className="rounded-lg border p-3 bg-red-50 dark:bg-red-950/30"> + <div className="text-sm text-red-600 dark:text-red-400">검증 실패</div> + <div className="text-2xl font-bold text-red-600 dark:text-red-400"> + {invalidFiles.length} + </div> + </div> + </div> + + {/* 경고 메시지 */} + {invalidFiles.length > 0 && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + {invalidFiles.length}개 파일의 파일명 형식이 올바르지 않습니다. + 검증에 성공한 {validFiles.length}개 파일만 업로드됩니다. + </AlertDescription> + </Alert> + )} + + {validFiles.length === 0 && ( + <Alert variant="destructive"> + <XCircle className="h-4 w-4" /> + <AlertDescription> + 업로드 가능한 파일이 없습니다. 파일명 형식을 확인해주세요. + </AlertDescription> + </Alert> + )} + + {/* 파일 목록 */} + <ScrollArea className="h-[300px] rounded-md border p-4"> + <div className="space-y-3"> + {/* 검증 성공 파일 */} + {validFiles.length > 0 && ( + <div className="space-y-2"> + <h4 className="text-sm font-semibold text-green-600 dark:text-green-400 flex items-center gap-2"> + <CheckCircle2 className="h-4 w-4" /> + 검증 성공 ({validFiles.length}개) + </h4> + {validFiles.map((result, index) => ( + <div + key={index} + className="rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-950/30 p-3" + > + <div className="flex items-start justify-between gap-2"> + <div className="flex-1 min-w-0"> + <div className="font-mono text-sm break-all"> + {result.file.name} + </div> + {result.parsed && ( + <div className="flex flex-wrap gap-1 mt-2"> + <Badge variant="outline" className="text-xs"> + 문서: {result.parsed.ownDocNo} + </Badge> + <Badge variant="outline" className="text-xs"> + Rev: {result.parsed.revNo} + </Badge> + <Badge variant="outline" className="text-xs"> + Stage: {result.parsed.stage} + </Badge> + {result.parsed.fileName && ( + <Badge variant="outline" className="text-xs"> + 파일명: {result.parsed.fileName} + </Badge> + )} + <Badge variant="outline" className="text-xs"> + 확장자: .{result.parsed.extension} + </Badge> + </div> + )} + </div> + <CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400 shrink-0" /> + </div> + </div> + ))} + </div> + )} + + {/* 검증 실패 파일 */} + {invalidFiles.length > 0 && ( + <div className="space-y-2 mt-4"> + <h4 className="text-sm font-semibold text-red-600 dark:text-red-400 flex items-center gap-2"> + <XCircle className="h-4 w-4" /> + 검증 실패 ({invalidFiles.length}개) + </h4> + {invalidFiles.map((result, index) => ( + <div + key={index} + className="rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-950/30 p-3" + > + <div className="flex items-start justify-between gap-2"> + <div className="flex-1 min-w-0"> + <div className="font-mono text-sm break-all"> + {result.file.name} + </div> + {result.error && ( + <div className="text-xs text-red-600 dark:text-red-400 mt-1"> + ✗ {result.error} + </div> + )} + </div> + <XCircle className="h-5 w-5 text-red-600 dark:text-red-400 shrink-0" /> + </div> + </div> + ))} + </div> + )} + </div> + </ScrollArea> + + {/* 형식 안내 */} + <div className="rounded-lg bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 p-3"> + <div className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-1"> + 올바른 파일명 형식 + </div> + <code className="text-xs text-blue-700 dark:text-blue-300"> + [DOC_NO]_[REV_NO]_[STAGE].[확장자] + </code> + <div className="text-xs text-blue-600 dark:text-blue-400 mt-1"> + 예: VD-DOC-001_01_IFA.pdf + </div> + <div className="text-xs text-blue-600 dark:text-blue-400 mt-1"> + ※ 선택사항: [DOC_NO]_[REV_NO]_[STAGE]_[파일명].[확장자] (파일명 추가 가능) + </div> + <div className="text-xs text-blue-600 dark:text-blue-400 mt-1"> + ※ 파일명에는 언더스코어(_)가 포함될 수 있습니다. + </div> + {isVendorMode && ( + <div className="text-xs text-blue-600 dark:text-blue-400 mt-2 pt-2 border-t border-blue-200 dark:border-blue-800"> + {availableDocNos.length > 0 ? ( + <>ℹ️ 업로드 가능한 문서: {availableDocNos.length}개</> + ) : ( + <>⚠️ 할당된 문서가 없습니다</> + )} + </div> + )} + </div> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={handleCancel} + disabled={isUploading} + > + 취소 + </Button> + <Button + onClick={handleUpload} + disabled={validFiles.length === 0 || isUploading} + > + {isUploading ? ( + <> + <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" /> + 업로드 중... + </> + ) : ( + <> + <Upload className="h-4 w-4 mr-2" /> + 업로드 ({validFiles.length}개) + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + 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<string, RevisionTreeNode>; +} + +export function SwpUploadedFilesDialog({ projNo, vndrCd, userId }: SwpUploadedFilesDialogProps) { + const [open, setOpen] = useState(false); + const [files, setFiles] = useState<SwpFileApiResponse[]>([]); + const [isLoading, startLoading] = useTransition(); + const [expandedDocs, setExpandedDocs] = useState<Set<string>>(new Set()); + const [expandedRevs, setExpandedRevs] = useState<Set<string>>(new Set()); + const [cancellingFiles, setCancellingFiles] = useState<Set<string>>(new Set()); + const { toast } = useToast(); + + // 파일 목록을 트리 구조로 변환 + const fileTree = useMemo(() => { + const tree = new Map<string, DocumentTreeNode>(); + + 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 ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button variant="outline" size="sm" disabled={!projNo || !vndrCd}> + <FileText className="h-4 w-4 mr-2" /> + 업로드 파일 관리 + </Button> + </DialogTrigger> + <DialogContent className="max-w-4xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>업로드한 파일 목록</DialogTitle> + <DialogDescription> + 프로젝트: {projNo} | 업체: {vndrCd} + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 액션 바 */} + <div className="flex items-center justify-between"> + <div className="text-sm text-muted-foreground"> + 총 {files.length}개 파일 + </div> + <Button + variant="outline" + size="sm" + onClick={loadFiles} + disabled={isLoading} + > + {isLoading ? ( + <> + <Loader2 className="h-4 w-4 mr-2 animate-spin" /> + 조회 중... + </> + ) : ( + <> + <RefreshCw className="h-4 w-4 mr-2" /> + 새로고침 + </> + )} + </Button> + </div> + + {/* 파일 트리 */} + <ScrollArea className="h-[500px] rounded-md border p-4"> + {isLoading && files.length === 0 ? ( + <div className="flex items-center justify-center h-full text-muted-foreground"> + <Loader2 className="h-6 w-6 animate-spin mr-2" /> + 파일 목록을 조회하는 중... + </div> + ) : files.length === 0 ? ( + <div className="flex items-center justify-center h-full text-muted-foreground"> + 업로드한 파일이 없습니다. + </div> + ) : ( + <div className="space-y-2"> + {Array.from(fileTree.entries()).map(([docNo, docNode]) => ( + <div key={docNo} className="space-y-1"> + {/* 문서번호 */} + <div + className="flex items-center gap-2 p-2 rounded-md hover:bg-muted cursor-pointer" + onClick={() => toggleDoc(docNo)} + > + {expandedDocs.has(docNo) ? ( + <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" /> + ) : ( + <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" /> + )} + <FileText className="h-4 w-4 text-blue-600 shrink-0" /> + <span className="font-semibold">{docNo}</span> + <Badge variant="outline" className="text-xs"> + {docNode.revisions.size}개 리비전 + </Badge> + </div> + + {/* 리비전 목록 */} + {expandedDocs.has(docNo) && ( + <div className="ml-6 space-y-1"> + {Array.from(docNode.revisions.entries()).map(([revNo, revNode]) => { + const revKey = `${docNo}_${revNo}`; + return ( + <div key={revKey} className="space-y-1"> + {/* 리비전 번호 */} + <div + className="flex items-center gap-2 p-2 rounded-md hover:bg-muted cursor-pointer" + onClick={() => toggleRev(docNo, revNo)} + > + {expandedRevs.has(revKey) ? ( + <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" /> + ) : ( + <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" /> + )} + <span className="font-medium text-sm">Rev: {revNo}</span> + <Badge variant="secondary" className="text-xs"> + {revNode.files.files.length}개 파일 + </Badge> + </div> + + {/* 파일 목록 */} + {expandedRevs.has(revKey) && ( + <div className="ml-6 space-y-1"> + {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 ( + <div + key={`${fileKey}_${idx}`} + className="flex items-center gap-2 p-2 rounded-md border bg-card" + > + <FileText className="h-4 w-4 text-muted-foreground shrink-0" /> + <div className="flex-1 min-w-0"> + <div className="text-sm truncate">{file.FILE_NM}</div> + <div className="flex items-center gap-2 text-xs text-muted-foreground"> + <span>Stage: {file.STAGE}</span> + <span>•</span> + <span>상태: {file.STAT_NM || file.STAT || "알 수 없음"}</span> + </div> + </div> + <Button + variant="destructive" + size="sm" + onClick={() => handleCancelFile(file)} + disabled={!isCancellable || isCancelling} + title={ + isCancellable + ? "파일 업로드 취소" + : `취소 불가 (상태: ${file.STAT_NM || file.STAT})` + } + > + {isCancelling ? ( + <> + <Loader2 className="h-3 w-3 mr-1 animate-spin" /> + 취소 중... + </> + ) : ( + <> + <X className="h-3 w-3 mr-1" /> + 취소 + </> + )} + </Button> + </div> + ); + })} + </div> + )} + </div> + ); + })} + </div> + )} + </div> + ))} + </div> + )} + </ScrollArea> + + {/* 안내 메시지 */} + <div className="rounded-lg bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 p-3"> + <div className="text-xs text-blue-600 dark:text-blue-400 space-y-1"> + <p>ℹ️ 접수 전(SCW01) 상태의 파일만 취소할 수 있습니다.</p> + <p>ℹ️ 취소된 파일은 목록에서 제거됩니다.</p> + </div> + </div> + </div> + </DialogContent> + </Dialog> + ); +} 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<DocumentListItem[]> { + 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<DocumentDetail> { + 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<string>`( - 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<void> { + 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<DownloadFileResult> { + 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<string> { - debugProcess("네트워크 파일 저장 시작", { revisionId, fileName: fileInfo.FILE_NM }); - - // 리비전 정보 조회 - const revisionInfo = await db - .select({ - DOC_NO: swpDocumentRevisions.DOC_NO, - REV_NO: swpDocumentRevisions.REV_NO, - PROJ_NO: sql<string>`( - SELECT d."PROJ_NO" FROM swp.swp_documents d - WHERE d."DOC_NO" = ${swpDocumentRevisions.DOC_NO} - )`, - VNDR_CD: sql<string>`( - 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<void> { - 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<SwpFileApiResponse[]> { - debugProcess("업데이트된 파일 목록 조회 시작", { revisionId }); - - // 리비전 정보 조회 - const revisionInfo = await db - .select({ - DOC_NO: swpDocumentRevisions.DOC_NO, - PROJ_NO: sql<string>`( - SELECT d."PROJ_NO" FROM swp.swp_documents d - WHERE d."DOC_NO" = ${swpDocumentRevisions.DOC_NO} - )`, - VNDR_CD: sql<string>`( - 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<void> { - 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 - }); -} - |
