From 753461913ef9ed1061f1b56399a2043a263a4329 Mon Sep 17 00:00:00 2001 From: gituser Date: Sun, 30 Aug 2026 21:46:38 +0900 Subject: [PATCH 1/3] feat(interview): align multi interview session preparation --- .../interview-page/interview/api/index.ts | 9 +- .../api/prepare-interview-record/index.ts | 83 +++++++++++++ .../interview/api/prepare-interview/index.ts | 9 ++ .../interview-page/interview/api/shared.ts | 7 +- .../interview/api/type/current-question.ts | 1 + .../interview/api/type/index.ts | 2 + .../interview/api/type/prepare-interview.ts | 11 ++ .../interview/api/type/submit-answer.ts | 1 + .../interview/model/useInterviewSession.ts | 117 +++++++++++------- .../model/useInterviewSetup.ts | 50 ++------ .../model/useMultiInterviewSetup.ts | 29 ++--- 11 files changed, 207 insertions(+), 112 deletions(-) diff --git a/src/features/interview-page/interview/api/index.ts b/src/features/interview-page/interview/api/index.ts index 20c5bd7..7f466be 100644 --- a/src/features/interview-page/interview/api/index.ts +++ b/src/features/interview-page/interview/api/index.ts @@ -12,7 +12,14 @@ export { parseInterviewSocketMessage, } from "./interview-socket"; export { prepareInterview } from "./prepare-interview"; -export { prepareInterviewRecord } from "./prepare-interview-record"; +export { + getInterviewPreparation, + prepareInterviewRecord, +} from "./prepare-interview-record"; +export type { + InterviewPreparationData, + InterviewPreparationStatus, +} from "./prepare-interview-record"; export { quitInterview } from "./quit-interview"; export { savePersona } from "./save-persona"; export { submitInterviewAnswer } from "./submit-answer"; diff --git a/src/features/interview-page/interview/api/prepare-interview-record/index.ts b/src/features/interview-page/interview/api/prepare-interview-record/index.ts index 3437d55..6549184 100644 --- a/src/features/interview-page/interview/api/prepare-interview-record/index.ts +++ b/src/features/interview-page/interview/api/prepare-interview-record/index.ts @@ -2,13 +2,33 @@ import { apiInstance } from "@/shared/api/axiosInstance"; import { getErrorMessage, + getInterviewQuestion, getNumericValue, getRecord, + getTrimmedString, } from "../shared"; +import type { CurrentInterviewQuestion } from "../type"; const PREPARE_INTERVIEW_RECORD_URL = "/api/interviews"; const PREPARE_INTERVIEW_RECORD_BODY = 0; +const PREPARATION_STATUS_VALUES = [ + "NOT_REQUESTED", + "PENDING", + "SUCCEEDED", + "FAILED", +] as const; + +export type InterviewPreparationStatus = + (typeof PREPARATION_STATUS_VALUES)[number]; + +export interface InterviewPreparationData { + chatDelivered: boolean; + errorMessage: string | null; + interviewId: number; + questions: CurrentInterviewQuestion[]; + status: InterviewPreparationStatus; +} const getInterviewId = (payload: unknown) => { const responseRecord = getRecord(payload); @@ -81,3 +101,66 @@ export const prepareInterviewRecord = async (interviewId: number) => { }; } }; + +const isInterviewPreparationStatus = ( + value: string | null, +): value is InterviewPreparationStatus => + value !== null && + PREPARATION_STATUS_VALUES.includes(value as InterviewPreparationStatus); + +export const getInterviewPreparation = async (interviewId: number) => { + if (!Number.isInteger(interviewId) || interviewId <= 0) { + return { + data: null, + errorMessage: "유효한 인터뷰 ID가 없습니다.", + }; + } + + try { + const response = await apiInstance.get("/api/questions/tailor", { + params: { interviewId }, + }); + const responseRecord = getRecord(response.data); + const sourceRecord = getRecord(responseRecord?.data) ?? responseRecord; + const status = getTrimmedString(sourceRecord?.status)?.toUpperCase() ?? null; + + if (!isInterviewPreparationStatus(status)) { + return { + data: null, + errorMessage: "면접 준비 상태를 확인하지 못했습니다.", + }; + } + + const questions = Array.isArray(sourceRecord?.questions) + ? sourceRecord.questions.reduce( + (items, question) => { + const normalizedQuestion = getInterviewQuestion(question); + + if (normalizedQuestion) { + items.push(normalizedQuestion); + } + + return items; + }, + [], + ) + : []; + + return { + data: { + chatDelivered: sourceRecord?.chatDelivered === true, + errorMessage: getTrimmedString(sourceRecord?.errorMessage), + interviewId: + getNumericValue(sourceRecord?.interviewId) ?? interviewId, + questions, + status, + } satisfies InterviewPreparationData, + errorMessage: null, + }; + } catch (error) { + return { + data: null, + errorMessage: getErrorMessage(error, "면접 준비 상태를 확인하지 못했습니다."), + }; + } +}; diff --git a/src/features/interview-page/interview/api/prepare-interview/index.ts b/src/features/interview-page/interview/api/prepare-interview/index.ts index 4484151..3d34ad1 100644 --- a/src/features/interview-page/interview/api/prepare-interview/index.ts +++ b/src/features/interview-page/interview/api/prepare-interview/index.ts @@ -220,6 +220,7 @@ const buildPrepareInterviewRequest = ( status: "IN_PROGRESS", currentQuestionIndex: 0, questions: normalizedQuestions, + mode: "SOLO", }; }; @@ -286,6 +287,14 @@ const normalizePreparedInterview = ( : fallbackData.status, currentQuestionIndex, questions: normalizedQuestions, + mode: "SOLO", + interviewers: [ + { + personaId: getNumericValue(sourceRecord?.personaId) ?? fallbackData.personaId, + name: getTrimmedString(sourceRecord?.personaName) ?? fallbackData.personaName, + roleLabel: "기술 면접관", + }, + ], }; }; diff --git a/src/features/interview-page/interview/api/shared.ts b/src/features/interview-page/interview/api/shared.ts index 83fcdbb..34a8836 100644 --- a/src/features/interview-page/interview/api/shared.ts +++ b/src/features/interview-page/interview/api/shared.ts @@ -86,10 +86,11 @@ export const getInterviewQuestion = ( questionId, parentId: getNumericValue(questionRecord?.parentId) ?? 0, type: getTrimmedString(questionRecord?.type) ?? "ORIGINAL", - intention: - getTrimmedString(questionRecord?.intention ?? questionRecord?.purpose) ?? - "", + intention: getTrimmedString( + questionRecord?.intention ?? questionRecord?.purpose ?? questionRecord?.category, + ) ?? "", content, + personaId: getNumericValue(questionRecord?.personaId) ?? undefined, }; }; diff --git a/src/features/interview-page/interview/api/type/current-question.ts b/src/features/interview-page/interview/api/type/current-question.ts index 46d410d..8921b99 100644 --- a/src/features/interview-page/interview/api/type/current-question.ts +++ b/src/features/interview-page/interview/api/type/current-question.ts @@ -4,4 +4,5 @@ export interface CurrentInterviewQuestion { type: string; intention: string; content: string; + personaId?: number; } diff --git a/src/features/interview-page/interview/api/type/index.ts b/src/features/interview-page/interview/api/type/index.ts index 332b6b9..bac0e0b 100644 --- a/src/features/interview-page/interview/api/type/index.ts +++ b/src/features/interview-page/interview/api/type/index.ts @@ -15,6 +15,8 @@ export type { CurrentInterviewQuestion } from "./current-question"; export type { InterviewLevel, InterviewProgressStatus, + PreparedInterviewer, + PreparedInterviewMode, PersonaType, PrepareInterviewParams, PrepareInterviewQuestion, diff --git a/src/features/interview-page/interview/api/type/prepare-interview.ts b/src/features/interview-page/interview/api/type/prepare-interview.ts index 4647c0b..88bfc82 100644 --- a/src/features/interview-page/interview/api/type/prepare-interview.ts +++ b/src/features/interview-page/interview/api/type/prepare-interview.ts @@ -13,6 +13,15 @@ export type InterviewProgressStatus = | "READY" | "ABANDONED"; +export type PreparedInterviewMode = "SOLO" | "MULTI"; + +export interface PreparedInterviewer { + personaId: number; + name: string; + roleLabel: string; + image?: string; +} + export interface PrepareInterviewQuestion { questionId: number; intention: string; @@ -56,4 +65,6 @@ export interface PreparedInterviewData { status: InterviewProgressStatus; currentQuestionIndex: number; questions: PrepareInterviewQuestion[]; + mode: PreparedInterviewMode; + interviewers?: PreparedInterviewer[]; } diff --git a/src/features/interview-page/interview/api/type/submit-answer.ts b/src/features/interview-page/interview/api/type/submit-answer.ts index a69f8ca..02b2f9c 100644 --- a/src/features/interview-page/interview/api/type/submit-answer.ts +++ b/src/features/interview-page/interview/api/type/submit-answer.ts @@ -11,6 +11,7 @@ export interface SubmitInterviewAnswerQuestion { type: string; intention: string; content: string; + personaId?: number; } export interface SubmitInterviewAnswerResponse { diff --git a/src/features/interview-page/interview/model/useInterviewSession.ts b/src/features/interview-page/interview/model/useInterviewSession.ts index ba30bf9..d110c20 100644 --- a/src/features/interview-page/interview/model/useInterviewSession.ts +++ b/src/features/interview-page/interview/model/useInterviewSession.ts @@ -6,7 +6,7 @@ import { disconnectInterviewSocket, getActiveInterviewSessionId, getCurrentInterviewQuestion, - prepareInterview, + getInterviewPreparation, quitInterview, setActiveInterviewSessionId, submitInterviewAnswer, @@ -26,6 +26,8 @@ import { useVoiceAnswer } from "./useVoiceAnswer"; type InterviewCloseReason = "completed" | "quit"; const INTERVIEW_COMPLETED_PATH = "/main/interview/completed"; +const INTERVIEW_PREPARATION_POLL_INTERVAL_MS = 1_000; +const INTERVIEW_PREPARATION_MAX_ATTEMPTS = 120; const buildQuestionKey = (question: CurrentInterviewQuestion | null) => { if (!question) { @@ -62,6 +64,7 @@ const buildInitialQuestion = ( type: "ORIGINAL", intention: nextQuestion.intention, content: nextQuestion.content, + personaId: nextQuestion.personaId, }; }; @@ -70,11 +73,13 @@ interface InterviewSessionState { displayQuestionNumber: number; interviewStatus: InterviewProgressStatus; isChatSessionReady: boolean; + totalQuestionCount: number; } type InterviewSessionAction = | { type: "RESET_SESSION"; preparedInterview?: PreparedInterviewData | null } | { type: "APPLY_QUESTION"; question: CurrentInterviewQuestion } + | { type: "SET_QUESTION_COUNT"; count: number } | { type: "SET_INTERVIEW_STATUS"; status: InterviewProgressStatus } | { type: "SET_CHAT_SESSION_READY" }; @@ -88,6 +93,7 @@ const buildInterviewSessionState = ( displayQuestionNumber: initialQuestion ? 1 : 0, interviewStatus: preparedInterview?.status ?? "IN_PROGRESS", isChatSessionReady: !preparedInterview, + totalQuestionCount: preparedInterview?.questions.length ?? 0, }; }; @@ -134,6 +140,13 @@ const interviewSessionReducer = ( return { ...state, interviewStatus: action.status }; } + case "SET_QUESTION_COUNT": { + const count = Math.max(0, action.count); + + return state.totalQuestionCount === count + ? state + : { ...state, totalQuestionCount: count }; + } case "SET_CHAT_SESSION_READY": { if (state.isChatSessionReady) { return state; @@ -158,6 +171,7 @@ export const useInterviewSession = ( displayQuestionNumber, interviewStatus, isChatSessionReady, + totalQuestionCount, } = session; const [mode, setMode] = useState("voice"); const [isSubmitting, setIsSubmitting] = useState(false); @@ -169,10 +183,11 @@ export const useInterviewSession = ( const sessionId = preparedInterview?.sessionId ?? getActiveInterviewSessionId(); const isSessionClosedRef = useRef(false); const hasPreparedChatSessionRef = useRef(false); - const preparedChatSessionIdRef = useRef(null); + const preparingSessionIdRef = useRef(null); const sessionIdRef = useRef(sessionId); const displayQuestionNumberRef = useRef(displayQuestionNumber); const autoPlayedQuestionKeyRef = useRef(null); + const questionStartedAtRef = useRef(0); const canSubmitAnswer = isChatSessionReady && interviewStatus === "IN_PROGRESS" && @@ -194,7 +209,7 @@ export const useInterviewSession = ( useEffect(() => { dispatch({ type: "RESET_SESSION", preparedInterview }); isSessionClosedRef.current = false; - hasPreparedChatSessionRef.current = !preparedInterview; + hasPreparedChatSessionRef.current = false; }, [preparedInterview]); useEffect(() => { @@ -214,6 +229,7 @@ export const useInterviewSession = ( const applyCurrentQuestion = useCallback( (nextQuestion: CurrentInterviewQuestion) => { + questionStartedAtRef.current = Date.now(); dispatch({ type: "APPLY_QUESTION", question: nextQuestion }); }, [dispatch], @@ -305,77 +321,76 @@ export const useInterviewSession = ( return; } - if (preparedChatSessionIdRef.current === sessionId) { + if (preparingSessionIdRef.current === sessionId) { return; } - preparedChatSessionIdRef.current = sessionId; + preparingSessionIdRef.current = sessionId; let isCancelled = false; + let attemptCount = 0; + let timeoutId: number | null = null; - const startInterviewSession = async () => { - const { data, errorMessage } = await prepareInterview({ - sessionId, - interviewId: preparedInterview.interviewId, - userId: preparedInterview.userId, - personaId: preparedInterview.personaId, - personaName: preparedInterview.personaName, - role: preparedInterview.role, - major: preparedInterview.major, - type: preparedInterview.type, - personaType: preparedInterview.personaType, - level: preparedInterview.level, - career: preparedInterview.career, - gender: preparedInterview.gender, - jobId: preparedInterview.jobId, - questions: preparedInterview.questions, - }); + const waitForChatSession = async () => { + attemptCount += 1; + const { data, errorMessage } = await getInterviewPreparation( + preparedInterview.interviewId, + ); if (isCancelled) { return; } if (errorMessage || !data) { - preparedChatSessionIdRef.current = null; - setPreparationError(errorMessage ?? "면접 준비에 실패했습니다."); + preparingSessionIdRef.current = null; + setPreparationError(errorMessage ?? "면접 준비 상태를 확인하지 못했습니다."); return; } - setActiveInterviewSessionId(data.sessionId); - sessionIdRef.current = data.sessionId; - hasPreparedChatSessionRef.current = true; - - if (data.status) { - dispatch({ type: "SET_INTERVIEW_STATUS", status: data.status }); + if (data.status === "FAILED") { + preparingSessionIdRef.current = null; + setPreparationError(data.errorMessage ?? "면접 질문 준비에 실패했습니다."); + return; } - const firstQuestion = buildInitialQuestion(data); - - if (firstQuestion) { - applyCurrentQuestion(firstQuestion); - } + if (data.chatDelivered) { + setActiveInterviewSessionId(sessionId); + sessionIdRef.current = sessionId; + hasPreparedChatSessionRef.current = true; - if (!firstQuestion) { - const { data: nextQuestion } = await getCurrentInterviewQuestion(data.sessionId); + const firstQuestion = data.questions[0]; - if (isCancelled) { - return; + if (firstQuestion) { + applyCurrentQuestion(firstQuestion); } - if (nextQuestion) { - applyCurrentQuestion(nextQuestion); - } + dispatch({ type: "SET_QUESTION_COUNT", count: data.questions.length }); + + dispatch({ type: "SET_CHAT_SESSION_READY" }); + return; } - dispatch({ type: "SET_CHAT_SESSION_READY" }); + if (attemptCount >= INTERVIEW_PREPARATION_MAX_ATTEMPTS) { + preparingSessionIdRef.current = null; + setPreparationError("면접 준비 시간이 초과되었습니다. 잠시 후 다시 시도해주세요."); + return; + } + + timeoutId = window.setTimeout(() => { + void waitForChatSession(); + }, INTERVIEW_PREPARATION_POLL_INTERVAL_MS); }; - void startInterviewSession(); + void waitForChatSession(); return () => { isCancelled = true; - if (preparedChatSessionIdRef.current === sessionId) { - preparedChatSessionIdRef.current = null; + if (timeoutId !== null) { + window.clearTimeout(timeoutId); + } + + if (preparingSessionIdRef.current === sessionId) { + preparingSessionIdRef.current = null; } }; }, [applyCurrentQuestion, preparedInterview, sessionId]); @@ -456,7 +471,13 @@ export const useInterviewSession = ( const { data, errorMessage } = await submitInterviewAnswer({ sessionId: activeSessionId, questionId: currentQuestion.questionId, - responseTime: 0, + responseTime: + questionStartedAtRef.current > 0 + ? Math.max( + 0, + Math.round((Date.now() - questionStartedAtRef.current) / 1_000), + ) + : 0, content: trimmedContent, }); @@ -512,6 +533,7 @@ export const useInterviewSession = ( cameraState, currentQuestion, displayQuestionNumber, + isSubmitting, isInterviewReady: isChatSessionReady, isPreparingInterview: Boolean(preparedInterview) && !isChatSessionReady, isVoiceStarted: voiceAnswer.isVoiceStarted, @@ -528,5 +550,6 @@ export const useInterviewSession = ( onToggleQuestionAudio: questionTts.onToggle, videoRef, voiceLevel: voiceAnswer.voiceLevel, + totalQuestionCount, }; }; diff --git a/src/features/interview-page/setting-interview/model/useInterviewSetup.ts b/src/features/interview-page/setting-interview/model/useInterviewSetup.ts index 7d6eedd..ef1e132 100644 --- a/src/features/interview-page/setting-interview/model/useInterviewSetup.ts +++ b/src/features/interview-page/setting-interview/model/useInterviewSetup.ts @@ -1,10 +1,6 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { - getTailoredQuestions, - normalizeTailoredQuestions, -} from "@/features/feedback-page/api/tailorQuestions"; import { createInterview, prepareInterviewRecord, @@ -16,10 +12,8 @@ import { type InterviewPersonaMajor, type InterviewPersonaRole, type PersonaType, - type PrepareInterviewQuestion, type SavePersonaParams, } from "@/features/interview-page/interview/api"; -import { extractErrorMessage } from "@/shared/api/errorMessage"; import { INTERVIEW_SETTING_DEFAULT_SELECTION, INTERVIEW_SETTING_INTERVIEWERS, @@ -175,43 +169,11 @@ export const useInterviewSetup = () => { data.personaId !== null && data.personaId > 0 ? data.personaId : savedPersona.personaId; - let tailoredQuestions: PrepareInterviewQuestion[] = []; - - try { - const tailorResponse = await getTailoredQuestions( - preparedInterviewRecord.interviewId, - ); - const normalizedTailoredQuestions = normalizeTailoredQuestions( - tailorResponse, - personaId, - ); - - if (normalizedTailoredQuestions.length > 0) { - tailoredQuestions = normalizedTailoredQuestions; - } - - console.log("[questions/tailor] questions saved", { - interviewId: preparedInterviewRecord.interviewId, - questionCount: tailoredQuestions.length, - questions: tailoredQuestions, - }); - } catch (error) { - setIsSubmitting(false); - setErrorMessage( - extractErrorMessage(error, "맞춤 질문을 불러오지 못했습니다."), - ); - return; - } - - console.log("[questions/tailor] completed before SSE", { - interviewId: preparedInterviewRecord.interviewId, - }); - setActiveInterviewSessionId(data.sessionId); setIsSubmitting(false); - navigate("/main/interview/1", { + navigate(`/main/interview/${data.interviewId}`, { state: { preparedInterview: { sessionId: data.sessionId, @@ -229,7 +191,15 @@ export const useInterviewSetup = () => { jobId, status: data.status === "COMPLETED" ? "COMPLETED" : "IN_PROGRESS", currentQuestionIndex: data.currentQuestionIndex, - questions: tailoredQuestions, + questions: [], + mode: "SOLO", + interviewers: [ + { + personaId, + name: selectedInterviewer.personaName, + roleLabel: "기술 면접관", + }, + ], }, interviewSetting: { style: selectedStyle, diff --git a/src/features/interview-page/setting-multi-interview/model/useMultiInterviewSetup.ts b/src/features/interview-page/setting-multi-interview/model/useMultiInterviewSetup.ts index e374483..d895a5a 100644 --- a/src/features/interview-page/setting-multi-interview/model/useMultiInterviewSetup.ts +++ b/src/features/interview-page/setting-multi-interview/model/useMultiInterviewSetup.ts @@ -1,16 +1,11 @@ import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { - getTailoredQuestions, - normalizeTailoredQuestions, -} from "@/features/feedback-page/api/tailorQuestions"; import { createInterview, prepareInterviewRecord, savePersona, setActiveInterviewSessionId, - type PrepareInterviewQuestion, type SavePersonaParams, } from "@/features/interview-page/interview/api"; import { extractErrorMessage } from "@/shared/api/errorMessage"; @@ -240,21 +235,6 @@ export const useMultiInterviewSetup = () => { throw new Error(prepareError ?? "질문 준비에 실패했습니다."); } - const tailorResponse = await getTailoredQuestions( - preparedInterviewRecord.interviewId, - ); - const tailoredQuestions: PrepareInterviewQuestion[] = normalizeTailoredQuestions( - tailorResponse, - savedPersonaIds[0], - ); - - if (tailoredQuestions.length === 0) { - throw new Error("질문 준비에 실패했습니다."); - } - - // 채팅 준비 API는 personaId 하나만 받으므로 MVP에서는 slot 1의 TECH - // 면접관을 대표 페르소나로 사용한다. 다중 personaId 질문 합성 API가 - // 제공되면 이 대표값 의존성을 제거한다. const representative = selectedSlots[0]; setActiveInterviewSessionId(createdInterview.sessionId); @@ -277,7 +257,14 @@ export const useMultiInterviewSetup = () => { status: createdInterview.status === "COMPLETED" ? "COMPLETED" : "IN_PROGRESS", currentQuestionIndex: createdInterview.currentQuestionIndex, - questions: tailoredQuestions, + questions: [], + mode: "MULTI", + interviewers: selectedSlots.map((interviewer, index) => ({ + personaId: savedPersonaIds[index], + name: interviewer.name, + roleLabel: interviewer.roleLabel, + image: interviewer.image, + })), }, multiInterview: { interviewId: createdInterview.interviewId, From 11f61cdf8664ac28630dcf93835b075f371c1e98 Mon Sep 17 00:00:00 2001 From: gituser Date: Sun, 30 Aug 2026 21:46:47 +0900 Subject: [PATCH 2/3] feat(interview): build multi interviewer dashboard --- src/pages/interview-page/interview/index.tsx | 158 +-------- .../interview/interview-content/index.tsx | 17 +- .../interview/interview-dashboard/index.tsx | 215 ++++++++++++ .../interview/interview-dashboard/style.ts | 316 ++++++++++++++++++ src/widgets/interview-page/interview/style.ts | 7 + 5 files changed, 555 insertions(+), 158 deletions(-) create mode 100644 src/widgets/interview-page/interview/interview-dashboard/index.tsx create mode 100644 src/widgets/interview-page/interview/interview-dashboard/style.ts diff --git a/src/pages/interview-page/interview/index.tsx b/src/pages/interview-page/interview/index.tsx index 3bab9df..a20020c 100644 --- a/src/pages/interview-page/interview/index.tsx +++ b/src/pages/interview-page/interview/index.tsx @@ -1,4 +1,3 @@ -import { useEffect, useState } from "react"; import { useLocation } from "react-router-dom"; import type { PreparedInterviewData } from "@/features/interview-page/interview/api"; @@ -6,11 +5,8 @@ import { InterviewSessionProvider, } from "@/features/interview-page/interview/model/interviewSessionContext"; import { useInterviewSessionContext } from "@/features/interview-page/interview/model/useInterviewSessionContext"; -import CameraIcon from "@/shared/img/interview-page/camara.svg?url"; -import MicIcon from "@/shared/img/interview-page/mike.svg?url"; import Loading from "@/shared/components/loading"; -import InterviewCameraView from "@/widgets/interview-page/interview/camera-view"; -import InterviewContentView from "@/widgets/interview-page/interview/interview-content"; +import InterviewDashboard from "@/widgets/interview-page/interview/interview-dashboard"; import * as S from "@/widgets/interview-page/interview/style"; const getPreparedInterviewFromState = (state: unknown) => { @@ -27,13 +23,6 @@ const getPreparedInterviewFromState = (state: unknown) => { return preparedInterview as PreparedInterviewData; }; -const formatElapsedTime = (elapsedSeconds: number) => { - const minutes = Math.floor(elapsedSeconds / 60); - const seconds = elapsedSeconds % 60; - - return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; -}; - const InterviewPage = () => { const location = useLocation(); const preparedInterview = getPreparedInterviewFromState(location.state); @@ -47,29 +36,6 @@ const InterviewPage = () => { const InterviewPageContent = () => { const interviewSession = useInterviewSessionContext(); - const [elapsedSeconds, setElapsedSeconds] = useState(0); - const [isTimerRunning, setIsTimerRunning] = useState(false); - const [isVoiceAnswering, setIsVoiceAnswering] = useState(false); - const isVoiceMode = interviewSession.mode === "voice"; - const isTextMode = interviewSession.mode === "text"; - const isVoiceActionStarted = isVoiceAnswering || interviewSession.isVoiceStarted; - const isQuestionLoading = interviewSession.currentQuestion === null; - const isStartActionDisabled = - !interviewSession.isInterviewReady || isQuestionLoading; - - useEffect(() => { - if (!isTimerRunning) { - return; - } - - const intervalId = window.setInterval(() => { - setElapsedSeconds((previousSeconds) => previousSeconds + 1); - }, 1000); - - return () => { - window.clearInterval(intervalId); - }; - }, [isTimerRunning]); if (interviewSession.preparationError) { return ( @@ -91,127 +57,7 @@ const InterviewPageContent = () => { return ; } - const handleStartVoice = () => { - setIsVoiceAnswering(true); - setIsTimerRunning(true); - interviewSession.onStartVoice(); - }; - - const handleCompleteVoice = async () => { - try { - await interviewSession.onCompleteVoice(); - } finally { - setIsVoiceAnswering(false); - } - }; - - const handleModeChange = (nextMode: typeof interviewSession.mode) => { - if (nextMode === "text") { - setIsVoiceAnswering(false); - } - - interviewSession.onModeChange(nextMode); - }; - - return ( - - - {isVoiceMode ? ( - - - - - - - handleModeChange("text")} - > - 텍스트 - - handleModeChange("voice")} - > - 음성 - - - - - ) : null} - - - - - {isVoiceMode && ( - - )} - - - {interviewSession.isPreparingInterview ? ( - - 포트폴리오를 등록하고 있습니다. 분석이 끝날 때까지 잠시 기다려주세요. - - ) : null} - - - void interviewSession.onQuitInterview()} - > - 그만두기 - - - {isVoiceMode ? ( - <> - - - - - {isVoiceActionStarted ? ( - - 끝내기 - - ) : ( - - {interviewSession.isPreparingInterview ? "준비 중..." : "시작하기"} - - )} - - ) : null} - - - - ); + return ; }; export default InterviewPage; diff --git a/src/widgets/interview-page/interview/interview-content/index.tsx b/src/widgets/interview-page/interview/interview-content/index.tsx index daf6b9b..29a8333 100644 --- a/src/widgets/interview-page/interview/interview-content/index.tsx +++ b/src/widgets/interview-page/interview/interview-content/index.tsx @@ -36,6 +36,7 @@ const InterviewContentView = ({ onModeChange }: InterviewContentViewProps) => { currentQuestion, displayQuestionNumber, isVoiceStarted, + isSubmitting, mode, onAnswerTextChange, onSubmitText, @@ -49,6 +50,9 @@ const InterviewContentView = ({ onModeChange }: InterviewContentViewProps) => { const totalQuestionCount = preparedInterview?.questions.length || FALLBACK_TOTAL_QUESTION_COUNT; const currentQuestionNumber = Math.max(displayQuestionNumber, 1); + const activeInterviewer = preparedInterview?.interviewers?.find( + (interviewer) => interviewer.personaId === currentQuestion?.personaId, + ); const question = { id: formatQuestionLabel({ currentQuestionNumber, @@ -66,6 +70,11 @@ const InterviewContentView = ({ onModeChange }: InterviewContentViewProps) => { {question.id} + {activeInterviewer ? ( + + 현재 면접관 · {activeInterviewer.name} ({activeInterviewer.roleLabel}) + + ) : null} {question.text} {isVoiceMode ? ( @@ -104,8 +113,12 @@ const InterviewContentView = ({ onModeChange }: InterviewContentViewProps) => { - - 제출하기 + + {isSubmitting ? "제출 중..." : "제출하기"} diff --git a/src/widgets/interview-page/interview/interview-dashboard/index.tsx b/src/widgets/interview-page/interview/interview-dashboard/index.tsx new file mode 100644 index 0000000..1a677c5 --- /dev/null +++ b/src/widgets/interview-page/interview/interview-dashboard/index.tsx @@ -0,0 +1,215 @@ +import { useEffect, useMemo, useState } from "react"; + +import { useInterviewSessionContext } from "@/features/interview-page/interview/model/useInterviewSessionContext"; +import InterviewCameraView from "@/widgets/interview-page/interview/camera-view"; +import * as S from "./style"; + +const TEXT_ANSWER_MAX_LENGTH = 10_000; + +const formatTime = (seconds: number) => { + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + + return `${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`; +}; + +const getMemoKey = (sessionId?: string) => + sessionId ? `interview-memo:${sessionId}` : null; + +const InterviewDashboard = () => { + const interview = useInterviewSessionContext(); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + const [isVoiceAnswering, setIsVoiceAnswering] = useState(false); + const sessionId = interview.preparedInterview?.sessionId; + const memoKey = useMemo(() => getMemoKey(sessionId), [sessionId]); + const [memo, setMemo] = useState(() => + memoKey ? window.sessionStorage.getItem(memoKey) ?? "" : "", + ); + const isVoiceMode = interview.mode === "voice"; + const isQuestionLoading = interview.currentQuestion === null; + const totalQuestionCount = interview.totalQuestionCount || 10; + const activePersonaId = interview.currentQuestion?.personaId; + const interviewers = interview.preparedInterview?.interviewers ?? []; + const activeInterviewer = interviewers.find( + (interviewer) => interviewer.personaId === activePersonaId, + ); + const isAnswerDisabled = + !interview.isInterviewReady || isQuestionLoading || interview.isSubmitting; + + useEffect(() => { + if (!interview.isInterviewReady) { + return; + } + + const intervalId = window.setInterval(() => { + setElapsedSeconds((seconds) => seconds + 1); + }, 1_000); + + return () => window.clearInterval(intervalId); + }, [interview.isInterviewReady]); + + useEffect(() => { + setMemo(memoKey ? window.sessionStorage.getItem(memoKey) ?? "" : ""); + }, [memoKey]); + + useEffect(() => { + if (memoKey) { + window.sessionStorage.setItem(memoKey, memo); + } + }, [memo, memoKey]); + + const handleModeChange = (mode: typeof interview.mode) => { + if (mode === "text") { + setIsVoiceAnswering(false); + } + + interview.onModeChange(mode); + }; + + const handleStartVoice = () => { + setIsVoiceAnswering(true); + interview.onStartVoice(); + }; + + const handleCompleteVoice = async () => { + try { + await interview.onCompleteVoice(); + } finally { + setIsVoiceAnswering(false); + } + }; + + return ( + + + + + + + + + + Question {String(Math.max(interview.displayQuestionNumber, 1)).padStart(2, "0")} / {String(totalQuestionCount).padStart(2, "0")} + + {activeInterviewer ? ( + + {activeInterviewer.name} · {activeInterviewer.roleLabel} + + ) : null} + + {interview.currentQuestion?.content ?? "질문을 불러오는 중입니다."} + + + + + + handleModeChange("voice")} + > + 영상/음성 답변 + + handleModeChange("text")} + > + 텍스트 답변 + + + + {isVoiceMode ? ( + + + + + + + + ) : ( + + )} + + + 글자 수 {interview.answerText.length} / {TEXT_ANSWER_MAX_LENGTH} + + void interview.onQuitInterview()} + > + 그만두기 + + {isVoiceMode ? ( + isVoiceAnswering || interview.isVoiceStarted ? ( + void handleCompleteVoice()}> + 답변 끝내기 + + ) : ( + + 음성 답변 시작 + + ) + ) : ( + void interview.onSubmitText()} + > + {interview.isSubmitting ? "제출 중..." : "제출하기"} + + )} + + + + + + + + {interviewers.map((interviewer) => { + const isActive = interviewer.personaId === activePersonaId; + + return ( + + {interviewer.image ? ( + + ) : ( + + )} + {interviewer.name} + {interviewer.roleLabel} + {isActive ? 질문 중 : null} + + ); + })} + + + + 메모 + setMemo(event.target.value)} + /> + + + + + + ); +}; + +export default InterviewDashboard; diff --git a/src/widgets/interview-page/interview/interview-dashboard/style.ts b/src/widgets/interview-page/interview/interview-dashboard/style.ts new file mode 100644 index 0000000..788812d --- /dev/null +++ b/src/widgets/interview-page/interview/interview-dashboard/style.ts @@ -0,0 +1,316 @@ +import styled from "styled-components"; + +export const Page = styled.section` + width: 100%; + min-height: 0; + height: 100%; + overflow: auto; + padding: clamp(1rem, 2.4vw, 2.2rem); + box-sizing: border-box; + background: linear-gradient(135deg, #f7f9ff 0%, #eef4ff 100%); +`; + +export const Content = styled.div` + width: min(100%, 74rem); + min-height: 100%; + margin: 0 auto; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 0.65rem; +`; + +export const Timer = styled.div` + width: fit-content; + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.9rem; + border: 0.0625rem solid #d7dce5; + border-radius: 999rem; + background: #fff; + color: #4b5563; + font-size: 1rem; + font-weight: 700; +`; + +export const TimerDot = styled.span` + width: 0.46rem; + height: 0.46rem; + border-radius: 50%; + background: #e15858; +`; + +export const MainGrid = styled.main` + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1.65fr) minmax(17rem, 0.95fr); + gap: clamp(0.85rem, 1.5vw, 1.2rem); + + @media (max-width: 58rem) { + grid-template-columns: 1fr; + } +`; + +export const LeftColumn = styled.div` + min-width: 0; + display: grid; + grid-template-rows: minmax(11rem, 0.72fr) minmax(23rem, 1.28fr); + gap: 0.85rem; + + @media (max-width: 58rem) { + grid-template-rows: auto auto; + } +`; + +export const RightColumn = styled.aside` + min-width: 0; + display: grid; + grid-template-rows: auto minmax(16rem, 1fr); + gap: 0.85rem; +`; + +const panel = ` + border: 0.0625rem solid #d8e0ed; + border-radius: 0.75rem; + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 0.35rem 1.2rem rgba(55, 82, 125, 0.08); +`; + +export const QuestionPanel = styled.article` + ${panel} + min-height: 0; + padding: clamp(1.5rem, 3.5vw, 3rem); + display: flex; + flex-direction: column; + justify-content: center; + gap: 1rem; +`; + +export const QuestionTag = styled.span` + width: fit-content; + padding: 0.25rem 0.55rem; + border: 0.0625rem solid #93c0ff; + border-radius: 999rem; + color: #1874e8; + font-size: 0.76rem; + font-weight: 800; +`; + +export const QuestionSpeaker = styled.span` + color: #4f6b91; + font-size: 0.86rem; + font-weight: 700; +`; + +export const QuestionText = styled.h1` + margin: 0; + color: #151a23; + font-size: clamp(1.18rem, 2vw, 1.55rem); + font-weight: 650; + line-height: 1.55; + word-break: keep-all; +`; + +export const AnswerPanel = styled.section` + ${panel} + min-height: 0; + overflow: hidden; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; +`; + +export const AnswerTabs = styled.div` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-bottom: 0.0625rem solid #dce3ee; +`; + +export const Tab = styled.button<{ $active: boolean }>` + min-height: 2.75rem; + border: 0; + border-right: 0.0625rem solid #dce3ee; + background: ${({ $active }) => ($active ? "#f9fbff" : "#f5f6f8")}; + color: ${({ $active }) => ($active ? "#1f78ef" : "#555d68")}; + box-shadow: ${({ $active }) => ($active ? "inset 0 0.125rem #6caeff" : "none")}; + font-weight: 800; + cursor: pointer; + + &:last-child { border-right: 0; } +`; + +export const TextArea = styled.textarea` + width: 100%; + min-height: 0; + resize: none; + border: 0; + outline: 0; + padding: 1.5rem; + box-sizing: border-box; + color: #273142; + font: inherit; + line-height: 1.6; + + &::placeholder { color: #a0a8b4; } +`; + +export const VideoArea = styled.div` + position: relative; + min-height: 0; + overflow: hidden; + background: #f4f7fb; +`; + +export const VideoControls = styled.div` + position: absolute; + left: 1rem; + bottom: 1rem; + display: flex; + gap: 0.55rem; + z-index: 2; +`; + +export const RoundButton = styled.button` + width: 2.8rem; + height: 2.8rem; + border: 0; + border-radius: 50%; + background: #fff; + color: #1f78ef; + box-shadow: 0 0.2rem 0.7rem rgba(34, 66, 112, 0.2); + font-size: 1.1rem; + cursor: pointer; +`; + +export const BottomRow = styled.div` + min-height: 3.5rem; + padding: 0.55rem 0.8rem; + border-top: 0.0625rem solid #e1e6ee; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +`; + +export const Count = styled.span` + color: #667085; + font-size: 0.8rem; + font-weight: 700; +`; + +export const Actions = styled.div` + display: flex; + gap: 0.5rem; +`; + +export const Button = styled.button<{ $secondary?: boolean }>` + min-height: 2.35rem; + padding: 0 1rem; + border: 0.0625rem solid ${({ $secondary }) => ($secondary ? "#cfd8e6" : "#1d76ea")}; + border-radius: 0.45rem; + background: ${({ $secondary }) => ($secondary ? "#fff" : "#2179ed")}; + color: ${({ $secondary }) => ($secondary ? "#4e5968" : "#fff")}; + font-size: 0.9rem; + font-weight: 800; + cursor: pointer; + + &:disabled { opacity: 0.55; cursor: not-allowed; } +`; + +export const Interviewers = styled.section` + ${panel} + padding: 0.65rem; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(6.6rem, 1fr)); + gap: 0.55rem; +`; + +export const InterviewerCard = styled.article<{ $active: boolean }>` + min-width: 0; + padding: 0.65rem; + border: 0.0625rem solid ${({ $active }) => ($active ? "#4d98ff" : "#dce2ea")}; + border-radius: 0.6rem; + background: ${({ $active }) => ($active ? "#f4f9ff" : "#fff")}; +`; + +export const InterviewerImage = styled.img` + width: 100%; + aspect-ratio: 1 / 0.82; + border-radius: 0.36rem; + object-fit: cover; + background: #e9edf4; +`; + +export const InterviewerFallback = styled.div` + width: 100%; + aspect-ratio: 1 / 0.82; + border-radius: 0.36rem; + display: grid; + place-items: center; + background: #e7effc; + color: #2478e9; + font-size: 1.6rem; + font-weight: 900; +`; + +export const InterviewerName = styled.strong` + display: block; + margin-top: 0.55rem; + overflow: hidden; + color: #1f2937; + font-size: 0.83rem; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export const InterviewerRole = styled.span` + display: block; + margin-top: 0.25rem; + color: #5e779c; + font-size: 0.7rem; + font-weight: 700; +`; + +export const ActiveBadge = styled.span` + display: block; + width: fit-content; + margin: 0.45rem 0 0 auto; + padding: 0.18rem 0.38rem; + border-radius: 999rem; + background: #2479ed; + color: #fff; + font-size: 0.62rem; + font-weight: 800; +`; + +export const MemoPanel = styled.section` + ${panel} + min-height: 0; + padding: 0.8rem; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 0.55rem; +`; + +export const MemoLabel = styled.label` + width: fit-content; + padding: 0.2rem 0.5rem; + border: 0.0625rem solid #9ac5ff; + border-radius: 999rem; + color: #2179ed; + font-size: 0.75rem; + font-weight: 800; +`; + +export const MemoTextArea = styled.textarea` + width: 100%; + min-height: 11rem; + resize: vertical; + padding: 1rem; + border: 0.0625rem solid #dce2ea; + border-radius: 0.45rem; + box-sizing: border-box; + color: #4b5563; + font: inherit; + line-height: 1.55; + outline-color: #78b0ff; +`; diff --git a/src/widgets/interview-page/interview/style.ts b/src/widgets/interview-page/interview/style.ts index 952009f..48a0d62 100644 --- a/src/widgets/interview-page/interview/style.ts +++ b/src/widgets/interview-page/interview/style.ts @@ -305,6 +305,13 @@ export const QuestionLabel = styled.span` } `; +export const QuestionSpeaker = styled.span` + color: #5d6b86; + font-size: clamp(0.75rem, 0.88vw, 0.86rem); + font-weight: 700; + line-height: 1.25; +`; + export const QuestionBody = styled.div` flex: 1; min-height: 0; From 544d771afc87e3344c0fc5187f65183360660775 Mon Sep 17 00:00:00 2001 From: gituser Date: Sun, 30 Aug 2026 21:58:17 +0900 Subject: [PATCH 3/3] feat(feedback): build multi interviewer feedback ui --- .../feedback-page/feedback-detail/index.tsx | 97 +------------------ .../feedback-page/feedback-overall/index.tsx | 70 +------------ .../feedback-page/multi-feedback/data.ts | 95 ++++++++++++++++++ .../feedback-page/multi-feedback/index.tsx | 41 ++++++++ .../feedback-page/multi-feedback/style.ts | 47 +++++++++ 5 files changed, 187 insertions(+), 163 deletions(-) create mode 100644 src/widgets/feedback-page/multi-feedback/data.ts create mode 100644 src/widgets/feedback-page/multi-feedback/index.tsx create mode 100644 src/widgets/feedback-page/multi-feedback/style.ts diff --git a/src/pages/feedback-page/feedback-detail/index.tsx b/src/pages/feedback-page/feedback-detail/index.tsx index 0b53ab1..4ad1660 100644 --- a/src/pages/feedback-page/feedback-detail/index.tsx +++ b/src/pages/feedback-page/feedback-detail/index.tsx @@ -1,98 +1,5 @@ -import { useFeedbackInterview } from "@/features/feedback-page/model/useFeedbackInterview"; -import { buildFeedbackDetailContent } from "@/features/feedback-page/model/feedbackDisplay"; -import { exportElementToPdf } from "@/shared/utils/exportElementToPdf"; -import FeedbackDetailBottomSection from "@/widgets/feedback-page/feedback-detail/bottom-section"; -import FeedbackDetailMiddleSection from "@/widgets/feedback-page/feedback-detail/middle-section"; -import FeedbackDetailTopSection from "@/widgets/feedback-page/feedback-detail/top-section"; -import FeedbackSummaryCard from "@/widgets/feedback-page/feedback-summary-card"; -import { useRef, useState } from "react"; -import { useParams } from "react-router-dom"; -import * as S from "./style"; +import MultiFeedback from "@/widgets/feedback-page/multi-feedback"; -const FeedbackDetailPage = () => { - const { id } = useParams(); - const { - errorMessage, - feedback, - feedbackErrorMessage, - interview, - isLoading, - refetch, - } = useFeedbackInterview(id); - const content = feedback ? buildFeedbackDetailContent(feedback) : null; - const panelRef = useRef(null); - const [isDownloadingPdf, setIsDownloadingPdf] = useState(false); - - const handleDownloadPdf = async () => { - if (!panelRef.current || isDownloadingPdf) { - return; - } - - setIsDownloadingPdf(true); - - try { - await exportElementToPdf(panelRef.current, { - fileName: `repit-feedback-detail-${id ?? "1"}.pdf`, - }); - } catch (error) { - console.error(error); - window.alert("PDF 저장 중 오류가 발생했습니다. 다시 시도해주세요."); - } finally { - setIsDownloadingPdf(false); - } - }; - - return ( - - - {isLoading ? ( - - 면접 정보를 불러오는 중입니다. - - ) : errorMessage || !interview ? ( - - {errorMessage ?? "면접 정보를 찾을 수 없습니다."} - { - void refetch(); - }} - > - 다시 시도 - - - ) : !content ? ( - - - {feedbackErrorMessage ?? "피드백 분석 결과를 불러오지 못했습니다."} - - { - void refetch(); - }} - > - 다시 시도 - - - ) : ( - <> - - - - - - )} - - - ); -}; +const FeedbackDetailPage = () => ; export default FeedbackDetailPage; diff --git a/src/pages/feedback-page/feedback-overall/index.tsx b/src/pages/feedback-page/feedback-overall/index.tsx index e68cde3..ccd1c65 100644 --- a/src/pages/feedback-page/feedback-overall/index.tsx +++ b/src/pages/feedback-page/feedback-overall/index.tsx @@ -1,71 +1,5 @@ -import { useFeedbackInterview } from "@/features/feedback-page/model/useFeedbackInterview"; -import { buildFeedbackOverallContent } from "@/features/feedback-page/model/feedbackDisplay"; -import FeedbackSummaryCard from "@/widgets/feedback-page/feedback-summary-card"; -import FeedbackOverallBottomSection from "@/widgets/feedback-page/feedback-overall/bottom-section"; -import FeedbackOverallMiddleSection from "@/widgets/feedback-page/feedback-overall/middle-section"; -import FeedbackOverallTopSection from "@/widgets/feedback-page/feedback-overall/top-section"; -import { useParams } from "react-router-dom"; -import * as S from "./style"; +import MultiFeedback from "@/widgets/feedback-page/multi-feedback"; -const FeedbackOverallPage = () => { - const { id } = useParams(); - const { - errorMessage, - feedback, - feedbackErrorMessage, - interview, - isLoading, - refetch, - } = useFeedbackInterview(id); - const content = feedback ? buildFeedbackOverallContent(feedback) : null; - - return ( - - - {isLoading ? ( - - 면접 정보를 불러오는 중입니다. - - ) : errorMessage || !interview ? ( - - {errorMessage ?? "면접 정보를 찾을 수 없습니다."} - { - void refetch(); - }} - > - 다시 시도 - - - ) : !content ? ( - - - {feedbackErrorMessage ?? "피드백 분석 결과를 불러오지 못했습니다."} - - { - void refetch(); - }} - > - 다시 시도 - - - ) : ( - <> - - - - - - )} - - - ); -}; +const FeedbackOverallPage = () => ; export default FeedbackOverallPage; diff --git a/src/widgets/feedback-page/multi-feedback/data.ts b/src/widgets/feedback-page/multi-feedback/data.ts new file mode 100644 index 0000000..eed29db --- /dev/null +++ b/src/widgets/feedback-page/multi-feedback/data.ts @@ -0,0 +1,95 @@ +import InterviewerImage1 from "@/shared/img/interview-page/interviewer1.svg?url"; +import InterviewerImage3 from "@/shared/img/interview-page/interviewer3.svg?url"; +import InterviewerImage4 from "@/shared/img/interview-page/interviewer4.svg?url"; + +export const multiFeedbackFixture = { + score: 82, + summary: "전반적으로 좋은 면접 점수입니다. 지원자의 경험을 근거로 답변을 구성하는 능력이 돋보였습니다.", + intent: "질문의 의도를 정확히 파악했고, 협업 경험을 구체적인 사례와 함께 설명해 핵심 역량을 잘 보여주었습니다.", + strengths: [ + "질문의 의도를 빠르게 파악하고 핵심부터 답변했습니다.", + "프로젝트 경험을 근거로 들어 답변의 신뢰도를 높였습니다.", + "기술적 내용과 협업 경험을 균형 있게 설명했습니다.", + ], + improvements: [ + "성과를 수치나 결과로 한 번 더 구체화하면 설득력이 높아집니다.", + "본인의 역할과 기여도를 조금 더 선명하게 강조해 보세요.", + ], + personas: [ + { + id: 1, + name: "김도윤", + role: "기술 면접관", + image: InterviewerImage1, + score: 86, + comment: "기술 선택의 배경과 구현 경험을 논리적으로 설명했습니다. 성능 개선의 결과를 수치로 덧붙이면 더 좋겠습니다.", + }, + { + id: 2, + name: "최유진", + role: "인사 면접관", + image: InterviewerImage4, + score: 79, + comment: "협업 태도와 갈등 해결 방식이 안정적입니다. 본인의 감정과 배움을 함께 설명하면 진정성이 높아집니다.", + }, + { + id: 3, + name: "윤하늘", + role: "대표 면접관", + image: InterviewerImage3, + score: 81, + comment: "사업 관점의 질문에도 사용자 가치와 실행력을 연결한 점이 인상적이었습니다.", + }, + ], + questions: [ + { + id: "q1", + personaId: 2, + label: "Q1", + question: "자기소개와 함께 본인의 강점을 말씀해주세요.", + intention: "지원자의 핵심 강점과 이를 뒷받침하는 경험의 구체성을 확인합니다.", + answer: "저는 문제를 구조화하고 팀과 빠르게 합의점을 찾는 강점이 있습니다. 프로젝트에서 API 응답 형식을 정리하며 협업 비용을 줄인 경험이 있습니다.", + modelAnswer: "저의 강점은 복잡한 문제를 팀이 실행할 수 있는 단위로 정리하는 능력입니다. 실제 프로젝트에서 API 규격을 문서화해 수정 요청을 줄였고, 개발 속도를 높였습니다.", + followUp: "같은 갈등이 다시 발생한다면 어떤 기준으로 의사결정하시겠습니까?", + strength: "갈등 상황과 해결 방법을 자신의 행동 중심으로 설명해 협업 역량이 잘 드러납니다.", + improvement: "합의 이후 어떤 긍정적 변화가 있었는지 수치나 팀의 반응으로 마무리해 보세요.", + }, + { + id: "q2", + personaId: 2, + label: "Q2", + question: "협업 중 의견 충돌이 발생했던 경험을 어떻게 해결했는지 설명해 주세요.", + intention: "갈등 상황에서의 소통 방식과 문제 해결 역량을 평가합니다.", + answer: "서로의 의견을 충분히 듣고 프로젝트 목표에 더 적합한 방향을 논의해 해결했습니다.", + modelAnswer: "각 안의 장단점을 사용자 영향과 일정 기준으로 비교했고, 결정 내용을 문서화해 팀이 같은 기준으로 실행하도록 했습니다.", + followUp: "다시 같은 상황이 발생한다면 무엇을 먼저 확인하시겠습니까?", + strength: "상대방의 의견을 경청하고 공통 목표를 기준으로 논의한 점이 좋습니다.", + improvement: "결정 이후 일정·품질·협업 방식이 어떻게 개선됐는지 구체적인 결과를 덧붙여 보세요.", + }, + { + id: "q3", + personaId: 1, + label: "Q3", + question: "GitHub Actions를 적용하게 된 이유와 기존 방식 대비 장점은 무엇인가요?", + intention: "자동화 도입 배경과 개발 생산성에 대한 이해를 확인합니다.", + answer: "반복적인 배포 과정을 자동화하고 검증 단계를 표준화하기 위해 적용했습니다.", + modelAnswer: "수동 배포에서 발생하던 누락을 줄이고, 테스트와 린트를 PR 단계에 고정해 배포 신뢰도를 높였습니다.", + followUp: "실패한 워크플로우를 어떻게 관찰하고 복구하겠습니까?", + strength: "자동화의 목적을 팀 생산성과 품질 관점으로 설명했습니다.", + improvement: "도입 전후 배포 시간이나 오류 건수 변화를 제시하면 더 강한 답변이 됩니다.", + }, + { + id: "q4", + personaId: 3, + label: "Q4", + question: "프로젝트의 우선순위를 정할 때 어떤 기준을 사용하나요?", + intention: "사업 목표와 사용자 가치 사이에서 균형 있게 판단하는 능력을 확인합니다.", + answer: "사용자 영향도와 구현 비용을 함께 고려해 팀과 우선순위를 정합니다.", + modelAnswer: "사용자 문제의 심각도, 매출·리텐션 영향, 구현 비용, 의존성을 함께 점검하고 판단 근거를 공유합니다.", + followUp: "핵심 지표가 악화된다면 무엇부터 확인하시겠습니까?", + strength: "사용자 영향과 실행 비용을 함께 고려한 점이 좋습니다.", + improvement: "판단 기준의 우선순위와 실제 의사결정 사례를 추가하면 더 설득력 있습니다.", + }, + ], + keywords: ["프로젝트", "협업", "개발", "문제", "해결", "백엔드", "디자인", "프론트"], +} as const; diff --git a/src/widgets/feedback-page/multi-feedback/index.tsx b/src/widgets/feedback-page/multi-feedback/index.tsx new file mode 100644 index 0000000..19c51c4 --- /dev/null +++ b/src/widgets/feedback-page/multi-feedback/index.tsx @@ -0,0 +1,41 @@ +import { useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; + +import { multiFeedbackFixture as data } from "./data"; +import * as S from "./style"; + +interface MultiFeedbackProps { view: "overall" | "detail"; } + +const MultiFeedback = ({ view }: MultiFeedbackProps) => { + const navigate = useNavigate(); + const { id } = useParams(); + const [selectedId, setSelectedId] = useState(data.questions[0].id); + const selected = data.questions.find((question) => question.id === selectedId) ?? data.questions[0]; + const feedbackPath = id ?? "1"; + + return + {view === "overall" ? "종합 피드백" : "상세 피드백"} + navigate(`/main/feedback/overall/${feedbackPath}`)}>종합 피드백 + navigate(`/main/feedback/detail/${feedbackPath}`)}>상세 피드백 + + {view === "overall" ? : } + ; +}; + +const Overall = () => <> + + 종합 점수{data.score}점{data.summary} + 나의 점수{data.score}점응시자 평균 점수40점 +

질문 의도 적합도

{data.intent}

+
+

☺ 장점

    {data.strengths.map(item=>
  1. {item}
  2. )}

☹ 단점

    {data.improvements.map(item=>
  1. {item}
  2. )}

지난 면접 대비

+ 10점이 상승했어요!


+ {data.personas.map(persona=>
{persona.name} · {persona.role}{persona.comment}
{persona.score}점
)}
+

핵심 키워드

{data.keywords.map((word,index)=>{word})}

응답 신뢰성

99% · 높음

면접관별 평균

기술 · 인사 · 대표 관점의 피드백을 함께 확인해 보세요.

+; + +const Detail = ({ selected, selectedId, onSelect }: { selected: typeof data.questions[number]; selectedId:string; onSelect:(id:string)=>void }) => + 상세 피드백{data.questions.map(question=>{const persona=data.personas.find(item=>item.id===question.personaId);return onSelect(question.id)}>{question.label} · {persona?.role}{question.question}})}면접 다시보기PDF로 저장 +

{selected.label}

{selected.question}

질문 의도

{selected.intention}

내 답변

{selected.answer}

모범 답변

{selected.modelAnswer}

예상 꼬리질문

{selected.followUp}

답변의 흐름은 자연스럽고 경험이 잘 드러납니다. 결과와 본인의 기여도를 더 구체적으로 연결하면 훨씬 신뢰도 높은 답변이 됩니다.

☺ 장점

{selected.strength}

☹ 단점

{selected.improvement}

+
; + +export default MultiFeedback; diff --git a/src/widgets/feedback-page/multi-feedback/style.ts b/src/widgets/feedback-page/multi-feedback/style.ts new file mode 100644 index 0000000..5d0dc75 --- /dev/null +++ b/src/widgets/feedback-page/multi-feedback/style.ts @@ -0,0 +1,47 @@ +import styled from "styled-components"; + +const surface = `background: rgba(255,255,255,.94); border: 1px solid #dce3ee; border-radius: 12px; box-shadow: 0 8px 20px rgba(46, 78, 124, .06);`; + +export const Page = styled.section` + min-height: 100%; padding: 1.5rem clamp(1rem, 3vw, 3rem) 2.5rem; box-sizing: border-box; + background: linear-gradient(135deg, #f8faff, #eef4ff); +`; +export const Shell = styled.div`width:min(100%, 106rem); margin:0 auto;`; +export const Header = styled.div`display:flex; justify-content:space-between; align-items:center; gap:1rem; margin-bottom:1rem;`; +export const Title = styled.h1`margin:0; color:#202938; font-size:1.35rem;`; +export const Tabs = styled.div`${surface} display:flex; overflow:hidden;`; +export const Tab = styled.button<{ $active: boolean }>`border:0; padding:.65rem 1rem; background:${p=>p.$active ? "#fff" : "#f2f4f7"}; color:${p=>p.$active ? "#2377ea" : "#555"}; font-weight:800; cursor:pointer;`; +export const OverallGrid = styled.div`display:grid; grid-template-columns:minmax(15rem,.78fr) minmax(0,1.75fr); gap:1rem; @media(max-width:58rem){grid-template-columns:1fr;}`; +export const Card = styled.section`${surface} padding:1.35rem; box-sizing:border-box;`; +export const ScoreCard = styled(Card)`min-height:21rem; display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center;`; +export const Donut = styled.div<{ $score: number }>`width:10.5rem; aspect-ratio:1; border-radius:50%; display:grid; place-items:center; background:conic-gradient(#2578e8 ${p=>p.$score}%, #e5e7eb 0); &::before{content:""; position:absolute; width:8.4rem; aspect-ratio:1; border-radius:50%; background:white;} position:relative;`; +export const DonutText = styled.div`position:relative; z-index:1; display:grid; gap:.2rem; font-weight:800; color:#222; span{color:#2478e9; font-size:2.4rem;}`; +export const Summary = styled.p`margin:1.3rem 0 0; line-height:1.5; color:#374151;`; +export const Stack = styled.div`display:grid; gap:1rem;`; +export const BarCard = styled(Card)`padding:1.25rem 1.5rem;`; +export const BarRow = styled.div`display:grid; gap:.45rem; &:not(:last-child){margin-bottom:1rem;}`; +export const BarTitle = styled.div`display:flex; justify-content:space-between; color:#394150; font-size:.92rem; font-weight:700; strong{color:#2277e8;}`; +export const Track = styled.div`height:.75rem; border-radius:99px; background:#e4e6e9; overflow:hidden;`; +export const Fill = styled.div<{ $score:number; $muted?:boolean }>`height:100%; width:${p=>p.$score}%; border-radius:inherit; background:${p=>p.$muted ? "#a8abb0" : "#2879e8"};`; +export const IntentCard = styled(Card)`min-height:11.7rem; h2{margin:0 0 .9rem; color:#2478e9; font-size:1.2rem;} p{margin:0; line-height:1.55; color:#303846;}`; +export const InsightGrid = styled.div`display:grid; grid-template-columns:1fr 1fr .8fr; gap:1rem; margin-top:1rem; @media(max-width:62rem){grid-template-columns:1fr;}`; +export const Insight = styled(Card)<{ $tone:"positive"|"negative"|"neutral" }>`background:${p=>p.$tone==="positive"?"#edf6ff":p.$tone==="negative"?"#fff3f2":"#fff"}; h2{margin:0 0 1rem; color:${p=>p.$tone==="negative"?"#ef5335":"#2478e9"}; font-size:1.15rem;} ol{margin:0;padding-left:1.3rem;display:grid;gap:.75rem;color:#344052;line-height:1.4;}`; +export const PersonaList = styled.div`display:grid; gap:.8rem; margin-top:1rem;`; +export const PersonaCard = styled.article`${surface} padding: .8rem; display:grid; grid-template-columns:4.5rem 1fr auto; gap:1rem; align-items:center; @media(max-width:38rem){grid-template-columns:3.5rem 1fr;}`; +export const PersonaImage = styled.img`width:4.5rem; aspect-ratio:1; object-fit:cover; border-radius:.45rem; @media(max-width:38rem){width:3.5rem;}`; +export const PersonaName = styled.strong`display:block; color:#2578e8;`; export const PersonaComment = styled.p`margin:.35rem 0 0; line-height:1.45; color:#303846;`; +export const ScoreBadge = styled.span`padding:.5rem .7rem; color:#fff; background:#2478e9; border-radius:.5rem; font-weight:800; @media(max-width:38rem){display:none;}`; +export const KeywordWrap = styled.div`display:flex; min-height:10rem; flex-wrap:wrap; gap:.65rem; align-content:center; justify-content:center;`; +export const Keyword = styled.span<{ $index:number }>`color:#2879e8; font-size:${p=>1 + (p.$index%4)*.45}rem; font-weight:${p=>p.$index%3===0?800:600};`; +export const DetailGrid = styled.div`display:grid; grid-template-columns:17rem minmax(0,1fr); gap:1rem; @media(max-width:58rem){grid-template-columns:1fr;}`; +export const Sidebar = styled.aside`${surface} padding:.8rem; display:flex; flex-direction:column; gap:.75rem;`; +export const SideTitle = styled.h2`margin:.35rem .4rem; color:#2478e9; font-size:1.12rem;`; +export const QuestionButton = styled.button<{ $active:boolean }>`text-align:left; border:1px solid ${p=>p.$active?"#71aeff":"#d7e2f3"}; border-radius:.65rem; padding:.85rem; background:${p=>p.$active?"#f2f7ff":"#fbfcff"}; color:${p=>p.$active?"#2478e9":"#333"}; cursor:pointer; display:grid; gap:.38rem; strong{font-size:.9rem;} span{font-size:.82rem; line-height:1.32;}`; +export const DetailContent = styled.div`display:grid; gap:.8rem;`; +export const QuestionCard = styled(Card)`h2{margin:.25rem 0 1.35rem; font-size:1.32rem; line-height:1.45;} h3{display:inline-block; margin:0 0 .7rem; padding:.42rem .8rem; border-radius:.45rem; color:white; background:#2478e9;} p{margin:0;line-height:1.55;color:#303846;}`; +export const Answers = styled.div`display:grid; grid-template-columns:1fr 1fr; gap:.8rem; @media(max-width:48rem){grid-template-columns:1fr;}`; +export const AnswerCard = styled(Card)`min-height:10rem; h2{margin:0 0 .75rem;color:#2478e9;font-size:1.2rem;} p{margin:0;line-height:1.52;color:#2f3743;}`; +export const FeedbackRow = styled.div`display:grid;grid-template-columns:1fr 1fr;gap:.8rem;@media(max-width:48rem){grid-template-columns:1fr;}`; +export const Coach = styled(Card)`color:#2478e9; font-size:1.15rem; font-weight:700; line-height:1.55;`; +export const Actions = styled.div`display:flex; gap:.7rem; margin-top:auto;`; +export const Action = styled.button<{ $primary?:boolean }>`border:1px solid #2478e9; border-radius:.45rem; padding:.7rem 1rem; background:${p=>p.$primary?"#2478e9":"#fff"}; color:${p=>p.$primary?"#fff":"#2478e9"}; font-weight:800; cursor:pointer;`;