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() ?? ""; @@ -142,6 +144,75 @@ export default function JobPostingReviewPage() { const [isSaving, setIsSaving] = useState(false); const [saveErrorMessage, setSaveErrorMessage] = useState(""); + const [lastSavedTime, setLastSavedTime] = useState(""); + + const isInitialRender = useRef(true); + + 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 }); @@ -158,11 +229,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 +254,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,6 +316,10 @@ 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")}`, + ); } }; @@ -268,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, @@ -322,7 +384,7 @@ export default function JobPostingReviewPage() { applicationLabel="첫 번째 지원" currentStep={1} steps={wizardSteps} - lastSavedAt="17:00" + lastSavedAt={lastSavedTime} 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..f82ba47 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"; @@ -16,12 +17,13 @@ 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, } 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,19 @@ export default function Home() { jobPosting?.detailClassificationName || "직무 미지정", currentStep: item.status === "ANSWER_WRITE" ? 2 : 1, - updatedAt: formatDate(item.createdAt), + updatedAt: item.createdAt + ? formatRelativeDate(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,31 +98,30 @@ export default function Home() { jobPosting.detailClassificationName || "직무 미지정", currentStep: 1, - updatedAt: "-", + updatedAt: jobPosting.createdAt + ? formatRelativeDate(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; }); + setDrafts([...savedOnlyDrafts, ...mappedDrafts]); setResults(mappedResults); } catch (error) { @@ -130,6 +145,7 @@ export default function Home() { console.error("채용 공고를 삭제하지 못했습니다.", error); } }; + return (
@@ -179,7 +195,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 +209,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/components/common/lnb/Lnb.tsx b/jobdri/src/components/common/lnb/Lnb.tsx index 3927dc2..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< @@ -173,7 +175,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/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/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..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; } @@ -136,7 +138,6 @@ interface TimedRequestInit extends RequestInit { const DEFAULT_REQUEST_TIMEOUT_MS = 30000; - function getImageContentType(file: File) { if (file.type) { return file.type; @@ -213,7 +214,6 @@ async function fetchWithTimeout( } } - async function postJobPosting( path: string, input: JobPostingRequestInput, @@ -354,21 +354,31 @@ export async function updateJobPosting( ); } +export interface PaginatedResponse { + content: T[]; + totalElements: number; + totalPages: number; + size: number; +} + 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( + const result = await parseApiResponse>( response, "내 지원 데이터를 불러오지 못했습니다.", { redirectOnUnauthorized }, ); - return result ?? []; + return result?.content ?? []; } export async function fetchMyJobPosting(jobPostingId: number) { @@ -449,11 +459,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 || 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: [] }, }; } 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}`; +}