diff --git a/frontend/src/features/analysis/ui/DocumentList.tsx b/frontend/src/features/analysis/ui/DocumentList.tsx index 5baf8030..08a1ae57 100644 --- a/frontend/src/features/analysis/ui/DocumentList.tsx +++ b/frontend/src/features/analysis/ui/DocumentList.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { isApiError } from '@/shared/api' -import { EmptyState, Modal, StatusBadge, type StatusTone } from '@/shared/ui' +import { EmptyState, ListSkeleton, Modal, StatusBadge, type StatusTone } from '@/shared/ui' import { DOCUMENT_SOURCE_LABEL as SOURCE_LABEL } from '@/domain/rag' import type { DocumentFilter } from '../api/analysis' import { useDocuments } from '../model/useDocuments' @@ -30,7 +30,7 @@ export function DocumentList({ filter = {}, sourceType }: Props) { const [activeId, setActiveId] = useState(null) if (isPending) { - return

분석 결과를 불러오는 중…

+ return } if (isError) { return ( diff --git a/frontend/src/features/cover-letter/ui/CoverLetterList.tsx b/frontend/src/features/cover-letter/ui/CoverLetterList.tsx index db147f61..33f512e9 100644 --- a/frontend/src/features/cover-letter/ui/CoverLetterList.tsx +++ b/frontend/src/features/cover-letter/ui/CoverLetterList.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { isApiError } from '@/shared/api' import { useAnalysisProgress } from '@/shared/hooks' -import { ConfirmDialog, EmptyState, StatusBadge, type StatusTone } from '@/shared/ui' +import { ConfirmDialog, EmptyState, ListSkeleton, StatusBadge, type StatusTone } from '@/shared/ui' import { useCoverLetters, useDeleteCoverLetter } from '../model/useCoverLetters' import type { CoverLetter, CoverLetterStatus } from '../model/types' @@ -17,7 +17,7 @@ export function CoverLetterList() { const remove = useDeleteCoverLetter() if (isPending) { - return

자소서를 불러오는 중…

+ return } if (isError) { return ( diff --git a/frontend/src/features/feedback/ui/FeedbackReport.tsx b/frontend/src/features/feedback/ui/FeedbackReport.tsx index b42665d0..480fbb87 100644 --- a/frontend/src/features/feedback/ui/FeedbackReport.tsx +++ b/frontend/src/features/feedback/ui/FeedbackReport.tsx @@ -94,91 +94,54 @@ export function FeedbackReport({ - {jobFit && ( + {/* 핵심 서사: 강점 → 개선 → 키워드 → 학습방향 (점수 직후로 끌어올려 스캔성↑) */} + {feedback.strengthsSummary && (
-

직무 적합도

-

- 채용공고(JD) 요구 대비 적합도·갭 평가입니다. 종합 점수에는 반영되지 않습니다. +

강점

+

+

-
- - {jobFit.detail && ( -

- -

- )} - {(jobFit.strength || jobFit.weakness) && ( -
- {jobFit.strength && 충족 · {jobFit.strength}} - {jobFit.weakness && 갭 · {jobFit.weakness}} -
- )} - {jobFit.scoreRationale && ( -

- 점수 근거 · {jobFit.scoreRationale} -

- )} -
)} - {roleUnderstanding && ( + {feedback.weaknessesSummary && (
-

직무 이해도

-

- 직무가 무엇을 하는 자리인지에 대한 이해·지원동기 평가입니다. 종합 점수에는 반영되지 - 않습니다. +

개선할 점

+

+

-
- - {roleUnderstanding.detail && ( -

- -

- )} - {(roleUnderstanding.strength || roleUnderstanding.weakness) && ( -
- {roleUnderstanding.strength && 강점 · {roleUnderstanding.strength}} - {roleUnderstanding.weakness && 보완 · {roleUnderstanding.weakness}} -
- )} - {roleUnderstanding.scoreRationale && ( -

- 점수 근거 · {roleUnderstanding.scoreRationale} -

- )} -
)} - {selfIntro && ( + {feedback.improvementKeywords && feedback.improvementKeywords.length > 0 && (
-

자기소개 첫인상

-

- 전달력·구성·직무적합성 평가입니다. 종합 점수에는 반영되지 않습니다. -

-
- - {selfIntro.detail && ( -

- -

- )} - {(selfIntro.strength || selfIntro.weakness) && ( -
- {selfIntro.strength && 강점 · {selfIntro.strength}} - {selfIntro.weakness && 보완 · {selfIntro.weakness}} -
- )} - {selfIntro.scoreRationale && ( -

- 점수 근거 · {selfIntro.scoreRationale} -

- )} +

다음에 채울 키워드

+
+ {feedback.improvementKeywords.map((kw) => ( + + {kw} + + ))}
)} + {feedback.studyPlan && feedback.studyPlan.length > 0 && ( +
+

학습 방향

+
    + {feedback.studyPlan.map((step, i) => ( +
  • + + + + +
  • + ))} +
+
+ )} + {interviewerPanel.length > 0 && (

면접관 패널 평가

@@ -206,50 +169,85 @@ export function FeedbackReport({
)} - {feedback.strengthsSummary && ( -
-

강점

-

- -

-
- )} + {/* 추가 평가 — 종합 점수에 미반영. 면책은 그룹 헤더에서 한 번만. */} + {(jobFit || roleUnderstanding || selfIntro) && ( +
+
+

추가 평가

+

+ 아래 항목은 종합 점수에 반영되지 않는 참고용 평가입니다. +

+
- {feedback.weaknessesSummary && ( -
-

개선할 점

-

- -

-
- )} + {jobFit && ( +
+

직무 적합도

+

채용공고(JD) 요구 대비 적합도·갭

+ + {jobFit.detail && ( +

+ +

+ )} + {(jobFit.strength || jobFit.weakness) && ( +
+ {jobFit.strength && 충족 · {jobFit.strength}} + {jobFit.weakness && 갭 · {jobFit.weakness}} +
+ )} + {jobFit.scoreRationale && ( +

점수 근거 · {jobFit.scoreRationale}

+ )} +
+ )} - {feedback.improvementKeywords && feedback.improvementKeywords.length > 0 && ( -
-

다음에 채울 키워드

-
- {feedback.improvementKeywords.map((kw) => ( - - {kw} - - ))} -
-
- )} + {roleUnderstanding && ( +
+

직무 이해도

+

직무에 대한 이해·지원동기

+ + {roleUnderstanding.detail && ( +

+ +

+ )} + {(roleUnderstanding.strength || roleUnderstanding.weakness) && ( +
+ {roleUnderstanding.strength && 강점 · {roleUnderstanding.strength}} + {roleUnderstanding.weakness && 보완 · {roleUnderstanding.weakness}} +
+ )} + {roleUnderstanding.scoreRationale && ( +

+ 점수 근거 · {roleUnderstanding.scoreRationale} +

+ )} +
+ )} - {feedback.studyPlan && feedback.studyPlan.length > 0 && ( -
-

학습 방향

-
    - {feedback.studyPlan.map((step, i) => ( -
  • - - - - -
  • - ))} -
+ {selfIntro && ( +
+

자기소개 첫인상

+

전달력·구성·직무적합성

+ + {selfIntro.detail && ( +

+ +

+ )} + {(selfIntro.strength || selfIntro.weakness) && ( +
+ {selfIntro.strength && 강점 · {selfIntro.strength}} + {selfIntro.weakness && 보완 · {selfIntro.weakness}} +
+ )} + {selfIntro.scoreRationale && ( +

+ 점수 근거 · {selfIntro.scoreRationale} +

+ )} +
+ )}
)} diff --git a/frontend/src/features/history/ui/SessionHistoryList.tsx b/frontend/src/features/history/ui/SessionHistoryList.tsx index 9aaebbce..7f9c0bd7 100644 --- a/frontend/src/features/history/ui/SessionHistoryList.tsx +++ b/frontend/src/features/history/ui/SessionHistoryList.tsx @@ -1,5 +1,5 @@ import { Link } from 'react-router-dom' -import { EmptyState } from '@/shared/ui' +import { EmptyState, ListSkeleton } from '@/shared/ui' import { useSessions } from '../model/useHistory' import { SessionCard } from './SessionCard' @@ -15,7 +15,7 @@ export function SessionHistoryList() { } = useSessions() if (isLoading) { - return

불러오는 중…

+ return } if (isError) { return ( diff --git a/frontend/src/features/interview/model/optimistic.ts b/frontend/src/features/interview/model/optimistic.ts index b102017f..c9a35828 100644 --- a/frontend/src/features/interview/model/optimistic.ts +++ b/frontend/src/features/interview/model/optimistic.ts @@ -27,6 +27,8 @@ export function pendingAnswers( return pending } +// 아직 서버에 반영 전인 낙관적 답변은 '전송 중'(CREATED)으로 표시한다. +// 서버 메시지(COMPLETED)가 도착하면 pendingAnswers 가 이 항목을 소진해 자연 대체된다. export function toOptimisticMessage(answer: OptimisticAnswer): Message { - return { role: 'INTERVIEWEE', content: answer.content, status: 'COMPLETED' } as Message + return { role: 'INTERVIEWEE', content: answer.content, status: 'CREATED' } as Message } diff --git a/frontend/src/features/interview/model/useLiveInterview.ts b/frontend/src/features/interview/model/useLiveInterview.ts index 3cdcaf84..3bd36624 100644 --- a/frontend/src/features/interview/model/useLiveInterview.ts +++ b/frontend/src/features/interview/model/useLiveInterview.ts @@ -29,6 +29,8 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode = const { end } = useSessionLifecycle(sessionId) const [optimistic, setOptimistic] = useState([]) + // 전송 실패로 롤백된 답변 본문 — 컴포저가 입력창을 복원하는 데 사용(nonce 로 매 실패마다 트리거). + const [restoreDraft, setRestoreDraft] = useState<{ content: string; nonce: number } | null>(null) const [connection, setConnection] = useState('connecting') const [deltaBuffer, setDeltaBuffer] = useState>({}) // 라이브 세그먼트 오디오가 지금 재생 중인 메시지(아바타·질문 카드의 '말하는 중' 표시용). @@ -46,6 +48,12 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode = // 소켓 onEvent 클로저에서 최신 모드를 읽기 위한 ref. 텍스트 모드는 음성 자동재생 안 함. const deliveryModeRef = useRef(deliveryMode) deliveryModeRef.current = deliveryMode + + // 텍스트 답변은 WS fire-and-forget 라 ack 가 없다. 제출 후 일정 시간 내 서버 반영이 + // 안 되면 stuck 된 낙관적 답변을 롤백한다. tempId 별 타이머 + 최신 optimistic 미러 ref. + const optimisticRef = useRef([]) + optimisticRef.current = optimistic + const answerTimers = useRef>>(new Map()) if (queueRef.current === null) { queueRef.current = createSegmentQueue(async (url) => { if (typeof Audio === 'undefined') return @@ -164,15 +172,35 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode = }, }) + const ANSWER_ACK_TIMEOUT_MS = 10_000 + const submitAnswer = useCallback( (content: string) => { const tempId = crypto.randomUUID() setOptimistic((prev) => [...prev, { tempId, content }]) socketSubmit(content, tempId) + // ack 타임아웃: 시간 내 서버 반영(낙관적 소진)이 없으면 롤백 + 입력 복원 + 안내. + const timer = setTimeout(() => { + answerTimers.current.delete(tempId) + if (!optimisticRef.current.some((o) => o.tempId === tempId)) return // 이미 반영됨 + setOptimistic((prev) => prev.filter((o) => o.tempId !== tempId)) + setRestoreDraft({ content, nonce: Date.now() }) + toast.error('답변 전송에 실패했어요. 입력을 복원했으니 다시 시도해 주세요.') + }, ANSWER_ACK_TIMEOUT_MS) + answerTimers.current.set(tempId, timer) }, [socketSubmit], ) + // 언마운트 시 남은 ack 타이머 정리(언마운트 후 setState/toast 방지). + useEffect(() => { + const timers = answerTimers.current + return () => { + timers.forEach((t) => clearTimeout(t)) + timers.clear() + } + }, []) + // 음성 답변은 REST 업로드(multipart). 성공 시 placeholder 메시지를 // 받으므로 목록을 무효화해 "음성 인식 중…" 버블을 띄운다. // 이후 STT 완료는 callback.voice → SESSION_MESSAGE SSE 로 갱신된다. @@ -216,6 +244,7 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode = turn: currentTurn(items), connection, submitAnswer, + restoreDraft, submitVoice, voiceUploading: voiceMutation.isPending, voiceError: voiceMutation.isError, diff --git a/frontend/src/features/interview/ui/live/AnswerComposer.tsx b/frontend/src/features/interview/ui/live/AnswerComposer.tsx index 345f9f23..53251cc2 100644 --- a/frontend/src/features/interview/ui/live/AnswerComposer.tsx +++ b/frontend/src/features/interview/ui/live/AnswerComposer.tsx @@ -34,16 +34,26 @@ export function AnswerComposer({ disabled = false, submitLocked = false, onSubmit, + restoreDraft, onSubmitVoice, voiceUploading = false, }: { disabled?: boolean submitLocked?: boolean onSubmit: (content: string) => void + restoreDraft?: { content: string; nonce: number } | null onSubmitVoice?: (audio: Blob) => void voiceUploading?: boolean }) { const [value, setValue] = useState('') + + // 전송 실패로 롤백된 답변을 입력창에 복원(입력 중인 새 내용은 덮어쓰지 않음). + // 프롭 변화에 따른 상태 보정은 effect 대신 렌더 중 직접 처리(React 권장 패턴). + const [restoredNonce, setRestoredNonce] = useState(undefined) + if (restoreDraft && restoreDraft.nonce !== restoredNonce) { + setRestoredNonce(restoreDraft.nonce) + if (value.trim().length === 0 && restoreDraft.content) setValue(restoreDraft.content) + } const { status: recStatus, stream, start, stop, cancel } = useVoiceRecorder() const recording = recStatus === 'recording' || recStatus === 'requesting' const voiceSupported = recStatus !== 'unsupported' && Boolean(onSubmitVoice) diff --git a/frontend/src/features/interview/ui/live/DeliveryModeToggle.tsx b/frontend/src/features/interview/ui/live/DeliveryModeToggle.tsx index d4b3c80e..7271c135 100644 --- a/frontend/src/features/interview/ui/live/DeliveryModeToggle.tsx +++ b/frontend/src/features/interview/ui/live/DeliveryModeToggle.tsx @@ -47,7 +47,7 @@ export function DeliveryModeToggle({ aria-checked={active} onClick={() => onChange(v)} className={[ - 'inline-flex items-center gap-1.5 rounded-pill px-2.5 py-1 text-caption font-medium transition-colors', + 'inline-flex min-h-11 items-center gap-1.5 rounded-pill px-3 py-1 text-caption font-medium transition-colors', active ? 'bg-sage-800 text-white shadow-sm' : 'text-fg-muted hover:text-fg', diff --git a/frontend/src/features/interview/ui/live/InterviewStage.tsx b/frontend/src/features/interview/ui/live/InterviewStage.tsx index a3af5566..9eba0fdf 100644 --- a/frontend/src/features/interview/ui/live/InterviewStage.tsx +++ b/frontend/src/features/interview/ui/live/InterviewStage.tsx @@ -53,6 +53,7 @@ export function InterviewStage({ awaitingQuestion, questionStreaming, onSubmit, + restoreDraft, onSubmitVoice, voiceUploading, onEnd, @@ -67,6 +68,7 @@ export function InterviewStage({ awaitingQuestion: boolean questionStreaming: boolean onSubmit: (content: string) => void + restoreDraft?: { content: string; nonce: number } | null onSubmitVoice: (audio: Blob) => void voiceUploading: boolean onEnd: () => void @@ -158,6 +160,7 @@ export function InterviewStage({ disabled={awaitingQuestion || connection !== 'open'} submitLocked={questionStreaming} onSubmit={onSubmit} + restoreDraft={restoreDraft} onSubmitVoice={onSubmitVoice} voiceUploading={voiceUploading} /> diff --git a/frontend/src/features/interview/ui/live/LiveInterview.tsx b/frontend/src/features/interview/ui/live/LiveInterview.tsx index c17fe883..37dd03cb 100644 --- a/frontend/src/features/interview/ui/live/LiveInterview.tsx +++ b/frontend/src/features/interview/ui/live/LiveInterview.tsx @@ -16,6 +16,7 @@ export function LiveInterview({ sessionId }: { sessionId: number }) { turn, connection, submitAnswer, + restoreDraft, submitVoice, voiceUploading, endSession, @@ -53,6 +54,7 @@ export function LiveInterview({ sessionId }: { sessionId: number }) { awaitingQuestion={awaitingQuestion} questionStreaming={questionStreaming} onSubmit={submitAnswer} + restoreDraft={restoreDraft} onSubmitVoice={submitVoice} voiceUploading={voiceUploading} onEnd={endSession} diff --git a/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx b/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx index 1c0d9b8d..90557a98 100644 --- a/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx +++ b/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx @@ -41,6 +41,8 @@ export function InterviewSetupForm({ jobCategories.length > 0 && maxQuestions >= 2 && maxQuestions <= 30 && + // 총 질문 상한은 최소한 일반질문 수 이상이어야 모순이 없다(상한 < 일반질문 수 차단). + maxQuestions >= generalQuestionCount && // 직무 맞춤 면접은 채용공고(JD)가 필수. (!isJobTailored || jobDescription.trim().length > 0) @@ -158,7 +160,7 @@ export function InterviewSetupForm({
질문당 꼬리질문 - 깊이 + 한 주제를 얼마나 파고들지 총 질문 상한 - 안전장치 + 꼬리질문 포함 전체 질문 최대 개수
+ {maxQuestions < generalQuestionCount && ( +

+ 총 질문 상한({maxQuestions})이 일반질문 수({generalQuestionCount})보다 작아요. 상한을 일반질문 수 이상으로 올려주세요. +

+ )} {isJobTailored && jobDescription.trim().length === 0 && (

채용공고(JD)를 입력해야 직무 맞춤 면접을 생성할 수 있어요. diff --git a/frontend/src/features/repo/ui/RepoList.tsx b/frontend/src/features/repo/ui/RepoList.tsx index 0467f529..102f7e20 100644 --- a/frontend/src/features/repo/ui/RepoList.tsx +++ b/frontend/src/features/repo/ui/RepoList.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { isApiError } from '@/shared/api' import { useAnalysisProgress } from '@/shared/hooks' -import { ConfirmDialog, EmptyState, StatusBadge, type StatusTone } from '@/shared/ui' +import { ConfirmDialog, EmptyState, ListSkeleton, StatusBadge, type StatusTone } from '@/shared/ui' import { useDeleteRepository, useRegisteredRepositories, @@ -23,7 +23,7 @@ export function RepoList() { const remove = useDeleteRepository() if (isPending) { - return

레포지토리를 불러오는 중…

+ return } if (isError) { return ( diff --git a/frontend/src/features/resume/ui/ResumeList.tsx b/frontend/src/features/resume/ui/ResumeList.tsx index 98476af3..88a38a65 100644 --- a/frontend/src/features/resume/ui/ResumeList.tsx +++ b/frontend/src/features/resume/ui/ResumeList.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { isApiError } from '@/shared/api' import { useAnalysisProgress } from '@/shared/hooks' -import { ConfirmDialog, EmptyState, StatusBadge, type StatusTone } from '@/shared/ui' +import { ConfirmDialog, EmptyState, ListSkeleton, StatusBadge, type StatusTone } from '@/shared/ui' import { useDeleteResume, useResumes } from '../model/useResumes' import { formatFileSize } from '../lib/format' import type { Resume, ResumeStatus } from '../model/types' @@ -18,7 +18,7 @@ export function ResumeList() { const remove = useDeleteResume() if (isPending) { - return

이력서를 불러오는 중…

+ return } if (isError) { return ( diff --git a/frontend/src/pages/InterviewSetup/ui/InterviewSetupPage.tsx b/frontend/src/pages/InterviewSetup/ui/InterviewSetupPage.tsx index 083164a7..f6c8b16c 100644 --- a/frontend/src/pages/InterviewSetup/ui/InterviewSetupPage.tsx +++ b/frontend/src/pages/InterviewSetup/ui/InterviewSetupPage.tsx @@ -1,4 +1,4 @@ -import { useNavigate } from 'react-router-dom' +import { Link, useNavigate } from 'react-router-dom' import { SiteNav } from '@/widgets/site-nav' import { SiteFooter } from '@/widgets/site-footer' import { useDocuments } from '@/features/analysis' @@ -24,7 +24,13 @@ export default function InterviewSetupPage() {
-

새 모의 면접

+ + 워크스페이스로 + +

새 모의 면접

모드와 직군을 선택하면 AI가 맞춤 질문을 생성합니다.

diff --git a/frontend/src/shared/ui/ListSkeleton/ListSkeleton.tsx b/frontend/src/shared/ui/ListSkeleton/ListSkeleton.tsx new file mode 100644 index 00000000..032be741 --- /dev/null +++ b/frontend/src/shared/ui/ListSkeleton/ListSkeleton.tsx @@ -0,0 +1,25 @@ +// 목록 로딩 자리표시 — 고정 높이 카드 n개로 레이아웃 시프트(CLS)를 줄인다. +// 한 줄짜리 '불러오는 중…' 텍스트 대신 실제 목록과 비슷한 골격을 먼저 깔아 둔다. +export function ListSkeleton({ + count = 3, + label = '불러오는 중…', + className = '', +}: { + count?: number + label?: string + className?: string +}) { + return ( +
+ {label} +
    + {Array.from({ length: count }).map((_, i) => ( +
  • + ))} +
+
+ ) +} diff --git a/frontend/src/shared/ui/ListSkeleton/index.ts b/frontend/src/shared/ui/ListSkeleton/index.ts new file mode 100644 index 00000000..8b42f805 --- /dev/null +++ b/frontend/src/shared/ui/ListSkeleton/index.ts @@ -0,0 +1 @@ +export { ListSkeleton } from './ListSkeleton' diff --git a/frontend/src/shared/ui/Stepper/Stepper.tsx b/frontend/src/shared/ui/Stepper/Stepper.tsx index 342e7195..d1a98005 100644 --- a/frontend/src/shared/ui/Stepper/Stepper.tsx +++ b/frontend/src/shared/ui/Stepper/Stepper.tsx @@ -18,6 +18,7 @@ export function Stepper({ value, onChange, min = 0, max = 100, step = 1, ariaLab size="sm" type="button" aria-label="감소" + className="min-h-11 min-w-11" disabled={value <= min} onClick={() => onChange(clamp(value - step))} > @@ -29,6 +30,7 @@ export function Stepper({ value, onChange, min = 0, max = 100, step = 1, ariaLab size="sm" type="button" aria-label="증가" + className="min-h-11 min-w-11" disabled={value >= max} onClick={() => onChange(clamp(value + step))} > diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts index 4364ab73..590d29f0 100644 --- a/frontend/src/shared/ui/index.ts +++ b/frontend/src/shared/ui/index.ts @@ -8,3 +8,4 @@ export { ToastViewport, toast, dismissToast } from './Toast' export type { ToastTone, ToastItem } from './Toast' export { EmptyState } from './EmptyState' export type { EmptyStateProps } from './EmptyState' +export { ListSkeleton } from './ListSkeleton'