diff --git a/frontend/src/features/analysis/ui/DocumentList.tsx b/frontend/src/features/analysis/ui/DocumentList.tsx index f23f84b2..0a88c937 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 { Modal, StatusBadge, type StatusTone } from '@/shared/ui' +import { EmptyState, Modal, StatusBadge, type StatusTone } from '@/shared/ui' import type { DocumentFilter } from '../api/analysis' import { useDocuments } from '../model/useDocuments' import type { @@ -52,12 +52,10 @@ export function DocumentList({ filter = {}, sourceType }: Props) { if (docs.length === 0) { const subject = sourceType ? SOURCE_LABEL[sourceType] : '이력서·레포' return ( -
-

아직 분석된 문서가 없습니다.

-

- {subject} 분석이 완료되면 요약과 기술 스택이 여기에 표시됩니다. -

-
+ ) } diff --git a/frontend/src/features/history/ui/SessionHistoryList.tsx b/frontend/src/features/history/ui/SessionHistoryList.tsx index f0fd9458..9aaebbce 100644 --- a/frontend/src/features/history/ui/SessionHistoryList.tsx +++ b/frontend/src/features/history/ui/SessionHistoryList.tsx @@ -1,3 +1,5 @@ +import { Link } from 'react-router-dom' +import { EmptyState } from '@/shared/ui' import { useSessions } from '../model/useHistory' import { SessionCard } from './SessionCard' @@ -29,7 +31,18 @@ export function SessionHistoryList() { const sessions = data?.pages.flatMap((p) => p.content ?? []) ?? [] if (sessions.length === 0) { return ( -

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

+ + 면접 시작 + + } + /> ) } diff --git a/frontend/src/features/repo/ui/RepoList.tsx b/frontend/src/features/repo/ui/RepoList.tsx index b0dade69..0467f529 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, StatusBadge, type StatusTone } from '@/shared/ui' +import { ConfirmDialog, EmptyState, StatusBadge, type StatusTone } from '@/shared/ui' import { useDeleteRepository, useRegisteredRepositories, @@ -34,14 +34,10 @@ export function RepoList() { } if (data.length === 0) { return ( -
-

- 아직 등록된 레포지토리가 없습니다. -

-

- 위에서 GitHub 레포를 가져오면 분석이 자동으로 시작됩니다. -

-
+ ) } diff --git a/frontend/src/features/resume/ui/ResumeList.tsx b/frontend/src/features/resume/ui/ResumeList.tsx index cde47178..98476af3 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, StatusBadge, type StatusTone } from '@/shared/ui' +import { ConfirmDialog, EmptyState, StatusBadge, type StatusTone } from '@/shared/ui' import { useDeleteResume, useResumes } from '../model/useResumes' import { formatFileSize } from '../lib/format' import type { Resume, ResumeStatus } from '../model/types' @@ -28,7 +28,12 @@ export function ResumeList() { ) } if (data.length === 0) { - return null + return ( + + ) } return ( diff --git a/frontend/src/shared/ui/EmptyState/EmptyState.test.tsx b/frontend/src/shared/ui/EmptyState/EmptyState.test.tsx new file mode 100644 index 00000000..183b291e --- /dev/null +++ b/frontend/src/shared/ui/EmptyState/EmptyState.test.tsx @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { EmptyState } from './EmptyState' + +describe('EmptyState', () => { + it('제목과 설명을 보여준다', () => { + render() + expect(screen.getByText('아직 없어요')).toBeTruthy() + expect(screen.getByText('추가해보세요')).toBeTruthy() + }) + + it('description 없으면 제목만 렌더한다', () => { + render() + expect(screen.getByText('비었음')).toBeTruthy() + }) + + it('action 영역(CTA)을 렌더한다', () => { + render(시작} />) + expect(screen.getByRole('button', { name: '시작' })).toBeTruthy() + }) +}) diff --git a/frontend/src/shared/ui/EmptyState/EmptyState.tsx b/frontend/src/shared/ui/EmptyState/EmptyState.tsx new file mode 100644 index 00000000..5330806f --- /dev/null +++ b/frontend/src/shared/ui/EmptyState/EmptyState.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from 'react' + +export type EmptyStateProps = { + title: ReactNode + description?: ReactNode + icon?: ReactNode + // CTA 등 액션 영역. 라우팅/도메인 의존을 피하려 호출부에서 ReactNode 로 주입한다. + action?: ReactNode + className?: string +} + +// 목록/결과가 비었을 때의 표준 안내 카드(점선 테두리). 4-state 패턴의 'empty' 담당. +export function EmptyState({ + title, + description, + icon, + action, + className = '', +}: EmptyStateProps) { + return ( +
+ {icon ? ( +
+ {icon} +
+ ) : null} +

{title}

+ {description ? ( +

{description}

+ ) : null} + {action ?
{action}
: null} +
+ ) +} diff --git a/frontend/src/shared/ui/EmptyState/index.ts b/frontend/src/shared/ui/EmptyState/index.ts new file mode 100644 index 00000000..9d843f54 --- /dev/null +++ b/frontend/src/shared/ui/EmptyState/index.ts @@ -0,0 +1,2 @@ +export { EmptyState } from './EmptyState' +export type { EmptyStateProps } from './EmptyState' diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts index 7920cb07..4364ab73 100644 --- a/frontend/src/shared/ui/index.ts +++ b/frontend/src/shared/ui/index.ts @@ -6,3 +6,5 @@ export { ConfirmDialog } from './ConfirmDialog' export type { ConfirmDialogProps } from './ConfirmDialog' export { ToastViewport, toast, dismissToast } from './Toast' export type { ToastTone, ToastItem } from './Toast' +export { EmptyState } from './EmptyState' +export type { EmptyStateProps } from './EmptyState'