From e63bfc5804230caca5c9093945e10bd251c966ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9C=A4=EC=84=9C?= Date: Sun, 19 Jul 2026 16:58:35 +0900 Subject: [PATCH 1/7] =?UTF-8?q?Feat:=20=EA=B2=B0=EA=B3=BC=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20UI=20=EA=B5=AC=ED=98=84=20[JDDEV-93]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resume-analysis-feedback/page.tsx | 123 ++++++++- jobdri/src/app/page.tsx | 2 +- .../common/scrollbar/scrollbarStyles.ts | 21 +- .../mockApply/Question/QuestionList.tsx | 21 +- .../mockApply/Question/SidePanel.tsx | 4 +- .../mockApply/result/AnalysisHeader.tsx | 26 +- .../result/DetailAnotationPannel.tsx | 6 +- .../mockApply/result/ResumeAnalysisDetail.tsx | 248 ++++++++++++++++++ .../{ => result}/ResumeAnalysisFeedback.tsx | 93 +------ 9 files changed, 436 insertions(+), 108 deletions(-) create mode 100644 jobdri/src/components/mockApply/result/ResumeAnalysisDetail.tsx rename jobdri/src/components/mockApply/{ => result}/ResumeAnalysisFeedback.tsx (74%) diff --git a/jobdri/src/app/mockApply/resume-analysis-feedback/page.tsx b/jobdri/src/app/mockApply/resume-analysis-feedback/page.tsx index e16bdc4..8a17509 100644 --- a/jobdri/src/app/mockApply/resume-analysis-feedback/page.tsx +++ b/jobdri/src/app/mockApply/resume-analysis-feedback/page.tsx @@ -1,29 +1,136 @@ -import ResumeAnalysisFeedback from "@/components/mockApply/ResumeAnalysisFeedback"; +"use client"; + +import { use, useState } from "react"; +import { useRouter } from "next/navigation"; +import ResumeAnalysisFeedback from "@/components/mockApply/result/ResumeAnalysisFeedback"; +import ResumeAnalysisDetail from "@/components/mockApply/result/ResumeAnalysisDetail"; +import Header from "@/components/common/header/Header"; +import AnalysisHeader from "@/components/mockApply/result/AnalysisHeader"; +import { formatApplicationSequenceLabel } from "@/lib/mockApply/applicationLabel"; +import { CtaFooter } from "@/components/common/cta"; +import { ModalNotice } from "@/components/common/modal"; +import { Toast } from "@/components/common/toast"; +import { useReApply } from "@/hooks/useReApply"; +import { getMockApplyResumeRecords } from "@/lib/api/mockApplies"; interface ResumeAnalysisFeedbackPageProps { searchParams: Promise<{ mockApplyId?: string; sequence?: string; + tab?: string; }>; } function parsePositiveNumber(value?: string) { const parsedValue = Number(value); - return Number.isFinite(parsedValue) && parsedValue > 0 ? parsedValue : undefined; } -export default async function ResumeAnalysisFeedbackPage({ +export default function ResumeAnalysisFeedbackPage({ searchParams, }: ResumeAnalysisFeedbackPageProps) { - const { mockApplyId, sequence } = await searchParams; + const { mockApplyId, sequence, tab } = use(searchParams); + + const router = useRouter(); + const { reApply, isSaving } = useReApply(); + const [isRetryModalOpen, setIsRetryModalOpen] = useState(false); + const [toast, setToast] = useState<{ open: boolean; message: string }>({ + open: false, + message: "", + }); + + const parsedSequence = parsePositiveNumber(sequence); + const parsedMockApplyId = parsePositiveNumber(mockApplyId); + const applicationLabel = formatApplicationSequenceLabel(parsedSequence); + + const activeTabId = tab === "score-detail" ? "score-detail" : "ai-feedback"; + const headerComponent = ; + + const closeToast = () => setToast({ open: false, message: "" }); + const showTopToast = (message: string) => { + setToast({ open: true, message }); + window.setTimeout(closeToast, 3000); + }; + + const handleRetryConfirm = async () => { + const resolvedMockApplyId = + parsedMockApplyId ?? getMockApplyResumeRecords()[0]?.mockApplyId; + + if (!resolvedMockApplyId) { + setIsRetryModalOpen(false); + showTopToast("재도전할 지원 정보를 찾지 못했어요."); + return; + } + try { + await reApply(resolvedMockApplyId); + } catch (error) { + setIsRetryModalOpen(false); + showTopToast("재도전을 시작하지 못했어요. 잠시 후 다시 시도해주세요."); + } + }; return ( - +
+
+ + {activeTabId === "ai-feedback" ? ( + + {headerComponent} + + ) : ( + + {headerComponent} + + )} + + setIsRetryModalOpen(true), + }} + saveAction={{ + onClick: () => router.push("/"), + }} + /> + + {/* 모달 및 토스트 컴포넌트 렌더링 */} + {isRetryModalOpen && ( +
+ setIsRetryModalOpen(false)} + secondaryAction={{ + label: "취소", + onClick: () => setIsRetryModalOpen(false), + }} + primaryAction={{ + label: "재도전 하기", + onClick: handleRetryConfirm, + disabled: isSaving, + }} + /> +
+ )} + + {toast.open && ( + + )} +
); } diff --git a/jobdri/src/app/page.tsx b/jobdri/src/app/page.tsx index 7922b98..b93eabd 100644 --- a/jobdri/src/app/page.tsx +++ b/jobdri/src/app/page.tsx @@ -152,7 +152,7 @@ export default function Home() { }} onResume={(app) => { // 예시: 결과 상세 페이지로 이동! - router.push(`/mockApply/result/${app.mockApplyId}`); + router.push(`/mockApply/resume-analysis-feedback`); }} /> diff --git a/jobdri/src/components/common/scrollbar/scrollbarStyles.ts b/jobdri/src/components/common/scrollbar/scrollbarStyles.ts index 245109c..ceb24af 100644 --- a/jobdri/src/components/common/scrollbar/scrollbarStyles.ts +++ b/jobdri/src/components/common/scrollbar/scrollbarStyles.ts @@ -1,2 +1,19 @@ -export const scrollbarClass = - "[&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-track-piece]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-icon-neutral-weak [&::-webkit-scrollbar-thumb]:rounded-full"; +const scrollbarBase = + "[&::-webkit-scrollbar-track]:bg-transparent " + + "[&::-webkit-scrollbar-thumb]:bg-icon-neutral-weak " + + "[&::-webkit-scrollbar-thumb]:rounded-full " + + "[&::-webkit-scrollbar-thumb]:border-solid " + + "[&::-webkit-scrollbar-thumb]:border-transparent " + + "[&::-webkit-scrollbar-thumb]:bg-clip-padding "; + +// 텍스트 인풋 등 넓은 영역에 사용 +export const scrollbarClassL = + scrollbarBase + + "[&::-webkit-scrollbar]:w-4 [&::-webkit-scrollbar]:h-4 " + // 전체 너비 16px + "[&::-webkit-scrollbar-thumb]:border-[4px]"; // 상하좌우 4px 투명 여백 (실제 보이는 두께 8px) + +// 리스트 내부, 드롭다운 등 좁은 영역에 사용 +export const scrollbarClassS = + scrollbarBase + + "[&::-webkit-scrollbar]:w-3 [&::-webkit-scrollbar]:h-3 " + // 전체 너비 12px + "[&::-webkit-scrollbar-thumb]:border-[4px]"; // 상하좌우 4px 투명 여백 (실제 보이는 두께 4px) diff --git a/jobdri/src/components/mockApply/Question/QuestionList.tsx b/jobdri/src/components/mockApply/Question/QuestionList.tsx index d159741..f77ce4d 100644 --- a/jobdri/src/components/mockApply/Question/QuestionList.tsx +++ b/jobdri/src/components/mockApply/Question/QuestionList.tsx @@ -9,6 +9,7 @@ interface QuestionListProps { onSelect: (id: string) => void; // 문항 클릭 시 부모에게 알림 onAdd: () => void; // 문항 추가 시 부모에게 알림 onDelete: (id: string) => void; // 문항 삭제 시 부모에게 알림 + type?: "result" | "apply"; } export const QuestionList = ({ @@ -17,15 +18,22 @@ export const QuestionList = ({ onSelect, onAdd, onDelete, + type = "apply", }: QuestionListProps) => { const MAX_QUESTIONS = 5; return (
{/* 헤더 영역 */} -
+

- 자소서 문항 {questions.length}/{MAX_QUESTIONS} + {type === "apply" ? ( + <> + 자소서 문항 {questions.length}/{MAX_QUESTIONS} + + ) : ( + <>문항별 피드백 + )}

@@ -44,12 +52,15 @@ export const QuestionList = ({ } isActive={q.id === selectedId} onClick={() => onSelect(q.id)} - onDelete={questions.length > 1 ? () => onDelete(q.id) : undefined} + onDelete={ + type === "apply" && questions.length > 1 + ? () => onDelete(q.id) + : undefined + } /> ))} - {/* 문항 추가 버튼 */} - {questions.length < MAX_QUESTIONS && ( + {type === "apply" && questions.length < MAX_QUESTIONS && (
diff --git a/jobdri/src/components/mockApply/result/DetailAnotationPannel.tsx b/jobdri/src/components/mockApply/result/DetailAnotationPannel.tsx index 1fb51ef..3ca7e65 100644 --- a/jobdri/src/components/mockApply/result/DetailAnotationPannel.tsx +++ b/jobdri/src/components/mockApply/result/DetailAnotationPannel.tsx @@ -13,7 +13,7 @@ const statusLabel: Record = { mentioned: "신뢰성 부족", }; -interface DetailAnnotationPanelProps { +export interface DetailAnnotationPanelProps { analyses: QuestionAnalysis[]; } @@ -23,9 +23,7 @@ export default function DetailAnnotationPanel({ const [clickedId, setClickedId] = useState(null); return ( -
+
{analyses.map((analysis) => { const isClicked = clickedId === analysis.questionAnalysisId; diff --git a/jobdri/src/components/mockApply/result/ResumeAnalysisDetail.tsx b/jobdri/src/components/mockApply/result/ResumeAnalysisDetail.tsx new file mode 100644 index 0000000..5786741 --- /dev/null +++ b/jobdri/src/components/mockApply/result/ResumeAnalysisDetail.tsx @@ -0,0 +1,248 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import { QuestionList } from "../Question/QuestionList"; +import { type QuestionItem as ApiQuestionItem } from "@/lib/api/questions"; +import { lnbHiddenScrollbarClass } from "@/components/common/lnb/LnbScrollbar"; +import DetailAnnotationPanel from "./DetailAnotationPannel"; +import { scrollbarClassS } from "@/components/common/scrollbar/scrollbarStyles"; + +interface ResumeAnalysisDetailProps { + mockApplyId?: number; + sequence?: number; + children?: React.ReactNode; +} + +const MOCK_QUESTIONS: ApiQuestionItem[] = [ + { + id: "1", + question: + "디자이너로서 프로젝트에 AI를 활용한 파이프라인을 구축한 경험이 있다면, 해당 과정을 상세히 기술하고 느낀 점을 작성해주세요.", + answer: + "잡드리 서비스 기획 및 디자인 과정에서 AI를 활용한 디자인 시스템 구축 파이프라인을 경험했습니다. 기존에는 컴포넌트 정의, 네이밍, 디스크립션 작성을 디자이너가 전부 수동으로 진행했습니다. 이 과정에서 일관성이 떨어지고, 개발팀과의 커뮤니케이션 비용이 높아지는 문제가 반복되었습니다. 이를 해결하기 위해 AI를 활용한 컴포넌트 문서화 파이프라인을 구축했습니다...", + }, + { + id: "2", + question: + "기존 디자인 작업 방식의 비효율을 발견하고, 이를 개선하기 위해 새로운 도구나 프로세스를 도입했던 경험을 서술해주세요.", + answer: + "이전 프로젝트에서 팀원 간의 피그마 컴포넌트 싱크가 맞지 않아 퍼블리싱 단계에서 잦은 수정 작업이 발생했습니다. 이를 해결하고자 디자인 토큰 관리 플러그인을 도입하고 자동화 스크립트를 연결하여...", + }, + { + id: "3", + question: + "협업 과정에서 반복적으로 발생하는 커뮤니케이션 문제를 발견하고 이를 주도적으로 해결한 경험이 있나요?", + answer: + "개발팀과의 정기적인 디자인 리뷰 세션을 제안하여 기획 의도를 명확히 공유했습니다. 소통의 간극을 줄이기 위해 인터랙션 정의서 가이드를 프레임별로 상세화하여 제공한 결과, 커뮤니케이션 오류를 40% 이상 줄일 수 있었습니다.", + }, +]; + +const MOCK_ANALYSES: Record = { + "1": [ + { + questionAnalysisId: 101, + status: "proven", // 적절함 + reason: "모호한 표현보다는 구체적으로 기술해주세요.", + sentence: + "단순히 AI를 사용했다고 쓰는 대신, 어느 지점에서 디자이너의 판단이 개입했는지를 구체적인 사례로 나열했어요.", + }, + { + questionAnalysisId: 102, + status: "mentioned", // 신뢰성 부족 + reason: "수치 없는 표현은 신뢰를 얻기 어려워요.", + sentence: + '"크게 단축"이라는 표현은 주관적 서술이에요. 얼마나 단축됐는지 수치가 없으면 판단하기 어려워요.', + improvement: + "친환경차로의 전환기에서 사용자가 겪는 새로운 불편함을 해결하고, 신뢰할 수 있는 HMI를 설계하고자 지원했습니다.", + }, + { + questionAnalysisId: 103, + status: "fabricated", // 신뢰성 부족 + reason: "수치 없는 표현은 신뢰를 얻기 어려워요.", + sentence: + '"크게 단축"이라는 표현은 주관적 서술이에요. 얼마나 단축됐는지 수치가 없으면 판단하기 어려워요.', + improvement: + "친환경차로의 전환기에서 사용자가 겪는 새로운 불편함을 해결하고, 신뢰할 수 있는 HMI를 설계하고자 지원했습니다.", + }, + ], + "2": [ + { + questionAnalysisId: 201, + status: "proven", + reason: "프로세스 개선 목적이 명확하게 드러납니다.", + sentence: + "디자인 토큰 관리 플러그인을 도입하고 자동화 스크립트를 연결하여 반복 작업을 덜어냈습니다.", + }, + ], + "3": [ + { + questionAnalysisId: 301, + status: "fabricated", // 구체성 부족 + reason: "해결 방안의 인과관계 보완이 필요합니다.", + sentence: "가이드를 상세화하여 제공한 결과 커뮤니케이션 오류를 줄임.", + improvement: + "어떤 방식으로 프레임을 상세화했는지, 구체적인 산출물 예시를 덧붙여주시면 훨씬 좋은 문장이 됩니다.", + }, + ], +}; + +interface QuestionViewerProps { + questionNumber?: number; + questionText?: string; + answerText?: string; +} + +function QuestionViewer({ + questionNumber = 1, + questionText = "문항이 선택되지 않았거나 등록된 내용이 없습니다.", + answerText = "", +}: QuestionViewerProps) { + return ( +
+
+ + {questionNumber}번 문항 + +

+ {questionText} +

+
+
+ {answerText} +
+
+ ); +} + +export default function ResumeAnalysisDetail({ + mockApplyId, + sequence, + children, +}: ResumeAnalysisDetailProps) { + const [questions, setQuestions] = useState(MOCK_QUESTIONS); + const [selectedId, setSelectedId] = useState("1"); + const scrollRef = useRef(null); + const [showGradient, setShowGradient] = useState(false); + + // 그라데이션 표시 논리 개선 (소수점 오차 완벽 차단) + const checkScroll = () => { + if (scrollRef.current) { + const { scrollHeight, clientHeight, scrollTop } = scrollRef.current; + + // 2px 정도의 여유 오차를 두어 확실하게 내용이 길 때만 스크롤이 있다고 판단 + const isScrollable = scrollHeight - clientHeight > 2; + const isNotAtBottom = + Math.ceil(scrollTop + clientHeight) < scrollHeight - 2; + + setShowGradient(isScrollable && isNotAtBottom); + } + }; + + // ResizeObserver 도입 (렌더링 속도 차이로 인한 오류 해결) + useEffect(() => { + // 탭 클릭 직후 렌더링 지연을 고려해 50ms 후 체크 + 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]); + + const currentQuestionIdx = questions.findIndex((q) => q.id === selectedId); + const currentQuestion = questions[currentQuestionIdx]; + + const handleAddQuestion = () => { + if (questions.length >= 5) return; + const newId = String(Date.now()); + const newQuestion: ApiQuestionItem = { + id: newId, + question: "새 문항", + answer: "", + }; + setQuestions([...questions, newQuestion]); + setSelectedId(newId); + }; + + const handleDeleteQuestion = (id: string) => { + if (questions.length <= 1) return; + const filtered = questions.filter((q) => q.id !== id); + setQuestions(filtered); + + if (selectedId === id) { + setSelectedId(filtered[0].id); + } + }; + + return ( +
+
+
+
+ {children} +
+
+
+ +
+ + + +
+
+

+ 피드백 +

+ + {selectedId ? MOCK_ANALYSES[selectedId]?.length || 0 : 0} + +
+ +
+ +
+ + {showGradient && ( +
+ )} +
+
+
+
+
+
+
+ ); +} diff --git a/jobdri/src/components/mockApply/ResumeAnalysisFeedback.tsx b/jobdri/src/components/mockApply/result/ResumeAnalysisFeedback.tsx similarity index 74% rename from jobdri/src/components/mockApply/ResumeAnalysisFeedback.tsx rename to jobdri/src/components/mockApply/result/ResumeAnalysisFeedback.tsx index a54cd09..cb813a0 100644 --- a/jobdri/src/components/mockApply/ResumeAnalysisFeedback.tsx +++ b/jobdri/src/components/mockApply/result/ResumeAnalysisFeedback.tsx @@ -4,7 +4,6 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { CtaFooter } from "@/components/common/cta"; import Divider from "@/components/common/Divider"; -import Header from "@/components/common/header/Header"; import Icon from "@/components/common/icons/Icon"; import { LnbScrollbar, @@ -14,17 +13,16 @@ import { import { ModalNotice } from "@/components/common/modal"; import TabMenu from "@/components/common/tabs/TabMenu"; import { Toast } from "@/components/common/toast"; -import AnalysisHeader from "@/components/mockApply/result/AnalysisHeader"; import Evaluation from "@/components/mockApply/result/Evaluation"; import ScoreBar from "@/components/mockApply/result/ScoreBar"; import ScoreCircle from "@/components/mockApply/result/ScoreCircle"; import { useReApply } from "@/hooks/useReApply"; import { getMockApplyResumeRecords } from "@/lib/api/mockApplies"; -import { formatApplicationSequenceLabel } from "@/lib/mockApply/applicationLabel"; interface ResumeAnalysisFeedbackProps { mockApplyId?: number; sequence?: number; + children?: React.ReactNode; // 추가된 부분: AnalysisHeader 등을 받기 위함 } const scoreItems = [ @@ -157,9 +155,9 @@ function ScoreSummaryCard() {

- 직무 적합성과 정량적 성과는 강하지만, 회사·직무에 대한 - 구체적 이해와 본인만의 차별점이 더 드러나면 통과 가능성이 한 - 단계 올라가요. 지원 동기와 입사 후 기여 방향을 좀 더 구체적으로 + 직무 적합성과 정량적 성과는 강하지만, 회사·직무에 대한 구체적 + 이해와 본인만의 차별점이 더 드러나면 통과 가능성이 한 단계 + 올라가요. 지원 동기와 입사 후 기여 방향을 좀 더 구체적으로 서술하면 설득력이 높아집니다.

@@ -210,47 +208,13 @@ function ReviewSummaryCard() { export default function ResumeAnalysisFeedback({ mockApplyId, sequence, + children, // 부모로부터 전달받은 컴포넌트 }: ResumeAnalysisFeedbackProps) { - const router = useRouter(); - const { reApply, isSaving } = useReApply(); - const [isRetryModalOpen, setIsRetryModalOpen] = useState(false); - const [toast, setToast] = useState<{ open: boolean; message: string }>({ - open: false, - message: "", - }); const { scrollAreaRef, scrollbarMetrics, updateScrollbarMetrics } = useLnbScrollMetrics(true, "resume-analysis-feedback"); - const applicationLabel = formatApplicationSequenceLabel(sequence); - - const closeToast = () => setToast({ open: false, message: "" }); - - const showTopToast = (message: string) => { - setToast({ open: true, message }); - window.setTimeout(closeToast, 3000); - }; - - const handleRetryConfirm = async () => { - const resolvedMockApplyId = - mockApplyId ?? getMockApplyResumeRecords()[0]?.mockApplyId; - - if (!resolvedMockApplyId) { - setIsRetryModalOpen(false); - showTopToast("재도전할 지원 정보를 찾지 못했어요."); - return; - } - - const retryResult = await reApply(resolvedMockApplyId); - - if (!retryResult) { - setIsRetryModalOpen(false); - showTopToast("재도전을 시작하지 못했어요. 잠시 후 다시 시도해주세요."); - } - }; return ( -
-
- + <>
- + {/* 기존 AnalysisHeader가 있던 위치에 children을 렌더링 */} + {children}
@@ -277,46 +242,6 @@ export default function ResumeAnalysisFeedback({ className="inset-y-0 right-0 z-20 items-end" />
- - setIsRetryModalOpen(true), - }} - saveAction={{ - onClick: () => router.push("/"), - }} - /> - - {isRetryModalOpen && ( -
- setIsRetryModalOpen(false)} - secondaryAction={{ - label: "취소", - onClick: () => setIsRetryModalOpen(false), - }} - primaryAction={{ - label: "재도전 하기", - onClick: handleRetryConfirm, - disabled: isSaving, - }} - /> -
- )} - - {toast.open && ( - - )} -
+ ); } From 60e0c8322cd5b1d762a3e6fdf4b403258f580655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9C=A4=EC=84=9C?= Date: Sun, 19 Jul 2026 17:52:30 +0900 Subject: [PATCH 2/7] =?UTF-8?q?Feat:=20=EA=B0=9C=EC=84=A0=EC=95=88=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20API=20=EC=97=B0=EB=8F=99=20[JDDEV-93]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jobdri/components/common/LoadMotion.tsx | 56 -- jobdri/src/app/layout.tsx | 3 +- .../src/app/mockApply/[mockApplyId]/page.tsx | 774 +++++++++--------- .../[jobPostingId]/ResultPageClient.tsx | 70 -- .../result/[jobPostingId]/page.tsx | 15 - .../result}/page.tsx | 73 +- jobdri/src/app/page.tsx | 2 +- .../src/components/common/chips/ChipTag.tsx | 4 +- .../mockApply/Question/QuestionList.tsx | 6 +- .../result/DetailAnotationPannel.tsx | 1 - .../mockApply/result/ResumeAnalysisDetail.tsx | 194 ++--- .../result/ResumeAnalysisFeedback.tsx | 107 ++- .../components/providers/QueryProvider.tsx | 26 + jobdri/src/hooks/useAnalysisResult.ts | 19 + jobdri/src/lib/api/result.ts | 32 +- 15 files changed, 655 insertions(+), 727 deletions(-) delete mode 100644 jobdri/components/common/LoadMotion.tsx delete mode 100644 jobdri/src/app/mockApply/[mockApplyId]/result/[jobPostingId]/ResultPageClient.tsx delete mode 100644 jobdri/src/app/mockApply/[mockApplyId]/result/[jobPostingId]/page.tsx rename jobdri/src/app/mockApply/{resume-analysis-feedback => [mockApplyId]/result}/page.tsx (69%) create mode 100644 jobdri/src/components/providers/QueryProvider.tsx create mode 100644 jobdri/src/hooks/useAnalysisResult.ts diff --git a/jobdri/components/common/LoadMotion.tsx b/jobdri/components/common/LoadMotion.tsx deleted file mode 100644 index ed88097..0000000 --- a/jobdri/components/common/LoadMotion.tsx +++ /dev/null @@ -1,56 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import clsx from "clsx"; - -interface LoadMotionProps { - className?: string; - dotFrameClassName?: string; - dotClassName?: string; - activeDotClassName?: string; - inactiveDotClassName?: string; - activeMotionClassName?: string; - inactiveMotionClassName?: string; -} - -export default function LoadMotion({ - className, - dotFrameClassName, - dotClassName, - activeDotClassName = "bg-icon-neutral-heavy", - inactiveDotClassName = "bg-icon-neutral-assistive", - activeMotionClassName = "-translate-y-0.5", - inactiveMotionClassName = "translate-y-0", -}: LoadMotionProps) { - const [active, setActive] = useState(0); - - useEffect(() => { - const interval = setInterval(() => { - setActive((prev) => (prev + 1) % 3); - }, 400); - return () => clearInterval(interval); - }, []); - return ( -
- {[0, 1, 2].map((i) => ( -
- -
- ))} -
- ); -} diff --git a/jobdri/src/app/layout.tsx b/jobdri/src/app/layout.tsx index 5be80ed..839463a 100644 --- a/jobdri/src/app/layout.tsx +++ b/jobdri/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import "./globals.css"; +import QueryProvider from "@/components/providers/QueryProvider"; // import LayoutShell from "./LayoutShell"; export const metadata: Metadata = { @@ -18,7 +19,7 @@ export default function RootLayout({ - {children} + {children} ); diff --git a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx index a31b68f..57e4009 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx @@ -1,387 +1,387 @@ -"use client"; -import { useState, useEffect, use } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; -import Header from "@/components/common/header/Header"; -import { QuestionList } from "@/components/mockApply/Question/QuestionList"; -import JDSidePanel from "@/components/mockApply/Question/SidePanel"; -import SideHeaderContainer from "@/components/common/header/SideHeaderContainer"; -import WritingForm from "@/components/mockApply/Question/WritingForm"; -import clsx from "clsx"; -import { scrollbarClass } from "@/components/common/scrollbar/scrollbarStyles"; -import { - fetchSelectedQuestions, - saveQuestions, - saveApply, - type QuestionItem, -} from "@/lib/api/questions"; -import { ModalCard } from "@/components/common/modal/ModalCard"; -import { Toast, type ToastVariant } from "@/components/common/toast"; -import { CtaFooter } from "@/components/common/cta"; -import { fetchCreditBalance } from "@/lib/api/credit"; -import { formatApplicationSequenceLabel } from "@/lib/mockApply/applicationLabel"; - -export default function MockApplyPage({ - params, -}: { - params: Promise<{ mockApplyId: string }>; -}) { - const { mockApplyId } = use(params); - - const router = useRouter(); - const searchParams = useSearchParams(); - const isRetryMode = searchParams.get("retry") === "1"; - const sequenceParam = Number(searchParams.get("sequence")); - const retrySequence = - Number.isFinite(sequenceParam) && sequenceParam > 0 ? sequenceParam : 2; - const applicationLabel = isRetryMode - ? formatApplicationSequenceLabel(retrySequence) - : undefined; - const [isPanelOpen, setIsPanelOpen] = useState(false); - const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); - const [isCreditShortModalOpen, setIsCreditShortModalOpen] = useState(false); - const [questions, setQuestions] = useState([]); - const [selectedId, setSelectedId] = useState(null); - const [toast, setToast] = useState<{ - open: boolean; - message: string; - variant: ToastVariant; - }>(() => ({ - open: isRetryMode, - message: isRetryMode - ? "기존 내용이 유지되었어요. 수정하고 다시 채점해 보세요!" - : "", - variant: isRetryMode ? "check" : "normal", - })); - const [modalTarget, setModalTarget] = useState(null); - const [lastSavedTime, setLastSavedTime] = useState("저장 전"); - - 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]); - - useEffect(() => { - if (questions.length === 0) return; - const autoSaveTimer = setTimeout(async () => { - try { - const answersToSubmit = questions.map((q) => ({ - questionId: q.questionId!, - answer: q.answer || "", - })); - await saveApply(Number(mockApplyId), answersToSubmit); - 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); - return () => clearTimeout(autoSaveTimer); - }, [questions, mockApplyId]); - - 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: "문항이 삭제되었어요", - variant: "normal", - }); - setTimeout( - () => setToast({ open: false, message: "", variant: "normal" }), - 3000, - ); - }; - - 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]); - - const currentQ = questions.find((q) => q.id === selectedId); - const mappedQuestionForForm = currentQ - ? { - title: currentQ.question, - answer: currentQ.answer || "", - maxLength: String(currentQ.maxLength || 1000), - } - : null; - - const handleUpdate = (field: string, value: string) => { - if (!selectedId) return; - - setQuestions((prevQuestions) => - prevQuestions.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 handleConfirm = async () => { - const answersToSubmit = questions.map((q) => ({ - questionId: q.questionId!, - answer: q.answer || "", - })); - - const saveResult = await saveApply(Number(mockApplyId), answersToSubmit); - - if (isRetryMode) { - const loadingParams = new URLSearchParams({ - mockApplyId, - sequence: String(saveResult.sequence || retrySequence), - }); - - router.push(`/mockApply/resume-analysis-loading?${loadingParams}`); - return; - } - - router.push(`/mockApply/${mockApplyId}/result/`); - }; - 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 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 handleTrySubmit = async () => { - let hasCredit = false; - - try { - const currentCredit = await fetchCreditBalance(); - hasCredit = currentCredit > 0; - - if (!hasCredit) { - setIsCreditShortModalOpen(true); - } - } catch (error) { - console.error("크레딧 조회 실패:", error); - setToast({ - open: true, - message: "크레딧 정보를 불러오지 못했어요. 잠시 후 다시 시도해주세요.", - variant: "normal", - }); - setTimeout( - () => setToast({ open: false, message: "", variant: "normal" }), - 3000, - ); - } - - if (!hasCredit) { - return; - } - - try { - await handleConfirm(); - } catch { - alert("답변 저장에 실패했습니다."); - } - }; - - return ( -
-
- -
-
- setSelectedId(id)} - onAdd={handleAddQuestion} - onDelete={handleDeleteQuestion} - /> - } - /> -
- -
-
- {mappedQuestionForForm ? ( - - ) : ( -
- 문항을 불러오는 중입니다... -
- )} -
-
-
- - setIsLeaveModalOpen(true), - }} - nextAction={{ - label: isRetryMode ? "채점하기" : "제출하기", - onClick: handleTrySubmit, - disabled: - !mappedQuestionForForm || - questions.some((q) => !(q.answer || "").trim()), - iconType: "SPARKLE", - }} - /> - - setIsPanelOpen(false)} - onOpen={() => setIsPanelOpen(true)} - /> - - {modalTarget && ( -
- setModalTarget(null)} - onErrorClick={() => { - performDelete(modalTarget); - setModalTarget(null); - }} - /> -
- )} - - {toast.open && ( - setToast({ ...toast, open: false })} - className="absolute top-6" - /> - )} - - {isLeaveModalOpen && ( -
- setIsLeaveModalOpen(false)} - onPrimaryClick={() => { - setIsLeaveModalOpen(false); - router.push(`/mockApply/actual/${mockApplyId}/jd-review`); - }} - /> -
- )} - - {isCreditShortModalOpen && ( -
- setIsCreditShortModalOpen(false)} - onPrimaryClick={() => { - setIsCreditShortModalOpen(false); - }} - /> -
- )} -
- ); -} +// "use client"; +// import { useState, useEffect, use } from "react"; +// import { useRouter, useSearchParams } from "next/navigation"; +// import Header from "@/components/common/header/Header"; +// import { QuestionList } from "@/components/mockApply/Question/QuestionList"; +// import JDSidePanel from "@/components/mockApply/Question/SidePanel"; +// import SideHeaderContainer from "@/components/common/header/SideHeaderContainer"; +// import WritingForm from "@/components/mockApply/Question/WritingForm"; +// import clsx from "clsx"; +// import { scrollbarClass } from "@/components/common/scrollbar/scrollbarStyles"; +// import { +// fetchSelectedQuestions, +// saveQuestions, +// saveApply, +// type QuestionItem, +// } from "@/lib/api/questions"; +// import { ModalCard } from "@/components/common/modal/ModalCard"; +// import { Toast, type ToastVariant } from "@/components/common/toast"; +// import { CtaFooter } from "@/components/common/cta"; +// import { fetchCreditBalance } from "@/lib/api/credit"; +// import { formatApplicationSequenceLabel } from "@/lib/mockApply/applicationLabel"; + +// export default function MockApplyPage({ +// params, +// }: { +// params: Promise<{ mockApplyId: string }>; +// }) { +// const { mockApplyId } = use(params); + +// const router = useRouter(); +// const searchParams = useSearchParams(); +// const isRetryMode = searchParams.get("retry") === "1"; +// const sequenceParam = Number(searchParams.get("sequence")); +// const retrySequence = +// Number.isFinite(sequenceParam) && sequenceParam > 0 ? sequenceParam : 2; +// const applicationLabel = isRetryMode +// ? formatApplicationSequenceLabel(retrySequence) +// : undefined; +// const [isPanelOpen, setIsPanelOpen] = useState(false); +// const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); +// const [isCreditShortModalOpen, setIsCreditShortModalOpen] = useState(false); +// const [questions, setQuestions] = useState([]); +// const [selectedId, setSelectedId] = useState(null); +// const [toast, setToast] = useState<{ +// open: boolean; +// message: string; +// variant: ToastVariant; +// }>(() => ({ +// open: isRetryMode, +// message: isRetryMode +// ? "기존 내용이 유지되었어요. 수정하고 다시 채점해 보세요!" +// : "", +// variant: isRetryMode ? "check" : "normal", +// })); +// const [modalTarget, setModalTarget] = useState(null); +// const [lastSavedTime, setLastSavedTime] = useState("저장 전"); + +// 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]); + +// useEffect(() => { +// if (questions.length === 0) return; +// const autoSaveTimer = setTimeout(async () => { +// try { +// const answersToSubmit = questions.map((q) => ({ +// questionId: q.questionId!, +// answer: q.answer || "", +// })); +// await saveApply(Number(mockApplyId), answersToSubmit); +// 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); +// return () => clearTimeout(autoSaveTimer); +// }, [questions, mockApplyId]); + +// 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: "문항이 삭제되었어요", +// variant: "normal", +// }); +// setTimeout( +// () => setToast({ open: false, message: "", variant: "normal" }), +// 3000, +// ); +// }; + +// 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]); + +// const currentQ = questions.find((q) => q.id === selectedId); +// const mappedQuestionForForm = currentQ +// ? { +// title: currentQ.question, +// answer: currentQ.answer || "", +// maxLength: String(currentQ.maxLength || 1000), +// } +// : null; + +// const handleUpdate = (field: string, value: string) => { +// if (!selectedId) return; + +// setQuestions((prevQuestions) => +// prevQuestions.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 handleConfirm = async () => { +// const answersToSubmit = questions.map((q) => ({ +// questionId: q.questionId!, +// answer: q.answer || "", +// })); + +// const saveResult = await saveApply(Number(mockApplyId), answersToSubmit); + +// if (isRetryMode) { +// const loadingParams = new URLSearchParams({ +// mockApplyId, +// sequence: String(saveResult.sequence || retrySequence), +// }); + +// router.push(`/mockApply/resume-analysis-loading?${loadingParams}`); +// return; +// } + +// router.push(`/mockApply/${mockApplyId}/result/`); +// }; +// 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 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 handleTrySubmit = async () => { +// let hasCredit = false; + +// try { +// const currentCredit = await fetchCreditBalance(); +// hasCredit = currentCredit > 0; + +// if (!hasCredit) { +// setIsCreditShortModalOpen(true); +// } +// } catch (error) { +// console.error("크레딧 조회 실패:", error); +// setToast({ +// open: true, +// message: "크레딧 정보를 불러오지 못했어요. 잠시 후 다시 시도해주세요.", +// variant: "normal", +// }); +// setTimeout( +// () => setToast({ open: false, message: "", variant: "normal" }), +// 3000, +// ); +// } + +// if (!hasCredit) { +// return; +// } + +// try { +// await handleConfirm(); +// } catch { +// alert("답변 저장에 실패했습니다."); +// } +// }; + +// return ( +//
+//
+ +//
+//
+// setSelectedId(id)} +// onAdd={handleAddQuestion} +// onDelete={handleDeleteQuestion} +// /> +// } +// /> +//
+ +//
+//
+// {mappedQuestionForForm ? ( +// +// ) : ( +//
+// 문항을 불러오는 중입니다... +//
+// )} +//
+//
+//
+ +// setIsLeaveModalOpen(true), +// }} +// nextAction={{ +// label: isRetryMode ? "채점하기" : "제출하기", +// onClick: handleTrySubmit, +// disabled: +// !mappedQuestionForForm || +// questions.some((q) => !(q.answer || "").trim()), +// iconType: "SPARKLE", +// }} +// /> + +// setIsPanelOpen(false)} +// onOpen={() => setIsPanelOpen(true)} +// /> + +// {modalTarget && ( +//
+// setModalTarget(null)} +// onErrorClick={() => { +// performDelete(modalTarget); +// setModalTarget(null); +// }} +// /> +//
+// )} + +// {toast.open && ( +// setToast({ ...toast, open: false })} +// className="absolute top-6" +// /> +// )} + +// {isLeaveModalOpen && ( +//
+// setIsLeaveModalOpen(false)} +// onPrimaryClick={() => { +// setIsLeaveModalOpen(false); +// router.push(`/mockApply/actual/${mockApplyId}/jd-review`); +// }} +// /> +//
+// )} + +// {isCreditShortModalOpen && ( +//
+// setIsCreditShortModalOpen(false)} +// onPrimaryClick={() => { +// setIsCreditShortModalOpen(false); +// }} +// /> +//
+// )} +//
+// ); +// } diff --git a/jobdri/src/app/mockApply/[mockApplyId]/result/[jobPostingId]/ResultPageClient.tsx b/jobdri/src/app/mockApply/[mockApplyId]/result/[jobPostingId]/ResultPageClient.tsx deleted file mode 100644 index a5757d4..0000000 --- a/jobdri/src/app/mockApply/[mockApplyId]/result/[jobPostingId]/ResultPageClient.tsx +++ /dev/null @@ -1,70 +0,0 @@ -"use client"; - -import { useCallback, useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; -import Header from "@/components/common/header/Header"; -import ApplyResult from "@/components/mockApply/result/ApplyResult"; -import { ModalNotice } from "@/components/common/modal"; -import { useReApply } from "@/hooks/useReApply"; - -interface ResultPageClientProps { - id: string; -} - -export default function ResultPageClient({ id }: ResultPageClientProps) { - const router = useRouter(); - const searchParams = useSearchParams(); - const sequence = Number(searchParams.get("sequence") ?? "1"); - const totalCount = Number(searchParams.get("totalCount") ?? sequence); - const jobPostingId = Number(id); - const [showAnalysisErrorModal, setShowAnalysisErrorModal] = useState(false); - const [mockApplyId, setMockApplyId] = useState(0); - const { reApply, isSaving } = useReApply(); - - const closeAnalysisErrorModal = () => { - setShowAnalysisErrorModal(false); - router.push("/mockApply"); - }; - const openAnalysisErrorModal = useCallback(() => { - setShowAnalysisErrorModal(true); - }, []); - - return ( -
-
reApply(mockApplyId), - disabled: isSaving || !mockApplyId, - }} - /> -
- -
- - {showAnalysisErrorModal && ( -
- -
- )} -
- ); -} diff --git a/jobdri/src/app/mockApply/[mockApplyId]/result/[jobPostingId]/page.tsx b/jobdri/src/app/mockApply/[mockApplyId]/result/[jobPostingId]/page.tsx deleted file mode 100644 index 9647ac7..0000000 --- a/jobdri/src/app/mockApply/[mockApplyId]/result/[jobPostingId]/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Suspense } from "react"; -import ResultPageClient from "./ResultPageClient"; - -interface ResultPageProps { - params: Promise<{ jobPostingId: string }>; -} - -export default async function ResultPage({ params }: ResultPageProps) { - const { jobPostingId } = await params; - return ( - - - - ); -} diff --git a/jobdri/src/app/mockApply/resume-analysis-feedback/page.tsx b/jobdri/src/app/mockApply/[mockApplyId]/result/page.tsx similarity index 69% rename from jobdri/src/app/mockApply/resume-analysis-feedback/page.tsx rename to jobdri/src/app/mockApply/[mockApplyId]/result/page.tsx index 8a17509..9beb9cb 100644 --- a/jobdri/src/app/mockApply/resume-analysis-feedback/page.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/result/page.tsx @@ -12,10 +12,13 @@ import { ModalNotice } from "@/components/common/modal"; import { Toast } from "@/components/common/toast"; import { useReApply } from "@/hooks/useReApply"; import { getMockApplyResumeRecords } from "@/lib/api/mockApplies"; +import { useAnalysisResult } from "@/hooks/useAnalysisResult"; -interface ResumeAnalysisFeedbackPageProps { +interface ResultPageProps { + params: Promise<{ + mockApplyId: string; + }>; searchParams: Promise<{ - mockApplyId?: string; sequence?: string; tab?: string; }>; @@ -28,11 +31,9 @@ function parsePositiveNumber(value?: string) { : undefined; } -export default function ResumeAnalysisFeedbackPage({ - searchParams, -}: ResumeAnalysisFeedbackPageProps) { - const { mockApplyId, sequence, tab } = use(searchParams); - +export default function ResultPage({ params, searchParams }: ResultPageProps) { + const { mockApplyId } = use(params); + const { sequence, tab } = use(searchParams); const router = useRouter(); const { reApply, isSaving } = useReApply(); const [isRetryModalOpen, setIsRetryModalOpen] = useState(false); @@ -44,7 +45,11 @@ export default function ResumeAnalysisFeedbackPage({ const parsedSequence = parsePositiveNumber(sequence); const parsedMockApplyId = parsePositiveNumber(mockApplyId); const applicationLabel = formatApplicationSequenceLabel(parsedSequence); - + const { + data: analysisData, + isPending, + isError, + } = useAnalysisResult(parsedMockApplyId); const activeTabId = tab === "score-detail" ? "score-detail" : "ai-feedback"; const headerComponent = ; @@ -75,30 +80,40 @@ export default function ResumeAnalysisFeedbackPage({
- {activeTabId === "ai-feedback" ? ( - - {headerComponent} - - ) : ( - - {headerComponent} - - )} + {isPending ? ( +
+ + 분석 결과를 불러오는 중... + +
+ ) : isError ? ( +
+ 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해주세요. +
+ ) : analysisData ? ( + activeTabId === "ai-feedback" ? ( + + {headerComponent} + + ) : ( + + {headerComponent} + + ) + ) : null} setIsRetryModalOpen(true), - }} - saveAction={{ - onClick: () => router.push("/"), - }} + retryAction={{ onClick: () => setIsRetryModalOpen(true) }} + saveAction={{ onClick: () => router.push("/") }} /> {/* 모달 및 토스트 컴포넌트 렌더링 */} diff --git a/jobdri/src/app/page.tsx b/jobdri/src/app/page.tsx index b93eabd..d52c7b2 100644 --- a/jobdri/src/app/page.tsx +++ b/jobdri/src/app/page.tsx @@ -152,7 +152,7 @@ export default function Home() { }} onResume={(app) => { // 예시: 결과 상세 페이지로 이동! - router.push(`/mockApply/resume-analysis-feedback`); + router.push(`/mockApply/${app.mockApplyId}/result`); }} />
diff --git a/jobdri/src/components/common/chips/ChipTag.tsx b/jobdri/src/components/common/chips/ChipTag.tsx index 156074c..91919e1 100644 --- a/jobdri/src/components/common/chips/ChipTag.tsx +++ b/jobdri/src/components/common/chips/ChipTag.tsx @@ -24,8 +24,8 @@ const stateColorMap: Record< > = { default: "default", proven: "green", - mentioned: "pink", - fabricated: "red", + mentioned: "red", + fabricated: "pink", }; export default function ChipTag({ diff --git a/jobdri/src/components/mockApply/Question/QuestionList.tsx b/jobdri/src/components/mockApply/Question/QuestionList.tsx index f77ce4d..2527580 100644 --- a/jobdri/src/components/mockApply/Question/QuestionList.tsx +++ b/jobdri/src/components/mockApply/Question/QuestionList.tsx @@ -7,8 +7,8 @@ interface QuestionListProps { questions: ApiQuestionItem[]; // API에서 받아온 전체 문항 배열 selectedId: string | null; // 현재 선택된 문항 ID onSelect: (id: string) => void; // 문항 클릭 시 부모에게 알림 - onAdd: () => void; // 문항 추가 시 부모에게 알림 - onDelete: (id: string) => void; // 문항 삭제 시 부모에게 알림 + onAdd?: () => void; // 문항 추가 시 부모에게 알림 + onDelete?: (id: string) => void; // 문항 삭제 시 부모에게 알림 type?: "result" | "apply"; } @@ -53,7 +53,7 @@ export const QuestionList = ({ isActive={q.id === selectedId} onClick={() => onSelect(q.id)} onDelete={ - type === "apply" && questions.length > 1 + onDelete && type === "apply" && questions.length > 1 ? () => onDelete(q.id) : undefined } diff --git a/jobdri/src/components/mockApply/result/DetailAnotationPannel.tsx b/jobdri/src/components/mockApply/result/DetailAnotationPannel.tsx index 3ca7e65..8362dfc 100644 --- a/jobdri/src/components/mockApply/result/DetailAnotationPannel.tsx +++ b/jobdri/src/components/mockApply/result/DetailAnotationPannel.tsx @@ -4,7 +4,6 @@ import { useState } from "react"; // 💡 useState 추가 import { type QuestionAnalysis } from "@/lib/api/result"; import ChipTag from "@/components/common/chips/ChipTag"; import { type HighlightStatus } from "./highlightStyles"; -import { scrollbarClass } from "@/components/common/scrollbar/scrollbarStyles"; import Icon from "@/components/common/icons/Icon"; // 새로 추가된 Icon 유지 const statusLabel: Record = { diff --git a/jobdri/src/components/mockApply/result/ResumeAnalysisDetail.tsx b/jobdri/src/components/mockApply/result/ResumeAnalysisDetail.tsx index 5786741..30bee04 100644 --- a/jobdri/src/components/mockApply/result/ResumeAnalysisDetail.tsx +++ b/jobdri/src/components/mockApply/result/ResumeAnalysisDetail.tsx @@ -6,97 +6,79 @@ import { type QuestionItem as ApiQuestionItem } from "@/lib/api/questions"; import { lnbHiddenScrollbarClass } from "@/components/common/lnb/LnbScrollbar"; import DetailAnnotationPanel from "./DetailAnotationPannel"; import { scrollbarClassS } from "@/components/common/scrollbar/scrollbarStyles"; +import { QuestionAnalysis, type AnalysisResult } from "@/lib/api/result"; +import { HighlightStatus, highlightStyles } from "./highlightStyles"; interface ResumeAnalysisDetailProps { mockApplyId?: number; sequence?: number; + analysisData: AnalysisResult; children?: React.ReactNode; } -const MOCK_QUESTIONS: ApiQuestionItem[] = [ - { - id: "1", - question: - "디자이너로서 프로젝트에 AI를 활용한 파이프라인을 구축한 경험이 있다면, 해당 과정을 상세히 기술하고 느낀 점을 작성해주세요.", - answer: - "잡드리 서비스 기획 및 디자인 과정에서 AI를 활용한 디자인 시스템 구축 파이프라인을 경험했습니다. 기존에는 컴포넌트 정의, 네이밍, 디스크립션 작성을 디자이너가 전부 수동으로 진행했습니다. 이 과정에서 일관성이 떨어지고, 개발팀과의 커뮤니케이션 비용이 높아지는 문제가 반복되었습니다. 이를 해결하기 위해 AI를 활용한 컴포넌트 문서화 파이프라인을 구축했습니다...", - }, - { - id: "2", - question: - "기존 디자인 작업 방식의 비효율을 발견하고, 이를 개선하기 위해 새로운 도구나 프로세스를 도입했던 경험을 서술해주세요.", - answer: - "이전 프로젝트에서 팀원 간의 피그마 컴포넌트 싱크가 맞지 않아 퍼블리싱 단계에서 잦은 수정 작업이 발생했습니다. 이를 해결하고자 디자인 토큰 관리 플러그인을 도입하고 자동화 스크립트를 연결하여...", - }, - { - id: "3", - question: - "협업 과정에서 반복적으로 발생하는 커뮤니케이션 문제를 발견하고 이를 주도적으로 해결한 경험이 있나요?", - answer: - "개발팀과의 정기적인 디자인 리뷰 세션을 제안하여 기획 의도를 명확히 공유했습니다. 소통의 간극을 줄이기 위해 인터랙션 정의서 가이드를 프레임별로 상세화하여 제공한 결과, 커뮤니케이션 오류를 40% 이상 줄일 수 있었습니다.", - }, -]; - -const MOCK_ANALYSES: Record = { - "1": [ - { - questionAnalysisId: 101, - status: "proven", // 적절함 - reason: "모호한 표현보다는 구체적으로 기술해주세요.", - sentence: - "단순히 AI를 사용했다고 쓰는 대신, 어느 지점에서 디자이너의 판단이 개입했는지를 구체적인 사례로 나열했어요.", - }, - { - questionAnalysisId: 102, - status: "mentioned", // 신뢰성 부족 - reason: "수치 없는 표현은 신뢰를 얻기 어려워요.", - sentence: - '"크게 단축"이라는 표현은 주관적 서술이에요. 얼마나 단축됐는지 수치가 없으면 판단하기 어려워요.', - improvement: - "친환경차로의 전환기에서 사용자가 겪는 새로운 불편함을 해결하고, 신뢰할 수 있는 HMI를 설계하고자 지원했습니다.", - }, - { - questionAnalysisId: 103, - status: "fabricated", // 신뢰성 부족 - reason: "수치 없는 표현은 신뢰를 얻기 어려워요.", - sentence: - '"크게 단축"이라는 표현은 주관적 서술이에요. 얼마나 단축됐는지 수치가 없으면 판단하기 어려워요.', - improvement: - "친환경차로의 전환기에서 사용자가 겪는 새로운 불편함을 해결하고, 신뢰할 수 있는 HMI를 설계하고자 지원했습니다.", - }, - ], - "2": [ - { - questionAnalysisId: 201, - status: "proven", - reason: "프로세스 개선 목적이 명확하게 드러납니다.", - sentence: - "디자인 토큰 관리 플러그인을 도입하고 자동화 스크립트를 연결하여 반복 작업을 덜어냈습니다.", - }, - ], - "3": [ - { - questionAnalysisId: 301, - status: "fabricated", // 구체성 부족 - reason: "해결 방안의 인과관계 보완이 필요합니다.", - sentence: "가이드를 상세화하여 제공한 결과 커뮤니케이션 오류를 줄임.", - improvement: - "어떤 방식으로 프레임을 상세화했는지, 구체적인 산출물 예시를 덧붙여주시면 훨씬 좋은 문장이 됩니다.", - }, - ], -}; - interface QuestionViewerProps { questionNumber?: number; questionText?: string; answerText?: string; + analyses?: QuestionAnalysis[]; + selectedAnalysisId?: number | null; + onAnalysisClick?: (analysisId: number) => void; } function QuestionViewer({ questionNumber = 1, questionText = "문항이 선택되지 않았거나 등록된 내용이 없습니다.", answerText = "", + analyses = [], + selectedAnalysisId, + onAnalysisClick, }: QuestionViewerProps) { + const renderHighlightedText = () => { + if (!answerText) return null; + if (!analyses || analyses.length === 0) return answerText; + + const sortedAnalyses = [...analyses].sort((a, b) => a.start - b.start); + const result = []; + let currentIndex = 0; + console.log("들어온 분석 데이터:", analyses); + + sortedAnalyses.forEach((analysis, idx) => { + if (analysis.start > currentIndex) { + result.push( + + {answerText.slice(currentIndex, analysis.start)} + , + ); + } + + const status = analysis.status as HighlightStatus; + const isSelected = selectedAnalysisId === analysis.questionAnalysisId; + const styleClass = + highlightStyles[status]?.[isSelected ? "selected" : "default"] || ""; + + // 3. 하이라이트 처리된 텍스트 렌더링 + result.push( + onAnalysisClick?.(analysis.questionAnalysisId)} + > + {answerText.slice(analysis.start, analysis.end)} + , + ); + + currentIndex = analysis.end; + }); + + if (currentIndex < answerText.length) { + result.push( + {answerText.slice(currentIndex)}, + ); + } + + return result; + }; + return (
@@ -107,8 +89,10 @@ function QuestionViewer({ {questionText}
-
- {answerText} + + {/* 🌟 렌더링 로직 적용 (whitespace-pre-line으로 줄바꿈 유지) */} +
+ {renderHighlightedText()}
); @@ -117,38 +101,41 @@ function QuestionViewer({ export default function ResumeAnalysisDetail({ mockApplyId, sequence, + analysisData, // 🌟 프롭스 받기 children, }: ResumeAnalysisDetailProps) { - const [questions, setQuestions] = useState(MOCK_QUESTIONS); - const [selectedId, setSelectedId] = useState("1"); + const initialQuestions = analysisData.questions.map((q) => ({ + id: String(q.questionId), + question: q.questionContent, + answer: q.answer, + })); + + const [questions, setQuestions] = + useState(initialQuestions); + // 첫 번째 문항을 기본 선택 상태로 둠 + const [selectedId, setSelectedId] = useState( + initialQuestions[0]?.id || null, + ); + const scrollRef = useRef(null); const [showGradient, setShowGradient] = useState(false); - // 그라데이션 표시 논리 개선 (소수점 오차 완벽 차단) const checkScroll = () => { if (scrollRef.current) { const { scrollHeight, clientHeight, scrollTop } = scrollRef.current; - - // 2px 정도의 여유 오차를 두어 확실하게 내용이 길 때만 스크롤이 있다고 판단 const isScrollable = scrollHeight - clientHeight > 2; const isNotAtBottom = Math.ceil(scrollTop + clientHeight) < scrollHeight - 2; - setShowGradient(isScrollable && isNotAtBottom); } }; - // ResizeObserver 도입 (렌더링 속도 차이로 인한 오류 해결) useEffect(() => { - // 탭 클릭 직후 렌더링 지연을 고려해 50ms 후 체크 const timer = setTimeout(checkScroll, 50); - - // 요소의 크기가 변할 때마다 무조건 다시 체크 const observer = new ResizeObserver(() => checkScroll()); if (scrollRef.current) { observer.observe(scrollRef.current); } - window.addEventListener("resize", checkScroll); return () => { @@ -161,27 +148,9 @@ export default function ResumeAnalysisDetail({ const currentQuestionIdx = questions.findIndex((q) => q.id === selectedId); const currentQuestion = questions[currentQuestionIdx]; - const handleAddQuestion = () => { - if (questions.length >= 5) return; - const newId = String(Date.now()); - const newQuestion: ApiQuestionItem = { - id: newId, - question: "새 문항", - answer: "", - }; - setQuestions([...questions, newQuestion]); - setSelectedId(newId); - }; - - const handleDeleteQuestion = (id: string) => { - if (questions.length <= 1) return; - const filtered = questions.filter((q) => q.id !== id); - setQuestions(filtered); - - if (selectedId === id) { - setSelectedId(filtered[0].id); - } - }; + const currentAnalyses = + analysisData.questions.find((q) => String(q.questionId) === selectedId) + ?.analyses || []; return (
@@ -198,8 +167,6 @@ export default function ResumeAnalysisDetail({ questions={questions} selectedId={selectedId} onSelect={setSelectedId} - onAdd={handleAddQuestion} - onDelete={handleDeleteQuestion} type="result" />
@@ -210,6 +177,7 @@ export default function ResumeAnalysisDetail({ } questionText={currentQuestion?.question} answerText={currentQuestion?.answer} + analyses={currentAnalyses} />
@@ -218,7 +186,7 @@ export default function ResumeAnalysisDetail({ 피드백 - {selectedId ? MOCK_ANALYSES[selectedId]?.length || 0 : 0} + {currentAnalyses.length}
@@ -227,11 +195,7 @@ export default function ResumeAnalysisDetail({ onScroll={checkScroll} className={`${scrollbarClassS} overflow-y-auto overflow-x-hidden w-full flex-1 min-h-0 px-3 pb-3`} > - +
{showGradient && ( diff --git a/jobdri/src/components/mockApply/result/ResumeAnalysisFeedback.tsx b/jobdri/src/components/mockApply/result/ResumeAnalysisFeedback.tsx index cb813a0..c80b1e5 100644 --- a/jobdri/src/components/mockApply/result/ResumeAnalysisFeedback.tsx +++ b/jobdri/src/components/mockApply/result/ResumeAnalysisFeedback.tsx @@ -18,11 +18,13 @@ import ScoreBar from "@/components/mockApply/result/ScoreBar"; import ScoreCircle from "@/components/mockApply/result/ScoreCircle"; import { useReApply } from "@/hooks/useReApply"; import { getMockApplyResumeRecords } from "@/lib/api/mockApplies"; +import type { AnalysisResult } from "@/lib/api/result"; interface ResumeAnalysisFeedbackProps { mockApplyId?: number; sequence?: number; - children?: React.ReactNode; // 추가된 부분: AnalysisHeader 등을 받기 위함 + children?: React.ReactNode; + analysisData: AnalysisResult; } const scoreItems = [ @@ -109,7 +111,26 @@ function ScoreMetricRow({ ); } -function ScoreSummaryCard() { +function ScoreSummaryCard({ data }: { data: AnalysisResult }) { + // API 데이터를 기반으로 점수 항목 생성 (80점 미만이면 danger 톤으로 처리) + const scoreItems = [ + { + label: "직무 적합성", + score: data.jobFit, + tone: data.jobFit >= 80 ? "primary" : "danger", + }, + { + label: "정량적 성과", + score: data.impact, + tone: data.impact >= 80 ? "primary" : "danger", + }, + { + label: "논리 구조", + score: data.completeness, + tone: data.completeness >= 80 ? "primary" : "danger", + }, + ] as const; + return (
- + 총점
@@ -127,7 +148,7 @@ function ScoreSummaryCard() {
- +
@@ -148,17 +169,16 @@ function ScoreSummaryCard() { type="SPARKLE" className="h-6 w-6 shrink-0 text-icon-primary-default" /> -

- 강점이 잘 드러나고 있어요 +

+ {data.score >= 80 + ? "강점이 잘 드러나고 있어요" + : "조금 더 보완이 필요해요"}

-

- 직무 적합성과 정량적 성과는 강하지만, 회사·직무에 대한 구체적 - 이해와 본인만의 차별점이 더 드러나면 통과 가능성이 한 단계 - 올라가요. 지원 동기와 입사 후 기여 방향을 좀 더 구체적으로 - 서술하면 설득력이 높아집니다. +

+ {data.feedback}

@@ -167,19 +187,17 @@ function ScoreSummaryCard() { ); } -function ReviewSummaryCard() { +// 총평 카드 (추후 missingKeywords나 questions.analyses 바탕으로 동적 구성 가능) +function ReviewSummaryCard({ data }: { data: AnalysisResult }) { const [activeTabId, setActiveTabId] = useState(reviewTabs[0].id); const isStrengthTab = activeTabId === "strengths"; const evaluations = isStrengthTab ? strengthEvaluations : weaknessEvaluations; return ( -