From f37f9a59f960026a66812b733d13d16f409c4d67 Mon Sep 17 00:00:00 2001 From: jmj Date: Sun, 14 Jun 2026 17:33:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=EB=B9=88=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=ED=91=9C=EC=A4=80=ED=99=94=20=E2=80=94=20EmptyState=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85=20+=20=EB=AA=A9=EB=A1=9D=204=EA=B3=B3=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 빈 목록 처리가 제각각이던 것(ResumeList는 null, 나머지는 인라인 div)을 공용 컴포넌트로 통일. - shared/ui/EmptyState 신설: 점선 카드 + 제목/설명/아이콘/CTA(action) 슬롯 - ResumeList: 비면 null 반환(화면 빈칸) → EmptyState 안내 - RepoList·DocumentList: 인라인 div → EmptyState 로 일관화 - SessionHistoryList: 맨텍스트 → EmptyState + "면접 시작" CTA(/sessions/new) - EmptyState 단위 테스트 3건 Co-Authored-By: Claude Opus 4.8 --- .../src/features/analysis/ui/DocumentList.tsx | 12 +++---- .../history/ui/SessionHistoryList.tsx | 15 +++++++- frontend/src/features/repo/ui/RepoList.tsx | 14 +++----- .../src/features/resume/ui/ResumeList.tsx | 9 +++-- .../shared/ui/EmptyState/EmptyState.test.tsx | 21 +++++++++++ .../src/shared/ui/EmptyState/EmptyState.tsx | 36 +++++++++++++++++++ frontend/src/shared/ui/EmptyState/index.ts | 2 ++ frontend/src/shared/ui/index.ts | 2 ++ 8 files changed, 92 insertions(+), 19 deletions(-) create mode 100644 frontend/src/shared/ui/EmptyState/EmptyState.test.tsx create mode 100644 frontend/src/shared/ui/EmptyState/EmptyState.tsx create mode 100644 frontend/src/shared/ui/EmptyState/index.ts 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'