From bb7ac2ffe2474f8cb6bd9665d63fa8ed3db68c6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9C=A4=EC=84=9C?= Date: Sat, 25 Jul 2026 19:10:07 +0900 Subject: [PATCH 1/5] =?UTF-8?q?Feat:=20=ED=99=88=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=8C=85=20=EC=97=B0=EA=B2=B0=20[JDDEV-89]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../job/[jobPostingId]/review/page.tsx | 88 ++++++++++++++++++- jobdri/src/app/page.tsx | 13 ++- jobdri/src/hooks/useAutoSave.ts | 63 +++++++++++++ 3 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 jobdri/src/hooks/useAutoSave.ts diff --git a/jobdri/src/app/mockApply/job/[jobPostingId]/review/page.tsx b/jobdri/src/app/mockApply/job/[jobPostingId]/review/page.tsx index 58eda74..17404d0 100644 --- a/jobdri/src/app/mockApply/job/[jobPostingId]/review/page.tsx +++ b/jobdri/src/app/mockApply/job/[jobPostingId]/review/page.tsx @@ -4,6 +4,7 @@ import { useMemo, useState, useEffect, + useRef, type MouseEvent, type ReactNode, } from "react"; @@ -142,6 +143,11 @@ export default function JobPostingReviewPage() { const [isSaving, setIsSaving] = useState(false); const [saveErrorMessage, setSaveErrorMessage] = useState(""); + // ✅ 헤더에 표시할 자동저장 시간과, 초기 마운트 여부를 추적하는 ref + const [lastSavedTime, setLastSavedTime] = useState(""); + const isInitialRender = useRef(true); + const isAutoSaving = useRef(false); + const { scrollAreaRef, scrollbarMetrics, updateScrollbarMetrics } = useLnbScrollMetrics(true, "job-posting-review", { trackPadding: 28 }); @@ -158,11 +164,11 @@ export default function JobPostingReviewPage() { [companyName, jobPostingName, roleName], ); + // 1. 초기 데이터 로딩 useEffect(() => { const loadData = async () => { try { if (urlJobPostingId) { - // URL 경로에 ID가 있으면 DB에서 패칭 const saved = await fetchMyJobPosting(Number(urlJobPostingId)); setProfileColor(saved.profileColor ?? "DEFAULT"); setJobPostingName( @@ -183,7 +189,6 @@ export default function JobPostingReviewPage() { setDetailClassificationId(saved.detailClassificationId ?? 0); setJobPostingId(saved.jobPostingId ?? null); } else { - // 파라미터가 없으면 스토어에서 임시 데이터 패칭 const result = getJobPostingAnalysis(); if (!result) { router.replace("/mockApply/job/create"); @@ -246,12 +251,89 @@ export default function JobPostingReviewPage() { router.replace("/"); } finally { setIsLoading(false); + // 처음 로딩 완료 시점을 자동저장 시간으로 초기 세팅 + const now = new Date(); + setLastSavedTime( + `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`, + ); } }; loadData(); }, [urlJobPostingId, router]); + // ✅ 2. 디바운싱 적용된 자동 저장 로직 + useEffect(() => { + // 로딩 중이거나, 필수값이 비어있거나, 직무 분류값이 없으면 실행 안 함 + if ( + isLoading || + !jobPostingName.trim() || + !companyName.trim() || + !roleName.trim() || + detailClassificationId <= 0 + ) { + return; + } + + // 처음 데이터를 불러와서 state에 셋팅될 때는 저장을 스킵 + if (isInitialRender.current) { + isInitialRender.current = false; + return; + } + + // 입력이 멈추고 1초(1000ms) 후에 API 호출 + const autoSaveTimer = setTimeout(async () => { + if (isAutoSaving.current) return; + isAutoSaving.current = true; + + try { + const payload: JobPostingSavePayload = { + profileColor, + postingName: jobPostingName.trim(), + companyName: companyName.trim(), + companySize: companySize, + jobTitle: roleName.trim(), + detailClassificationId: detailClassificationId, + task: task.trim(), + requirement: requirements.trim(), + preferred: preferred.trim(), + }; + + let savedResult; + if (jobPostingId) { + savedResult = await updateJobPosting(jobPostingId, payload); + } else { + savedResult = await saveJobPosting(payload); + setJobPostingId(savedResult.jobPostingId); // 새로 생성 시 ID 업데이트 + } + + const now = new Date(); + setLastSavedTime( + `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`, + ); + } catch (error) { + console.error("자동 저장 실패:", error); + } finally { + isAutoSaving.current = false; + } + }, 1000); + + // 값이 바뀌면 기존 타이머를 날리고 새로 타이머를 세팅 (디바운싱의 핵심) + return () => clearTimeout(autoSaveTimer); + }, [ + profileColor, + jobPostingName, + companyName, + roleName, + task, + requirements, + preferred, + companySize, + detailClassificationId, + jobPostingId, + isLoading, // 로딩 상태가 바뀔 때도 감지 + ]); + const handleHomeClick = (event: MouseEvent) => { event.preventDefault(); setShowHomeConfirm(true); @@ -322,7 +404,7 @@ export default function JobPostingReviewPage() { applicationLabel="첫 번째 지원" currentStep={1} steps={wizardSteps} - lastSavedAt="17:00" + lastSavedAt={lastSavedTime} // ✅ 하드코딩(17:00) 삭제하고 자동저장 시간 상태 바인딩 homeAction={{ label: "홈으로", onClick: handleHomeClick }} className="min-w-[1100px] max-w-none shrink-0 self-stretch" /> diff --git a/jobdri/src/app/page.tsx b/jobdri/src/app/page.tsx index 0c6adb2..19c3429 100644 --- a/jobdri/src/app/page.tsx +++ b/jobdri/src/app/page.tsx @@ -107,6 +107,13 @@ export default function Home() { status: "completed", }; }); + // console.log("순수 채용공고 개수:", savedOnlyDrafts.length); + // console.log("작성 중인 모의지원 개수:", mappedDrafts.length); + // console.log( + // "합쳐서 총 Draft 개수:", + // savedOnlyDrafts.length + mappedDrafts.length, + // ); + setDrafts([...savedOnlyDrafts, ...mappedDrafts]); setResults(mappedResults); } catch (error) { @@ -179,7 +186,9 @@ export default function Home() { generated: null, saved, }); - router.push("/mockApply/job/review"); + router.push( + `/mockApply/job/${targetDraft.jobPostingId}/review`, + ); }) .catch((error) => { console.error("채용 공고를 불러오지 못했습니다.", error); @@ -191,7 +200,7 @@ export default function Home() { case 1: case 2: router.push( - `/mockApply/${targetDraft.mockApplyId}?jobPostingId=${targetDraft.jobPostingId}`, + `/mockApply/job/${targetDraft.jobPostingId}/review`, ); break; case 3: diff --git a/jobdri/src/hooks/useAutoSave.ts b/jobdri/src/hooks/useAutoSave.ts new file mode 100644 index 0000000..4fad430 --- /dev/null +++ b/jobdri/src/hooks/useAutoSave.ts @@ -0,0 +1,63 @@ +import { useEffect, useRef } from "react"; + +// T: 감지할 데이터의 타입 +// R: API 호출 후 반환되는 결과값의 타입 (기본값은 void) +interface UseAutoSaveProps { + data: T; + onSave: (data: T) => Promise; // any 대신 제네릭 R 사용 + delay?: number; + enabled?: boolean; + onSuccess?: (savedTime: string, result: R) => void; // any 대신 제네릭 R 사용 + onError?: (error: unknown) => void; +} + +export function useAutoSave({ + data, + onSave, + delay = 1000, + enabled = true, + onSuccess, + onError, +}: UseAutoSaveProps) { + const isInitialRender = useRef(true); + const isSaving = useRef(false); + + const onSaveRef = useRef(onSave); + const onSuccessRef = useRef(onSuccess); + const onErrorRef = useRef(onError); + + useEffect(() => { + onSaveRef.current = onSave; + onSuccessRef.current = onSuccess; + onErrorRef.current = onError; + }, [onSave, onSuccess, onError]); + + useEffect(() => { + if (!enabled) return; + + if (isInitialRender.current) { + isInitialRender.current = false; + return; + } + + const timer = setTimeout(async () => { + if (isSaving.current) return; + isSaving.current = true; + + try { + const result = await onSaveRef.current(data); + const now = new Date(); + const timeString = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; + + onSuccessRef.current?.(timeString, result); + } catch (error) { + console.error("자동 저장 에러:", error); + onErrorRef.current?.(error); + } finally { + isSaving.current = false; + } + }, delay); + + return () => clearTimeout(timer); + }, [data, delay, enabled]); +} From 6a6d0e09294c0734617ebab1bca30fb0d17c9c5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9C=A4=EC=84=9C?= Date: Sat, 25 Jul 2026 19:37:16 +0900 Subject: [PATCH 2/5] =?UTF-8?q?Feat:=20=EC=9E=90=EB=8F=99=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=A1=9C=EC=A7=81=20=EB=B3=B4=EA=B0=95=20[JDDEV-89?= =?UTF-8?q?]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/mockApply/[mockApplyId]/page.tsx | 38 ++++- .../job/[jobPostingId]/review/page.tsx | 160 ++++++++---------- jobdri/src/components/common/lnb/Lnb.tsx | 2 +- jobdri/src/hooks/useAutoSave.ts | 63 ------- jobdri/src/hooks/useDebounce.ts | 15 ++ jobdri/src/lib/api/jobPostings.ts | 20 +-- 6 files changed, 132 insertions(+), 166 deletions(-) delete mode 100644 jobdri/src/hooks/useAutoSave.ts create mode 100644 jobdri/src/hooks/useDebounce.ts diff --git a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx index 58fd38c..fd5e176 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx @@ -1,5 +1,5 @@ "use client"; -import { useState, useEffect, use } from "react"; +import { useState, useEffect, useRef, use } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { QuestionList } from "@/components/mockApply/Question/QuestionList"; import JDSidePanel from "@/components/mockApply/Question/SidePanel"; @@ -26,6 +26,7 @@ import { fetchMyJobPosting } from "@/lib/api/jobPostings"; 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"; export default function MockApplyPage({ params, @@ -52,6 +53,7 @@ export default function MockApplyPage({ : sequenceJobPostingId; const [selectedId, setSelectedId] = useState(null); const [lastSavedTime, setLastSavedTime] = useState("저장 전"); + const isInitialRender = useRef(true); // 처음 데이터를 불러왔을 때 저장되는 것 방지 const [toast, setToast] = useState<{ open: boolean; @@ -335,6 +337,40 @@ 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 ( ) { return values.find((value) => value?.trim())?.trim() ?? ""; @@ -143,10 +144,74 @@ export default function JobPostingReviewPage() { const [isSaving, setIsSaving] = useState(false); const [saveErrorMessage, setSaveErrorMessage] = useState(""); - // ✅ 헤더에 표시할 자동저장 시간과, 초기 마운트 여부를 추적하는 ref const [lastSavedTime, setLastSavedTime] = useState(""); + const isInitialRender = useRef(true); - const isAutoSaving = useRef(false); + + const currentPayload: JobPostingSavePayload = useMemo( + () => ({ + profileColor, + postingName: jobPostingName.trim(), + companyName: companyName.trim(), + companySize: companySize, + jobTitle: roleName.trim(), + detailClassificationId: detailClassificationId, + task: task.trim(), + requirement: requirements.trim(), + preferred: preferred.trim(), + }), + [ + profileColor, + jobPostingName, + companyName, + companySize, + roleName, + detailClassificationId, + task, + requirements, + preferred, + ], + ); + + const debouncedPayload = useDebounce(currentPayload, 1000); + + useEffect(() => { + if ( + isLoading || + debouncedPayload.detailClassificationId <= 0 || + !debouncedPayload.postingName || + !debouncedPayload.companyName || + !debouncedPayload.jobTitle + ) { + return; + } + + if (isInitialRender.current) { + isInitialRender.current = false; + return; + } + + const autoSave = async () => { + try { + let savedResult; + if (jobPostingId) { + savedResult = await updateJobPosting(jobPostingId, debouncedPayload); + } else { + savedResult = await saveJobPosting(debouncedPayload); + setJobPostingId(savedResult.jobPostingId); + } + + const now = new Date(); + setLastSavedTime( + `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`, + ); + } catch (error) { + console.error("채용 공고 자동 저장 실패:", error); + } + }; + + autoSave(); + }, [debouncedPayload, jobPostingId, isLoading]); const { scrollAreaRef, scrollbarMetrics, updateScrollbarMetrics } = useLnbScrollMetrics(true, "job-posting-review", { trackPadding: 28 }); @@ -251,7 +316,6 @@ export default function JobPostingReviewPage() { router.replace("/"); } finally { setIsLoading(false); - // 처음 로딩 완료 시점을 자동저장 시간으로 초기 세팅 const now = new Date(); setLastSavedTime( `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`, @@ -262,78 +326,6 @@ export default function JobPostingReviewPage() { loadData(); }, [urlJobPostingId, router]); - // ✅ 2. 디바운싱 적용된 자동 저장 로직 - useEffect(() => { - // 로딩 중이거나, 필수값이 비어있거나, 직무 분류값이 없으면 실행 안 함 - if ( - isLoading || - !jobPostingName.trim() || - !companyName.trim() || - !roleName.trim() || - detailClassificationId <= 0 - ) { - return; - } - - // 처음 데이터를 불러와서 state에 셋팅될 때는 저장을 스킵 - if (isInitialRender.current) { - isInitialRender.current = false; - return; - } - - // 입력이 멈추고 1초(1000ms) 후에 API 호출 - const autoSaveTimer = setTimeout(async () => { - if (isAutoSaving.current) return; - isAutoSaving.current = true; - - try { - const payload: JobPostingSavePayload = { - profileColor, - postingName: jobPostingName.trim(), - companyName: companyName.trim(), - companySize: companySize, - jobTitle: roleName.trim(), - detailClassificationId: detailClassificationId, - task: task.trim(), - requirement: requirements.trim(), - preferred: preferred.trim(), - }; - - let savedResult; - if (jobPostingId) { - savedResult = await updateJobPosting(jobPostingId, payload); - } else { - savedResult = await saveJobPosting(payload); - setJobPostingId(savedResult.jobPostingId); // 새로 생성 시 ID 업데이트 - } - - const now = new Date(); - setLastSavedTime( - `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`, - ); - } catch (error) { - console.error("자동 저장 실패:", error); - } finally { - isAutoSaving.current = false; - } - }, 1000); - - // 값이 바뀌면 기존 타이머를 날리고 새로 타이머를 세팅 (디바운싱의 핵심) - return () => clearTimeout(autoSaveTimer); - }, [ - profileColor, - jobPostingName, - companyName, - roleName, - task, - requirements, - preferred, - companySize, - detailClassificationId, - jobPostingId, - isLoading, // 로딩 상태가 바뀔 때도 감지 - ]); - const handleHomeClick = (event: MouseEvent) => { event.preventDefault(); setShowHomeConfirm(true); @@ -350,21 +342,9 @@ export default function JobPostingReviewPage() { throw new Error("직무 분류 정보가 없어 공고를 저장할 수 없습니다."); } - const payload: JobPostingSavePayload = { - profileColor, - postingName: jobPostingName.trim(), - companyName: companyName.trim(), - companySize: companySize, - jobTitle: roleName.trim(), - detailClassificationId: detailClassificationId, - task: task.trim(), - requirement: requirements.trim(), - preferred: preferred.trim(), - }; - const savedJobPosting = jobPostingId - ? await updateJobPosting(jobPostingId, payload) - : await saveJobPosting(payload); + ? await updateJobPosting(jobPostingId, currentPayload) + : await saveJobPosting(currentPayload); const createdApply = await createApplyFromJobPosting({ jobPostingId: savedJobPosting.jobPostingId, @@ -404,7 +384,7 @@ export default function JobPostingReviewPage() { applicationLabel="첫 번째 지원" currentStep={1} steps={wizardSteps} - lastSavedAt={lastSavedTime} // ✅ 하드코딩(17:00) 삭제하고 자동저장 시간 상태 바인딩 + lastSavedAt={lastSavedTime} homeAction={{ label: "홈으로", onClick: handleHomeClick }} className="min-w-[1100px] max-w-none shrink-0 self-stretch" /> diff --git a/jobdri/src/components/common/lnb/Lnb.tsx b/jobdri/src/components/common/lnb/Lnb.tsx index 3927dc2..7fe1f49 100644 --- a/jobdri/src/components/common/lnb/Lnb.tsx +++ b/jobdri/src/components/common/lnb/Lnb.tsx @@ -173,7 +173,7 @@ export default function Lnb({ const unsubscribe = subscribeToNotificationStream( (newNotification) => { - console.log("🔥 [SSE 수신 완료] 서버에서 알림 옴!!!", newNotification); + // console.log("🔥 [SSE 수신 완료] 서버에서 알림 옴!!!", newNotification); setTimeout(() => { fetchAndUpdateNotifications(); }, 500); diff --git a/jobdri/src/hooks/useAutoSave.ts b/jobdri/src/hooks/useAutoSave.ts deleted file mode 100644 index 4fad430..0000000 --- a/jobdri/src/hooks/useAutoSave.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { useEffect, useRef } from "react"; - -// T: 감지할 데이터의 타입 -// R: API 호출 후 반환되는 결과값의 타입 (기본값은 void) -interface UseAutoSaveProps { - data: T; - onSave: (data: T) => Promise; // any 대신 제네릭 R 사용 - delay?: number; - enabled?: boolean; - onSuccess?: (savedTime: string, result: R) => void; // any 대신 제네릭 R 사용 - onError?: (error: unknown) => void; -} - -export function useAutoSave({ - data, - onSave, - delay = 1000, - enabled = true, - onSuccess, - onError, -}: UseAutoSaveProps) { - const isInitialRender = useRef(true); - const isSaving = useRef(false); - - const onSaveRef = useRef(onSave); - const onSuccessRef = useRef(onSuccess); - const onErrorRef = useRef(onError); - - useEffect(() => { - onSaveRef.current = onSave; - onSuccessRef.current = onSuccess; - onErrorRef.current = onError; - }, [onSave, onSuccess, onError]); - - useEffect(() => { - if (!enabled) return; - - if (isInitialRender.current) { - isInitialRender.current = false; - return; - } - - const timer = setTimeout(async () => { - if (isSaving.current) return; - isSaving.current = true; - - try { - const result = await onSaveRef.current(data); - const now = new Date(); - const timeString = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; - - onSuccessRef.current?.(timeString, result); - } catch (error) { - console.error("자동 저장 에러:", error); - onErrorRef.current?.(error); - } finally { - isSaving.current = false; - } - }, delay); - - return () => clearTimeout(timer); - }, [data, delay, enabled]); -} diff --git a/jobdri/src/hooks/useDebounce.ts b/jobdri/src/hooks/useDebounce.ts new file mode 100644 index 0000000..4fd3ee2 --- /dev/null +++ b/jobdri/src/hooks/useDebounce.ts @@ -0,0 +1,15 @@ +import { useState, useEffect } from "react"; + +export function useDebounce(value: T, delay: number = 1000): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} diff --git a/jobdri/src/lib/api/jobPostings.ts b/jobdri/src/lib/api/jobPostings.ts index bb87848..348956b 100644 --- a/jobdri/src/lib/api/jobPostings.ts +++ b/jobdri/src/lib/api/jobPostings.ts @@ -136,7 +136,6 @@ interface TimedRequestInit extends RequestInit { const DEFAULT_REQUEST_TIMEOUT_MS = 30000; - function getImageContentType(file: File) { if (file.type) { return file.type; @@ -213,7 +212,6 @@ async function fetchWithTimeout( } } - async function postJobPosting( path: string, input: JobPostingRequestInput, @@ -357,16 +355,20 @@ export async function updateJobPosting( export async function fetchMyJobPostings({ redirectOnUnauthorized = true, }: { redirectOnUnauthorized?: boolean } = {}) { - const response = await fetchWithTimeout(`${API_BASE_URL}/api/job-postings/me`, { - headers: getAuthHeaders(), - cache: "no-store", - }); + const response = await fetchWithTimeout( + `${API_BASE_URL}/api/job-postings/me`, + { + headers: getAuthHeaders(), + cache: "no-store", + }, + ); const result = await parseApiResponse( response, "내 지원 데이터를 불러오지 못했습니다.", { redirectOnUnauthorized }, ); + console.log("fetchMyJobPostings 응답:", result); return result ?? []; } @@ -449,11 +451,7 @@ export async function waitForJobPostingIngest( return status; } - if ( - status.error || - status.failureReason || - isFailedStatus(status.status) - ) { + if (status.error || status.failureReason || isFailedStatus(status.status)) { throw new Error( status.failureReason || status.error || From 7993314f5fc13b3e3fdccebbaea854a3114fa973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9C=A4=EC=84=9C?= Date: Sun, 26 Jul 2026 01:05:29 +0900 Subject: [PATCH 3/5] =?UTF-8?q?Fix:=20API=20=EC=9D=91=EB=8B=B5=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=88=98=EC=A0=95=20[JDDEV-89]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jobdri/src/app/page.tsx | 67 ++++++++++--------- .../mockApply/home/applicationHomeUtils.ts | 8 ++- jobdri/src/lib/api/jobPostings.ts | 14 +++- jobdri/src/lib/api/mockApplies.ts | 43 +++++++----- 4 files changed, 81 insertions(+), 51 deletions(-) diff --git a/jobdri/src/app/page.tsx b/jobdri/src/app/page.tsx index 19c3429..3cce75f 100644 --- a/jobdri/src/app/page.tsx +++ b/jobdri/src/app/page.tsx @@ -1,4 +1,5 @@ "use client"; + import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/common/buttons"; @@ -22,6 +23,7 @@ import { ApplicationCardData, } from "@/components/mockApply/home/types"; import { useReApply } from "@/hooks/useReApply"; +import { mapMockApplyToApplication } from "@/components/mockApply/home/applicationHomeUtils"; export default function Home() { const router = useRouter(); @@ -32,10 +34,18 @@ export default function Home() { useEffect(() => { const loadMockApplies = async () => { try { - const [data, jobPostings] = await Promise.all([ + const [data, fetchedJobPostings] = await Promise.all([ fetchMyMockApplies({ redirectOnUnauthorized: false }), fetchMyJobPostings({ redirectOnUnauthorized: false }).catch(() => []), ]); + + const jobPostings = Array.isArray(fetchedJobPostings) + ? fetchedJobPostings + : []; + + const inProgressList = data?.inProgress || []; + const completedList = data?.completed?.content || []; + const jobPostingById = new Map( jobPostings.map((jobPosting) => [ jobPosting.jobPostingId, @@ -43,7 +53,8 @@ export default function Home() { ]), ); - const mappedDrafts = data.inProgress.map((item) => { + // 🌟 작성 중인 모의지원(Drafts) 매핑 + const mappedDrafts = inProgressList.map((item) => { const jobPosting = jobPostingById.get(item.jobPostingId); return { @@ -60,14 +71,17 @@ export default function Home() { jobPosting?.detailClassificationName || "직무 미지정", currentStep: item.status === "ANSWER_WRITE" ? 2 : 1, - updatedAt: formatDate(item.createdAt), + updatedAt: item.createdAt ? formatDate(item.createdAt) : "-", }; }); + const linkedJobPostingIds = new Set( - [...data.inProgress, ...data.completed].map( + [...inProgressList, ...completedList].map( (item) => item.jobPostingId, ), ); + + // 🌟 순수 채용 공고(Drafts) 매핑 const savedOnlyDrafts = jobPostings .filter( (jobPosting) => !linkedJobPostingIds.has(jobPosting.jobPostingId), @@ -82,37 +96,29 @@ export default function Home() { jobPosting.detailClassificationName || "직무 미지정", currentStep: 1, - updatedAt: "-", + updatedAt: jobPosting.createdAt + ? formatDate(jobPosting.createdAt) + : "-", })); - const mappedResults = data.completed.map((item) => { + const mappedResults = completedList.map((item) => { const jobPosting = jobPostingById.get(item.jobPostingId); - return { - id: item.mockApplyId, - jobPostingId: item.jobPostingId, - mockApplyId: item.mockApplyId, - company: - item.companyName || jobPosting?.companyName || "회사명 미입력", - profileColor: jobPosting?.profileColor ?? "DEFAULT", - position: - item.jobTitle || - jobPosting?.jobTitle || - item.detailClassificationName || - jobPosting?.detailClassificationName || - "직무 미지정", - createdAt: formatDate(item.createdAt), - score: item.score || 0, - version: item.version || 1, - status: "completed", - }; + const cardData = mapMockApplyToApplication( + { + ...item, + profileColor: jobPosting?.profileColor ?? "DEFAULT", + jobTitle: item.jobTitle || jobPosting?.jobTitle || "", + detailClassificationName: + item.detailClassificationName || + jobPosting?.detailClassificationName || + "", + }, + "completed", + ); + + return cardData; }); - // console.log("순수 채용공고 개수:", savedOnlyDrafts.length); - // console.log("작성 중인 모의지원 개수:", mappedDrafts.length); - // console.log( - // "합쳐서 총 Draft 개수:", - // savedOnlyDrafts.length + mappedDrafts.length, - // ); setDrafts([...savedOnlyDrafts, ...mappedDrafts]); setResults(mappedResults); @@ -137,6 +143,7 @@ export default function Home() { console.error("채용 공고를 삭제하지 못했습니다.", error); } }; + return (
diff --git a/jobdri/src/components/mockApply/home/applicationHomeUtils.ts b/jobdri/src/components/mockApply/home/applicationHomeUtils.ts index 74c81e7..74129b9 100644 --- a/jobdri/src/components/mockApply/home/applicationHomeUtils.ts +++ b/jobdri/src/components/mockApply/home/applicationHomeUtils.ts @@ -1,6 +1,7 @@ import type { SavedJobPosting } from "@/lib/api/jobPostings"; import { getMockApplyResumeRecords, + JobPostingApplyType, type MockApplyHomeItem, } from "@/lib/api/mockApplies"; import { @@ -10,6 +11,7 @@ import { getJdReviewStorageKey, } from "@/components/mockApply/jd/jdReviewSections"; import type { ApplicationCardData } from "./types"; +import { JobPostingProfileColor } from "@/lib/api/jobPostings"; export const EMPTY_APPLICATION_TITLE = "아직 지원 내역이 없어요!"; export const EMPTY_APPLICATION_DESCRIPTION = @@ -81,7 +83,7 @@ export function mapMockApplyToApplication( jobPostingId: item.jobPostingId, company: companyName || "회사명 미입력", hasCompanyName: companyName.length > 0, - profileColor: item.profileColor ?? "DEFAULT", + profileColor: (item.profileColor ?? "DEFAULT") as JobPostingProfileColor, position: item.jobTitle || item.detailClassificationName || "직무 미분류", createdAt: formatCreatedAt(item.createdAt), createdAtTime: getCreatedAtTime(item.createdAt), @@ -89,8 +91,8 @@ export function mapMockApplyToApplication( mockApplyId: item.mockApplyId, resumePath: item.resumePath, status, - applyType: item.applyType, - version: item.version ?? 1, + applyType: item.applyType as JobPostingApplyType, + version: item.sequence ?? 1, }; } diff --git a/jobdri/src/lib/api/jobPostings.ts b/jobdri/src/lib/api/jobPostings.ts index 348956b..01978bc 100644 --- a/jobdri/src/lib/api/jobPostings.ts +++ b/jobdri/src/lib/api/jobPostings.ts @@ -62,6 +62,8 @@ export interface SavedJobPosting { detailClassificationId: number; detailClassificationName: string; task: string; + createdAt: string; + updatedAt?: string; requirement: string; preferred: string; } @@ -352,6 +354,13 @@ export async function updateJobPosting( ); } +export interface PaginatedResponse { + content: T[]; + totalElements: number; + totalPages: number; + size: number; +} + export async function fetchMyJobPostings({ redirectOnUnauthorized = true, }: { redirectOnUnauthorized?: boolean } = {}) { @@ -363,14 +372,13 @@ export async function fetchMyJobPostings({ }, ); - const result = await parseApiResponse( + const result = await parseApiResponse>( response, "내 지원 데이터를 불러오지 못했습니다.", { redirectOnUnauthorized }, ); - console.log("fetchMyJobPostings 응답:", result); - return result ?? []; + return result?.content ?? []; } export async function fetchMyJobPosting(jobPostingId: number) { diff --git a/jobdri/src/lib/api/mockApplies.ts b/jobdri/src/lib/api/mockApplies.ts index 8edd3c7..515be19 100644 --- a/jobdri/src/lib/api/mockApplies.ts +++ b/jobdri/src/lib/api/mockApplies.ts @@ -34,26 +34,39 @@ export interface MockApplyResumeRecord { updatedAt: string; } +interface PageResponse { + content: T[]; + totalElements: number; + totalPages: number; + size: number; + number: number; +} + +export interface MyMockAppliesResponse { + inProgress: MockApplyHomeItem[]; + completed: PageResponse; +} + +// 모의지원 아이템 타입 (이미지 속 필드들 반영) export interface MockApplyHomeItem { mockApplyId: number; - resumePath?: string | null; + resumePath: string; jobPostingId: number; - status: MockApplyProgressStatus | string; + sequence: number; + status: string; companyName: string; - profileColor?: JobPostingProfileColor | null; - postingName?: string | null; - detailClassificationName?: string | null; - jobTitle?: string | null; + profileColor: string; + detailClassificationName: string; + jobTitle: string; createdAt: string; - applyType: JobPostingApplyType; - score?: number | null; - version: number; + applyType: string; + score?: number; } -export interface MockApplyHomeList { - inProgress: MockApplyHomeItem[]; - completed: MockApplyHomeItem[]; -} +// export interface MockApplyHomeList { +// inProgress: MockApplyHomeItem[]; +// completed: MockApplyHomeItem[]; +// } export const APPLY_TYPE_STORAGE_KEY = "jobdri.applyType"; const MOCK_APPLY_RESUME_STORAGE_KEY = "jobdri.mockApplyResumeRecords"; @@ -116,7 +129,7 @@ export async function fetchMyMockApplies({ signal, }); - const result = await parseApiResponse( + const result = await parseApiResponse( response, "내 지원 데이터를 불러오지 못했습니다.", { redirectOnUnauthorized }, @@ -124,7 +137,7 @@ export async function fetchMyMockApplies({ return { inProgress: result.inProgress ?? [], - completed: result.completed ?? [], + completed: result.completed ?? { content: [] }, }; } From 83b3c1d7be8fe9ca9290fd104393c8b92d43f88e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9C=A4=EC=84=9C?= Date: Sun, 26 Jul 2026 01:15:53 +0900 Subject: [PATCH 4/5] =?UTF-8?q?Feat:=20=EC=B5=9C=EA=B7=BC=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EA=B8=B0=EB=A1=9D=20=EB=B3=B5=EA=B5=AC=20[JDDEV-89?= =?UTF-8?q?]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jobdri/src/components/common/lnb/Lnb.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/jobdri/src/components/common/lnb/Lnb.tsx b/jobdri/src/components/common/lnb/Lnb.tsx index 7fe1f49..32a01cd 100644 --- a/jobdri/src/components/common/lnb/Lnb.tsx +++ b/jobdri/src/components/common/lnb/Lnb.tsx @@ -27,6 +27,7 @@ import { type LnbItemKey, type LnbRecentItem, type LnbNavItem, + defaultRecentItems, } from "./LnbShared"; interface LnbProps { @@ -89,7 +90,8 @@ export default function Lnb({ const [searchQuery, setSearchQuery] = useState(""); const [isRecentOpen, setIsRecentOpen] = useState(defaultRecentOpen); - const [recentItems, setRecentItems] = useState([]); + const [recentItems, setRecentItems] = + useState(defaultRecentItems); const [selectedRecentItemId, setSelectedRecentItemId] = useState(""); const [notificationItems, setNotificationItems] = useState< From aa002f31b56304ec1e0a5b69f9d0bee2f6a08859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9C=A4=EC=84=9C?= Date: Sun, 26 Jul 2026 01:25:09 +0900 Subject: [PATCH 5/5] =?UTF-8?q?Feat:=20n=EC=9D=BC=EC=A0=84=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20format=20util=20=EC=B6=94=EA=B0=80=20[JDDEV-89]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jobdri/src/app/page.tsx | 8 +++++--- jobdri/src/utils/date.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/jobdri/src/app/page.tsx b/jobdri/src/app/page.tsx index 3cce75f..f82ba47 100644 --- a/jobdri/src/app/page.tsx +++ b/jobdri/src/app/page.tsx @@ -17,7 +17,7 @@ import { fetchMyJobPostings, } from "@/lib/api/jobPostings"; import { saveJobPostingAnalysis } from "@/app/mockApply/job/jobPostingDraftStore"; -import { formatDate } from "@/utils/date"; +import { formatDate, formatRelativeDate } from "@/utils/date"; import { DraftData, ApplicationCardData, @@ -71,7 +71,9 @@ export default function Home() { jobPosting?.detailClassificationName || "직무 미지정", currentStep: item.status === "ANSWER_WRITE" ? 2 : 1, - updatedAt: item.createdAt ? formatDate(item.createdAt) : "-", + updatedAt: item.createdAt + ? formatRelativeDate(item.createdAt) + : "-", }; }); @@ -97,7 +99,7 @@ export default function Home() { "직무 미지정", currentStep: 1, updatedAt: jobPosting.createdAt - ? formatDate(jobPosting.createdAt) + ? formatRelativeDate(jobPosting.createdAt) : "-", })); diff --git a/jobdri/src/utils/date.ts b/jobdri/src/utils/date.ts index 3dfd65e..0c8b034 100644 --- a/jobdri/src/utils/date.ts +++ b/jobdri/src/utils/date.ts @@ -8,3 +8,36 @@ export const formatDate = (dateString: string) => { return `${year}.${month}.${day}`; }; + +export function formatRelativeDate(date: string | Date): string { + const targetDate = new Date(date); + const now = new Date(); + + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const targetDay = new Date( + targetDate.getFullYear(), + targetDate.getMonth(), + targetDate.getDate(), + ); + + const diffTime = today.getTime() - targetDay.getTime(); + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) { + return "오늘"; + } + + if (diffDays === 1) { + return "어제"; + } + + if (diffDays >= 2 && diffDays <= 7) { + return `${diffDays}일전`; + } + + const year = String(targetDate.getFullYear()).slice(-2); + const month = String(targetDate.getMonth() + 1).padStart(2, "0"); + const day = String(targetDate.getDate()).padStart(2, "0"); + + return `${year}.${month}.${day}`; +}