From 1dc24d48e52f2e490f5603ceb02842586ecae533 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 24 Jul 2025 11:06:32 +0000 Subject: (대표님) 정기평가 피드백 반영, 설계 피드백 반영, (최겸) 기술영업 피드백 반영 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../template/create-revision-dialog.tsx | 564 +++++++++++++++++++++ 1 file changed, 564 insertions(+) create mode 100644 lib/basic-contract/template/create-revision-dialog.tsx (limited to 'lib/basic-contract/template/create-revision-dialog.tsx') diff --git a/lib/basic-contract/template/create-revision-dialog.tsx b/lib/basic-contract/template/create-revision-dialog.tsx new file mode 100644 index 00000000..262df6ba --- /dev/null +++ b/lib/basic-contract/template/create-revision-dialog.tsx @@ -0,0 +1,564 @@ +"use client"; + +import * as React from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { toast } from "sonner"; +import { v4 as uuidv4 } from 'uuid'; +import { Copy, FileText, Loader } from "lucide-react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dropzone, + DropzoneZone, + DropzoneUploadIcon, + DropzoneTitle, + DropzoneDescription, + DropzoneInput +} from "@/components/ui/dropzone"; +import { Progress } from "@/components/ui/progress"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { useRouter } from "next/navigation"; +import { BUSINESS_UNITS } from "@/config/basicContractColumnsConfig"; +import { BasicContractTemplate } from "@/db/schema"; + +// 템플릿 이름 옵션 정의 +const TEMPLATE_NAME_OPTIONS = [ + "준법서약 (한글)", + "준법서약 (영문)", + "기술자료 요구서", + "비밀유지 계약서", + "표준하도급기본 계약서", + "General GTC", + "안전보건관리 약정서", + "동반성장", + "윤리규범 준수 서약서", + "기술자료 동의서", + "내국신용장 미개설 합의서", + "직납자재 하도급대급등 연동제 의향서" +] as const; + +// 리비전 생성 스키마 정의 +const createRevisionSchema = z.object({ + revision: z.coerce.number().int().min(1), + legalReviewRequired: z.boolean().default(false), + + // 적용 범위 + shipBuildingApplicable: z.boolean().default(false), + windApplicable: z.boolean().default(false), + pcApplicable: z.boolean().default(false), + nbApplicable: z.boolean().default(false), + rcApplicable: z.boolean().default(false), + gyApplicable: z.boolean().default(false), + sysApplicable: z.boolean().default(false), + infraApplicable: z.boolean().default(false), + + file: z + .instanceof(File, { message: "파일을 업로드해주세요." }) + .refine((file) => file.size <= 100 * 1024 * 1024, { + message: "파일 크기는 100MB 이하여야 합니다.", + }) + .refine( + (file) => + file.type === 'application/msword' || + file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + { message: "워드 파일(.doc, .docx)만 업로드 가능합니다." } + ), +}).refine((data) => { + // 적어도 하나의 적용 범위는 선택되어야 함 + const scopeFields = [ + 'shipBuildingApplicable', 'windApplicable', 'pcApplicable', 'nbApplicable', + 'rcApplicable', 'gyApplicable', 'sysApplicable', 'infraApplicable' + ]; + + const hasAnyScope = scopeFields.some(field => data[field as keyof typeof data] === true); + return hasAnyScope; +}, { + message: "적어도 하나의 적용 범위를 선택해야 합니다.", + path: ["shipBuildingApplicable"], +}); + +type CreateRevisionFormValues = z.infer; + +interface CreateRevisionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + baseTemplate: BasicContractTemplate | null; + onSuccess?: () => void; +} + +export function CreateRevisionDialog({ + open, + onOpenChange, + baseTemplate, + onSuccess +}: CreateRevisionDialogProps) { + const [isLoading, setIsLoading] = React.useState(false); + const [selectedFile, setSelectedFile] = React.useState(null); + const [uploadProgress, setUploadProgress] = React.useState(0); + const [showProgress, setShowProgress] = React.useState(false); + const [suggestedRevision, setSuggestedRevision] = React.useState(1); + const router = useRouter(); + + // 기본 템플릿의 다음 리비전 번호 계산 + React.useEffect(() => { + if (baseTemplate) { + setSuggestedRevision(baseTemplate.revision + 1); + } + }, [baseTemplate]); + + // 기본값 설정 (기존 템플릿의 설정을 상속) + const defaultValues: Partial = React.useMemo(() => { + if (!baseTemplate) return {}; + + return { + revision: suggestedRevision, + legalReviewRequired: baseTemplate.legalReviewRequired, + shipBuildingApplicable: baseTemplate.shipBuildingApplicable, + windApplicable: baseTemplate.windApplicable, + pcApplicable: baseTemplate.pcApplicable, + nbApplicable: baseTemplate.nbApplicable, + rcApplicable: baseTemplate.rcApplicable, + gyApplicable: baseTemplate.gyApplicable, + sysApplicable: baseTemplate.sysApplicable, + infraApplicable: baseTemplate.infraApplicable, + }; + }, [baseTemplate, suggestedRevision]); + + // 폼 초기화 + const form = useForm({ + resolver: zodResolver(createRevisionSchema), + defaultValues, + mode: "onChange", + }); + + // baseTemplate이 변경될 때 폼 값 재설정 + React.useEffect(() => { + if (baseTemplate && defaultValues) { + form.reset(defaultValues); + } + }, [baseTemplate, defaultValues, form]); + + // 파일 선택 핸들러 + const handleFileChange = (files: File[]) => { + if (files.length > 0) { + const file = files[0]; + setSelectedFile(file); + form.setValue("file", file); + } + }; + + // 모든 적용 범위 선택/해제 + const handleSelectAllScopes = (checked: boolean) => { + BUSINESS_UNITS.forEach(unit => { + form.setValue(unit.key as keyof CreateRevisionFormValues, checked); + }); + }; + + // 이전 설정 복사 + const handleCopyPreviousSettings = () => { + if (!baseTemplate) return; + + BUSINESS_UNITS.forEach(unit => { + const value = baseTemplate[unit.key as keyof BasicContractTemplate] as boolean; + form.setValue(unit.key as keyof CreateRevisionFormValues, value); + }); + + form.setValue("legalReviewRequired", baseTemplate.legalReviewRequired); + toast.success("이전 설정이 복사되었습니다."); + }; + + // 청크 크기 설정 (1MB) + const CHUNK_SIZE = 1 * 1024 * 1024; + + // 파일을 청크로 분할하여 업로드하는 함수 + const uploadFileInChunks = async (file: File, fileId: string) => { + const totalChunks = Math.ceil(file.size / CHUNK_SIZE); + setShowProgress(true); + setUploadProgress(0); + + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + const start = chunkIndex * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, file.size); + const chunk = file.slice(start, end); + + const formData = new FormData(); + formData.append('chunk', chunk); + formData.append('filename', file.name); + formData.append('chunkIndex', chunkIndex.toString()); + formData.append('totalChunks', totalChunks.toString()); + formData.append('fileId', fileId); + + try { + const response = await fetch('/api/upload/basicContract/chunk', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`청크 업로드 실패: ${response.statusText}`); + } + + // 진행률 업데이트 + const progress = Math.round(((chunkIndex + 1) / totalChunks) * 100); + setUploadProgress(progress); + + const result = await response.json(); + + // 마지막 청크인 경우 파일 경로 반환 + if (chunkIndex === totalChunks - 1) { + return result; + } + } catch (error) { + console.error(`청크 ${chunkIndex} 업로드 오류:`, error); + throw error; + } + } + }; + + // 폼 제출 핸들러 + async function onSubmit(formData: CreateRevisionFormValues) { + if (!baseTemplate) return; + + setIsLoading(true); + try { + if (!formData.file) { + throw new Error("파일이 선택되지 않았습니다."); + } + + // 고유 파일 ID 생성 + const fileId = uuidv4(); + + // 파일 청크 업로드 + const uploadResult = await uploadFileInChunks(formData.file, fileId); + + if (!uploadResult.success) { + throw new Error("파일 업로드에 실패했습니다."); + } + + // 새 리비전 생성 API 호출 + const createResponse = await fetch('/api/basicContract/create-revision', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + baseTemplateId: baseTemplate.id, + templateName: baseTemplate.templateName, + revision: formData.revision, + legalReviewRequired: formData.legalReviewRequired, + shipBuildingApplicable: formData.shipBuildingApplicable, + windApplicable: formData.windApplicable, + pcApplicable: formData.pcApplicable, + nbApplicable: formData.nbApplicable, + rcApplicable: formData.rcApplicable, + gyApplicable: formData.gyApplicable, + sysApplicable: formData.sysApplicable, + infraApplicable: formData.infraApplicable, + fileName: uploadResult.fileName, + filePath: uploadResult.filePath, + }), + next: { tags: ["basic-contract-templates"] }, + }); + + const createResult = await createResponse.json(); + + if (!createResult.success) { + throw new Error(createResult.error || "리비전 생성에 실패했습니다."); + } + + toast.success(`${baseTemplate.templateName} v${formData.revision} 리비전이 성공적으로 생성되었습니다.`); + form.reset(); + setSelectedFile(null); + onOpenChange(false); + setShowProgress(false); + onSuccess?.(); + router.refresh(); + } catch (error) { + console.error("Submit error:", error); + toast.error("리비전 생성 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + } + + // 모달이 닫힐 때 폼 초기화 + React.useEffect(() => { + if (!open) { + form.reset(); + setSelectedFile(null); + setShowProgress(false); + setUploadProgress(0); + } + }, [open, form]); + + // 현재 선택된 적용 범위 수 + const selectedScopesCount = BUSINESS_UNITS.filter(unit => + form.watch(unit.key as keyof CreateRevisionFormValues) + ).length; + + if (!baseTemplate) return null; + + return ( + + + {/* 고정된 헤더 */} + + + + 새 리비전 생성 + + +
+
+ + {baseTemplate.templateName} + 현재 v{baseTemplate.revision} + + 새 v{form.watch("revision")} +
+

+ 기존 템플릿을 기반으로 새로운 리비전을 생성합니다. + * 표시된 항목은 필수 입력사항입니다. +

+
+
+
+ + {/* 스크롤 가능한 컨텐츠 영역 */} +
+
+ + {/* 리비전 정보 */} + + + 리비전 정보 + + 새로 생성할 리비전의 번호를 설정하세요 + + + + ( + + + 리비전 번호 * + + + field.onChange(parseInt(e.target.value) || suggestedRevision)} + /> + + + 권장 리비전: {suggestedRevision} (현재 리비전보다 큰 숫자여야 합니다) + + + + )} + /> + + ( + +
+ 법무검토 필요 + + 법무팀 검토가 필요한 템플릿인지 설정 + +
+ + + +
+ )} + /> +
+
+ + {/* 적용 범위 */} + + + + 적용 범위 * + + + 이 리비전이 적용될 사업부를 선택하세요. ({selectedScopesCount}개 선택됨) + + + +
+
+ + +
+ +
+ + + +
+ {BUSINESS_UNITS.map((unit) => ( + ( + + + + +
+ + {unit.label} + +
+
+ )} + /> + ))} +
+ + {form.formState.errors.shipBuildingApplicable && ( +

+ {form.formState.errors.shipBuildingApplicable.message} +

+ )} +
+
+ + {/* 파일 업로드 */} + + + 파일 업로드 + + 새 리비전의 템플릿 파일을 업로드하세요 + + + + ( + + + 템플릿 파일 * + + + + + + + {selectedFile ? selectedFile.name : "워드 파일을 여기에 드래그하세요"} + + + {selectedFile + ? `파일 크기: ${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB` + : "또는 클릭하여 워드 파일(.doc, .docx)을 선택하세요 (최대 100MB)"} + + + + + + + + )} + /> + + {showProgress && ( +
+
+ 업로드 진행률 + {uploadProgress}% +
+ +
+ )} +
+
+
+ +
+ + {/* 고정된 푸터 */} + + + + +
+
+ ); +} \ No newline at end of file -- cgit v1.2.3