diff options
Diffstat (limited to 'lib/swp/table/swp-upload-validation-dialog.tsx')
| -rw-r--r-- | lib/swp/table/swp-upload-validation-dialog.tsx | 373 |
1 files changed, 373 insertions, 0 deletions
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> + ); +} + |
