From 9325e705bb0f67a29a832f6ae3affc8ac08d28ff Mon Sep 17 00:00:00 2001 From: mingo Date: Tue, 21 Jul 2026 23:17:55 +0900 Subject: [PATCH 01/10] =?UTF-8?q?Feat:=20job=20posting=20ai=20api=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99[JDDEV-43]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 0 -> 6148 bytes jobdri/src/lib/api/jobPostings.ts | 28 ++++++++++++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..9fff8df44c921fe785350aaba2c2f87b9726a381 GIT binary patch literal 6148 zcmeHKPfrs;6n_H+wji=Vix4&0*oz4b(jap1;8Gxl7z1eumH?LB?ot=FGj(@MMMBcM z-u(c6026QC^aJS8s|UY;7ti?SPXiS_YQ*eI-u&Lad2eRkubG(+0FZpc$pPp9prLY1 z^kef2;r(3CNtg2UAQCY~0N%3CDz?w;bJ4LQW*}zZUt@sp-Co!O1~xd*xqtbgkVl<5 z5h9k}5oFv|{v;l3q_=Gi!krg$4ZF=XqJM;+G8vj%lSkz14Wi+o%S!eS zTpGMQqz{jb4yT6G>Gar@@rjYE*UI{+ZB_QG0blL{^I4Gh8*W)23bE+r3Ztumdw|83 zBKKKT^qyna$WI9c*Li&ET05*`OzPAj2E8A+F5RpKp0l#M8@f^HjJ?5Us|3vVS6S#q z9-|vuV)n?bEz@0wGx8Oz)OlC9?gk;^xt!956$BQoy7|Lx-?CkQh2~ilv0BN)D)`F9 zl*PJL@n|)3@0(;YnK5rlONUIGTrJhM6q3w)W(s?aGU{T|s_e;_4^)T-lQ0iO*n+3f zf>-bw-oOX=1YhAhoRC2>MsAQvk|ncbj+kVg6v!soAr{$_`CaWF`nnoV0SVFQ_@NJq z0reTJqMzp^p#V!b^CCP%oPruSXYMk-7v7?t-3$16p+zyJ4P59d;>E`d#0>li2Kf14 zpmH=7wj_$90~_%KKun-p2-^Ipmb43NZPagjgt z)!lloIDXd}s5_`!cw9@ONWn&3$HL%O@i?jw>@&E5Xew+;L=Q^*M?laJH!%Z$m4P3a Co8%k- literal 0 HcmV?d00001 diff --git a/jobdri/src/lib/api/jobPostings.ts b/jobdri/src/lib/api/jobPostings.ts index fdead38..b82368d 100644 --- a/jobdri/src/lib/api/jobPostings.ts +++ b/jobdri/src/lib/api/jobPostings.ts @@ -84,13 +84,20 @@ export interface JobPostingIngestStatus { status: string; message: string; error: string | null; + failureReason: string | null; + workerId: string | null; + retryCount: number; + maxRetryCount: number; + queueLatencyMillis: number; createdAt: string; + submittedAt: string | null; + lastAttemptAt: string | null; startedAt: string | null; completedAt: string | null; result: JobPostingProcessedResult | null; } -interface JobPostingRequestInput { +export interface JobPostingRequestInput { rawText?: string; sourceUrl?: string; imageObjectKey?: string; @@ -392,13 +399,26 @@ export async function waitForJobPostingIngest( for (let attempt = 0; attempt < maxAttempts; attempt += 1) { const status = await fetchJobPostingIngestStatus(taskId); - if (status.result || isCompletedStatus(status.status)) { + if (status.result) { return status; } - if (status.error || isFailedStatus(status.status)) { + if ( + status.error || + status.failureReason || + isFailedStatus(status.status) + ) { throw new Error( - status.error || status.message || "채용 공고 처리에 실패했습니다.", + status.failureReason || + status.error || + status.message || + "채용 공고 처리에 실패했습니다.", + ); + } + + if (isCompletedStatus(status.status)) { + throw new Error( + status.message || "완료된 채용 공고 처리 결과를 확인할 수 없습니다.", ); } From cc4b172b0de7905224871a94206bc8a6e641bec8 Mon Sep 17 00:00:00 2001 From: mingo Date: Tue, 21 Jul 2026 23:57:15 +0900 Subject: [PATCH 02/10] =?UTF-8?q?Feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=8F=20=EC=B1=84=EC=9A=A9?= =?UTF-8?q?=EA=B3=B5=EA=B3=A0=20=EB=B6=84=EC=84=9D=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20[JDDEV-43]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(jd)/jd-input/JdInputPageClient.tsx | 49 ++++++++++++--- jobdri/src/app/mockApply/job/create/page.tsx | 5 +- .../app/mockApply/job/jobPostingDraftStore.ts | 14 +++++ jobdri/src/app/mockApply/job/loading/page.tsx | 60 +++++++++++++++---- jobdri/src/app/mockApply/job/review/page.tsx | 56 +++++++++++++++-- .../src/components/common/input/LLMInput.tsx | 21 ++++++- jobdri/src/lib/api/jobPostings.ts | 8 ++- 7 files changed, 182 insertions(+), 31 deletions(-) diff --git a/jobdri/src/app/mockApply/[mockApplyId]/(jd)/jd-input/JdInputPageClient.tsx b/jobdri/src/app/mockApply/[mockApplyId]/(jd)/jd-input/JdInputPageClient.tsx index 10a467e..f5d6b59 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/(jd)/jd-input/JdInputPageClient.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/(jd)/jd-input/JdInputPageClient.tsx @@ -248,15 +248,46 @@ const JdInputPageClient = forwardRef< const moveToJdReviewWithResult = (status: JobPostingIngestStatus) => { const result = status.result; - const jobPosting = result?.generated ?? result?.extracted; - - if (!jobPosting) { - throw new Error("추출된 공고 정보를 확인할 수 없습니다."); + const generated = result?.generated; + const extracted = result?.extracted; + const saved = result?.saved; + const classification = result?.classification; + const firstNonEmpty = (...values: Array) => + values.find((value) => value?.trim())?.trim() ?? ""; + const jobPosting = { + companyName: firstNonEmpty( + generated?.companyName, + extracted?.companyName, + saved?.companyName, + ), + jobTitle: firstNonEmpty( + generated?.jobTitle, + extracted?.jobTitle, + classification?.detailClassificationName, + ), + task: firstNonEmpty(generated?.task, extracted?.task, saved?.task), + requirements: firstNonEmpty( + generated?.requirements, + extracted?.requirements, + saved?.requirement, + ), + preferredQualifications: firstNonEmpty( + generated?.preferredQualifications, + extracted?.preferredQualifications, + saved?.preferred, + ), + }; + + if (!Object.values(jobPosting).some(Boolean)) { + throw new Error( + result?.message || + "이미지에서 공고 정보를 추출하지 못했습니다. 더 선명한 이미지를 사용해주세요.", + ); } const detailClassificationId = - result?.saved?.detailClassificationId ?? - result?.classification?.detailClassificationId ?? + saved?.detailClassificationId ?? + classification?.detailClassificationId ?? result?.candidates?.[0]?.detailClassificationId ?? 0; @@ -267,15 +298,15 @@ const JdInputPageClient = forwardRef< window.sessionStorage.setItem( getJdReviewMetadataStorageKey(id), JSON.stringify({ - companySize: result?.saved?.companySize ?? "STARTUP", + companySize: saved?.companySize ?? "STARTUP", detailClassificationId, }), ); - if (result?.saved) { + if (saved) { window.sessionStorage.setItem( getJdReviewSavedStorageKey(id), - JSON.stringify(result.saved), + JSON.stringify(saved), ); } else { window.sessionStorage.removeItem(getJdReviewSavedStorageKey(id)); diff --git a/jobdri/src/app/mockApply/job/create/page.tsx b/jobdri/src/app/mockApply/job/create/page.tsx index 8069717..563333e 100644 --- a/jobdri/src/app/mockApply/job/create/page.tsx +++ b/jobdri/src/app/mockApply/job/create/page.tsx @@ -94,12 +94,13 @@ export default function JobPostingCreatePage() { useEffect(() => { const searchParams = new URLSearchParams(window.location.search); + const analysisError = searchParams.get("analysisError"); const toastMessage = searchParams.get("analysisCanceled") === "1" ? "공고 분석을 중단했습니다." - : searchParams.get("analysisError") === "1" + : analysisError === "1" ? "업로드에 실패했습니다." - : null; + : analysisError; if (!toastMessage) { return; diff --git a/jobdri/src/app/mockApply/job/jobPostingDraftStore.ts b/jobdri/src/app/mockApply/job/jobPostingDraftStore.ts index 7c68037..bdd37bf 100644 --- a/jobdri/src/app/mockApply/job/jobPostingDraftStore.ts +++ b/jobdri/src/app/mockApply/job/jobPostingDraftStore.ts @@ -1,5 +1,7 @@ "use client"; +import type { JobPostingProcessedResult } from "@/lib/api/jobPostings"; + interface JobPostingDraft { files: File[]; value: string; @@ -10,6 +12,8 @@ let jobPostingDraft: JobPostingDraft = { value: "", }; +let jobPostingAnalysis: JobPostingProcessedResult | null = null; + export function getJobPostingDraft() { return jobPostingDraft; } @@ -19,6 +23,15 @@ export function saveJobPostingDraft(draft: JobPostingDraft) { files: draft.files, value: draft.value, }; + jobPostingAnalysis = null; +} + +export function getJobPostingAnalysis() { + return jobPostingAnalysis; +} + +export function saveJobPostingAnalysis(result: JobPostingProcessedResult) { + jobPostingAnalysis = result; } export function clearJobPostingDraft() { @@ -26,4 +39,5 @@ export function clearJobPostingDraft() { files: [], value: "", }; + jobPostingAnalysis = null; } diff --git a/jobdri/src/app/mockApply/job/loading/page.tsx b/jobdri/src/app/mockApply/job/loading/page.tsx index b655f53..8c519e6 100644 --- a/jobdri/src/app/mockApply/job/loading/page.tsx +++ b/jobdri/src/app/mockApply/job/loading/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import clsx from "clsx"; import Header from "@/components/common/header/Header"; @@ -10,7 +10,16 @@ import { ModalNotice } from "@/components/common/modal"; import LoadingGraphic from "@/components/mockApply/LoadingGraphic"; import questionLoadingBook from "@/assets/lottie/question-loading-book.json"; import questionLoadingSparkle from "@/assets/lottie/question-loading-sparkle.json"; -import { clearJobPostingDraft } from "../jobPostingDraftStore"; +import { + clearJobPostingDraft, + getJobPostingDraft, + saveJobPostingAnalysis, +} from "../jobPostingDraftStore"; +import { + ingestJobPosting, + uploadJobPostingImage, + waitForJobPostingIngest, +} from "@/lib/api/jobPostings"; const MIN_LOADING_DURATION_MS = 50_000; const MAX_LOADING_DURATION_MS = 60_000; @@ -109,20 +118,49 @@ export default function JobPostingLoadingPage() { const router = useRouter(); const [loadingDurationMs] = useState(createRandomLoadingDurationMs); const [showStopConfirm, setShowStopConfirm] = useState(false); + const hasStartedAnalysisRef = useRef(false); useEffect(() => { - if (showStopConfirm) { + if (hasStartedAnalysisRef.current) { return; } - - const completionTimer = window.setTimeout(() => { - router.replace("/mockApply/job/review"); - }, loadingDurationMs); - - return () => { - window.clearTimeout(completionTimer); + hasStartedAnalysisRef.current = true; + + const analyzeJobPosting = async () => { + try { + const draft = getJobPostingDraft(); + const rawText = draft.value.trim() || undefined; + const imageObjectKey = draft.files[0] + ? await uploadJobPostingImage(draft.files[0]) + : undefined; + + if (!rawText && !imageObjectKey) { + throw new Error("분석할 채용 공고가 없습니다."); + } + + const accepted = await ingestJobPosting({ rawText, imageObjectKey }); + const status = await waitForJobPostingIngest(accepted.taskId); + + if (!status.result) { + throw new Error("채용 공고 분석 결과를 확인할 수 없습니다."); + } + + saveJobPostingAnalysis(status.result); + router.replace("/mockApply/job/review"); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "채용 공고 분석에 실패했습니다."; + + router.replace( + `/mockApply/job/create?analysisError=${encodeURIComponent(message)}`, + ); + } }; - }, [loadingDurationMs, router, showStopConfirm]); + + void analyzeJobPosting(); + }, [router]); const closeStopConfirm = () => setShowStopConfirm(false); const stopAnalysis = () => { diff --git a/jobdri/src/app/mockApply/job/review/page.tsx b/jobdri/src/app/mockApply/job/review/page.tsx index d987727..dac0c89 100644 --- a/jobdri/src/app/mockApply/job/review/page.tsx +++ b/jobdri/src/app/mockApply/job/review/page.tsx @@ -13,6 +13,11 @@ import { CtaFooter } from "@/components/common/cta"; import { JDInput } from "@/components/common/input"; import { ModalNotice } from "@/components/common/modal"; import Avatar from "@/components/mockApply/home/Avatar"; +import { getJobPostingAnalysis } from "../jobPostingDraftStore"; + +function firstNonEmpty(...values: Array) { + return values.find((value) => value?.trim())?.trim() ?? ""; +} const wizardSteps = [ { label: "공고 확인" }, @@ -87,9 +92,44 @@ function JobProfileRow({ avatarName }: { avatarName: string }) { export default function JobPostingReviewPage() { const router = useRouter(); - const [jobPostingName, setJobPostingName] = useState(""); - const [companyName, setCompanyName] = useState(""); - const [roleName, setRoleName] = useState(""); + const [initialValues] = useState(() => { + const result = getJobPostingAnalysis(); + const generated = result?.generated; + const extracted = result?.extracted; + const saved = result?.saved; + + return { + companyName: firstNonEmpty( + generated?.companyName, + extracted?.companyName, + saved?.companyName, + ), + jobTitle: firstNonEmpty( + generated?.jobTitle, + extracted?.jobTitle, + result?.classification?.detailClassificationName, + ), + task: firstNonEmpty(generated?.task, extracted?.task, saved?.task), + requirements: firstNonEmpty( + generated?.requirements, + extracted?.requirements, + saved?.requirement, + ), + preferred: firstNonEmpty( + generated?.preferredQualifications, + extracted?.preferredQualifications, + saved?.preferred, + ), + }; + }); + const [jobPostingName, setJobPostingName] = useState(initialValues.jobTitle); + const [companyName, setCompanyName] = useState(initialValues.companyName); + const [roleName, setRoleName] = useState(initialValues.jobTitle); + const [task, setTask] = useState(initialValues.task); + const [requirements, setRequirements] = useState( + initialValues.requirements, + ); + const [preferred, setPreferred] = useState(initialValues.preferred); const [showBackConfirm, setShowBackConfirm] = useState(false); const [showHomeConfirm, setShowHomeConfirm] = useState(false); const { scrollAreaRef, scrollbarMetrics, updateScrollbarMetrics } = @@ -115,8 +155,8 @@ export default function JobPostingReviewPage() {
@@ -184,11 +226,15 @@ export default function JobPostingReviewPage() { diff --git a/jobdri/src/components/common/input/LLMInput.tsx b/jobdri/src/components/common/input/LLMInput.tsx index c63f958..7cc9fe7 100644 --- a/jobdri/src/components/common/input/LLMInput.tsx +++ b/jobdri/src/components/common/input/LLMInput.tsx @@ -94,6 +94,9 @@ export function LLMInput({ const rootRef = useRef(null); const fileInputRef = useRef(null); const imagePreviewsRef = useRef([]); + const previewCleanupTimerRef = useRef | null>(null); const imageLimitToastTimerRef = useRef | null>( null, ); @@ -149,10 +152,22 @@ export function LLMInput({ }, [imagePreviews]); useEffect(() => { + if (previewCleanupTimerRef.current) { + clearTimeout(previewCleanupTimerRef.current); + previewCleanupTimerRef.current = null; + } + return () => { - imagePreviewsRef.current.forEach((imagePreview) => { - URL.revokeObjectURL(imagePreview.url); - }); + const previewUrls = imagePreviewsRef.current.map( + (imagePreview) => imagePreview.url, + ); + + previewCleanupTimerRef.current = setTimeout(() => { + previewUrls.forEach((previewUrl) => { + URL.revokeObjectURL(previewUrl); + }); + }, 0); + if (imageLimitToastTimerRef.current) { clearTimeout(imageLimitToastTimerRef.current); } diff --git a/jobdri/src/lib/api/jobPostings.ts b/jobdri/src/lib/api/jobPostings.ts index b82368d..71cb872 100644 --- a/jobdri/src/lib/api/jobPostings.ts +++ b/jobdri/src/lib/api/jobPostings.ts @@ -278,7 +278,7 @@ async function uploadImageToPresignedUrl({ } } -export async function ingestJobPostingImage(file: File) { +export async function uploadJobPostingImage(file: File) { const contentType = getImageContentType(file); const { objectKey, uploadUrl } = await createJobPostingImagePresignedUploadUrl({ @@ -292,6 +292,12 @@ export async function ingestJobPostingImage(file: File) { contentType, }); + return objectKey; +} + +export async function ingestJobPostingImage(file: File) { + const objectKey = await uploadJobPostingImage(file); + return ingestJobPosting({ imageObjectKey: objectKey }); } From 8901504abfa44558649882933b86dee288f28663 Mon Sep 17 00:00:00 2001 From: mingo Date: Wed, 22 Jul 2026 01:11:04 +0900 Subject: [PATCH 03/10] =?UTF-8?q?Fix:=20=ED=99=88=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EC=8B=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=ED=97=A4=EC=A0=9C=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?[JDDEV-43]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jobdri/src/app/mockApply/job/create/page.tsx | 9 +++++++++ jobdri/src/app/mockApply/job/review/page.tsx | 4 ++-- jobdri/src/app/page.tsx | 4 +++- jobdri/src/components/common/lnb/Lnb.tsx | 6 ++++-- jobdri/src/lib/api/client.ts | 3 ++- jobdri/src/lib/api/credit.ts | 18 ++++++++++++++---- jobdri/src/lib/api/mockApplies.ts | 7 ++++++- 7 files changed, 40 insertions(+), 11 deletions(-) diff --git a/jobdri/src/app/mockApply/job/create/page.tsx b/jobdri/src/app/mockApply/job/create/page.tsx index 563333e..c0e70c3 100644 --- a/jobdri/src/app/mockApply/job/create/page.tsx +++ b/jobdri/src/app/mockApply/job/create/page.tsx @@ -133,6 +133,15 @@ export default function JobPostingCreatePage() { event.preventDefault(); event.stopPropagation(); + const hasInput = + jobPostingInputValue.trim().length > 0 || attachedFiles.length > 0; + + if (!hasInput) { + clearJobPostingDraft(); + router.push("/"); + return; + } + setShowExitConfirm(true); }; diff --git a/jobdri/src/app/mockApply/job/review/page.tsx b/jobdri/src/app/mockApply/job/review/page.tsx index dac0c89..5ad67a3 100644 --- a/jobdri/src/app/mockApply/job/review/page.tsx +++ b/jobdri/src/app/mockApply/job/review/page.tsx @@ -172,9 +172,9 @@ export default function JobPostingReviewPage() {
-
+
{ const loadMockApplies = async () => { try { - const data = await fetchMyMockApplies(); + const data = await fetchMyMockApplies({ + redirectOnUnauthorized: false, + }); const mappedDrafts = data.inProgress.map((item) => ({ id: String(item.mockApplyId), diff --git a/jobdri/src/components/common/lnb/Lnb.tsx b/jobdri/src/components/common/lnb/Lnb.tsx index a7c92bf..45ba3ec 100644 --- a/jobdri/src/components/common/lnb/Lnb.tsx +++ b/jobdri/src/components/common/lnb/Lnb.tsx @@ -95,7 +95,9 @@ export default function Lnb({ useEffect(() => { const loadData = async () => { try { - const data = await fetchMyMockApplies(); + const data = await fetchMyMockApplies({ + redirectOnUnauthorized: false, + }); const allItems = [...data.inProgress, ...data.completed]; const mappedItems: LnbRecentItem[] = allItems.map((item) => ({ @@ -122,7 +124,7 @@ export default function Lnb({ // Credit Fetch useEffect(() => { if (disableCreditFetch) return; - fetchCreditBalance() + fetchCreditBalance({ redirectOnUnauthorized: false }) .then(setCreditCount) .catch(() => {}); }, [disableCreditFetch]); diff --git a/jobdri/src/lib/api/client.ts b/jobdri/src/lib/api/client.ts index 535571b..fc5446e 100644 --- a/jobdri/src/lib/api/client.ts +++ b/jobdri/src/lib/api/client.ts @@ -32,8 +32,9 @@ export function handleUnauthorized(): never { export async function parseApiResponse( response: Response, fallbackMessage: string, + { redirectOnUnauthorized = true } = {}, ): Promise { - if (response.status === 401) { + if (response.status === 401 && redirectOnUnauthorized) { handleUnauthorized(); } diff --git a/jobdri/src/lib/api/credit.ts b/jobdri/src/lib/api/credit.ts index b33ea62..1e2cdd4 100644 --- a/jobdri/src/lib/api/credit.ts +++ b/jobdri/src/lib/api/credit.ts @@ -19,16 +19,26 @@ interface ApiResponse { error: string | null; } -function checkResponse(response: Response, fallbackMessage: string): void { - if (response.status === 401) handleUnauthorized(); +function checkResponse( + response: Response, + fallbackMessage: string, + redirectOnUnauthorized = true, +): void { + if (response.status === 401 && redirectOnUnauthorized) handleUnauthorized(); if (!response.ok) throw new Error(fallbackMessage); } -export async function fetchCreditBalance(): Promise { +export async function fetchCreditBalance({ + redirectOnUnauthorized = true, +}: { redirectOnUnauthorized?: boolean } = {}): Promise { const response = await fetch(`${BASE_URL}/api/payments/credits/me`, { headers: getAuthHeaders(), }); - checkResponse(response, "크레딧 잔액 조회에 실패했습니다."); + checkResponse( + response, + "크레딧 잔액 조회에 실패했습니다.", + redirectOnUnauthorized, + ); const { result }: ApiResponse<{ creditBalance: number }> = await response.json(); return result.creditBalance; diff --git a/jobdri/src/lib/api/mockApplies.ts b/jobdri/src/lib/api/mockApplies.ts index 74e8260..6e15f02 100644 --- a/jobdri/src/lib/api/mockApplies.ts +++ b/jobdri/src/lib/api/mockApplies.ts @@ -102,7 +102,11 @@ export function createApplyFromJobPosting({ export async function fetchMyMockApplies({ signal, -}: { signal?: AbortSignal } = {}) { + redirectOnUnauthorized = true, +}: { + signal?: AbortSignal; + redirectOnUnauthorized?: boolean; +} = {}) { const response = await fetch(`${API_BASE_URL}/api/mock-applies/me`, { headers: getAuthHeaders(), cache: "no-store", @@ -112,6 +116,7 @@ export async function fetchMyMockApplies({ const result = await parseApiResponse( response, "내 지원 데이터를 불러오지 못했습니다.", + { redirectOnUnauthorized }, ); return { From 82dda24cf852891412b7a2edfcd7365f34844586 Mon Sep 17 00:00:00 2001 From: mingo Date: Wed, 22 Jul 2026 19:54:46 +0900 Subject: [PATCH 04/10] =?UTF-8?q?Feat:=20job=20posting=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5,=20=EC=A1=B0=ED=9A=8C,=20=EC=88=98=EC=A0=95,=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20api=20=EC=97=B0=EB=8F=99=20[JDDEV-43]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jobdri/src/app/mockApply/job/review/page.tsx | 78 ++++++++++++++- jobdri/src/app/page.tsx | 99 +++++++++++++++++-- jobdri/src/components/mockApply/home/types.ts | 2 + jobdri/src/lib/api/jobPostings.ts | 27 ++++- 4 files changed, 195 insertions(+), 11 deletions(-) diff --git a/jobdri/src/app/mockApply/job/review/page.tsx b/jobdri/src/app/mockApply/job/review/page.tsx index 5ad67a3..8e6a248 100644 --- a/jobdri/src/app/mockApply/job/review/page.tsx +++ b/jobdri/src/app/mockApply/job/review/page.tsx @@ -13,7 +13,16 @@ import { CtaFooter } from "@/components/common/cta"; import { JDInput } from "@/components/common/input"; import { ModalNotice } from "@/components/common/modal"; import Avatar from "@/components/mockApply/home/Avatar"; -import { getJobPostingAnalysis } from "../jobPostingDraftStore"; +import { + clearJobPostingDraft, + getJobPostingAnalysis, +} from "../jobPostingDraftStore"; +import { + saveJobPosting, + updateJobPosting, + type JobPostingSavePayload, +} from "@/lib/api/jobPostings"; +import { createMockApplyFromJobPosting } from "@/lib/api/mockApplies"; function firstNonEmpty(...values: Array) { return values.find((value) => value?.trim())?.trim() ?? ""; @@ -108,6 +117,7 @@ export default function JobPostingReviewPage() { generated?.jobTitle, extracted?.jobTitle, result?.classification?.detailClassificationName, + saved?.detailClassificationName, ), task: firstNonEmpty(generated?.task, extracted?.task, saved?.task), requirements: firstNonEmpty( @@ -120,6 +130,13 @@ export default function JobPostingReviewPage() { extracted?.preferredQualifications, saved?.preferred, ), + companySize: saved?.companySize?.trim() || "STARTUP", + detailClassificationId: + saved?.detailClassificationId ?? + result?.classification?.detailClassificationId ?? + result?.candidates?.[0]?.detailClassificationId ?? + 0, + jobPostingId: saved?.jobPostingId ?? null, }; }); const [jobPostingName, setJobPostingName] = useState(initialValues.jobTitle); @@ -132,6 +149,8 @@ export default function JobPostingReviewPage() { const [preferred, setPreferred] = useState(initialValues.preferred); const [showBackConfirm, setShowBackConfirm] = useState(false); const [showHomeConfirm, setShowHomeConfirm] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [saveErrorMessage, setSaveErrorMessage] = useState(""); const { scrollAreaRef, scrollbarMetrics, updateScrollbarMetrics } = useLnbScrollMetrics(true, "job-posting-review", { trackPadding: 28 }); const companyAvatarName = useMemo(() => { @@ -150,6 +169,45 @@ export default function JobPostingReviewPage() { event.preventDefault(); setShowHomeConfirm(true); }; + const handleNext = async () => { + if (!isNextEnabled || isSaving) { + return; + } + + setIsSaving(true); + setSaveErrorMessage(""); + + try { + if (initialValues.detailClassificationId <= 0) { + throw new Error("직무 분류 정보가 없어 공고를 저장할 수 없습니다."); + } + + const payload: JobPostingSavePayload = { + companyName: companyName.trim(), + companySize: initialValues.companySize, + detailClassificationId: initialValues.detailClassificationId, + task: task.trim(), + requirement: requirements.trim(), + preferred: preferred.trim(), + }; + const savedJobPosting = initialValues.jobPostingId + ? await updateJobPosting(initialValues.jobPostingId, payload) + : await saveJobPosting(payload); + const createdApply = await createMockApplyFromJobPosting( + savedJobPosting.jobPostingId, + ); + + clearJobPostingDraft(); + router.push(`/mockApply/${createdApply.mockApplyId}`); + } catch (error) { + setSaveErrorMessage( + error instanceof Error + ? error.message + : "채용 공고를 저장하지 못했습니다.", + ); + setIsSaving(false); + } + }; return (
@@ -260,7 +318,8 @@ export default function JobPostingReviewPage() { }} nextAction={{ label: "다음으로", - disabled: !isNextEnabled, + disabled: !isNextEnabled || isSaving, + onClick: () => void handleNext(), }} />
@@ -303,6 +362,21 @@ export default function JobPostingReviewPage() { />
)} + + {saveErrorMessage && ( +
+ setSaveErrorMessage("")} + primaryAction={{ + label: "확인", + onClick: () => setSaveErrorMessage(""), + }} + /> +
+ )}
); } diff --git a/jobdri/src/app/page.tsx b/jobdri/src/app/page.tsx index 1128d88..42f51ec 100644 --- a/jobdri/src/app/page.tsx +++ b/jobdri/src/app/page.tsx @@ -7,6 +7,12 @@ import { Lnb } from "@/components/common/lnb"; import ResultDraftList from "@/components/mockApply/home/ResultDraftList"; import ResultApplicationList from "@/components/mockApply/home/ResultApplicationList"; import { fetchMyMockApplies } from "@/lib/api/mockApplies"; +import { + deleteJobPosting, + fetchMyJobPosting, + fetchMyJobPostings, +} from "@/lib/api/jobPostings"; +import { saveJobPostingAnalysis } from "@/app/mockApply/job/jobPostingDraftStore"; import { formatDate } from "@/utils/date"; import { DraftData, @@ -57,18 +63,55 @@ export default function Home() { useEffect(() => { const loadMockApplies = async () => { try { - const data = await fetchMyMockApplies({ - redirectOnUnauthorized: false, - }); + const [data, jobPostings] = await Promise.all([ + fetchMyMockApplies({ redirectOnUnauthorized: false }), + fetchMyJobPostings({ redirectOnUnauthorized: false }).catch( + () => [], + ), + ]); + const jobPostingById = new Map( + jobPostings.map((jobPosting) => [ + jobPosting.jobPostingId, + jobPosting, + ]), + ); const mappedDrafts = data.inProgress.map((item) => ({ id: String(item.mockApplyId), - companyName: item.companyName, + jobPostingId: item.jobPostingId, + mockApplyId: item.mockApplyId, + companyName: + item.companyName || + jobPostingById.get(item.jobPostingId)?.companyName || + "회사명 미입력", position: - item.jobTitle || item.detailClassificationName || "직무 미지정", + item.jobTitle || + item.detailClassificationName || + jobPostingById.get(item.jobPostingId) + ?.detailClassificationName || + "직무 미지정", currentStep: item.status === "ANSWER_WRITE" ? 2 : 1, updatedAt: formatDate(item.createdAt), })); + const linkedJobPostingIds = new Set( + [...data.inProgress, ...data.completed].map( + (item) => item.jobPostingId, + ), + ); + const savedOnlyDrafts = jobPostings + .filter( + (jobPosting) => + !linkedJobPostingIds.has(jobPosting.jobPostingId), + ) + .map((jobPosting) => ({ + id: `job-posting-${jobPosting.jobPostingId}`, + jobPostingId: jobPosting.jobPostingId, + companyName: jobPosting.companyName || "회사명 미입력", + position: + jobPosting.detailClassificationName || "직무 미지정", + currentStep: 1, + updatedAt: "-", + })); const mappedResults = data.completed.map((item) => ({ id: item.mockApplyId, @@ -82,7 +125,7 @@ export default function Home() { version: item.version || 1, status: "completed", })); - setDrafts(mappedDrafts); + setDrafts([...savedOnlyDrafts, ...mappedDrafts]); setResults(mappedResults); } catch (error) { console.error("데이터를 불러오는데 실패했습니다.", error); @@ -91,6 +134,20 @@ export default function Home() { loadMockApplies(); }, []); + + const deletePosting = async (jobPostingId: number) => { + try { + await deleteJobPosting(jobPostingId); + setDrafts((current) => + current.filter((draft) => draft.jobPostingId !== jobPostingId), + ); + setResults((current) => + current.filter((result) => result.jobPostingId !== jobPostingId), + ); + } catch (error) { + console.error("채용 공고를 삭제하지 못했습니다.", error); + } + }; return (
@@ -125,6 +182,26 @@ export default function Home() { if (!targetDraft) return; + if (!targetDraft.mockApplyId) { + void fetchMyJobPosting(targetDraft.jobPostingId) + .then((saved) => { + saveJobPostingAnalysis({ + savedToDatabase: true, + message: "저장된 채용 공고를 불러왔습니다.", + extracted: null, + candidates: [], + classification: null, + generated: null, + saved, + }); + router.push("/mockApply/job/review"); + }) + .catch((error) => { + console.error("채용 공고를 불러오지 못했습니다.", error); + }); + return; + } + switch (targetDraft.currentStep) { case 1: // 1단계 (공고 확인/질문 선택)에서 멈췄을 때 @@ -142,13 +219,19 @@ export default function Home() { router.push(`/mockApply/${id}`); } }} - onDelete={(id) => console.log(id)} + onDelete={(id) => { + const targetDraft = drafts.find((draft) => draft.id === id); + + if (targetDraft) { + void deletePosting(targetDraft.jobPostingId); + } + }} /> {/* 분석 완료 섹션 */} console.log(app.id, "삭제")} + onDelete={(app) => void deletePosting(app.jobPostingId)} onRetry={(app) => { router.push(`/mockApply/retry/${app.jobPostingId}`); }} diff --git a/jobdri/src/components/mockApply/home/types.ts b/jobdri/src/components/mockApply/home/types.ts index 6b54a37..fbd6c32 100644 --- a/jobdri/src/components/mockApply/home/types.ts +++ b/jobdri/src/components/mockApply/home/types.ts @@ -19,6 +19,8 @@ export interface ApplicationCardData { // types.ts (또는 해당 파일 상단) export interface DraftData { id: string; + jobPostingId: number; + mockApplyId?: number; companyName: string; position: string; currentStep: number; diff --git a/jobdri/src/lib/api/jobPostings.ts b/jobdri/src/lib/api/jobPostings.ts index 71cb872..68fc68a 100644 --- a/jobdri/src/lib/api/jobPostings.ts +++ b/jobdri/src/lib/api/jobPostings.ts @@ -317,7 +317,31 @@ export async function saveJobPosting(payload: JobPostingSavePayload) { ); } -export async function fetchMyJobPostings() { +export async function updateJobPosting( + jobPostingId: number, + payload: JobPostingSavePayload, +) { + const response = await fetchWithTimeout( + `${API_BASE_URL}/api/job-postings/${jobPostingId}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...getAuthHeaders(), + }, + body: JSON.stringify(payload), + }, + ); + + return parseApiResponse( + response, + "채용 공고 수정에 실패했습니다.", + ); +} + +export async function fetchMyJobPostings({ + redirectOnUnauthorized = true, +}: { redirectOnUnauthorized?: boolean } = {}) { const response = await fetchWithTimeout(`${API_BASE_URL}/api/job-postings/me`, { headers: getAuthHeaders(), cache: "no-store", @@ -326,6 +350,7 @@ export async function fetchMyJobPostings() { const result = await parseApiResponse( response, "내 지원 데이터를 불러오지 못했습니다.", + { redirectOnUnauthorized }, ); return result ?? []; From 112b8eddde3b665991a7909fbb55a400079c766b Mon Sep 17 00:00:00 2001 From: mingo Date: Wed, 22 Jul 2026 20:12:19 +0900 Subject: [PATCH 05/10] =?UTF-8?q?Feat:=20jd=20=ED=99=95=EC=9D=B8=EA=B3=BC?= =?UTF-8?q?=20=EC=9E=90=EC=86=8C=EC=84=9C=20=EC=9E=85=EB=A0=A5=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20[JDDEV-43]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/mockApply/[mockApplyId]/page.tsx | 199 ++++++++++++++++-- .../app/mockApply/job/jobPostingDraftStore.ts | 6 +- jobdri/src/app/mockApply/job/review/page.tsx | 8 +- jobdri/src/app/page.tsx | 8 +- .../components/common/MockApplyTemplate.tsx | 6 + .../mockApply/Question/SidePanel.tsx | 29 +-- .../mockApply/home/applicationHomeUtils.ts | 23 +- 7 files changed, 203 insertions(+), 76 deletions(-) diff --git a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx index b00fb2b..f392913 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useState, useEffect, use } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import Header from "@/components/common/header/Header"; import { QuestionList } from "@/components/mockApply/Question/QuestionList"; import JDSidePanel from "@/components/mockApply/Question/SidePanel"; import SideHeaderContainer from "@/components/common/header/SideHeaderContainer"; @@ -9,17 +8,21 @@ 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 { Toast, type ToastVariant } from "@/components/common/toast"; -import { CtaFooter } from "@/components/common/cta"; +import { Toast } from "@/components/common/toast"; import { fetchCreditBalance } from "@/lib/api/credit"; import { requestAnalysis } from "@/lib/api/analysis"; import MockApplyTemplate from "@/components/common/MockApplyTemplate"; +import { fetchMyJobPosting } from "@/lib/api/jobPostings"; +import type { JDData } from "@/components/mockApply/Question/SidePanel"; +import { saveJobPostingAnalysis } from "@/app/mockApply/job/jobPostingDraftStore"; +import { fetchSequence } from "@/lib/api/result"; const ModalOverlay = ({ children }: { children: React.ReactNode }) => (
@@ -34,9 +37,22 @@ export default function MockApplyPage({ }) { const { mockApplyId } = use(params); const router = useRouter(); + const searchParams = useSearchParams(); + const requestedJobPostingId = Number(searchParams.get("jobPostingId")); + const hasRequestedJobPostingId = + Number.isInteger(requestedJobPostingId) && requestedJobPostingId > 0; 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 [selectedId, setSelectedId] = useState(null); const [lastSavedTime, setLastSavedTime] = useState("저장 전"); @@ -53,8 +69,6 @@ export default function MockApplyPage({ const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); const [isCreditShortModalOpen, setIsCreditShortModalOpen] = useState(false); - const [applicationLabel, setApplicationLabel] = useState(""); - // 데이터 포맷팅 함수 const getSubmitPayload = (questionsData: QuestionItem[]) => { return questionsData.map((q) => ({ @@ -67,18 +81,156 @@ export default function MockApplyPage({ // 초기 데이터 불러오기 useEffect(() => { + let ignore = false; + const loadQuestions = async () => { try { - const data = await fetchSelectedQuestions(Number(mockApplyId)); + setIsQuestionsLoading(true); + setQuestionsErrorMessage(""); + + const [selectedResult, candidatesResult] = await Promise.allSettled([ + fetchSelectedQuestions(Number(mockApplyId)), + fetchQuestions(Number(mockApplyId)), + ]); + let data = + selectedResult.status === "fulfilled" ? selectedResult.value : []; + + if (data.length === 0) { + if (candidatesResult.status === "rejected") { + throw candidatesResult.reason; + } + + const candidates = candidatesResult.value; + const preselectedCandidates = candidates.filter( + (question) => question.selected, + ); + const initialQuestions = ( + preselectedCandidates.length > 0 + ? preselectedCandidates + : candidates + ).slice(0, 5); + + if (initialQuestions.length > 0) { + await saveQuestions(Number(mockApplyId), initialQuestions); + const savedQuestions = await fetchSelectedQuestions( + Number(mockApplyId), + ).catch(() => []); + data = savedQuestions.length > 0 + ? savedQuestions + : initialQuestions; + } + } + + if (ignore) { + return; + } + setQuestions(data); - if (data && data.length > 0) setSelectedId(data[0].id); + setSelectedId(data[0]?.id ?? null); + + if (data.length === 0) { + setQuestionsErrorMessage( + "공고에 맞는 자소서 문항을 불러오지 못했습니다.", + ); + } } catch (error) { + if (ignore) { + return; + } + console.error("문항을 불러오지 못했습니다.", error); + setQuestions([]); + setSelectedId(null); + setQuestionsErrorMessage( + error instanceof Error + ? error.message + : "자소서 문항을 불러오지 못했습니다.", + ); + } finally { + if (!ignore) { + setIsQuestionsLoading(false); + } } }; - loadQuestions(); + + void loadQuestions(); + + return () => { + ignore = true; + }; }, [mockApplyId]); + 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]); + + useEffect(() => { + if (!jobPostingId) { + return; + } + + let ignore = false; + + fetchMyJobPosting(jobPostingId) + .then((jobPosting) => { + if (ignore) { + return; + } + + setJdData({ + companyName: jobPosting.companyName, + title: jobPosting.detailClassificationName || "채용 공고", + sections: [ + { + subtitle: "직무", + content: jobPosting.detailClassificationName, + }, + { subtitle: "주요 업무", content: jobPosting.task }, + { subtitle: "자격요건", content: jobPosting.requirement }, + { subtitle: "우대사항", content: jobPosting.preferred }, + ].filter((section) => section.content.trim().length > 0), + }); + saveJobPostingAnalysis({ + savedToDatabase: true, + message: "저장된 채용 공고를 불러왔습니다.", + extracted: null, + candidates: [], + classification: null, + generated: null, + saved: jobPosting, + }); + }) + .catch((error) => { + if (!ignore) { + console.error("채용 공고를 불러오지 못했습니다.", error); + } + }); + + return () => { + ignore = true; + }; + }, [jobPostingId]); + // 자동 저장 타이머 useEffect(() => { if (!toast.open || toast.variant !== "check") return; @@ -229,14 +381,22 @@ export default function MockApplyPage({ maxLength: String(currentQ.maxLength || 1000), } : null; + const isSubmitDisabled = + !mappedQuestionForForm || + questions.some((question) => !(question.answer || "").trim()); return ( setIsLeaveModalOpen(true)} + onNextClick={() => setIsConfirmModalOpen(true)} + isNextDisabled={isSubmitDisabled} > -
+
- ) : ( + ) : isQuestionsLoading ? (
문항을 불러오는 중입니다...
+ ) : ( +
+ {questionsErrorMessage} +
)}
- setIsLeaveModalOpen(true) }} - nextAction={{ - label: "채점하기", - onClick: () => setIsConfirmModalOpen(true), - disabled: - !mappedQuestionForForm || - questions.some((q) => !(q.answer || "").trim()), - iconType: "SPARKLE", - }} - /> - setIsPanelOpen(false)} onOpen={() => setIsPanelOpen(true)} + data={jdData ?? undefined} /> {toast.open && ( @@ -335,7 +488,7 @@ export default function MockApplyPage({ onSecondaryClick={() => setIsLeaveModalOpen(false)} onPrimaryClick={() => { setIsLeaveModalOpen(false); - router.push(`/mockApply/actual/${mockApplyId}/jd-review`); + router.push("/mockApply/job/review"); }} /> diff --git a/jobdri/src/app/mockApply/job/jobPostingDraftStore.ts b/jobdri/src/app/mockApply/job/jobPostingDraftStore.ts index bdd37bf..4a930c7 100644 --- a/jobdri/src/app/mockApply/job/jobPostingDraftStore.ts +++ b/jobdri/src/app/mockApply/job/jobPostingDraftStore.ts @@ -34,10 +34,14 @@ export function saveJobPostingAnalysis(result: JobPostingProcessedResult) { jobPostingAnalysis = result; } -export function clearJobPostingDraft() { +export function clearJobPostingInput() { jobPostingDraft = { files: [], value: "", }; +} + +export function clearJobPostingDraft() { + clearJobPostingInput(); jobPostingAnalysis = null; } diff --git a/jobdri/src/app/mockApply/job/review/page.tsx b/jobdri/src/app/mockApply/job/review/page.tsx index 8e6a248..aafed50 100644 --- a/jobdri/src/app/mockApply/job/review/page.tsx +++ b/jobdri/src/app/mockApply/job/review/page.tsx @@ -14,7 +14,7 @@ import { JDInput } from "@/components/common/input"; import { ModalNotice } from "@/components/common/modal"; import Avatar from "@/components/mockApply/home/Avatar"; import { - clearJobPostingDraft, + clearJobPostingInput, getJobPostingAnalysis, } from "../jobPostingDraftStore"; import { @@ -197,8 +197,10 @@ export default function JobPostingReviewPage() { savedJobPosting.jobPostingId, ); - clearJobPostingDraft(); - router.push(`/mockApply/${createdApply.mockApplyId}`); + clearJobPostingInput(); + router.push( + `/mockApply/${createdApply.mockApplyId}?jobPostingId=${savedJobPosting.jobPostingId}`, + ); } catch (error) { setSaveErrorMessage( error instanceof Error diff --git a/jobdri/src/app/page.tsx b/jobdri/src/app/page.tsx index 42f51ec..ab80eb2 100644 --- a/jobdri/src/app/page.tsx +++ b/jobdri/src/app/page.tsx @@ -204,12 +204,10 @@ export default function Home() { switch (targetDraft.currentStep) { case 1: - // 1단계 (공고 확인/질문 선택)에서 멈췄을 때 - router.push(`/mockApply/question-select/${id}`); - break; case 2: - // 2단계 (자소서 작성)에서 멈췄을 때 - router.push(`/mockApply/${id}`); + router.push( + `/mockApply/${targetDraft.mockApplyId}?jobPostingId=${targetDraft.jobPostingId}`, + ); break; case 3: // 3단계 (채점 중)일 때 (보통 대기 화면이나 결과 화면으로) diff --git a/jobdri/src/components/common/MockApplyTemplate.tsx b/jobdri/src/components/common/MockApplyTemplate.tsx index 194951d..22573ca 100644 --- a/jobdri/src/components/common/MockApplyTemplate.tsx +++ b/jobdri/src/components/common/MockApplyTemplate.tsx @@ -10,6 +10,8 @@ import { ModalCard } from "./modal/ModalCard"; interface MockApplyTemplateProps { mockApplyId: number; currentStep: number; + companyName?: string; + jobTitle?: string; children: React.ReactNode; customHomeModal?: (typeof LEAVE_MODAL_CONFIG)[keyof typeof LEAVE_MODAL_CONFIG]; @@ -28,6 +30,8 @@ export default function MockApplyTemplate({ mockApplyId, children, currentStep, + companyName, + jobTitle, customHomeModal = LEAVE_MODAL_CONFIG.COMMON_HOME, onHomeLeave, @@ -68,6 +72,8 @@ export default function MockApplyTemplate({ return (
) { const normalizedResumePath = normalizeResumePath(resumePath, mockApplyId); - if (normalizedResumePath) { - if (normalizedResumePath.includes("/write")) { - return withJobPostingId(normalizedResumePath, jobPostingId); - } - + if (normalizedResumePath.includes("/result")) { return normalizedResumePath; } - if (status === "ANSWER_WRITE") { - return `/mockApply/${mockApplyId}/write?jobPostingId=${jobPostingId}`; - } - - return `/mockApply/${mockApplyId}/questions`; + return `/mockApply/${mockApplyId}?jobPostingId=${jobPostingId}`; } export function getResultPath({ @@ -275,7 +258,7 @@ export function getRetryPath({ mockApplyId, jobPostingId, }: Pick) { - return `/mockApply/${mockApplyId}/write?jobPostingId=${jobPostingId}`; + return `/mockApply/${mockApplyId}?jobPostingId=${jobPostingId}`; } export function saveJdReviewSessionFromJobPosting( From 9b775809a6b853fbcbaf6d866137e9fd95193991 Mon Sep 17 00:00:00 2001 From: mingo Date: Thu, 23 Jul 2026 00:51:03 +0900 Subject: [PATCH 06/10] =?UTF-8?q?Feat:=20=EC=9E=90=EC=86=8C=EC=84=9C=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=EA=B2=B0=EA=B3=BC=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?api=20=EC=97=B0=EA=B2=B0=20[JDDEV-43]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(jd)/jd-input/JdInputPageClient.tsx | 4 +- .../[mockApplyId]/(jd)/jd-input/page.tsx | 2 +- .../[mockApplyId]/(jd)/jd-review/page.tsx | 17 +- .../app/mockApply/[mockApplyId]/(jd)/page.tsx | 2 +- .../src/app/mockApply/[mockApplyId]/page.tsx | 21 ++- .../mockApply/[mockApplyId]/result/page.tsx | 1 - .../ResumeAnalysisLoadingPageClient.tsx | 156 ++++++++++++++++-- .../result/resume-analysis-loading/page.tsx | 6 +- .../apply-type/ApplyTypePageClient.tsx | 2 +- jobdri/src/app/mockApply/job/review/page.tsx | 12 +- jobdri/src/app/page.tsx | 21 ++- .../src/components/common/lnb/LnbShared.tsx | 2 +- .../mockApply/result/ResumeAnalysisDetail.tsx | 2 - .../result/ResumeAnalysisFeedback.tsx | 36 +--- jobdri/src/lib/api/analysis.ts | 101 +----------- jobdri/src/lib/api/questions.ts | 26 --- jobdri/src/lib/api/result.ts | 89 ++++++++-- 17 files changed, 284 insertions(+), 216 deletions(-) diff --git a/jobdri/src/app/mockApply/[mockApplyId]/(jd)/jd-input/JdInputPageClient.tsx b/jobdri/src/app/mockApply/[mockApplyId]/(jd)/jd-input/JdInputPageClient.tsx index f5d6b59..8c7c764 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/(jd)/jd-input/JdInputPageClient.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/(jd)/jd-input/JdInputPageClient.tsx @@ -144,7 +144,7 @@ const JdInputPageClient = forwardRef< >(function JdInputPageClient({ selectedMethod, onMethodChange }, ref) { const { mockApplyId: id } = useParams<{ mockApplyId: string }>(); const router = useRouter(); - const manualJdReviewPath = `/mockApply/actual/${id}/jd-review?mode=manual`; + const manualJdReviewPath = `/mockApply/${id}/jd-review?mode=manual`; const activeRequestIdRef = useRef(0); const [isTextModalOpen, setIsTextModalOpen] = useState(false); @@ -312,7 +312,7 @@ const JdInputPageClient = forwardRef< window.sessionStorage.removeItem(getJdReviewSavedStorageKey(id)); } - router.push(`/mockApply/actual/${id}/jd-review`); + router.push(`/mockApply/${id}/jd-review`); }; const processJobPosting = async ({ diff --git a/jobdri/src/app/mockApply/[mockApplyId]/(jd)/jd-input/page.tsx b/jobdri/src/app/mockApply/[mockApplyId]/(jd)/jd-input/page.tsx index 44dfb53..4c6dcc7 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/(jd)/jd-input/page.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/(jd)/jd-input/page.tsx @@ -24,7 +24,7 @@ export default function MockApplicationJdInputPage() { onMethodChange={setSelectedMethod} />
router.push("/mockApply") }} + backAction={{ onClick: () => router.push("/") }} ctaAction={{ label: "선택하기", disabled: selectedMethod === null, 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 0428fb4..02da154 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/(jd)/jd-review/page.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/(jd)/jd-review/page.tsx @@ -200,7 +200,7 @@ export default function MockApplicationJdReviewPage() { const openBackConfirm = () => setShowBackConfirm(true); const closeBackConfirm = () => setShowBackConfirm(false); - const goToJdInput = () => router.replace(`/mockApply/actual/${id}/jd-input`); + const goToJdInput = () => router.replace(`/mockApply/${id}/jd-input`); const closeSaveError = () => setSaveErrorMessage(""); const handleConfirm = async () => { @@ -250,7 +250,7 @@ export default function MockApplicationJdReviewPage() { setIsSaving(true); try { - const createNextApplyId = async () => { + const createNextApply = async () => { const savedJobPosting = await createSavedJobPosting(); const createdApply = await createApplyFromJobPosting({ jobPostingId: savedJobPosting.jobPostingId, @@ -275,16 +275,21 @@ export default function MockApplicationJdReviewPage() { savedJobPosting, }); - return nextApplyId; + return { + jobPostingId: savedJobPosting.jobPostingId, + mockApplyId: nextApplyId, + }; }; - const [nextApplyId] = await Promise.all([ - createNextApplyId(), + const [nextApply] = await Promise.all([ + createNextApply(), delay(loadingDurationMs), ]); shouldKeepLoading = true; - router.push(`/mockApply/actual/${nextApplyId}/questions`); + router.push( + `/mockApply/${nextApply.mockApplyId}?jobPostingId=${nextApply.jobPostingId}`, + ); } catch (error) { setSaveErrorMessage( error instanceof Error diff --git a/jobdri/src/app/mockApply/[mockApplyId]/(jd)/page.tsx b/jobdri/src/app/mockApply/[mockApplyId]/(jd)/page.tsx index a2c58be..12cdfda 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/(jd)/page.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/(jd)/page.tsx @@ -17,7 +17,7 @@ export default function JdPage({ params }: JdPageProps) {
{/* JD 입력 UI */}
diff --git a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx index f392913..2ab1208 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx @@ -17,7 +17,7 @@ import { import { ModalCard } from "@/components/common/modal/ModalCard"; import { Toast } from "@/components/common/toast"; import { fetchCreditBalance } from "@/lib/api/credit"; -import { requestAnalysis } from "@/lib/api/analysis"; +import { requestAnalysis } from "@/lib/api/result"; import MockApplyTemplate from "@/components/common/MockApplyTemplate"; import { fetchMyJobPosting } from "@/lib/api/jobPostings"; import type { JDData } from "@/components/mockApply/Question/SidePanel"; @@ -278,12 +278,23 @@ export default function MockApplyPage({ const handleConfirm = async () => { try { - await saveApply(Number(mockApplyId), getSubmitPayload(questions)); - const response = await requestAnalysis(Number(mockApplyId)); - const taskId = response.taskId; + const savedApply = await saveApply( + Number(mockApplyId), + getSubmitPayload(questions), + ); + await requestAnalysis(Number(mockApplyId)); + const loadingSearchParams = new URLSearchParams(); + + if (savedApply.sequence > 0) { + loadingSearchParams.set("sequence", String(savedApply.sequence)); + } + + const loadingQuery = loadingSearchParams.size + ? `?${loadingSearchParams.toString()}` + : ""; router.push( - `/mockApply/${mockApplyId}/result/resume-analysis-loading?taskId=${taskId}`, + `/mockApply/${mockApplyId}/result/resume-analysis-loading${loadingQuery}`, ); } catch (error) { console.error("제출 실패:", error); diff --git a/jobdri/src/app/mockApply/[mockApplyId]/result/page.tsx b/jobdri/src/app/mockApply/[mockApplyId]/result/page.tsx index 79d1280..352bc92 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/result/page.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/result/page.tsx @@ -11,7 +11,6 @@ import { Toast } from "@/components/common/toast"; import { useReApply } from "@/hooks/useReApply"; import { getMockApplyResumeRecords } from "@/lib/api/mockApplies"; import { useAnalysisResult } from "@/hooks/useAnalysisResult"; -import { AnalysisResultResponse } from "@/lib/api/analysis"; import MockApplyTemplate from "@/components/common/MockApplyTemplate"; interface ResultPageProps { diff --git a/jobdri/src/app/mockApply/[mockApplyId]/result/resume-analysis-loading/ResumeAnalysisLoadingPageClient.tsx b/jobdri/src/app/mockApply/[mockApplyId]/result/resume-analysis-loading/ResumeAnalysisLoadingPageClient.tsx index d6a09df..4495efa 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/result/resume-analysis-loading/ResumeAnalysisLoadingPageClient.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/result/resume-analysis-loading/ResumeAnalysisLoadingPageClient.tsx @@ -1,32 +1,166 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useRouter, useParams } from "next/navigation"; import ResumeAnalysisLoading from "@/components/mockApply/ResumeAnalysisLoading"; +import { ModalNotice } from "@/components/common/modal"; +import { + AnalysisPendingError, + fetchAnalysisResult, +} from "@/lib/api/result"; const RESUME_ANALYSIS_LOADING_DURATION_MS = 316_000; +const ANALYSIS_POLL_INTERVAL_MS = 2_500; +const ANALYSIS_POLL_TIMEOUT_MS = 10 * 60 * 1000; interface ResumeAnalysisLoadingPageClientProps { applicationLabel?: string; + initialSequence?: number; } export default function ResumeAnalysisLoadingPageClient({ applicationLabel, + initialSequence, }: ResumeAnalysisLoadingPageClientProps) { const router = useRouter(); const params = useParams(); - const mockApplyId = params.mockApplyId; + const mockApplyId = Number(params.mockApplyId); + const [pollingRetryKey, setPollingRetryKey] = useState(0); + const [errorMessage, setErrorMessage] = useState(""); - const handleComplete = useCallback(() => { - if (!mockApplyId) return; - router.replace(`/mockApply/${mockApplyId}/result`); - }, [router, mockApplyId]); + const moveToResult = useCallback( + (sequence?: number) => { + if (!Number.isInteger(mockApplyId) || mockApplyId <= 0) { + return; + } + + const resolvedSequence = sequence ?? initialSequence; + const sequenceQuery = resolvedSequence + ? `?sequence=${resolvedSequence}` + : ""; + + router.replace(`/mockApply/${mockApplyId}/result${sequenceQuery}`); + }, + [initialSequence, mockApplyId, router], + ); + + useEffect(() => { + if (!Number.isInteger(mockApplyId) || mockApplyId <= 0) { + return; + } + + let cancelled = false; + let pollTimer: ReturnType | undefined; + const startedAt = Date.now(); + + const scheduleNextPoll = () => { + if (cancelled) { + return; + } + + if (Date.now() - startedAt >= ANALYSIS_POLL_TIMEOUT_MS) { + setErrorMessage( + "분석 시간이 예상보다 길어지고 있어요. 잠시 후 다시 확인해주세요.", + ); + return; + } + + pollTimer = window.setTimeout(() => { + void pollAnalysis(); + }, ANALYSIS_POLL_INTERVAL_MS); + }; + + const pollAnalysis = async () => { + try { + const result = await fetchAnalysisResult(mockApplyId); + + if (cancelled) { + return; + } + + const normalizedStatus = result.status?.toUpperCase() ?? ""; + + if ( + ["FAILED", "ERROR"].includes(normalizedStatus) || + normalizedStatus.endsWith("_FAILED") + ) { + setErrorMessage("자소서 분석에 실패했어요. 다시 시도해주세요."); + return; + } + + const isCompleted = [ + "COMPLETED", + "SUCCESS", + "SUCCEEDED", + "DONE", + ].includes(normalizedStatus) || normalizedStatus.endsWith("_COMPLETED"); + + if (isCompleted) { + moveToResult(result.sequence); + return; + } + + scheduleNextPoll(); + } catch (error) { + if (cancelled) { + return; + } + + if (error instanceof AnalysisPendingError) { + scheduleNextPoll(); + return; + } + + console.error("분석 결과 조회에 실패했습니다.", error); + setErrorMessage( + error instanceof Error + ? error.message + : "분석 결과를 불러오지 못했어요.", + ); + } + }; + + void pollAnalysis(); + + return () => { + cancelled = true; + if (pollTimer !== undefined) { + window.clearTimeout(pollTimer); + } + }; + }, [mockApplyId, moveToResult, pollingRetryKey]); + + const handleRetry = () => { + setErrorMessage(""); + setPollingRetryKey((current) => current + 1); + }; return ( - + <> + router.replace(`/mockApply/${mockApplyId}`)} + applicationLabel={applicationLabel} + /> + + {errorMessage && ( +
+ router.replace(`/mockApply/${mockApplyId}`), + }} + primaryAction={{ + label: "다시 확인", + onClick: handleRetry, + }} + /> +
+ )} + ); } diff --git a/jobdri/src/app/mockApply/[mockApplyId]/result/resume-analysis-loading/page.tsx b/jobdri/src/app/mockApply/[mockApplyId]/result/resume-analysis-loading/page.tsx index 8a9ce34..69153e7 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/result/resume-analysis-loading/page.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/result/resume-analysis-loading/page.tsx @@ -19,12 +19,12 @@ export default async function ResumeAnalysisLoadingPage({ searchParams, }: ResumeAnalysisLoadingPageProps) { const { sequence } = await searchParams; + const parsedSequence = parsePositiveNumber(sequence); return ( ); } diff --git a/jobdri/src/app/mockApply/apply-type/ApplyTypePageClient.tsx b/jobdri/src/app/mockApply/apply-type/ApplyTypePageClient.tsx index eea81bd..28892f3 100644 --- a/jobdri/src/app/mockApply/apply-type/ApplyTypePageClient.tsx +++ b/jobdri/src/app/mockApply/apply-type/ApplyTypePageClient.tsx @@ -37,7 +37,7 @@ export default function ApplyTypePageClient() { const handleSubmit = () => { if (selectedType === "real") { saveSelectedApplyType("ACTUAL"); - router.push("/mockApply/actual/new/jd-input"); + router.push("/mockApply/job/create"); return; } diff --git a/jobdri/src/app/mockApply/job/review/page.tsx b/jobdri/src/app/mockApply/job/review/page.tsx index aafed50..954898f 100644 --- a/jobdri/src/app/mockApply/job/review/page.tsx +++ b/jobdri/src/app/mockApply/job/review/page.tsx @@ -22,7 +22,10 @@ import { updateJobPosting, type JobPostingSavePayload, } from "@/lib/api/jobPostings"; -import { createMockApplyFromJobPosting } from "@/lib/api/mockApplies"; +import { + createApplyFromJobPosting, + getSelectedApplyType, +} from "@/lib/api/mockApplies"; function firstNonEmpty(...values: Array) { return values.find((value) => value?.trim())?.trim() ?? ""; @@ -193,9 +196,10 @@ export default function JobPostingReviewPage() { const savedJobPosting = initialValues.jobPostingId ? await updateJobPosting(initialValues.jobPostingId, payload) : await saveJobPosting(payload); - const createdApply = await createMockApplyFromJobPosting( - savedJobPosting.jobPostingId, - ); + const createdApply = await createApplyFromJobPosting({ + jobPostingId: savedJobPosting.jobPostingId, + applyType: getSelectedApplyType(), + }); clearJobPostingInput(); router.push( diff --git a/jobdri/src/app/page.tsx b/jobdri/src/app/page.tsx index ab80eb2..d12a0de 100644 --- a/jobdri/src/app/page.tsx +++ b/jobdri/src/app/page.tsx @@ -6,7 +6,10 @@ import { BusinessFooter } from "@/components/common/footer"; import { Lnb } from "@/components/common/lnb"; import ResultDraftList from "@/components/mockApply/home/ResultDraftList"; import ResultApplicationList from "@/components/mockApply/home/ResultApplicationList"; -import { fetchMyMockApplies } from "@/lib/api/mockApplies"; +import { + fetchMyMockApplies, + saveSelectedApplyType, +} from "@/lib/api/mockApplies"; import { deleteJobPosting, fetchMyJobPosting, @@ -18,6 +21,7 @@ import { DraftData, ApplicationCardData, } from "@/components/mockApply/home/types"; +import { useReApply } from "@/hooks/useReApply"; // const DUMMY_DRAFTS = [ // { @@ -57,6 +61,7 @@ import { export default function Home() { const router = useRouter(); + const { reApply } = useReApply(); const [drafts, setDrafts] = useState([]); const [results, setResults] = useState([]); @@ -167,7 +172,10 @@ export default function Home() { styleType="primary" size="large" iconType="SPARKLE" - onClick={() => router.push("/mockApply/job/create")} + onClick={() => { + saveSelectedApplyType("MOCK"); + router.push("/mockApply/job/create"); + }} />
@@ -210,8 +218,9 @@ export default function Home() { ); break; case 3: - // 3단계 (채점 중)일 때 (보통 대기 화면이나 결과 화면으로) - router.push(`/mockApply/grading/${id}`); + router.push( + `/mockApply/${targetDraft.mockApplyId}/result/resume-analysis-loading`, + ); break; default: router.push(`/mockApply/${id}`); @@ -230,9 +239,7 @@ export default function Home() { void deletePosting(app.jobPostingId)} - onRetry={(app) => { - router.push(`/mockApply/retry/${app.jobPostingId}`); - }} + onRetry={(app) => void reApply(app.mockApplyId)} onResume={(app) => { // 예시: 결과 상세 페이지로 이동! router.push(`/mockApply/${app.mockApplyId}/result`); diff --git a/jobdri/src/components/common/lnb/LnbShared.tsx b/jobdri/src/components/common/lnb/LnbShared.tsx index cc16da1..8731344 100644 --- a/jobdri/src/components/common/lnb/LnbShared.tsx +++ b/jobdri/src/components/common/lnb/LnbShared.tsx @@ -39,7 +39,7 @@ export const navItems: LnbNavItem[] = [ key: "apply", label: "모의 서류 지원", iconType: "APPLY", - href: "/mockApply", + href: "/", }, { key: "experience", diff --git a/jobdri/src/components/mockApply/result/ResumeAnalysisDetail.tsx b/jobdri/src/components/mockApply/result/ResumeAnalysisDetail.tsx index f29d500..9129954 100644 --- a/jobdri/src/components/mockApply/result/ResumeAnalysisDetail.tsx +++ b/jobdri/src/components/mockApply/result/ResumeAnalysisDetail.tsx @@ -46,8 +46,6 @@ function QuestionViewer({ const sortedAnalyses = [...analyses].sort((a, b) => a.start - b.start); const result = []; let currentIndex = 0; - console.log("들어온 분석 데이터:", analyses); - sortedAnalyses.forEach((analysis, idx) => { if (analysis.start > currentIndex) { result.push( diff --git a/jobdri/src/components/mockApply/result/ResumeAnalysisFeedback.tsx b/jobdri/src/components/mockApply/result/ResumeAnalysisFeedback.tsx index c2f90a3..fd8492f 100644 --- a/jobdri/src/components/mockApply/result/ResumeAnalysisFeedback.tsx +++ b/jobdri/src/components/mockApply/result/ResumeAnalysisFeedback.tsx @@ -23,43 +23,11 @@ interface ResumeAnalysisFeedbackProps { analysisData: AnalysisResult; } -const scoreItems = [ - { label: "직무 적합성", score: 86, tone: "primary" }, - { label: "정량적 성과", score: 72, tone: "danger" }, - { label: "논리 구조", score: 86, tone: "primary" }, -] as const; - const reviewTabs = [ { id: "strengths", label: "핵심 강점" }, { id: "weaknesses", label: "핵심 약점" }, ]; -const strengthEvaluations = [ - { - content: "직무 연관 경험이 명확하게 드러나요", - quote: - '"UX 리서치를 통해 핵심 불편 지점을 발굴하고, 이를 기능 개선으로 연결한 경..."', - }, - { - content: "수치 기반 성과 서술이 신뢰감을 줘요", - quote: - '"리텐션율 23% 개선, MAU 15% 증가라는 정량적 결과를 이끌어낸 경험이..."', - }, -]; - -const weaknessEvaluations = [ - { - content: "멘트 더미텍스트입니다. 최대 한 줄", - quote: - "자소서 본문 인용 더미텍스트입니다. 최대 한 줄, 자소서 본문 인용 더미텍스트...", - }, - { - content: "멘트 더미텍스트입니다. 최대 한 줄", - quote: - "자소서 본문 인용 더미텍스트입니다. 최대 한 줄, 자소서 본문 인용 더미텍스트...", - }, -]; - function ScoreSummaryIcon() { return (