From f5fafae3e879a19a5254ccd0f662233c7666de1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9C=A4=EC=84=9C?= Date: Thu, 23 Jul 2026 10:03:46 +0900 Subject: [PATCH 1/9] =?UTF-8?q?Feat:=20#88=20=EC=95=8C=EB=A6=BC=20SSE=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jobdri/package.json | 1 + jobdri/pnpm-lock.yaml | 8 + jobdri/src/app/page.tsx | 4 +- jobdri/src/components/common/lnb/Lnb.tsx | 135 ++++++++++--- .../components/common/lnb/LnbNotification.tsx | 189 +++++++++++------- .../mockApply/result/ResumeAnalysisDetail.tsx | 34 +--- .../src/constants/notificationIconStyles.ts | 35 ++++ jobdri/src/hooks/useScrollGradient.ts | 43 ++++ jobdri/src/lib/api/notification.ts | 89 +++++++++ 9 files changed, 413 insertions(+), 125 deletions(-) create mode 100644 jobdri/src/constants/notificationIconStyles.ts create mode 100644 jobdri/src/hooks/useScrollGradient.ts create mode 100644 jobdri/src/lib/api/notification.ts diff --git a/jobdri/package.json b/jobdri/package.json index a52bdee..8160de3 100644 --- a/jobdri/package.json +++ b/jobdri/package.json @@ -18,6 +18,7 @@ ] }, "dependencies": { + "@microsoft/fetch-event-source": "^2.0.1", "@tanstack/react-query": "^5.101.2", "@tosspayments/tosspayments-sdk": "^2.7.0", "clsx": "^2.1.1", diff --git a/jobdri/pnpm-lock.yaml b/jobdri/pnpm-lock.yaml index 14bd013..c4b7af6 100644 --- a/jobdri/pnpm-lock.yaml +++ b/jobdri/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@microsoft/fetch-event-source': + specifier: ^2.0.1 + version: 2.0.1 '@tanstack/react-query': specifier: ^5.101.2 version: 5.101.2(react@19.2.4) @@ -847,6 +850,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@microsoft/fetch-event-source@2.0.1': + resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -3757,6 +3763,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@microsoft/fetch-event-source@2.0.1': {} + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.10.0 diff --git a/jobdri/src/app/page.tsx b/jobdri/src/app/page.tsx index d52c7b2..cb2dea3 100644 --- a/jobdri/src/app/page.tsx +++ b/jobdri/src/app/page.tsx @@ -91,8 +91,8 @@ export default function Home() { }, []); return (
- -
+ +
diff --git a/jobdri/src/components/common/lnb/Lnb.tsx b/jobdri/src/components/common/lnb/Lnb.tsx index a7c92bf..05bd8ea 100644 --- a/jobdri/src/components/common/lnb/Lnb.tsx +++ b/jobdri/src/components/common/lnb/Lnb.tsx @@ -13,10 +13,16 @@ import { } from "@/lib/auth"; import { fetchCreditBalance } from "@/lib/api/credit"; import { fetchMyMockApplies } from "@/lib/api/mockApplies"; +import { + NotificationResponse, + fetchNotifications, + subscribeToNotificationStream, +} from "@/lib/api/notification"; import LnbDefault from "./LnbDefault"; import LnbFolded from "./LnbFolded"; import { - defaultNotificationItems, + // defaultNotificationItems, + mapApiToLnbItem, type LnbNotificationItem, } from "./LnbNotification"; import { @@ -61,9 +67,7 @@ export default function Lnb({ initialActiveItem = "apply", email, className, - notificationItems = defaultNotificationItems, defaultRecentOpen = true, - hasNotification = true, disableCreditFetch = false, }: LnbProps) { const router = useRouter(); @@ -87,36 +91,117 @@ export default function Lnb({ const [searchQuery, setSearchQuery] = useState(""); const [isRecentOpen, setIsRecentOpen] = useState(defaultRecentOpen); - // API Data States const [recentItems, setRecentItems] = useState([]); const [selectedRecentItemId, setSelectedRecentItemId] = useState(""); - // Fetch API Data + const [notificationItems, setNotificationItems] = useState< + LnbNotificationItem[] + >([]); + const [hasNotification, setHasNotification] = useState(false); + + // // Fetch 최근 항목 + // useEffect(() => { + // const loadData = async () => { + // try { + // const data = await fetchMyMockApplies(); + // const allItems = [...data.inProgress, ...data.completed]; + + // const mappedItems: LnbRecentItem[] = allItems.map((item) => ({ + // id: String(item.mockApplyId), + // companyName: item.companyName, + // jobTitle: + // item.jobTitle || item.detailClassificationName || "직무 미지정", + // version: item.version ?? 1, + // })); + + // setRecentItems(mappedItems); + // if (mappedItems.length > 0) { + // setSelectedRecentItemId(mappedItems[0].id); + // } + // } catch (error) { + // console.error("데이터 로드 실패:", error); + // setRecentItems([]); + // } + // }; + + // loadData(); + // }, []); + + // useEffect(() => { + // const loadInitialNotifications = async () => { + // try { + // const res = await fetch("/api/notifications"); + // const data = await res.json(); + + // if (data.isSuccess && data.result) { + // const mappedItems = data.result.map(mapApiToLnbItem); + // setNotificationItems(mappedItems); + // setHasNotification( + // mappedItems.some((item: LnbNotificationItem) => !item.read), + // ); + // } + // } catch (error) { + // console.error("초기 알림 목록 로드 실패:", error); + // } + // }; + + // loadInitialNotifications(); + + // const eventSource = new EventSource("/api/notifications/stream"); + + // eventSource.onmessage = (event) => { + // try { + // const newNotification = JSON.parse(event.data); + // const mappedNewItem = mapApiToLnbItem(newNotification); + // setNotificationItems((prev) => [mappedNewItem, ...prev]); + // setHasNotification(true); + // } catch (error) { + // console.error("SSE 메시지 파싱 오류:", error, event.data); + // } + // }; + + // eventSource.onerror = (error) => { + // console.error("SSE 스트림 연결 에러:", error); + // eventSource.close(); // 필요시 재연결 로직 추가 가능 + // }; + + // return () => { + // eventSource.close(); + // }; + // }, []); + useEffect(() => { - const loadData = async () => { + // 1. 기존 알림 먼저 불러오기 + const loadInitialNotifications = async () => { try { - const data = await fetchMyMockApplies(); - const allItems = [...data.inProgress, ...data.completed]; - - const mappedItems: LnbRecentItem[] = allItems.map((item) => ({ - id: String(item.mockApplyId), - companyName: item.companyName, - jobTitle: - item.jobTitle || item.detailClassificationName || "직무 미지정", - version: item.version ?? 1, - })); - - setRecentItems(mappedItems); - if (mappedItems.length > 0) { - setSelectedRecentItemId(mappedItems[0].id); + const data = await fetchNotifications(); + if (data.isSuccess && data.result) { + const mappedItems = data.result.map(mapApiToLnbItem); + setNotificationItems(mappedItems); + setHasNotification(mappedItems.some((item) => !item.read)); } } catch (error) { - console.error("데이터 로드 실패:", error); - setRecentItems([]); + console.error("초기 알림 목록 로드 실패:", error); } }; - loadData(); + loadInitialNotifications(); + + const unsubscribe = subscribeToNotificationStream( + (newNotification) => { + const mappedNewItem = mapApiToLnbItem(newNotification); + setNotificationItems((prev) => [mappedNewItem, ...prev]); + setHasNotification(true); + }, + // 에러가 났을 때 + (error) => { + console.error("실시간 알림 연결 문제 발생:", error); + }, + ); + + return () => { + unsubscribe(); + }; }, []); // Credit Fetch @@ -181,8 +266,8 @@ export default function Lnb({ email={displayEmail} emailInitial={emailInitial} hasNotification={hasNotification} - isRecentOpen={isRecentOpen} notificationItems={notificationItems} + isRecentOpen={isRecentOpen} recentItems={recentItems} searchQuery={searchQuery} selectedRecentItemId={selectedRecentItemId} @@ -198,7 +283,7 @@ export default function Lnb({ {showComingSoonModal && createPortal( -
+
결과 보러가기.", - timestamp: "26.07.14", - type: "normal", - }, - { - id: "analysis-error", - title: "분석중 문제가 발생했어요", - description: "<토스 | UX리서처> 분석을 다시 시도해주세요.", - timestamp: "26.07.14", - type: "fail", - }, - { - id: "job-posting-imported", - title: "공고 입력이 완료되었어요", - description: "<토스 | UX리서처> 자소서 쓰러 가기.", - timestamp: "26.07.14", - type: "complete", - }, - { - id: "notification-sample-1", - title: "알림 제목 영역입니다. 알림 제목 영역입니다.", - description: "알림 내용 최대 25자 알림 내용 최대 25자", - timestamp: "26.07.08", - }, - { - id: "notification-sample-2", - title: "알림 제목 영역입니다. 알림 제목 영역입니다.", - description: "알림 내용 최대 25자 알림 내용 최대 25자", - timestamp: "YY.MM.DD", - read: true, - }, - { - id: "notification-sample-3", - title: "알림 제목 영역입니다. 알림 제목 영역입니다.", - description: "알림 내용 최대 25자 알림 내용 최대 25자", - timestamp: "YY.MM.DD", - type: "fail", - read: true, - }, - { - id: "notification-sample-4", - title: "알림 제목 영역입니다. 알림 제목 영역입니다.", - description: "알림 내용 최대 25자 알림 내용 최대 25자", - timestamp: "YY.MM.DD", - type: "complete", - read: true, - }, -]; +// export const defaultNotificationItems: LnbNotificationItem[] = [ +// { +// id: "resume-analysis-complete", +// title: "자소서 분석이 완료되었어요", +// description: "<토스 | UX리서처> 결과 보러가기.", +// timestamp: "26.07.14", +// type: "normal", +// }, +// { +// id: "analysis-error", +// title: "분석중 문제가 발생했어요", +// description: "<토스 | UX리서처> 분석을 다시 시도해주세요.", +// timestamp: "26.07.14", +// type: "fail", +// }, +// { +// id: "job-posting-imported", +// title: "공고 입력이 완료되었어요", +// description: "<토스 | UX리서처> 자소서 쓰러 가기.", +// timestamp: "26.07.14", +// type: "complete", +// }, +// { +// id: "notification-sample-1", +// title: "알림 제목 영역입니다. 알림 제목 영역입니다.", +// description: "알림 내용 최대 25자 알림 내용 최대 25자", +// timestamp: "26.07.08", +// }, +// { +// id: "notification-sample-2", +// title: "알림 제목 영역입니다. 알림 제목 영역입니다.", +// description: "알림 내용 최대 25자 알림 내용 최대 25자", +// timestamp: "YY.MM.DD", +// read: true, +// }, +// { +// id: "notification-sample-3", +// title: "알림 제목 영역입니다. 알림 제목 영역입니다.", +// description: "알림 내용 최대 25자 알림 내용 최대 25자", +// timestamp: "YY.MM.DD", +// type: "fail", +// read: true, +// }, +// { +// id: "notification-sample-4", +// title: "알림 제목 영역입니다. 알림 제목 영역입니다.", +// description: "알림 내용 최대 25자 알림 내용 최대 25자", +// timestamp: "YY.MM.DD", +// type: "complete", +// read: true, +// }, +// ]; const notificationIconStyles: Record< NonNullable, @@ -100,6 +107,37 @@ const notificationIconStyles: Record< }, }; +function mapNotificationType(apiType?: string): "normal" | "fail" | "complete" { + // 🛡️ 방어막: 만약 백엔드가 type을 안 줬으면(undefined), 그냥 "normal"로 치고 넘어갑니다! + if (!apiType) { + return "normal"; + } + + // 이제 apiType이 확실히 글자라는 걸 아니까 안심하고 includes를 씁니다. + if (apiType.includes("FAILED") || apiType.includes("ERROR")) { + return "fail"; + } + if (apiType.includes("SUCCEEDED") || apiType.includes("COMPLETE")) { + return "complete"; + } + + return "normal"; +} + +// API 아이템 -> UI 아이템 변환 함수 +export function mapApiToLnbItem( + item: ApiNotificationItem, +): LnbNotificationItem { + return { + id: String(item.id), + title: item.title, + description: item.body, + timestamp: formatDate(item.createdAt), + type: mapNotificationType(item.type), + read: item.isRead, + }; +} + export function LnbNotificationButton({ hasNotification, notificationItems, @@ -108,8 +146,7 @@ export function LnbNotificationButton({ notificationItems: LnbNotificationItem[]; }) { const notificationMenuRef = useRef(null); - const [isNotificationPanelOpen, setIsNotificationPanelOpen] = - useState(false); + const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false); useOutsideClick( notificationMenuRef, @@ -120,7 +157,7 @@ export function LnbNotificationButton({ return ( - setIsNotificationPanelOpen( - (prevIsNotificationPanelOpen) => !prevIsNotificationPanelOpen, - ) - } + onClick={() => { + setIsNotificationPanelOpen((prev) => !prev); + // (선택 사항) 패널을 열 때마다 최신 알림을 다시 불러오고 싶다면 여기에 추가 + // if (!isNotificationPanelOpen) { fetchNotifications(); } + }} /> + + {/* 안 읽은 알림이 있으면 빨간 점 표시 */} {hasNotification && ( @@ -144,6 +183,7 @@ export function LnbNotificationButton({ {isNotificationPanelOpen && ( @@ -192,7 +232,9 @@ export function LnbNotificationPanel({ aria-label="알림 메뉴" aria-expanded={isMenuOpen} aria-haspopup="menu" - onClick={() => setIsMenuOpen((prevIsMenuOpen) => !prevIsMenuOpen)} + onClick={() => + setIsMenuOpen((prevIsMenuOpen) => !prevIsMenuOpen) + } />
@@ -230,15 +272,23 @@ function LnbNotificationList({ const { scrollAreaRef, scrollbarMetrics, updateScrollbarMetrics } = useLnbScrollMetrics(true, notificationItems.length); + const { scrollRef, showGradient, checkScroll } = + useScrollGradient([notificationItems]); return (
{ + if (scrollAreaRef) scrollAreaRef.current = node; + if (scrollRef) scrollRef.current = node; + }} + onScroll={(e) => { + updateScrollbarMetrics(); + checkScroll(); + }} className={clsx( "flex min-h-0 min-w-0 flex-1 flex-col items-start self-stretch overflow-y-auto overflow-x-hidden", - lnbHiddenScrollbarClass, + scrollbarClassS, )} >
@@ -251,9 +301,9 @@ function LnbNotificationList({
- - -
+ {showGradient && ( +
+ )}
); @@ -290,13 +340,14 @@ function LnbNotificationListItem({
( null, ); + const initialQuestions = analysisData.questions.map((q) => ({ id: String(q.questionId), question: q.questionContent, @@ -132,33 +132,9 @@ export default function ResumeAnalysisDetail({ initialQuestions[0]?.id || null, ); - const scrollRef = useRef(null); - const [showGradient, setShowGradient] = useState(false); - - const checkScroll = () => { - if (scrollRef.current) { - const { scrollHeight, clientHeight, scrollTop } = scrollRef.current; - const isScrollable = scrollHeight - clientHeight > 2; - const isNotAtBottom = - Math.ceil(scrollTop + clientHeight) < scrollHeight - 2; - setShowGradient(isScrollable && isNotAtBottom); - } - }; - - useEffect(() => { - const timer = setTimeout(checkScroll, 50); - const observer = new ResizeObserver(() => checkScroll()); - if (scrollRef.current) { - observer.observe(scrollRef.current); - } - window.addEventListener("resize", checkScroll); - - return () => { - clearTimeout(timer); - observer.disconnect(); - window.removeEventListener("resize", checkScroll); - }; - }, [selectedId, questions]); + // 👇 커스텀 훅 적용 (deps로 selectedId, questions 전달) + const { scrollRef, showGradient, checkScroll } = + useScrollGradient([selectedId, questions]); const currentQuestionIdx = questions.findIndex((q) => q.id === selectedId); const currentQuestion = questions[currentQuestionIdx]; diff --git a/jobdri/src/constants/notificationIconStyles.ts b/jobdri/src/constants/notificationIconStyles.ts new file mode 100644 index 0000000..fe79536 --- /dev/null +++ b/jobdri/src/constants/notificationIconStyles.ts @@ -0,0 +1,35 @@ +import { LnbNotificationItem } from "@/components/common/lnb"; +import { IconType } from "@/components/common/icons/Icon"; + +const notificationIconStyles: Record< + NonNullable, + { + iconType: IconType; + frameClassName: string; + iconClassName: string; + readFrameClassName: string; + readIconClassName: string; + } +> = { + normal: { + iconType: "SPARKLE", + frameClassName: "bg-fill-primary-assistive", + iconClassName: "text-icon-primary-strong", + readFrameClassName: "bg-fill-neutral-assistive", + readIconClassName: "text-icon-neutral-muted", + }, + fail: { + iconType: "WARN_24", + frameClassName: "bg-fill-system-fail-hover", + iconClassName: "text-fill-system-fail-strong", + readFrameClassName: "bg-fill-neutral-assistive", + readIconClassName: "text-icon-neutral-muted", + }, + complete: { + iconType: "CIRCLE_CHECK", + frameClassName: "bg-fill-secondary-assistive", + iconClassName: "text-fill-secondary-default", + readFrameClassName: "bg-fill-neutral-assistive", + readIconClassName: "text-icon-neutral-muted", + }, +}; diff --git a/jobdri/src/hooks/useScrollGradient.ts b/jobdri/src/hooks/useScrollGradient.ts new file mode 100644 index 0000000..76aff8b --- /dev/null +++ b/jobdri/src/hooks/useScrollGradient.ts @@ -0,0 +1,43 @@ +import { useState, useEffect, useRef, useCallback } from "react"; + +export function useScrollGradient( + deps: React.DependencyList = [], +) { + const scrollRef = useRef(null); + const [showGradient, setShowGradient] = useState(false); + + const checkScroll = useCallback(() => { + if (scrollRef.current) { + const { scrollHeight, clientHeight, scrollTop } = scrollRef.current; + const isScrollable = scrollHeight - clientHeight > 2; + const isNotAtBottom = + Math.ceil(scrollTop + clientHeight) < scrollHeight - 2; + + setShowGradient(isScrollable && isNotAtBottom); + } + }, []); + + useEffect(() => { + const timer = setTimeout(checkScroll, 50); + let observer: ResizeObserver | null = null; + + if (scrollRef.current) { + observer = new ResizeObserver(() => checkScroll()); + observer.observe(scrollRef.current); + } + + window.addEventListener("resize", checkScroll); + + return () => { + clearTimeout(timer); + if (observer) observer.disconnect(); + window.removeEventListener("resize", checkScroll); + }; + }, [checkScroll, ...deps]); + + return { + scrollRef, + showGradient, + checkScroll, + }; +} diff --git a/jobdri/src/lib/api/notification.ts b/jobdri/src/lib/api/notification.ts new file mode 100644 index 0000000..d32bb81 --- /dev/null +++ b/jobdri/src/lib/api/notification.ts @@ -0,0 +1,89 @@ +import { fetchEventSource } from "@microsoft/fetch-event-source"; +import { API_BASE_URL, getAuthHeaders } from "@/lib/auth"; + +export interface ApiNotificationItem { + id: number; + type: string; + title: string; + body: string; + isRead: boolean; + createdAt: string; +} + +export interface NotificationResponse { + isSuccess: boolean; + code: string; + message: string; + result: ApiNotificationItem[]; +} + +export async function fetchNotifications(): Promise { + const headers = getAuthHeaders(); + + const response = await fetch(`${API_BASE_URL}/api/notifications`, { + method: "GET", + headers: { + "Content-Type": "application/json", + ...headers, + }, + }); + + if (!response.ok) { + throw new Error(`알림 목록 조회 실패: ${response.status}`); + } + + return response.json(); +} + +export function subscribeToNotificationStream( + onMessage: (data: ApiNotificationItem) => void, + onError?: (error: unknown) => void, +) { + const headers = getAuthHeaders(); + const ctrl = new AbortController(); + + if (!headers.Authorization) { + console.warn("로그인 토큰이 없어 실시간 알림을 연결하지 않습니다."); + return () => ctrl.abort(); + } + + const tokenOnly = headers.Authorization.replace("Bearer ", ""); + + fetchEventSource( + `${API_BASE_URL}/api/notifications/stream?accessToken=${tokenOnly}`, + { + method: "GET", + headers: { + ...headers, + Accept: "text/event-stream", + }, + signal: ctrl.signal, + + onmessage(event) { + if (!event.data || !event.data.trim().startsWith("{")) { + console.log("JSON이 아닌 데이터 무시됨:", event.data); + return; + } + + try { + const parsedData = JSON.parse(event.data) as ApiNotificationItem; + onMessage(parsedData); + } catch (e: unknown) { + console.error( + "SSE 데이터 파싱 실패:", + e, + "들어온 데이터:", + event.data, + ); + } + }, + + onerror(err: unknown) { + console.error("SSE 스트림 에러:", err); + if (onError) onError(err); + }, + }, + ); + + return () => ctrl.abort(); +} From 5942948da097e245e67b572ea61e090e613741ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9C=A4=EC=84=9C?= Date: Thu, 23 Jul 2026 11:35:24 +0900 Subject: [PATCH 2/9] =?UTF-8?q?Feat:=20#88=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jobdri/src/app/page.tsx | 2 +- jobdri/src/components/common/lnb/Lnb.tsx | 27 +++- .../src/components/common/lnb/LnbDefault.tsx | 8 +- .../src/components/common/lnb/LnbFolded.tsx | 2 +- .../components/common/lnb/LnbNotification.tsx | 133 ++++++++++++++---- .../src/components/common/lnb/LnbShared.tsx | 10 +- jobdri/src/components/common/lnb/index.ts | 16 +-- .../src/constants/notificationIconStyles.ts | 2 +- jobdri/src/hooks/useNotification.ts | 2 +- jobdri/src/lib/api/notification.ts | 101 +++++++++++-- 10 files changed, 252 insertions(+), 51 deletions(-) diff --git a/jobdri/src/app/page.tsx b/jobdri/src/app/page.tsx index cb2dea3..1dd0ba5 100644 --- a/jobdri/src/app/page.tsx +++ b/jobdri/src/app/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/common/buttons"; import { BusinessFooter } from "@/components/common/footer"; -import { Lnb } from "@/components/common/lnb"; +import Lnb from "@/components/common/lnb/Lnb"; import ResultDraftList from "@/components/mockApply/home/ResultDraftList"; import ResultApplicationList from "@/components/mockApply/home/ResultApplicationList"; import { fetchMyMockApplies } from "@/lib/api/mockApplies"; diff --git a/jobdri/src/components/common/lnb/Lnb.tsx b/jobdri/src/components/common/lnb/Lnb.tsx index 05bd8ea..60f272d 100644 --- a/jobdri/src/components/common/lnb/Lnb.tsx +++ b/jobdri/src/components/common/lnb/Lnb.tsx @@ -17,13 +17,13 @@ import { NotificationResponse, fetchNotifications, subscribeToNotificationStream, + LnbNotificationItem, } from "@/lib/api/notification"; import LnbDefault from "./LnbDefault"; import LnbFolded from "./LnbFolded"; import { // defaultNotificationItems, mapApiToLnbItem, - type LnbNotificationItem, } from "./LnbNotification"; import { type LnbItemKey, @@ -239,6 +239,23 @@ export default function Lnb({ router.replace("/login"); }; + const handleReadItem = (id: string) => { + setNotificationItems((prev) => { + const updated = prev.map((item) => + item.id === id ? { ...item, read: true } : item, + ); + setHasNotification(updated.some((item) => !item.read)); + return updated; + }); + }; + + const handleMarkAllRead = () => { + setNotificationItems((prev) => + prev.map((item) => ({ ...item, read: true })), + ); + setHasNotification(false); + }; + return ( <> diff --git a/jobdri/src/components/common/lnb/LnbDefault.tsx b/jobdri/src/components/common/lnb/LnbDefault.tsx index 9809215..6888da0 100644 --- a/jobdri/src/components/common/lnb/LnbDefault.tsx +++ b/jobdri/src/components/common/lnb/LnbDefault.tsx @@ -1,6 +1,6 @@ "use client"; -import type { LnbNotificationItem } from "./LnbNotification"; +import { LnbNotificationItem } from "@/lib/api/notification"; import { LnbDefaultFooter, LnbDefaultHeader, @@ -29,6 +29,8 @@ export interface LnbDefaultProps { onSearchQueryChange: (value: string) => void; onToggleFold: () => void; onToggleRecentOpen: () => void; + onMarkAllRead?: () => void; + onReadItem?: (id: string) => void; } export default function LnbDefault({ @@ -48,6 +50,8 @@ export default function LnbDefault({ onSearchQueryChange, onToggleFold, onToggleRecentOpen, + onMarkAllRead, + onReadItem, }: LnbDefaultProps) { return ( <> @@ -80,6 +84,8 @@ export default function LnbDefault({ hasNotification={hasNotification} notificationItems={notificationItems} onLogout={onLogout} + onMarkAllRead={onMarkAllRead} + onReadItem={onReadItem} /> ); diff --git a/jobdri/src/components/common/lnb/LnbFolded.tsx b/jobdri/src/components/common/lnb/LnbFolded.tsx index 5ac992b..85a69e5 100644 --- a/jobdri/src/components/common/lnb/LnbFolded.tsx +++ b/jobdri/src/components/common/lnb/LnbFolded.tsx @@ -1,6 +1,6 @@ "use client"; -import type { LnbNotificationItem } from "./LnbNotification"; +import { LnbNotificationItem } from "@/lib/api/notification"; import { LnbDivider, LnbFoldedFooter, diff --git a/jobdri/src/components/common/lnb/LnbNotification.tsx b/jobdri/src/components/common/lnb/LnbNotification.tsx index d38fd56..3d5cb3a 100644 --- a/jobdri/src/components/common/lnb/LnbNotification.tsx +++ b/jobdri/src/components/common/lnb/LnbNotification.tsx @@ -1,6 +1,7 @@ "use client"; import { useRef, useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; import clsx from "clsx"; import { IconButton } from "@/components/common/buttons"; import Icon, { type IconType } from "@/components/common/icons/Icon"; @@ -15,20 +16,14 @@ import { import { ApiNotificationItem, NotificationResponse, + LnbNotificationItem, + markAllNotificationsAsRead, + markNotificationAsRead, } from "@/lib/api/notification"; import { formatDate } from "@/utils/date"; import { scrollbarClassS } from "../scrollbar/scrollbarStyles"; import { useScrollGradient } from "@/hooks/useScrollGradient"; -export interface LnbNotificationItem { - id: string; - title: string; - description: string; - timestamp: string; - type?: "normal" | "fail" | "complete"; - read?: boolean; -} - // export const defaultNotificationItems: LnbNotificationItem[] = [ // { // id: "resume-analysis-complete", @@ -108,12 +103,10 @@ const notificationIconStyles: Record< }; function mapNotificationType(apiType?: string): "normal" | "fail" | "complete" { - // 🛡️ 방어막: 만약 백엔드가 type을 안 줬으면(undefined), 그냥 "normal"로 치고 넘어갑니다! if (!apiType) { return "normal"; } - // 이제 apiType이 확실히 글자라는 걸 아니까 안심하고 includes를 씁니다. if (apiType.includes("FAILED") || apiType.includes("ERROR")) { return "fail"; } @@ -124,26 +117,38 @@ function mapNotificationType(apiType?: string): "normal" | "fail" | "complete" { return "normal"; } -// API 아이템 -> UI 아이템 변환 함수 export function mapApiToLnbItem( item: ApiNotificationItem, ): LnbNotificationItem { + const mockApplyId = + item.payload?.mockApplyId !== undefined && + item.payload?.mockApplyId !== null + ? String(item.payload.mockApplyId) + : undefined; + return { - id: String(item.id), + id: item.id ? String(item.id) : crypto.randomUUID(), title: item.title, description: item.body, timestamp: formatDate(item.createdAt), type: mapNotificationType(item.type), read: item.isRead, + targetType: item.targetType, + mockApplyId, + apiType: item.type, }; } export function LnbNotificationButton({ hasNotification, notificationItems, + onMarkAllRead, + onReadItem, }: { hasNotification: boolean; notificationItems: LnbNotificationItem[]; + onMarkAllRead?: () => void; + onReadItem?: (id: string) => void; }) { const notificationMenuRef = useRef(null); const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false); @@ -183,8 +188,9 @@ export function LnbNotificationButton({ {isNotificationPanelOpen && ( )} @@ -195,9 +201,13 @@ export function LnbNotificationButton({ export function LnbNotificationPanel({ notificationItems, className, + onMarkAllRead, + onReadItem, }: { notificationItems: LnbNotificationItem[]; className?: string; + onMarkAllRead?: () => void; + onReadItem?: (id: string) => void; }) { const hasNotificationItems = notificationItems.length > 0; const menuRef = useRef(null); @@ -247,7 +257,13 @@ export function LnbNotificationPanel({ role="menuitem" itemClassName="w-full" label="모두 읽음 표시" - onClick={() => setIsMenuOpen(false)} + onClick={() => { + setIsMenuOpen(false); + // 1. 서버에 전체 읽음 API 호출 (백그라운드) + markAllNotificationsAsRead().catch(console.error); + // 2. 부모에게 알려서 화면 즉시 갱신 + if (onMarkAllRead) onMarkAllRead(); + }} />
)} @@ -256,7 +272,10 @@ export function LnbNotificationPanel({ {hasNotificationItems ? ( - + ) : ( )} @@ -266,14 +285,25 @@ export function LnbNotificationPanel({ function LnbNotificationList({ notificationItems, + onReadItem, }: { notificationItems: LnbNotificationItem[]; + onReadItem?: (id: string) => void; }) { const { scrollAreaRef, scrollbarMetrics, updateScrollbarMetrics } = useLnbScrollMetrics(true, notificationItems.length); const { scrollRef, showGradient, checkScroll } = useScrollGradient([notificationItems]); + + const sortedItems = [...notificationItems].sort((a, b) => { + const aRead = a.read ?? false; + const bRead = b.read ?? false; + if (!aRead && bRead) return -1; + if (aRead && !bRead) return 1; + return 0; + }); + return (
@@ -282,7 +312,7 @@ function LnbNotificationList({ if (scrollAreaRef) scrollAreaRef.current = node; if (scrollRef) scrollRef.current = node; }} - onScroll={(e) => { + onScroll={() => { updateScrollbarMetrics(); checkScroll(); }} @@ -292,12 +322,19 @@ function LnbNotificationList({ )} >
- {notificationItems.map((notificationItem) => ( - - ))} + {sortedItems.map((notificationItem) => { + if (!notificationItem.id || notificationItem.id === "undefined") { + return null; + } + + return ( + + ); + })}
@@ -330,17 +367,65 @@ function LnbNotificationEmptyState() { function LnbNotificationListItem({ notificationItem, + onReadItem, }: { notificationItem: LnbNotificationItem; + onReadItem?: (id: string) => void; }) { + const router = useRouter(); const notificationType = notificationItem.type ?? "normal"; const iconStyle = notificationIconStyles[notificationType]; + const handleNotificationClick = () => { + // 🚨 어떤 녀석을 클릭했는지 원본 데이터를 통째로 찍어봅니다! + console.log("클릭한 알림 전체 데이터:", notificationItem); + + const { id, targetType, mockApplyId, read, apiType } = notificationItem; + + if (!read) { + markNotificationAsRead(id).catch(console.error); + if (onReadItem) onReadItem(id); + } + + // 만약 여기서 걸린다면 어떤 알림인지 콘솔 창에 찍힙니다. + if (!mockApplyId) { + console.warn("⚠️ 이 알림에는 mockApplyId가 없습니다!", notificationItem); + router.push("/apply"); // ID가 없으면 안전하게 목록으로 이동 + return; + } + + switch (apiType) { + case "JOB_POSTING_ASYNC_SUCCEEDED": + router.push(`/job-posting/${mockApplyId}`); + break; + + case "JOB_POSTING_ASYNC_FAILED": + router.push(`/job-posting/${mockApplyId}`); + break; + + case "ANALYSIS_ASYNC_SUCCEEDED": + router.push(`/mockApply/${mockApplyId}/result`); + break; + + case "ANALYSIS_ASYNC_FAILED": + -router.push( + `/mockApply/${mockApplyId}/result/resume-analysis-loading`, + ); + break; + + default: + // 일반(GENERAL) 알림이거나 기타 경우 + router.push(`/mockApply/${mockApplyId}/result`); + break; + } + }; + return (
void; + onMarkAllRead?: () => void; + onReadItem?: (id: string) => void; }) { const accountMenuRef = useRef(null); const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false); diff --git a/jobdri/src/components/common/lnb/index.ts b/jobdri/src/components/common/lnb/index.ts index 1fd27ec..a500251 100644 --- a/jobdri/src/components/common/lnb/index.ts +++ b/jobdri/src/components/common/lnb/index.ts @@ -1,8 +1,8 @@ -export { default as Lnb } from "./Lnb"; -export { default as LnbDefault } from "./LnbDefault"; -export { default as LnbFolded } from "./LnbFolded"; -export { - defaultNotificationItems, - LnbNotificationPanel, - type LnbNotificationItem, -} from "./LnbNotification"; +// export { default as Lnb } from "./Lnb"; +// export { default as LnbDefault } from "./LnbDefault"; +// export { default as LnbFolded } from "./LnbFolded"; +// export { +// defaultNotificationItems, +// LnbNotificationPanel, +// type LnbNotificationItem, +// } from "./LnbNotification"; diff --git a/jobdri/src/constants/notificationIconStyles.ts b/jobdri/src/constants/notificationIconStyles.ts index fe79536..9c6ceef 100644 --- a/jobdri/src/constants/notificationIconStyles.ts +++ b/jobdri/src/constants/notificationIconStyles.ts @@ -1,4 +1,4 @@ -import { LnbNotificationItem } from "@/components/common/lnb"; +import { LnbNotificationItem } from "@/lib/api/notification"; import { IconType } from "@/components/common/icons/Icon"; const notificationIconStyles: Record< diff --git a/jobdri/src/hooks/useNotification.ts b/jobdri/src/hooks/useNotification.ts index 6070487..241b3ae 100644 --- a/jobdri/src/hooks/useNotification.ts +++ b/jobdri/src/hooks/useNotification.ts @@ -1,6 +1,6 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; -import { LnbNotificationItem } from "@/components/common/lnb"; +import { LnbNotificationItem } from "@/lib/api/notification"; export function useNotifications() { const queryClient = useQueryClient(); diff --git a/jobdri/src/lib/api/notification.ts b/jobdri/src/lib/api/notification.ts index d32bb81..f3ae362 100644 --- a/jobdri/src/lib/api/notification.ts +++ b/jobdri/src/lib/api/notification.ts @@ -8,6 +8,26 @@ export interface ApiNotificationItem { body: string; isRead: boolean; createdAt: string; + targetType?: string; + targetId?: string; + payload?: { + mockApplyId?: number; + taskId?: string; + status?: string; + }; +} + +export interface LnbNotificationItem { + id: string; + title: string; + description: string; + timestamp: string; + type?: "normal" | "fail" | "complete"; + read?: boolean; + targetType?: string; + targetId?: string; + mockApplyId?: string; + apiType?: string; } export interface NotificationResponse { @@ -61,29 +81,94 @@ export function subscribeToNotificationStream( onmessage(event) { if (!event.data || !event.data.trim().startsWith("{")) { - console.log("JSON이 아닌 데이터 무시됨:", event.data); return; } try { const parsedData = JSON.parse(event.data) as ApiNotificationItem; + + if (!parsedData.title || parsedData.title.trim() === "") { + return; + } + onMessage(parsedData); } catch (e: unknown) { - console.error( - "SSE 데이터 파싱 실패:", - e, - "들어온 데이터:", - event.data, - ); + console.error("SSE 데이터 파싱 실패:", e); } }, onerror(err: unknown) { + if (err instanceof Error && err.name === "AbortError") { + return; + } + console.error("SSE 스트림 에러:", err); if (onError) onError(err); }, }, - ); + ).catch((err) => { + if (err.name === "AbortError") { + console.log("알림 스트림 연결이 정상적으로 해제되었습니다. (Abort)"); + return; + } + console.error("SSE 연결 중 예외 발생:", err); + }); return () => ctrl.abort(); } + +export async function markNotificationAsRead( + notificationId: string, +): Promise { + const headers = getAuthHeaders(); + + if (!headers.Authorization) { + console.warn("로그인 토큰이 없어 알림 읽음 처리를 수행할 수 없습니다."); + return; + } + + try { + const response = await fetch( + `${API_BASE_URL}/api/notifications/${notificationId}/read`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...headers, + }, + }, + ); + + if (!response.ok) { + throw new Error(`알림 읽음 처리 실패: ${response.status}`); + } + } catch (error: unknown) { + console.error("알림 읽음 처리 중 에러 발생:", error); + } +} + +// 전체 읽음 +export async function markAllNotificationsAsRead(): Promise { + const headers = getAuthHeaders(); + + if (!headers.Authorization) { + console.warn("로그인 토큰이 없어 전체 읽음 처리를 수행할 수 없습니다."); + return; + } + + try { + const response = await fetch(`${API_BASE_URL}/api/notifications/read-all`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...headers, // 토큰 헤더 포함 + }, + }); + + if (!response.ok) { + throw new Error(`전체 알림 읽음 처리 실패: ${response.status}`); + } + } catch (error: unknown) { + console.error("전체 알림 읽음 처리 중 에러 발생:", error); + } +} From ffb72db38ade67fc6520928e06802b3984d77e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9C=A4=EC=84=9C?= Date: Thu, 23 Jul 2026 15:23:48 +0900 Subject: [PATCH 3/9] =?UTF-8?q?Fix:=20#88=20Optimistic=20Update=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jobdri/src/components/common/lnb/Lnb.tsx | 4 ++-- jobdri/src/components/common/lnb/LnbShared.tsx | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/jobdri/src/components/common/lnb/Lnb.tsx b/jobdri/src/components/common/lnb/Lnb.tsx index 60f272d..2812a9d 100644 --- a/jobdri/src/components/common/lnb/Lnb.tsx +++ b/jobdri/src/components/common/lnb/Lnb.tsx @@ -298,8 +298,8 @@ export default function Lnb({ } onToggleFold={handleToggleFold} onToggleRecentOpen={() => setIsRecentOpen((prev) => !prev)} - onMarkAllRead={handleMarkAllRead} // 👈 추가 - onReadItem={handleReadItem} // 👈 추가 + onMarkAllRead={handleMarkAllRead} + onReadItem={handleReadItem} /> )} diff --git a/jobdri/src/components/common/lnb/LnbShared.tsx b/jobdri/src/components/common/lnb/LnbShared.tsx index f4e3aa4..1f5b119 100644 --- a/jobdri/src/components/common/lnb/LnbShared.tsx +++ b/jobdri/src/components/common/lnb/LnbShared.tsx @@ -699,6 +699,8 @@ export function LnbDefaultFooter({
From a9a86a9bdef25eb7599f3a6c5a67e9d41269013c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9C=A4=EC=84=9C?= Date: Thu, 23 Jul 2026 16:22:01 +0900 Subject: [PATCH 4/9] =?UTF-8?q?Feat:=20#88=20=EC=9E=90=EC=86=8C=EC=84=9C?= =?UTF-8?q?=20=EB=B6=84=EC=84=9D=20=EC=98=A4=EB=A5=98=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/mockApply/[mockApplyId]/page.tsx | 488 +++++------------- .../ResumeAnalysisLoadingPageClient.tsx | 7 +- .../components/common/lnb/LnbNotification.tsx | 18 +- .../mockApply/ResumeAnalysisLoading.tsx | 23 +- 4 files changed, 182 insertions(+), 354 deletions(-) diff --git a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx index b00fb2b..1e5769a 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx @@ -1,372 +1,170 @@ "use client"; -import { useState, useEffect, use } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; + +import { useEffect, useMemo, useState } from "react"; 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"; -import WritingForm from "@/components/mockApply/Question/WritingForm"; -import clsx from "clsx"; -import { scrollbarClassS } from "@/components/common/scrollbar/scrollbarStyles"; -import { - fetchSelectedQuestions, - saveQuestions, - saveApply, - type QuestionItem, -} from "@/lib/api/questions"; +import ProgressPanelRow from "@/components/common/progress/ProgressPanelRow"; +import { TextButton } from "@/components/common/buttons"; +import resumeAnalysisLoading from "@/assets/lottie/resume-analysis-loading.json"; +import LoadingGraphic from "@/components/mockApply/LoadingGraphic"; import { ModalCard } from "@/components/common/modal/ModalCard"; -import { Toast, type ToastVariant } from "@/components/common/toast"; -import { CtaFooter } from "@/components/common/cta"; -import { fetchCreditBalance } from "@/lib/api/credit"; -import { requestAnalysis } from "@/lib/api/analysis"; -import MockApplyTemplate from "@/components/common/MockApplyTemplate"; - -const ModalOverlay = ({ children }: { children: React.ReactNode }) => ( -
- {children} -
-); - -export default function MockApplyPage({ - params, -}: { - params: Promise<{ mockApplyId: string }>; -}) { - const { mockApplyId } = use(params); - const router = useRouter(); - - const [isPanelOpen, setIsPanelOpen] = useState(false); - const [questions, setQuestions] = useState([]); - const [selectedId, setSelectedId] = useState(null); - const [lastSavedTime, setLastSavedTime] = useState("저장 전"); - - const [toast, setToast] = useState<{ - open: boolean; - message: string; - variant?: string; - }>({ - open: false, - message: "", - variant: "normal", - }); - const [modalTarget, setModalTarget] = useState(null); - 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) => ({ - questionId: q.questionId, - content: q.question, - charLimit: q.maxLength ?? 1000, - answer: q.answer || "", - })); - }; +interface ResumeAnalysisLoadingProps { + durationMs: number; + onBack?: () => void; + onComplete?: () => void; + applicationLabel?: string; + isFailed?: boolean; + onErrorConfirm?: () => void; +} - // 초기 데이터 불러오기 - useEffect(() => { - const loadQuestions = async () => { - try { - const data = await fetchSelectedQuestions(Number(mockApplyId)); - setQuestions(data); - if (data && data.length > 0) setSelectedId(data[0].id); - } catch (error) { - console.error("문항을 불러오지 못했습니다.", error); - } - }; - loadQuestions(); - }, [mockApplyId]); +function formatRemainingTime(totalSeconds: number) { + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; - // 자동 저장 타이머 - useEffect(() => { - if (!toast.open || toast.variant !== "check") return; + if (minutes === 0) { + return `${seconds}초`; + } - const retryToastTimer = window.setTimeout(() => { - setToast({ open: false, message: "", variant: "normal" }); - }, 3000); + return `${minutes}분 ${seconds}초`; +} - return () => window.clearTimeout(retryToastTimer); - }, [toast.open, toast.variant]); +export default function ResumeAnalysisLoading({ + durationMs, + onBack, + onComplete, + applicationLabel, + isFailed = false, + onErrorConfirm, +}: ResumeAnalysisLoadingProps) { + const initialRemainingSeconds = Math.max(1, Math.ceil(durationMs / 1000)); + const [remainingSeconds, setRemainingSeconds] = useState( + initialRemainingSeconds, + ); + const [currentStep, setCurrentStep] = useState(1); useEffect(() => { - if (questions.length === 0) return; - - const autoSaveTimer = setTimeout(async () => { - try { - await saveApply(Number(mockApplyId), getSubmitPayload(questions)); - - const now = new Date(); - const timeString = `${now.getHours()}:${String(now.getMinutes()).padStart(2, "0")}`; - setLastSavedTime(timeString); - console.log("자동 저장 완료!", timeString); - } catch (error) { - console.error("자동 저장 실패:", error); - } - }, 2000); + if (isFailed) return; - return () => clearTimeout(autoSaveTimer); - }, [questions, mockApplyId]); + const countdownTimer = window.setInterval(() => { + setRemainingSeconds((prev) => Math.max(prev - 1, 0)); + }, 1000); - // 핸들러 - const performDelete = (targetId: string) => { - setQuestions((prev) => { - const targetIndex = prev.findIndex((q) => q.id === targetId); - const newList = prev.filter((q) => q.id !== targetId); - if (selectedId === targetId && newList.length > 0) { - const newSelectedIndex = Math.max(0, targetIndex - 1); - setSelectedId(newList[newSelectedIndex].id); - } - return newList; - }); - setToast({ open: true, message: "문항이 삭제되었어요" }); - setTimeout(() => setToast({ open: false, message: "" }), 3000); - }; + const completeTimer = window.setTimeout(() => { + onComplete?.(); + }, durationMs); - const handleConfirm = async () => { - try { - await saveApply(Number(mockApplyId), getSubmitPayload(questions)); - const response = await requestAnalysis(Number(mockApplyId)); - const taskId = response.taskId; + const secondStepTimer = window.setTimeout(() => { + setCurrentStep(2); + }, durationMs / 4); - router.push( - `/mockApply/${mockApplyId}/result/resume-analysis-loading?taskId=${taskId}`, - ); - } catch (error) { - console.error("제출 실패:", error); - alert("채점 요청 중 오류가 발생했습니다."); - } - }; + const thirdStepTimer = window.setTimeout(() => { + setCurrentStep(3); + }, durationMs / 2); - const handleDeleteQuestion = (targetId: string) => { - const targetQ = questions.find((q) => q.id === targetId); - const hasContent = - (targetQ?.question?.trim() || "") !== "" || - (targetQ?.answer?.trim() || "") !== ""; - - if (hasContent) { - setModalTarget(targetId); - } else { - performDelete(targetId); - } - }; - - const handleUpdate = (field: string, value: string) => { - if (!selectedId) return; - setQuestions((prev) => - prev.map((q) => { - if (q.id !== selectedId) return q; - if (field === "title") return { ...q, question: value }; - if (field === "answer") return { ...q, answer: value }; - if (field === "maxLength") return { ...q, maxLength: Number(value) }; - return q; - }), + const fourthStepTimer = window.setTimeout( + () => { + setCurrentStep(4); + }, + (durationMs / 4) * 3, ); - }; - - const handleAddQuestion = async () => { - if (questions.length >= 5) return; - try { - const newQuestion: QuestionItem = { - id: `temp-${Date.now()}`, - questionId: 0, - question: "새로운 문항", - answer: "", - maxLength: 1000, - custom: true, - }; - - const updatedQuestions = [...questions, newQuestion]; - await saveQuestions(Number(mockApplyId), updatedQuestions); - const refreshedQuestions = await fetchSelectedQuestions( - Number(mockApplyId), - ); - setQuestions(refreshedQuestions); - - const lastQuestion = refreshedQuestions[refreshedQuestions.length - 1]; - if (lastQuestion) setSelectedId(lastQuestion.id); - } catch (error) { - console.error("문항 추가 실패:", error); - setToast({ - open: true, - message: "문항 추가에 실패했어요. 잠시 후 다시 시도해주세요.", - variant: "normal", - }); - } - }; - - const handleTrySubmit = async () => { - try { - const currentCredit = await fetchCreditBalance(); - setIsConfirmModalOpen(false); - - if (currentCredit > 0) { - await handleConfirm(); - } else { - setIsCreditShortModalOpen(true); - } - } catch (error) { - console.error("크레딧 조회/저장 실패:", error); - setToast({ - open: true, - message: "오류가 발생했어요. 잠시 후 다시 시도해주세요.", - }); - setTimeout( - () => setToast({ open: false, message: "", variant: "normal" }), - 3000, - ); - } - }; - - // 현재 선택된 문항 폼 데이터 - const currentQ = questions.find((q) => q.id === selectedId); - const mappedQuestionForForm = currentQ - ? { - title: currentQ.question, - answer: currentQ.answer || "", - maxLength: String(currentQ.maxLength || 1000), - } - : null; + return () => { + window.clearInterval(countdownTimer); + window.clearTimeout(completeTimer); + window.clearTimeout(secondStepTimer); + window.clearTimeout(thirdStepTimer); + window.clearTimeout(fourthStepTimer); + }; + }, [durationMs, onComplete, isFailed]); + + const progressItems = useMemo( + () => [ + { + title: "내용 구조 파악", + description: "작성해주신 내용을 확인하고 의미를 분석합니다.", + }, + { + title: "자소서 평가", + description: "직무 스킬을 매칭하고 적합도를 평가합니다.", + }, + { + title: "지원자 간 비교", + description: "동일 회사 및 직무의 지원자와 비교 평가합니다.", + }, + { + title: "리포트 생성", + description: "분석 결과를 토대로 약점 리포트를 생성합니다.", + }, + ], + [], + ); return ( - -
-
+
+ +
+
-
- - } - /> -
+
+ + +
+
-
-
- - setIsLeaveModalOpen(true) }} - nextAction={{ - label: "채점하기", - onClick: () => setIsConfirmModalOpen(true), - disabled: - !mappedQuestionForForm || - questions.some((q) => !(q.answer || "").trim()), - iconType: "SPARKLE", - }} - /> - - setIsPanelOpen(false)} - onOpen={() => setIsPanelOpen(true)} - /> +
+ +
- {toast.open && ( - setToast({ ...toast, open: false })} - className="absolute top-6" + {isFailed && ( +
+ - )} - - {modalTarget && ( - - setModalTarget(null)} - onErrorClick={() => { - performDelete(modalTarget); - setModalTarget(null); - }} - /> - - )} - - {isLeaveModalOpen && ( - - setIsLeaveModalOpen(false)} - onPrimaryClick={() => { - setIsLeaveModalOpen(false); - router.push(`/mockApply/actual/${mockApplyId}/jd-review`); - }} - /> - - )} - - {isConfirmModalOpen && ( - - setIsConfirmModalOpen(false)} - onPrimaryClick={handleTrySubmit} - /> - - )} - - {isCreditShortModalOpen && ( - - setIsCreditShortModalOpen(false)} - onPrimaryClick={() => setIsCreditShortModalOpen(false)} - /> - - )} -
- +
+ )} +
); } 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..06693e5 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,7 +1,7 @@ "use client"; import { useCallback } from "react"; -import { useRouter, useParams } from "next/navigation"; +import { useRouter, useParams, useSearchParams } from "next/navigation"; import ResumeAnalysisLoading from "@/components/mockApply/ResumeAnalysisLoading"; const RESUME_ANALYSIS_LOADING_DURATION_MS = 316_000; @@ -14,9 +14,13 @@ export default function ResumeAnalysisLoadingPageClient({ applicationLabel, }: ResumeAnalysisLoadingPageClientProps) { const router = useRouter(); + const params = useParams(); const mockApplyId = params.mockApplyId; + const searchParams = useSearchParams(); + const isError = searchParams.get("error") === "true"; + const handleComplete = useCallback(() => { if (!mockApplyId) return; router.replace(`/mockApply/${mockApplyId}/result`); @@ -27,6 +31,7 @@ export default function ResumeAnalysisLoadingPageClient({ durationMs={RESUME_ANALYSIS_LOADING_DURATION_MS} onComplete={handleComplete} applicationLabel={applicationLabel} + isFailed={isError} /> ); } diff --git a/jobdri/src/components/common/lnb/LnbNotification.tsx b/jobdri/src/components/common/lnb/LnbNotification.tsx index 3d5cb3a..14c4efe 100644 --- a/jobdri/src/components/common/lnb/LnbNotification.tsx +++ b/jobdri/src/components/common/lnb/LnbNotification.tsx @@ -110,7 +110,15 @@ function mapNotificationType(apiType?: string): "normal" | "fail" | "complete" { if (apiType.includes("FAILED") || apiType.includes("ERROR")) { return "fail"; } - if (apiType.includes("SUCCEEDED") || apiType.includes("COMPLETE")) { + + if (apiType === "ANALYSIS_ASYNC_SUCCEEDED" || apiType === "GENERAL") { + return "normal"; + } + + if ( + apiType === "JOB_POSTING_ASYNC_SUCCEEDED" || + apiType.includes("COMPLETE") + ) { return "complete"; } @@ -365,7 +373,7 @@ function LnbNotificationEmptyState() { ); } -function LnbNotificationListItem({ +export function LnbNotificationListItem({ notificationItem, onReadItem, }: { @@ -408,18 +416,16 @@ function LnbNotificationListItem({ break; case "ANALYSIS_ASYNC_FAILED": - -router.push( - `/mockApply/${mockApplyId}/result/resume-analysis-loading`, + router.push( + `/mockApply/${mockApplyId}/result/resume-analysis-loading?error=true`, ); break; default: - // 일반(GENERAL) 알림이거나 기타 경우 router.push(`/mockApply/${mockApplyId}/result`); break; } }; - return (
void; onComplete?: () => void; applicationLabel?: string; + isFailed?: boolean; + onErrorConfirm?: () => void; } function formatRemainingTime(totalSeconds: number) { @@ -30,14 +34,18 @@ export default function ResumeAnalysisLoading({ onBack, onComplete, applicationLabel, + isFailed = false, }: ResumeAnalysisLoadingProps) { const initialRemainingSeconds = Math.max(1, Math.ceil(durationMs / 1000)); const [remainingSeconds, setRemainingSeconds] = useState( initialRemainingSeconds, ); const [currentStep, setCurrentStep] = useState(1); + const router = useRouter(); useEffect(() => { + if (isFailed) return; + const countdownTimer = window.setInterval(() => { setRemainingSeconds((prev) => Math.max(prev - 1, 0)); }, 1000); @@ -68,7 +76,7 @@ export default function ResumeAnalysisLoading({ window.clearTimeout(thirdStepTimer); window.clearTimeout(fourthStepTimer); }; - }, [durationMs, onComplete]); + }, [durationMs, onComplete, isFailed]); const progressItems = useMemo( () => [ @@ -93,7 +101,7 @@ export default function ResumeAnalysisLoading({ ); return ( -
+
@@ -147,6 +155,17 @@ export default function ResumeAnalysisLoading({
+ + {isFailed && ( +
+ router.replace("/")} + /> +
+ )}
); } From 9678fcb75ace75eaddb6798db475abff028a8295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9C=A4=EC=84=9C?= Date: Thu, 23 Jul 2026 17:45:50 +0900 Subject: [PATCH 5/9] =?UTF-8?q?Fix:=20#88=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jobdri/src/components/common/MockApplyTemplate.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/jobdri/src/components/common/MockApplyTemplate.tsx b/jobdri/src/components/common/MockApplyTemplate.tsx index 194951d..82e95fe 100644 --- a/jobdri/src/components/common/MockApplyTemplate.tsx +++ b/jobdri/src/components/common/MockApplyTemplate.tsx @@ -6,6 +6,7 @@ import CtaFooter from "@/components/common/cta/CtaFooter"; import { fetchSequence } from "@/lib/api/result"; import { LEAVE_MODAL_CONFIG } from "@/constants/modalConfig"; import { ModalCard } from "./modal/ModalCard"; +import { type IconType } from "@/components/common/icons/Icon"; // 👈 아이콘 타입 임포트 추가 interface MockApplyTemplateProps { mockApplyId: number; @@ -22,6 +23,8 @@ interface MockApplyTemplateProps { isNextDisabled?: boolean; onRetryClick?: () => void; onSaveAndExitClick?: () => void; + nextLabel?: string; + nextIconType?: IconType; } export default function MockApplyTemplate({ @@ -37,6 +40,8 @@ export default function MockApplyTemplate({ isNextDisabled, onRetryClick, onSaveAndExitClick, + nextLabel = "다음으로", + nextIconType, }: MockApplyTemplateProps) { const router = useRouter(); const [showHomeConfirm, setShowHomeConfirm] = useState(false); @@ -86,14 +91,15 @@ export default function MockApplyTemplate({ type={isResultPage ? "result" : "wizard"} backAction={{ onClick: onBackClick }} nextAction={{ - label: "채점하기", - iconType: "SPARKLE", + label: nextLabel, + iconType: nextIconType, onClick: onNextClick, disabled: isNextDisabled, }} retryAction={{ onClick: onRetryClick }} saveAction={{ onClick: onSaveAndExitClick }} /> + {showHomeConfirm && (
Date: Fri, 24 Jul 2026 10:52:43 +0900 Subject: [PATCH 6/9] =?UTF-8?q?Feat:=20#88=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=82=AD=EC=A0=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/common/lnb/LnbNotification.tsx | 45 +++++++++++++++---- jobdri/src/lib/api/notification.ts | 2 + 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/jobdri/src/components/common/lnb/LnbNotification.tsx b/jobdri/src/components/common/lnb/LnbNotification.tsx index 14c4efe..c8a06d6 100644 --- a/jobdri/src/components/common/lnb/LnbNotification.tsx +++ b/jobdri/src/components/common/lnb/LnbNotification.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState, useEffect } from "react"; +import { useRef, useState, useEffect, useMemo } from "react"; import { useRouter } from "next/navigation"; import clsx from "clsx"; import { IconButton } from "@/components/common/buttons"; @@ -141,6 +141,7 @@ export function mapApiToLnbItem( timestamp: formatDate(item.createdAt), type: mapNotificationType(item.type), read: item.isRead, + readAt: item.readAt, targetType: item.targetType, mockApplyId, apiType: item.type, @@ -304,13 +305,39 @@ function LnbNotificationList({ const { scrollRef, showGradient, checkScroll } = useScrollGradient([notificationItems]); - const sortedItems = [...notificationItems].sort((a, b) => { - const aRead = a.read ?? false; - const bRead = b.read ?? false; - if (!aRead && bRead) return -1; - if (aRead && !bRead) return 1; - return 0; - }); + const [now, setNow] = useState(null); + + useEffect(() => { + const timer = setTimeout(() => { + setNow(Date.now()); + }, 0); + + return () => clearTimeout(timer); + }, []); + + const sortedItems = useMemo(() => { + let filteredItems = notificationItems; + + if (now !== null) { + const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000; + + filteredItems = notificationItems.filter((item) => { + if (!item.read) return true; + if (!item.readAt) return true; + + const readTime = new Date(item.readAt).getTime(); + return readTime >= sevenDaysAgo; + }); + } + + return [...filteredItems].sort((a, b) => { + const aRead = a.read ?? false; + const bRead = b.read ?? false; + if (!aRead && bRead) return -1; + if (aRead && !bRead) return 1; + return 0; + }); + }, [notificationItems, now]); return (
@@ -330,6 +357,7 @@ function LnbNotificationList({ )} >
+ {/* 💡 정렬과 필터링이 모두 완료된 sortedItems를 매핑합니다 */} {sortedItems.map((notificationItem) => { if (!notificationItem.id || notificationItem.id === "undefined") { return null; @@ -353,7 +381,6 @@ function LnbNotificationList({
); } - function LnbNotificationEmptyState() { return (
diff --git a/jobdri/src/lib/api/notification.ts b/jobdri/src/lib/api/notification.ts index f3ae362..af12832 100644 --- a/jobdri/src/lib/api/notification.ts +++ b/jobdri/src/lib/api/notification.ts @@ -7,6 +7,7 @@ export interface ApiNotificationItem { title: string; body: string; isRead: boolean; + readAt: string; createdAt: string; targetType?: string; targetId?: string; @@ -28,6 +29,7 @@ export interface LnbNotificationItem { targetId?: string; mockApplyId?: string; apiType?: string; + readAt?: string; } export interface NotificationResponse { From c4a03aaa8f34b69082913ae2b932a844d27affb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9C=A4=EC=84=9C?= Date: Fri, 24 Jul 2026 11:28:43 +0900 Subject: [PATCH 7/9] =?UTF-8?q?Fix:=20#88=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/mockApply/[mockApplyId]/page.tsx | 341 ++++-------------- .../components/common/modal/ModalOverlay.tsx | 24 ++ 2 files changed, 98 insertions(+), 267 deletions(-) create mode 100644 jobdri/src/components/common/modal/ModalOverlay.tsx diff --git a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx index 39073fb..89c046a 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx @@ -1,14 +1,4 @@ "use client"; -<<<<<<< HEAD - -import { useEffect, useMemo, useState } from "react"; -import Header from "@/components/common/header/Header"; -import ProgressPanelRow from "@/components/common/progress/ProgressPanelRow"; -import { TextButton } from "@/components/common/buttons"; -import resumeAnalysisLoading from "@/assets/lottie/resume-analysis-loading.json"; -import LoadingGraphic from "@/components/mockApply/LoadingGraphic"; -import { ModalCard } from "@/components/common/modal/ModalCard"; -======= import { useState, useEffect, use } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { QuestionList } from "@/components/mockApply/Question/QuestionList"; @@ -35,43 +25,8 @@ 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"; ->>>>>>> develop - -interface ResumeAnalysisLoadingProps { - durationMs: number; - onBack?: () => void; - onComplete?: () => void; - applicationLabel?: string; - isFailed?: boolean; - onErrorConfirm?: () => void; -} - -<<<<<<< HEAD -function formatRemainingTime(totalSeconds: number) { - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; +import { ModalOverlay } from "@/components/common/modal/ModalOverlay"; - if (minutes === 0) { - return `${seconds}초`; - } - - return `${minutes}분 ${seconds}초`; -} - -export default function ResumeAnalysisLoading({ - durationMs, - onBack, - onComplete, - applicationLabel, - isFailed = false, - onErrorConfirm, -}: ResumeAnalysisLoadingProps) { - const initialRemainingSeconds = Math.max(1, Math.ceil(durationMs / 1000)); - const [remainingSeconds, setRemainingSeconds] = useState( - initialRemainingSeconds, - ); - const [currentStep, setCurrentStep] = useState(1); -======= export default function MockApplyPage({ params, }: { @@ -102,17 +57,14 @@ export default function MockApplyPage({ open: boolean; message: string; variant?: string; - }>({ - open: false, - message: "", - variant: "normal", - }); + }>({ open: false, message: "", variant: "normal" }); + const [modalTarget, setModalTarget] = useState(null); const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); const [isCreditShortModalOpen, setIsCreditShortModalOpen] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); - // 데이터 포맷팅 함수 + const getSubmitPayload = (questionsData: QuestionItem[]) => { return questionsData.map((q) => ({ questionId: q.questionId, @@ -122,10 +74,9 @@ export default function MockApplyPage({ })); }; - // 초기 데이터 불러오기 + // 1. 초기 데이터 불러오기 useEffect(() => { let ignore = false; - const loadQuestions = async () => { try { setIsQuestionsLoading(true); @@ -139,14 +90,10 @@ export default function MockApplyPage({ selectedResult.status === "fulfilled" ? selectedResult.value : []; if (data.length === 0) { - if (candidatesResult.status === "rejected") { + if (candidatesResult.status === "rejected") throw candidatesResult.reason; - } - const candidates = candidatesResult.value; - const preselectedCandidates = candidates.filter( - (question) => question.selected, - ); + const preselectedCandidates = candidates.filter((q) => q.selected); const initialQuestions = ( preselectedCandidates.length > 0 ? preselectedCandidates @@ -158,29 +105,20 @@ export default function MockApplyPage({ const savedQuestions = await fetchSelectedQuestions( Number(mockApplyId), ).catch(() => []); - data = savedQuestions.length > 0 - ? savedQuestions - : initialQuestions; + data = + savedQuestions.length > 0 ? savedQuestions : initialQuestions; } } - if (ignore) { - return; - } - + if (ignore) return; setQuestions(data); setSelectedId(data[0]?.id ?? null); - - if (data.length === 0) { + if (data.length === 0) setQuestionsErrorMessage( "공고에 맞는 자소서 문항을 불러오지 못했습니다.", ); - } } catch (error) { - if (ignore) { - return; - } - + if (ignore) return; console.error("문항을 불러오지 못했습니다.", error); setQuestions([]); setSelectedId(null); @@ -190,56 +128,39 @@ export default function MockApplyPage({ : "자소서 문항을 불러오지 못했습니다.", ); } finally { - if (!ignore) { - setIsQuestionsLoading(false); - } + if (!ignore) setIsQuestionsLoading(false); } }; - void loadQuestions(); - return () => { ignore = true; }; }, [mockApplyId]); + // 2. 공고 시퀀스 불러오기 useEffect(() => { - if (hasRequestedJobPostingId) { - return; - } - + if (hasRequestedJobPostingId) return; let ignore = false; - fetchSequence(Number(mockApplyId)) .then((sequence) => { - if (!ignore) { - setSequenceJobPostingId(sequence.jobPostingId); - } + if (!ignore) setSequenceJobPostingId(sequence.jobPostingId); }) .catch((error) => { - if (!ignore) { + if (!ignore) console.error("연결된 채용 공고를 확인하지 못했습니다.", error); - } }); - return () => { ignore = true; }; }, [hasRequestedJobPostingId, mockApplyId]); + // 3. JD 데이터 불러오기 useEffect(() => { - if (!jobPostingId) { - return; - } - + if (!jobPostingId) return; let ignore = false; - fetchMyJobPosting(jobPostingId) .then((jobPosting) => { - if (ignore) { - return; - } - + if (ignore) return; setJdData({ companyName: jobPosting.companyName, profileColor: jobPosting.profileColor, @@ -251,8 +172,7 @@ export default function MockApplyPage({ { subtitle: "직무", content: - jobPosting.jobTitle || - jobPosting.detailClassificationName, + jobPosting.jobTitle || jobPosting.detailClassificationName, }, { subtitle: "주요 업무", content: jobPosting.task }, { subtitle: "자격요건", content: jobPosting.requirement }, @@ -270,59 +190,26 @@ export default function MockApplyPage({ }); }) .catch((error) => { - if (!ignore) { - console.error("채용 공고를 불러오지 못했습니다.", error); - } + if (!ignore) console.error("채용 공고를 불러오지 못했습니다.", error); }); - return () => { ignore = true; }; }, [jobPostingId]); - // 자동 저장 타이머 + // 4. 토스트 타이머 useEffect(() => { if (!toast.open || toast.variant !== "check") return; - const retryToastTimer = window.setTimeout(() => { setToast({ open: false, message: "", variant: "normal" }); }, 3000); - return () => window.clearTimeout(retryToastTimer); }, [toast.open, toast.variant]); ->>>>>>> develop - useEffect(() => { - if (isFailed) return; - - const countdownTimer = window.setInterval(() => { - setRemainingSeconds((prev) => Math.max(prev - 1, 0)); - }, 1000); - - const completeTimer = window.setTimeout(() => { - onComplete?.(); - }, durationMs); - - const secondStepTimer = window.setTimeout(() => { - setCurrentStep(2); - }, durationMs / 4); - - const thirdStepTimer = window.setTimeout(() => { - setCurrentStep(3); - }, durationMs / 2); - -<<<<<<< HEAD - const fourthStepTimer = window.setTimeout( - () => { - setCurrentStep(4); - }, - (durationMs / 4) * 3, -======= - const handleConfirm = async () => { - if (isSubmitting) { - return; - } + // --- 이벤트 핸들러 모음 --- + const handleConfirm = async () => { + if (isSubmitting) return; setIsSubmitting(true); setIsConfirmModalOpen(false); @@ -334,12 +221,10 @@ export default function MockApplyPage({ const acceptedAnalysis = await requestAnalysis(Number(mockApplyId)); const analysisTaskId = acceptedAnalysis.taskId?.trim(); - if (!analysisTaskId) { + if (!analysisTaskId) throw new Error("자소서 분석 작업 번호를 확인할 수 없습니다."); - } let resolvedJobPostingId = jobPostingId; - if (!resolvedJobPostingId || resolvedJobPostingId <= 0) { try { const sequenceResult = await fetchSequence(Number(mockApplyId)); @@ -354,33 +239,23 @@ export default function MockApplyPage({ const loadingSearchParams = new URLSearchParams(); loadingSearchParams.set("taskId", analysisTaskId); - - if (savedApply.sequence > 0) { + if (savedApply.sequence > 0) loadingSearchParams.set("sequence", String(savedApply.sequence)); - } - - if (resolvedJobPostingId && resolvedJobPostingId > 0) { - loadingSearchParams.set( - "jobPostingId", - String(resolvedJobPostingId), - ); - } + if (resolvedJobPostingId && resolvedJobPostingId > 0) + loadingSearchParams.set("jobPostingId", String(resolvedJobPostingId)); const loadingQuery = loadingSearchParams.size ? `?${loadingSearchParams.toString()}` : ""; - router.push( `/mockApply/${mockApplyId}/result/resume-analysis-loading${loadingQuery}`, ); } catch (error) { setIsSubmitting(false); - if (error instanceof CreditInsufficientError) { setIsCreditShortModalOpen(true); return; } - console.error("제출 실패:", error); setToast({ open: true, @@ -397,6 +272,10 @@ export default function MockApplyPage({ } }; + const performDelete = async (targetId: string) => { + setQuestions(questions.filter((q) => q.id !== targetId)); + }; + const handleDeleteQuestion = (targetId: string) => { const targetQ = questions.find((q) => q.id === targetId); const hasContent = @@ -420,51 +299,19 @@ export default function MockApplyPage({ if (field === "maxLength") return { ...q, maxLength: Number(value) }; return q; }), ->>>>>>> develop ); + }; - return () => { - window.clearInterval(countdownTimer); - window.clearTimeout(completeTimer); - window.clearTimeout(secondStepTimer); - window.clearTimeout(thirdStepTimer); - window.clearTimeout(fourthStepTimer); - }; - }, [durationMs, onComplete, isFailed]); - -<<<<<<< HEAD - const progressItems = useMemo( - () => [ - { - title: "내용 구조 파악", - description: "작성해주신 내용을 확인하고 의미를 분석합니다.", - }, - { - title: "자소서 평가", - description: "직무 스킬을 매칭하고 적합도를 평가합니다.", - }, - { - title: "지원자 간 비교", - description: "동일 회사 및 직무의 지원자와 비교 평가합니다.", - }, - { - title: "리포트 생성", - description: "분석 결과를 토대로 약점 리포트를 생성합니다.", - }, - ], - [], - ); + const handleAddQuestion = async () => { + try { + const newQuestion: QuestionItem = { + id: `temp-${Date.now()}`, // 임시 ID + questionId: 0, + question: "", + answer: "", + maxLength: 1000, + }; - return ( -
-
- -
-
q.id === selectedId); const mappedQuestionForForm = currentQ ? { @@ -515,75 +361,48 @@ export default function MockApplyPage({ "flex-1 flex gap-6 transition-all duration-300 ease-in-out", isPanelOpen ? "mr-[300px]" : "mr-0", )} ->>>>>>> develop > -
+
-
- -
-
-
-======= -
-
- {mappedQuestionForForm ? ( - - ) : isQuestionsLoading ? ( -
- 문항을 불러오는 중입니다... -
- ) : ( -
- {questionsErrorMessage} -
+
+
+ {mappedQuestionForForm ? ( + + ) : isQuestionsLoading ? ( +
+ 문항을 불러오는 중입니다... +
+ ) : ( +
+ {questionsErrorMessage} +
+ )} +
@@ -662,19 +481,7 @@ export default function MockApplyPage({ /> )} ->>>>>>> develop
- - {isFailed && ( -
- -
- )} -
+ ); } diff --git a/jobdri/src/components/common/modal/ModalOverlay.tsx b/jobdri/src/components/common/modal/ModalOverlay.tsx new file mode 100644 index 0000000..cf62ec4 --- /dev/null +++ b/jobdri/src/components/common/modal/ModalOverlay.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { useEffect, type ReactNode } from "react"; + +interface ModalOverlayProps { + children: ReactNode; +} + +export function ModalOverlay({ children }: ModalOverlayProps) { + useEffect(() => { + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = "unset"; + }; + }, []); + + return ( +
+
e.stopPropagation()}> + {children} +
+
+ ); +} From d0473233306174ddcec2e3ed24f2043f44326517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9C=A4=EC=84=9C?= Date: Fri, 24 Jul 2026 11:48:36 +0900 Subject: [PATCH 8/9] =?UTF-8?q?Fix:=20#88=20=EC=9E=90=EC=86=8C=EC=84=9C=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EB=AC=B8=ED=95=AD=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/mockApply/[mockApplyId]/page.tsx | 51 ++++++++----------- 1 file changed, 22 insertions(+), 29 deletions(-) diff --git a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx index 89c046a..11187bb 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx @@ -77,48 +77,35 @@ export default function MockApplyPage({ // 1. 초기 데이터 불러오기 useEffect(() => { let ignore = false; + const loadQuestions = async () => { try { setIsQuestionsLoading(true); setQuestionsErrorMessage(""); - const [selectedResult, candidatesResult] = await Promise.allSettled([ - fetchSelectedQuestions(Number(mockApplyId)), - fetchQuestions(Number(mockApplyId)), - ]); - let data = - selectedResult.status === "fulfilled" ? selectedResult.value : []; + let data = await fetchSelectedQuestions(Number(mockApplyId)); - if (data.length === 0) { - if (candidatesResult.status === "rejected") - throw candidatesResult.reason; - const candidates = candidatesResult.value; - const preselectedCandidates = candidates.filter((q) => q.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 (data.length > 5) { + data = data.slice(0, 5); + } + + if (ignore) { + return; } - if (ignore) return; setQuestions(data); setSelectedId(data[0]?.id ?? null); - if (data.length === 0) + + if (data.length === 0) { setQuestionsErrorMessage( "공고에 맞는 자소서 문항을 불러오지 못했습니다.", ); + } } catch (error) { - if (ignore) return; + if (ignore) { + return; + } + console.error("문항을 불러오지 못했습니다.", error); setQuestions([]); setSelectedId(null); @@ -128,10 +115,14 @@ export default function MockApplyPage({ : "자소서 문항을 불러오지 못했습니다.", ); } finally { - if (!ignore) setIsQuestionsLoading(false); + if (!ignore) { + setIsQuestionsLoading(false); + } } }; + void loadQuestions(); + return () => { ignore = true; }; @@ -354,6 +345,8 @@ export default function MockApplyPage({ onBackClick={() => setIsLeaveModalOpen(true)} onNextClick={() => setIsConfirmModalOpen(true)} isNextDisabled={isSubmitDisabled || isSubmitting} + nextLabel="채점하기" + nextIconType="SPARKLE" >
Date: Fri, 24 Jul 2026 11:49:41 +0900 Subject: [PATCH 9/9] =?UTF-8?q?Feat:=20#88=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jobdri/src/app/mockApply/[mockApplyId]/page.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx index 11187bb..58fd38c 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx @@ -348,7 +348,12 @@ export default function MockApplyPage({ nextLabel="채점하기" nextIconType="SPARKLE" > -
+