From 4066a1ca3b7f5f81a8bcd011461ff146a8ad6391 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 02:02:46 +0900 Subject: [PATCH 01/13] =?UTF-8?q?=EC=84=B8=EB=B6=80=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Interview/feedback_result.tsx | 115 ++++++++++++++++------- src/pages/Interview/question_loading.tsx | 45 +++++++-- src/services/interviewApi.ts | 67 ++++++++----- 3 files changed, 164 insertions(+), 63 deletions(-) diff --git a/src/pages/Interview/feedback_result.tsx b/src/pages/Interview/feedback_result.tsx index 40499df..af97f27 100644 --- a/src/pages/Interview/feedback_result.tsx +++ b/src/pages/Interview/feedback_result.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import InterviewLayout from '@/layouts/InterviewLayout'; -import type { IFeedbackItem, IFinalFeedbackResponse } from '@/services/interviewApi'; +import type { IFinalFeedbackResponse } from '@/services/interviewApi'; import { getFinalFeedback } from '@/services/interviewApi'; interface IQuestionState { @@ -27,13 +27,39 @@ export default function FeedbackResult() { navigate('/upload'); return; } + const fetchFeedback = async () => { try { setIsLoading(true); + console.log('๐Ÿ“Š ์ตœ์ข… ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ:', sessionId); + const response = await getFinalFeedback(sessionId); - setFeedbackData(response); - const states = response.feedbacks.map((_, idx) => ({ id: idx + 1, showAnswer: false })); - setQuestionStates(states); + + console.log('โœ… ํ”ผ๋“œ๋ฐฑ ์‘๋‹ต:', response); + + // feedbackProgressStatus ํ™•์ธ + if (response.feedbackProgressStatus === 'WORKING') { + // ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ ์ค‘ - 5์ดˆ ํ›„ ์žฌ์‹œ๋„ + setTimeout(fetchFeedback, 5000); + return; + } + + if (response.feedbackProgressStatus === 'FAILED') { + setError('ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + setIsLoading(false); + return; + } + + if (response.interviewSummary) { + setFeedbackData(response); + + // ์งˆ๋ฌธ ์ƒํƒœ ์ดˆ๊ธฐํ™” + const states = response.interviewSummary.questionSummaries.map((_, idx) => ({ + id: idx + 1, + showAnswer: false, + })); + setQuestionStates(states); + } } catch (err) { console.error('โŒ ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ ์‹คํŒจ:', err); setError('ํ”ผ๋“œ๋ฐฑ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); @@ -41,6 +67,7 @@ export default function FeedbackResult() { setIsLoading(false); } }; + void fetchFeedback(); }, [navigate, sessionId]); @@ -64,7 +91,7 @@ export default function FeedbackResult() { ); } - if (error || !feedbackData) { + if (error || !feedbackData || !feedbackData.interviewSummary) { return (
@@ -79,61 +106,69 @@ export default function FeedbackResult() { ); } - const { feedbacks, totalQuestions, timeoutCount } = feedbackData; + const { interviewSummary } = feedbackData; return (

- ์ด {totalQuestions}๋ฌธํ•ญ์— ๋Œ€ํ•œ ์ตœ์ข… ํ”ผ๋“œ๋ฐฑ + {interviewSummary.interviewTitle}์— ๋Œ€ํ•œ ์ตœ์ข… + ํ”ผ๋“œ๋ฐฑ

- {timeoutCount > 0 && ( + {interviewSummary.timeoutQuestionNumber > 0 && (

์‹œ๊ฐ„ ์ดˆ๊ณผ๋กœ ๋‹ต๋ณ€ํ•˜์ง€ ๋ชปํ•œ ์งˆ๋ฌธ{' '} - {timeoutCount}๊ฐœ + {interviewSummary.timeoutQuestionNumber}๊ฐœ

)}
- {feedbacks.map((item: IFeedbackItem, index: number) => { + {interviewSummary.questionSummaries.map((summary, index) => { const isShowingAnswer = questionStates.find((q) => q.id === index + 1)?.showAnswer || false; - const isPositive = item.feedbackType === 'positive'; - const feedbackTypeLabel = isPositive ? 'AI ํ”ผ๋“œ๋ฐฑ(๊ธ์ •)' : 'AI ํ”ผ๋“œ๋ฐฑ(๊ฐœ์„ )'; - const hasAnswer = !!item.answer && item.answer.trim().length > 0; + + // AI ํ”ผ๋“œ๋ฐฑ๊ณผ ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ ์ค‘ ํ‘œ์‹œํ•  ๊ฒƒ ์„ ํƒ + const feedbackText = summary.aiFeedback || summary.selfFeedback; + const feedbackType = summary.aiFeedback ? 'AI ํ”ผ๋“œ๋ฐฑ' : summary.selfFeedback ? '์…€ํ”„ ํ”ผ๋“œ๋ฐฑ' : 'ํ”ผ๋“œ๋ฐฑ ์—†์Œ'; + + // ๋‹ต๋ณ€ ํ…์ŠคํŠธ (Q&A ํ„ด์—์„œ ANSWER๋งŒ ์ถ”์ถœ) + const answerTurns = summary.qnaTurns.filter((turn) => turn.turn === 'ANSWER'); + const hasAnswer = answerTurns.length > 0; return ( -
+
+ {/* ์นด๋“œ ํ—ค๋” */}

- {index + 1}. {item.question} + {summary.questionNumber}. {summary.rootQuestion}

-

{feedbackTypeLabel}

+

{feedbackType}

+ {/* ์นด๋“œ ๋‚ด์šฉ (์Šคํฌ๋กค ๊ฐ€๋Šฅ) */}
{isShowingAnswer ? ( hasAnswer ? (
-
-

๋‹ต๋ณ€:

-

{item.answer}

-
+ {summary.qnaTurns.map((turn, turnIndex) => ( +
+

{turn.turn === 'QUESTION' ? '์งˆ๋ฌธ:' : '๋‹ต๋ณ€:'}

+

{turn.content}

+
+ ))}
) : ( -

{item.timeout ? '์‹œ๊ฐ„ ์ดˆ๊ณผ๋กœ ๋‹ต๋ณ€ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.' : '๋‹ต๋ณ€์ด ์ œ๊ณต๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.'}

+

์‹œ๊ฐ„ ์ดˆ๊ณผ๋กœ ๋‹ต๋ณ€ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.

) - ) : item.feedback ? ( -

{item.feedback}

+ ) : feedbackText ? ( +

{feedbackText}

) : (

ํ”ผ๋“œ๋ฐฑ์ด ์ƒ์„ฑ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.

)}
+ {/* ๋ฒ„ํŠผ */}
); diff --git a/src/pages/Interview/question_loading.tsx b/src/pages/Interview/question_loading.tsx index b19f35c..17e5fcd 100644 --- a/src/pages/Interview/question_loading.tsx +++ b/src/pages/Interview/question_loading.tsx @@ -1,17 +1,17 @@ -๏ปฟimport { useEffect } from 'react'; +import { useEffect } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import InterviewLayout from '@/layouts/InterviewLayout'; import type { ICreateInterviewSessionResponse } from '@/services/interviewApi'; import { createInterviewSession } from '@/services/interviewApi'; -const ANSWER_ROUTE = '/main-answer'; // ํ”„๋กœ์ ํŠธ ๋ผ์šฐํŠธ์— ๋งž๊ฒŒ ์กฐ์ • +const ANSWER_ROUTE = '/main-answer'; type TLocationState = { fileName?: string; jobTitle?: string; interviewType?: 'normal' | 'pressure'; - resumeKey?: string; // S3 key + resumeKey?: string; }; export default function QuestionLoading() { @@ -26,20 +26,29 @@ export default function QuestionLoading() { const interviewType = location.state?.interviewType ?? 'normal'; const resumeKey = location.state?.resumeKey; + console.log('๐ŸŽฌ ๋ฉด์ ‘ ์„ธ์…˜ ์ƒ์„ฑ ์‹œ์ž‘'); + console.log('- fileName:', fileName); + console.log('- jobTitle:', jobTitle); + console.log('- interviewType:', interviewType); + console.log('- resumeKey:', resumeKey); + if (!jobTitle || !resumeKey) { alert('๋ฉด์ ‘ ์ƒ์„ฑ์— ํ•„์š”ํ•œ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. (jobTitle/resumeKey)'); navigate(-1); return; } - // ์ŠคํŽ™์— ๋งž๊ฒŒ ์š”์ฒญ const resp: ICreateInterviewSessionResponse = await createInterviewSession({ resumeKey, jobTitle, interviewType, }); - // ์„ฑ๊ณต โ†’ ๋‹ต๋ณ€ ํŽ˜์ด์ง€๋กœ ์ด๋™ (ํ•„์š”๊ฐ’ ์ „๋‹ฌ) + console.log('โœ… ๋ฉด์ ‘ ์„ธ์…˜ ์ƒ์„ฑ ์„ฑ๊ณต:', resp); + console.log('- sessionId:', resp.sessionId); + console.log('- firstQuestion:', resp.firstQuestion); + + // ์„ฑ๊ณต โ†’ ๋‹ต๋ณ€ ํŽ˜์ด์ง€๋กœ ์ด๋™ navigate(ANSWER_ROUTE, { replace: true, state: { @@ -52,8 +61,12 @@ export default function QuestionLoading() { fromLoading: true, }, }); - } catch (e) { - console.error('์งˆ๋ฌธ ์ƒ์„ฑ ์‹คํŒจ:', e); + } catch (e: any) { + console.error('โŒ ์งˆ๋ฌธ ์ƒ์„ฑ ์‹คํŒจ:', e); + console.error('- ์—๋Ÿฌ ๋ฉ”์‹œ์ง€:', e.message); + console.error('- ์—๋Ÿฌ ์‘๋‹ต:', e.response?.data); + console.error('- ์—๋Ÿฌ ์ƒํƒœ:', e.response?.status); + alert('๋งž์ถคํ˜• ์งˆ๋ฌธ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'); navigate(-1); } @@ -82,9 +95,21 @@ export default function QuestionLoading() {
); diff --git a/src/services/interviewApi.ts b/src/services/interviewApi.ts index bcf7cf4..5d57955 100644 --- a/src/services/interviewApi.ts +++ b/src/services/interviewApi.ts @@ -142,29 +142,54 @@ export interface ICreateInterviewSessionResponse { export const createInterviewSession = async ( data: ICreateInterviewSessionRequest, ): Promise => { - const resumeId = extractResumeId(data.resumeKey); - const mode: InterviewMode = data.interviewType === 'pressure' ? 'HARD' : 'NORMAL'; + try { + console.log('๐ŸŽฌ API ํ˜ธ์ถœ: /api/interview-sessions'); + console.log('- resumeKey:', data.resumeKey); + console.log('- jobTitle:', data.jobTitle); + console.log('- interviewType:', data.interviewType); - const payload: ICreateInterviewSessionPayload = { - mode, - jobRole: data.jobTitle, - resumeId, - }; + const resumeId = extractResumeId(data.resumeKey); + const mode: InterviewMode = data.interviewType === 'pressure' ? 'HARD' : 'NORMAL'; - const response = await apiClient.post('/api/interview-sessions', payload); - const apiResult = unwrapResult(response.data); + const payload: ICreateInterviewSessionPayload = { + mode, + jobRole: data.jobTitle, + resumeId, + }; - const firstQuestion: IQuestion = { - questionId: String(apiResult.firstQuestionId), - mainQuestion: apiResult.firstQuestionText, - subQuestion: '', - order: 1, - }; + console.log('๐Ÿ“ค ์š”์ฒญ payload:', payload); - return { - sessionId: String(apiResult.sessionId), - firstQuestion, - }; + const response = await apiClient.post('/api/interview-sessions', payload); + + console.log('๐Ÿ“ฅ ์›๋ณธ ์‘๋‹ต:', response.data); + + const apiResult = unwrapResult(response.data); + + console.log('โœ… unwrap ํ›„ ๊ฒฐ๊ณผ:', apiResult); + + const firstQuestion: IQuestion = { + questionId: String(apiResult.firstQuestionId), + mainQuestion: apiResult.firstQuestionText, + subQuestion: '', + order: 1, + }; + + const result = { + sessionId: String(apiResult.sessionId), + firstQuestion, + }; + + console.log('โœ… ์ตœ์ข… ๋ฐ˜ํ™˜๊ฐ’:', result); + + return result; + } catch (error: any) { + console.error('โŒ createInterviewSession ์—๋Ÿฌ:'); + console.error('- ์—๋Ÿฌ ๊ฐ์ฒด:', error); + console.error('- ์—๋Ÿฌ ๋ฉ”์‹œ์ง€:', error.message); + console.error('- ์‘๋‹ต ๋ฐ์ดํ„ฐ:', error.response?.data); + console.error('- ์‘๋‹ต ์ƒํƒœ:', error.response?.status); + throw error; + } }; // ===================================================== @@ -407,10 +432,10 @@ export interface IFinalFeedbackResponse { interviewSummary: IInterviewSummary | null; // WORKING์ผ ๋•Œ๋Š” null } -/** ์ตœ์ข… ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ (GET /api/interview-sesisons/{sessionId}) */ +/** ์ตœ์ข… ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ (GET /api/interview-sessions/{sessionId}) */ export const getFinalFeedback = async ( sessionId: string | number, ): Promise => { - const response = await apiClient.get(`/api/interview-sesisons/${sessionId}`); + const response = await apiClient.get(`/api/interview-sessions/${sessionId}`); return unwrapResult(response.data); }; From 9f47ad9b6700c68050c234f41ac71b5b851d5b07 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 02:06:25 +0900 Subject: [PATCH 02/13] =?UTF-8?q?500=EC=97=90=EB=9F=AC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/api.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/services/api.ts b/src/services/api.ts index 0fae877..0db2d7f 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -39,6 +39,17 @@ apiClient.interceptors.response.use( // ์„œ๋ฒ„๊ฐ€ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ ๊ฒฝ์šฐ console.error('API Error:', error.response.data); + // ํŠน๋ณ„ ์ฒ˜๋ฆฌ: 500 ์—๋Ÿฌ์—ฌ๋„ result ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์œผ๋ฉด ์„ฑ๊ณต์œผ๋กœ ์ฒ˜๋ฆฌ + // (๋ฐฑ์—”๋“œ ๋ฒ„๊ทธ: 500 ์ฝ”๋“œ๋ฅผ ๋ณด๋‚ด์ง€๋งŒ ์ •์ƒ ๋ฐ์ดํ„ฐ๋„ ํ•จ๊ป˜ ๋ณด๋ƒ„) + if ( + error.response.status === 500 && + error.response.data && + (error.response.data.result || error.response.data.sessionId) + ) { + console.warn('โš ๏ธ 500 ์—๋Ÿฌ์ง€๋งŒ ์ •์ƒ ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์–ด ์„ฑ๊ณต์œผ๋กœ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.'); + return error.response; + } + // 401 Unauthorized if (error.response.status === 401) { // ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ๋“ฑ From 6a7922708cd8adb0fc04c317e37528583eca7d50 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 02:12:53 +0900 Subject: [PATCH 03/13] =?UTF-8?q?500=EC=97=90=EB=9F=AC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/api.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/services/api.ts b/src/services/api.ts index 0db2d7f..d78c34c 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -34,22 +34,22 @@ apiClient.interceptors.response.use( return response; }, (error) => { + // ํŠน๋ณ„ ์ฒ˜๋ฆฌ: 500 ์—๋Ÿฌ์—ฌ๋„ result ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์œผ๋ฉด ์„ฑ๊ณต์œผ๋กœ ์ฒ˜๋ฆฌ + // (๋ฐฑ์—”๋“œ ๋ฒ„๊ทธ: 500 ์ฝ”๋“œ๋ฅผ ๋ณด๋‚ด์ง€๋งŒ ์ •์ƒ ๋ฐ์ดํ„ฐ๋„ ํ•จ๊ป˜ ๋ณด๋ƒ„) + if ( + error.response?.status === 500 && + error.response?.data && + (error.response.data.result || error.response.data.sessionId || error.response.data.interviewSummary) + ) { + // ์กฐ์šฉํžˆ ์„ฑ๊ณต์œผ๋กœ ์ฒ˜๋ฆฌ (์—๋Ÿฌ ๋กœ๊ทธ ์—†์ด) + return error.response; + } + // ์—๋Ÿฌ ์ฒ˜๋ฆฌ if (error.response) { // ์„œ๋ฒ„๊ฐ€ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ ๊ฒฝ์šฐ console.error('API Error:', error.response.data); - // ํŠน๋ณ„ ์ฒ˜๋ฆฌ: 500 ์—๋Ÿฌ์—ฌ๋„ result ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์œผ๋ฉด ์„ฑ๊ณต์œผ๋กœ ์ฒ˜๋ฆฌ - // (๋ฐฑ์—”๋“œ ๋ฒ„๊ทธ: 500 ์ฝ”๋“œ๋ฅผ ๋ณด๋‚ด์ง€๋งŒ ์ •์ƒ ๋ฐ์ดํ„ฐ๋„ ํ•จ๊ป˜ ๋ณด๋ƒ„) - if ( - error.response.status === 500 && - error.response.data && - (error.response.data.result || error.response.data.sessionId) - ) { - console.warn('โš ๏ธ 500 ์—๋Ÿฌ์ง€๋งŒ ์ •์ƒ ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์–ด ์„ฑ๊ณต์œผ๋กœ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.'); - return error.response; - } - // 401 Unauthorized if (error.response.status === 401) { // ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ๋“ฑ From 54d75718628655191bd5ef5a6dfba023d362c432 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 02:15:00 +0900 Subject: [PATCH 04/13] =?UTF-8?q?500=EC=97=90=EB=9F=AC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Interview/question_loading.tsx | 49 +++++++++--------------- 1 file changed, 19 insertions(+), 30 deletions(-) diff --git a/src/pages/Interview/question_loading.tsx b/src/pages/Interview/question_loading.tsx index 17e5fcd..fbc59f5 100644 --- a/src/pages/Interview/question_loading.tsx +++ b/src/pages/Interview/question_loading.tsx @@ -1,4 +1,5 @@ -import { useEffect } from 'react'; +// src/pages/Interview/question_loading.tsx +import { useEffect, useRef } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import InterviewLayout from '@/layouts/InterviewLayout'; @@ -11,14 +12,21 @@ type TLocationState = { fileName?: string; jobTitle?: string; interviewType?: 'normal' | 'pressure'; - resumeKey?: string; + resumeKey?: string; // S3 key }; export default function QuestionLoading() { const navigate = useNavigate(); const location = useLocation() as { state?: TLocationState }; + // โœ… ๋ถ€ํŠธ์ŠคํŠธ๋žฉ์ด ์ค‘๋ณต ์‹คํ–‰๋˜๋Š” ๊ฒƒ์„ ๋ง‰๊ธฐ ์œ„ํ•œ ref ๊ฐ€๋“œ + const bootstrappedRef = useRef(false); + useEffect(() => { + // ์ด๋ฏธ ์‹คํ–‰๋œ ์  ์žˆ์œผ๋ฉด ๋‹ค์‹œ ์‹คํ–‰ํ•˜์ง€ ์•Š์Œ + if (bootstrappedRef.current) return; + bootstrappedRef.current = true; + const bootstrap = async () => { try { const fileName = location.state?.fileName ?? '์ž์†Œ์„œ'; @@ -26,29 +34,20 @@ export default function QuestionLoading() { const interviewType = location.state?.interviewType ?? 'normal'; const resumeKey = location.state?.resumeKey; - console.log('๐ŸŽฌ ๋ฉด์ ‘ ์„ธ์…˜ ์ƒ์„ฑ ์‹œ์ž‘'); - console.log('- fileName:', fileName); - console.log('- jobTitle:', jobTitle); - console.log('- interviewType:', interviewType); - console.log('- resumeKey:', resumeKey); - if (!jobTitle || !resumeKey) { alert('๋ฉด์ ‘ ์ƒ์„ฑ์— ํ•„์š”ํ•œ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. (jobTitle/resumeKey)'); navigate(-1); return; } + // ์ŠคํŽ™์— ๋งž๊ฒŒ ์„ธ์…˜ ์ƒ์„ฑ ์š”์ฒญ const resp: ICreateInterviewSessionResponse = await createInterviewSession({ resumeKey, jobTitle, interviewType, }); - console.log('โœ… ๋ฉด์ ‘ ์„ธ์…˜ ์ƒ์„ฑ ์„ฑ๊ณต:', resp); - console.log('- sessionId:', resp.sessionId); - console.log('- firstQuestion:', resp.firstQuestion); - - // ์„ฑ๊ณต โ†’ ๋‹ต๋ณ€ ํŽ˜์ด์ง€๋กœ ์ด๋™ + // โœ… ์„ฑ๊ณต โ†’ ๋‹ต๋ณ€ ํŽ˜์ด์ง€๋กœ ์ด๋™ navigate(ANSWER_ROUTE, { replace: true, state: { @@ -61,19 +60,16 @@ export default function QuestionLoading() { fromLoading: true, }, }); - } catch (e: any) { - console.error('โŒ ์งˆ๋ฌธ ์ƒ์„ฑ ์‹คํŒจ:', e); - console.error('- ์—๋Ÿฌ ๋ฉ”์‹œ์ง€:', e.message); - console.error('- ์—๋Ÿฌ ์‘๋‹ต:', e.response?.data); - console.error('- ์—๋Ÿฌ ์ƒํƒœ:', e.response?.status); - + } catch (e) { + console.error('์งˆ๋ฌธ ์ƒ์„ฑ ์‹คํŒจ:', e); alert('๋งž์ถคํ˜• ์งˆ๋ฌธ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'); navigate(-1); } }; void bootstrap(); - }, [location.state, navigate]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [navigate]); // location.state๋Š” ์ดˆ๊ธฐ ํ•œ ๋ฒˆ๋งŒ ์“ฐ๊ณ , ์žฌ์‹คํ–‰์„ ๋ง‰๊ธฐ ์œ„ํ•ด deps์—์„œ ์ œ์™ธ return ( @@ -95,17 +91,10 @@ export default function QuestionLoading() {
); diff --git a/src/hooks/useGetInterviewSummary.ts b/src/hooks/useGetInterviewSummary.ts new file mode 100644 index 0000000..da0bb4d --- /dev/null +++ b/src/hooks/useGetInterviewSummary.ts @@ -0,0 +1,9 @@ +import { getInterviewSummary } from '@/apis/myPage'; +import { useCoreQuery } from '@/hooks/customQuery'; + +export default function useGetInterviewSummary(interviewId: number) { + const { data, isLoading, error } = useCoreQuery(['getInterviewSummary', interviewId], () => getInterviewSummary(interviewId), { + enabled: !!interviewId, + }); + return { data, isLoading, error }; +} diff --git a/src/hooks/useGetQuestionAnswers.ts b/src/hooks/useGetQuestionAnswers.ts new file mode 100644 index 0000000..9c051e0 --- /dev/null +++ b/src/hooks/useGetQuestionAnswers.ts @@ -0,0 +1,9 @@ +import { getQuestionAnswers } from '@/apis/myPage'; +import { useCoreQuery } from '@/hooks/customQuery'; + +export default function useGetQuestionAnswers(questionId: number | null) { + const { data, isLoading, error } = useCoreQuery(['getQuestionAnswers', questionId], () => getQuestionAnswers(questionId!), { + enabled: !!questionId, + }); + return { data, isLoading, error }; +} diff --git a/src/hooks/useGetQuestionFeedback.ts b/src/hooks/useGetQuestionFeedback.ts new file mode 100644 index 0000000..cc7c243 --- /dev/null +++ b/src/hooks/useGetQuestionFeedback.ts @@ -0,0 +1,9 @@ +import { getQuestionFeedback } from '@/apis/myPage'; +import { useCoreQuery } from '@/hooks/customQuery'; + +export default function useGetQuestionFeedback(questionId: number | null) { + const { data, isLoading, error } = useCoreQuery(['getQuestionFeedback', questionId], () => getQuestionFeedback(questionId!), { + enabled: !!questionId, + }); + return { data, isLoading, error }; +} diff --git a/src/hooks/useGetRandomQuestions.ts b/src/hooks/useGetRandomQuestions.ts new file mode 100644 index 0000000..84e90e1 --- /dev/null +++ b/src/hooks/useGetRandomQuestions.ts @@ -0,0 +1,9 @@ +import { getRandomQuestions } from '@/apis/myPage'; +import { useCoreQuery } from '@/hooks/customQuery'; + +export default function useGetRandomQuestions(questionId: number | null) { + const { data, isLoading, error } = useCoreQuery(['getRandomQuestions', questionId], () => getRandomQuestions(questionId!), { + enabled: !!questionId, + }); + return { data, isLoading, error }; +} diff --git a/src/pages/myInterviews.tsx b/src/pages/myInterviews.tsx index 32d2106..bf16c3d 100644 --- a/src/pages/myInterviews.tsx +++ b/src/pages/myInterviews.tsx @@ -1,8 +1,275 @@ -export default function MyInterview() { +import { useEffect, useRef, useState } from 'react'; +import { useParams } from 'react-router-dom'; + +import useGetInterviewSummary from '@/hooks/useGetInterviewSummary'; +import useGetQuestionAnswers from '@/hooks/useGetQuestionAnswers'; +import useGetQuestionFeedback from '@/hooks/useGetQuestionFeedback'; +import useGetRandomQuestions from '@/hooks/useGetRandomQuestions'; + +import ClockFrog from '@/assets/clockFrog.svg?react'; + +type TabType = 'answer' | 'feedback' | 'random'; + +export default function MyInterviews() { + const { id } = useParams<{ id: string }>(); + const interviewId = id ? parseInt(id) : 0; + + const [selectedQuestionId, setSelectedQuestionId] = useState(null); + const [activeTab, setActiveTab] = useState('answer'); + const [playingAudio, setPlayingAudio] = useState(null); + const audioRef = useRef(null); + + const { data: summaryData, isLoading: summaryLoading } = useGetInterviewSummary(interviewId); + const { data: answersData, isLoading: answersLoading } = useGetQuestionAnswers(activeTab === 'answer' ? selectedQuestionId : null); + const { data: feedbackData, isLoading: feedbackLoading } = useGetQuestionFeedback(activeTab === 'feedback' ? selectedQuestionId : null); + const { data: randomData, isLoading: randomLoading } = useGetRandomQuestions(activeTab === 'random' ? selectedQuestionId : null); + + // ์ฒซ ๋ฒˆ์งธ ์งˆ๋ฌธ ์ž๋™ ์„ ํƒ + useEffect(() => { + if (summaryData?.result.questionCards && summaryData.result.questionCards.length > 0 && !selectedQuestionId) { + setSelectedQuestionId(summaryData.result.questionCards[0].questionId); + } + }, [summaryData, selectedQuestionId]); + + const handleAudioPlay = (url: string) => { + if (playingAudio === url) { + audioRef.current?.pause(); + setPlayingAudio(null); + } else { + if (audioRef.current) { + audioRef.current.pause(); + } + audioRef.current = new Audio(url); + audioRef.current.play(); + setPlayingAudio(url); + + audioRef.current.onended = () => { + setPlayingAudio(null); + }; + } + }; + + if (summaryLoading) { + return ( +
+
+ +

๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

+
+
+ ); + } + + if (!summaryData?.result) { + return ( +
+

๋ฉด์ ‘ ๋ฐ์ดํ„ฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

+
+ ); + } + + const { title, timedOutCount, questionCards } = summaryData.result; + const isLoading = answersLoading || feedbackLoading || randomLoading; + return ( -
-

interview

-

hi

+
+
+ {/* ํ—ค๋” */} +
+

{title}

+ {timedOutCount > 0 && ( +

+ ์‹œ๊ฐ„ ์ดˆ๊ณผ๋กœ ๋‹ต๋ณ€ํ•˜์ง€ ๋ชปํ•œ ์งˆ๋ฌธ{' '} + {timedOutCount}๊ฐœ +

+ )} +
+ + {/* ์งˆ๋ฌธ ์นด๋“œ ๋ฆฌ์ŠคํŠธ */} +
+

๋ฉด์ ‘ ์งˆ๋ฌธ

+
+ {questionCards.map((card) => ( + + ))} +
+
+ + {/* ํƒญ ๋ฉ”๋‰ด */} +
+
+ + + +
+ + {/* ํƒญ ์ฝ˜ํ…์ธ  */} +
+ {isLoading ? ( +
+ +
+ ) : ( + <> + {/* ๋‹ต๋ณ€ ํ™•์ธ ํƒญ */} + {activeTab === 'answer' && answersData?.result && ( +
+ {answersData.result.map((item) => ( +
+
+ + ์งˆ๋ฌธ {item.order} + +

{item.question}

+
+ {item.answerText ? ( + <> +

{item.answerText}

+ + + ) : ( +

๋‹ต๋ณ€์ด ์—†์Šต๋‹ˆ๋‹ค.

+ )} +
+ ))} +
+ )} + + {/* ํ”ผ๋“œ๋ฐฑ ํ™•์ธ ํƒญ */} + {activeTab === 'feedback' && feedbackData?.result && ( +
+ {/* AI ํ”ผ๋“œ๋ฐฑ */} + {feedbackData.result.aiFeedback && ( +
+

๐Ÿค– AI ํ”ผ๋“œ๋ฐฑ

+

{feedbackData.result.aiFeedback}

+
+ )} + + {/* ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ */} + {feedbackData.result.selfFeedback && ( +
+

โœ๏ธ ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ

+

{feedbackData.result.selfFeedback}

+
+ )} + + {/* ๋™๋ฃŒ ํ”ผ๋“œ๋ฐฑ */} + {feedbackData.result.peerItems && feedbackData.result.peerItems.length > 0 && ( +
+

๐Ÿ‘ฅ ๋™๋ฃŒ ํ”ผ๋“œ๋ฐฑ

+
    + {feedbackData.result.peerItems.map((item, index) => ( +
  • + โ€ข + {item} +
  • + ))} +
+
+ )} + + {!feedbackData.result.aiFeedback && !feedbackData.result.selfFeedback && feedbackData.result.peerItems.length === 0 && ( +

ํ”ผ๋“œ๋ฐฑ์ด ์—†์Šต๋‹ˆ๋‹ค.

+ )} +
+ )} + + {/* ๋žœ๋ค ์งˆ๋ฌธ ํƒญ */} + {activeTab === 'random' && randomData?.result && ( +
+ {randomData.result.length > 0 ? ( + randomData.result.map((item, index) => ( +
+
+ + ๋žœ๋ค ์งˆ๋ฌธ {index + 1} + +

{item.question}

+
+ +
+

๋‹ต๋ณ€:

+

{item.answerText}

+
+ +
+ {item.aiFeedback && ( +
+

AI ํ”ผ๋“œ๋ฐฑ

+

{item.aiFeedback}

+
+ )} + {item.selfFeedback && ( +
+

์…€ํ”„ ํ”ผ๋“œ๋ฐฑ

+

{item.selfFeedback}

+
+ )} +
+ + +
+ )) + ) : ( +

๋žœ๋ค ์งˆ๋ฌธ์ด ์—†์Šต๋‹ˆ๋‹ค.

+ )} +
+ )} + + )} +
+
+
); } diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 43f0d0e..68cbb3a 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -32,7 +32,7 @@ export const router = createBrowserRouter([ { path: 'community/write', element: }, { path: 'community/edit/:id', element: }, { path: 'community/detail/:id', element: }, - { path: 'myInterview/:id', element: }, + { path: 'myInterviews/:id', element: }, { path: 'myEvaluate/:id', element: }, { path: 'upload', element: }, // โ† myInterview์—์„œ upload๋กœ ๋ณ€๊ฒฝ { path: 'upload-done', element: }, diff --git a/src/services/randomQuestionApi.ts b/src/services/randomQuestionApi.ts index 5ddff10..587a0b4 100644 --- a/src/services/randomQuestionApi.ts +++ b/src/services/randomQuestionApi.ts @@ -1,91 +1,164 @@ import apiClient from './api'; +// result๋งŒ ๋ฝ‘์•„์ฃผ๋Š” ํ—ฌํผ +const unwrapResult = (data: any): T => { + if (data && typeof data === 'object' && 'result' in data) { + return data.result as T; + } + return data as T; +}; + // ==================== ํƒ€์ž… ์ •์˜ ==================== +// 1) SSE ์•Œ๋ฆผ payload (ํŒ์—… ์•Œ๋ฆผ์šฉ) โ€” ํŒ์—… ์•Œ๋ฆผ์„ ์œ„ํ•œ SSE ๊ตฌ๋…์šฉ API +export interface IRandomNotificationPayload { + jobName: string; // ์ง๋ฌด ์ด๋ฆ„ + interviewName: string; // ์ธํ„ฐ๋ทฐ ์ œ๋ชฉ + questionNumber: number; // ํ•ด๋‹น ์ธํ„ฐ๋ทฐ์˜ ๋ช‡ ๋ฒˆ์งธ ์งˆ๋ฌธ์ธ์ง€ + peerFeedbackId: number; // = peerAnswerId (๋žœ๋ค ์งˆ๋ฌธ ์กฐํšŒ์— ์‚ฌ์šฉ) +} + +// 2) ๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ ์กฐํšŒ ์‘๋‹ต โ€” ๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ ์กฐํšŒ API +export interface IRandomQuestionContext { + questionId: number; + questionText: string; + presignedRecordingGetUrl: string; + sttText: string; +} + export interface IRandomQuestion { - questionId: string; - question: string; - peerAnswerId: string; + question: { + questionId: number; + questionText: string; + }; + context: IRandomQuestionContext; +} + +// 3) presign ์‘๋‹ต โ€” ๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ - ๋…น์Œ ์—…๋กœ๋“œ์šฉ ํ”„๋ฆฌ์‚ฌ์ธ URL ๋ฐœ๊ธ‰ +export interface IPresignUrlResponse { + uploadUrl: string; // S3 PUT presigned URL + key: string; // ์—…๋กœ๋“œ๋  S3 ์˜ค๋ธŒ์ ํŠธ ๊ฒฝ๋กœ + requiredHeaders: Record; // PUT ์‹œ ํ•จ๊ป˜ ๋ณด๋‚ด์•ผ ํ•  ํ—ค๋”๋“ค } +// recordingKey ์š”์ฒญ์šฉ ํƒ€์ž…์€ ๋” ์ด์ƒ ์‚ฌ์šฉ ์•ˆ ํ•˜์ง€๋งŒ, ๋‚จ๊ฒจ๋‘  (ํ˜ธํ™˜์šฉ) export interface IRandomQuestionRecordingRequest { recordingKey: string; } +// 4) ๋…น์Œ ์ €์žฅ ์‘๋‹ต โ€” ๋žœ๋ค ์งˆ๋ฌธ์— ๋Œ€ํ•œ recording ์ €์žฅ ๋ฐ ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ API (๋น„๋™๊ธฐ) export interface IFeedbackRecordingResponse { - recordingId: string; - status: 'processing' | 'completed' | 'failed'; + recordingId: number; + status: 'UPLOADED'; // ๋น„๋™๊ธฐ ์ž‘์—…์ด ํ์— ์˜ฌ๋ผ๊ฐ”๋‹ค๋Š” ๋œป } -export interface IFeedbackResultResponse { - status: 'processing' | 'completed' | 'failed'; - feedback?: string; +// 5) ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ ์‘๋‹ต โ€” ๋žœ๋ค ์งˆ๋ฌธ์— ๋Œ€ํ•œ ํ”ผ๋“œ๋ฐฑ ํ™•์ธ API (polling) +export type FeedbackProgressStatus = 'WORKING' | 'READY' | 'FAILED'; + +export interface IFeedbackResult { + questionId: number; + questionText: string; + aiFeedback: string; + selfFeedback: string; + presignedRecordingGetUrl: string; + sttText: string; } -export interface IPresignUrlResponse { - presignedUrl: string; - fileKey: string; +export interface IFeedbackResultResponse { + progressStatus: FeedbackProgressStatus; + result: IFeedbackResult | null; // WORKING/FAILED์ผ ๋•Œ๋Š” null } // ==================== API ํ•จ์ˆ˜๋“ค ==================== /** * 1. ๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ ์กฐํšŒ + * GET /api/random-questions/peer/{peerAnswerId} + * peerAnswerId = SSE ์•Œ๋ฆผ์˜ peerFeedbackId */ -export const getRandomQuestion = async (peerAnswerId: string): Promise => { +export const getRandomQuestion = async ( + peerAnswerId: number | string, +): Promise => { const response = await apiClient.get(`/api/random-questions/peer/${peerAnswerId}`); - return response.data; + return unwrapResult(response.data); }; /** * 2. ๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ - ๋…น์Œ ์—…๋กœ๋“œ์šฉ ํ”„๋ฆฌ์‚ฌ์ธ URL ๋ฐœ๊ธ‰ + * POST /api/presign/recording/feedback-question + * Body: { questionId: Long, contentType: String } */ -export const getFeedbackRecordingPresignUrl = async (fileName: string): Promise => { - const response = await apiClient.post('/api/presign/recording/feedback-question', { fileName }); - return response.data; +export const getFeedbackRecordingPresignUrl = async ( + questionId: number, + contentType: string, +): Promise => { + const response = await apiClient.post('/api/presign/recording/feedback-question', { + questionId, + contentType, + }); + return unwrapResult(response.data); }; /** - * 3. S3์— ํŒŒ์ผ ์—…๋กœ๋“œ + * 3. S3์— ํŒŒ์ผ ์—…๋กœ๋“œ (ํ”„๋ฆฌ์‚ฌ์ธ URL ์‚ฌ์šฉ) */ -export const uploadToS3 = async (presignedUrl: string, file: Blob): Promise => { +export const uploadToS3 = async ( + presignedUrl: string, + file: Blob, + extraHeaders: Record = {}, +): Promise => { await fetch(presignedUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type || 'audio/webm', + ...extraHeaders, }, }); }; /** * 4. ๋žœ๋ค ์งˆ๋ฌธ์— ๋Œ€ํ•œ recording ์ €์žฅ ๋ฐ ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ (๋น„๋™๊ธฐ) + * POST /api/random-questions/peer/questions/{questionId} + * Body ์—†์Œ, path param์œผ๋กœ questionId๋งŒ ๋„˜๊น€ */ -export const saveFeedbackRecording = async (questionId: string, data: IRandomQuestionRecordingRequest): Promise => { - const response = await apiClient.get(`/api/random-questions/peer/questions/${questionId}`, { - params: data, - }); - return response.data; +export const saveFeedbackRecording = async ( + questionId: number, +): Promise => { + const response = await apiClient.post( + `/api/random-questions/peer/questions/${questionId}`, + ); + return unwrapResult(response.data); }; /** * 5. ๋žœ๋ค ์งˆ๋ฌธ์— ๋Œ€ํ•œ ํ”ผ๋“œ๋ฐฑ ํ™•์ธ (polling) + * GET /api/random-questions/peer/recordings/{recordingId}/feedbacks */ -export const getFeedbackResult = async (recordingId: string): Promise => { - const response = await apiClient.get(`/api/random-questions/peer/recordings/${recordingId}/feedbacks`); - return response.data; +export const getFeedbackResult = async ( + recordingId: number, +): Promise => { + const response = await apiClient.get( + `/api/random-questions/peer/recordings/${recordingId}/feedbacks`, + ); + return unwrapResult(response.data); }; /** * 6. Polling ํ—ฌํผ ํ•จ์ˆ˜ + * progressStatus ๊ฐ€ READY / FAILED ๊ฐ€ ๋  ๋•Œ๊นŒ์ง€ ์กฐํšŒ */ -export const pollFeedbackResult = async (recordingId: string, maxAttempts: number = 60, interval: number = 5000): Promise => { +export const pollFeedbackResult = async ( + recordingId: number, + maxAttempts: number = 60, + interval: number = 5000, +): Promise => { let attempts = 0; while (attempts < maxAttempts) { const result = await getFeedbackResult(recordingId); - if (result.status === 'completed' || result.status === 'failed') { + if (result.progressStatus === 'READY' || result.progressStatus === 'FAILED') { return result; } @@ -98,9 +171,14 @@ export const pollFeedbackResult = async (recordingId: string, maxAttempts: numbe /** * 7. SSE ๊ตฌ๋… (Server-Sent Events) + * GET /api/subscribe */ -export const subscribeToNotifications = (onMessage: (event: MessageEvent) => void, onError?: (error: Event) => void): EventSource => { - const eventSource = new EventSource(`${apiClient.defaults.baseURL}/api/subscribe`); +export const subscribeToNotifications = ( + onMessage: (event: MessageEvent) => void, + onError?: (error: Event) => void, +): EventSource => { + const baseURL = apiClient.defaults.baseURL ?? ''; + const eventSource = new EventSource(`${baseURL}/api/subscribe`); eventSource.onmessage = onMessage; @@ -115,32 +193,41 @@ export const subscribeToNotifications = (onMessage: (event: MessageEvent) => voi /** * ๋žœ๋ค ์งˆ๋ฌธ ๋…น์Œ ์—…๋กœ๋“œ ๋ฐ ํ”ผ๋“œ๋ฐฑ ๋ฐ›๊ธฐ ์ „์ฒด ํ”Œ๋กœ์šฐ + * + * 1) ํ”„๋ฆฌ์‚ฌ์ธ URL ๋ฐœ๊ธ‰ + * 2) S3 ์—…๋กœ๋“œ + * 3) recording ์ €์žฅ (๋น„๋™๊ธฐ ํ์— ์˜ฌ๋ฆฌ๊ธฐ) + * 4) ํ”ผ๋“œ๋ฐฑ READY ๋  ๋•Œ๊นŒ์ง€ polling + * 5) IFeedbackResult ๋ฆฌํ„ด (aiFeedback, selfFeedback ๋“ฑ ํฌํ•จ) */ -export const uploadFeedbackRecordingAndGetResult = async (questionId: string, audioBlob: Blob): Promise => { +export const uploadFeedbackRecordingAndGetResult = async ( + questionId: number, + audioBlob: Blob, +): Promise => { // 1. ํ”„๋ฆฌ์‚ฌ์ธ URL ๋ฐ›๊ธฐ - const fileName = `feedback-${questionId}-${Date.now()}.webm`; - const { presignedUrl, fileKey } = await getFeedbackRecordingPresignUrl(fileName); + const contentType = audioBlob.type || 'audio/webm'; + const { uploadUrl, requiredHeaders } = await getFeedbackRecordingPresignUrl( + questionId, + contentType, + ); // 2. S3์— ์—…๋กœ๋“œ - await uploadToS3(presignedUrl, audioBlob); + await uploadToS3(uploadUrl, audioBlob, requiredHeaders); - // 3. ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ ์‹œ์ž‘ - const { recordingId, status } = await saveFeedbackRecording(questionId, { - recordingKey: fileKey, - }); + // 3. ๋…น์Œ ์ €์žฅ & ๋น„๋™๊ธฐ ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ ํŠธ๋ฆฌ๊ฑฐ + const { recordingId, status } = await saveFeedbackRecording(questionId); - // 4. ์ฆ‰์‹œ ์™„๋ฃŒ๋œ ๊ฒฝ์šฐ - if (status === 'completed') { - const result = await getFeedbackResult(recordingId); - return result.feedback || 'ํ”ผ๋“œ๋ฐฑ์„ ๋ฐ›์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'; + if (status !== 'UPLOADED') { + throw new Error(`์˜ˆ์ƒ์น˜ ๋ชปํ•œ recording ์ƒํƒœ์ž…๋‹ˆ๋‹ค: ${status}`); } - // 5. ์ฒ˜๋ฆฌ ์ค‘์ด๋ฉด polling + // 4. ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ ์ƒํƒœ polling const result = await pollFeedbackResult(recordingId); - if (result.status === 'failed') { + if (result.progressStatus === 'FAILED' || !result.result) { throw new Error('ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); } - return result.feedback || 'ํ”ผ๋“œ๋ฐฑ์„ ๋ฐ›์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'; + // 5. ์ตœ์ข… ํ”ผ๋“œ๋ฐฑ ๊ฒฐ๊ณผ ๋ฆฌํ„ด + return result.result; }; diff --git a/src/types/myPage.ts b/src/types/myPage.ts index ab9d01c..56379cc 100644 --- a/src/types/myPage.ts +++ b/src/types/myPage.ts @@ -96,3 +96,73 @@ export const GROWTH_TAG_LABELS: Record = { export const getExperienceHashTags = (tags?: TExperienceTags[]) => (tags ?? []).map((tag) => `# ${EXPERIENCE_TAG_LABELS[tag]}`); export const getGrowthHashTags = (tags?: TGrowthTags[]) => (tags ?? []).map((tag) => `# ${GROWTH_TAG_LABELS[tag]}`); + +// ๋‚˜์˜ ๋ฉด์ ‘ ์ƒ์„ธ - ์ „์ฒด ์š”์•ฝ API +export type TInterviewSummaryRequest = { + interviewId: number; +}; + +export type TQuestionCard = { + order: number; + questionId: number; +}; + +export type TAnswerCheckItem = { + order: number; + questionId: number; + question: string; + answerText: string | null; + recordUrl: string; +}; + +export type TInterviewSummaryResponse = { + errorCode: null | string; + message: string; + result: { + title: string; + timedOutCount: number; + questionCards: TQuestionCard[]; + firstQuestionThread: TAnswerCheckItem[]; + }; +}; + +// ๋‚˜์˜ ๋ฉด์ ‘ ์ƒ์„ธ - ์งˆ๋ฌธ๋ณ„ ๋‹ต๋ณ€ ํ™•์ธ API +export type TQuestionAnswersRequest = { + questionId: number; +}; + +export type TQuestionAnswersResponse = { + errorCode: null | string; + message: string; + result: TAnswerCheckItem[]; +}; + +// ๋‚˜์˜ ๋ฉด์ ‘ ์ƒ์„ธ - ์งˆ๋ฌธ๋ณ„ ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ API +export type TQuestionFeedbackRequest = { + questionId: number; +}; + +export type TQuestionFeedbackResponse = { + errorCode: null | string; + message: string; + result: { + aiFeedback: string; + selfFeedback: string; + peerItems: string[]; + }; +}; + +// ๋‚˜์˜ ๋ฉด์ ‘ ์ƒ์„ธ - ์งˆ๋ฌธ๋ณ„ ๋žœ๋ค ์งˆ๋ฌธ ๋‹ต๋ณ€ ํ™•์ธ API +export type TRandomQuestionItem = { + question: string; + aiFeedback: string; + selfFeedback: string; + answerText: string; + recordingUrl: string; +}; + +export type TRandomQuestionsResponse = { + errorCode: null | string; + message: string; + result: TRandomQuestionItem[]; +}; From 9930eb8e2d665a0c83593845bd8abca348915978 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 03:03:00 +0900 Subject: [PATCH 06/13] =?UTF-8?q?api=EC=97=B0=EB=8F=99,=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/myInterviews.tsx | 114 ++++++++++++++++++------------------- 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/src/pages/myInterviews.tsx b/src/pages/myInterviews.tsx index bf16c3d..37c401a 100644 --- a/src/pages/myInterviews.tsx +++ b/src/pages/myInterviews.tsx @@ -51,10 +51,10 @@ export default function MyInterviews() { if (summaryLoading) { return ( -
-
- -

๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

+
+
+ +

๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

); @@ -62,8 +62,8 @@ export default function MyInterviews() { if (!summaryData?.result) { return ( -
-

๋ฉด์ ‘ ๋ฐ์ดํ„ฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

+
+

๋ฉด์ ‘ ๋ฐ์ดํ„ฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

); } @@ -72,23 +72,23 @@ export default function MyInterviews() { const isLoading = answersLoading || feedbackLoading || randomLoading; return ( -
-
+
+
{/* ํ—ค๋” */} -
-

{title}

+
+

{title}

{timedOutCount > 0 && ( -

+

์‹œ๊ฐ„ ์ดˆ๊ณผ๋กœ ๋‹ต๋ณ€ํ•˜์ง€ ๋ชปํ•œ ์งˆ๋ฌธ{' '} - {timedOutCount}๊ฐœ + {timedOutCount}๊ฐœ

)}
{/* ์งˆ๋ฌธ ์นด๋“œ ๋ฆฌ์ŠคํŠธ */} -
-

๋ฉด์ ‘ ์งˆ๋ฌธ

-
+
+

๋ฉด์ ‘ ์งˆ๋ฌธ

+
{questionCards.map((card) => ( ))}
{/* ํƒญ ๋ฉ”๋‰ด */} -
-
+
+
{/* ํƒญ ์ฝ˜ํ…์ธ  */} -
+
{isLoading ? ( -
- +
+
) : ( <> {/* ๋‹ต๋ณ€ ํ™•์ธ ํƒญ */} {activeTab === 'answer' && answersData?.result && ( -
+
{answersData.result.map((item) => ( -
-
- +
+
+ ์งˆ๋ฌธ {item.order} -

{item.question}

+

{item.question}

{item.answerText ? ( <> -

{item.answerText}

+

{item.answerText}

) : ( -

๋‹ต๋ณ€์ด ์—†์Šต๋‹ˆ๋‹ค.

+

๋‹ต๋ณ€์ด ์—†์Šต๋‹ˆ๋‹ค.

)}
))} @@ -177,31 +177,31 @@ export default function MyInterviews() { {/* ํ”ผ๋“œ๋ฐฑ ํ™•์ธ ํƒญ */} {activeTab === 'feedback' && feedbackData?.result && ( -
+
{/* AI ํ”ผ๋“œ๋ฐฑ */} {feedbackData.result.aiFeedback && ( -
-

๐Ÿค– AI ํ”ผ๋“œ๋ฐฑ

-

{feedbackData.result.aiFeedback}

+
+

๐Ÿค– AI ํ”ผ๋“œ๋ฐฑ

+

{feedbackData.result.aiFeedback}

)} {/* ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ */} {feedbackData.result.selfFeedback && ( -
-

โœ๏ธ ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ

-

{feedbackData.result.selfFeedback}

+
+

โœ๏ธ ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ

+

{feedbackData.result.selfFeedback}

)} {/* ๋™๋ฃŒ ํ”ผ๋“œ๋ฐฑ */} {feedbackData.result.peerItems && feedbackData.result.peerItems.length > 0 && ( -
-

๐Ÿ‘ฅ ๋™๋ฃŒ ํ”ผ๋“œ๋ฐฑ

-
    +
    +

    ๐Ÿ‘ฅ ๋™๋ฃŒ ํ”ผ๋“œ๋ฐฑ

    +
      {feedbackData.result.peerItems.map((item, index) => ( -
    • - โ€ข +
    • + โ€ข {item}
    • ))} @@ -210,40 +210,40 @@ export default function MyInterviews() { )} {!feedbackData.result.aiFeedback && !feedbackData.result.selfFeedback && feedbackData.result.peerItems.length === 0 && ( -

      ํ”ผ๋“œ๋ฐฑ์ด ์—†์Šต๋‹ˆ๋‹ค.

      +

      ํ”ผ๋“œ๋ฐฑ์ด ์—†์Šต๋‹ˆ๋‹ค.

      )}
    )} {/* ๋žœ๋ค ์งˆ๋ฌธ ํƒญ */} {activeTab === 'random' && randomData?.result && ( -
    +
    {randomData.result.length > 0 ? ( randomData.result.map((item, index) => ( -
    -
    - +
    +
    + ๋žœ๋ค ์งˆ๋ฌธ {index + 1} -

    {item.question}

    +

    {item.question}

    -
    -

    ๋‹ต๋ณ€:

    -

    {item.answerText}

    +
    +

    ๋‹ต๋ณ€:

    +

    {item.answerText}

    -
    +
    {item.aiFeedback && ( -
    -

    AI ํ”ผ๋“œ๋ฐฑ

    -

    {item.aiFeedback}

    +
    +

    AI ํ”ผ๋“œ๋ฐฑ

    +

    {item.aiFeedback}

    )} {item.selfFeedback && ( -
    -

    ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ

    -

    {item.selfFeedback}

    +
    +

    ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ

    +

    {item.selfFeedback}

    )}
    @@ -261,7 +261,7 @@ export default function MyInterviews() {
    )) ) : ( -

    ๋žœ๋ค ์งˆ๋ฌธ์ด ์—†์Šต๋‹ˆ๋‹ค.

    +

    ๋žœ๋ค ์งˆ๋ฌธ์ด ์—†์Šต๋‹ˆ๋‹ค.

    )}
    )} From e2f575e5d36bb00f52d225defd67544b02a3f8db Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 03:17:13 +0900 Subject: [PATCH 07/13] =?UTF-8?q?401=EC=97=90=EB=9F=AC=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 6 ++++++ package.json | 1 + src/services/randomQuestionApi.ts | 5 ++++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 7c0c646..a46262d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-react": "^7.37.5", "eslint-plugin-simple-import-sort": "^12.1.1", + "event-source-polyfill": "^1.0.31", "lucide-react": "^0.545.0", "prettier": "^3.6.2", "react": "^19.0.0", @@ -4061,6 +4062,11 @@ "node": ">= 0.6" } }, + "node_modules/event-source-polyfill": { + "version": "1.0.31", + "resolved": "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.31.tgz", + "integrity": "sha512-4IJSItgS/41IxN5UVAVuAyczwZF7ZIEsM1XAoUzIHA6A+xzusEZUutdXz2Nr+MQPLxfTiCvqE79/C8HT8fKFvA==" + }, "node_modules/eventemitter2": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-5.0.1.tgz", diff --git a/package.json b/package.json index 03f3bac..b3db69e 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-react": "^7.37.5", "eslint-plugin-simple-import-sort": "^12.1.1", + "event-source-polyfill": "^1.0.31", "lucide-react": "^0.545.0", "prettier": "^3.6.2", "react": "^19.0.0", diff --git a/src/services/randomQuestionApi.ts b/src/services/randomQuestionApi.ts index 587a0b4..e4ef732 100644 --- a/src/services/randomQuestionApi.ts +++ b/src/services/randomQuestionApi.ts @@ -1,4 +1,5 @@ import apiClient from './api'; +import { EventSourcePolyfill } from 'event-source-polyfill'; // result๋งŒ ๋ฝ‘์•„์ฃผ๋Š” ํ—ฌํผ const unwrapResult = (data: any): T => { @@ -178,7 +179,9 @@ export const subscribeToNotifications = ( onError?: (error: Event) => void, ): EventSource => { const baseURL = apiClient.defaults.baseURL ?? ''; - const eventSource = new EventSource(`${baseURL}/api/subscribe`); + const eventSource = new EventSourcePolyfill(`${baseURL}/api/subscribe`, { + withCredentials: true, + }) as EventSource; eventSource.onmessage = onMessage; From e78bd41651435efbb41aa004a8cc34a51467acdd Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 03:33:49 +0900 Subject: [PATCH 08/13] =?UTF-8?q?=EB=9E=9C=EB=8D=A4=EC=A7=88=EB=AC=B8=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=ED=91=9C=EC=8B=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/myPage/interviewCard.tsx | 131 ++++++++----------- src/components/myPage/randomQuestionCard.tsx | 45 +++++++ src/pages/myPage.tsx | 11 +- src/types/event-source-polyfill.d.ts | 18 +++ src/types/myPage.ts | 1 + 5 files changed, 130 insertions(+), 76 deletions(-) create mode 100644 src/components/myPage/randomQuestionCard.tsx create mode 100644 src/types/event-source-polyfill.d.ts diff --git a/src/components/myPage/interviewCard.tsx b/src/components/myPage/interviewCard.tsx index 1f66c97..c04a78e 100644 --- a/src/components/myPage/interviewCard.tsx +++ b/src/components/myPage/interviewCard.tsx @@ -1,106 +1,91 @@ import React, { useEffect, useState } from 'react'; import useDeleteInterview from '@/hooks/useDeleteInterview'; -import usePatchInterview from '@/hooks/usePatchInterview'; +import { getInterviewSummary, getRandomQuestions } from '@/apis/myPage'; import Delete from '@/assets/delete.svg?react'; -import Edit from '@/assets/edit.svg?react'; import Frog from '@/assets/frog.svg?react'; -import Check from '@/assets/o.svg?react'; type TCardProps = { id: number; title: string; + createdAt?: string; onClick?: () => void; }; -export default function InterviewCard({ id, title, onClick }: TCardProps) { - const { mutate: updateTitle } = usePatchInterview(); +export default function InterviewCard({ id, title, createdAt, onClick }: TCardProps) { const { mutate: deleteInterview } = useDeleteInterview(); - const [isEditing, setIsEditing] = useState(false); - const [localTitle, setLocalTitle] = useState(title); + const [hasRandomQuestions, setHasRandomQuestions] = useState(false); + // ๋žœ๋ค ์งˆ๋ฌธ ์—ฌ๋ถ€ ํ™•์ธ useEffect(() => { - setLocalTitle(title); - }, [title]); + const checkRandomQuestions = async () => { + try { + // 1. ๋ฉด์ ‘ ์ƒ์„ธ ์ •๋ณด ์กฐํšŒ + const summary = await getInterviewSummary(id); + + if (summary.result.questionCards && summary.result.questionCards.length > 0) { + // 2. ์ฒซ ๋ฒˆ์งธ ์งˆ๋ฌธ์˜ ๋žœ๋ค ์งˆ๋ฌธ ์กฐํšŒ + const firstQuestionId = summary.result.questionCards[0].questionId; + const randomQuestionsData = await getRandomQuestions(firstQuestionId); + + // 3. ๋žœ๋ค ์งˆ๋ฌธ์ด ์žˆ์œผ๋ฉด true + if (randomQuestionsData.result && randomQuestionsData.result.length > 0) { + setHasRandomQuestions(true); + } + } + } catch (error) { + // ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ๋ฌด์‹œ (๋žœ๋ค ์งˆ๋ฌธ ์—†์Œ์œผ๋กœ ์ฒ˜๋ฆฌ) + console.log('๋žœ๋ค ์งˆ๋ฌธ ํ™•์ธ ์ค‘ ์˜ค๋ฅ˜:', error); + } + }; - const handleEditClick = (e: React.MouseEvent) => { - e.stopPropagation(); - setIsEditing(true); - }; - - const confirmEdit = () => { - setIsEditing(false); - if (localTitle === title) { - console.log('์ œ๋ชฉ ์•ˆ ๋ฐ”๋€œ. PATCH ์•ˆ ๋ณด๋ƒ„'); - return; - } - - console.log('PATCH ๋ณด๋ƒ„', { id, localTitle }); - updateTitle({ interviewId: id, title: localTitle }); - }; - - const handleInputKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - confirmEdit(); - } - if (e.key === 'Escape') { - e.preventDefault(); - setLocalTitle(title); - setIsEditing(false); - } - }; + void checkRandomQuestions(); + }, [id]); const handleDeleteClick = (e: React.MouseEvent) => { e.stopPropagation(); deleteInterview({ sessionId: id }); }; + // ๋‚ ์งœ ํฌ๋งทํŒ… + const formatDate = (dateString?: string) => { + if (!dateString) return null; + const date = new Date(dateString); + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + return `${year}๋…„ ${month}์›” ${day}์ผ`; + }; + + const formattedDate = formatDate(createdAt); + return (
    -
    - -
    - {isEditing ? ( -
    - setLocalTitle(e.target.value)} - onBlur={() => setIsEditing(false)} - onKeyDown={handleInputKeyDown} - onClick={(e) => e.stopPropagation()} - /> - -
    - ) : ( - <> -

    {title}

    - - - )} -
    -
    - + {/* ์‚ญ์ œ ๋ฒ„ํŠผ */} + + {/* ๋‚ ์งœ, ๋žœ๋ค ์งˆ๋ฌธ ๋ฑƒ์ง€ */} +
    + {formattedDate && {formattedDate}} + {hasRandomQuestions && ( + ๋žœ๋ค์งˆ๋ฌธ + )} +
    + + {/* ๋ฉ”์ธ ์ฝ˜ํ…์ธ  */} +
    + +
    + {/* ํฌ๋ง์ง๊ตฐ (์ˆ˜์ • ๋ถˆ๊ฐ€) */} +

    {title}

    +
    +
    ); } diff --git a/src/components/myPage/randomQuestionCard.tsx b/src/components/myPage/randomQuestionCard.tsx new file mode 100644 index 0000000..df34f1d --- /dev/null +++ b/src/components/myPage/randomQuestionCard.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import OrangeFrog from '@/assets/orangeFrog.svg?react'; + +type TRandomQuestionCardProps = { + questionText: string; + createdAt: string; + jobRole?: string; + onClick?: () => void; +}; + +export default function RandomQuestionCard({ questionText, createdAt, jobRole, onClick }: TRandomQuestionCardProps) { + // ๋‚ ์งœ ํฌ๋งทํŒ… + const formatDate = (dateString: string) => { + const date = new Date(dateString); + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + return `${year}๋…„ ${month}์›” ${day}์ผ`; + }; + + const formattedDate = formatDate(createdAt); + + return ( +
    + {/* ๋žœ๋ค ์งˆ๋ฌธ ๋ฑƒ์ง€ */} +
    + ๋žœ๋ค์งˆ๋ฌธ +
    + + {/* ๋ฉ”์ธ ์ฝ˜ํ…์ธ  */} +
    + +
    +

    {formattedDate}

    + {jobRole &&

    {jobRole}

    } +

    {questionText}

    +
    +
    +
    + ); +} diff --git a/src/pages/myPage.tsx b/src/pages/myPage.tsx index d10611f..d01f238 100644 --- a/src/pages/myPage.tsx +++ b/src/pages/myPage.tsx @@ -83,15 +83,20 @@ export default function MyPage() {

    {title}

    {description}

    - + window.location.replace(title === '๋‚˜์˜ ๋ฉด์ ‘,' ? route.myInterviews : route.evaluate)} /> {title === '๋‚˜์˜ ๋ฉด์ ‘,' - ? myInterviewData?.result.items.map(({ interviewId, jobRole }) => ( + ? myInterviewData?.result.items.map(({ interviewId, jobRole, createdAt }) => ( - movePage(routes, interviewId)} /> + movePage(routes, interviewId)} + /> )) : myFeedbackData?.result.items.map(({ peerFeedbackId, title: jobTitle }) => ( diff --git a/src/types/event-source-polyfill.d.ts b/src/types/event-source-polyfill.d.ts new file mode 100644 index 0000000..f51b082 --- /dev/null +++ b/src/types/event-source-polyfill.d.ts @@ -0,0 +1,18 @@ +// src/types/event-source-polyfill.d.ts +declare module 'event-source-polyfill' { + // ๊ธฐ๋ณธ EventSource ์˜ต์…˜์— ํ—ค๋” ๋“ฑ ๋ช‡ ๊ฐ€์ง€ ํ™•์žฅ ์˜ต์…˜์„ ์ถ”๊ฐ€ + export interface ExtendedEventSourceInit extends EventSourceInit { + headers?: Record; + heartbeatTimeout?: number; + withCredentials?: boolean; + } + + // ๋ธŒ๋ผ์šฐ์ € EventSource๋ฅผ ์ƒ์†ํ•˜๋Š” Polyfill ํด๋ž˜์Šค + export class EventSourcePolyfill extends EventSource { + constructor(url: string, eventSourceInitDict?: ExtendedEventSourceInit); + } + + // ์›๋ž˜์˜ ๋„ค์ดํ‹ฐ๋ธŒ EventSource๋ฅผ ํ•จ๊ป˜ export + export const NativeEventSource: typeof EventSource; + } + \ No newline at end of file diff --git a/src/types/myPage.ts b/src/types/myPage.ts index 56379cc..b76c986 100644 --- a/src/types/myPage.ts +++ b/src/types/myPage.ts @@ -23,6 +23,7 @@ export type TMyInterviewResponse = { items: { interviewId: number; jobRole: string; + createdAt?: string; // ์ƒ์„ฑ ๋‚ ์งœ }[]; nextCursor: number | null; hasNext: boolean; From aedc147be3907208fb2e8f4e532aeffe7d3fd011 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 03:39:14 +0900 Subject: [PATCH 09/13] =?UTF-8?q?=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/myInterviews.tsx | 145 +++++++++++++++++++++++++++---------- 1 file changed, 108 insertions(+), 37 deletions(-) diff --git a/src/pages/myInterviews.tsx b/src/pages/myInterviews.tsx index 37c401a..cd196d5 100644 --- a/src/pages/myInterviews.tsx +++ b/src/pages/myInterviews.tsx @@ -3,13 +3,23 @@ import { useParams } from 'react-router-dom'; import useGetInterviewSummary from '@/hooks/useGetInterviewSummary'; import useGetQuestionAnswers from '@/hooks/useGetQuestionAnswers'; -import useGetQuestionFeedback from '@/hooks/useGetQuestionFeedback'; import useGetRandomQuestions from '@/hooks/useGetRandomQuestions'; +import { getQuestionFeedback } from '@/apis/myPage'; +import type { TQuestionFeedbackResponse } from '@/types/myPage'; import ClockFrog from '@/assets/clockFrog.svg?react'; type TabType = 'answer' | 'feedback' | 'random'; +type FeedbackItem = { + order: number; + questionId: number; + question: string; + aiFeedback: string; + selfFeedback: string; + peerItems: string[]; +}; + export default function MyInterviews() { const { id } = useParams<{ id: string }>(); const interviewId = id ? parseInt(id) : 0; @@ -17,11 +27,12 @@ export default function MyInterviews() { const [selectedQuestionId, setSelectedQuestionId] = useState(null); const [activeTab, setActiveTab] = useState('answer'); const [playingAudio, setPlayingAudio] = useState(null); + const [feedbackList, setFeedbackList] = useState([]); + const [loadingFeedback, setLoadingFeedback] = useState(false); const audioRef = useRef(null); const { data: summaryData, isLoading: summaryLoading } = useGetInterviewSummary(interviewId); - const { data: answersData, isLoading: answersLoading } = useGetQuestionAnswers(activeTab === 'answer' ? selectedQuestionId : null); - const { data: feedbackData, isLoading: feedbackLoading } = useGetQuestionFeedback(activeTab === 'feedback' ? selectedQuestionId : null); + const { data: answersData, isLoading: answersLoading } = useGetQuestionAnswers(activeTab === 'answer' || activeTab === 'feedback' ? selectedQuestionId : null); const { data: randomData, isLoading: randomLoading } = useGetRandomQuestions(activeTab === 'random' ? selectedQuestionId : null); // ์ฒซ ๋ฒˆ์งธ ์งˆ๋ฌธ ์ž๋™ ์„ ํƒ @@ -31,6 +42,49 @@ export default function MyInterviews() { } }, [summaryData, selectedQuestionId]); + // ํ”ผ๋“œ๋ฐฑ ํƒญ์ผ ๋•Œ ๋ชจ๋“  ์งˆ๋ฌธ์˜ ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ + useEffect(() => { + if (activeTab === 'feedback' && answersData?.result) { + const fetchAllFeedback = async () => { + setLoadingFeedback(true); + try { + const feedbackPromises = answersData.result.map(async (answer) => { + try { + const feedback = await getQuestionFeedback(answer.questionId); + return { + order: answer.order, + questionId: answer.questionId, + question: answer.question, + aiFeedback: feedback.result.aiFeedback, + selfFeedback: feedback.result.selfFeedback, + peerItems: feedback.result.peerItems, + }; + } catch (error) { + console.error(`์งˆ๋ฌธ ${answer.questionId} ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ ์‹คํŒจ:`, error); + return { + order: answer.order, + questionId: answer.questionId, + question: answer.question, + aiFeedback: '', + selfFeedback: '', + peerItems: [], + }; + } + }); + + const allFeedback = await Promise.all(feedbackPromises); + setFeedbackList(allFeedback); + } catch (error) { + console.error('ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜:', error); + } finally { + setLoadingFeedback(false); + } + }; + + void fetchAllFeedback(); + } + }, [activeTab, answersData]); + const handleAudioPlay = (url: string) => { if (playingAudio === url) { audioRef.current?.pause(); @@ -69,7 +123,7 @@ export default function MyInterviews() { } const { title, timedOutCount, questionCards } = summaryData.result; - const isLoading = answersLoading || feedbackLoading || randomLoading; + const isLoading = answersLoading || loadingFeedback || randomLoading; return (
    @@ -176,45 +230,62 @@ export default function MyInterviews() { )} {/* ํ”ผ๋“œ๋ฐฑ ํ™•์ธ ํƒญ */} - {activeTab === 'feedback' && feedbackData?.result && ( -
    - {/* AI ํ”ผ๋“œ๋ฐฑ */} - {feedbackData.result.aiFeedback && ( -
    -

    ๐Ÿค– AI ํ”ผ๋“œ๋ฐฑ

    -

    {feedbackData.result.aiFeedback}

    -
    - )} + {activeTab === 'feedback' && feedbackList.length > 0 && ( +
    + {feedbackList.map((item) => ( +
    +
    + + ์งˆ๋ฌธ {item.order} + +

    {item.question}

    +
    - {/* ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ */} - {feedbackData.result.selfFeedback && ( -
    -

    โœ๏ธ ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ

    -

    {feedbackData.result.selfFeedback}

    -
    - )} +
    + {/* AI ํ”ผ๋“œ๋ฐฑ */} + {item.aiFeedback && ( +
    +

    ๐Ÿค– AI ํ”ผ๋“œ๋ฐฑ

    +

    {item.aiFeedback}

    +
    + )} - {/* ๋™๋ฃŒ ํ”ผ๋“œ๋ฐฑ */} - {feedbackData.result.peerItems && feedbackData.result.peerItems.length > 0 && ( -
    -

    ๐Ÿ‘ฅ ๋™๋ฃŒ ํ”ผ๋“œ๋ฐฑ

    -
      - {feedbackData.result.peerItems.map((item, index) => ( -
    • - โ€ข - {item} -
    • - ))} -
    -
    - )} + {/* ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ */} + {item.selfFeedback && ( +
    +

    โœ๏ธ ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ

    +

    {item.selfFeedback}

    +
    + )} - {!feedbackData.result.aiFeedback && !feedbackData.result.selfFeedback && feedbackData.result.peerItems.length === 0 && ( -

    ํ”ผ๋“œ๋ฐฑ์ด ์—†์Šต๋‹ˆ๋‹ค.

    - )} + {/* ๋™๋ฃŒ ํ”ผ๋“œ๋ฐฑ */} + {item.peerItems && item.peerItems.length > 0 && ( +
    +

    ๐Ÿ‘ฅ ๋™๋ฃŒ ํ”ผ๋“œ๋ฐฑ

    +
      + {item.peerItems.map((peer, index) => ( +
    • + โ€ข + {peer} +
    • + ))} +
    +
    + )} + + {!item.aiFeedback && !item.selfFeedback && item.peerItems.length === 0 && ( +

    ์ด ์งˆ๋ฌธ์— ๋Œ€ํ•œ ํ”ผ๋“œ๋ฐฑ์ด ์—†์Šต๋‹ˆ๋‹ค.

    + )} +
    +
    + ))}
    )} + {activeTab === 'feedback' && feedbackList.length === 0 && !loadingFeedback && ( +

    ํ”ผ๋“œ๋ฐฑ์ด ์—†์Šต๋‹ˆ๋‹ค.

    + )} + {/* ๋žœ๋ค ์งˆ๋ฌธ ํƒญ */} {activeTab === 'random' && randomData?.result && (
    From 1169ad83f0196241b5fdd9c0afd2333f54f8fbfd Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 03:42:37 +0900 Subject: [PATCH 10/13] =?UTF-8?q?=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/myInterviews.tsx | 273 +++++++++++++++++++------------------ 1 file changed, 143 insertions(+), 130 deletions(-) diff --git a/src/pages/myInterviews.tsx b/src/pages/myInterviews.tsx index cd196d5..bf75fd5 100644 --- a/src/pages/myInterviews.tsx +++ b/src/pages/myInterviews.tsx @@ -1,3 +1,4 @@ +// src/pages/myInterviews.tsx import { useEffect, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; @@ -5,7 +6,7 @@ import useGetInterviewSummary from '@/hooks/useGetInterviewSummary'; import useGetQuestionAnswers from '@/hooks/useGetQuestionAnswers'; import useGetRandomQuestions from '@/hooks/useGetRandomQuestions'; import { getQuestionFeedback } from '@/apis/myPage'; -import type { TQuestionFeedbackResponse } from '@/types/myPage'; +// import type { TQuestionFeedbackResponse } from '@/types/myPage'; // ๋ฏธ์‚ฌ์šฉ์ด๋ฉด ์ฃผ์„ import ClockFrog from '@/assets/clockFrog.svg?react'; @@ -22,7 +23,7 @@ type FeedbackItem = { export default function MyInterviews() { const { id } = useParams<{ id: string }>(); - const interviewId = id ? parseInt(id) : 0; + const interviewId = Number(id) || 0; const [selectedQuestionId, setSelectedQuestionId] = useState(null); const [activeTab, setActiveTab] = useState('answer'); @@ -32,77 +33,98 @@ export default function MyInterviews() { const audioRef = useRef(null); const { data: summaryData, isLoading: summaryLoading } = useGetInterviewSummary(interviewId); - const { data: answersData, isLoading: answersLoading } = useGetQuestionAnswers(activeTab === 'answer' || activeTab === 'feedback' ? selectedQuestionId : null); + + // ํ˜„์žฌ ํƒญ์— ๋”ฐ๋ผ ์งˆ๋ฌธ/๋žœ๋ค์งˆ๋ฌธ ๋ฐ์ดํ„ฐ + const { data: answersData, isLoading: answersLoading } = useGetQuestionAnswers( + activeTab === 'answer' || activeTab === 'feedback' ? selectedQuestionId : null, + ); const { data: randomData, isLoading: randomLoading } = useGetRandomQuestions(activeTab === 'random' ? selectedQuestionId : null); - // ์ฒซ ๋ฒˆ์งธ ์งˆ๋ฌธ ์ž๋™ ์„ ํƒ + // ์ฒซ ๋ฒˆ์งธ ์งˆ๋ฌธ ์ž๋™ ์„ ํƒ (๋„ ๊ฐ€๋“œ + ๊ธฐ๋ณธ๊ฐ’) useEffect(() => { - if (summaryData?.result.questionCards && summaryData.result.questionCards.length > 0 && !selectedQuestionId) { - setSelectedQuestionId(summaryData.result.questionCards[0].questionId); + const questionCards = summaryData?.result?.questionCards ?? []; + if (questionCards.length > 0 && !selectedQuestionId) { + setSelectedQuestionId(questionCards[0].questionId); } }, [summaryData, selectedQuestionId]); // ํ”ผ๋“œ๋ฐฑ ํƒญ์ผ ๋•Œ ๋ชจ๋“  ์งˆ๋ฌธ์˜ ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ useEffect(() => { - if (activeTab === 'feedback' && answersData?.result) { - const fetchAllFeedback = async () => { - setLoadingFeedback(true); - try { - const feedbackPromises = answersData.result.map(async (answer) => { - try { - const feedback = await getQuestionFeedback(answer.questionId); - return { - order: answer.order, - questionId: answer.questionId, - question: answer.question, - aiFeedback: feedback.result.aiFeedback, - selfFeedback: feedback.result.selfFeedback, - peerItems: feedback.result.peerItems, - }; - } catch (error) { - console.error(`์งˆ๋ฌธ ${answer.questionId} ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ ์‹คํŒจ:`, error); - return { - order: answer.order, - questionId: answer.questionId, - question: answer.question, - aiFeedback: '', - selfFeedback: '', - peerItems: [], - }; - } - }); - - const allFeedback = await Promise.all(feedbackPromises); - setFeedbackList(allFeedback); - } catch (error) { - console.error('ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜:', error); - } finally { - setLoadingFeedback(false); - } - }; - - void fetchAllFeedback(); + if (activeTab !== 'feedback') return; + const answers = answersData?.result ?? []; + if (answers.length === 0) { + setFeedbackList([]); + return; } + + const fetchAllFeedback = async () => { + setLoadingFeedback(true); + try { + const feedbackPromises = answers.map(async (answer) => { + try { + const feedback = await getQuestionFeedback(answer.questionId); + return { + order: answer.order, + questionId: answer.questionId, + question: answer.question, + aiFeedback: feedback.result?.aiFeedback ?? '', + selfFeedback: feedback.result?.selfFeedback ?? '', + peerItems: feedback.result?.peerItems ?? [], + } as FeedbackItem; + } catch (error) { + console.error(`์งˆ๋ฌธ ${answer.questionId} ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ ์‹คํŒจ:`, error); + return { + order: answer.order, + questionId: answer.questionId, + question: answer.question, + aiFeedback: '', + selfFeedback: '', + peerItems: [], + } as FeedbackItem; + } + }); + + const allFeedback = await Promise.all(feedbackPromises); + setFeedbackList(allFeedback); + } catch (error) { + console.error('ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜:', error); + setFeedbackList([]); + } finally { + setLoadingFeedback(false); + } + }; + + void fetchAllFeedback(); }, [activeTab, answersData]); + // ์˜ค๋””์˜ค ์žฌ์ƒ/์ •์ง€ const handleAudioPlay = (url: string) => { if (playingAudio === url) { audioRef.current?.pause(); setPlayingAudio(null); - } else { + return; + } + if (audioRef.current) { + audioRef.current.pause(); + } + const audio = new Audio(url); + audioRef.current = audio; + audio.play().catch(() => setPlayingAudio(null)); + setPlayingAudio(url); + audio.onended = () => setPlayingAudio(null); + }; + + // ์ •๋ฆฌ: ์–ธ๋งˆ์šดํŠธ ์‹œ ์˜ค๋””์˜ค ์ •์ง€ + useEffect(() => { + return () => { if (audioRef.current) { audioRef.current.pause(); + audioRef.current = null; } - audioRef.current = new Audio(url); - audioRef.current.play(); - setPlayingAudio(url); - - audioRef.current.onended = () => { - setPlayingAudio(null); - }; - } - }; + }; + }, []); + // ๋กœ๋”ฉ/์—๋Ÿฌ ์ฒ˜๋ฆฌ if (summaryLoading) { return (
    @@ -122,7 +144,10 @@ export default function MyInterviews() { ); } - const { title, timedOutCount, questionCards } = summaryData.result; + const title = summaryData.result.title ?? '๋ฉด์ ‘'; + const timedOutCount = summaryData.result.timedOutCount ?? 0; + const questionCards = summaryData.result.questionCards ?? []; // โ† ํ•ต์‹ฌ ๊ฐ€๋“œ + const isLoading = answersLoading || loadingFeedback || randomLoading; return ( @@ -142,21 +167,24 @@ export default function MyInterviews() { {/* ์งˆ๋ฌธ ์นด๋“œ ๋ฆฌ์ŠคํŠธ */}

    ๋ฉด์ ‘ ์งˆ๋ฌธ

    -
    - {questionCards.map((card) => ( - - ))} -
    + + {questionCards.length === 0 ? ( +

    ์งˆ๋ฌธ ์นด๋“œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

    + ) : ( +
    + {questionCards.map((card: any) => ( + + ))} +
    + )}
    {/* ํƒญ ๋ฉ”๋‰ด */} @@ -197,14 +225,12 @@ export default function MyInterviews() { ) : ( <> {/* ๋‹ต๋ณ€ ํ™•์ธ ํƒญ */} - {activeTab === 'answer' && answersData?.result && ( + {activeTab === 'answer' && (answersData?.result ?? []).length > 0 && (
    - {answersData.result.map((item) => ( + {(answersData?.result ?? []).map((item: any) => (
    - - ์งˆ๋ฌธ {item.order} - + ์งˆ๋ฌธ {item.order}

    {item.question}

    {item.answerText ? ( @@ -213,9 +239,7 @@ export default function MyInterviews() { + {(randomData?.result ?? []).map((item: any, index: number) => ( +
    +
    + ๋žœ๋ค ์งˆ๋ฌธ {index + 1} +

    {item.question}

    +
    + +
    +

    ๋‹ต๋ณ€:

    +

    {item.answerText}

    - )) - ) : ( -

    ๋žœ๋ค ์งˆ๋ฌธ์ด ์—†์Šต๋‹ˆ๋‹ค.

    - )} + +
    + {item.aiFeedback && ( +
    +

    AI ํ”ผ๋“œ๋ฐฑ

    +

    {item.aiFeedback}

    +
    + )} + {item.selfFeedback && ( +
    +

    ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ

    +

    {item.selfFeedback}

    +
    + )} +
    + + +
    + ))}
    )} + + {activeTab === 'random' && !(randomData?.result ?? []).length &&

    ๋žœ๋ค ์งˆ๋ฌธ์ด ์—†์Šต๋‹ˆ๋‹ค.

    } )}
    From 036a41e5c9858e6cdef8ffc55d7fdef50d605b1c Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 03:47:14 +0900 Subject: [PATCH 11/13] =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=BA=90=EB=A6=AD=ED=84=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Interview/upload.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/Interview/upload.tsx b/src/pages/Interview/upload.tsx index 6f9baa5..ad8fefb 100644 --- a/src/pages/Interview/upload.tsx +++ b/src/pages/Interview/upload.tsx @@ -4,6 +4,7 @@ import { Upload } from 'lucide-react'; import InterviewLayout from '@/layouts/InterviewLayout'; import { uploadResume } from '@/services/interviewApi'; +import orangeFrog from '@/assets/orangeFrog.svg'; const ALLOWED_EXTENSIONS = ['.pdf', '.docx']; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB @@ -148,7 +149,7 @@ export default function MyInterview() {
    - ๋ฆฌ๋ทฐ์บ๋ฆญํ„ฐ + ๋ฆฌ๋ทฐ์บ๋ฆญํ„ฐ