diff --git a/jobdri/src/app/credit/page.tsx b/jobdri/src/app/credit/page.tsx index 3ba1fe2..817e4b7 100644 --- a/jobdri/src/app/credit/page.tsx +++ b/jobdri/src/app/credit/page.tsx @@ -10,7 +10,6 @@ import { type CreditPlan, } from "@/lib/api/credit"; import { Lnb } from "@/components/common/lnb"; -import Header from "@/components/common/header/Header"; import PageHeader from "@/components/common/PageHeader"; function calcDiscountRate(plan: CreditPlan, basePricePerUnit: number): string { @@ -44,28 +43,30 @@ export default function CreditPage() { plans.find((p) => p.planCode === "ONE_TIME")?.price ?? 2500; return ( -
- -
- -
- {plans.map((plan) => ( - - ))} -
-
- -
-
+
+ +
+
+ +
+ {plans.map((plan) => ( + + ))} +
+
+ +
+
+
); } diff --git a/jobdri/src/app/mockApply/[mockApplyId]/(jd)/jd-review/page.tsx b/jobdri/src/app/mockApply/[mockApplyId]/(jd)/jd-review/page.tsx index fbb3eb6..cf66033 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/(jd)/jd-review/page.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/(jd)/jd-review/page.tsx @@ -347,7 +347,7 @@ export default function MockApplicationJdReviewPage() { {showBackConfirm && (
+ 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, @@ -38,25 +94,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 [toast, setToast] = useState<{ open: boolean; message: string; - variant?: string; + variant: ToastVariant; }>({ open: false, message: "", variant: "normal" }); const [modalTarget, setModalTarget] = useState(null); @@ -65,14 +122,128 @@ 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 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(() => { @@ -83,7 +254,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); @@ -93,12 +323,12 @@ export default function MockApplyPage({ return; } - setQuestions(data); + replaceQuestions(data); setSelectedId(data[0]?.id ?? null); if (data.length === 0) { setQuestionsErrorMessage( - "공고에 맞는 자소서 문항을 불러오지 못했습니다.", + "등록된 문항이 없습니다. 문항 추가 버튼을 눌러 작성해주세요.", ); } } catch (error) { @@ -107,7 +337,7 @@ export default function MockApplyPage({ } console.error("문항을 불러오지 못했습니다.", error); - setQuestions([]); + replaceQuestions([]); setSelectedId(null); setQuestionsErrorMessage( error instanceof Error @@ -126,32 +356,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, @@ -186,28 +409,79 @@ 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 ( + questions.length === 0 || + isQuestionStructureSavingRef.current + ) { + return; + } + + const revision = questionSaveRevisionRef.current; + const questionsSnapshot = questions; + const autoSaveTimer = window.setTimeout(() => { + 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); + } + }); + }, 2000); + + return () => window.clearTimeout(autoSaveTimer); + }, [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(); @@ -256,15 +530,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) => { @@ -276,42 +596,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("추가된 자소서 문항을 확인하지 못했습니다."); + } - const lastQuestion = refreshedQuestions[refreshedQuestions.length - 1]; + setQuestionsErrorMessage(""); + + const lastQuestion = savedQuestions[savedQuestions.length - 1]; if (lastQuestion) setSelectedId(lastQuestion.id); } catch (error) { console.error("문항 추가 실패:", error); @@ -348,21 +678,16 @@ export default function MockApplyPage({ nextLabel="채점하기" nextIconType="SPARKLE" > -
+
-
-