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'