diff --git a/frontend/src/app/router/index.tsx b/frontend/src/app/router/index.tsx index a7008443..0255590d 100644 --- a/frontend/src/app/router/index.tsx +++ b/frontend/src/app/router/index.tsx @@ -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: }, @@ -44,6 +45,14 @@ export const router = createBrowserRouter([ ), }, + { + path: '/history', + element: ( + + + + ), + }, { path: '/design-system/*', lazy: async () => { diff --git a/frontend/src/features/feedback/ui/FeedbackReport.tsx b/frontend/src/features/feedback/ui/FeedbackReport.tsx index 5e4c1d18..51571edb 100644 --- a/frontend/src/features/feedback/ui/FeedbackReport.tsx +++ b/frontend/src/features/feedback/ui/FeedbackReport.tsx @@ -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 diff --git a/frontend/src/features/history/api/historyApi.ts b/frontend/src/features/history/api/historyApi.ts new file mode 100644 index 00000000..754303b5 --- /dev/null +++ b/frontend/src/features/history/api/historyApi.ts @@ -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 { + return (await apiClient.get('/api/sessions')).data +} + +export async function getUserStats(): Promise { + return (await apiClient.get('/api/users/me/stats')).data +} diff --git a/frontend/src/features/history/index.ts b/frontend/src/features/history/index.ts new file mode 100644 index 00000000..afcb9c7d --- /dev/null +++ b/frontend/src/features/history/index.ts @@ -0,0 +1,4 @@ +export { SessionHistoryList } from './ui/SessionHistoryList' +export { StatsSummary } from './ui/StatsSummary' +export { ScoreTrend } from './ui/ScoreTrend' +export { useUserStats } from './model/useHistory' diff --git a/frontend/src/features/history/model/useHistory.ts b/frontend/src/features/history/model/useHistory.ts new file mode 100644 index 00000000..b3a81fc9 --- /dev/null +++ b/frontend/src/features/history/model/useHistory.ts @@ -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 }) +} diff --git a/frontend/src/features/history/ui/ScoreTrend.tsx b/frontend/src/features/history/ui/ScoreTrend.tsx new file mode 100644 index 00000000..1ba5affe --- /dev/null +++ b/frontend/src/features/history/ui/ScoreTrend.tsx @@ -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 ( +
+ 점수 추이 +

아직 채점된 면접이 없어요.

+
+ ) + } + + return ( +
+ 종합 점수 추이 (최근 {points.length}회) +
+ {points.map((r) => { + const score = Math.max(0, Math.min(100, r.overall as number)) + return ( +
+
+
+
+ {Math.round(score)} +
+ ) + })} +
+
+ ) +} diff --git a/frontend/src/features/history/ui/SessionCard.tsx b/frontend/src/features/history/ui/SessionCard.tsx new file mode 100644 index 00000000..e7ba769b --- /dev/null +++ b/frontend/src/features/history/ui/SessionCard.tsx @@ -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 = { + READY: { label: '준비', tone: 'neutral' }, + IN_PROGRESS: { label: '진행 중', tone: 'info' }, + COMPLETED: { label: '완료', tone: 'success' }, + INTERRUPTED: { label: '중단됨', tone: 'warning' }, + CANCELLED: { label: '취소됨', tone: 'neutral' }, +} + +const MODE: Record = { + TECHNICAL: '기술', + PERSONALITY: '인성', + INTEGRATED: '통합', +} + +export function SessionCard({ session }: { session: Session }) { + const status = session.status ? STATUS[session.status] : undefined + const completed = session.status === 'COMPLETED' + + const body = ( +
+
+ {session.title || `면접 #${session.id}`} + + {formatDate(session.createdAt)} · {MODE[session.mode ?? ''] ?? session.mode} ·{' '} + {session.jobCategory} · 질문 {session.totalQuestionCount ?? 0}개 + +
+
+ {status && {status.label}} + {completed && 리포트 →} +
+
+ ) + + return completed ? ( + {body} + ) : ( + body + ) +} diff --git a/frontend/src/features/history/ui/SessionHistoryList.tsx b/frontend/src/features/history/ui/SessionHistoryList.tsx new file mode 100644 index 00000000..ae3ac8ad --- /dev/null +++ b/frontend/src/features/history/ui/SessionHistoryList.tsx @@ -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

불러오는 중…

+ } + if (isError) { + return ( +
+

세션을 불러오지 못했습니다.

+ +
+ ) + } + if (!data || data.length === 0) { + return ( +

아직 진행한 면접이 없어요.

+ ) + } + + return ( +
+ {data.map((s) => ( + + ))} +
+ ) +} diff --git a/frontend/src/features/history/ui/StatsSummary.tsx b/frontend/src/features/history/ui/StatsSummary.tsx new file mode 100644 index 00000000..33eb9fc1 --- /dev/null +++ b/frontend/src/features/history/ui/StatsSummary.tsx @@ -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 ( +
+
+ + +
+ {a && ( +
+ 평균 점수 + + + + +
+ )} +
+ ) +} + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
+ {value} + {label} +
+ ) +} diff --git a/frontend/src/pages/History/index.ts b/frontend/src/pages/History/index.ts index e69de29b..c77a6ed6 100644 --- a/frontend/src/pages/History/index.ts +++ b/frontend/src/pages/History/index.ts @@ -0,0 +1 @@ +export { default } from './ui/HistoryPage' diff --git a/frontend/src/pages/History/ui/HistoryPage.tsx b/frontend/src/pages/History/ui/HistoryPage.tsx new file mode 100644 index 00000000..4a2484cc --- /dev/null +++ b/frontend/src/pages/History/ui/HistoryPage.tsx @@ -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 ( +
+ +
+

면접 히스토리

+ + {stats && ( +
+ + +
+ )} + +
+

지난 면접

+ +
+
+ +
+ ) +} diff --git a/frontend/src/features/feedback/ui/ScoreBar.tsx b/frontend/src/shared/ui/ScoreBar/ScoreBar.tsx similarity index 100% rename from frontend/src/features/feedback/ui/ScoreBar.tsx rename to frontend/src/shared/ui/ScoreBar/ScoreBar.tsx diff --git a/frontend/src/shared/ui/ScoreBar/index.ts b/frontend/src/shared/ui/ScoreBar/index.ts new file mode 100644 index 00000000..20c10836 --- /dev/null +++ b/frontend/src/shared/ui/ScoreBar/index.ts @@ -0,0 +1 @@ +export { ScoreBar } from './ScoreBar' diff --git a/frontend/src/shared/utils/date.ts b/frontend/src/shared/utils/date.ts new file mode 100644 index 00000000..63b85819 --- /dev/null +++ b/frontend/src/shared/utils/date.ts @@ -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}` +} diff --git a/frontend/src/shared/utils/index.ts b/frontend/src/shared/utils/index.ts index e69de29b..6451cb28 100644 --- a/frontend/src/shared/utils/index.ts +++ b/frontend/src/shared/utils/index.ts @@ -0,0 +1 @@ +export { formatDate } from './date' diff --git a/frontend/src/widgets/site-nav/ui/SiteNav.tsx b/frontend/src/widgets/site-nav/ui/SiteNav.tsx index 4e7a357b..37d8c096 100644 --- a/frontend/src/widgets/site-nav/ui/SiteNav.tsx +++ b/frontend/src/widgets/site-nav/ui/SiteNav.tsx @@ -53,6 +53,12 @@ export function SiteNav() {
{status === 'authenticated' ? ( <> + + 히스토리 +