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/swp/table/swp-table-toolbar.tsx | |
| parent | 2fc9e5492e220041ba322d9a1479feb7803228cf (diff) | |
(김준회) SWP 파일 업로드 취소 기능 추가, 업로드 파일명 검증로직에서 파일명 비필수로 변경
Diffstat (limited to 'lib/swp/table/swp-table-toolbar.tsx')
| -rw-r--r-- | lib/swp/table/swp-table-toolbar.tsx | 634 |
1 files changed, 338 insertions, 296 deletions
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> </> ); } - |
