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
9 changes: 9 additions & 0 deletions frontend/src/app/router/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import WorkspacePage from '@/pages/Workspace'
import InterviewSetupPage from '@/pages/InterviewSetup'
import InterviewSessionPage from '@/pages/InterviewSession'
import SessionFeedbackPage from '@/pages/SessionFeedback'
import HistoryPage from '@/pages/History'

export const router = createBrowserRouter([
{ path: '/', element: <HomePage /> },
Expand Down Expand Up @@ -44,6 +45,14 @@ export const router = createBrowserRouter([
</RequireAuth>
),
},
{
path: '/history',
element: (
<RequireAuth>
<HistoryPage />
</RequireAuth>
),
},
{
path: '/design-system/*',
lazy: async () => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/features/feedback/ui/FeedbackReport.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { StatusBadge } from '@/shared/ui/StatusBadge'
import { ScoreBar } from '@/shared/ui/ScoreBar'
import type { Feedback } from '../api/feedbackApi'
import { ScoreBar } from './ScoreBar'

export function FeedbackReport({ feedback }: { feedback: Feedback }) {
const overall = feedback.overallScore
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/features/history/api/historyApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { apiClient } from '@/shared/api'
import type { components } from '@/shared/api/generated'

type S = components['schemas']
export type Session = S['SessionResponse']
export type UserStats = S['UserStatsResponse']

export async function listSessions(): Promise<Session[]> {
return (await apiClient.get<Session[]>('/api/sessions')).data
}

export async function getUserStats(): Promise<UserStats> {
return (await apiClient.get<UserStats>('/api/users/me/stats')).data
}
4 changes: 4 additions & 0 deletions frontend/src/features/history/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { SessionHistoryList } from './ui/SessionHistoryList'
export { StatsSummary } from './ui/StatsSummary'
export { ScoreTrend } from './ui/ScoreTrend'
export { useUserStats } from './model/useHistory'
15 changes: 15 additions & 0 deletions frontend/src/features/history/model/useHistory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query'
import { getUserStats, listSessions } from '../api/historyApi'

export const historyKeys = {
sessions: ['history', 'sessions'] as const,
stats: ['history', 'stats'] as const,
}

export function useSessions() {
return useQuery({ queryKey: historyKeys.sessions, queryFn: listSessions })
}

export function useUserStats() {
return useQuery({ queryKey: historyKeys.stats, queryFn: getUserStats })
}
40 changes: 40 additions & 0 deletions frontend/src/features/history/ui/ScoreTrend.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { UserStats } from '../api/historyApi'

// 최근 종합 점수 추이를 라이브러리 없이 inline 막대로. recent 는 최신순이라 뒤집어 시간순으로.
export function ScoreTrend({ stats }: { stats: UserStats }) {
const points = [...(stats.recent ?? [])]
.reverse()
.filter((r) => typeof r.overall === 'number')

if (points.length === 0) {
return (
<section className="flex flex-col gap-2 rounded-lg border border-border bg-surface-raised p-5">
<span className="text-caption text-fg-muted">점수 추이</span>
<p className="text-body text-fg-muted">아직 채점된 면접이 없어요.</p>
</section>
)
}

return (
<section className="flex flex-col gap-3 rounded-lg border border-border bg-surface-raised p-5">
<span className="text-caption text-fg-muted">종합 점수 추이 (최근 {points.length}회)</span>
<div className="flex h-32 items-end gap-2">
{points.map((r) => {
const score = Math.max(0, Math.min(100, r.overall as number))
return (
<div key={r.sessionId} className="flex flex-1 flex-col items-center gap-1">
<div className="flex w-full flex-1 items-end">
<div
className="w-full rounded-t bg-primary"
style={{ height: `${Math.max(score, 2)}%` }}
title={`${Math.round(score)}점`}
/>
</div>
<span className="text-caption text-fg-muted">{Math.round(score)}</span>
</div>
)
})}
</div>
</section>
)
}
45 changes: 45 additions & 0 deletions frontend/src/features/history/ui/SessionCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Link } from 'react-router-dom'
import { StatusBadge, type StatusTone } from '@/shared/ui'
import { formatDate } from '@/shared/utils'
import type { Session } from '../api/historyApi'

const STATUS: Record<string, { label: string; tone: StatusTone }> = {
READY: { label: '준비', tone: 'neutral' },
IN_PROGRESS: { label: '진행 중', tone: 'info' },
COMPLETED: { label: '완료', tone: 'success' },
INTERRUPTED: { label: '중단됨', tone: 'warning' },
CANCELLED: { label: '취소됨', tone: 'neutral' },
}

const MODE: Record<string, string> = {
TECHNICAL: '기술',
PERSONALITY: '인성',
INTEGRATED: '통합',
}

export function SessionCard({ session }: { session: Session }) {
const status = session.status ? STATUS[session.status] : undefined
const completed = session.status === 'COMPLETED'

const body = (
<div className="flex items-center justify-between gap-4 rounded-lg border border-border bg-surface-raised px-5 py-4 transition-colors hover:bg-surface">
<div className="flex flex-col gap-1">
<span className="text-body text-fg">{session.title || `면접 #${session.id}`}</span>
<span className="text-caption text-fg-muted">
{formatDate(session.createdAt)} · {MODE[session.mode ?? ''] ?? session.mode} ·{' '}
{session.jobCategory} · 질문 {session.totalQuestionCount ?? 0}개
</span>
</div>
<div className="flex items-center gap-3">
{status && <StatusBadge tone={status.tone}>{status.label}</StatusBadge>}
{completed && <span className="text-caption text-fg-muted">리포트 →</span>}
</div>
</div>
)

return completed ? (
<Link to={`/sessions/${session.id}/feedback`}>{body}</Link>
) : (
body
)
}
33 changes: 33 additions & 0 deletions frontend/src/features/history/ui/SessionHistoryList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useSessions } from '../model/useHistory'
import { SessionCard } from './SessionCard'

export function SessionHistoryList() {
const { data, isLoading, isError, refetch } = useSessions()

if (isLoading) {
return <p className="py-8 text-center text-body text-fg-muted">불러오는 중…</p>
}
if (isError) {
return (
<div className="flex flex-col items-center gap-2 py-8">
<p className="text-body text-fg-muted">세션을 불러오지 못했습니다.</p>
<button className="text-caption text-primary underline" onClick={() => refetch()}>
다시 시도
</button>
</div>
)
}
if (!data || data.length === 0) {
return (
<p className="py-8 text-center text-body text-fg-muted">아직 진행한 면접이 없어요.</p>
)
}

return (
<div className="flex flex-col gap-3">
{data.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</div>
)
}
32 changes: 32 additions & 0 deletions frontend/src/features/history/ui/StatsSummary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ScoreBar } from '@/shared/ui/ScoreBar'
import type { UserStats } from '../api/historyApi'

export function StatsSummary({ stats }: { stats: UserStats }) {
const a = stats.averages
return (
<section className="flex flex-col gap-4 rounded-lg border border-border bg-surface-raised p-5">
<div className="flex gap-8">
<Stat label="총 면접" value={stats.totalSessionCount ?? 0} />
<Stat label="완료" value={stats.completedSessionCount ?? 0} />
</div>
{a && (
<div className="flex flex-col gap-3">
<span className="text-caption text-fg-muted">평균 점수</span>
<ScoreBar label="종합" score={a.overall} />
<ScoreBar label="기술 정확도" score={a.technical} />
<ScoreBar label="논리력" score={a.logic} />
<ScoreBar label="전달력" score={a.communication} />
</div>
)}
</section>
)
}

function Stat({ label, value }: { label: string; value: number }) {
return (
<div className="flex flex-col">
<span className="text-h4 text-fg">{value}</span>
<span className="text-caption text-fg-muted">{label}</span>
</div>
)
}
1 change: 1 addition & 0 deletions frontend/src/pages/History/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './ui/HistoryPage'
34 changes: 34 additions & 0 deletions frontend/src/pages/History/ui/HistoryPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { SiteNav } from '@/widgets/site-nav'
import { SiteFooter } from '@/widgets/site-footer'
import {
ScoreTrend,
SessionHistoryList,
StatsSummary,
useUserStats,
} from '@/features/history'

export default function HistoryPage() {
const { data: stats } = useUserStats()

return (
<div className="flex min-h-svh flex-col bg-bg text-fg">
<SiteNav />
<main className="mx-auto flex w-full max-w-content flex-1 flex-col gap-8 px-6 py-12 lg:px-12">
<h1 className="text-h4 text-fg">면접 히스토리</h1>

{stats && (
<div className="grid gap-4 md:grid-cols-2">
<StatsSummary stats={stats} />
<ScoreTrend stats={stats} />
</div>
)}

<section className="flex flex-col gap-3">
<h2 className="text-h6 text-fg">지난 면접</h2>
<SessionHistoryList />
</section>
</main>
<SiteFooter />
</div>
)
}
1 change: 1 addition & 0 deletions frontend/src/shared/ui/ScoreBar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ScoreBar } from './ScoreBar'
10 changes: 10 additions & 0 deletions frontend/src/shared/utils/date.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// ISO 문자열 → 'YYYY.MM.DD'. 없거나 잘못된 값이면 '-'.
export function formatDate(iso?: string | null): string {
if (!iso) return '-'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return '-'
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}.${m}.${day}`
}
1 change: 1 addition & 0 deletions frontend/src/shared/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { formatDate } from './date'
6 changes: 6 additions & 0 deletions frontend/src/widgets/site-nav/ui/SiteNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ export function SiteNav() {
<div className="flex items-center gap-2">
{status === 'authenticated' ? (
<>
<Link
to="/history"
className="hidden sm:inline-flex items-center px-3 py-2 text-button text-fg-strong/80 hover:text-fg-strong transition-colors duration-fast"
>
히스토리
</Link>
<Link
to="/workspace"
className="hidden sm:inline-flex items-center gap-2 px-3 py-2 text-button text-fg-strong/80 hover:text-fg-strong transition-colors duration-fast"
Expand Down