From 2ecdac866c19abea0b5389708fcdf5b3889c969a Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Wed, 29 Oct 2025 15:59:04 +0900 Subject: (김준회) SWP 파일 업로드 취소 기능 추가, 업로드 파일명 검증로직에서 파일명 비필수로 변경 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../evcp/(evcp)/(eng)/swp-document-upload/page.tsx | 58 ---- .../swp-document-upload/swp-document-page.tsx | 231 -------------- .../swp-document-upload/vendor-document-page.tsx | 346 ++++++++++++--------- app/api/swp/upload/route.ts | 57 +++- 4 files changed, 239 insertions(+), 453 deletions(-) delete mode 100644 app/[lng]/evcp/(evcp)/(eng)/swp-document-upload/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(eng)/swp-document-upload/swp-document-page.tsx (limited to 'app') diff --git a/app/[lng]/evcp/(evcp)/(eng)/swp-document-upload/page.tsx b/app/[lng]/evcp/(evcp)/(eng)/swp-document-upload/page.tsx deleted file mode 100644 index 25a0bfe6..00000000 --- a/app/[lng]/evcp/(evcp)/(eng)/swp-document-upload/page.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Suspense } from "react"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import SwpDocumentPage from "./swp-document-page"; - -export const metadata = { - title: "SWP 문서 관리", - description: "SWP 시스템 문서 조회 및 동기화", -}; - -// ============================================================================ -// 로딩 스켈레톤 -// ============================================================================ - -function SwpDocumentSkeleton() { - return ( - - -
- - -
-
- - - - -
- ); -} - -export default async function SwpDocumentUploadPage({ - searchParams, -}: { - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; -}) { - const params = await searchParams; - - return ( -
- {/* 헤더 */} - - - SWP 문서 관리 - - 외부 시스템(SWP)에서 문서 및 첨부파일을 조회하고 동기화합니다. - 문서 → 리비전 → 파일 계층 구조로 확인할 수 있습니다. - - - - - {/* 메인 컨텐츠 */} - }> - - -
- ); -} diff --git a/app/[lng]/evcp/(evcp)/(eng)/swp-document-upload/swp-document-page.tsx b/app/[lng]/evcp/(evcp)/(eng)/swp-document-upload/swp-document-page.tsx deleted file mode 100644 index eedb68e2..00000000 --- a/app/[lng]/evcp/(evcp)/(eng)/swp-document-upload/swp-document-page.tsx +++ /dev/null @@ -1,231 +0,0 @@ -"use client"; - -import { useState, useEffect, useTransition } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Skeleton } from "@/components/ui/skeleton"; -import { InfoIcon } from "lucide-react"; -import { SwpTable } from "@/lib/swp/table/swp-table"; -import { SwpTableToolbar } from "@/lib/swp/table/swp-table-toolbar"; -import { - fetchSwpDocuments, - fetchProjectList, - fetchSwpStats, - type SwpTableFilters, - type SwpDocumentWithStats, -} from "@/lib/swp/actions"; - -interface SwpDocumentPageProps { - searchParams: { [key: string]: string | string[] | undefined }; -} - -export default function SwpDocumentPage({ searchParams }: SwpDocumentPageProps) { - const router = useRouter(); - const params = useSearchParams(); - const [isPending, startTransition] = useTransition(); - - // URL에서 필터 파라미터 추출 - const initialFilters: SwpTableFilters = { - projNo: (searchParams.projNo as string) || "", - docNo: (searchParams.docNo as string) || "", - docTitle: (searchParams.docTitle as string) || "", - pkgNo: (searchParams.pkgNo as string) || "", - vndrCd: (searchParams.vndrCd as string) || "", - stage: (searchParams.stage as string) || "", - }; - - const initialPage = parseInt((searchParams.page as string) || "1", 10); - const initialPageSize = parseInt((searchParams.pageSize as string) || "100", 10); - - // 상태 관리 - const [documents, setDocuments] = useState([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(initialPage); - const [pageSize] = useState(initialPageSize); - const [totalPages, setTotalPages] = useState(0); - const [filters, setFilters] = useState(initialFilters); - const [projects, setProjects] = useState>([]); - const [stats, setStats] = useState({ - total_documents: 0, - total_revisions: 0, - total_files: 0, - last_sync: null as Date | null, - }); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - // 초기 데이터 로드 - useEffect(() => { - loadInitialData(); - }, []); - - // 필터 변경 시 데이터 재로드 - useEffect(() => { - if (!isLoading) { - loadDocuments(); - } - }, [filters, page]); - - const loadInitialData = async () => { - try { - setIsLoading(true); - setError(null); - - // 병렬로 데이터 로드 - const [projectsData, statsData, documentsData] = await Promise.all([ - fetchProjectList(), - fetchSwpStats(), - fetchSwpDocuments({ - page, - pageSize, - filters: Object.keys(initialFilters).length > 0 ? initialFilters : undefined, - }), - ]); - - setProjects(projectsData); - setStats(statsData); - setDocuments(documentsData.data); - setTotal(documentsData.total); - setTotalPages(documentsData.totalPages); - } catch (err) { - console.error("초기 데이터 로드 실패:", err); - setError(err instanceof Error ? err.message : "데이터 로드 실패"); - } finally { - setIsLoading(false); - } - }; - - const loadDocuments = async () => { - startTransition(async () => { - try { - const data = await fetchSwpDocuments({ - page, - pageSize, - filters: Object.keys(filters).some((key) => filters[key as keyof SwpTableFilters]) - ? filters - : undefined, - }); - - setDocuments(data.data); - setTotal(data.total); - setTotalPages(data.totalPages); - - // URL 업데이트 - const params = new URLSearchParams(); - if (filters.projNo) params.set("projNo", filters.projNo); - if (filters.docNo) params.set("docNo", filters.docNo); - if (filters.docTitle) params.set("docTitle", filters.docTitle); - if (filters.pkgNo) params.set("pkgNo", filters.pkgNo); - if (filters.vndrCd) params.set("vndrCd", filters.vndrCd); - if (filters.stage) params.set("stage", filters.stage); - if (page !== 1) params.set("page", page.toString()); - - router.push(`?${params.toString()}`, { scroll: false }); - } catch (err) { - console.error("문서 로드 실패:", err); - setError(err instanceof Error ? err.message : "문서 로드 실패"); - } - }); - }; - - const handleFiltersChange = (newFilters: SwpTableFilters) => { - setFilters(newFilters); - setPage(1); // 필터 변경 시 첫 페이지로 - }; - - const handlePageChange = (newPage: number) => { - setPage(newPage); - }; - - if (isLoading) { - return ( - - - - - - - - - - - ); - } - - if (error) { - return ( - - {error} - - ); - } - - return ( -
- {/* 통계 카드 */} -
- - - 총 문서 - {stats.total_documents.toLocaleString()} - - - - - 총 리비전 - {stats.total_revisions.toLocaleString()} - - - - - 총 파일 - {stats.total_files.toLocaleString()} - - - - - 마지막 동기화 - - {stats.last_sync - ? new Date(stats.last_sync).toLocaleDateString("ko-KR") - : "없음"} - - - -
- - {/* 안내 메시지 */} - {documents.length === 0 && !filters.projNo && ( - - - - 시작하려면 프로젝트를 선택하고 SWP 동기화 버튼을 클릭하세요. - - - )} - - {/* 메인 테이블 */} - - - - - - - - -
- ); -} - diff --git a/app/[lng]/partners/(partners)/swp-document-upload/vendor-document-page.tsx b/app/[lng]/partners/(partners)/swp-document-upload/vendor-document-page.tsx index ba78bfdf..dc6fbe7c 100644 --- a/app/[lng]/partners/(partners)/swp-document-upload/vendor-document-page.tsx +++ b/app/[lng]/partners/(partners)/swp-document-upload/vendor-document-page.tsx @@ -1,11 +1,11 @@ "use client"; -import { useState, useEffect, useTransition } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useState, useEffect, useMemo, useCallback } from "react"; +import { useDropzone } from "react-dropzone"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Skeleton } from "@/components/ui/skeleton"; -import { InfoIcon } from "lucide-react"; +import { InfoIcon, Upload } from "lucide-react"; import { SwpTable } from "@/lib/swp/table/swp-table"; import { SwpTableToolbar } from "@/lib/swp/table/swp-table-toolbar"; import { @@ -14,36 +14,20 @@ import { fetchVendorSwpStats, getVendorSessionInfo, } from "@/lib/swp/vendor-actions"; -import { type SwpTableFilters, type SwpDocumentWithStats } from "@/lib/swp/actions"; +import type { DocumentListItem } from "@/lib/swp/document-service"; +import { toast } from "sonner"; interface VendorDocumentPageProps { searchParams: { [key: string]: string | string[] | undefined }; } export default function VendorDocumentPage({ searchParams }: VendorDocumentPageProps) { - const router = useRouter(); - const params = useSearchParams(); - const [isPending, startTransition] = useTransition(); - - // URL에서 필터 파라미터 추출 (vndrCd는 제외 - 서버에서 자동 설정) - const initialFilters: SwpTableFilters = { - projNo: (searchParams.projNo as string) || "", - docNo: (searchParams.docNo as string) || "", - docTitle: (searchParams.docTitle as string) || "", - pkgNo: (searchParams.pkgNo as string) || "", - stage: (searchParams.stage as string) || "", - }; - - const initialPage = parseInt((searchParams.page as string) || "1", 10); - const initialPageSize = parseInt((searchParams.pageSize as string) || "100", 10); + // URL에서 프로젝트 번호만 사용 (나머지는 클라이언트 필터링) + const initialProjNo = (searchParams.projNo as string) || ""; // 상태 관리 - const [documents, setDocuments] = useState([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(initialPage); - const [pageSize] = useState(initialPageSize); - const [totalPages, setTotalPages] = useState(0); - const [filters, setFilters] = useState(initialFilters); + const [documents, setDocuments] = useState([]); + const [projNo, setProjNo] = useState(initialProjNo); const [projects, setProjects] = useState>([]); const [stats, setStats] = useState({ total_documents: 0, @@ -59,91 +43,132 @@ export default function VendorDocumentPage({ searchParams }: VendorDocumentPageP companyId: number; } | null>(null); const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(null); - // 초기 데이터 로드 - useEffect(() => { - loadInitialData(); - }, []); + // 클라이언트 필터 + const [searchFilters, setSearchFilters] = useState({ + docNo: (searchParams.docNo as string) || "", + docTitle: (searchParams.docTitle as string) || "", + pkgNo: (searchParams.pkgNo as string) || "", + stage: (searchParams.stage as string) || "", + }); - // 필터 변경 시 데이터 재로드 - useEffect(() => { - if (!isLoading) { - loadDocuments(); + // Dropzone 설정 + const [droppedFiles, setDroppedFiles] = useState([]); + + // 파일 드롭 핸들러 + const onDrop = useCallback((acceptedFiles: File[]) => { + if (acceptedFiles.length > 0) { + setDroppedFiles(acceptedFiles); } - }, [filters, page]); + }, []); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + noClick: true, // 클릭으로 파일 선택 비활성화 (버튼을 통해서만 선택) + noKeyboard: true, + }); - const loadInitialData = async () => { + const loadInitialData = useCallback(async () => { try { setIsLoading(true); setError(null); - // 병렬로 데이터 로드 (벤더 정보 포함) - const [vendorInfoData, projectsData, statsData, documentsData] = await Promise.all([ + // 병렬로 데이터 로드 + const [vendorInfoData, projectsData] = await Promise.all([ getVendorSessionInfo(), fetchVendorProjects(), - fetchVendorSwpStats(), - fetchVendorDocuments({ - page, - pageSize, - filters: Object.keys(initialFilters).length > 0 ? initialFilters : undefined, - }), ]); setVendorInfo(vendorInfoData); setProjects(projectsData); - setStats(statsData); - setDocuments(documentsData.data); - setTotal(documentsData.total); - setTotalPages(documentsData.totalPages); + + // 초기 프로젝트가 있으면 문서 로드 + if (initialProjNo) { + const [documentsData, statsData] = await Promise.all([ + fetchVendorDocuments(initialProjNo), + fetchVendorSwpStats(initialProjNo), + ]); + setDocuments(documentsData); + setStats(statsData); + } } catch (err) { console.error("초기 데이터 로드 실패:", err); setError(err instanceof Error ? err.message : "데이터 로드 실패"); + toast.error("데이터 로드 실패"); + } finally { + setIsLoading(false); } - setIsLoading(false); // finally 대신 여기서 호출 - }; + }, [initialProjNo]); - const loadDocuments = async () => { - startTransition(async () => { - try { - const data = await fetchVendorDocuments({ - page, - pageSize, - filters: Object.keys(filters).some((key) => filters[key as keyof SwpTableFilters]) - ? filters - : undefined, - }); - - setDocuments(data.data); - setTotal(data.total); - setTotalPages(data.totalPages); - - // URL 업데이트 - const params = new URLSearchParams(); - if (filters.projNo) params.set("projNo", filters.projNo); - if (filters.docNo) params.set("docNo", filters.docNo); - if (filters.docTitle) params.set("docTitle", filters.docTitle); - if (filters.pkgNo) params.set("pkgNo", filters.pkgNo); - if (filters.stage) params.set("stage", filters.stage); - if (page !== 1) params.set("page", page.toString()); - - router.push(`?${params.toString()}`, { scroll: false }); - } catch (err) { - console.error("문서 로드 실패:", err); - setError(err instanceof Error ? err.message : "문서 로드 실패"); - } - }); + const loadDocuments = useCallback(async () => { + if (!projNo) return; + + try { + setIsRefreshing(true); + setError(null); + + const [documentsData, statsData] = await Promise.all([ + fetchVendorDocuments(projNo), + fetchVendorSwpStats(projNo), + ]); + + setDocuments(documentsData); + setStats(statsData); + toast.success("문서 목록을 갱신했습니다"); + } catch (err) { + console.error("문서 로드 실패:", err); + setError(err instanceof Error ? err.message : "문서 로드 실패"); + toast.error("문서 로드 실패"); + } finally { + setIsRefreshing(false); + } + }, [projNo]); + + // 초기 데이터 로드 + useEffect(() => { + loadInitialData(); + }, [loadInitialData]); + + // 프로젝트 변경 시 문서 재로드 + useEffect(() => { + if (!isLoading && projNo) { + loadDocuments(); + } + }, [projNo, isLoading, loadDocuments]); + + const handleProjectChange = (newProjNo: string) => { + setProjNo(newProjNo); }; - const handleFiltersChange = (newFilters: SwpTableFilters) => { - setFilters(newFilters); - setPage(1); // 필터 변경 시 첫 페이지로 + const handleFiltersChange = (filters: typeof searchFilters) => { + setSearchFilters(filters); }; - const handlePageChange = (newPage: number) => { - setPage(newPage); + const handleRefresh = () => { + loadDocuments(); }; + // 클라이언트 사이드 필터링 + const filteredDocuments = useMemo(() => { + return documents.filter((doc) => { + if (searchFilters.docNo && !doc.DOC_NO.toLowerCase().includes(searchFilters.docNo.toLowerCase())) { + return false; + } + if (searchFilters.docTitle && !doc.DOC_TITLE.toLowerCase().includes(searchFilters.docTitle.toLowerCase())) { + return false; + } + if (searchFilters.pkgNo && !doc.PKG_NO?.toLowerCase().includes(searchFilters.pkgNo.toLowerCase())) { + return false; + } + if (searchFilters.stage && doc.STAGE !== searchFilters.stage) { + return false; + } + return true; + }); + }, [documents, searchFilters]); + if (isLoading) { return ( @@ -159,78 +184,101 @@ export default function VendorDocumentPage({ searchParams }: VendorDocumentPageP ); } - return ( -
- {/* 에러 메시지 */} - {error && ( - - {error} - +
+ + + {/* 드래그 오버레이 */} + {isDragActive && ( +
+
+ +
+

파일을 여기에 드롭하세요

+

+ 여러 파일을 한 번에 업로드할 수 있습니다 +

+
+
+
)} - {/* 통계 카드 */} -
- - - 할당된 문서 - {stats.total_documents.toLocaleString()} - - - - - 총 리비전 - {stats.total_revisions.toLocaleString()} - - - - - 총 파일 - {stats.total_files.toLocaleString()} - - +
+ {/* 에러 메시지 */} + {error && ( + + {error} + + )} + + {/* 통계 카드 */} +
+ + + 할당된 문서 + {stats.total_documents.toLocaleString()} + + + + + 총 리비전 + {stats.total_revisions.toLocaleString()} + + + + + 총 파일 + {stats.total_files.toLocaleString()} + + + + + 업로드한 파일 + + {stats.uploaded_files.toLocaleString()} + + + +
+ + {/* 안내 메시지 */} + {documents.length === 0 && !projNo && ( + + + + 프로젝트를 선택하여 할당된 문서를 확인하세요. + + + )} + + {/* 메인 테이블 */} - - 업로드한 파일 - - {stats.uploaded_files.toLocaleString()} - + + setDroppedFiles([])} + documents={filteredDocuments} + userId={String(vendorInfo?.vendorId || "")} + /> + + +
- - {/* 안내 메시지 */} - {documents.length === 0 && !filters.projNo && ( - - - - 프로젝트를 선택하여 할당된 문서를 확인하세요. - - - )} - - {/* 메인 테이블 */} - - - - - - - -
); } - diff --git a/app/api/swp/upload/route.ts b/app/api/swp/upload/route.ts index d17fcff7..b38c4ff4 100644 --- a/app/api/swp/upload/route.ts +++ b/app/api/swp/upload/route.ts @@ -26,7 +26,8 @@ interface InBoxFileInfo { } /** - * 파일명 파싱: [OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDDhhmmss].[확장자] + * 파일명 파싱: [DOC_NO]_[REV_NO]_[STAGE]_[자유-파일명].[확장자] + * 자유 파일명에는 언더스코어가 포함될 수 있음 */ function parseFileName(fileName: string) { const lastDotIndex = fileName.lastIndexOf("."); @@ -35,23 +36,38 @@ function parseFileName(fileName: string) { const parts = nameWithoutExt.split("_"); - if (parts.length !== 4) { + // 최소 4개 파트 필요: docNo, revNo, stage, fileName + if (parts.length < 4) { throw new Error( `잘못된 파일명 형식입니다: ${fileName}. ` + - `형식: [OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDDhhmmss].확장자` + `형식: [DOC_NO]_[REV_NO]_[STAGE]_[파일명].확장자 (언더스코어 최소 3개 필요)` ); } - const [ownDocNo, revNo, stage, timestamp] = parts; + // 앞에서부터 3개는 고정: docNo, revNo, stage + const ownDocNo = parts[0]; + const revNo = parts[1]; + const stage = parts[2]; + + // 나머지는 자유 파일명 (언더스코어 포함 가능) + const customFileName = parts.slice(3).join("_"); - if (!/^\d{14}$/.test(timestamp)) { - throw new Error( - `잘못된 타임스탬프 형식입니다: ${timestamp}. ` + - `YYYYMMDDhhmmss 형식이어야 합니다.` - ); - } + return { ownDocNo, revNo, stage, fileName: customFileName, extension }; +} - return { ownDocNo, revNo, stage, timestamp, extension }; +/** + * 현재 시간을 YYYYMMDDhhmmss 형식으로 반환 + */ +function generateTimestamp(): string { + const now = new Date(); + const year = now.getFullYear().toString(); + const month = (now.getMonth() + 1).toString().padStart(2, "0"); + const day = now.getDate().toString().padStart(2, "0"); + const hours = now.getHours().toString().padStart(2, "0"); + const minutes = now.getMinutes().toString().padStart(2, "0"); + const seconds = now.getSeconds().toString().padStart(2, "0"); + + return `${year}${month}${day}${hours}${minutes}${seconds}`; } /** @@ -171,6 +187,10 @@ export async function POST(request: NextRequest) { const inBoxFileInfos: InBoxFileInfo[] = []; const swpMountDir = process.env.SWP_MOUNT_DIR || "/mnt/swp-smb-dir/"; + + // 업로드 시점의 timestamp 생성 (모든 파일에 동일한 timestamp 사용) + const uploadTimestamp = generateTimestamp(); + console.log(`[upload] 업로드 타임스탬프 생성: ${uploadTimestamp}`); for (const file of files) { try { @@ -178,8 +198,8 @@ export async function POST(request: NextRequest) { const parsed = parseFileName(file.name); console.log(`[upload] 파일명 파싱:`, parsed); - // 네트워크 경로 생성 - const networkPath = path.join(swpMountDir, projNo, cpyCd, parsed.timestamp, file.name); + // 네트워크 경로 생성 (timestamp를 경로에만 사용) + const networkPath = path.join(swpMountDir, projNo, cpyCd, uploadTimestamp, file.name); // 파일 중복 체크 try { @@ -245,8 +265,8 @@ export async function POST(request: NextRequest) { matchesOriginal: buffer.slice(0, 20).equals(verifyBuffer.slice(0, 20)) }); - // InBox 파일 정보 준비 - const fldPath = `\\\\${projNo}\\\\${cpyCd}\\\\${parsed.timestamp}`; + // InBox 파일 정보 준비 (FLD_PATH에 업로드 timestamp 사용) + const fldPath = `\\\\${projNo}\\\\${cpyCd}\\\\${uploadTimestamp}`; inBoxFileInfos.push({ CPY_CD: cpyCd, @@ -339,12 +359,19 @@ export async function POST(request: NextRequest) { console.log(`[upload] 완료:`, { success, message, result }); + // 동기화 완료 정보 추가 + const syncCompleted = result.successCount > 0; + const syncTimestamp = new Date().toISOString(); + return NextResponse.json({ success, message, successCount: result.successCount, failedCount: result.failedCount, details: result.details, + syncCompleted, + syncTimestamp, + affectedVndrCd: vndrCd, }); } catch (error) { console.error("[upload] 오류:", error); -- cgit v1.2.3