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 --- .../add-basic-contract-template-dialog.tsx | 268 +++++++++++---------- 1 file changed, 135 insertions(+), 133 deletions(-) (limited to 'lib/basic-contract/template/add-basic-contract-template-dialog.tsx') diff --git a/lib/basic-contract/template/add-basic-contract-template-dialog.tsx b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx index 9b036445..fd1bd333 100644 --- a/lib/basic-contract/template/add-basic-contract-template-dialog.tsx +++ b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx @@ -40,10 +40,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Separator } from "@/components/ui/separator"; import { useRouter } from "next/navigation"; import { BUSINESS_UNITS } from "@/config/basicContractColumnsConfig"; +import { getExistingTemplateNames } from "../service"; -// 템플릿 이름 옵션 정의 +// ✅ 서버 액션 import + +// 전체 템플릿 후보 const TEMPLATE_NAME_OPTIONS = [ - "준법서약", + "준법서약 (한글)", + "준법서약 (영문)", "기술자료 요구서", "비밀유지 계약서", "표준하도급기본 계약서", @@ -56,14 +60,11 @@ const TEMPLATE_NAME_OPTIONS = [ "직납자재 하도급대급등 연동제 의향서" ] as const; -// 업데이트된 계약서 템플릿 스키마 정의 (워드파일만 허용) const templateFormSchema = z.object({ templateName: z.enum(TEMPLATE_NAME_OPTIONS, { required_error: "템플릿 이름을 선택해주세요.", }), - revision: z.coerce.number().int().min(1).default(1), legalReviewRequired: z.boolean().default(false), - // 적용 범위 shipBuildingApplicable: z.boolean().default(false), windApplicable: z.boolean().default(false), @@ -73,31 +74,42 @@ const templateFormSchema = z.object({ 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)만 업로드 가능합니다." } - ), - status: z.enum(["ACTIVE", "DISPOSED"]).default("ACTIVE"), -}).refine((data) => { - // 적어도 하나의 적용 범위는 선택되어야 함 + file: z.instanceof(File).optional(), +}) +.refine((data) => { + if (data.templateName !== "General GTC" && !data.file) return false; + return true; +}, { + message: "파일을 업로드해주세요.", + path: ["file"], +}) +.refine((data) => { + if (data.file && data.file.size > 100 * 1024 * 1024) return false; + return true; +}, { + message: "파일 크기는 100MB 이하여야 합니다.", + path: ["file"], +}) +.refine((data) => { + if (data.file) { + const isValidType = data.file.type === 'application/msword' || + data.file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + return isValidType; + } + return true; +}, { + message: "워드 파일(.doc, .docx)만 업로드 가능합니다.", + path: ["file"], +}) +.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; + return scopeFields.some(field => data[field as keyof typeof data] === true); }, { message: "적어도 하나의 적용 범위를 선택해야 합니다.", - path: ["shipBuildingApplicable"], // 에러를 첫 번째 체크박스에 표시 + path: ["shipBuildingApplicable"], }); type TemplateFormValues = z.infer; @@ -108,12 +120,12 @@ export function AddTemplateDialog() { const [selectedFile, setSelectedFile] = React.useState(null); const [uploadProgress, setUploadProgress] = React.useState(0); const [showProgress, setShowProgress] = React.useState(false); + const [availableTemplateNames, setAvailableTemplateNames] = React.useState(TEMPLATE_NAME_OPTIONS); const router = useRouter(); - // 기본값 설정 (templateCode 제거) + // 기본값 const defaultValues: Partial = { templateName: undefined, - revision: 1, legalReviewRequired: false, shipBuildingApplicable: false, windApplicable: false, @@ -123,17 +135,33 @@ export function AddTemplateDialog() { gyApplicable: false, sysApplicable: false, infraApplicable: false, - status: "ACTIVE", }; - // 폼 초기화 const form = useForm({ resolver: zodResolver(templateFormSchema), defaultValues, mode: "onChange", }); - // 파일 선택 핸들러 + // 🔸 마운트 시 이미 등록된 templateName 목록 가져와서 필터링 + React.useEffect(() => { + let cancelled = false; + (async () => { + try { + const usedNames = await getExistingTemplateNames(); + if (cancelled) return; + + // 이미 있는 이름 제외 + const filtered = TEMPLATE_NAME_OPTIONS.filter(name => !usedNames.includes(name)); + setAvailableTemplateNames(filtered); + } catch (err) { + console.error("Failed to fetch existing template names", err); + // 실패 시 전체 옵션 보여주거나, 오류 알려주기 + } + })(); + return () => { cancelled = true; }; + }, []); + const handleFileChange = (files: File[]) => { if (files.length > 0) { const file = files[0]; @@ -142,88 +170,71 @@ export function AddTemplateDialog() { } }; - // 모든 적용 범위 선택/해제 const handleSelectAllScopes = (checked: boolean) => { BUSINESS_UNITS.forEach(unit => { form.setValue(unit.key as keyof TemplateFormValues, checked); }); }; - // 청크 크기 설정 (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; + + 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; } } }; - // 폼 제출 핸들러 (templateCode 제거) async function onSubmit(formData: TemplateFormValues) { 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("파일 업로드에 실패했습니다."); + let uploadResult = null; + + if (formData.file) { + const fileId = uuidv4(); + uploadResult = await uploadFileInChunks(formData.file, fileId); + + if (!uploadResult?.success) { + throw new Error("파일 업로드에 실패했습니다."); + } } - - // 메타데이터 저장 (templateCode 제거됨) + const saveResponse = await fetch('/api/upload/basicContract/complete', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ templateName: formData.templateName, - revision: formData.revision, + revision: 1, legalReviewRequired: formData.legalReviewRequired, shipBuildingApplicable: formData.shipBuildingApplicable, windApplicable: formData.windApplicable, @@ -233,35 +244,32 @@ export function AddTemplateDialog() { gyApplicable: formData.gyApplicable, sysApplicable: formData.sysApplicable, infraApplicable: formData.infraApplicable, - status: formData.status, - fileName: uploadResult.fileName, - filePath: uploadResult.filePath, + status: "ACTIVE", + fileName: uploadResult?.fileName || `${formData.templateName}_v1.docx`, + filePath: uploadResult?.filePath || "", }), next: { tags: ["basic-contract-templates"] }, }); - + const saveResult = await saveResponse.json(); - if (!saveResult.success) { - throw new Error("템플릿 정보 저장에 실패했습니다."); + throw new Error(saveResult.error || "템플릿 정보 저장에 실패했습니다."); } - + toast.success('템플릿이 성공적으로 추가되었습니다.'); form.reset(); setSelectedFile(null); setOpen(false); setShowProgress(false); - router.refresh(); } catch (error) { console.error("Submit error:", error); - toast.error("템플릿 추가 중 오류가 발생했습니다."); + toast.error(error instanceof Error ? error.message : "템플릿 추가 중 오류가 발생했습니다."); } finally { setIsLoading(false); } } - // 모달이 닫힐 때 폼 초기화 React.useEffect(() => { if (!open) { form.reset(); @@ -278,11 +286,17 @@ export function AddTemplateDialog() { setOpen(nextOpen); } - // 현재 선택된 적용 범위 수 - const selectedScopesCount = BUSINESS_UNITS.filter(unit => + const selectedScopesCount = BUSINESS_UNITS.filter(unit => form.watch(unit.key as keyof TemplateFormValues) ).length; + const templateNameIsRequired = form.watch("templateName") !== "General GTC"; + + const isSubmitDisabled = isLoading || + !form.watch("templateName") || + (templateNameIsRequired && !form.watch("file")) || + !BUSINESS_UNITS.some(unit => form.watch(unit.key as keyof TemplateFormValues)); + return ( @@ -291,16 +305,14 @@ export function AddTemplateDialog() { - {/* 고정된 헤더 */} 새 기본계약서 템플릿 추가 - 템플릿 정보를 입력하고 계약서 파일을 업로드하세요. + 템플릿 정보를 입력하고 계약서 파일을 업로드하세요. (리비전은 자동으로 1로 설정됩니다) * 표시된 항목은 필수 입력사항입니다. - {/* 스크롤 가능한 컨텐츠 영역 */}
@@ -310,7 +322,7 @@ export function AddTemplateDialog() { 기본 정보 -
+
템플릿 이름 * - - - + + - {TEMPLATE_NAME_OPTIONS.map((option) => ( + {availableTemplateNames.map((option) => ( {option} @@ -334,33 +350,7 @@ export function AddTemplateDialog() { - 미리 정의된 템플릿 중에서 선택 - - - - )} - /> - - ( - - 리비전 - - field.onChange(parseInt(e.target.value) || 1)} - /> - - - 템플릿 버전 (기본값: 1) -
- - 동일한 템플릿 이름의 리비전은 중복될 수 없습니다. - + 이미 등록되지 않은 템플릿만 표시됩니다. (리비전 1로 생성)
@@ -412,9 +402,9 @@ export function AddTemplateDialog() { 전체 선택
- + - +
{BUSINESS_UNITS.map((unit) => ( ))}
- + {form.formState.errors.shipBuildingApplicable && (

{form.formState.errors.shipBuildingApplicable.message} @@ -452,6 +442,11 @@ export function AddTemplateDialog() { 파일 업로드 + + {form.watch("templateName") === "General GTC" + ? "General GTC는 파일 업로드가 선택사항입니다" + : "템플릿 파일을 업로드하세요"} + ( - 계약서 파일 * + 템플릿 파일 + {form.watch("templateName") !== "General GTC" && ( + * + )} + {form.watch("templateName") === "General GTC" && ( + (선택사항) + )} {selectedFile ? `파일 크기: ${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB` - : "또는 클릭하여 워드 파일(.doc, .docx)을 선택하세요 (최대 100MB)"} + : form.watch("templateName") === "General GTC" + ? "또는 클릭하여 워드 파일(.doc, .docx)을 선택하세요 (선택사항, 최대 100MB)" + : "또는 클릭하여 워드 파일(.doc, .docx)을 선택하세요 (최대 100MB)"} @@ -488,7 +491,7 @@ export function AddTemplateDialog() { )} /> - + {showProgress && (

@@ -504,7 +507,6 @@ export function AddTemplateDialog() {
- {/* 고정된 푸터 */} @@ -525,4 +527,4 @@ export function AddTemplateDialog() {
); -} \ No newline at end of file +} -- cgit v1.2.3