diff --git a/jobdri/src/app/credit/page.tsx b/jobdri/src/app/credit/page.tsx index cb54a76..d1129cd 100644 --- a/jobdri/src/app/credit/page.tsx +++ b/jobdri/src/app/credit/page.tsx @@ -74,7 +74,7 @@ function CreditContent() { )} -
+
{plans.map((plan) => ( - -
-
+
+ +
+
로딩 중...
}> diff --git a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx index fd5e176..4110023 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx @@ -1,9 +1,8 @@ "use client"; -import { useState, useEffect, useRef, 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"; -import SideHeaderContainer from "@/components/common/header/SideHeaderContainer"; import WritingForm from "@/components/mockApply/Question/WritingForm"; import clsx from "clsx"; import { scrollbarClassS } from "@/components/common/scrollbar/scrollbarStyles"; @@ -14,20 +13,77 @@ import { saveApply, type QuestionItem, } from "@/lib/api/questions"; -import { ModalCard } from "@/components/common/modal/ModalCard"; -import { Toast } from "@/components/common/toast"; +import { ModalNotice } from "@/components/common/modal"; +import { Toast, type ToastVariant } from "@/components/common/toast"; import { CreditInsufficientError, fetchSequence, 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"; -import { ModalOverlay } from "@/components/common/modal/ModalOverlay"; import { useDebounce } from "@/hooks/useDebounce"; +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, }: { @@ -39,26 +95,26 @@ export default function MockApplyPage({ const requestedJobPostingId = Number(searchParams.get("jobPostingId")); const hasRequestedJobPostingId = Number.isInteger(requestedJobPostingId) && requestedJobPostingId > 0; + const isRetryEntry = searchParams.get("retry") === "1"; const [isPanelOpen, setIsPanelOpen] = useState(false); const [questions, setQuestions] = useState([]); 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("저장 전"); - const isInitialRender = useRef(true); // 처음 데이터를 불러왔을 때 저장되는 것 방지 const [toast, setToast] = useState<{ open: boolean; message: string; - variant?: string; + variant: ToastVariant; }>({ open: false, message: "", variant: "normal" }); const [modalTarget, setModalTarget] = useState(null); @@ -66,15 +122,130 @@ export default function MockApplyPage({ const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); const [isCreditShortModalOpen, setIsCreditShortModalOpen] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); + const debouncedQuestions = useDebounce(questions, 1000); - 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 retryToastShownForRef = useRef(null); + + 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]); + + useEffect(() => { + if ( + !isRetryEntry || + retryToastShownForRef.current === mockApplyId + ) { + return; + } + + retryToastShownForRef.current = mockApplyId; + setToast({ + open: true, + message: "기존 내용이 유지되었어요. 수정하고 다시 채점해 보세요!", + variant: "check", + }); + }, [isRetryEntry, mockApplyId]); // 1. 초기 데이터 불러오기 useEffect(() => { @@ -85,7 +256,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); @@ -95,12 +325,12 @@ export default function MockApplyPage({ return; } - setQuestions(data); + replaceQuestions(data); setSelectedId(data[0]?.id ?? null); if (data.length === 0) { setQuestionsErrorMessage( - "공고에 맞는 자소서 문항을 불러오지 못했습니다.", + "등록된 문항이 없습니다. 문항 추가 버튼을 눌러 작성해주세요.", ); } } catch (error) { @@ -109,7 +339,7 @@ export default function MockApplyPage({ } console.error("문항을 불러오지 못했습니다.", error); - setQuestions([]); + replaceQuestions([]); setSelectedId(null); setQuestionsErrorMessage( error instanceof Error @@ -128,32 +358,25 @@ export default function MockApplyPage({ return () => { ignore = true; }; - }, [mockApplyId]); + }, [mockApplyId, replaceQuestions]); - // 2. 공고 시퀀스 불러오기 + // 2. 연결된 채용 공고 불러오기 useEffect(() => { - if (hasRequestedJobPostingId) return; - let ignore = false; - fetchSequence(Number(mockApplyId)) - .then((sequence) => { - if (!ignore) setSequenceJobPostingId(sequence.jobPostingId); - }) - .catch((error) => { - if (!ignore) - console.error("연결된 채용 공고를 확인하지 못했습니다.", error); - }); - return () => { - ignore = true; - }; - }, [hasRequestedJobPostingId, mockApplyId]); + const parsedMockApplyId = Number(mockApplyId); + + if (!Number.isInteger(parsedMockApplyId) || parsedMockApplyId <= 0) { + return; + } - // 3. JD 데이터 불러오기 - useEffect(() => { - if (!jobPostingId) return; let ignore = false; - fetchMyJobPosting(jobPostingId) + + fetchMockApplyJobPosting(parsedMockApplyId) .then((jobPosting) => { - if (ignore) return; + if (ignore) { + return; + } + + setLinkedJobPostingId(jobPosting.jobPostingId); setJdData({ companyName: jobPosting.companyName, profileColor: jobPosting.profileColor, @@ -188,28 +411,82 @@ export default function MockApplyPage({ return () => { ignore = true; }; - }, [jobPostingId]); + }, [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 ( + isQuestionsLoading || + debouncedQuestions.length === 0 || + isQuestionStructureSavingRef.current + ) { + return; + } + + const revision = questionSaveRevisionRef.current; + const questionsSnapshot = debouncedQuestions; + + void enqueueQuestionSave(async () => { + if ( + revision !== questionSaveRevisionRef.current || + isQuestionStructureSavingRef.current + ) { + return; + } + + await saveApply( + Number(mockApplyId), + getSubmitPayload(questionsSnapshot), + ); + + if (revision === questionSaveRevisionRef.current) { + setLastSavedTime(getCurrentTime()); + } + }).catch((error) => { + if (revision === questionSaveRevisionRef.current) { + console.error("자동 저장 실패:", error); + } + }); + }, [ + debouncedQuestions, + enqueueQuestionSave, + isQuestionsLoading, + 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(); @@ -258,15 +535,61 @@ export default function MockApplyPage({ : "채점 요청 중 오류가 발생했습니다.", variant: "normal", }); - window.setTimeout( - () => setToast({ open: false, message: "", variant: "normal" }), - 3000, - ); } }; const performDelete = async (targetId: string) => { - setQuestions(questions.filter((q) => q.id !== targetId)); + if (isQuestionStructureSavingRef.current) { + setToast({ + open: true, + message: "문항을 저장하고 있어요. 잠시 후 다시 시도해주세요.", + variant: "normal", + }); + return; + } + + 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; + } + + 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) => { @@ -278,42 +601,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); @@ -337,40 +670,6 @@ export default function MockApplyPage({ !mappedQuestionForForm || questions.some((question) => !(question.answer || "").trim()); - // ✅ 1. questions 배열을 1초 딜레이시키는 디바운스 생성 - const debouncedQuestions = useDebounce(questions, 1000); - - // ✅ 2. 1초 뒤에 갱신된 debouncedQuestions를 감지해서 API 호출! - useEffect(() => { - // 로딩 중이거나 데이터가 없으면 무시 - if (isQuestionsLoading || debouncedQuestions.length === 0) return; - - // 첫 마운트(데이터 패칭 직후) 시점에는 자동저장 방지 - if (isInitialRender.current) { - isInitialRender.current = false; - return; - } - - const autoSave = async () => { - try { - // 방금 파악하신 대로 saveApply 사용! - await saveApply( - Number(mockApplyId), - getSubmitPayload(debouncedQuestions), - ); - - const now = new Date(); - setLastSavedTime( - `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`, - ); - } catch (error) { - console.error("자소서 자동 저장 실패:", error); - } - }; - - autoSave(); - }, [debouncedQuestions, mockApplyId, isQuestionsLoading]); - return ( -
+
-
-