diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..9b7dd84 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,149 @@ +# Code Review Instructions + +You are reviewing a **Vite + React 18 + TypeScript + TanStack Query v5 + Zustand** admin dashboard project. +The app manages whisky tasting platform data (배너, 큐레이션, 위스키, 테이스팅 태그 등). + +## Language + +- Write all review comments in Korean +- Do not translate technical terms (null check, race condition, stale closure, query key, mutation, refetch 등) + +## Review Categories + +### 1. Runtime Errors (앱을 중단시키거나 깨뜨리는 버그) + +- API 응답 데이터의 null/undefined 접근 처리 누락 +- useEffect cleanup 누락으로 인한 메모리 누수 (setTimeout, event listener 등) +- useCallback/useEffect 내 stale closure (outdated state 참조) +- 무한 리렌더 루프 (render 내 setState, 의존성 배열 오류) +- `useSearchParams` 값을 Number 변환 시 NaN 처리 누락 + +### 2. Data Integrity (잘못된 데이터 표시 또는 유실) + +- 잘못된 query key로 인한 stale/incorrect 캐시 데이터 +- mutation 후 `queryClient.invalidateQueries` 또는 `refetch` 누락 +- TanStack Query 훅의 `data`를 초기값 없이 직접 구조분해 (undefined 가능성) +- URL 파라미터 기반 상태에서 기본값 처리 누락 (`Number(null)` = 0 등 의도치 않은 변환) +- 페이지네이션 `page` 파라미터가 0-based임에도 1-based로 연산 + +### 3. Auth & Security + +- 인증 필요 API 호출 시 auth 토큰 누락 +- 사용자 입력을 `dangerouslySetInnerHTML`로 렌더링 (sanitization 없이) +- 토큰/userId 등 민감 정보가 URL 파라미터나 `console.log`에 노출 + +### 4. Convention Violations (미래 버그를 유발하는 프로젝트 규칙 위반) + +**Import & Module:** + +- 배럴 파일 import 사용 (`from '@/hooks'` 대신 `from '@/hooks/useBanners'` 직접 import) +- 컴포넌트에서 API를 직접 호출 (fetch/axios) — 반드시 서비스 레이어 경유 +- 3계층 API 패턴 순서 위반: `types/api/*.api.ts` → `services/*.service.ts` → `hooks/use*.ts` + - 컴포넌트가 service를 직접 import하거나 types/api를 건너뛰는 경우 + +**State Management:** + +- 서버 데이터(API 응답)를 Zustand에 저장 — TanStack Query로 관리해야 함 +- 검색/필터/페이지네이션 상태를 useState로 관리 — URL 파라미터(`useSearchParams`)로 관리해야 함 +- Zustand store에서 auth, sidebar 외 도메인 데이터 저장 + +**Form:** + +- React Hook Form + Zod 없이 수동 `onChange` + `useState`로 폼 처리 +- Zod 스키마 없이 수동 유효성 검사 +- `FormField` 컴포넌트 미사용으로 에러 메시지 표시 방식 불일치 + +**File Placement:** + +- 테스트 파일이 `__tests__/` 서브디렉토리 외 위치에 작성됨 +- Hook 테스트가 `hooks/__tests__/` 아닌 다른 위치에 작성됨 +- 새 `index.ts` 배럴 파일 생성 (CLAUDE.md 명시 금지) + +**Other:** + +- `console.log` 사용 (디버그 목적으로 남겨진 경우) +- `src/components/ui/` 내 shadcn/ui 파일 직접 수정 + +## Severity + +모든 코멘트에 다음 중 하나의 접두사를 붙이세요: + +- **[P0]** 앱 크래시, 데이터 유실, 보안 취약점, 인증 우회 +- **[P1]** 일반 사용자 플로우에서 오작동, 잘못된 데이터 표시, 네비게이션 오류 +- **[P2]** 엣지 케이스 버그, 유효성 검사 누락, 유지보수성을 해치는 컨벤션 위반 +- **[nit]** 경미한 컨벤션 이탈, 블로킹 아님 + +### 코멘트 기준: P0, P1, P2만 작성. + +- P0/P1: 항상 코멘트. 수정이 명확한 경우 `suggestion` 블록 포함. +- P2: `suggestion` 없이 코멘트. 어떤 규칙을 위반했는지 한 문장으로 설명. +- [nit]: 코멘트하지 않음. 완전히 스킵. + +### P1 기준 (하나 이상 해당해야 함): + +- 일반 사용자 플로우에서 버그가 발생 +- 잘못된 데이터가 표시되거나 제출됨 +- 네비게이션 오류 (빈 페이지, 무한 리디렉션) +- 관리자 권한 체크 누락으로 접근 제어 실패 +- 장시간 유지되는 페이지에서 메모리 누수 + +### P1 아닌 경우 (플래그 달지 말 것): + +- "서버가 예상치 못한 형태를 반환할 수도 있음" (API 계약은 서버 책임) +- 실제 버그 없는 useEffect 의존성 배열 lint 경고 +- 로딩/에러 UI 누락 (UX 문제, 버그 아님) +- 사용자 체감 영향 없는 성능 최적화 + +## 리뷰하지 않는 항목 (완전히 무시): + +- 코드 스타일, 포맷, 들여쓰기 (Prettier가 처리) +- import 순서 (ESLint가 처리) +- 네이밍 컨벤션 (진짜로 혼란스러운 경우 제외) +- 문서, 주석, README +- 테스트 파일 (테스트 로직이 틀린 경우 제외) +- 설정 파일 (빌드/런타임 오류 유발 아닌 경우) +- 리팩토링 제안 +- "있으면 좋은" 개선사항 +- 사용자 체감 없는 성능 최적화 + +## 코멘트 형식 + +``` +[P{n}] {한 줄 요약: 무엇이 문제인지} + +{재현 조건 또는 영향 범위 (1문장)} +``` + +- 최대 1단락. 여러 단락 설명 금지. +- P0/P1이고 수정이 명확한 경우에만 `suggestion` 블록 포함. +- Suggestion 코드는 5줄 이하. +- Suggestion 블록에서 아키텍처 변경 제안 금지. + +## Examples + +**올바른 P1 코멘트:** + +[P1] `page` 파라미터가 없을 때 `Number(null)`이 `0`이 되어 올바른 기본값처럼 보이지만, `NaN` 케이스가 별도로 처리되지 않습니다. 필터 파라미터 변경 시 페이지가 의도치 않은 값으로 요청됩니다. + +```suggestion +const page = Number(urlParams.get('page')) || 0; +``` + +**올바른 P2 코멘트:** + +[P2] 배럴 파일 import 사용. `@/hooks/useBanners`에서 직접 import 해주세요. + +**나쁜 코멘트 (이렇게 쓰지 말 것):** + +~~[P1] 이 코드는 개선이 필요합니다. useEffect cleanup이 없는데 React best practice에 위배됩니다. cleanup을 추가하면 메모리 누수를 방지할 수 있고...~~ + +(너무 길고, 재현 조건 없음. "Best practice"는 버그가 아님.) + +## PR Overview Guidelines + +- 최대 3문장 +- 구성: + - 변경 요약: 무엇이 왜 바뀌었는지 (1문장) + - 우려 사항: 발견된 P0/P1, 없으면 "특이사항 없음" (1~2문장) +- 인라인 코멘트에서 이미 언급한 내용 반복 금지 +- 칭찬 생략 ("좋은 PR", "잘 작성" 등 금지) diff --git a/.gitignore b/.gitignore index 3f48c16..a23b544 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,10 @@ CLAUDE.personal.md .secrets .claude/ +# OMC (oh-my-claudecode) internal state +.omc/ + + # Playwright e2e/.auth/ e2e/reports/ diff --git a/.omc/continuation-count.json b/.omc/continuation-count.json deleted file mode 100644 index cb7623e..0000000 --- a/.omc/continuation-count.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "count": 22, - "last_checked_at": "2026-02-05T05:51:54.254Z" -} \ No newline at end of file diff --git a/VERSION b/VERSION index e4c0d46..781dcb0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.3 \ No newline at end of file +1.1.3 diff --git a/git.environment-variables b/git.environment-variables index f649a36..1db659f 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit f649a363ffea86ecf2e19a09cd936362acbd47d2 +Subproject commit 1db659ff61276fe7c2a0c447909507eb590dfd04 diff --git a/src/components/common/StatusToggle.tsx b/src/components/common/StatusToggle.tsx new file mode 100644 index 0000000..80b798e --- /dev/null +++ b/src/components/common/StatusToggle.tsx @@ -0,0 +1,40 @@ +/** + * 활성/비활성 상태 토글 컴포넌트 + * Switch와 Badge를 조합하여 인라인 상태 변경 UI 제공 + */ + +import { Check, X } from 'lucide-react'; +import { Switch } from '@/components/ui/switch'; +import { Badge } from '@/components/ui/badge'; + +interface StatusToggleProps { + isActive: boolean; + onToggle: () => void; + disabled?: boolean; +} + +export function StatusToggle({ isActive, onToggle, disabled }: StatusToggleProps) { + return ( +
e.stopPropagation()} + > + + {isActive ? ( + + + 활성 + + ) : ( + + + 비활성 + + )} +
+ ); +} diff --git a/src/hooks/__tests__/useAdminAlcohols.test.ts b/src/hooks/__tests__/useAdminAlcohols.test.ts new file mode 100644 index 0000000..7e2da80 --- /dev/null +++ b/src/hooks/__tests__/useAdminAlcohols.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } 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 { useAdminAlcoholList } from '../useAdminAlcohols'; + +const BASE = '/admin/api/v1/alcohols'; + +describe('useAdminAlcohols hooks', () => { + // ========================================== + // useAdminAlcoholList + // ========================================== + describe('useAdminAlcoholList', () => { + it('목록 데이터를 반환한다 (삭제 데이터 제외)', async () => { + const { result } = renderHook(() => useAdminAlcoholList()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // 기본 조회: 삭제된 위스키(ID 30)는 제외 + expect(result.current.data!.items.length).toBe(2); + expect(result.current.data!.items.every((item) => item.deletedAt === null)).toBe(true); + }); + + it('includeDeleted=true 시 삭제된 데이터도 포함한다', async () => { + const { result } = renderHook(() => + useAdminAlcoholList({ includeDeleted: true }) + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // 삭제 포함: 3개 모두 반환 + expect(result.current.data!.items.length).toBe(3); + const deletedItem = result.current.data!.items.find((item) => item.alcoholId === 30); + expect(deletedItem).toBeDefined(); + expect(deletedItem!.deletedAt).not.toBeNull(); + }); + + it('keyword로 필터링한다', async () => { + const { result } = renderHook(() => + useAdminAlcoholList({ keyword: '글렌피딕' }) + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data!.items).toHaveLength(1); + expect(result.current.data!.items[0]!.korName).toBe('글렌피딕 12년'); + }); + + it('API 에러 시 에러 상태가 된다', async () => { + server.use( + http.get(BASE, () => { + return HttpResponse.json(wrapApiError(500, 'SERVER_ERROR', '서버 오류'), { + status: 500, + }); + }) + ); + + const { result } = renderHook(() => useAdminAlcoholList()); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + + it('페이지네이션 메타 정보를 반환한다', async () => { + const { result } = renderHook(() => useAdminAlcoholList({ page: 0, size: 10 })); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data!.meta).toBeDefined(); + expect(result.current.data!.meta.page).toBe(0); + expect(result.current.data!.meta.totalElements).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/hooks/__tests__/useReorderDrag.test.ts b/src/hooks/__tests__/useReorderDrag.test.ts new file mode 100644 index 0000000..c5753dc --- /dev/null +++ b/src/hooks/__tests__/useReorderDrag.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect, vi } from 'vitest'; +import { act } from '@testing-library/react'; +import { renderHook } from '@/test/test-utils'; +import { useReorderDrag } from '../useReorderDrag'; + +// 최소 DragEvent 모의 객체 +const makeDragEvent = () => + ({ + dataTransfer: { effectAllowed: '' }, + preventDefault: vi.fn(), + } as unknown as React.DragEvent); + +// 테스트용 아이템 (order 필드로 순서 추적) +const ITEMS = [ + { id: 1, order: 0 }, + { id: 2, order: 1 }, + { id: 3, order: 2 }, +]; + +function makeDefaultHook(overrides: { + onReorder?: ReturnType; + onAfterReorder?: ReturnType; + pageOffset?: number; +} = {}) { + const onReorder = overrides.onReorder ?? vi.fn().mockResolvedValue(undefined); + const onAfterReorder = overrides.onAfterReorder ?? vi.fn().mockResolvedValue(undefined); + + const hook = renderHook(() => + useReorderDrag({ + items: ITEMS, + getOrder: (item) => item.order, + onReorder: onReorder as (itemId: number, newOrder: number) => Promise, + onAfterReorder: onAfterReorder as () => Promise, + pageOffset: overrides.pageOffset, + }) + ); + + return { ...hook, onReorder, onAfterReorder }; +} + +describe('useReorderDrag', () => { + // ========================================== + // 초기 상태 + // ========================================== + describe('초기 상태', () => { + it('isReorderMode가 false로 시작한다', () => { + const { result } = makeDefaultHook(); + expect(result.current.isReorderMode).toBe(false); + }); + + it('isReordering이 false로 시작한다', () => { + const { result } = makeDefaultHook(); + expect(result.current.isReordering).toBe(false); + }); + + it('dragOverId가 null로 시작한다', () => { + const { result } = makeDefaultHook(); + expect(result.current.dragOverId).toBeNull(); + }); + }); + + // ========================================== + // toggleReorderMode + // ========================================== + describe('toggleReorderMode', () => { + it('호출 시 순서 변경 모드가 활성화된다', () => { + const { result } = makeDefaultHook(); + + act(() => result.current.toggleReorderMode()); + + expect(result.current.isReorderMode).toBe(true); + }); + + it('두 번 호출 시 비활성화된다', () => { + const { result } = makeDefaultHook(); + + act(() => result.current.toggleReorderMode()); + act(() => result.current.toggleReorderMode()); + + expect(result.current.isReorderMode).toBe(false); + }); + + it('비활성화 시 dragOverId가 초기화된다', () => { + const { result } = makeDefaultHook(); + + act(() => result.current.toggleReorderMode()); + // dragOverId 세팅 + act(() => result.current.getDragHandlers(ITEMS[0]!).onDragOver(makeDragEvent())); + expect(result.current.dragOverId).toBe(1); + + // 모드 종료 + act(() => result.current.toggleReorderMode()); + expect(result.current.dragOverId).toBeNull(); + }); + }); + + // ========================================== + // getDragHandlers + // ========================================== + describe('getDragHandlers', () => { + it('isReorderMode가 false면 draggable이 false다', () => { + const { result } = makeDefaultHook(); + expect(result.current.getDragHandlers(ITEMS[0]!).draggable).toBe(false); + }); + + it('isReorderMode가 true면 draggable이 true다', () => { + const { result } = makeDefaultHook(); + act(() => result.current.toggleReorderMode()); + expect(result.current.getDragHandlers(ITEMS[0]!).draggable).toBe(true); + }); + + it('onDragOver 호출 시 dragOverId가 해당 아이템 id로 설정된다', () => { + const { result } = makeDefaultHook(); + act(() => result.current.toggleReorderMode()); + + act(() => result.current.getDragHandlers(ITEMS[1]!).onDragOver(makeDragEvent())); + + expect(result.current.dragOverId).toBe(ITEMS[1]!.id); + }); + + it('onDragLeave 호출 시 dragOverId가 초기화된다', () => { + const { result } = makeDefaultHook(); + act(() => result.current.toggleReorderMode()); + act(() => result.current.getDragHandlers(ITEMS[0]!).onDragOver(makeDragEvent())); + + act(() => result.current.getDragHandlers(ITEMS[0]!).onDragLeave()); + + expect(result.current.dragOverId).toBeNull(); + }); + + it('onDragEnd 호출 시 dragOverId가 초기화된다', () => { + const { result } = makeDefaultHook(); + act(() => result.current.toggleReorderMode()); + act(() => result.current.getDragHandlers(ITEMS[0]!).onDragOver(makeDragEvent())); + + act(() => result.current.getDragHandlers(ITEMS[0]!).onDragEnd()); + + expect(result.current.dragOverId).toBeNull(); + }); + }); + + // ========================================== + // 드롭 성공 + // ========================================== + describe('드롭 성공', () => { + it('영향받는 범위 항목의 순서를 업데이트한다', async () => { + const { result, onReorder } = makeDefaultHook(); + + act(() => result.current.toggleReorderMode()); + // items[0]을 드래그 + act(() => result.current.getDragHandlers(ITEMS[0]!).onDragStart(makeDragEvent())); + // items[2]에 드롭 → 0,1,2 모두 영향받음 + await act(async () => { + await result.current.getDragHandlers(ITEMS[2]!).onDrop(makeDragEvent()); + }); + + expect(onReorder).toHaveBeenCalledTimes(3); + }); + + it('드롭 성공 후 onAfterReorder를 호출한다', async () => { + const { result, onAfterReorder } = makeDefaultHook(); + + act(() => result.current.toggleReorderMode()); + act(() => result.current.getDragHandlers(ITEMS[0]!).onDragStart(makeDragEvent())); + await act(async () => { + await result.current.getDragHandlers(ITEMS[1]!).onDrop(makeDragEvent()); + }); + + expect(onAfterReorder).toHaveBeenCalledTimes(1); + }); + + it('드롭 완료 후 isReordering이 false가 된다', async () => { + const { result } = makeDefaultHook(); + + act(() => result.current.toggleReorderMode()); + act(() => result.current.getDragHandlers(ITEMS[0]!).onDragStart(makeDragEvent())); + await act(async () => { + await result.current.getDragHandlers(ITEMS[1]!).onDrop(makeDragEvent()); + }); + + expect(result.current.isReordering).toBe(false); + }); + + it('pageOffset이 새 순서 계산에 반영된다', async () => { + const { result, onReorder } = makeDefaultHook({ pageOffset: 20 }); + + act(() => result.current.toggleReorderMode()); + // items[0](id=1)을 items[1](id=2)로 드래그 + act(() => result.current.getDragHandlers(ITEMS[0]!).onDragStart(makeDragEvent())); + await act(async () => { + await result.current.getDragHandlers(ITEMS[1]!).onDrop(makeDragEvent()); + }); + + // 재정렬 후: [items[1], items[0], ...], 영향 범위 minIndex=0 + // idx=0: id=2 → 20+0+0=20 + // idx=1: id=1 → 20+0+1=21 + expect(onReorder).toHaveBeenNthCalledWith(1, ITEMS[1]!.id, 20); + expect(onReorder).toHaveBeenNthCalledWith(2, ITEMS[0]!.id, 21); + }); + }); + + // ========================================== + // 드롭 실패 → 롤백 + // ========================================== + describe('드롭 실패 시 롤백', () => { + it('onReorder 실패 시 원래 순서로 롤백을 시도한다', async () => { + let callCount = 0; + const onReorder = vi.fn().mockImplementation(() => { + callCount++; + // 첫 번째 호출만 실패 + if (callCount === 1) return Promise.reject(new Error('API 실패')); + return Promise.resolve(); + }); + const { result } = makeDefaultHook({ onReorder }); + + act(() => result.current.toggleReorderMode()); + act(() => result.current.getDragHandlers(ITEMS[0]!).onDragStart(makeDragEvent())); + await act(async () => { + await result.current.getDragHandlers(ITEMS[1]!).onDrop(makeDragEvent()); + }); + + // 첫 번째(실패) + 롤백 호출들 + expect(onReorder.mock.calls.length).toBeGreaterThan(1); + }); + + it('롤백 후 onAfterReorder를 한 번 호출한다', async () => { + const onReorder = vi.fn().mockRejectedValue(new Error('API 실패')); + const { result, onAfterReorder } = makeDefaultHook({ onReorder }); + + act(() => result.current.toggleReorderMode()); + act(() => result.current.getDragHandlers(ITEMS[0]!).onDragStart(makeDragEvent())); + await act(async () => { + await result.current.getDragHandlers(ITEMS[1]!).onDrop(makeDragEvent()); + }); + + expect(onAfterReorder).toHaveBeenCalledTimes(1); + }); + + it('롤백 후 isReordering이 false가 된다', async () => { + const onReorder = vi.fn().mockRejectedValue(new Error('API 실패')); + const { result } = makeDefaultHook({ onReorder }); + + act(() => result.current.toggleReorderMode()); + act(() => result.current.getDragHandlers(ITEMS[0]!).onDragStart(makeDragEvent())); + await act(async () => { + await result.current.getDragHandlers(ITEMS[1]!).onDrop(makeDragEvent()); + }); + + expect(result.current.isReordering).toBe(false); + }); + }); + + // ========================================== + // 엣지 케이스 + // ========================================== + describe('엣지 케이스', () => { + it('같은 아이템으로 드롭하면 onReorder를 호출하지 않는다', async () => { + const { result, onReorder, onAfterReorder } = makeDefaultHook(); + + act(() => result.current.toggleReorderMode()); + act(() => result.current.getDragHandlers(ITEMS[0]!).onDragStart(makeDragEvent())); + await act(async () => { + // 같은 아이템에 드롭 + await result.current.getDragHandlers(ITEMS[0]!).onDrop(makeDragEvent()); + }); + + expect(onReorder).not.toHaveBeenCalled(); + expect(onAfterReorder).not.toHaveBeenCalled(); + }); + + it('isReorderMode가 false일 때 드롭해도 onReorder를 호출하지 않는다', async () => { + const { result, onReorder } = makeDefaultHook(); + + // 모드 활성화 없이 드롭 + await act(async () => { + await result.current.getDragHandlers(ITEMS[1]!).onDrop(makeDragEvent()); + }); + + expect(onReorder).not.toHaveBeenCalled(); + }); + + it('isReorderMode가 false일 때 onDragOver를 호출해도 dragOverId가 변하지 않는다', () => { + const { result } = makeDefaultHook(); + + act(() => result.current.getDragHandlers(ITEMS[0]!).onDragOver(makeDragEvent())); + + expect(result.current.dragOverId).toBeNull(); + }); + }); +}); diff --git a/src/hooks/useReorderDrag.ts b/src/hooks/useReorderDrag.ts new file mode 100644 index 0000000..a83f4d3 --- /dev/null +++ b/src/hooks/useReorderDrag.ts @@ -0,0 +1,152 @@ +/** + * 드래그 앤 드롭 순서 변경 훅 + * - 순서 변경 모드 상태 관리 + * - 드래그 상태 관리 + * - 영향 범위 계산, 순차 API 호출, 실패 시 롤백 + * + * @example + * const { isReorderMode, isReordering, dragOverId, toggleReorderMode, getDragHandlers } = useReorderDrag({ + * items: data?.items, + * getOrder: (item) => item.sortOrder, + * onReorder: (itemId, newOrder) => + * updateSortOrderMutation.mutateAsync({ bannerId: itemId, data: { sortOrder: newOrder } }), + * onAfterReorder: refetch, + * pageOffset: page * size, + * }); + */ + +import { useState } from 'react'; + +interface ReorderItem { + id: number; +} + +interface UseReorderDragOptions { + /** 현재 목록 아이템 */ + items: TItem[] | undefined; + /** 아이템에서 현재 순서 값을 추출하는 함수 (롤백용) */ + getOrder: (item: TItem) => number; + /** 단일 아이템 순서 변경 API 호출. 실패 시 롤백에도 사용됨 */ + onReorder: (itemId: number, newOrder: number) => Promise; + /** 순서 변경 완료 후 호출 (주로 refetch) */ + onAfterReorder: () => Promise; + /** 전역 순서 계산 시 더할 페이지 오프셋 (기본값: 0) */ + pageOffset?: number; +} + +interface DragHandlers { + draggable: boolean; + onDragStart: (e: React.DragEvent) => void; + onDragOver: (e: React.DragEvent) => void; + onDragLeave: () => void; + onDrop: (e: React.DragEvent) => Promise; + onDragEnd: () => void; +} + +export function useReorderDrag({ + items, + getOrder, + onReorder, + onAfterReorder, + pageOffset = 0, +}: UseReorderDragOptions) { + const [isReorderMode, setIsReorderMode] = useState(false); + const [isReordering, setIsReordering] = useState(false); + const [draggedItem, setDraggedItem] = useState(null); + const [dragOverId, setDragOverId] = useState(null); + + const toggleReorderMode = () => { + setIsReorderMode((prev) => { + if (prev) { + setDraggedItem(null); + setDragOverId(null); + } + return !prev; + }); + }; + + const getDragHandlers = (targetItem: TItem): DragHandlers => ({ + draggable: isReorderMode, + onDragStart: (e) => { + if (!isReorderMode) return; + setDraggedItem(targetItem); + e.dataTransfer.effectAllowed = 'move'; + }, + onDragOver: (e) => { + if (!isReorderMode) return; + e.preventDefault(); + setDragOverId(targetItem.id); + }, + onDragLeave: () => { + if (!isReorderMode) return; + setDragOverId(null); + }, + onDrop: async (e) => { + if (!isReorderMode || isReordering) return; + e.preventDefault(); + setDragOverId(null); + + if (!draggedItem || !items || draggedItem.id === targetItem.id) { + setDraggedItem(null); + return; + } + + const currentItems = [...items]; + const draggedIndex = currentItems.findIndex((item) => item.id === draggedItem.id); + const targetIndex = currentItems.findIndex((item) => item.id === targetItem.id); + + if (draggedIndex === -1 || targetIndex === -1) { + setDraggedItem(null); + return; + } + + // 배열 재정렬 + currentItems.splice(draggedIndex, 1); + currentItems.splice(targetIndex, 0, draggedItem); + + // 영향받는 범위만 업데이트 + const minIndex = Math.min(draggedIndex, targetIndex); + const maxIndex = Math.max(draggedIndex, targetIndex); + const affectedItems = currentItems.slice(minIndex, maxIndex + 1); + + // 롤백용 원본 순서 저장 + const originalOrders = new Map(items.map((item) => [item.id, getOrder(item)])); + + setIsReordering(true); + try { + for (let idx = 0; idx < affectedItems.length; idx++) { + const item = affectedItems[idx]!; + await onReorder(item.id, pageOffset + minIndex + idx); + } + await onAfterReorder(); + } catch { + // 실패 시 원래 순서로 롤백 + for (const item of affectedItems) { + const originalOrder = originalOrders.get(item.id); + if (originalOrder === undefined) continue; + try { + await onReorder(item.id, originalOrder); + } catch { + // 롤백 중 에러는 무시하고 가능한 한 복구 시도 + } + } + await onAfterReorder(); + } finally { + setIsReordering(false); + setDraggedItem(null); + } + }, + onDragEnd: () => { + setDraggedItem(null); + setDragOverId(null); + }, + }); + + return { + isReorderMode, + isReordering, + dragOverId, + toggleReorderMode, + getDragHandlers, + }; +} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 600f483..5d3e12b 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -2,20 +2,25 @@ * 대시보드 페이지 */ -import { Wine, MessageSquare, Tag, Image } from 'lucide-react'; +import { Link } from 'react-router'; +import { Wine, MessageSquare, Tag, Image, BookOpen } from 'lucide-react'; import { useAdminAlcoholList } from '@/hooks/useAdminAlcohols'; import { useHelpList } from '@/hooks/useHelps'; +import { useTastingTagList } from '@/hooks/useTastingTags'; +import { useBannerList } from '@/hooks/useBanners'; +import { useCurationList } from '@/hooks/useCurations'; interface StatCardProps { title: string; value: number | string; icon: React.ReactNode; isLoading?: boolean; + href?: string; } -function StatCard({ title, value, icon, isLoading }: StatCardProps) { - return ( -
+function StatCard({ title, value, icon, isLoading, href }: StatCardProps) { + const inner = ( +

{title}

{icon}
@@ -29,6 +34,8 @@ function StatCard({ title, value, icon, isLoading }: StatCardProps) {

); + + return href ? {inner} : inner; } export function DashboardPage() { @@ -36,8 +43,18 @@ export function DashboardPage() { const { data: alcoholData, isLoading: isAlcoholLoading } = useAdminAlcoholList({ size: 1, }); + const { data: tagData, isLoading: isTagLoading } = useTastingTagList({ + size: 1, + }); + const { data: bannerData, isLoading: isBannerLoading } = useBannerList({ + size: 1, + }); + const { data: curationData, isLoading: isCurationLoading } = useCurationList({ + size: 1, + }); const { data: helpData, isLoading: isHelpLoading } = useHelpList({ pageSize: 1, + status: 'WAITING', }); return ( @@ -49,28 +66,41 @@ export function DashboardPage() {

-
+
} isLoading={isAlcoholLoading} + href="/whisky" /> } + isLoading={isTagLoading} + href="/tasting-tags" /> } + isLoading={isBannerLoading} + href="/banners" + /> + } + isLoading={isCurationLoading} + href="/curations" /> } isLoading={isHelpLoading} + href="/inquiries" />
diff --git a/src/pages/banners/BannerList.tsx b/src/pages/banners/BannerList.tsx index c8f764c..1e08afa 100644 --- a/src/pages/banners/BannerList.tsx +++ b/src/pages/banners/BannerList.tsx @@ -1,12 +1,13 @@ /** * 배너 목록 페이지 * - URL 쿼리파라미터로 검색/필터/페이지네이션 상태 관리 + * - 인라인 Switch로 활성화 상태 토글 * - 순서 변경 모드에서 드래그 앤 드롭으로 순서 변경 */ import { useState, useEffect } from 'react'; import { useNavigate, useSearchParams } from 'react-router'; -import { Search, ImageOff, Plus, GripVertical, Check, X, ArrowUpDown } from 'lucide-react'; +import { Search, Plus, GripVertical, ArrowUpDown } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Select, @@ -26,21 +27,18 @@ import { import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Pagination } from '@/components/common/Pagination'; -import { useBannerList, useBannerUpdateSortOrder } from '@/hooks/useBanners'; +import { StatusToggle } from '@/components/common/StatusToggle'; +import { useReorderDrag } from '@/hooks/useReorderDrag'; +import { useBannerList, useBannerUpdateStatus, useBannerUpdateSortOrder } from '@/hooks/useBanners'; import { type BannerSearchParams, - type BannerType, - type BannerListItem, BANNER_TYPE_LABELS, } from '@/types/api'; -const BANNER_TYPE_OPTIONS: { value: BannerType | 'ALL'; label: string }[] = [ +const IS_ACTIVE_OPTIONS = [ { value: 'ALL', label: '전체' }, - { value: 'SURVEY', label: '설문조사' }, - { value: 'CURATION', label: '큐레이션' }, - { value: 'AD', label: '광고' }, - { value: 'PARTNERSHIP', label: '제휴' }, - { value: 'ETC', label: '기타' }, + { value: 'true', label: '활성' }, + { value: 'false', label: '비활성' }, ]; export function BannerListPage() { @@ -49,20 +47,13 @@ export function BannerListPage() { // URL에서 검색 파라미터 읽기 const keyword = urlParams.get('keyword') ?? ''; - const bannerType = urlParams.get('bannerType') as BannerType | null; + const isActiveParam = urlParams.get('isActive'); const page = Number(urlParams.get('page')) || 0; const size = Number(urlParams.get('size')) || 20; // 검색 입력 필드용 로컬 상태 (Enter/버튼 클릭 시에만 URL 반영) const [keywordInput, setKeywordInput] = useState(keyword); - // 순서 변경 모드 상태 - const [isReorderMode, setIsReorderMode] = useState(false); - - // 드래그 상태 (순서 변경 모드에서만 사용) - const [draggedItem, setDraggedItem] = useState(null); - const [dragOverId, setDragOverId] = useState(null); - // URL의 keyword가 변경되면 입력 필드도 동기화 useEffect(() => { setKeywordInput(keyword); @@ -71,14 +62,25 @@ export function BannerListPage() { // API 요청용 파라미터 const searchParams: BannerSearchParams = { keyword: keyword || undefined, - bannerType: bannerType || undefined, + isActive: isActiveParam === 'true' ? true : isActiveParam === 'false' ? false : undefined, page, size, }; - const { data, isLoading } = useBannerList(searchParams); + const { data, isLoading, refetch } = useBannerList(searchParams); + const toggleStatusMutation = useBannerUpdateStatus(); const updateSortOrderMutation = useBannerUpdateSortOrder(); + const { isReorderMode, isReordering, dragOverId, toggleReorderMode, getDragHandlers } = + useReorderDrag({ + items: data?.items, + getOrder: (item) => item.sortOrder, + onReorder: (itemId, newOrder) => + updateSortOrderMutation.mutateAsync({ bannerId: itemId, data: { sortOrder: newOrder } }), + onAfterReorder: refetch, + pageOffset: page * size, + }); + // URL 파라미터 업데이트 헬퍼 const updateUrlParams = (updates: Record) => { const newParams = new URLSearchParams(urlParams); @@ -101,7 +103,7 @@ export function BannerListPage() { const handleSearch = () => { updateUrlParams({ keyword: keywordInput || undefined, - page: '0', // 검색 시 첫 페이지로 + page: '0', }); }; @@ -111,117 +113,39 @@ export function BannerListPage() { } }; - const handleBannerTypeChange = (value: string) => { + const handleIsActiveChange = (value: string) => { updateUrlParams({ - bannerType: value === 'ALL' ? undefined : value, - page: '0', // 타입 변경 시 첫 페이지로 + isActive: value === 'ALL' ? undefined : value, + page: '0', }); }; const handlePageChange = (newPage: number) => { - updateUrlParams({ - page: String(newPage), - }); + updateUrlParams({ page: String(newPage) }); }; const handlePageSizeChange = (newSize: number) => { - updateUrlParams({ - size: String(newSize), - page: '0', // 페이지 크기 변경 시 첫 페이지로 - }); + updateUrlParams({ size: String(newSize), page: '0' }); }; const handleRowClick = (bannerId: number) => { - // 순서 변경 모드에서는 클릭으로 상세 페이지 이동하지 않음 if (!isReorderMode) { navigate(`/banners/${bannerId}`); } }; - // 순서 변경 모드 토글 - const toggleReorderMode = () => { - setIsReorderMode(!isReorderMode); - // 모드 종료 시 드래그 상태 초기화 - if (isReorderMode) { - setDraggedItem(null); - setDragOverId(null); - } - }; - - // 드래그 앤 드롭 핸들러 (순서 변경 모드에서만 동작) - const handleDragStart = (e: React.DragEvent, item: BannerListItem) => { - if (!isReorderMode) return; - setDraggedItem(item); - e.dataTransfer.effectAllowed = 'move'; - }; - - const handleDragOver = (e: React.DragEvent, itemId: number) => { - if (!isReorderMode) return; - e.preventDefault(); - setDragOverId(itemId); - }; - - const handleDragLeave = () => { - if (!isReorderMode) return; - setDragOverId(null); - }; - - const handleDrop = (e: React.DragEvent, targetItem: BannerListItem) => { - if (!isReorderMode) return; - e.preventDefault(); - setDragOverId(null); - - if (!draggedItem || !data?.items || draggedItem.id === targetItem.id) { - setDraggedItem(null); - return; - } - - // 새로운 순서 계산 - const items = [...data.items]; - const draggedIndex = items.findIndex((item) => item.id === draggedItem.id); - const targetIndex = items.findIndex((item) => item.id === targetItem.id); - - if (draggedIndex === -1 || targetIndex === -1) { - setDraggedItem(null); - return; - } - - // 순서 변경 - items.splice(draggedIndex, 1); - items.splice(targetIndex, 0, draggedItem); - - // 변경된 순서로 개별 API 호출 (페이지 오프셋 반영) - const pageOffset = page * size; - items.forEach((item, index) => { - const newSortOrder = pageOffset + index; - if (item.sortOrder !== newSortOrder) { - updateSortOrderMutation.mutate({ bannerId: item.id, data: { sortOrder: newSortOrder } }); - } - }); - - setDraggedItem(null); - }; - - const handleDragEnd = () => { - setDraggedItem(null); - setDragOverId(null); + const handleStatusToggle = (bannerId: number, currentStatus: boolean) => { + toggleStatusMutation.mutate({ bannerId, data: { isActive: !currentStatus } }); }; // 노출 기간 포맷팅 const formatExposurePeriod = (startDate: string | null, endDate: string | null) => { - if (!startDate && !endDate) { - return '상시 노출'; - } - + if (!startDate && !endDate) return '상시 노출'; const formatDate = (dateStr: string) => { const date = new Date(dateStr); return `${date.getMonth() + 1}/${date.getDate()}`; }; - - const start = startDate ? formatDate(startDate) : ''; - const end = endDate ? formatDate(endDate) : ''; - - return `${start} ~ ${end}`; + return `${startDate ? formatDate(startDate) : ''} ~ ${endDate ? formatDate(endDate) : ''}`; }; return ( @@ -252,6 +176,7 @@ export function BannerListPage() {

순서 변경 모드 - 우측의 핸들을 드래그하여 배너 순서를 변경할 수 있습니다. + {isReordering && (순서 변경 중...)}

)} @@ -268,15 +193,12 @@ export function BannerListPage() { className="pl-9" />
- - + - {BANNER_TYPE_OPTIONS.map((option) => ( + {IS_ACTIVE_OPTIONS.map((option) => ( {option.label} @@ -288,101 +210,67 @@ export function BannerListPage() { {/* 테이블 */}
- +
{isReorderMode && 순서} - 이미지 배너명 타입 노출기간 - 상태 {!isReorderMode && 순서} + 상태 {isReorderMode && } {isLoading ? ( - + 로딩 중... ) : data?.items.length === 0 ? ( - - - 검색 결과가 없습니다. - + + 검색 결과가 없습니다. ) : ( data?.items.map((item) => ( handleDragStart(e, item)} - onDragOver={(e) => handleDragOver(e, item.id)} - onDragLeave={handleDragLeave} - onDrop={(e) => handleDrop(e, item)} - onDragEnd={handleDragEnd} + } onClick={() => handleRowClick(item.id)} > - {/* 순서 변경 모드: 좌측에 순서 번호 */} {isReorderMode && ( {item.sortOrder + 1} )} - - {item.imageUrl ? ( - {item.name} - ) : ( -
- -
- )} -
{item.name} - - {BANNER_TYPE_LABELS[item.bannerType]} - + {BANNER_TYPE_LABELS[item.bannerType]} {formatExposurePeriod(item.startDate, item.endDate)} - - {item.isActive ? ( - - - 활성 - - ) : ( - - - 비활성 - - )} - - {/* 일반 모드: 우측에 순서 번호 */} {!isReorderMode && ( {item.sortOrder + 1} )} - {/* 순서 변경 모드: 우측에 드래그 핸들 */} + + handleStatusToggle(item.id, item.isActive)} + disabled={toggleStatusMutation.isPending || isReorderMode} + /> + {isReorderMode && ( (null); - const [dragOverId, setDragOverId] = useState(null); - // URL의 keyword가 변경되면 입력 필드도 동기화 useEffect(() => { setKeywordInput(keyword); @@ -74,8 +68,14 @@ export function CurationListPage() { const toggleStatusMutation = useCurationToggleStatus(); const updateDisplayOrderMutation = useCurationUpdateDisplayOrder(); - // 순서 변경 진행 중 상태 - const [isReordering, setIsReordering] = useState(false); + const { isReorderMode, isReordering, dragOverId, toggleReorderMode, getDragHandlers } = + useReorderDrag({ + items: data?.items, + getOrder: (item) => item.displayOrder, + onReorder: (itemId, newOrder) => + updateDisplayOrderMutation.mutateAsync({ curationId: itemId, data: { displayOrder: newOrder } }), + onAfterReorder: refetch, + }); // URL 파라미터 업데이트 헬퍼 const updateUrlParams = (updates: Record) => { @@ -99,7 +99,7 @@ export function CurationListPage() { const handleSearch = () => { updateUrlParams({ keyword: keywordInput || undefined, - page: '0', // 검색 시 첫 페이지로 + page: '0', }); }; @@ -112,136 +112,26 @@ export function CurationListPage() { const handleIsActiveChange = (value: string) => { updateUrlParams({ isActive: value === 'ALL' ? undefined : value, - page: '0', // 필터 변경 시 첫 페이지로 + page: '0', }); }; const handlePageChange = (newPage: number) => { - updateUrlParams({ - page: String(newPage), - }); + updateUrlParams({ page: String(newPage) }); }; const handlePageSizeChange = (newSize: number) => { - updateUrlParams({ - size: String(newSize), - page: '0', // 페이지 크기 변경 시 첫 페이지로 - }); + updateUrlParams({ size: String(newSize), page: '0' }); }; const handleRowClick = (curationId: number) => { - // 순서 변경 모드에서는 클릭으로 상세 페이지 이동하지 않음 if (!isReorderMode) { navigate(`/curations/${curationId}`); } }; const handleStatusToggle = (curationId: number, currentStatus: boolean) => { - toggleStatusMutation.mutate({ - curationId, - data: { isActive: !currentStatus }, - }); - }; - - // 순서 변경 모드 토글 - const toggleReorderMode = () => { - setIsReorderMode(!isReorderMode); - // 모드 종료 시 드래그 상태 초기화 - if (isReorderMode) { - setDraggedItem(null); - setDragOverId(null); - } - }; - - // 드래그 앤 드롭 핸들러 (순서 변경 모드에서만 동작) - const handleDragStart = (e: React.DragEvent, item: CurationListItem) => { - if (!isReorderMode) return; - setDraggedItem(item); - e.dataTransfer.effectAllowed = 'move'; - }; - - const handleDragOver = (e: React.DragEvent, itemId: number) => { - if (!isReorderMode) return; - e.preventDefault(); - setDragOverId(itemId); - }; - - const handleDragLeave = () => { - if (!isReorderMode) return; - setDragOverId(null); - }; - - const handleDrop = async (e: React.DragEvent, targetItem: CurationListItem) => { - if (!isReorderMode || isReordering) return; - e.preventDefault(); - setDragOverId(null); - - if (!draggedItem || !data?.items || draggedItem.id === targetItem.id) { - setDraggedItem(null); - return; - } - - // 새로운 순서 계산 - const items = [...data.items]; - const draggedIndex = items.findIndex((item) => item.id === draggedItem.id); - const targetIndex = items.findIndex((item) => item.id === targetItem.id); - - if (draggedIndex === -1 || targetIndex === -1) { - setDraggedItem(null); - return; - } - - // 순서 변경 (배열 재정렬) - items.splice(draggedIndex, 1); - items.splice(targetIndex, 0, draggedItem); - - // 영향받는 범위 계산 (draggedIndex ~ targetIndex) - const minIndex = Math.min(draggedIndex, targetIndex); - const maxIndex = Math.max(draggedIndex, targetIndex); - const affectedItems = items.slice(minIndex, maxIndex + 1); - - // 롤백을 위한 기존 displayOrder 저장 - const originalDisplayOrders = new Map( - data.items.map((item) => [item.id, item.displayOrder]) - ); - - // 각 큐레이션의 displayOrder 업데이트 (순차 호출 + 실패 시 롤백) - setIsReordering(true); - try { - for (let idx = 0; idx < affectedItems.length; idx++) { - const item = affectedItems[idx]!; - await updateDisplayOrderMutation.mutateAsync({ - curationId: item.id, - data: { displayOrder: minIndex + idx }, - }); - } - // 목록 새로고침 - await refetch(); - } catch { - // 실패 시 기존 displayOrder로 롤백 시도 - for (const item of affectedItems) { - const originalOrder = originalDisplayOrders.get(item.id); - if (originalOrder === undefined) continue; - try { - await updateDisplayOrderMutation.mutateAsync({ - curationId: item.id, - data: { displayOrder: originalOrder }, - }); - } catch { - // 롤백 중 에러는 무시하고 가능한 한 복구 시도 - } - } - // 롤백 후 목록 새로고침 - await refetch(); - } finally { - setIsReordering(false); - setDraggedItem(null); - } - }; - - const handleDragEnd = () => { - setDraggedItem(null); - setDragOverId(null); + toggleStatusMutation.mutate({ curationId, data: { isActive: !currentStatus } }); }; return ( @@ -289,10 +179,7 @@ export function CurationListPage() { className="pl-9" /> - @@ -314,9 +201,9 @@ export function CurationListPage() { {isReorderMode && 순서} 큐레이션명 - 위스키 수 - {!isReorderMode && 순서} - 상태 + 위스키 수 + {!isReorderMode && 순서} + 상태 {isReorderMode && } @@ -330,31 +217,21 @@ export function CurationListPage() { ) : data?.items.length === 0 ? ( - - 검색 결과가 없습니다. - + 검색 결과가 없습니다. ) : ( data?.items.map((item) => ( handleDragStart(e, item)} - onDragOver={(e) => handleDragOver(e, item.id)} - onDragLeave={handleDragLeave} - onDrop={(e) => handleDrop(e, item)} - onDragEnd={handleDragEnd} + } onClick={() => handleRowClick(item.id)} > - {/* 순서 변경 모드: 좌측에 순서 번호 */} {isReorderMode && ( {item.displayOrder} @@ -362,40 +239,20 @@ export function CurationListPage() { )} {item.name} - - {item.alcoholCount}개 - + {item.alcoholCount}개 - {/* 일반 모드: 우측에 순서 번호 */} {!isReorderMode && ( {item.displayOrder} )} -
e.stopPropagation()} - > - handleStatusToggle(item.id, item.isActive)} - disabled={toggleStatusMutation.isPending || isReorderMode} - /> - {item.isActive ? ( - - - 활성 - - ) : ( - - - 비활성 - - )} -
+ handleStatusToggle(item.id, item.isActive)} + disabled={toggleStatusMutation.isPending || isReorderMode} + />
- {/* 순서 변경 모드: 우측에 드래그 핸들 */} {isReorderMode && ( { - console.log('[DEBUG] handleSubmit callback called', data); onSubmit(data, { tastingTags, relatedKeywords, imagePreviewUrl }); }, - (errors) => { - console.log('[DEBUG] handleSubmit validation errors', errors); - } ); const handleDeleteConfirm = () => { @@ -108,21 +106,29 @@ export function WhiskyDetailPage() { {/* 헤더 */} - {whiskyData && ( - + )} + - )} - - + + ) } /> @@ -137,6 +143,7 @@ export function WhiskyDetailPage() { categories={categories} regions={regions} distilleries={distilleries} + disabled={isDeleted} /> {/* 이미지 + 통계 카드 */} @@ -146,6 +153,7 @@ export function WhiskyDetailPage() { onImageChange={handleImageChange} error={form.formState.errors.imageUrl?.message} isUploading={isImageUploading} + disabled={isDeleted} /> {whiskyData && ( @@ -164,10 +172,11 @@ export function WhiskyDetailPage() { tastingTags={tastingTags} availableTags={availableTags} onTagsChange={setTastingTags} + disabled={isDeleted} /> {/* 연관 키워드 섹션 */} - + )} diff --git a/src/pages/whisky/WhiskyList.tsx b/src/pages/whisky/WhiskyList.tsx index 746f30b..71cbb1e 100644 --- a/src/pages/whisky/WhiskyList.tsx +++ b/src/pages/whisky/WhiskyList.tsx @@ -2,6 +2,7 @@ * 위스키 목록 페이지 * - URL 쿼리파라미터로 검색/필터/페이지네이션 상태 관리 * - 새로고침/뒤로가기 시 상태 유지 + * - 삭제된 데이터 포함 필터 지원 */ import { useState, useEffect } from 'react'; @@ -24,6 +25,9 @@ import { TableRow, } from '@/components/ui/table'; import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; import { Pagination } from '@/components/common/Pagination'; import { useAdminAlcoholList } from '@/hooks/useAdminAlcohols'; import type { AlcoholSearchParams, AlcoholCategory } from '@/types/api'; @@ -47,6 +51,7 @@ export function WhiskyListPage() { const category = urlParams.get('category') as AlcoholCategory | null; const page = Number(urlParams.get('page')) || 0; const size = Number(urlParams.get('size')) || 20; + const includeDeleted = urlParams.get('includeDeleted') === 'true'; // 검색 입력 필드용 로컬 상태 (Enter/버튼 클릭 시에만 URL 반영) const [keywordInput, setKeywordInput] = useState(keyword); @@ -62,10 +67,14 @@ export function WhiskyListPage() { category: category || undefined, page, size, + includeDeleted: includeDeleted || undefined, }; const { data, isLoading } = useAdminAlcoholList(searchParams); + // 테이블 컬럼 수 계산 + const columnCount = includeDeleted ? 7 : 6; + // URL 파라미터 업데이트 헬퍼 const updateUrlParams = (updates: Record) => { const newParams = new URLSearchParams(urlParams); @@ -105,6 +114,13 @@ export function WhiskyListPage() { }); }; + const handleIncludeDeletedChange = (checked: boolean | 'indeterminate') => { + updateUrlParams({ + includeDeleted: checked === true ? 'true' : undefined, + page: '0', // 필터 변경 시 첫 페이지로 + }); + }; + const handlePageChange = (newPage: number) => { updateUrlParams({ page: String(newPage), @@ -158,6 +174,16 @@ export function WhiskyListPage() { +
+ + +
{/* 테이블 */} @@ -171,56 +197,69 @@ export function WhiskyListPage() { 영문명 카테고리 수정일 + {includeDeleted && ( + 상태 + )}
{isLoading ? ( - + 로딩 중... ) : data?.items.length === 0 ? ( - + 검색 결과가 없습니다. ) : ( - data?.items.map((item) => ( - handleRowClick(item.alcoholId)} - > - - {item.alcoholId} - - - {item.imageUrl ? ( - {item.korName} - ) : ( -
- -
+ data?.items.map((item) => { + const isDeleted = item.deletedAt != null; + return ( + handleRowClick(item.alcoholId)} + > + + {item.alcoholId} + + + {item.imageUrl ? ( + {item.korName} + ) : ( +
+ +
+ )} +
+ {item.korName} + + {item.engName} + + {item.korCategoryName} + + {new Date(item.modifiedAt).toLocaleDateString('ko-KR')} + + {includeDeleted && ( + + {isDeleted && ( + 삭제됨 + )} + )} -
- {item.korName} - - {item.engName} - - {item.korCategoryName} - - {new Date(item.modifiedAt).toLocaleDateString('ko-KR')} - -
- )) +
+ ); + }) )}
diff --git a/src/pages/whisky/components/WhiskyBasicInfoCard.tsx b/src/pages/whisky/components/WhiskyBasicInfoCard.tsx index 35fa792..82dbee8 100644 --- a/src/pages/whisky/components/WhiskyBasicInfoCard.tsx +++ b/src/pages/whisky/components/WhiskyBasicInfoCard.tsx @@ -27,6 +27,7 @@ export interface WhiskyBasicInfoCardProps { categories: CategoryReference[]; regions: Array<{ id: number; korName: string }>; distilleries: Array<{ id: number; korName: string }>; + disabled?: boolean; } export function WhiskyBasicInfoCard({ @@ -34,6 +35,7 @@ export function WhiskyBasicInfoCard({ categories, regions, distilleries, + disabled = false, }: WhiskyBasicInfoCardProps) { const { register, watch, setValue, formState } = form; const { errors } = formState; @@ -71,7 +73,7 @@ export function WhiskyBasicInfoCard({ 기본 정보 위스키의 기본 정보를 입력합니다. - + {/* 한글명 / 영문명 */}
diff --git a/src/pages/whisky/components/WhiskyImageCard.tsx b/src/pages/whisky/components/WhiskyImageCard.tsx index 573b5cc..aea6b87 100644 --- a/src/pages/whisky/components/WhiskyImageCard.tsx +++ b/src/pages/whisky/components/WhiskyImageCard.tsx @@ -19,6 +19,7 @@ export interface WhiskyImageCardProps { onImageChange: (file: File | null, previewUrl: string | null) => void; error?: string; isUploading?: boolean; + disabled?: boolean; } export function WhiskyImageCard({ @@ -26,6 +27,7 @@ export function WhiskyImageCard({ onImageChange, error, isUploading = false, + disabled = false, }: WhiskyImageCardProps) { return ( @@ -41,8 +43,8 @@ export function WhiskyImageCard({ 이미지를 드래그하거나 클릭하여 업로드합니다. - - + + {} : onImageChange} /> {error &&

{error}

}
diff --git a/src/pages/whisky/components/WhiskyRelatedKeywordsCard.tsx b/src/pages/whisky/components/WhiskyRelatedKeywordsCard.tsx index ec9d2a0..986cfa5 100644 --- a/src/pages/whisky/components/WhiskyRelatedKeywordsCard.tsx +++ b/src/pages/whisky/components/WhiskyRelatedKeywordsCard.tsx @@ -14,11 +14,13 @@ import { TagSelector } from '@/components/common/TagSelector'; export interface WhiskyRelatedKeywordsCardProps { keywords: string[]; onKeywordsChange: (keywords: string[]) => void; + disabled?: boolean; } export function WhiskyRelatedKeywordsCard({ keywords, onKeywordsChange, + disabled = false, }: WhiskyRelatedKeywordsCardProps) { return ( @@ -28,7 +30,7 @@ export function WhiskyRelatedKeywordsCard({ 검색 시 이 위스키를 찾을 수 있도록 연관 키워드를 입력합니다. - + void; + disabled?: boolean; } export function WhiskyTastingTagCard({ tastingTags, availableTags = [], onTagsChange, + disabled = false, }: WhiskyTastingTagCardProps) { const handleTagsChange = (tags: string[]) => { const newTags = tags.map((name) => { @@ -41,7 +43,7 @@ export function WhiskyTastingTagCard({ 이 위스키의 테이스팅 노트를 선택하거나 직접 추가할 수 있습니다. - + tag.korName)} availableTags={availableTags} diff --git a/src/pages/whisky/useWhiskyDetailForm.ts b/src/pages/whisky/useWhiskyDetailForm.ts index 11674ed..bf0bac6 100644 --- a/src/pages/whisky/useWhiskyDetailForm.ts +++ b/src/pages/whisky/useWhiskyDetailForm.ts @@ -47,6 +47,7 @@ export interface UseWhiskyDetailFormReturn { form: ReturnType>; isLoading: boolean; isNewMode: boolean; + isDeleted: boolean; isPending: boolean; whiskyData: ReturnType['data']; categories: CategoryReference[]; @@ -191,10 +192,13 @@ export function useWhiskyDetailForm(id: string | undefined): UseWhiskyDetailForm } }; + const isDeleted = whiskyData?.deletedAt != null; + return { form, isLoading, isNewMode, + isDeleted, isPending: createMutation.isPending || deleteMutation.isPending || updateMutation.isPending, whiskyData, categories: categoryData ?? [], diff --git a/src/test/mocks/data.ts b/src/test/mocks/data.ts index 909b629..f7abbef 100644 --- a/src/test/mocks/data.ts +++ b/src/test/mocks/data.ts @@ -5,6 +5,8 @@ import type { TastingTagDeleteResponse, TastingTagAlcoholConnectionResponse, TastingTagAlcohol, + AlcoholListItem, + AlcoholDeleteResponse, BannerListItem, BannerDetail, BannerCreateResponse, @@ -109,6 +111,53 @@ export const mockAlcoholDisconnectionResponse: TastingTagAlcoholConnectionRespon responseAt: '2024-06-01T00:00:00', }; +// ============================================ +// Alcohol Mock Data +// ============================================ + +export const mockAlcoholListItems: AlcoholListItem[] = [ + { + alcoholId: 10, + korName: '글렌피딕 12년', + engName: 'Glenfiddich 12', + korCategoryName: '싱글몰트', + engCategoryName: 'Single Malt', + imageUrl: 'https://example.com/glenfiddich.jpg', + createdAt: '2024-01-01T00:00:00', + modifiedAt: '2024-06-01T00:00:00', + deletedAt: null, + }, + { + alcoholId: 20, + korName: '맥캘란 18년', + engName: 'Macallan 18', + korCategoryName: '싱글몰트', + engCategoryName: 'Single Malt', + imageUrl: null, + createdAt: '2024-03-01T00:00:00', + modifiedAt: '2024-06-01T00:00:00', + deletedAt: null, + }, + { + alcoholId: 30, + korName: '삭제된 위스키', + engName: 'Deleted Whisky', + korCategoryName: '블렌디드', + engCategoryName: 'Blend', + imageUrl: null, + createdAt: '2024-01-01T00:00:00', + modifiedAt: '2024-05-01T00:00:00', + deletedAt: '2024-07-01T00:00:00', + }, +]; + +export const mockAlcoholDeleteResponse: AlcoholDeleteResponse = { + code: 'ALCOHOL_DELETED', + message: '위스키가 삭제되었습니다.', + targetId: 10, + responseAt: '2024-06-01T00:00:00', +}; + // ============================================ // Banner Mock Data // ============================================ diff --git a/src/test/mocks/handlers.ts b/src/test/mocks/handlers.ts index 03bd556..8c1de21 100644 --- a/src/test/mocks/handlers.ts +++ b/src/test/mocks/handlers.ts @@ -6,6 +6,8 @@ import { mockDeleteResponse, mockAlcoholConnectionResponse, mockAlcoholDisconnectionResponse, + mockAlcoholListItems, + mockAlcoholDeleteResponse, mockBannerListItems, mockBannerDetail, mockBannerCreateResponse, @@ -231,4 +233,60 @@ export const bannerHandlers = [ }), ]; -export const handlers = [...tastingTagHandlers, ...bannerHandlers]; +// ============================================ +// Alcohol Handlers +// ============================================ + +const ALCOHOL_BASE = '/admin/api/v1/alcohols'; + +export const alcoholHandlers = [ + // GET 목록 + http.get(ALCOHOL_BASE, ({ request }) => { + const url = new URL(request.url); + const keyword = url.searchParams.get('keyword'); + const category = url.searchParams.get('category'); + const includeDeleted = url.searchParams.get('includeDeleted'); + const size = Number(url.searchParams.get('size') ?? 20); + const page = Number(url.searchParams.get('page') ?? 0); + + let items = mockAlcoholListItems; + + // 기본: 삭제된 데이터 제외 + if (includeDeleted !== 'true') { + items = items.filter((item) => item.deletedAt === null); + } + + if (keyword) { + items = items.filter( + (item) => + item.korName.includes(keyword) || + item.engName.toLowerCase().includes(keyword.toLowerCase()) + ); + } + if (category) { + items = items.filter((item) => item.engCategoryName.toUpperCase().replace(' ', '_') === category); + } + + return HttpResponse.json( + wrapApiResponse(items, { + page, + size, + totalElements: items.length, + totalPages: Math.ceil(items.length / size), + hasNext: false, + }) + ); + }), + + // DELETE 삭제 + http.delete(`${ALCOHOL_BASE}/:alcoholId`, ({ params }) => { + return HttpResponse.json( + wrapApiResponse({ + ...mockAlcoholDeleteResponse, + targetId: Number(params.alcoholId), + }) + ); + }), +]; + +export const handlers = [...tastingTagHandlers, ...bannerHandlers, ...alcoholHandlers]; diff --git a/src/types/api/alcohol.api.ts b/src/types/api/alcohol.api.ts index 4a873b1..488cedb 100644 --- a/src/types/api/alcohol.api.ts +++ b/src/types/api/alcohol.api.ts @@ -98,6 +98,8 @@ export interface AlcoholApiTypes { page?: number; /** 페이지 크기 (기본값: 20) */ size?: number; + /** 삭제된 데이터 포함 여부 (기본값: false) */ + includeDeleted?: boolean; }; /** 응답 아이템 */ response: { @@ -117,6 +119,8 @@ export interface AlcoholApiTypes { createdAt: string; /** 수정일시 */ modifiedAt: string; + /** 삭제일시 (null이면 미삭제) */ + deletedAt: string | null; }; /** 페이지네이션 메타 정보 */ meta: { @@ -195,6 +199,8 @@ export interface AlcoholApiTypes { createdAt: string; /** 수정일시 */ modifiedAt: string; + /** 삭제일시 (null이면 미삭제) */ + deletedAt: string | null; }; }; /** 술 생성 */