From 1ede1a1e3849320f62edf4ecde00334ff45b0ef7 Mon Sep 17 00:00:00 2001 From: Wibaek Park Date: Wed, 8 Jul 2026 00:30:42 +0900 Subject: [PATCH] =?UTF-8?q?fix(university):=20=EC=A0=9C=EC=B6=9C=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=A4=91=EB=B3=B5=20=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ApplicationBottomActionBar.tsx | 7 ++- .../application/apply/ApplyPageContent.tsx | 30 +++++++++---- .../application/apply/ConfirmStep.tsx | 5 ++- .../score/submit/gpa/GpaSubmitForm.tsx | 45 ++++++++++++------- .../language-test/LanguageTestSubmitForm.tsx | 17 ++++--- 5 files changed, 70 insertions(+), 34 deletions(-) diff --git a/apps/web/src/app/university/application/_components/ApplicationBottomActionBar.tsx b/apps/web/src/app/university/application/_components/ApplicationBottomActionBar.tsx index 7b97e940e..02a2b536f 100644 --- a/apps/web/src/app/university/application/_components/ApplicationBottomActionBar.tsx +++ b/apps/web/src/app/university/application/_components/ApplicationBottomActionBar.tsx @@ -3,12 +3,15 @@ import BlockBtn from "@/components/button/BlockBtn"; type ApplicationBottomActionBarProps = { label: string; onClick: () => void; + disabled?: boolean; }; -const ApplicationBottomActionBar = ({ label, onClick }: ApplicationBottomActionBarProps) => { +const ApplicationBottomActionBar = ({ label, onClick, disabled = false }: ApplicationBottomActionBarProps) => { return (
- {label} + + {label} +
); }; diff --git a/apps/web/src/app/university/application/apply/ApplyPageContent.tsx b/apps/web/src/app/university/application/apply/ApplyPageContent.tsx index 266cbe8ad..5b23ce440 100644 --- a/apps/web/src/app/university/application/apply/ApplyPageContent.tsx +++ b/apps/web/src/app/university/application/apply/ApplyPageContent.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { usePostSubmitApplication } from "@/apis/applications"; import { useGetMyGpaScore, useGetMyLanguageTestScore } from "@/apis/Scores"; import { useUniversitySearch } from "@/apis/universities"; @@ -41,7 +41,8 @@ const ApplyPageContent = () => { // TODO: 서버의 모학교 미인증 에러 코드가 확정되면 GPA 조회 실패 시 학교 인증으로 보내는 fallback을 추가한다. const { data: gpaScoreData } = useGetMyGpaScore({ enabled: shouldFetchGpaScore }); const { data: languageTestScoreList = [] } = useGetMyLanguageTestScore(); - const { mutate: postSubmitApplication } = usePostSubmitApplication({ + const isSubmitLockedRef = useRef(false); + const { mutate: postSubmitApplication, isPending: isSubmitApplicationPending } = usePostSubmitApplication({ onSuccess: () => { setStep(99); }, @@ -70,6 +71,10 @@ const ApplyPageContent = () => { }; const handleSubmit = async () => { + if (isSubmitLockedRef.current || isSubmitApplicationPending) { + return; + } + if (curGpaScore === null) { showIconToast("logo", "GPA를 선택해주세요."); return; @@ -89,13 +94,21 @@ const ApplyPageContent = () => { return; } - postSubmitApplication({ - gpaScoreId: curGpaScore, - languageTestScoreId: curLanguageTestScore, - universityChoiceRequest: { - choices: selectedUniversityIds, + isSubmitLockedRef.current = true; + postSubmitApplication( + { + gpaScoreId: curGpaScore, + languageTestScoreId: curLanguageTestScore, + universityChoiceRequest: { + choices: selectedUniversityIds, + }, + }, + { + onSettled: () => { + isSubmitLockedRef.current = false; + }, }, - }); + ); }; const gpaScoreList = gpaScoreData?.gpaScoreStatusResponseList ?? []; @@ -154,6 +167,7 @@ const ApplyPageContent = () => { .filter(Boolean) as ListUniversity[] } onNext={handleSubmit} + isSubmitting={isSubmitApplicationPending} /> )} {step === 99 && } diff --git a/apps/web/src/app/university/application/apply/ConfirmStep.tsx b/apps/web/src/app/university/application/apply/ConfirmStep.tsx index cd8fa7a65..94386890d 100644 --- a/apps/web/src/app/university/application/apply/ConfirmStep.tsx +++ b/apps/web/src/app/university/application/apply/ConfirmStep.tsx @@ -7,6 +7,7 @@ import ApplicationBottomActionBar from "../_components/ApplicationBottomActionBa type ConfirmStepProps = { universityList: ListUniversity[]; onNext: () => void; + isSubmitting?: boolean; }; type ConfirmUniversityCardProps = { @@ -38,7 +39,7 @@ const ConfirmUniversityCard = ({ universityList }: ConfirmUniversityCardProps) = ); }; -const ConfirmStep = ({ universityList, onNext }: ConfirmStepProps) => { +const ConfirmStep = ({ universityList, onNext, isSubmitting = false }: ConfirmStepProps) => { return (
@@ -55,7 +56,7 @@ const ConfirmStep = ({ universityList, onNext }: ConfirmStepProps) => {
- +
); }; diff --git a/apps/web/src/app/university/score/submit/gpa/GpaSubmitForm.tsx b/apps/web/src/app/university/score/submit/gpa/GpaSubmitForm.tsx index 59b219999..a641d5496 100644 --- a/apps/web/src/app/university/score/submit/gpa/GpaSubmitForm.tsx +++ b/apps/web/src/app/university/score/submit/gpa/GpaSubmitForm.tsx @@ -4,7 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import clsx from "clsx"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { Suspense, useEffect, useState } from "react"; +import { Suspense, useEffect, useRef, useState } from "react"; import { Controller, type SubmitHandler, useForm } from "react-hook-form"; import { usePostGpaScore } from "@/apis/Scores"; import { getSchoolEmailVerificationPath } from "@/app/my/school-email/_lib/returnTo"; @@ -22,7 +22,8 @@ const GpaSubmitForm = () => { const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const [showResult, setShowResult] = useState(false); const [submittedData, setSubmittedData] = useState(null); - const { mutateAsync: postGpaScore } = usePostGpaScore(); + const isSubmitLockedRef = useRef(false); + const { mutateAsync: postGpaScore, isPending: isPostGpaScorePending } = usePostGpaScore(); // 2. react-hook-form 설정 const { @@ -31,12 +32,13 @@ const GpaSubmitForm = () => { control, watch, reset, - formState: { errors, isValid }, + formState: { errors, isSubmitting, isValid }, } = useForm({ resolver: zodResolver(gpaSchema), mode: "onChange", }); const selectedFile = watch("file"); + const isSubmitDisabled = !isValid || isSubmitting || isPostGpaScorePending; useEffect(() => { if (!isAuthInitialized || !isAuthenticated || homeUniversityId !== null) { @@ -52,18 +54,27 @@ const GpaSubmitForm = () => { // 3. 폼 제출 핸들러 const onSubmit: SubmitHandler = async (data) => { - await postGpaScore({ - gpaScoreRequest: { - gpa: Number(data.gpa), - gpaCriteria: Number(data.gpaCriteria), - issueDate: "2025-01-01", // TODO: 실제 날짜 데이터로 변경 - }, - file: data.file[0], - }); - // 성공 시, 결과 화면에 보여줄 데이터를 저장하고 상태 변경 - setSubmittedData(data); - setShowResult(true); - reset(); + if (isSubmitLockedRef.current) return; + + isSubmitLockedRef.current = true; + try { + await postGpaScore({ + gpaScoreRequest: { + gpa: Number(data.gpa), + gpaCriteria: Number(data.gpaCriteria), + issueDate: "2025-01-01", // TODO: 실제 날짜 데이터로 변경 + }, + file: data.file[0], + }); + // 성공 시, 결과 화면에 보여줄 데이터를 저장하고 상태 변경 + setSubmittedData(data); + setShowResult(true); + reset(); + } catch (_error) { + // 실패 토스트는 React Query 전역 onError에서 단일 처리 + } finally { + isSubmitLockedRef.current = false; + } }; if (showResult && submittedData) { @@ -175,9 +186,9 @@ const GpaSubmitForm = () => { diff --git a/apps/web/src/app/university/score/submit/language-test/LanguageTestSubmitForm.tsx b/apps/web/src/app/university/score/submit/language-test/LanguageTestSubmitForm.tsx index 18e3072ce..219ec3058 100644 --- a/apps/web/src/app/university/score/submit/language-test/LanguageTestSubmitForm.tsx +++ b/apps/web/src/app/university/score/submit/language-test/LanguageTestSubmitForm.tsx @@ -4,7 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import clsx from "clsx"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { Suspense, useState } from "react"; +import { Suspense, useRef, useState } from "react"; import { Controller, type SubmitHandler, useForm } from "react-hook-form"; import { usePostLanguageTestScore } from "@/apis/Scores"; import SubmitLinkTab from "@/components/score/SubmitLinkTab"; @@ -17,7 +17,8 @@ import { type LanguageTestFormData, languageTestSchema } from "./_lib/schema"; const LanguageTestSubmitForm = () => { const router = useRouter(); const [showResult, setShowResult] = useState(false); - const { mutateAsync: postLanguageTestScore } = usePostLanguageTestScore(); + const isSubmitLockedRef = useRef(false); + const { mutateAsync: postLanguageTestScore, isPending: isPostLanguageTestScorePending } = usePostLanguageTestScore(); const [submittedData, setSubmittedData] = useState(null); // 3. react-hook-form 설정 @@ -27,16 +28,20 @@ const LanguageTestSubmitForm = () => { control, watch, reset, - formState: { errors, isValid }, + formState: { errors, isSubmitting, isValid }, } = useForm({ resolver: zodResolver(languageTestSchema), mode: "onChange", // 입력값이 변경될 때마다 유효성 검사 }); const selectedFile = watch("file"); + const isSubmitDisabled = !isValid || isSubmitting || isPostLanguageTestScorePending; // 4. 폼 제출 핸들러 const onSubmit: SubmitHandler = async (data) => { + if (isSubmitLockedRef.current) return; + + isSubmitLockedRef.current = true; try { await postLanguageTestScore({ languageTestScoreRequest: { @@ -53,6 +58,8 @@ const LanguageTestSubmitForm = () => { setSubmittedData(data); } catch (_error) { // 실패 토스트는 React Query 전역 onError에서 단일 처리 + } finally { + isSubmitLockedRef.current = false; } }; @@ -162,9 +169,9 @@ const LanguageTestSubmitForm = () => {