From 3ebc7ad400b19b289d52f857abeec06f477ea260 Mon Sep 17 00:00:00 2001 From: mingo Date: Fri, 24 Jul 2026 16:00:03 +0900 Subject: [PATCH 1/5] =?UTF-8?q?Feat:=20=EB=AA=A8=EC=9D=98=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=20=EA=B3=B5=EA=B3=A0=20=EC=A1=B0=ED=9A=8C=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20[JDDEV-98]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/mockApply/[mockApplyId]/page.tsx | 45 +++++------------ .../mockApply/[mockApplyId]/result/page.tsx | 15 +++--- jobdri/src/lib/api/mockApplies.ts | 50 +++++++++++++++---- 3 files changed, 60 insertions(+), 50 deletions(-) diff --git a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx index d559ef9..798d1f8 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx @@ -22,7 +22,7 @@ import { requestAnalysis, } from "@/lib/api/result"; import MockApplyTemplate from "@/components/common/MockApplyTemplate"; -import { fetchMyJobPosting } from "@/lib/api/jobPostings"; +import { fetchMockApplyJobPosting } from "@/lib/api/mockApplies"; import type { JDData } from "@/components/mockApply/Question/SidePanel"; import { saveJobPostingAnalysis } from "@/app/mockApply/job/jobPostingDraftStore"; @@ -49,12 +49,12 @@ export default function MockApplyPage({ const [isQuestionsLoading, setIsQuestionsLoading] = useState(true); const [questionsErrorMessage, setQuestionsErrorMessage] = useState(""); const [jdData, setJdData] = useState(null); - const [sequenceJobPostingId, setSequenceJobPostingId] = useState< - number | null - >(null); - const jobPostingId = hasRequestedJobPostingId - ? requestedJobPostingId - : sequenceJobPostingId; + const [linkedJobPostingId, setLinkedJobPostingId] = useState( + null, + ); + const jobPostingId = + linkedJobPostingId ?? + (hasRequestedJobPostingId ? requestedJobPostingId : null); const [selectedId, setSelectedId] = useState(null); const [lastSavedTime, setLastSavedTime] = useState("저장 전"); @@ -164,42 +164,21 @@ export default function MockApplyPage({ }, [mockApplyId]); useEffect(() => { - if (hasRequestedJobPostingId) { - return; - } - - let ignore = false; + const parsedMockApplyId = Number(mockApplyId); - fetchSequence(Number(mockApplyId)) - .then((sequence) => { - if (!ignore) { - setSequenceJobPostingId(sequence.jobPostingId); - } - }) - .catch((error) => { - if (!ignore) { - console.error("연결된 채용 공고를 확인하지 못했습니다.", error); - } - }); - - return () => { - ignore = true; - }; - }, [hasRequestedJobPostingId, mockApplyId]); - - useEffect(() => { - if (!jobPostingId) { + if (!Number.isInteger(parsedMockApplyId) || parsedMockApplyId <= 0) { return; } let ignore = false; - fetchMyJobPosting(jobPostingId) + fetchMockApplyJobPosting(parsedMockApplyId) .then((jobPosting) => { if (ignore) { return; } + setLinkedJobPostingId(jobPosting.jobPostingId); setJdData({ companyName: jobPosting.companyName, profileColor: jobPosting.profileColor, @@ -238,7 +217,7 @@ export default function MockApplyPage({ return () => { ignore = true; }; - }, [jobPostingId]); + }, [mockApplyId]); // 자동 저장 타이머 useEffect(() => { diff --git a/jobdri/src/app/mockApply/[mockApplyId]/result/page.tsx b/jobdri/src/app/mockApply/[mockApplyId]/result/page.tsx index 33e9f1e..1846384 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/result/page.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/result/page.tsx @@ -8,11 +8,12 @@ import AnalysisHeader from "@/components/mockApply/result/AnalysisHeader"; import { ModalNotice } from "@/components/common/modal"; import { Toast } from "@/components/common/toast"; import { useReApply } from "@/hooks/useReApply"; -import { getMockApplyResumeRecords } from "@/lib/api/mockApplies"; +import { + fetchMockApplyJobPosting, + getMockApplyResumeRecords, +} from "@/lib/api/mockApplies"; import { useAnalysisResult } from "@/hooks/useAnalysisResult"; import MockApplyTemplate from "@/components/common/MockApplyTemplate"; -import { fetchMyJobPosting } from "@/lib/api/jobPostings"; -import { fetchSequence } from "@/lib/api/result"; interface ResultPageProps { params: Promise<{ @@ -73,10 +74,8 @@ export default function ResultPage({ params, searchParams }: ResultPageProps) { const loadJobPostingHeader = async () => { try { - const resolvedJobPostingId = - parsedJobPostingId ?? - (await fetchSequence(parsedMockApplyId)).jobPostingId; - const jobPosting = await fetchMyJobPosting(resolvedJobPostingId); + const jobPosting = + await fetchMockApplyJobPosting(parsedMockApplyId); if (!ignore) { setJobPostingHeader({ @@ -99,7 +98,7 @@ export default function ResultPage({ params, searchParams }: ResultPageProps) { return () => { ignore = true; }; - }, [parsedJobPostingId, parsedMockApplyId]); + }, [parsedMockApplyId]); const closeToast = () => setToast({ open: false, message: "" }); const showTopToast = (message: string) => { diff --git a/jobdri/src/lib/api/mockApplies.ts b/jobdri/src/lib/api/mockApplies.ts index 8edd3c7..6917b3e 100644 --- a/jobdri/src/lib/api/mockApplies.ts +++ b/jobdri/src/lib/api/mockApplies.ts @@ -3,7 +3,10 @@ import { getAuthHeaders, parseApiResponse, } from "@/lib/api/client"; -import type { JobPostingProfileColor } from "@/lib/api/jobPostings"; +import type { + JobPostingProfileColor, + SavedJobPosting, +} from "@/lib/api/jobPostings"; export type JobPostingApplyType = "MOCK" | "ACTUAL"; export type MockApplyProgressStatus = @@ -16,6 +19,12 @@ export interface MockApplyFromJobPosting { jobPostingId: number; mockApplyId: number; applyType: JobPostingApplyType; + sequence: number; +} + +export interface MockApplyFromJobPostingPayload { + jobPostingId: number; + sequence?: number; } export interface MockApplyRetryResult { @@ -60,7 +69,7 @@ const MOCK_APPLY_RESUME_STORAGE_KEY = "jobdri.mockApplyResumeRecords"; async function postMockApplyFromJobPosting( path: string, - jobPostingId: number, + payload: MockApplyFromJobPostingPayload, fallbackMessage: string, ) { const response = await fetch(`${API_BASE_URL}${path}`, { @@ -69,24 +78,28 @@ async function postMockApplyFromJobPosting( "Content-Type": "application/json", ...getAuthHeaders(), }, - body: JSON.stringify({ jobPostingId }), + body: JSON.stringify(payload), }); return parseApiResponse(response, fallbackMessage); } -export function createMockApplyFromJobPosting(jobPostingId: number) { +export function createMockApplyFromJobPosting( + payload: MockApplyFromJobPostingPayload, +) { return postMockApplyFromJobPosting( "/api/mock-applies/mock/from-job-posting", - jobPostingId, + payload, "모의 서류 지원 생성에 실패했습니다.", ); } -export function createActualApplyFromJobPosting(jobPostingId: number) { +export function createActualApplyFromJobPosting( + payload: MockApplyFromJobPostingPayload, +) { return postMockApplyFromJobPosting( "/api/mock-applies/actual", - jobPostingId, + payload, "실제 공고 기반 서류 지원 생성에 실패했습니다.", ); } @@ -94,13 +107,15 @@ export function createActualApplyFromJobPosting(jobPostingId: number) { export function createApplyFromJobPosting({ jobPostingId, applyType, + sequence, }: { jobPostingId: number; applyType: JobPostingApplyType; + sequence?: number; }) { return applyType === "MOCK" - ? createMockApplyFromJobPosting(jobPostingId) - : createActualApplyFromJobPosting(jobPostingId); + ? createMockApplyFromJobPosting({ jobPostingId, sequence }) + : createActualApplyFromJobPosting({ jobPostingId, sequence }); } export async function fetchMyMockApplies({ @@ -128,6 +143,23 @@ export async function fetchMyMockApplies({ }; } +export async function fetchMockApplyJobPosting( + mockApplyId: number, +): Promise { + const response = await fetch( + `${API_BASE_URL}/api/mock-applies/${mockApplyId}/job-posting`, + { + headers: getAuthHeaders(), + cache: "no-store", + }, + ); + + return parseApiResponse( + response, + "연결된 채용 공고를 불러오지 못했습니다.", + ); +} + export function saveSelectedApplyType(applyType: JobPostingApplyType) { if (typeof window === "undefined") { return; From 9da658fdd4c07ce5c6b0de254f8c82e1809a8b77 Mon Sep 17 00:00:00 2001 From: mingo Date: Fri, 24 Jul 2026 16:33:34 +0900 Subject: [PATCH 2/5] =?UTF-8?q?Feat:=20=ED=99=88=20=EB=AA=A8=EC=9D=98?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20=EB=8B=A8=EA=B1=B4=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?api=20=EC=97=B0=EB=8F=99=20[JDDEV-98]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jobdri/src/app/page.tsx | 26 +++++++- jobdri/src/components/common/lnb/Lnb.tsx | 32 ++++++++- .../mockApply/home/applicationHomeUtils.ts | 4 +- jobdri/src/lib/api/mockApplies.ts | 65 +++++++++++++++---- 4 files changed, 109 insertions(+), 18 deletions(-) diff --git a/jobdri/src/app/page.tsx b/jobdri/src/app/page.tsx index c06e63f..7d83111 100644 --- a/jobdri/src/app/page.tsx +++ b/jobdri/src/app/page.tsx @@ -7,6 +7,7 @@ import { Lnb } from "@/components/common/lnb"; import ResultDraftList from "@/components/mockApply/home/ResultDraftList"; import ResultApplicationList from "@/components/mockApply/home/ResultApplicationList"; import { + deleteMockApply, fetchMyMockApplies, saveSelectedApplyType, } from "@/lib/api/mockApplies"; @@ -146,7 +147,7 @@ export default function Home() { "직무 미지정", createdAt: formatDate(item.createdAt), score: item.score || 0, - version: item.version || 1, + version: item.sequence ?? 1, status: "completed", }; }); @@ -173,6 +174,21 @@ export default function Home() { console.error("채용 공고를 삭제하지 못했습니다.", error); } }; + + const deleteApplication = async (mockApplyId: number) => { + try { + await deleteMockApply(mockApplyId); + setDrafts((current) => + current.filter((draft) => draft.mockApplyId !== mockApplyId), + ); + setResults((current) => + current.filter((result) => result.mockApplyId !== mockApplyId), + ); + } catch (error) { + console.error("모의 서류 지원을 삭제하지 못했습니다.", error); + } + }; + return (
@@ -249,7 +265,11 @@ export default function Home() { onDelete={(id) => { const targetDraft = drafts.find((draft) => draft.id === id); - if (targetDraft) { + if (!targetDraft) return; + + if (typeof targetDraft.mockApplyId === "number") { + void deleteApplication(targetDraft.mockApplyId); + } else { void deletePosting(targetDraft.jobPostingId); } }} @@ -258,7 +278,7 @@ export default function Home() { {/* 분석 완료 섹션 */} void deletePosting(app.jobPostingId)} + onDelete={(app) => void deleteApplication(app.mockApplyId)} onRetry={(app) => void reApply(app.mockApplyId)} onResume={(app) => { router.push( diff --git a/jobdri/src/components/common/lnb/Lnb.tsx b/jobdri/src/components/common/lnb/Lnb.tsx index 45ba3ec..9160f1b 100644 --- a/jobdri/src/components/common/lnb/Lnb.tsx +++ b/jobdri/src/components/common/lnb/Lnb.tsx @@ -12,7 +12,10 @@ import { requestLogout, } from "@/lib/auth"; import { fetchCreditBalance } from "@/lib/api/credit"; -import { fetchMyMockApplies } from "@/lib/api/mockApplies"; +import { + fetchMyMockApplies, + MOCK_APPLY_DELETED_EVENT, +} from "@/lib/api/mockApplies"; import LnbDefault from "./LnbDefault"; import LnbFolded from "./LnbFolded"; import { @@ -105,7 +108,7 @@ export default function Lnb({ companyName: item.companyName, jobTitle: item.jobTitle || item.detailClassificationName || "직무 미지정", - version: item.version ?? 1, + version: item.sequence ?? 1, })); setRecentItems(mappedItems); @@ -121,6 +124,31 @@ export default function Lnb({ loadData(); }, []); + useEffect(() => { + const handleMockApplyDeleted = (event: Event) => { + const mockApplyId = (event as CustomEvent).detail; + + setRecentItems((current) => + current.filter((item) => item.id !== String(mockApplyId)), + ); + setSelectedRecentItemId((current) => + current === String(mockApplyId) ? "" : current, + ); + }; + + window.addEventListener( + MOCK_APPLY_DELETED_EVENT, + handleMockApplyDeleted, + ); + + return () => { + window.removeEventListener( + MOCK_APPLY_DELETED_EVENT, + handleMockApplyDeleted, + ); + }; + }, []); + // Credit Fetch useEffect(() => { if (disableCreditFetch) return; diff --git a/jobdri/src/components/mockApply/home/applicationHomeUtils.ts b/jobdri/src/components/mockApply/home/applicationHomeUtils.ts index 74c81e7..cc780ae 100644 --- a/jobdri/src/components/mockApply/home/applicationHomeUtils.ts +++ b/jobdri/src/components/mockApply/home/applicationHomeUtils.ts @@ -81,7 +81,7 @@ export function mapMockApplyToApplication( jobPostingId: item.jobPostingId, company: companyName || "회사명 미입력", hasCompanyName: companyName.length > 0, - profileColor: item.profileColor ?? "DEFAULT", + profileColor: "DEFAULT", position: item.jobTitle || item.detailClassificationName || "직무 미분류", createdAt: formatCreatedAt(item.createdAt), createdAtTime: getCreatedAtTime(item.createdAt), @@ -90,7 +90,7 @@ export function mapMockApplyToApplication( resumePath: item.resumePath, status, applyType: item.applyType, - version: item.version ?? 1, + version: item.sequence ?? 1, }; } diff --git a/jobdri/src/lib/api/mockApplies.ts b/jobdri/src/lib/api/mockApplies.ts index 6917b3e..51ae229 100644 --- a/jobdri/src/lib/api/mockApplies.ts +++ b/jobdri/src/lib/api/mockApplies.ts @@ -2,11 +2,9 @@ import { API_BASE_URL, getAuthHeaders, parseApiResponse, + parseApiResponseAllowNull, } from "@/lib/api/client"; -import type { - JobPostingProfileColor, - SavedJobPosting, -} from "@/lib/api/jobPostings"; +import type { SavedJobPosting } from "@/lib/api/jobPostings"; export type JobPostingApplyType = "MOCK" | "ACTUAL"; export type MockApplyProgressStatus = @@ -45,18 +43,16 @@ export interface MockApplyResumeRecord { export interface MockApplyHomeItem { mockApplyId: number; - resumePath?: string | null; + resumePath: string; jobPostingId: number; + sequence: number; status: MockApplyProgressStatus | string; companyName: string; - profileColor?: JobPostingProfileColor | null; - postingName?: string | null; - detailClassificationName?: string | null; - jobTitle?: string | null; + detailClassificationName: string; + jobTitle: string; createdAt: string; applyType: JobPostingApplyType; - score?: number | null; - version: number; + score: number; } export interface MockApplyHomeList { @@ -65,6 +61,7 @@ export interface MockApplyHomeList { } export const APPLY_TYPE_STORAGE_KEY = "jobdri.applyType"; +export const MOCK_APPLY_DELETED_EVENT = "jobdri:mock-apply-deleted"; const MOCK_APPLY_RESUME_STORAGE_KEY = "jobdri.mockApplyResumeRecords"; async function postMockApplyFromJobPosting( @@ -143,6 +140,32 @@ export async function fetchMyMockApplies({ }; } +export async function deleteMockApply(mockApplyId: number) { + const response = await fetch( + `${API_BASE_URL}/api/mock-applies/${mockApplyId}`, + { + method: "DELETE", + headers: getAuthHeaders(), + }, + ); + const result = await parseApiResponseAllowNull( + response, + "모의 서류 지원 삭제에 실패했습니다.", + ); + + removeMockApplyResumeRecord(mockApplyId); + + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent(MOCK_APPLY_DELETED_EVENT, { + detail: mockApplyId, + }), + ); + } + + return result; +} + export async function fetchMockApplyJobPosting( mockApplyId: number, ): Promise { @@ -251,6 +274,26 @@ export function saveMockApplyResumeRecord({ ); } +export function removeMockApplyResumeRecord(mockApplyId: number) { + if (typeof window === "undefined") { + return; + } + + const nextRecords = getMockApplyResumeRecords().filter( + (record) => record.mockApplyId !== mockApplyId, + ); + + if (nextRecords.length === 0) { + window.localStorage.removeItem(MOCK_APPLY_RESUME_STORAGE_KEY); + return; + } + + window.localStorage.setItem( + MOCK_APPLY_RESUME_STORAGE_KEY, + JSON.stringify(nextRecords), + ); +} + export function updateMockApplyResumeStatus( mockApplyId: number, status: MockApplyProgressStatus, From 943fe4d554cf166ab14dceee0499c84b49107575 Mon Sep 17 00:00:00 2001 From: mingo Date: Fri, 24 Jul 2026 18:12:50 +0900 Subject: [PATCH 3/5] =?UTF-8?q?Fix:=20=EC=9E=90=EC=86=8C=EC=84=9C=20?= =?UTF-8?q?=EB=AC=B8=ED=95=AD=20=EC=A0=80=EC=9E=A5=20=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9E=91=EC=84=B1=20=ED=99=94=EB=A9=B4=20UI=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20[JDDEV-98]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/mockApply/[mockApplyId]/page.tsx | 495 ++++++++++++++---- .../components/common/modal/ModalNotice.tsx | 14 +- jobdri/src/lib/api/questions.ts | 74 ++- 3 files changed, 449 insertions(+), 134 deletions(-) diff --git a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx index a560599..f7bc09b 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx @@ -1,5 +1,5 @@ "use client"; -import { useState, useEffect, use } from "react"; +import { useState, useEffect, use, useCallback, useRef } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { QuestionList } from "@/components/mockApply/Question/QuestionList"; import JDSidePanel from "@/components/mockApply/Question/SidePanel"; @@ -7,12 +7,14 @@ import WritingForm from "@/components/mockApply/Question/WritingForm"; import clsx from "clsx"; import { scrollbarClassS } from "@/components/common/scrollbar/scrollbarStyles"; import { + fetchQuestions, fetchSelectedQuestions, saveQuestions, saveApply, type QuestionItem, } from "@/lib/api/questions"; import { ModalCard } from "@/components/common/modal/ModalCard"; +import { ModalNotice } from "@/components/common/modal"; import { Toast } from "@/components/common/toast"; import { CreditInsufficientError, @@ -25,6 +27,64 @@ import type { JDData } from "@/components/mockApply/Question/SidePanel"; import { saveJobPostingAnalysis } from "@/app/mockApply/job/jobPostingDraftStore"; import { ModalOverlay } from "@/components/common/modal/ModalOverlay"; +const getSubmitPayload = (questionsData: QuestionItem[]) => + questionsData.map((question) => { + const payload = { + content: question.question, + charLimit: question.maxLength ?? 1000, + answer: question.answer || "", + }; + + if ( + question.questionId !== undefined && + Number.isInteger(question.questionId) && + question.questionId > 0 + ) { + return { ...payload, questionId: question.questionId }; + } + + return payload; + }); + +const getCurrentTime = () => { + const now = new Date(); + return `${now.getHours()}:${String(now.getMinutes()).padStart(2, "0")}`; +}; + +const mergeLocalQuestionEdits = ( + canonicalQuestions: QuestionItem[], + desiredQuestions: QuestionItem[], + liveQuestions: QuestionItem[], +) => + canonicalQuestions.map((canonicalQuestion, index) => { + const desiredQuestion = desiredQuestions[index]; + const latestQuestion = desiredQuestion + ? liveQuestions.find( + (question) => + question.id === desiredQuestion.id || + (question.questionId !== undefined && + question.questionId === desiredQuestion.questionId), + ) + : undefined; + + return { + ...canonicalQuestion, + question: + latestQuestion?.question ?? + desiredQuestion?.question ?? + canonicalQuestion.question, + maxLength: + latestQuestion?.maxLength ?? + desiredQuestion?.maxLength ?? + canonicalQuestion.maxLength, + answer: + latestQuestion?.answer ?? + desiredQuestion?.answer ?? + canonicalQuestion.answer ?? + "", + }; + }); + export default function MockApplyPage({ params, }: { @@ -63,14 +123,111 @@ export default function MockApplyPage({ const [isCreditShortModalOpen, setIsCreditShortModalOpen] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); - const getSubmitPayload = (questionsData: QuestionItem[]) => { - return questionsData.map((q) => ({ - questionId: q.questionId, - content: q.question, - charLimit: q.maxLength ?? 1000, - answer: q.answer || "", - })); - }; + const questionsRef = useRef([]); + const questionSaveQueueRef = useRef>(Promise.resolve()); + const questionSaveRevisionRef = useRef(0); + const isQuestionStructureSavingRef = useRef(false); + + const replaceQuestions = useCallback((nextQuestions: QuestionItem[]) => { + questionsRef.current = nextQuestions; + setQuestions(nextQuestions); + }, []); + + const enqueueQuestionSave = useCallback( + (operation: () => Promise): Promise => { + const queuedOperation = questionSaveQueueRef.current.then(operation); + questionSaveQueueRef.current = queuedOperation.then( + () => undefined, + () => undefined, + ); + return queuedOperation; + }, + [], + ); + + const syncQuestionStructure = useCallback( + async (desiredQuestions: QuestionItem[]) => { + if (isQuestionStructureSavingRef.current) { + throw new Error("문항을 저장하고 있습니다. 잠시 후 다시 시도해주세요."); + } + + isQuestionStructureSavingRef.current = true; + const revision = ++questionSaveRevisionRef.current; + let canonicalFallback: QuestionItem[] | null = null; + + try { + const savedQuestions = await enqueueQuestionSave(async () => { + const canonicalQuestions = await saveQuestions( + Number(mockApplyId), + desiredQuestions, + ); + + if (canonicalQuestions.length !== desiredQuestions.length) { + throw new Error("저장된 문항 목록을 확인하지 못했습니다."); + } + + const questionsWithLocalEdits = mergeLocalQuestionEdits( + canonicalQuestions, + desiredQuestions, + questionsRef.current, + ); + canonicalFallback = questionsWithLocalEdits; + + if (questionsWithLocalEdits.length === 0) { + return []; + } + + const savedApply = await saveApply( + Number(mockApplyId), + getSubmitPayload(questionsWithLocalEdits), + ); + const persistedQuestions = + savedApply.questions.length === questionsWithLocalEdits.length + ? savedApply.questions + : questionsWithLocalEdits; + + return mergeLocalQuestionEdits( + persistedQuestions, + desiredQuestions, + questionsRef.current, + ); + }); + + if (revision === questionSaveRevisionRef.current) { + replaceQuestions(savedQuestions); + } + + return savedQuestions; + } catch (error) { + if ( + canonicalFallback && + revision === questionSaveRevisionRef.current + ) { + const reconciledQuestions = mergeLocalQuestionEdits( + canonicalFallback, + desiredQuestions, + questionsRef.current, + ); + replaceQuestions(reconciledQuestions); + console.error( + "문항 구성은 저장됐지만 답변 저장을 다시 시도해야 합니다.", + error, + ); + return reconciledQuestions; + } + throw error; + } finally { + if (revision === questionSaveRevisionRef.current) { + isQuestionStructureSavingRef.current = false; + } + } + }, + [enqueueQuestionSave, mockApplyId, replaceQuestions], + ); + + useEffect(() => { + questionsRef.current = questions; + }, [questions]); // 1. 초기 데이터 불러오기 useEffect(() => { @@ -81,7 +238,66 @@ export default function MockApplyPage({ setIsQuestionsLoading(true); setQuestionsErrorMessage(""); - let data = await fetchSelectedQuestions(Number(mockApplyId)); + const parsedMockApplyId = Number(mockApplyId); + let data = await fetchSelectedQuestions(parsedMockApplyId); + + if (ignore) { + return; + } + + if (data.length === 0) { + const candidates = await fetchQuestions(parsedMockApplyId); + + if (ignore) { + return; + } + + const seenQuestionIds = new Set(); + const seenQuestionContents = new Set(); + const validCandidates = candidates.filter((question) => { + const normalizedContent = question.question + .trim() + .replace(/\s+/g, " "); + const questionId = question.questionId; + + if (!normalizedContent) { + return false; + } + if ( + (questionId && seenQuestionIds.has(questionId)) || + seenQuestionContents.has(normalizedContent) + ) { + return false; + } + + if (questionId) { + seenQuestionIds.add(questionId); + } + seenQuestionContents.add(normalizedContent); + return true; + }); + const preselectedCandidates = validCandidates.filter( + (question) => question.selected, + ); + const initialQuestions = ( + preselectedCandidates.length > 0 + ? preselectedCandidates + : validCandidates + ).slice(0, 5); + + if (initialQuestions.length > 0) { + const savedQuestions = await saveQuestions( + parsedMockApplyId, + initialQuestions, + ); + + if (savedQuestions.length === 0) { + throw new Error("저장된 자소서 문항을 확인하지 못했습니다."); + } + + data = savedQuestions; + } + } if (data.length > 5) { data = data.slice(0, 5); @@ -91,12 +307,12 @@ export default function MockApplyPage({ return; } - setQuestions(data); + replaceQuestions(data); setSelectedId(data[0]?.id ?? null); if (data.length === 0) { setQuestionsErrorMessage( - "공고에 맞는 자소서 문항을 불러오지 못했습니다.", + "등록된 문항이 없습니다. 문항 추가 버튼을 눌러 작성해주세요.", ); } } catch (error) { @@ -105,7 +321,7 @@ export default function MockApplyPage({ } console.error("문항을 불러오지 못했습니다.", error); - setQuestions([]); + replaceQuestions([]); setSelectedId(null); setQuestionsErrorMessage( error instanceof Error @@ -124,7 +340,7 @@ export default function MockApplyPage({ return () => { ignore = true; }; - }, [mockApplyId]); + }, [mockApplyId, replaceQuestions]); // 2. 연결된 채용 공고 불러오기 useEffect(() => { @@ -179,46 +395,77 @@ export default function MockApplyPage({ }; }, [mockApplyId]); - // 4. 토스트 타이머 + // 4. 토스트 자동 닫힘 useEffect(() => { - if (!toast.open || toast.variant !== "check") return; - const retryToastTimer = window.setTimeout(() => { + if (!toast.open) return; + + const toastTimer = window.setTimeout(() => { setToast({ open: false, message: "", variant: "normal" }); }, 3000); - return () => window.clearTimeout(retryToastTimer); - }, [toast.open, toast.variant]); + + return () => window.clearTimeout(toastTimer); + }, [toast.open, toast.message]); useEffect(() => { - if (questions.length === 0) return; + if ( + questions.length === 0 || + isQuestionStructureSavingRef.current + ) { + return; + } - const autoSaveTimer = window.setTimeout(async () => { - try { - await saveApply(Number(mockApplyId), getSubmitPayload(questions)); + const revision = questionSaveRevisionRef.current; + const questionsSnapshot = questions; + const autoSaveTimer = window.setTimeout(() => { + void enqueueQuestionSave(async () => { + if ( + revision !== questionSaveRevisionRef.current || + isQuestionStructureSavingRef.current + ) { + return; + } - const now = new Date(); - const timeString = `${now.getHours()}:${String( - now.getMinutes(), - ).padStart(2, "0")}`; - setLastSavedTime(timeString); - } catch (error) { - console.error("자동 저장 실패:", error); - } + await saveApply( + Number(mockApplyId), + getSubmitPayload(questionsSnapshot), + ); + + if (revision === questionSaveRevisionRef.current) { + setLastSavedTime(getCurrentTime()); + } + }).catch((error) => { + if (revision === questionSaveRevisionRef.current) { + console.error("자동 저장 실패:", error); + } + }); }, 2000); return () => window.clearTimeout(autoSaveTimer); - }, [questions, mockApplyId]); + }, [enqueueQuestionSave, questions, mockApplyId]); // --- 이벤트 핸들러 모음 --- const handleConfirm = async () => { if (isSubmitting) return; + if (isQuestionStructureSavingRef.current) { + setToast({ + open: true, + message: "문항 저장이 끝난 뒤 다시 시도해주세요.", + variant: "normal", + }); + return; + } + setIsSubmitting(true); setIsConfirmModalOpen(false); + questionSaveRevisionRef.current += 1; try { - const savedApply = await saveApply( - Number(mockApplyId), - getSubmitPayload(questions), + const savedApply = await enqueueQuestionSave(() => + saveApply( + Number(mockApplyId), + getSubmitPayload(questionsRef.current), + ), ); const acceptedAnalysis = await requestAnalysis(Number(mockApplyId)); const analysisTaskId = acceptedAnalysis.taskId?.trim(); @@ -267,34 +514,61 @@ export default function MockApplyPage({ : "채점 요청 중 오류가 발생했습니다.", variant: "normal", }); - window.setTimeout( - () => setToast({ open: false, message: "", variant: "normal" }), - 3000, - ); } }; - const performDelete = (targetId: string) => { - setQuestions((previousQuestions) => { - const targetIndex = previousQuestions.findIndex( - (question) => question.id === targetId, - ); - const nextQuestions = previousQuestions.filter( - (question) => question.id !== targetId, - ); + const performDelete = async (targetId: string) => { + if (isQuestionStructureSavingRef.current) { + setToast({ + open: true, + message: "문항을 저장하고 있어요. 잠시 후 다시 시도해주세요.", + variant: "normal", + }); + return; + } - if (selectedId === targetId) { - const nextSelectedIndex = Math.max(0, targetIndex - 1); - setSelectedId(nextQuestions[nextSelectedIndex]?.id ?? null); + const previousQuestions = questionsRef.current; + const targetIndex = previousQuestions.findIndex( + (question) => question.id === targetId, + ); + if (targetIndex < 0) return; + + const selectedIndex = previousQuestions.findIndex( + (question) => question.id === selectedId, + ); + const desiredQuestions = previousQuestions.filter( + (question) => question.id !== targetId, + ); + + try { + const savedQuestions = await syncQuestionStructure(desiredQuestions); + let nextSelectedIndex = selectedIndex; + + if (selectedIndex === targetIndex) { + nextSelectedIndex = Math.max(0, targetIndex - 1); + } else if (selectedIndex > targetIndex) { + nextSelectedIndex = selectedIndex - 1; } - return nextQuestions; - }); - setToast({ - open: true, - message: "문항이 삭제되었어요", - variant: "normal", - }); + setSelectedId(savedQuestions[nextSelectedIndex]?.id ?? null); + setQuestionsErrorMessage( + savedQuestions.length === 0 + ? "등록된 문항이 없습니다. 문항 추가 버튼을 눌러 작성해주세요." + : "", + ); + setToast({ + open: true, + message: "문항이 삭제되었어요", + variant: "normal", + }); + } catch (error) { + console.error("문항 삭제 실패:", error); + setToast({ + open: true, + message: "문항 삭제에 실패했어요. 잠시 후 다시 시도해주세요.", + variant: "normal", + }); + } }; const handleDeleteQuestion = (targetId: string) => { @@ -306,42 +580,52 @@ export default function MockApplyPage({ if (hasContent) { setModalTarget(targetId); } else { - performDelete(targetId); + void performDelete(targetId); } }; const handleUpdate = (field: string, value: string) => { if (!selectedId) return; - setQuestions((prev) => - prev.map((q) => { + setQuestions((previousQuestions) => { + const nextQuestions = previousQuestions.map((q) => { if (q.id !== selectedId) return q; if (field === "title") return { ...q, question: value }; if (field === "answer") return { ...q, answer: value }; if (field === "maxLength") return { ...q, maxLength: Number(value) }; return q; - }), - ); + }); + questionsRef.current = nextQuestions; + return nextQuestions; + }); }; const handleAddQuestion = async () => { + if ( + questionsRef.current.length >= 5 || + isQuestionStructureSavingRef.current + ) { + return; + } + try { const newQuestion: QuestionItem = { - id: `temp-${Date.now()}`, // 임시 ID - questionId: 0, - question: "", + id: `temp-${Date.now()}`, + question: "새로운 문항", answer: "", maxLength: 1000, + custom: true, }; - const updatedQuestions = [...questions, newQuestion]; - await saveQuestions(Number(mockApplyId), updatedQuestions); + const desiredQuestions = [...questionsRef.current, newQuestion]; + const savedQuestions = await syncQuestionStructure(desiredQuestions); - const refreshedQuestions = await fetchSelectedQuestions( - Number(mockApplyId), - ); - setQuestions(refreshedQuestions); + if (savedQuestions.length !== desiredQuestions.length) { + throw new Error("추가된 자소서 문항을 확인하지 못했습니다."); + } + + setQuestionsErrorMessage(""); - const lastQuestion = refreshedQuestions[refreshedQuestions.length - 1]; + const lastQuestion = savedQuestions[savedQuestions.length - 1]; if (lastQuestion) setSelectedId(lastQuestion.id); } catch (error) { console.error("문항 추가 실패:", error); @@ -378,21 +662,16 @@ export default function MockApplyPage({ nextLabel="채점하기" nextIconType="SPARKLE" > -
+
-
-