From 2ecdac866c19abea0b5389708fcdf5b3889c969a Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Wed, 29 Oct 2025 15:59:04 +0900 Subject: (김준회) SWP 파일 업로드 취소 기능 추가, 업로드 파일명 검증로직에서 파일명 비필수로 변경 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/swp/table/swp-table-toolbar.tsx | 634 +++++++++++++++++++----------------- 1 file changed, 338 insertions(+), 296 deletions(-) (limited to 'lib/swp/table/swp-table-toolbar.tsx') diff --git a/lib/swp/table/swp-table-toolbar.tsx b/lib/swp/table/swp-table-toolbar.tsx index fefff091..0fd29fd3 100644 --- a/lib/swp/table/swp-table-toolbar.tsx +++ b/lib/swp/table/swp-table-toolbar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useTransition, useMemo } from "react"; +import { useState, useTransition, useMemo, useEffect, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -9,94 +9,137 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { Label } from "@/components/ui/label"; -import { RefreshCw, Search, X, Check, ChevronsUpDown, Upload } from "lucide-react"; -import { syncSwpProjectAction, type SwpTableFilters } from "../actions"; +import { Search, X, Check, ChevronsUpDown, Upload, RefreshCw } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; -import { useRouter } from "next/navigation"; import { cn } from "@/lib/utils"; -import { useRef } from "react"; import { SwpUploadHelpDialog } from "./swp-help-dialog"; import { SwpUploadResultDialog } from "./swp-upload-result-dialog"; +import { + SwpUploadValidationDialog, + validateFileName +} from "./swp-upload-validation-dialog"; +import { SwpUploadedFilesDialog } from "./swp-uploaded-files-dialog"; + +interface SwpTableFilters { + docNo?: string; + docTitle?: string; + pkgNo?: string; + stage?: string; +} interface SwpTableToolbarProps { + projNo: string; filters: SwpTableFilters; + onProjNoChange: (projNo: string) => void; onFiltersChange: (filters: SwpTableFilters) => void; - projects?: Array<{ PROJ_NO: string; PROJ_NM: string }>; - vendorCode?: string; // 벤더가 접속했을 때 고정할 벤더 코드 + onRefresh: () => void; + isRefreshing: boolean; + projects?: Array<{ PROJ_NO: string; PROJ_NM: string | null }>; + vendorCode?: string; + droppedFiles?: File[]; + onFilesProcessed?: () => void; + documents?: Array<{ DOC_NO: string }>; // 업로드 권한 검증용 문서 목록 + userId?: string; // 파일 취소 시 필요 } export function SwpTableToolbar({ + projNo, filters, + onProjNoChange, onFiltersChange, + onRefresh, + isRefreshing, projects = [], vendorCode, + droppedFiles = [], + onFilesProcessed, + documents = [], + userId, }: SwpTableToolbarProps) { - const [isSyncing, startSync] = useTransition(); const [isUploading, startUpload] = useTransition(); - const [localFilters, setLocalFilters] = useState(filters); + const [localFilters, setLocalFilters] = useState(filters); const { toast } = useToast(); - const router = useRouter(); const [projectSearchOpen, setProjectSearchOpen] = useState(false); const [projectSearch, setProjectSearch] = useState(""); const fileInputRef = useRef(null); const [uploadResults, setUploadResults] = useState>([]); const [showResultDialog, setShowResultDialog] = useState(false); + + // 검증 다이얼로그 상태 + const [validationResults, setValidationResults] = useState>([]); + const [showValidationDialog, setShowValidationDialog] = useState(false); - // 동기화 핸들러 - const handleSync = () => { - const projectNo = localFilters.projNo; - - if (!projectNo) { - toast({ - variant: "destructive", - title: "프로젝트 선택 필요", - description: "동기화할 프로젝트를 먼저 선택해주세요.", - }); - return; - } + /** + * 업로드 가능한 문서번호 목록 추출 + */ + const availableDocNos = useMemo(() => { + return documents.map(doc => doc.DOC_NO); + }, [documents]); - startSync(async () => { - try { + /** + * 벤더 모드 여부 (벤더 코드가 있으면 벤더 모드) + */ + const isVendorMode = !!vendorCode; + + /** + * 드롭된 파일 처리 - useEffect로 감지하여 자동 검증 + */ + useEffect(() => { + if (droppedFiles.length > 0) { + // 프로젝트와 벤더 코드 검증 + if (!projNo) { toast({ - title: "동기화 시작", - description: `프로젝트 ${projectNo} 동기화를 시작합니다...`, + variant: "destructive", + title: "프로젝트 선택 필요", + description: "파일을 업로드할 프로젝트를 먼저 선택해주세요.", }); + onFilesProcessed?.(); + return; + } - const result = await syncSwpProjectAction(projectNo, "V"); - - if (result.success) { - toast({ - title: "동기화 완료", - description: `문서 ${result.stats.documents.total}개, 파일 ${result.stats.files.total}개 동기화 완료`, - }); - - // 페이지 새로고침 - router.refresh(); - } else { - throw new Error(result.errors.join(", ")); - } - } catch (error) { - console.error("동기화 실패:", error); + if (!vendorCode) { toast({ variant: "destructive", - title: "동기화 실패", - description: error instanceof Error ? error.message : "알 수 없는 오류", + title: "업체 코드 오류", + description: "벤더 정보를 가져올 수 없습니다.", }); + onFilesProcessed?.(); + return; } - }); - }; + + // 파일명 검증 (문서번호 권한 포함) + const results = droppedFiles.map((file) => { + const validation = validateFileName(file.name, availableDocNos, isVendorMode); + return { + file, + valid: validation.valid, + parsed: validation.parsed, + error: validation.error, + }; + }); + + setValidationResults(results); + setShowValidationDialog(true); + onFilesProcessed?.(); + } + }, [droppedFiles, projNo, vendorCode, toast, onFilesProcessed, availableDocNos, isVendorMode]); /** * 파일 업로드 핸들러 - * 1) 네트워크 드라이브에 정해진 규칙대로, 파일이름 기반으로 파일 업로드하기 - * 2) 1~N개 파일 받아서, 파일 이름 기준으로 파싱해서 SaveInBoxList API를 통해 업로드 처리 - */ + */ const handleUploadFiles = () => { - // 프로젝트와 벤더 코드 체크 - const projectNo = localFilters.projNo; - const vndrCd = vendorCode || localFilters.vndrCd; - - if (!projectNo) { + if (!projNo) { toast({ variant: "destructive", title: "프로젝트 선택 필요", @@ -105,48 +148,66 @@ export function SwpTableToolbar({ return; } - if (!vndrCd) { + if (!vendorCode) { toast({ variant: "destructive", - title: "업체 코드 입력 필요", - description: "파일을 업로드할 업체 코드를 입력해주세요.", + title: "업체 코드 오류", + description: "벤더 정보를 가져올 수 없습니다.", }); return; } - // 파일 선택 다이얼로그 열기 fileInputRef.current?.click(); }; /** - * 파일 선택 핸들러 + * 파일 선택 핸들러 - 검증만 수행 */ - const handleFileChange = async (event: React.ChangeEvent) => { + const handleFileChange = (event: React.ChangeEvent) => { const selectedFiles = event.target.files; if (!selectedFiles || selectedFiles.length === 0) { return; } - const projectNo = localFilters.projNo!; - const vndrCd = vendorCode || localFilters.vndrCd!; + // 각 파일의 파일명 검증 (문서번호 권한 포함) + const results = Array.from(selectedFiles).map((file) => { + const validation = validateFileName(file.name, availableDocNos, isVendorMode); + return { + file, + valid: validation.valid, + parsed: validation.parsed, + error: validation.error, + }; + }); + + setValidationResults(results); + setShowValidationDialog(true); + + // input 초기화 (같은 파일 재선택 가능하도록) + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + /** + * 검증 완료 후 실제 업로드 실행 + */ + const handleConfirmUpload = async (validFiles: File[]) => { startUpload(async () => { try { toast({ title: "파일 업로드 시작", - description: `${selectedFiles.length}개 파일을 업로드합니다...`, + description: `${validFiles.length}개 파일을 업로드합니다...`, }); - // FormData 생성 (바이너리 직접 전송) const formData = new FormData(); - formData.append("projNo", projectNo); - formData.append("vndrCd", vndrCd); - - Array.from(selectedFiles).forEach((file) => { + formData.append("projNo", projNo); + formData.append("vndrCd", vendorCode!); + + validFiles.forEach((file) => { formData.append("files", file); }); - // API Route 호출 const response = await fetch("/api/swp/upload", { method: "POST", body: formData, @@ -158,31 +219,31 @@ export function SwpTableToolbar({ const result = await response.json(); - // 결과 저장 및 다이얼로그 표시 + // 검증 다이얼로그 닫기 + setShowValidationDialog(false); + + // 결과 다이얼로그 표시 setUploadResults(result.details || []); setShowResultDialog(true); - // 성공한 파일이 있으면 페이지 새로고침 - if (result.successCount > 0) { - router.refresh(); - } + toast({ + title: result.success ? "업로드 완료" : "일부 업로드 실패", + description: result.message, + }); } catch (error) { console.error("파일 업로드 실패:", error); - - // 예외 발생 시에도 결과 다이얼로그 표시 - const errorResults = Array.from(selectedFiles).map((file) => ({ + + // 검증 다이얼로그 닫기 + setShowValidationDialog(false); + + const errorResults = validFiles.map((file) => ({ fileName: file.name, success: false, error: error instanceof Error ? error.message : "알 수 없는 오류", })); - + setUploadResults(errorResults); setShowResultDialog(true); - } finally { - // 파일 입력 초기화 - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } } }); }; @@ -194,7 +255,12 @@ export function SwpTableToolbar({ // 검색 초기화 const handleReset = () => { - const resetFilters: SwpTableFilters = {}; + const resetFilters: SwpTableFilters = { + docNo: "", + docTitle: "", + pkgNo: "", + stage: "", + }; setLocalFilters(resetFilters); onFiltersChange(resetFilters); }; @@ -202,17 +268,28 @@ export function SwpTableToolbar({ // 프로젝트 필터링 const filteredProjects = useMemo(() => { if (!projectSearch) return projects; - + const search = projectSearch.toLowerCase(); return projects.filter( (proj) => proj.PROJ_NO.toLowerCase().includes(search) || - proj.PROJ_NM.toLowerCase().includes(search) + (proj.PROJ_NM?.toLowerCase().includes(search) ?? false) ); }, [projects, projectSearch]); return ( <> + {/* 업로드 검증 다이얼로그 */} + + {/* 업로드 결과 다이얼로그 */} -
+
{/* 상단 액션 바 */} -
-
+ {vendorCode && ( +
+ -
- -
- {/* 벤더만 파일 업로드 기능 사용 가능 */} - {vendorCode && ( - <> - - - - - + + + {userId && ( + )} + +
-
+ )} - {/* 검색 필터 */} -
-
-

검색 필터

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

검색 필터

+ +
-
- {/* 프로젝트 번호 */} -
- - {projects.length > 0 ? ( - - - - - -
-
- - setProjectSearch(e.target.value)} - className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0" - /> -
-
-
-
- - {filteredProjects.map((proj) => ( - - ))} - {filteredProjects.length === 0 && ( -
- 검색 결과가 없습니다. -
+
+ {/* 프로젝트 번호 */} +
+ + {projects.length > 0 ? ( + + + + + +
+
+ + setProjectSearch(e.target.value)} + className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0" + /> +
+
+
+
+ {filteredProjects.map((proj) => ( + + ))} + {filteredProjects.length === 0 && ( +
+ 검색 결과가 없습니다. +
+ )} +
-
- - - ) : ( + + + ) : ( + + )} +
+ + {/* 문서 번호 */} +
+ - setLocalFilters({ ...localFilters, projNo: e.target.value }) + setLocalFilters({ ...localFilters, docNo: e.target.value }) } - disabled - className="bg-muted" /> - )} -
- - {/* 문서 번호 */} -
- - - setLocalFilters({ ...localFilters, docNo: e.target.value }) - } - /> -
+
- {/* 문서 제목 */} -
- - - setLocalFilters({ ...localFilters, docTitle: e.target.value }) - } - /> -
+ {/* 문서 제목 */} +
+ + + setLocalFilters({ ...localFilters, docTitle: e.target.value }) + } + /> +
- {/* 패키지 번호 */} -
- - - setLocalFilters({ ...localFilters, pkgNo: e.target.value }) - } - /> -
+ {/* 패키지 번호 */} +
+ + + setLocalFilters({ ...localFilters, pkgNo: e.target.value }) + } + /> +
- {/* 업체 코드 */} -
- - - setLocalFilters({ ...localFilters, vndrCd: e.target.value }) - } - disabled={!!vendorCode} // 벤더 코드가 제공되면 입력 비활성화 - className={vendorCode ? "bg-muted" : ""} - /> + {/* 스테이지 */} +
+ + + setLocalFilters({ ...localFilters, stage: e.target.value }) + } + /> +
- {/* 스테이지 */} -
- - - setLocalFilters({ ...localFilters, stage: e.target.value }) - } - /> +
+
- -
- -
-
); } - -- cgit v1.2.3