Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions frontend/src/app/styles/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@
--color-fg-strong: var(--color-sage-800);
/* 본문 위 보조 텍스트가 밝은 표면에서 충분히 읽히도록 한 단계씩 진하게 (WCAG AA). */
--color-fg-muted: var(--color-sage-500);
--color-fg-subtle: var(--color-sage-400);
/* sage-400(#6e7f9f)는 white 4.04:1 로 AA(4.5:1) 미달 — 흐린 텍스트 전용으로 한 톤 진하게.
* #586a8e: white 5.43:1 / surface 4.80:1, fg-muted(#4a5a7e)보다 밝아 위계 유지. */
--color-fg-subtle: #586a8e;
--color-fg-disabled: var(--color-sage-200);
--color-fg-on-primary: var(--color-white);

Expand All @@ -68,7 +70,7 @@

--color-warning-50: #f4e8d4;
--color-warning-500: #b88840;
--color-warning-700: #8a6529;
--color-warning-700: #7e5a22;
--color-warning: var(--color-warning-500);

--color-danger-50: #f4e0d8;
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/domain/rag/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// 분석 문서(RAG 소스)의 출처 유형 — 이력서/레포/웹/자소서. features 간 공유(라벨 일관성).
export type DocumentSourceType = 'RESUME' | 'REPOSITORY' | 'WEB' | 'COVER_LETTER'

export const DOCUMENT_SOURCE_LABEL: Record<DocumentSourceType, string> = {
RESUME: '이력서',
REPOSITORY: '레포지토리',
WEB: '웹',
COVER_LETTER: '자소서',
}

// 임의 문자열(서버 ENUM)도 안전하게 한국어 라벨로. 미매핑이면 원문 반환.
export function documentSourceLabel(source: string): string {
return DOCUMENT_SOURCE_LABEL[source as DocumentSourceType] ?? source
}
8 changes: 1 addition & 7 deletions frontend/src/features/analysis/ui/DocumentList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { isApiError } from '@/shared/api'
import { EmptyState, 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'
import type {
Expand All @@ -16,13 +17,6 @@ const STATUS_META: Record<AnalysisStatus, { tone: StatusTone; label: string }> =
FAILED: { tone: 'danger', label: '분석 실패' },
}

const SOURCE_LABEL: Record<AnalysisSourceType, string> = {
RESUME: '이력서',
REPOSITORY: '레포지토리',
WEB: '웹',
COVER_LETTER: '자소서',
}

const TECH_PREVIEW_COUNT = 4

type Props = {
Expand Down
68 changes: 44 additions & 24 deletions frontend/src/features/cover-letter/ui/CoverLetterForm.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,40 @@
import { useState } from 'react'
import { Spinner } from '@/shared/ui/Spinner'
import { Button } from '@/shared/ui/Button'
import { useCreateCoverLetter } from '../model/useCoverLetters'

const TITLE_MAX = 200
const ANSWER_MAX = 5000

type DraftItem = { question: string; answer: string }
type DraftItem = { id: string; question: string; answer: string }

const emptyItem = (): DraftItem => ({ question: '', answer: '' })
const emptyItem = (): DraftItem => ({ id: crypto.randomUUID(), question: '', answer: '' })

const FIELD_FOCUS =
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--color-primary)] focus-visible:border-primary'

// 공채 자소서 문항별 입력 — 질문(문항) + 답변을 여러 개 추가. 텍스트 전용.
export function CoverLetterForm() {
const [title, setTitle] = useState('')
const [items, setItems] = useState<DraftItem[]>([emptyItem()])
const create = useCreateCoverLetter()

const updateItem = (idx: number, patch: Partial<DraftItem>) =>
setItems((prev) => prev.map((it, i) => (i === idx ? { ...it, ...patch } : it)))
const updateItem = (id: string, patch: Partial<DraftItem>) =>
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, ...patch } : it)))
const addItem = () => setItems((prev) => [...prev, emptyItem()])
const removeItem = (idx: number) =>
setItems((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== idx)))
const removeItem = (id: string) =>
setItems((prev) => (prev.length <= 1 ? prev : prev.filter((it) => it.id !== id)))

// 답변이 하나라도 채워져 있어야 제출 가능.
const hasAnswer = items.some((it) => it.answer.trim().length > 0)
// 질문은 적었는데 답변이 빈 문항 — 무음 drop 대신 경고 + 제출 차단.
const isIncomplete = (it: DraftItem) =>
it.question.trim().length > 0 && it.answer.trim().length === 0
const hasIncomplete = items.some(isIncomplete)
const submitting = create.isPending
const canSubmit = hasAnswer && !hasIncomplete && !submitting

const handleSubmit = () => {
if (!hasAnswer || submitting) return
if (!canSubmit) return
const payloadItems = items
.filter((it) => it.answer.trim().length > 0)
.map((it) => ({ question: it.question.trim(), answer: it.answer.trim() }))
Expand Down Expand Up @@ -54,19 +62,23 @@ export function CoverLetterForm() {
maxLength={TITLE_MAX}
placeholder="예: OO기업 2026 상반기 공채 자소서"
onChange={(e) => setTitle(e.target.value)}
className="rounded-lg border border-border bg-surface px-3 py-2 text-body text-fg outline-none focus:border-primary"
className={`rounded-lg border border-border bg-surface px-3 py-2 text-body text-fg ${FIELD_FOCUS}`}
/>
</div>

<div className="flex flex-col gap-4">
{items.map((item, idx) => (
<div key={idx} className="flex flex-col gap-2 rounded-xl border border-border bg-surface p-4">
<div
key={item.id}
className="flex flex-col gap-2 rounded-xl border border-border bg-surface p-4"
>
<div className="flex items-center justify-between gap-2">
<span className="text-caption font-semibold text-fg-subtle">문항 {idx + 1}</span>
{items.length > 1 ? (
<button
type="button"
onClick={() => removeItem(idx)}
onClick={() => removeItem(item.id)}
aria-label={`문항 ${idx + 1} 삭제`}
className="rounded-md px-2 py-0.5 text-caption text-fg-subtle transition-colors hover:bg-surface-raised hover:text-danger-700"
>
삭제
Expand All @@ -76,21 +88,30 @@ export function CoverLetterForm() {
<input
type="text"
value={item.question}
aria-label={`문항 ${idx + 1} 질문`}
placeholder="문항 (예: 지원 동기와 입사 후 포부를 기술해 주세요)"
onChange={(e) => updateItem(idx, { question: e.target.value })}
className="rounded-lg border border-border bg-surface-raised px-3 py-2 text-body text-fg outline-none focus:border-primary"
onChange={(e) => updateItem(item.id, { question: e.target.value })}
className={`rounded-lg border border-border bg-surface-raised px-3 py-2 text-body text-fg ${FIELD_FOCUS}`}
/>
<textarea
value={item.answer}
rows={5}
maxLength={ANSWER_MAX}
aria-label={`문항 ${idx + 1} 답변`}
placeholder="답변을 입력하세요"
onChange={(e) => updateItem(idx, { answer: e.target.value })}
className="resize-y rounded-lg border border-border bg-surface-raised px-3 py-2 text-body text-fg outline-none focus:border-primary"
onChange={(e) => updateItem(item.id, { answer: e.target.value })}
className={`resize-y rounded-lg border border-border bg-surface-raised px-3 py-2 text-body text-fg ${FIELD_FOCUS}`}
/>
<span className="self-end text-caption text-fg-muted">
{item.answer.length} / {ANSWER_MAX}
</span>
<div className="flex items-center justify-between gap-2">
{isIncomplete(item) ? (
<span className="text-caption text-danger-700">답변을 입력하거나 문항을 비워 주세요.</span>
) : (
<span />
)}
<span className="text-caption text-fg-muted">
{item.answer.length} / {ANSWER_MAX}
</span>
</div>
</div>
))}
</div>
Expand All @@ -103,16 +124,15 @@ export function CoverLetterForm() {
>
+ 문항 추가
</button>
<button
<Button
type="button"
variant="primary"
onClick={handleSubmit}
disabled={!hasAnswer || submitting}
aria-busy={submitting}
className="inline-flex items-center gap-2 rounded-lg bg-primary px-5 py-2 text-button text-fg-on-primary transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
disabled={!canSubmit}
loading={submitting}
>
{submitting ? <Spinner /> : null}
{submitting ? '저장 중…' : '자소서 저장'}
</button>
</Button>
</div>
</div>
)
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/features/feedback/model/useFeedback.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useMutation, useQuery } from '@tanstack/react-query'
import { isApiError } from '@/shared/api'
import { toast } from '@/shared/ui'
import { enableShare, getFeedback, getSharedFeedback } from '../api/feedbackApi'

export const feedbackKeys = {
Expand All @@ -24,7 +25,10 @@ export function useFeedback(sessionId: number) {

// 공유 토큰 발급(버튼 클릭).
export function useShareFeedback(sessionId: number) {
return useMutation({ mutationFn: () => enableShare(sessionId) })
return useMutation({
mutationFn: () => enableShare(sessionId),
onError: () => toast.error('공유 링크 발급에 실패했어요. 다시 시도해 주세요.'),
})
}

// 공개 페이지: 공유 토큰으로 피드백 조회(비인증, 재시도 없음).
Expand Down
12 changes: 10 additions & 2 deletions frontend/src/features/feedback/ui/FeedbackReport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useRef, useState } from 'react'
import { StatusBadge } from '@/shared/ui/StatusBadge'
import { ScoreBar } from '@/shared/ui/ScoreBar'
import { Button } from '@/shared/ui/Button'
import { toast } from '@/shared/ui'
import { useCopyToClipboard } from '@/shared/hooks'
import type { Feedback } from '../api/feedbackApi'
import { downloadElementAsPdf } from '../lib/downloadPdf'
Expand Down Expand Up @@ -29,6 +30,8 @@ export function FeedbackReport({
setDownloading(true)
try {
await downloadElementAsPdf(reportRef.current, '면접피드백.pdf')
} catch {
toast.error('PDF 생성에 실패했어요. 다시 시도해 주세요.')
} finally {
setDownloading(false)
}
Expand All @@ -37,8 +40,13 @@ export function FeedbackReport({
const share = useShareFeedback(feedback.sessionId ?? 0)
const { copy, copied } = useCopyToClipboard()
const handleShare = async () => {
const token = await share.mutateAsync()
if (token) await copy(`${window.location.origin}/share/${token}`)
// 실패 토스트는 useShareFeedback.onError 가 띄운다 — 여기선 unhandled rejection 만 방지.
try {
const token = await share.mutateAsync()
if (token) await copy(`${window.location.origin}/share/${token}`)
} catch {
/* handled by mutation onError */
}
}

const overall = feedback.overallScore
Expand Down
3 changes: 1 addition & 2 deletions frontend/src/features/history/ui/ScoreTrend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,7 @@ export function ScoreTrend({ stats }: { stats: UserStats }) {
<span className="text-caption text-fg-muted">지표별 점수 추이 (최근 {n}회)</span>
<svg
viewBox={`0 0 ${W} ${H}`}
className="h-56 w-full"
preserveAspectRatio="none"
className="aspect-[24/11] w-full"
role="img"
aria-label={`지표별 점수 추이, 최근 ${n}회. ${series
.map((s) => `${s.label} ${s.latest ?? '미산정'}`)
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/features/interview/model/useLiveInterview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { currentTurn } from '@/domain/session'
import type { Message } from '@/domain/session'
import { toast } from '@/shared/ui'
import { submitVoiceAnswer, fetchMessageSegmentObjectUrl } from '../api/messageApi'
import { createSegmentQueue } from '../lib/media/segmentAudioQueue'
import { sessionKeys, useSession } from './useSession'
Expand Down Expand Up @@ -179,6 +180,8 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode =
mutationFn: (audio: Blob) => submitVoiceAnswer(sessionId, audio, crypto.randomUUID()),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: messageKeys.list(sessionId) }),
onError: () =>
toast.error('음성 답변 업로드에 실패했어요. 다시 시도해 주세요.'),
})

const submitVoice = useCallback(
Expand Down
17 changes: 14 additions & 3 deletions frontend/src/features/interview/ui/setup/ContextDocumentPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { Link } from 'react-router-dom'
import { documentSourceLabel } from '@/domain/rag'

export type DocOption = { id: number; label: string; sourceType: string }

export function ContextDocumentPicker({
Expand All @@ -12,7 +15,13 @@ export function ContextDocumentPicker({
if (documents.length === 0) {
return (
<p className="text-caption text-fg-muted">
분석 완료된 이력서·레포지토리가 없습니다. 워크스페이스에서 먼저 분석하세요.
분석 완료된 이력서·자소서·레포지토리가 없습니다.{' '}
<Link
to="/workspace/resumes"
className="font-semibold text-primary underline-offset-2 hover:underline"
>
자료 준비하기 →
</Link>
</p>
)
}
Expand All @@ -30,8 +39,10 @@ export function ContextDocumentPicker({
checked={selected.includes(doc.id)}
onChange={() => onToggle(doc.id)}
/>
<span className="text-button text-fg">{doc.label}</span>
<span className="text-caption text-fg-muted">{doc.sourceType}</span>
<span className="flex-1 text-button text-fg">{doc.label}</span>
<span className="rounded-pill bg-sage-100 px-2 py-0.5 text-caption text-fg-muted">
{documentSourceLabel(doc.sourceType)}
</span>
</label>
))}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function InterviewSetupForm({
onChange={(e) => setTitle(e.target.value)}
maxLength={60}
placeholder="예: 백엔드 기술 면접 2차"
className="rounded-md border border-border bg-surface-raised px-3 py-2 text-body text-fg placeholder:text-fg-muted focus:border-border-strong focus:outline-none"
className="rounded-md border border-border bg-surface-raised px-3 py-2 text-body text-fg placeholder:text-fg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--color-primary)] focus-visible:border-primary"
/>
</section>
<section className="flex flex-col gap-2">
Expand Down Expand Up @@ -114,7 +114,7 @@ export function InterviewSetupForm({
onChange={(e) => setCompanyName(e.target.value)}
maxLength={200}
placeholder="예: 토스, 우아한형제들"
className="rounded-md border border-border bg-surface-raised px-3 py-2 text-body text-fg placeholder:text-fg-muted focus:border-border-strong focus:outline-none"
className="rounded-md border border-border bg-surface-raised px-3 py-2 text-body text-fg placeholder:text-fg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--color-primary)] focus-visible:border-primary"
/>
</div>
<div className="flex flex-col gap-1.5">
Expand All @@ -128,7 +128,7 @@ export function InterviewSetupForm({
maxLength={20000}
rows={8}
placeholder="채용공고의 자격요건·우대사항·주요업무를 붙여넣어 주세요. 이 내용으로 적합도·지원동기 질문과 직무 적합도 피드백이 생성됩니다."
className="resize-y rounded-md border border-border bg-surface-raised px-3 py-2 text-body text-fg placeholder:text-fg-muted focus:border-border-strong focus:outline-none"
className="resize-y rounded-md border border-border bg-surface-raised px-3 py-2 text-body text-fg placeholder:text-fg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--color-primary)] focus-visible:border-primary"
/>
</div>
</section>
Expand Down
12 changes: 8 additions & 4 deletions frontend/src/features/interview/ui/setup/ModeSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import { RadioCardGroup } from '@/shared/ui/RadioCardGroup'
import type { SessionMode } from '@/domain/session'

const OPTIONS = [
{ value: 'TECHNICAL' as const, label: '기술 면접' },
{ value: 'PERSONALITY' as const, label: '인성 면접' },
{ value: 'INTEGRATED' as const, label: '종합 면접' },
{ value: 'JOB_TAILORED' as const, label: '직무 맞춤 면접' },
{ value: 'TECHNICAL' as const, label: '기술 면접', description: '실무 기술·CS 위주' },
{ value: 'PERSONALITY' as const, label: '인성 면접', description: '경험·태도·협업' },
{ value: 'INTEGRATED' as const, label: '종합 면접', description: '기술 + 인성 혼합' },
{
value: 'JOB_TAILORED' as const,
label: '직무 맞춤 면접',
description: '채용공고(JD) 기반 적합도·지원동기 (JD 입력 필요)',
},
]

export function ModeSelector({
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/pages/InterviewSetup/ui/InterviewSetupPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SiteFooter } from '@/widgets/site-footer'
import { useDocuments } from '@/features/analysis'
import { InterviewSetupForm, useCreateSession } from '@/features/interview'
import type { DocOption } from '@/features/interview'
import { documentSourceLabel } from '@/domain/rag'

export default function InterviewSetupPage() {
const navigate = useNavigate()
Expand All @@ -14,7 +15,7 @@ export default function InterviewSetupPage() {
.filter((d) => d.analysisStatus === 'ANALYZED')
.map((d) => ({
id: d.id,
label: d.summary?.slice(0, 40) ?? `${d.sourceType} #${d.sourceId}`,
label: d.summary?.slice(0, 40) ?? `${documentSourceLabel(d.sourceType)} #${d.sourceId}`,
sourceType: d.sourceType,
}))

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/shared/ui/TextArea/TextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function TextArea({ value, onChange, className = '', rows = 1, ref, ...re
value={value}
rows={rows}
onChange={(e) => onChange(e.target.value)}
className={`max-h-40 min-h-10 flex-1 resize-none rounded-md border border-border bg-surface-raised px-3 py-2 text-body text-fg placeholder:text-fg-muted focus:border-border-strong focus:outline-none disabled:opacity-50 ${className}`}
className={`max-h-40 min-h-10 flex-1 resize-none rounded-md border border-border bg-surface-raised px-3 py-2 text-body text-fg placeholder:text-fg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--color-primary)] focus-visible:border-primary disabled:opacity-50 ${className}`}
{...rest}
/>
)
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/widgets/home-cta/ui/HomeCta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function HomeCta() {

<Link
to={getStartedTo}
className="mt-10 inline-flex items-center gap-2 pl-5 pr-2 py-2.5 rounded-pill bg-[#dbe2ec] text-sage-900 text-button hover:bg-white transition-colors duration-fast"
className="mt-10 inline-flex items-center gap-2 pl-5 pr-2 py-2.5 rounded-pill bg-sage-100 text-sage-900 text-button hover:bg-white transition-colors duration-fast"
>
Get Started
<span
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/widgets/home-services/ui/HomeServices.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ function CardInner({ card }: { card: ServiceCard }) {
}}
/>

<span className="absolute top-4 left-4 px-3 py-1 rounded-pill bg-[#dbe2ec] text-sage-900 text-caption font-medium">
<span className="absolute top-4 left-4 px-3 py-1 rounded-pill bg-sage-100 text-sage-900 text-caption font-medium">
{card.tag}
</span>

Expand Down
Loading