From ab1f5d2cda4c78f280cd01e7698371d3a57d3941 Mon Sep 17 00:00:00 2001 From: jmj Date: Sun, 14 Jun 2026 15:40:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=ED=8C=8C=EA=B4=B4=EC=A0=81=20?= =?UTF-8?q?=EC=95=A1=EC=85=98=20=ED=99=95=EC=9D=B8=EC=B0=BD(ConfirmDialog)?= =?UTF-8?q?=20=EB=8F=84=EC=9E=85=20+=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 확인 없이 즉시 실행되던 되돌릴 수 없는 액션에 확인 단계를 추가. - shared/ui/ConfirmDialog 신설: Modal(ESC·포커스 복원·스크롤락) 위에 표준 취소/확인 푸터, danger·loading 지원 (디자인시스템 §8 계획 컴포넌트) - 라이브 면접 "종료": 한 번 오클릭으로 세션이 끝나던 것 → 확인창 - 이력서/레포 "삭제": 단일 클릭 즉시 삭제 → 카드별 확인창(loading 반영) - ConfirmDialog 단위 테스트 4건 Co-Authored-By: Claude Opus 4.8 --- .../interview/ui/live/InterviewStage.tsx | 18 +++++- frontend/src/features/repo/ui/RepoList.tsx | 17 ++++- .../src/features/resume/ui/ResumeList.tsx | 17 ++++- .../ui/ConfirmDialog/ConfirmDialog.test.tsx | 63 +++++++++++++++++++ .../shared/ui/ConfirmDialog/ConfirmDialog.tsx | 53 ++++++++++++++++ frontend/src/shared/ui/ConfirmDialog/index.ts | 2 + frontend/src/shared/ui/index.ts | 2 + 7 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 frontend/src/shared/ui/ConfirmDialog/ConfirmDialog.test.tsx create mode 100644 frontend/src/shared/ui/ConfirmDialog/ConfirmDialog.tsx create mode 100644 frontend/src/shared/ui/ConfirmDialog/index.ts diff --git a/frontend/src/features/interview/ui/live/InterviewStage.tsx b/frontend/src/features/interview/ui/live/InterviewStage.tsx index 2b525f9a..a3af5566 100644 --- a/frontend/src/features/interview/ui/live/InterviewStage.tsx +++ b/frontend/src/features/interview/ui/live/InterviewStage.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { StatusBadge } from '@/shared/ui/StatusBadge' import { Button } from '@/shared/ui/Button' +import { ConfirmDialog } from '@/shared/ui/ConfirmDialog' import { isQuestion, isTranscribing, sessionProgress } from '@/domain/session' import type { Session } from '@/domain/session' import type { ConnectionStatus, ThreadItem } from '../../model/useLiveInterview' @@ -75,6 +76,7 @@ export function InterviewStage({ onDeliveryModeChange: (mode: DeliveryMode) => void }) { const [transcriptOpen, setTranscriptOpen] = useState(false) + const [endConfirmOpen, setEndConfirmOpen] = useState(false) const progress = sessionProgress(session) const currentQuestion = [...items].reverse().find(isQuestion) const lastItem = items[items.length - 1] @@ -116,7 +118,7 @@ export function InterviewStage({ - @@ -169,6 +171,20 @@ export function InterviewStage({ onClose={() => setTranscriptOpen(false)} /> )} + + { + setEndConfirmOpen(false) + onEnd() + }} + onCancel={() => setEndConfirmOpen(false)} + /> ) } diff --git a/frontend/src/features/repo/ui/RepoList.tsx b/frontend/src/features/repo/ui/RepoList.tsx index 86dcd86e..b0dade69 100644 --- a/frontend/src/features/repo/ui/RepoList.tsx +++ b/frontend/src/features/repo/ui/RepoList.tsx @@ -1,6 +1,7 @@ +import { useState } from 'react' import { isApiError } from '@/shared/api' import { useAnalysisProgress } from '@/shared/hooks' -import { StatusBadge, type StatusTone } from '@/shared/ui' +import { ConfirmDialog, StatusBadge, type StatusTone } from '@/shared/ui' import { useDeleteRepository, useRegisteredRepositories, @@ -68,6 +69,7 @@ function RepoCard({ onDelete: () => void }) { const meta = STATUS_META[repo.status] + const [confirmOpen, setConfirmOpen] = useState(false) const progress = useAnalysisProgress('REPOSITORY', repo.id) // 진행 문구는 분석 진행 중일 때만 의미 있다. 완료/실패 시 store 가 clear 되지만 방어적으로 가드. const showProgress = @@ -95,7 +97,7 @@ function RepoCard({ + setConfirmOpen(false)} + /> +
{meta.label} diff --git a/frontend/src/features/resume/ui/ResumeList.tsx b/frontend/src/features/resume/ui/ResumeList.tsx index f20e0599..cde47178 100644 --- a/frontend/src/features/resume/ui/ResumeList.tsx +++ b/frontend/src/features/resume/ui/ResumeList.tsx @@ -1,6 +1,7 @@ +import { useState } from 'react' import { isApiError } from '@/shared/api' import { useAnalysisProgress } from '@/shared/hooks' -import { StatusBadge, type StatusTone } from '@/shared/ui' +import { ConfirmDialog, StatusBadge, type StatusTone } from '@/shared/ui' import { useDeleteResume, useResumes } from '../model/useResumes' import { formatFileSize } from '../lib/format' import type { Resume, ResumeStatus } from '../model/types' @@ -54,6 +55,7 @@ function ResumeCard({ onDelete: () => void }) { const meta = STATUS_META[resume.status] + const [confirmOpen, setConfirmOpen] = useState(false) const progress = useAnalysisProgress('RESUME', resume.id) const showProgress = !!progress && @@ -76,7 +78,7 @@ function ResumeCard({
+ setConfirmOpen(false)} + /> +
{meta.label} diff --git a/frontend/src/shared/ui/ConfirmDialog/ConfirmDialog.test.tsx b/frontend/src/shared/ui/ConfirmDialog/ConfirmDialog.test.tsx new file mode 100644 index 00000000..eeb7c3c9 --- /dev/null +++ b/frontend/src/shared/ui/ConfirmDialog/ConfirmDialog.test.tsx @@ -0,0 +1,63 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ConfirmDialog } from './ConfirmDialog' + +const base = { + title: '삭제하시겠습니까?', + description: '되돌릴 수 없습니다.', + onConfirm: () => {}, + onCancel: () => {}, +} + +describe('ConfirmDialog', () => { + it('open=false면 렌더되지 않는다', () => { + render() + expect(screen.queryByText('삭제하시겠습니까?')).toBeNull() + }) + + it('open이면 제목·설명을 보여준다', () => { + render() + expect(screen.getByText('삭제하시겠습니까?')).toBeTruthy() + expect(screen.getByText('되돌릴 수 없습니다.')).toBeTruthy() + }) + + it('확인/취소 버튼이 각각 콜백을 호출한다', async () => { + const onConfirm = vi.fn() + const onCancel = vi.fn() + render( + , + ) + await userEvent.click(screen.getByRole('button', { name: '삭제' })) + expect(onConfirm).toHaveBeenCalledOnce() + await userEvent.click(screen.getByRole('button', { name: '취소' })) + expect(onCancel).toHaveBeenCalledOnce() + }) + + it('loading이면 취소가 비활성화되고 확인은 막힌다', async () => { + const onConfirm = vi.fn() + render( + , + ) + expect(screen.getByRole('button', { name: '취소' })).toBeDisabled() + // loading 시 Spinner가 접근성 이름에 더해질 수 있어 부분 일치로 찾는다. + const confirm = screen.getByRole('button', { name: /삭제/ }) + expect(confirm).toBeDisabled() + await userEvent.click(confirm) + expect(onConfirm).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/shared/ui/ConfirmDialog/ConfirmDialog.tsx b/frontend/src/shared/ui/ConfirmDialog/ConfirmDialog.tsx new file mode 100644 index 00000000..e35afc6b --- /dev/null +++ b/frontend/src/shared/ui/ConfirmDialog/ConfirmDialog.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from 'react' +import { Modal } from '../Modal' +import { Button } from '../Button' + +export type ConfirmDialogProps = { + open: boolean + title: ReactNode + description?: ReactNode + confirmLabel?: string + cancelLabel?: string + danger?: boolean + loading?: boolean + onConfirm: () => void + onCancel: () => void +} + +// 파괴적/되돌릴 수 없는 액션 전 확인을 받는 공용 다이얼로그. +// Modal(ESC·포커스 복원·스크롤 락 내장) 위에 표준 취소/확인 푸터를 얹는다. +export function ConfirmDialog({ + open, + title, + description, + confirmLabel = '확인', + cancelLabel = '취소', + danger = false, + loading = false, + onConfirm, + onCancel, +}: ConfirmDialogProps) { + return ( + {} : onCancel} + title={title} + footer={ +
+ + +
+ } + > +

{description}

+
+ ) +} diff --git a/frontend/src/shared/ui/ConfirmDialog/index.ts b/frontend/src/shared/ui/ConfirmDialog/index.ts new file mode 100644 index 00000000..ab3f9465 --- /dev/null +++ b/frontend/src/shared/ui/ConfirmDialog/index.ts @@ -0,0 +1,2 @@ +export { ConfirmDialog } from './ConfirmDialog' +export type { ConfirmDialogProps } from './ConfirmDialog' diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts index c8b626a9..3f371790 100644 --- a/frontend/src/shared/ui/index.ts +++ b/frontend/src/shared/ui/index.ts @@ -2,3 +2,5 @@ export { StatusBadge } from './StatusBadge' export type { StatusBadgeProps, StatusTone } from './StatusBadge' export { Modal } from './Modal' export type { ModalProps } from './Modal' +export { ConfirmDialog } from './ConfirmDialog' +export type { ConfirmDialogProps } from './ConfirmDialog'