From 36bd42c4a7814477ab461a7808c6662ec07255e2 Mon Sep 17 00:00:00 2001 From: hyejj19 Date: Tue, 21 Jul 2026 18:35:00 +0900 Subject: [PATCH 1/2] refactor: remove legacy curation implementation --- e2e/pages/curation-detail.page.ts | 271 --------- e2e/pages/curation-list.page.ts | 237 -------- e2e/pages/index.ts | 2 - e2e/specs/curations.spec.ts | 530 ------------------ src/config/menu.config.ts | 26 - src/data/mock/curations.mock.ts | 26 - src/hooks/__tests__/useCurationOld.test.ts | 50 -- src/hooks/useCurationOld.ts | 452 --------------- src/pages/Dashboard.tsx | 4 +- src/pages/banners/BannerDetail.tsx | 2 +- .../components/BannerLinkSettingsCard.tsx | 7 +- src/pages/curation-old/CurationDetail.tsx | 323 ----------- src/pages/curation-old/CurationList.tsx | 363 ------------ src/pages/curation-old/curation-old.schema.ts | 21 - .../curation-old/useCurationDetailForm.ts | 136 ----- src/routes/index.tsx | 28 - .../__tests__/curation-old.service.test.ts | 61 -- src/services/curation-old.service.ts | 185 ------ src/test/mocks/data.ts | 75 +-- src/test/mocks/handlers.ts | 81 --- src/types/api/curation-old.api.ts | 383 ------------- src/types/api/index.ts | 1 - 22 files changed, 7 insertions(+), 3257 deletions(-) delete mode 100644 e2e/pages/curation-detail.page.ts delete mode 100644 e2e/pages/curation-list.page.ts delete mode 100644 e2e/specs/curations.spec.ts delete mode 100644 src/data/mock/curations.mock.ts delete mode 100644 src/hooks/__tests__/useCurationOld.test.ts delete mode 100644 src/hooks/useCurationOld.ts delete mode 100644 src/pages/curation-old/CurationDetail.tsx delete mode 100644 src/pages/curation-old/CurationList.tsx delete mode 100644 src/pages/curation-old/curation-old.schema.ts delete mode 100644 src/pages/curation-old/useCurationDetailForm.ts delete mode 100644 src/services/__tests__/curation-old.service.test.ts delete mode 100644 src/services/curation-old.service.ts delete mode 100644 src/types/api/curation-old.api.ts diff --git a/e2e/pages/curation-detail.page.ts b/e2e/pages/curation-detail.page.ts deleted file mode 100644 index 803f9f4..0000000 --- a/e2e/pages/curation-detail.page.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { type Page } from '@playwright/test'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { BasePage } from './base.page'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -/** - * 큐레이션 상세 페이지 Page Object - * - * 실제 UI 구조: - * - 기본 정보 카드 (큐레이션명, 설명, 노출순서, 활성상태) - * - 포함된 위스키 카드 (검색 선택, 위스키 목록) - * - 커버 이미지 카드 - */ -export class CurationDetailPage extends BasePage { - constructor(page: Page) { - super(page); - } - - /* ============================================ - * Locators - * ============================================ */ - - /** 페이지 제목 (등록/수정) */ - readonly pageTitle = () => this.page.getByRole('heading', { level: 1 }); - - /** 뒤로가기 버튼 (아이콘 전용 버튼 - 헤더 영역의 첫 번째 버튼) */ - readonly backButton = () => this.page.locator('main button').first(); - - /** 저장/등록 버튼 */ - readonly saveButton = () => this.page.getByRole('button', { name: /등록|저장/ }); - - /** 삭제 버튼 */ - readonly deleteButton = () => this.page.getByRole('button', { name: '삭제' }); - - /** 로딩 상태 */ - readonly loadingState = () => this.page.getByText('로딩 중...'); - - /* Form Fields */ - - /** 큐레이션명 입력 */ - readonly nameInput = () => this.page.getByPlaceholder('큐레이션명을 입력하세요'); - - /** 설명 입력 */ - readonly descriptionTextarea = () => - this.page.getByPlaceholder('큐레이션에 대한 설명을 입력하세요'); - - /** 노출 순서 입력 */ - readonly displayOrderInput = () => this.page.locator('input#displayOrder'); - - /** 활성화 상태 스위치 */ - readonly isActiveSwitch = () => this.page.locator('button#isActive'); - - /* Cards */ - - /** 기본 정보 카드 */ - readonly basicInfoCard = () => this.page.locator('text=기본 정보').locator('..'); - - /** 포함된 위스키 카드 */ - readonly whiskyCard = () => this.page.locator('text=포함된 위스키').locator('..'); - - /** 커버 이미지 카드 */ - readonly imageCard = () => this.page.locator('text=커버 이미지').locator('..'); - - /* Whisky Section */ - - /** 위스키 검색 입력 */ - readonly whiskySearchInput = () => this.page.getByPlaceholder('위스키 검색하여 추가...'); - - /** 위스키 목록 아이템 */ - readonly whiskyList = () => this.page.locator('ul.space-y-2 li'); - - /** 위스키 없음 상태 */ - readonly whiskyEmptyState = () => this.page.getByText('포함된 위스키가 없습니다.'); - - /** 특정 위스키의 제거 버튼 */ - readonly whiskyRemoveButton = (whiskyName: string) => - this.page.locator('li').filter({ hasText: whiskyName }).getByRole('button'); - - /* Delete Dialog */ - - /** 삭제 확인 다이얼로그 */ - readonly deleteDialog = () => this.page.getByRole('alertdialog'); - - /** 다이얼로그 내 삭제 확인 버튼 */ - readonly confirmDeleteButton = () => this.deleteDialog().getByRole('button', { name: '삭제' }); - - /* ============================================ - * Actions - * ============================================ */ - - /** - * 새 큐레이션 등록 페이지로 이동 - */ - async gotoNew() { - await this.page.goto('/curations/new'); - await this.waitForLoadingComplete(); - } - - /** - * 특정 큐레이션 상세 페이지로 이동 - */ - async gotoDetail(id: number) { - await this.page.goto(`/curations/${id}`); - await this.waitForLoadingComplete(); - } - - /** - * 로딩 완료 대기 - */ - async waitForLoadingComplete() { - await this.loadingState() - .waitFor({ state: 'hidden', timeout: 15000 }) - .catch(() => {}); - } - - /** - * 뒤로가기 - */ - async goBack() { - await this.backButton().click(); - } - - /** - * 저장/등록 버튼 클릭 - */ - async clickSave() { - await this.saveButton().click(); - } - - /** - * 삭제 버튼 클릭 - */ - async clickDelete() { - await this.deleteButton().click(); - } - - /** - * 삭제 확인 다이얼로그에서 삭제 클릭 - */ - async confirmDelete() { - await this.deleteDialog().waitFor({ state: 'visible', timeout: 5000 }); - await this.confirmDeleteButton().click(); - } - - /** - * 기본 정보 폼 입력 (필수/선택 필드) - */ - async fillBasicInfo(data: { name: string; description?: string; displayOrder?: number }) { - await this.nameInput().fill(data.name); - if (data.description) { - await this.descriptionTextarea().fill(data.description); - } - if (data.displayOrder !== undefined) { - await this.displayOrderInput().fill(String(data.displayOrder)); - } - } - - /** - * 큐레이션명만 수정 - */ - async updateName(name: string) { - await this.nameInput().fill(name); - } - - /** - * 활성화 상태 토글 - */ - async toggleActive() { - await this.isActiveSwitch().click(); - } - - /** 이미지 파일 입력 (hidden) */ - readonly imageFileInput = () => this.page.locator('input[type="file"][accept="image/*"]'); - - /** 업로드된 이미지 미리보기 */ - readonly uploadedImage = () => this.page.locator('img[alt="업로드된 이미지"]'); - - /** - * 테스트용 이미지 업로드 - */ - async uploadTestImage() { - const testImagePath = path.resolve(__dirname, '../fixtures/test-image.png'); - await this.imageFileInput().setInputFiles(testImagePath); - // S3 업로드 완료 대기: 업로드된 이미지 미리보기가 표시될 때까지 대기 - await this.uploadedImage().waitFor({ state: 'visible', timeout: 10000 }); - } - - /** - * 커버 이미지가 없으면 테스트 이미지 업로드 - */ - async ensureCoverImage() { - const hasImage = await this.uploadedImage() - .isVisible() - .catch(() => false); - if (!hasImage) { - await this.uploadTestImage(); - } - } - - /** - * 위스키 검색 후 첫 번째 결과 선택 - * @returns 선택 성공 여부 - */ - async addFirstWhiskyBySearch(keyword: string): Promise { - await this.searchWhisky(keyword); - - const firstOption = this.whiskyDropdownList().locator('li button').first(); - const hasOptions = await firstOption.isVisible().catch(() => false); - if (!hasOptions) return false; - - await firstOption.click(); - return true; - } - - /** - * 위스키 검색 (키워드 입력 + debounce 300ms + API 응답 대기) - */ - async searchWhisky(keyword: string) { - await this.whiskySearchInput().fill(keyword); - // 컴포넌트 debounce (300ms) + API 응답 대기 - await this.page - .waitForResponse((resp) => resp.url().includes('/alcohols') && resp.status() === 200, { - timeout: 10000, - }) - .catch(() => {}); - // 드롭다운 렌더링 대기: 목록이 실제로 표시될 때까지 대기 - await this.whiskyDropdownList() - .waitFor({ state: 'visible', timeout: 5000 }) - .catch(() => {}); - } - - /** - * 위스키 검색 드롭다운 목록 (ul > li > button 구조) - */ - readonly whiskyDropdownList = () => this.page.getByTestId('whisky-search-dropdown'); - - /** - * 드롭다운에서 첫 번째 위스키 선택 - */ - async selectFirstWhiskyFromDropdown() { - const firstOption = this.whiskyDropdownList().locator('li button').first(); - await firstOption.click(); - } - - /** - * 드롭다운에서 특정 위스키 선택 (이름으로) - */ - async selectWhiskyFromDropdown(whiskyName: string) { - await this.whiskyDropdownList().locator('li button').filter({ hasText: whiskyName }).click(); - } - - /** - * 특정 위스키 제거 - */ - async removeWhisky(whiskyName: string) { - await this.whiskyRemoveButton(whiskyName).click(); - } - - /** - * 선택된 위스키 개수 확인 - */ - async getWhiskyCount() { - const isEmpty = await this.whiskyEmptyState() - .isVisible() - .catch(() => false); - if (isEmpty) return 0; - return await this.whiskyList().count(); - } -} diff --git a/e2e/pages/curation-list.page.ts b/e2e/pages/curation-list.page.ts deleted file mode 100644 index 1f7f091..0000000 --- a/e2e/pages/curation-list.page.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { type Page } from '@playwright/test'; -import { BasePage } from './base.page'; - -/** - * 큐레이션 목록 페이지 Page Object - * - * 실제 UI 구조: - * - 테이블 형태로 큐레이션 목록 표시 - * - 검색 + 상태 필터 (전체/활성/비활성) - * - 행 클릭 시 상세 페이지로 이동 - * - 인라인 Switch로 활성화 상태 토글 - * - 순서 변경 모드에서 드래그 앤 드롭으로 순서 변경 - */ -export class CurationListPage extends BasePage { - constructor(page: Page) { - super(page); - } - - /* ============================================ - * Locators - * ============================================ */ - - /** 페이지 제목 */ - readonly pageTitle = () => this.page.getByRole('heading', { name: '큐레이션 관리' }); - - /** 검색 입력 필드 */ - readonly searchInput = () => this.page.getByPlaceholder('큐레이션명으로 검색...'); - - /** 검색 버튼 */ - readonly searchButton = () => this.page.getByRole('button', { name: '검색' }); - - /** 큐레이션 등록 버튼 */ - readonly createButton = () => this.page.getByRole('button', { name: '큐레이션 등록' }); - - /** 상태 필터 드롭다운 트리거 */ - readonly statusFilterTrigger = () => - this.page.locator('button[role="combobox"]').filter({ hasText: /전체|활성|비활성/ }); - - /** 테이블 */ - readonly table = () => this.page.locator('table'); - - /** 테이블 행들 (헤더 제외) */ - readonly tableRows = () => this.page.locator('tbody tr'); - - /** 클릭 가능한 테이블 행 (cursor-pointer) */ - readonly clickableRows = () => this.page.locator('tbody tr.cursor-pointer'); - - /** 로딩 상태 */ - readonly loadingState = () => this.page.getByText('로딩 중...'); - - /** 검색 결과 없음 메시지 */ - readonly noResultMessage = () => this.page.getByText('검색 결과가 없습니다.'); - - /** 특정 큐레이션명을 포함한 행 */ - readonly rowWithName = (name: string) => this.page.locator('tbody tr').filter({ hasText: name }); - - /** - * 특정 행의 상태 토글 스위치 - * @param rowLocator - tableRows() 또는 rowWithName()으로 얻은 행 Locator - */ - readonly statusSwitch = (rowLocator: ReturnType) => - rowLocator.locator('button[role="switch"]'); - - /** 순서 변경 버튼 */ - readonly reorderButton = () => this.page.getByRole('button', { name: /순서 변경/ }); - - /** 순서 변경 모드 안내 배너 */ - readonly reorderModeBanner = () => this.page.getByText('순서 변경 모드'); - - /** 드래그 핸들 (순서 변경 모드에서만 표시) */ - readonly dragHandles = () => this.page.locator('tbody tr td.cursor-grab svg'); - - /** 특정 행의 순서 번호 (순서 변경 모드 시 첫 번째 컬럼) */ - readonly orderColumn = (rowLocator: ReturnType) => - rowLocator.locator('td').first(); - - /* ============================================ - * Actions - * ============================================ */ - - /** - * 페이지로 이동 - */ - async goto() { - await this.page.goto('/curations'); - await this.waitForLoadingComplete(); - } - - /** - * 로딩 완료 대기 - */ - async waitForLoadingComplete() { - await this.loadingState() - .waitFor({ state: 'hidden', timeout: 10000 }) - .catch(() => {}); - } - - /** - * 검색 수행 (Enter 키) - */ - async search(keyword: string) { - await this.searchInput().fill(keyword); - // API 응답 대기 준비 - const responsePromise = this.page - .waitForResponse((resp) => resp.url().includes('/curations') && resp.status() === 200, { - timeout: 10000, - }) - .catch(() => {}); - await this.searchInput().press('Enter'); - await responsePromise; - await this.waitForLoadingComplete(); - } - - /** - * 검색 버튼 클릭으로 검색 수행 - */ - async searchWithButton(keyword: string) { - await this.searchInput().fill(keyword); - // API 응답 대기 준비 - const responsePromise = this.page - .waitForResponse((resp) => resp.url().includes('/curations') && resp.status() === 200, { - timeout: 10000, - }) - .catch(() => {}); - await this.searchButton().click(); - await responsePromise; - await this.waitForLoadingComplete(); - } - - /** - * 상태 필터 선택 - * @param status - 전체|활성|비활성 - */ - async selectStatusFilter(status: '전체' | '활성' | '비활성') { - await this.statusFilterTrigger().click(); - // API 응답 대기 준비 - const responsePromise = this.page - .waitForResponse((resp) => resp.url().includes('/curations') && resp.status() === 200, { - timeout: 10000, - }) - .catch(() => {}); - await this.page.getByRole('option', { name: status, exact: true }).click(); - await responsePromise; - await this.waitForLoadingComplete(); - } - - /** - * 큐레이션 등록 버튼 클릭 - */ - async clickCreate() { - await this.createButton().click(); - } - - /** - * 첫 번째 행 클릭 - */ - async clickFirstRow() { - await this.clickableRows().first().click(); - } - - /** - * 특정 큐레이션명 행 클릭 - */ - async clickRowByName(name: string) { - await this.rowWithName(name).click(); - } - - /** - * 테이블 행 개수 반환 - */ - async getRowCount() { - const noResult = await this.noResultMessage() - .isVisible() - .catch(() => false); - if (noResult) return 0; - return await this.clickableRows().count(); - } - - /** - * 특정 행의 상태 토글 - */ - async toggleStatus(rowLocator: ReturnType) { - const switchBtn = this.statusSwitch(rowLocator); - await switchBtn.click(); - } - - /** - * 특정 큐레이션이 목록에 있는지 확인 - */ - async expectCurationExists(name: string) { - await this.rowWithName(name).waitFor({ state: 'visible' }); - } - - /** - * 특정 큐레이션이 목록에 없는지 확인 - */ - async expectCurationNotExists(name: string) { - await this.rowWithName(name).waitFor({ state: 'hidden' }); - } - - /** - * 순서 변경 모드 진입 - */ - async enterReorderMode() { - await this.reorderButton().click(); - await this.reorderModeBanner().waitFor({ state: 'visible', timeout: 5000 }); - await this.waitForLoadingComplete(); - await this.dragHandles() - .first() - .waitFor({ state: 'visible', timeout: 10000 }) - .catch(() => {}); - } - - /** - * 순서 변경 모드 종료 - */ - async exitReorderMode() { - await this.reorderButton().click(); - await this.reorderModeBanner().waitFor({ state: 'hidden', timeout: 5000 }); - } - - /** - * 순서 변경 모드 여부 확인 - */ - async isReorderMode() { - return await this.reorderModeBanner() - .isVisible() - .catch(() => false); - } - - /** - * 드래그 핸들 개수 확인 (순서 변경 모드에서만 표시) - */ - async getDragHandleCount() { - return await this.dragHandles().count(); - } -} diff --git a/e2e/pages/index.ts b/e2e/pages/index.ts index d513e7c..670aa45 100644 --- a/e2e/pages/index.ts +++ b/e2e/pages/index.ts @@ -6,7 +6,5 @@ export { LoginPage } from './login.page'; export { TastingTagListPage } from './tasting-tag-list.page'; export { WhiskyListPage } from './whisky-list.page'; export { WhiskyDetailPage } from './whisky-detail.page'; -export { CurationListPage } from './curation-list.page'; -export { CurationDetailPage } from './curation-detail.page'; export { BannerListPage } from './banner-list.page'; export { BannerDetailPage } from './banner-detail.page'; diff --git a/e2e/specs/curations.spec.ts b/e2e/specs/curations.spec.ts deleted file mode 100644 index 9d27f74..0000000 --- a/e2e/specs/curations.spec.ts +++ /dev/null @@ -1,530 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { CurationListPage } from '../pages/curation-list.page'; -import { CurationDetailPage } from '../pages/curation-detail.page'; - -/** - * 큐레이션 E2E 테스트 - * - * 실제 UI: - * - 테이블 형태로 큐레이션 목록 표시 - * - 검색 + 상태 필터 - * - 행 클릭 → 상세 페이지 - * - 인라인 Switch로 상태 토글 - */ - -test.describe('큐레이션 목록', () => { - let listPage: CurationListPage; - - test.beforeEach(async ({ page }) => { - listPage = new CurationListPage(page); - }); - - test('목록 페이지에 접근할 수 있다', async ({ page }) => { - await listPage.goto(); - - await expect(page).toHaveURL(/.*curations/); - await expect(listPage.pageTitle()).toBeVisible(); - await expect(listPage.table()).toBeVisible(); - }); - - test('검색으로 큐레이션을 필터링할 수 있다', async () => { - await listPage.goto(); - - // 검색 수행 - await listPage.search('추천'); - - // 검색 결과가 있거나 "검색 결과가 없습니다" 메시지가 보여야 함 - const rowCount = await listPage.getRowCount(); - const hasNoResultMessage = await listPage - .noResultMessage() - .isVisible() - .catch(() => false); - - expect(rowCount > 0 || hasNoResultMessage).toBe(true); - }); - - test('상태로 필터링할 수 있다 (활성/비활성)', async ({ page }) => { - await listPage.goto(); - - // 활성 상태 필터 선택 - await listPage.selectStatusFilter('활성'); - - // URL에 isActive 파라미터가 추가되어야 함 - await expect(page).toHaveURL(/isActive=true/); - - // 비활성 상태 필터 선택 - await listPage.selectStatusFilter('비활성'); - await expect(page).toHaveURL(/isActive=false/); - - // 전체로 다시 선택 - await listPage.selectStatusFilter('전체'); - // 전체는 isActive 파라미터 없음 - await expect(page).not.toHaveURL(/isActive=/); - }); - - test('큐레이션을 클릭하면 상세 페이지로 이동한다', async ({ page }) => { - await listPage.goto(); - - const rowCount = await listPage.getRowCount(); - - if (rowCount > 0) { - await listPage.clickFirstRow(); - await expect(page).toHaveURL(/.*curations\/\d+/); - } else { - test.skip(); - } - }); - - test('큐레이션 등록 버튼을 클릭하면 등록 페이지로 이동한다', async ({ page }) => { - await listPage.goto(); - - await listPage.clickCreate(); - - await expect(page).toHaveURL(/.*curations\/new/); - }); - - test('인라인 스위치로 상태를 토글할 수 있다', async () => { - await listPage.goto(); - - const rowCount = await listPage.getRowCount(); - - if (rowCount === 0) { - test.skip(); - return; - } - - // 첫 번째 행의 스위치 찾기 - const firstRow = listPage.clickableRows().first(); - const statusSwitch = listPage.statusSwitch(firstRow); - - // 현재 스위치 상태 확인 - const isCurrentlyChecked = (await statusSwitch.getAttribute('data-state')) === 'checked'; - const expectedNewState = isCurrentlyChecked ? 'unchecked' : 'checked'; - - // 스위치 클릭 - await statusSwitch.click(); - - // 스위치 상태가 변경될 때까지 대기 (polling) - await expect(statusSwitch).toHaveAttribute('data-state', expectedNewState, { timeout: 10000 }); - }); -}); - -test.describe('큐레이션 상세', () => { - let listPage: CurationListPage; - let detailPage: CurationDetailPage; - - test.beforeEach(async ({ page }) => { - listPage = new CurationListPage(page); - detailPage = new CurationDetailPage(page); - }); - - test('상세 페이지에서 큐레이션 정보를 볼 수 있다', async () => { - // 목록에서 첫 번째 큐레이션으로 이동 - await listPage.goto(); - const rowCount = await listPage.getRowCount(); - - if (rowCount === 0) { - test.skip(); - return; - } - - await listPage.clickFirstRow(); - await detailPage.waitForLoadingComplete(); - - // 상세 페이지 요소 확인 - await expect(detailPage.pageTitle()).toContainText('큐레이션 수정'); - await expect(detailPage.nameInput()).toBeVisible(); - await expect(detailPage.descriptionTextarea()).toBeVisible(); - await expect(detailPage.displayOrderInput()).toBeVisible(); - await expect(detailPage.isActiveSwitch()).toBeVisible(); - await expect(detailPage.saveButton()).toBeVisible(); - await expect(detailPage.deleteButton()).toBeVisible(); - }); - - test('뒤로가기 버튼을 클릭하면 목록으로 돌아간다', async ({ page }) => { - await listPage.goto(); - const rowCount = await listPage.getRowCount(); - - if (rowCount === 0) { - test.skip(); - return; - } - - await listPage.clickFirstRow(); - await detailPage.waitForLoadingComplete(); - - // 뒤로가기 버튼 클릭 - await detailPage.goBack(); - - // 목록 페이지로 돌아가야 함 - await expect(page).toHaveURL(/.*\/curations(\?.*)?$/); - }); -}); - -test.describe('큐레이션 폼 리셋', () => { - test('상세 페이지에서 추가 페이지로 이동하면 폼이 초기화된다', async ({ page }) => { - const listPage = new CurationListPage(page); - const detailPage = new CurationDetailPage(page); - - // 1. 목록에서 큐레이션 선택 - await listPage.goto(); - const rowCount = await listPage.getRowCount(); - if (rowCount === 0) { - test.skip(); - return; - } - - await listPage.clickFirstRow(); - await detailPage.waitForLoadingComplete(); - - // 2. 폼 데이터 로딩 대기 (auto-retrying assertion) - await expect(detailPage.nameInput()).not.toHaveValue('', { timeout: 10000 }); - - // 3. 사이드바에서 "큐레이션 추가" 메뉴 클릭 - // 사이드바 메뉴 구조: 큐레이션 관리 > 큐레이션 추가 - await page.getByRole('button', { name: '큐레이션 관리' }).click(); - await page.getByRole('button', { name: '큐레이션 추가' }).click(); - - // 4. URL 확인 - await expect(page).toHaveURL(/.*curations\/new/); - await detailPage.waitForLoadingComplete(); - - // 5. 폼이 비어있어야 함 - await expect(detailPage.nameInput()).toHaveValue('', { timeout: 5000 }); - }); -}); - -test.describe('큐레이션 CRUD 플로우', () => { - const testCurationName = `테스트큐레이션_${Date.now()}`; - const updatedCurationName = `${testCurationName}_수정됨`; - - test('큐레이션 생성 → 수정 → 삭제 전체 플로우', async ({ page }) => { - const listPage = new CurationListPage(page); - const detailPage = new CurationDetailPage(page); - - // 1. 목록 페이지 이동 - await page.goto('/curations'); - await listPage.waitForLoadingComplete(); - - // 2. 큐레이션 등록 버튼 클릭 - await listPage.clickCreate(); - await expect(page).toHaveURL(/.*curations\/new/); - - // 3. 폼 입력 (필수 필드 모두 채우기) - await detailPage.fillBasicInfo({ - name: testCurationName, - description: 'E2E 테스트용 큐레이션입니다.', - displayOrder: 999, - }); - - // 3-1. 위스키 추가 (필수 - 최소 1개, skip 판단을 위해 이미지 업로드보다 먼저) - const added = await detailPage.addFirstWhiskyBySearch('글렌'); - if (!added) { - test.skip(); - return; - } - - // 3-2. 커버 이미지 업로드 (필수) - await detailPage.uploadTestImage(); - - // 4. 등록 버튼 클릭 + API 응답 대기 - await Promise.all([ - page.waitForResponse( - (resp) => resp.url().includes('/curations') && resp.request().method() === 'POST', - { timeout: 10000 } - ), - detailPage.clickSave(), - ]); - - // 5. 저장 성공 확인 (생성 후에는 상세 페이지로 리다이렉트) - await expect(page).toHaveURL(/.*curations\/\d+/, { timeout: 10000 }); - await detailPage.waitForLoadingComplete(); - - // 6. 폼 수정 - await detailPage.updateName(updatedCurationName); - - // 7. 저장 버튼 클릭 + API 응답 대기 - await Promise.all([ - page.waitForResponse( - (resp) => resp.url().includes('/curations/') && resp.request().method() === 'PUT', - { timeout: 10000 } - ), - detailPage.clickSave(), - ]); - - // 8. 삭제 버튼 클릭 - await detailPage.clickDelete(); - - // 9. AlertDialog가 열릴 때까지 대기 - await detailPage.deleteDialog().waitFor({ state: 'visible', timeout: 5000 }); - - // 10. 삭제 확인 버튼 클릭 + API 응답 대기 - await Promise.all([ - page.waitForResponse( - (resp) => resp.url().includes('/curations/') && resp.request().method() === 'DELETE', - { timeout: 10000 } - ), - detailPage.confirmDelete(), - ]); - - // 11. 목록으로 리다이렉트 확인 - await expect(page).toHaveURL(/.*\/curations(\?.*)?$/, { timeout: 10000 }); - }); -}); - -test.describe('큐레이션 순서 변경', () => { - let listPage: CurationListPage; - - test.beforeEach(async ({ page }) => { - listPage = new CurationListPage(page); - }); - - test('순서 변경 버튼을 클릭하면 순서 변경 모드로 진입한다', async () => { - await listPage.goto(); - - const rowCount = await listPage.getRowCount(); - if (rowCount === 0) { - test.skip(); - return; - } - - // 순서 변경 모드 진입 - await listPage.enterReorderMode(); - - // 순서 변경 모드 안내 배너가 표시됨 - await expect(listPage.reorderModeBanner()).toBeVisible(); - - // 버튼 텍스트가 "순서 변경 완료"로 변경됨 - await expect(listPage.reorderButton()).toContainText('순서 변경 완료'); - - // 드래그 핸들이 표시됨 - const handleCount = await listPage.getDragHandleCount(); - expect(handleCount).toBeGreaterThan(0); - }); - - test('순서 변경 모드에서는 행 클릭으로 상세 페이지 이동이 안 된다', async ({ page }) => { - await listPage.goto(); - - const rowCount = await listPage.getRowCount(); - if (rowCount === 0) { - test.skip(); - return; - } - - // 순서 변경 모드 진입 - await listPage.enterReorderMode(); - - // 첫 번째 행 클릭 - await listPage.tableRows().first().click(); - - // URL이 변경되지 않아야 함 (상세 페이지로 이동하지 않음) - await expect(page).toHaveURL(/.*\/curations(\?.*)?$/); - }); - - test('순서 변경 완료 버튼을 클릭하면 일반 모드로 돌아간다', async () => { - await listPage.goto(); - - const rowCount = await listPage.getRowCount(); - if (rowCount === 0) { - test.skip(); - return; - } - - // 순서 변경 모드 진입 - await listPage.enterReorderMode(); - await expect(listPage.reorderModeBanner()).toBeVisible(); - - // 순서 변경 모드 종료 - await listPage.exitReorderMode(); - - // 안내 배너가 사라짐 - await expect(listPage.reorderModeBanner()).not.toBeVisible(); - - // 버튼 텍스트가 "순서 변경"으로 변경됨 - await expect(listPage.reorderButton()).toContainText('순서 변경'); - await expect(listPage.reorderButton()).not.toContainText('완료'); - }); - - test('순서 변경 모드에서 드래그 후 완료하면 bulk reorder API가 호출된다', async ({ page }) => { - await listPage.goto(); - - const rowCount = await listPage.getRowCount(); - if (rowCount < 2) { - test.skip(); - return; - } - - // 순서 변경 모드 진입 - await listPage.enterReorderMode(); - - // 첫 번째와 두 번째 행 가져오기 - const firstRow = listPage.tableRows().first(); - const secondRow = listPage.tableRows().nth(1); - - // 첫 번째 행을 두 번째 행 위치로 드래그 - await firstRow.dragTo(secondRow); - - // 완료 버튼을 눌러 저장하면 bulk reorder API가 호출됨 - const displayOrderPromise = page.waitForResponse( - (resp) => resp.url().includes('/bulk/reorder') && resp.request().method() === 'PATCH', - { timeout: 10000 } - ); - await listPage.reorderButton().click(); - - // API 호출 확인 - const response = await displayOrderPromise; - expect(response.ok()).toBe(true); - await expect(listPage.reorderModeBanner()).not.toBeVisible(); - }); -}); - -test.describe('큐레이션 위스키 관리', () => { - test.describe.configure({ mode: 'serial' }); - - let listPage: CurationListPage; - let detailPage: CurationDetailPage; - - test.beforeEach(async ({ page }) => { - listPage = new CurationListPage(page); - detailPage = new CurationDetailPage(page); - }); - - test('큐레이션에 위스키를 추가하고 저장할 수 있다', async ({ page }) => { - // 기존 큐레이션 상세로 이동 - await listPage.goto(); - const rowCount = await listPage.getRowCount(); - - if (rowCount === 0) { - test.skip(); - return; - } - - await listPage.clickFirstRow(); - await detailPage.waitForLoadingComplete(); - - // 커버 이미지 없으면 업로드 (필수 필드) - await detailPage.ensureCoverImage(); - - // 현재 위스키 개수 확인 - const initialCount = await detailPage.getWhiskyCount(); - - // 위스키 검색 - await detailPage.searchWhisky('글렌'); - - // 드롭다운 아이템 대기 - const dropdownList = detailPage.whiskyDropdownList(); - const firstOption = dropdownList.locator('li button').first(); - - await expect(dropdownList).toBeVisible({ timeout: 10000 }); - const hasOptions = await firstOption.isVisible().catch(() => false); - - if (!hasOptions) { - test.skip(); - return; - } - - // 위스키 선택 (로컬 상태에 추가) - await firstOption.click(); - - // 위스키가 로컬 상태에 추가되었는지 확인 - const afterAddCount = await detailPage.getWhiskyCount(); - expect(afterAddCount).toBeGreaterThan(initialCount); - - // 저장 버튼 클릭 (PUT 요청 대기) - await Promise.all([ - page.waitForResponse( - (resp) => resp.url().includes('/curations/') && resp.request().method() === 'PUT', - { timeout: 10000 } - ), - detailPage.clickSave(), - ]); - - // 저장 성공 후 페이지 새로고침하여 서버 상태 확인 - await page.reload(); - await detailPage.waitForLoadingComplete(); - - // 서버에 저장된 위스키 개수 확인 - const savedCount = await detailPage.getWhiskyCount(); - expect(savedCount).toBeGreaterThan(initialCount); - }); - - test('큐레이션에서 위스키를 제거하고 저장할 수 있다', async ({ page }) => { - // 기존 큐레이션 상세로 이동 - await listPage.goto(); - const rowCount = await listPage.getRowCount(); - - if (rowCount === 0) { - test.skip(); - return; - } - - await listPage.clickFirstRow(); - await detailPage.waitForLoadingComplete(); - - // 커버 이미지 없으면 업로드 (필수 필드) - await detailPage.ensureCoverImage(); - - // 현재 위스키 개수 확인 - let currentCount = await detailPage.getWhiskyCount(); - - // 제거 후에도 최소 1개 위스키가 남도록 사전 데이터를 보강 - let shouldSavePreparedWhiskies = false; - while (currentCount < 2) { - const added = await detailPage.addFirstWhiskyBySearch('글렌'); - - if (!added) { - test.skip(); - return; - } - - const nextCount = await detailPage.getWhiskyCount(); - if (nextCount <= currentCount) { - test.skip(); - return; - } - - currentCount = nextCount; - shouldSavePreparedWhiskies = true; - } - - if (shouldSavePreparedWhiskies) { - await Promise.all([ - page.waitForResponse( - (resp) => resp.url().includes('/curations/') && resp.request().method() === 'PUT', - { timeout: 10000 } - ), - detailPage.clickSave(), - ]); - - await page.reload(); - await detailPage.waitForLoadingComplete(); - currentCount = await detailPage.getWhiskyCount(); - } - - // 첫 번째 위스키의 제거 버튼 클릭 - const firstWhiskyItem = detailPage.whiskyList().first(); - const removeButton = firstWhiskyItem.getByRole('button'); - await removeButton.click(); - - // 로컬 상태에서 제거되었는지 확인 - const afterRemoveCount = await detailPage.getWhiskyCount(); - expect(afterRemoveCount).toBeLessThan(currentCount); - - // 저장 버튼 클릭 (PUT 요청 대기) - await Promise.all([ - page.waitForResponse( - (resp) => resp.url().includes('/curations/') && resp.request().method() === 'PUT', - { timeout: 10000 } - ), - detailPage.clickSave(), - ]); - - // 저장 성공 후 페이지 새로고침하여 서버 상태 확인 - await page.reload(); - await detailPage.waitForLoadingComplete(); - - // 서버에 저장된 위스키 개수 확인 - const savedCount = await detailPage.getWhiskyCount(); - expect(savedCount).toBeLessThan(currentCount); - }); -}); diff --git a/src/config/menu.config.ts b/src/config/menu.config.ts index e350bba..7fd4d4d 100644 --- a/src/config/menu.config.ts +++ b/src/config/menu.config.ts @@ -151,32 +151,6 @@ export const menuConfig: MenuGroup[] = [ }, ], }, - // Deprecated: 예전 큐레이션 관리 라우트(/curations)는 더 이상 사용하지 않아 nav에서 제외합니다. - // { - // id: 'curation', - // items: [ - // { - // id: 'curation-management', - // label: '큐레이션 관리', - // icon: Layers, - // roles: ['ROOT_ADMIN'], - // children: [ - // { - // id: 'curation-list', - // label: '큐레이션 목록', - // icon: List, - // path: '/curations', - // }, - // { - // id: 'curation-create', - // label: '큐레이션 추가', - // icon: Plus, - // path: '/curations/new', - // }, - // ], - // }, - // ], - // }, { id: 'inquiry', items: [ diff --git a/src/data/mock/curations.mock.ts b/src/data/mock/curations.mock.ts deleted file mode 100644 index 88ab4fa..0000000 --- a/src/data/mock/curations.mock.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * 큐레이션 Mock 데이터 - * TODO: 실제 API 연동 시 제거 - */ - -export interface CurationListItem { - id: number; - name: string; - description?: string; -} - -/** - * 큐레이션 목록 Mock 데이터 - */ -export const MOCK_CURATIONS: CurationListItem[] = [ - { id: 1, name: '봄 추천 위스키', description: '봄에 어울리는 상쾌한 위스키' }, - { id: 2, name: '입문자 추천', description: '위스키 입문자를 위한 선택' }, - { id: 3, name: '선물용 추천', description: '선물하기 좋은 위스키' }, - { id: 4, name: '가성비 위스키', description: '가격 대비 품질 좋은 위스키' }, - { id: 5, name: '프리미엄 컬렉션', description: '특별한 날을 위한 프리미엄' }, - { id: 6, name: '하이볼 추천', description: '하이볼에 어울리는 위스키' }, - { id: 7, name: '피트 위스키', description: '스모키한 피트향 위스키' }, - { id: 8, name: '셰리캐스크 위스키', description: '셰리캐스크 숙성 위스키' }, - { id: 9, name: '여름 추천', description: '시원하게 즐기는 여름 위스키' }, - { id: 10, name: '겨울 추천', description: '따뜻하게 즐기는 겨울 위스키' }, -]; diff --git a/src/hooks/__tests__/useCurationOld.test.ts b/src/hooks/__tests__/useCurationOld.test.ts deleted file mode 100644 index 06e6639..0000000 --- a/src/hooks/__tests__/useCurationOld.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { waitFor } from '@testing-library/react'; -import { http, HttpResponse } from 'msw'; -import { server } from '@/test/mocks/server'; -import { renderHook } from '@/test/test-utils'; -import { wrapApiError } from '@/test/mocks/data'; -import { useCurationList, useCurationBulkReorder } from '../useCurationOld'; - -const BASE = '/admin/api/v1/curations'; - -describe('useCurationOld hooks', () => { - describe('useCurationList', () => { - it('목록 데이터를 반환한다', async () => { - const { result } = renderHook(() => useCurationList()); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(result.current.data!.items.length).toBeGreaterThan(0); - expect(result.current.data!.meta.totalElements).toBeGreaterThan(0); - }); - }); - - describe('useCurationBulkReorder', () => { - it('일괄 노출순서 변경 mutation이 성공한다', async () => { - const onSuccess = vi.fn(); - const { result } = renderHook(() => useCurationBulkReorder({ onSuccess })); - - result.current.mutate({ ids: [2, 1] }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(onSuccess).toHaveBeenCalled(); - }); - - it('에러 시 에러 상태가 된다', async () => { - server.use( - http.patch(`${BASE}/bulk/reorder`, () => { - return HttpResponse.json(wrapApiError(500, 'SERVER_ERROR', '서버 오류'), { - status: 500, - }); - }) - ); - - const { result } = renderHook(() => useCurationBulkReorder()); - - result.current.mutate({ ids: [2, 1] }); - - await waitFor(() => expect(result.current.isError).toBe(true)); - }); - }); -}); diff --git a/src/hooks/useCurationOld.ts b/src/hooks/useCurationOld.ts deleted file mode 100644 index 8a66329..0000000 --- a/src/hooks/useCurationOld.ts +++ /dev/null @@ -1,452 +0,0 @@ -/** - * 큐레이션 API 커스텀 훅 - */ - -import { useQueryClient } from '@tanstack/react-query'; -import { useApiQuery } from './useApiQuery'; -import { useApiMutation, type UseApiMutationOptions } from './useApiMutation'; -import { - curationService, - curationKeys, - type CurationListResponse, -} from '@/services/curation-old.service'; -import type { - CurationSearchParams, - CurationDetail, - CurationCreateRequest, - CurationCreateResponse, - CurationUpdateRequest, - CurationUpdateResponse, - CurationDeleteResponse, - CurationToggleStatusRequest, - CurationToggleStatusResponse, - CurationBulkReorderResponse, - CurationAddAlcoholsRequest, - CurationAddAlcoholsResponse, - CurationRemoveAlcoholResponse, -} from '@/types/api'; - -/** - * 큐레이션 목록 조회 훅 - * - * @example - * ```tsx - * const { data, isLoading } = useCurationList({ keyword: '신년' }); - * - * if (data) { - * console.log(data.items); // 큐레이션 목록 - * console.log(data.meta); // 페이지네이션 정보 - * } - * ``` - */ -export function useCurationList(params?: CurationSearchParams) { - return useApiQuery( - curationKeys.list(params), - () => curationService.search(params), - { - staleTime: 1000 * 60 * 5, // 5분 - } - ); -} - -/** - * 큐레이션 상세 조회 훅 - * - * @example - * ```tsx - * const { data, isLoading } = useCurationDetail(1); - * - * if (data) { - * console.log(data.name); // 큐레이션명 - * console.log(data.alcohols); // 포함된 위스키 목록 - * } - * ``` - */ -export function useCurationDetail(curationId: number | undefined) { - return useApiQuery( - curationKeys.detail(curationId ?? 0), - () => curationService.getDetail(curationId!), - { - enabled: !!curationId && curationId > 0, - staleTime: 1000 * 60 * 5, // 5분 - } - ); -} - -/** - * 큐레이션 생성 훅 - * - * @example - * ```tsx - * const createMutation = useCurationCreate({ - * onSuccess: (data) => { - * console.log('생성된 ID:', data.targetId); - * navigate('/curations'); - * }, - * }); - * - * createMutation.mutate({ - * name: '신년 특집', - * description: '새해를 맞이하는 특별한 위스키', - * // ... - * }); - * ``` - */ -export function useCurationCreate( - options?: Omit< - UseApiMutationOptions, - 'successMessage' - > -) { - const queryClient = useQueryClient(); - const { onSuccess, ...restOptions } = options ?? {}; - - return useApiMutation( - curationService.create, - { - successMessage: '큐레이션이 등록되었습니다.', - ...restOptions, - onSuccess: (data, variables, context) => { - // 목록 캐시 무효화 - queryClient.invalidateQueries({ queryKey: curationKeys.lists() }); - // 원래 onSuccess 콜백 호출 - if (onSuccess) { - (onSuccess as (data: CurationCreateResponse, variables: CurationCreateRequest, context: unknown) => void)(data, variables, context); - } - }, - } - ); -} - -/** - * 큐레이션 수정 mutation 변수 타입 - */ -export interface CurationUpdateVariables { - curationId: number; - data: CurationUpdateRequest; -} - -/** - * 큐레이션 수정 훅 - * - * @example - * ```tsx - * const updateMutation = useCurationUpdate({ - * onSuccess: () => { - * console.log('수정 완료'); - * }, - * }); - * - * updateMutation.mutate({ - * curationId: 1, - * data: { - * name: '수정된 큐레이션', - * description: '새로운 설명', - * // ... - * }, - * }); - * ``` - */ -export function useCurationUpdate( - options?: Omit, 'successMessage'> -) { - const queryClient = useQueryClient(); - const { onSuccess, ...restOptions } = options ?? {}; - - return useApiMutation( - ({ curationId, data }) => curationService.update(curationId, data), - { - successMessage: '큐레이션이 수정되었습니다.', - ...restOptions, - onSuccess: (data, variables, context) => { - // 목록 캐시 무효화 - queryClient.invalidateQueries({ queryKey: curationKeys.lists() }); - // 상세 캐시 무효화 - queryClient.invalidateQueries({ queryKey: curationKeys.detail(variables.curationId) }); - // 원래 onSuccess 콜백 호출 - if (onSuccess) { - (onSuccess as (data: CurationUpdateResponse, variables: CurationUpdateVariables, context: unknown) => void)(data, variables, context); - } - }, - } - ); -} - -/** - * 큐레이션 삭제 훅 - * - * @example - * ```tsx - * const deleteMutation = useCurationDelete({ - * onSuccess: () => { - * navigate('/curations'); - * }, - * }); - * - * deleteMutation.mutate(curationId); - * ``` - */ -export function useCurationDelete( - options?: Omit, 'successMessage'> -) { - const queryClient = useQueryClient(); - const { onSuccess, ...restOptions } = options ?? {}; - - return useApiMutation( - curationService.delete, - { - successMessage: '큐레이션이 삭제되었습니다.', - ...restOptions, - onSuccess: (data, variables, context) => { - // 목록 캐시 무효화 - queryClient.invalidateQueries({ queryKey: curationKeys.lists() }); - // 상세 캐시 무효화 - queryClient.invalidateQueries({ queryKey: curationKeys.detail(variables) }); - // 원래 onSuccess 콜백 호출 - if (onSuccess) { - (onSuccess as (data: CurationDeleteResponse, variables: number, context: unknown) => void)(data, variables, context); - } - }, - } - ); -} - -/** - * 큐레이션 활성화 상태 토글 mutation 변수 타입 - */ -export interface CurationToggleStatusVariables { - curationId: number; - data: CurationToggleStatusRequest; -} - -/** - * 큐레이션 활성화 상태 토글 훅 - * - * @example - * ```tsx - * const toggleMutation = useCurationToggleStatus({ - * onSuccess: () => { - * console.log('상태 변경 완료'); - * }, - * }); - * - * toggleMutation.mutate({ - * curationId: 1, - * data: { isActive: false }, - * }); - * ``` - */ -export function useCurationToggleStatus( - options?: Omit, 'successMessage'> -) { - const queryClient = useQueryClient(); - const { onSuccess, ...restOptions } = options ?? {}; - - return useApiMutation( - ({ curationId, data }) => curationService.toggleStatus(curationId, data), - { - successMessage: '큐레이션 상태가 변경되었습니다.', - ...restOptions, - onSuccess: (data, variables, context) => { - // 목록 캐시 무효화 - queryClient.invalidateQueries({ queryKey: curationKeys.lists() }); - // 상세 캐시 무효화 - queryClient.invalidateQueries({ queryKey: curationKeys.detail(variables.curationId) }); - // 원래 onSuccess 콜백 호출 - if (onSuccess) { - (onSuccess as (data: CurationToggleStatusResponse, variables: CurationToggleStatusVariables, context: unknown) => void)(data, variables, context); - } - }, - } - ); -} - -/** 큐레이션 노출순서 일괄 변경 훅 */ -export function useCurationBulkReorder( - options?: Omit< - UseApiMutationOptions, - 'successMessage' - > -) { - const queryClient = useQueryClient(); - const { onSuccess, ...restOptions } = options ?? {}; - - return useApiMutation( - ({ ids }) => curationService.bulkReorder(ids), - { - successMessage: '큐레이션 순서가 변경되었습니다.', - ...restOptions, - onSuccess: (data, variables, context) => { - // 목록 캐시 무효화 - queryClient.invalidateQueries({ - queryKey: curationKeys.lists(), - refetchType: 'none', - }); - // 원래 onSuccess 콜백 호출 - if (onSuccess) { - ( - onSuccess as ( - data: CurationBulkReorderResponse, - variables: { ids: number[] }, - context: unknown - ) => void - )(data, variables, context); - } - }, - } - ); -} - -/** - * 큐레이션 위스키 추가 mutation 변수 타입 - */ -export interface CurationAddAlcoholsVariables { - curationId: number; - data: CurationAddAlcoholsRequest; -} - -/** - * 큐레이션 위스키 추가 훅 - * - * @example - * ```tsx - * const addMutation = useCurationAddAlcohols({ - * onSuccess: () => { - * console.log('위스키 추가 완료'); - * }, - * }); - * - * addMutation.mutate({ - * curationId: 1, - * data: { alcoholIds: [10, 20, 30] }, - * }); - * ``` - */ -export function useCurationAddAlcohols( - options?: Omit, 'successMessage'> -) { - const queryClient = useQueryClient(); - const { onSuccess, ...restOptions } = options ?? {}; - - return useApiMutation( - ({ curationId, data }) => curationService.addAlcohols(curationId, data), - { - successMessage: '위스키가 추가되었습니다.', - ...restOptions, - onSuccess: (data, variables, context) => { - // 목록 캐시 무효화 - queryClient.invalidateQueries({ queryKey: curationKeys.lists() }); - // 상세 캐시 무효화 (alcohols 배열 변경) - queryClient.invalidateQueries({ queryKey: curationKeys.detail(variables.curationId) }); - // 원래 onSuccess 콜백 호출 - if (onSuccess) { - (onSuccess as (data: CurationAddAlcoholsResponse, variables: CurationAddAlcoholsVariables, context: unknown) => void)(data, variables, context); - } - }, - } - ); -} - -/** - * 큐레이션 위스키 제거 mutation 변수 타입 - */ -export interface CurationRemoveAlcoholVariables { - curationId: number; - alcoholId: number; -} - -/** - * 큐레이션 위스키 제거 훅 - * - * @example - * ```tsx - * const removeMutation = useCurationRemoveAlcohol({ - * onSuccess: () => { - * console.log('위스키 제거 완료'); - * }, - * }); - * - * removeMutation.mutate({ - * curationId: 1, - * alcoholId: 10, - * }); - * ``` - */ -export function useCurationRemoveAlcohol( - options?: Omit, 'successMessage'> -) { - const queryClient = useQueryClient(); - const { onSuccess, ...restOptions } = options ?? {}; - - return useApiMutation( - ({ curationId, alcoholId }) => curationService.removeAlcohol(curationId, alcoholId), - { - successMessage: '위스키가 제거되었습니다.', - ...restOptions, - onSuccess: (data, variables, context) => { - // 목록 캐시 무효화 - queryClient.invalidateQueries({ queryKey: curationKeys.lists() }); - // 상세 캐시 무효화 (alcohols 배열 변경) - queryClient.invalidateQueries({ queryKey: curationKeys.detail(variables.curationId) }); - // 원래 onSuccess 콜백 호출 - if (onSuccess) { - (onSuccess as (data: CurationRemoveAlcoholResponse, variables: CurationRemoveAlcoholVariables, context: unknown) => void)(data, variables, context); - } - }, - } - ); -} - -/** - * 큐레이션 위스키 벌크 제거 mutation 변수 타입 - */ -export interface CurationRemoveAlcoholsVariables { - curationId: number; - alcoholIds: number[]; -} - -/** - * 큐레이션 위스키 벌크 제거 훅 - * API가 단건만 지원하므로 내부적으로 병렬 호출 - * - * @example - * ```tsx - * const removeMutation = useCurationRemoveAlcohols({ - * onSuccess: () => { - * console.log('위스키 벌크 제거 완료'); - * }, - * }); - * - * removeMutation.mutate({ - * curationId: 1, - * alcoholIds: [10, 20, 30], - * }); - * ``` - */ -export function useCurationRemoveAlcohols( - options?: Omit, 'successMessage'> -) { - const queryClient = useQueryClient(); - const { onSuccess, ...restOptions } = options ?? {}; - - return useApiMutation( - ({ curationId, alcoholIds }) => curationService.removeAlcohols(curationId, alcoholIds), - { - successMessage: '위스키가 제거되었습니다.', - ...restOptions, - onSuccess: (data, variables, context) => { - // 목록 캐시 무효화 - queryClient.invalidateQueries({ queryKey: curationKeys.lists() }); - // 상세 캐시 무효화 (alcohols 배열 변경) - queryClient.invalidateQueries({ queryKey: curationKeys.detail(variables.curationId) }); - // 원래 onSuccess 콜백 호출 - if (onSuccess) { - (onSuccess as (data: void, variables: CurationRemoveAlcoholsVariables, context: unknown) => void)(data, variables, context); - } - }, - } - ); -} - -// Re-export curationService for convenience -export { curationService }; diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 996515e..b61b778 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -8,7 +8,7 @@ import { useAdminAlcoholList } from '@/hooks/useAdminAlcohols'; import { useHelpList } from '@/hooks/useHelps'; import { useTastingTagList } from '@/hooks/useTastingTags'; import { useBannerList } from '@/hooks/useBanners'; -import { useCurationList } from '@/hooks/useCurationOld'; +import { useCurationList } from '@/hooks/useCurations'; interface StatCardProps { title: string; @@ -93,7 +93,7 @@ export function DashboardPage() { value={curationData?.meta.totalElements ?? 0} icon={} isLoading={isCurationLoading} - href="/curations" + href="/dashboard/curations" /> ; - curations: CurationListItem[]; + curations: CurationV2ListItem[]; } export function BannerLinkSettingsCard({ form, curations }: BannerLinkSettingsCardProps) { @@ -32,7 +31,7 @@ export function BannerLinkSettingsCard({ form, curations }: BannerLinkSettingsCa const handleCurationChange = (value: string) => { const id = parseInt(value, 10); form.setValue('curationId', id); - form.setValue('targetUrl', curationService.generateCurationUrl(id)); + form.setValue('targetUrl', `/search?curationId=${id}`); form.setValue('isExternalUrl', false); }; diff --git a/src/pages/curation-old/CurationDetail.tsx b/src/pages/curation-old/CurationDetail.tsx deleted file mode 100644 index 87e46d8..0000000 --- a/src/pages/curation-old/CurationDetail.tsx +++ /dev/null @@ -1,323 +0,0 @@ -/** - * 큐레이션 상세/등록 페이지 - * - 신규 등록 (id가 'new'인 경우) - * - 상세 조회 및 수정 (id가 숫자인 경우) - * - 위스키 연결은 로컬 상태로 관리하다가 저장 시 PUT API로 일괄 처리 - */ - -import { useState, useEffect } from 'react'; -import { useParams } from 'react-router'; -import { Save, Trash2, X } from 'lucide-react'; - -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Textarea } from '@/components/ui/textarea'; -import { Switch } from '@/components/ui/switch'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { DetailPageHeader } from '@/components/common/DetailPageHeader'; -import { DeleteConfirmDialog } from '@/components/common/DeleteConfirmDialog'; -import { ImageUpload } from '@/components/common/ImageUpload'; -import { FormField } from '@/components/common/FormField'; -import { WhiskySearchSelect, type SelectedWhisky } from '@/components/common/WhiskySearchSelect'; - -import { useCurationDetailForm } from './useCurationDetailForm'; -import { useImageUpload, S3UploadPath } from '@/hooks/useImageUpload'; - -export function CurationDetailPage() { - const { id } = useParams<{ id: string }>(); - - // 폼 관련 로직을 커스텀 훅으로 분리 - const { - form, - isLoading, - isNewMode, - isPending, - curationData, - onSubmit, - handleBack, - handleDelete, - } = useCurationDetailForm(id); - - // 이미지 업로드 훅 (큐레이션 전용 S3 경로 사용) - const { upload: uploadImage, isUploading: isImageUploading } = useImageUpload({ - rootPath: S3UploadPath.CURATION, - }); - - // 로컬 상태 - const [imagePreviewUrl, setImagePreviewUrl] = useState(null); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [selectedWhiskies, setSelectedWhiskies] = useState([]); - - // curationData 변경 시 로컬 상태 동기화 - useEffect(() => { - if (curationData) { - setImagePreviewUrl(curationData.coverImageUrl); - setSelectedWhiskies( - curationData.alcohols.map((a) => ({ - alcoholId: a.alcoholId, - korName: a.korName, - engName: a.engName, - imageUrl: a.imageUrl, - })) - ); - } - }, [curationData]); - - const handleImageChange = async (file: File | null, previewUrl: string | null) => { - // 즉시 프리뷰 표시 - setImagePreviewUrl(previewUrl); - - if (file) { - // S3에 업로드하고 CDN URL 획득 - const viewUrl = await uploadImage(file); - if (viewUrl) { - // 업로드 성공 시 CDN URL로 업데이트 - form.setValue('coverImageUrl', viewUrl); - } else { - // 업로드 실패 시 프리뷰 URL 유지 (에러는 훅에서 처리) - form.setValue('coverImageUrl', previewUrl ?? ''); - } - } else { - // 이미지 삭제 시 - form.setValue('coverImageUrl', previewUrl ?? ''); - } - }; - - // 위스키 추가 (로컬 상태만 - 저장 시 API 호출) - const handleAddWhisky = (whisky: SelectedWhisky) => { - // 폼 상태에 추가 (유효성 검사용) - const currentIds = form.getValues('alcoholIds'); - form.setValue('alcoholIds', [...currentIds, whisky.alcoholId], { shouldValidate: true }); - // 로컬 상태에 추가 - setSelectedWhiskies((prev) => [...prev, whisky]); - }; - - // 위스키 제거 (로컬 상태만 - 저장 시 API 호출) - const handleRemoveWhisky = (alcoholId: number) => { - // 폼 상태에서 제거 (유효성 검사용) - const currentIds = form.getValues('alcoholIds'); - form.setValue( - 'alcoholIds', - currentIds.filter((id) => id !== alcoholId), - { shouldValidate: true } - ); - // 로컬 상태에서 제거 - setSelectedWhiskies((prev) => prev.filter((w) => w.alcoholId !== alcoholId)); - }; - - const handleSubmit = form.handleSubmit( - (data) => { - // PUT API가 alcoholIds를 포함하므로 단일 호출로 처리 - onSubmit(data); - }, - (errors) => { - console.log('[DEBUG] Form validation errors:', errors); - } - ); - - const handleDeleteConfirm = () => { - handleDelete(); - setIsDeleteDialogOpen(false); - }; - - // 폼 값 watch - const isActive = form.watch('isActive'); - - // 현재 선택된 위스키 ID 목록 (excludeIds용) - const alcoholIds = form.watch('alcoholIds'); - - return ( -
- {/* 헤더 */} - - {curationData && ( - - )} - - - } - /> - - {isLoading ? ( -
로딩 중...
- ) : ( -
- {/* 왼쪽 컬럼 */} -
- {/* 기본 정보 */} - - - 기본 정보 - - - - - - - -