summaryrefslogtreecommitdiff
path: root/lib/swp/table/swp-upload-validation-dialog.tsx
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-10-29 15:59:04 +0900
committerjoonhoekim <26rote@gmail.com>2025-10-29 15:59:04 +0900
commit2ecdac866c19abea0b5389708fcdf5b3889c969a (patch)
treee02a02cfa0890691fb28a7df3a96ef495b3d4b79 /lib/swp/table/swp-upload-validation-dialog.tsx
parent2fc9e5492e220041ba322d9a1479feb7803228cf (diff)
(김준회) SWP 파일 업로드 취소 기능 추가, 업로드 파일명 검증로직에서 파일명 비필수로 변경
Diffstat (limited to 'lib/swp/table/swp-upload-validation-dialog.tsx')
-rw-r--r--lib/swp/table/swp-upload-validation-dialog.tsx373
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>
+ );
+}
+