From 0bab6002e33258fd251b25737ff5c87f9a226635 Mon Sep 17 00:00:00 2001 From: hyejj19 Date: Thu, 16 Apr 2026 10:45:19 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[feat]=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EB=AA=A9=EB=A1=9D=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 목록 조회 API(GET /admin/api/v1/users)를 연동하여 사용자 관리 탭의 목록 페이지를 구현한다. - types/api/user.api.ts: 엔드포인트 정의 및 타입 - services/user.service.ts: API 서비스 레이어 - hooks/useUsers.ts: useUserList 훅 - pages/users/UserList.tsx: 테이블 기반 목록 페이지 (검색, 상태 필터, 정렬, 페이지네이션) - hooks/__tests__/useUsers.test.ts: 훅 테스트 4건 - test/mocks: mock 데이터 및 핸들러 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/hooks/__tests__/useUsers.test.ts | 54 +++++ src/hooks/useUsers.ts | 34 +++ src/pages/users/UserList.tsx | 300 ++++++++++++++++++++++++++- src/services/user.service.ts | 55 +++++ src/test/mocks/data.ts | 50 +++++ src/test/mocks/handlers.ts | 40 +++- src/types/api/index.ts | 1 + src/types/api/user.api.ts | 113 ++++++++++ 8 files changed, 641 insertions(+), 6 deletions(-) create mode 100644 src/hooks/__tests__/useUsers.test.ts create mode 100644 src/hooks/useUsers.ts create mode 100644 src/services/user.service.ts create mode 100644 src/types/api/user.api.ts diff --git a/src/hooks/__tests__/useUsers.test.ts b/src/hooks/__tests__/useUsers.test.ts new file mode 100644 index 0000000..d7ad4dd --- /dev/null +++ b/src/hooks/__tests__/useUsers.test.ts @@ -0,0 +1,54 @@ +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 { useUserList } from '../useUsers'; + +const BASE = '/admin/api/v1/users'; + +describe('useUsers hooks', () => { + describe('useUserList', () => { + it('목록 데이터를 반환한다', async () => { + const { result } = renderHook(() => useUserList()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data!.items.length).toBeGreaterThan(0); + expect(result.current.data!.meta.totalElements).toBeGreaterThan(0); + }); + + it('keyword로 필터링한다', async () => { + const { result } = renderHook(() => useUserList({ keyword: '위스키러버' })); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data!.items).toHaveLength(1); + expect(result.current.data!.items[0]!.nickName).toBe('위스키러버'); + }); + + it('status로 필터링한다', async () => { + const { result } = renderHook(() => useUserList({ status: 'DELETED' })); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data!.items).toHaveLength(1); + expect(result.current.data!.items[0]!.status).toBe('DELETED'); + }); + + it('API 에러 시 에러 상태가 된다', async () => { + server.use( + http.get(BASE, () => { + return HttpResponse.json(wrapApiError(500, 'SERVER_ERROR', '서버 오류'), { + status: 500, + }); + }) + ); + + const { result } = renderHook(() => useUserList()); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + }); +}); diff --git a/src/hooks/useUsers.ts b/src/hooks/useUsers.ts new file mode 100644 index 0000000..53084a2 --- /dev/null +++ b/src/hooks/useUsers.ts @@ -0,0 +1,34 @@ +/** + * 사용자 API 커스텀 훅 + */ + +import { useApiQuery } from './useApiQuery'; +import { + userService, + userKeys, + type UserListResponse, +} from '@/services/user.service'; +import type { UserSearchParams } from '@/types/api'; + +/** + * 사용자 목록 조회 훅 + * + * @example + * ```tsx + * const { data, isLoading } = useUserList({ keyword: 'test', status: 'ACTIVE' }); + * + * if (data) { + * console.log(data.items); // 사용자 목록 + * console.log(data.meta); // 페이지네이션 정보 + * } + * ``` + */ +export function useUserList(params?: UserSearchParams) { + return useApiQuery( + userKeys.list(params), + () => userService.list(params), + { + staleTime: 1000 * 60 * 5, // 5분 + } + ); +} diff --git a/src/pages/users/UserList.tsx b/src/pages/users/UserList.tsx index 032149a..67043ca 100644 --- a/src/pages/users/UserList.tsx +++ b/src/pages/users/UserList.tsx @@ -1,22 +1,312 @@ /** * 사용자 관리 페이지 (ROOT_ADMIN 전용) + * - URL 쿼리파라미터로 검색/필터/정렬/페이지네이션 상태 관리 + * - 읽기 전용 목록 (현재 API는 목록 조회만 지원) */ +import { useState, useEffect } from 'react'; +import { useSearchParams } from 'react-router'; +import { Search, Users } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Pagination } from '@/components/common/Pagination'; +import { useUserList } from '@/hooks/useUsers'; +import type { UserSearchParams, UserStatus, UserSortType, UserSortOrder } from '@/types/api'; + +const STATUS_OPTIONS = [ + { value: 'ALL', label: '전체' }, + { value: 'ACTIVE', label: '활성' }, + { value: 'DELETED', label: '탈퇴' }, +]; + +const SORT_OPTIONS = [ + { value: 'CREATED_AT', label: '가입일' }, + { value: 'NICK_NAME', label: '닉네임' }, + { value: 'EMAIL', label: '이메일' }, + { value: 'REVIEW_COUNT', label: '리뷰 수' }, + { value: 'RATING_COUNT', label: '평점 수' }, +]; + +const SOCIAL_TYPE_COLORS: Record = { + KAKAO: 'bg-yellow-100 text-yellow-800', + NAVER: 'bg-green-100 text-green-800', + GOOGLE: 'bg-blue-100 text-blue-800', + APPLE: 'bg-gray-100 text-gray-800', +}; + +function formatDate(dateStr: string | null) { + if (!dateStr) return '-'; + const date = new Date(dateStr); + return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`; +} + export function UserListPage() { + const [urlParams, setUrlParams] = useSearchParams(); + + // URL에서 검색 파라미터 읽기 + const keyword = urlParams.get('keyword') ?? ''; + const statusParam = urlParams.get('status'); + const sortType = (urlParams.get('sortType') ?? 'CREATED_AT') as UserSortType; + const sortOrder = (urlParams.get('sortOrder') ?? 'DESC') as UserSortOrder; + const page = Number(urlParams.get('page')) || 0; + const size = Number(urlParams.get('size')) || 20; + + // 검색 입력 필드용 로컬 상태 + const [keywordInput, setKeywordInput] = useState(keyword); + + useEffect(() => { + setKeywordInput(keyword); + }, [keyword]); + + // API 요청용 파라미터 + const searchParams: UserSearchParams = { + keyword: keyword || undefined, + status: statusParam && statusParam !== 'ALL' ? (statusParam as UserStatus) : undefined, + sortType, + sortOrder, + page, + size, + }; + + const { data, isLoading } = useUserList(searchParams); + + // URL 파라미터 업데이트 헬퍼 + const updateUrlParams = (updates: Record) => { + const newParams = new URLSearchParams(urlParams); + + Object.entries(updates).forEach(([key, value]) => { + if (value === undefined || value === '') { + newParams.delete(key); + } else { + newParams.set(key, value); + } + }); + + // 기본값은 URL에서 제거 + if (newParams.get('page') === '0') newParams.delete('page'); + if (newParams.get('size') === '20') newParams.delete('size'); + if (newParams.get('sortType') === 'CREATED_AT') newParams.delete('sortType'); + if (newParams.get('sortOrder') === 'DESC') newParams.delete('sortOrder'); + + setUrlParams(newParams); + }; + + const handleSearch = () => { + updateUrlParams({ + keyword: keywordInput || undefined, + page: '0', + }); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSearch(); + } + }; + + const handleStatusChange = (value: string) => { + updateUrlParams({ + status: value === 'ALL' ? undefined : value, + page: '0', + }); + }; + + const handleSortTypeChange = (value: string) => { + updateUrlParams({ + sortType: value === 'CREATED_AT' ? undefined : value, + page: '0', + }); + }; + + const handleSortOrderToggle = () => { + updateUrlParams({ + sortOrder: sortOrder === 'DESC' ? 'ASC' : undefined, + }); + }; + + const handlePageChange = (newPage: number) => { + updateUrlParams({ page: String(newPage) }); + }; + + const handlePageSizeChange = (newSize: number) => { + updateUrlParams({ size: String(newSize), page: '0' }); + }; + return (
+ {/* 헤더 */}

사용자 관리

- 어드민 사용자를 관리합니다. + 서비스에 가입한 사용자를 조회합니다.

-
-

- API 연동 후 사용자 목록이 표시됩니다. -

+ {/* 필터 */} +
+
+ + setKeywordInput(e.target.value)} + onKeyDown={handleKeyDown} + className="pl-9" + /> +
+ + + +
+ + {/* 테이블 */} +
+ + + + 프로필 + 닉네임 + 이메일 + 역할 + 상태 + 소셜 로그인 + 리뷰 + 평점 + + 가입일 + 최근 로그인 + + + + {isLoading ? ( + + + 로딩 중... + + + ) : data?.items.length === 0 ? ( + + +
+ + + {keyword ? '검색 결과가 없습니다.' : '등록된 사용자가 없습니다.'} + +
+
+
+ ) : ( + data?.items.map((user) => ( + + + + + + {user.nickName.charAt(0)} + + + + {user.nickName} + {user.email} + + + {user.role === 'ROLE_ADMIN' ? '관리자' : '일반'} + + + + + {user.status === 'ACTIVE' ? '활성' : '탈퇴'} + + + +
+ {user.socialType.map((type) => ( + + {type} + + ))} +
+
+ + {user.reviewCount.toLocaleString()} + + + {user.ratingCount.toLocaleString()} + + + {user.picksCount.toLocaleString()} + + + {formatDate(user.createAt)} + + + {formatDate(user.lastLoginAt)} + +
+ )) + )} +
+
+
+ + {/* 페이지네이션 */} + {data && data.items.length > 0 && ( + + )}
); } diff --git a/src/services/user.service.ts b/src/services/user.service.ts new file mode 100644 index 0000000..3e99803 --- /dev/null +++ b/src/services/user.service.ts @@ -0,0 +1,55 @@ +/** + * 사용자 API 서비스 + */ + +import { apiClient } from '@/lib/api-client'; +import { createQueryKeys } from '@/hooks/useApiQuery'; +import { + UserApi, + type UserSearchParams, + type UserListItem, + type UserPageMeta, +} from '@/types/api'; + +// ============================================ +// Query Keys +// ============================================ + +export const userKeys = createQueryKeys('users'); + +// ============================================ +// 응답 타입 (페이지네이션 포함) +// ============================================ + +export interface UserListResponse { + items: UserListItem[]; + meta: UserPageMeta; +} + +// ============================================ +// Service +// ============================================ + +export const userService = { + /** + * 사용자 목록 조회 + * 페이지 기반 페이지네이션 (meta에 페이지 정보 포함) + */ + list: async (params?: UserSearchParams): Promise => { + const response = await apiClient.getWithMeta( + UserApi.list.endpoint, + { params } + ); + + return { + items: response.data ?? [], + meta: { + page: response.meta.page ?? params?.page ?? 0, + size: response.meta.size ?? params?.size ?? 20, + totalElements: response.meta.totalElements ?? 0, + totalPages: response.meta.totalPages ?? 0, + hasNext: response.meta.hasNext ?? false, + }, + }; + }, +}; diff --git a/src/test/mocks/data.ts b/src/test/mocks/data.ts index 50f7bb0..771bfcc 100644 --- a/src/test/mocks/data.ts +++ b/src/test/mocks/data.ts @@ -15,6 +15,7 @@ import type { BannerDeleteResponse, BannerUpdateStatusResponse, BannerUpdateSortOrderResponse, + UserListItem, } from '@/types/api'; export const mockTastingTagListItems: TastingTagListItem[] = [ @@ -268,6 +269,55 @@ export const mockBannerUpdateSortOrderResponse: BannerUpdateSortOrderResponse = responseAt: '2024-06-01T00:00:00', }; +// ============================================ +// User Mock Data +// ============================================ + +export const mockUserListItems: UserListItem[] = [ + { + userId: 1, + email: 'whisky@example.com', + nickName: '위스키러버', + imageUrl: 'https://example.com/user1.jpg', + role: 'ROLE_USER', + status: 'ACTIVE', + socialType: ['KAKAO'], + reviewCount: 15, + ratingCount: 42, + picksCount: 8, + createAt: '2024-01-15T10:00:00', + lastLoginAt: '2024-06-01T18:30:00', + }, + { + userId: 2, + email: 'admin@bottlenote.com', + nickName: '관리자', + imageUrl: null, + role: 'ROLE_ADMIN', + status: 'ACTIVE', + socialType: ['GOOGLE', 'APPLE'], + reviewCount: 0, + ratingCount: 5, + picksCount: 2, + createAt: '2023-12-01T09:00:00', + lastLoginAt: '2024-06-10T08:00:00', + }, + { + userId: 3, + email: 'deleted@example.com', + nickName: '탈퇴유저', + imageUrl: null, + role: 'ROLE_USER', + status: 'DELETED', + socialType: ['NAVER'], + reviewCount: 3, + ratingCount: 10, + picksCount: 1, + createAt: '2024-02-01T12:00:00', + lastLoginAt: null, + }, +]; + // ============================================ // Utility Functions // ============================================ diff --git a/src/test/mocks/handlers.ts b/src/test/mocks/handlers.ts index 5a1d150..d9551af 100644 --- a/src/test/mocks/handlers.ts +++ b/src/test/mocks/handlers.ts @@ -16,6 +16,7 @@ import { mockBannerDeleteResponse, mockBannerUpdateStatusResponse, mockBannerUpdateSortOrderResponse, + mockUserListItems, wrapApiResponse, } from './data'; @@ -295,4 +296,41 @@ export const alcoholHandlers = [ }), ]; -export const handlers = [...tastingTagHandlers, ...bannerHandlers, ...alcoholHandlers]; +// ============================================ +// User Handlers +// ============================================ + +const USER_BASE = '/admin/api/v1/users'; + +export const userHandlers = [ + // GET 목록 + http.get(USER_BASE, ({ request }) => { + const url = new URL(request.url); + const keyword = url.searchParams.get('keyword'); + const status = url.searchParams.get('status'); + const size = Number(url.searchParams.get('size') ?? 20); + const page = Number(url.searchParams.get('page') ?? 0); + + let items = mockUserListItems; + if (keyword) { + items = items.filter( + (u) => u.nickName.includes(keyword) || u.email.toLowerCase().includes(keyword.toLowerCase()) + ); + } + if (status) { + items = items.filter((u) => u.status === status); + } + + return HttpResponse.json( + wrapApiResponse(items, { + page, + size, + totalElements: items.length, + totalPages: Math.ceil(items.length / size), + hasNext: false, + }) + ); + }), +]; + +export const handlers = [...tastingTagHandlers, ...bannerHandlers, ...alcoholHandlers, ...userHandlers]; diff --git a/src/types/api/index.ts b/src/types/api/index.ts index 82f1a16..fb76aa4 100644 --- a/src/types/api/index.ts +++ b/src/types/api/index.ts @@ -11,3 +11,4 @@ export * from './distillery.api'; export * from './s3.api'; export * from './banner.api'; export * from './curation.api'; +export * from './user.api'; diff --git a/src/types/api/user.api.ts b/src/types/api/user.api.ts new file mode 100644 index 0000000..50c598f --- /dev/null +++ b/src/types/api/user.api.ts @@ -0,0 +1,113 @@ +/** + * User API 타입 정의 + * 어드민 사용자 관련 API 스펙 + */ + +// ============================================ +// API 엔드포인트 정의 +// ============================================ + +export const UserApi = { + /** 사용자 목록 조회 */ + list: { + endpoint: '/admin/api/v1/users', + method: 'GET', + }, +} as const; + +// ============================================ +// 공통 타입 +// ============================================ + +/** 사용자 상태 */ +export type UserStatus = 'ACTIVE' | 'DELETED'; + +/** 사용자 역할 */ +export type UserRole = 'ROLE_USER' | 'ROLE_ADMIN'; + +/** 소셜 로그인 타입 */ +export type UserSocialType = 'KAKAO' | 'NAVER' | 'GOOGLE' | 'APPLE'; + +/** 사용자 목록 정렬 기준 */ +export type UserSortType = 'CREATED_AT' | 'NICK_NAME' | 'EMAIL' | 'RATING_COUNT' | 'REVIEW_COUNT'; + +/** 정렬 방향 */ +export type UserSortOrder = 'ASC' | 'DESC'; + +// ============================================ +// API 타입 정의 +// ============================================ + +export interface UserApiTypes { + /** 사용자 목록 조회 */ + list: { + /** 요청 파라미터 */ + params: { + /** 검색어 (닉네임/이메일 검색) */ + keyword?: string; + /** 사용자 상태 필터 */ + status?: UserStatus; + /** 정렬 기준 */ + sortType?: UserSortType; + /** 정렬 방향 (기본값: DESC) */ + sortOrder?: UserSortOrder; + /** 페이지 번호 (기본값: 0) */ + page?: number; + /** 페이지 크기 (기본값: 20) */ + size?: number; + }; + /** 응답 아이템 */ + response: { + /** 사용자 ID */ + userId: number; + /** 이메일 */ + email: string; + /** 닉네임 */ + nickName: string; + /** 프로필 이미지 URL */ + imageUrl: string | null; + /** 역할 */ + role: UserRole; + /** 상태 */ + status: UserStatus; + /** 소셜 로그인 타입 목록 */ + socialType: UserSocialType[]; + /** 리뷰 수 */ + reviewCount: number; + /** 평점 수 */ + ratingCount: number; + /** 픽 수 */ + picksCount: number; + /** 가입일시 */ + createAt: string; + /** 최근 로그인 일시 */ + lastLoginAt: string | null; + }; + /** 페이지네이션 메타 정보 */ + meta: { + /** 현재 페이지 */ + page: number; + /** 페이지 크기 */ + size: number; + /** 전체 요소 수 */ + totalElements: number; + /** 전체 페이지 수 */ + totalPages: number; + /** 다음 페이지 존재 여부 */ + hasNext: boolean; + }; + }; +} + +// ============================================ +// 헬퍼 타입 +// ============================================ + +/** 사용자 목록 조회 파라미터 */ +export type UserSearchParams = UserApiTypes['list']['params']; + +/** 사용자 목록 아이템 */ +export type UserListItem = UserApiTypes['list']['response']; + +/** 사용자 목록 페이지네이션 메타 */ +export type UserPageMeta = UserApiTypes['list']['meta']; From be3f1e8c7138fe1e3a3c0d5dd4d15916d0846bd6 Mon Sep 17 00:00:00 2001 From: hyejj19 Date: Tue, 21 Apr 2026 20:24:19 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[feat]=20=EC=A6=9D=EB=A5=98=EC=86=8C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 증류소 목록/상세/등록/수정/삭제 CRUD 구현 - API 타입, 서비스, 훅 3계층 추가 (기존 tasting-tag 패턴) - TDD: useDistilleries 훅 테스트 11개 케이스 - 사이드바 메뉴 및 라우트 등록 (ROOT_ADMIN 전용) - 백엔드는 목록 조회만 존재, 나머지 CRUD 엔드포인트는 예측 스펙으로 구현 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/config/menu.config.ts | 20 ++ src/hooks/__tests__/useDistilleries.test.ts | 169 +++++++++++++ src/hooks/useDistilleries.ts | 115 ++++++++- src/pages/distilleries/DistilleryDetail.tsx | 259 ++++++++++++++++++++ src/pages/distilleries/DistilleryList.tsx | 222 +++++++++++++++++ src/pages/distilleries/distillery.schema.ts | 15 ++ src/routes/index.tsx | 28 +++ src/services/distillery.service.ts | 38 +++ src/test/mocks/data.ts | 53 ++++ src/test/mocks/handlers.ts | 93 ++++++- src/types/api/distillery.api.ts | 95 +++++++ 11 files changed, 1095 insertions(+), 12 deletions(-) create mode 100644 src/hooks/__tests__/useDistilleries.test.ts create mode 100644 src/pages/distilleries/DistilleryDetail.tsx create mode 100644 src/pages/distilleries/DistilleryList.tsx create mode 100644 src/pages/distilleries/distillery.schema.ts diff --git a/src/config/menu.config.ts b/src/config/menu.config.ts index dc6d7e3..2194ce8 100644 --- a/src/config/menu.config.ts +++ b/src/config/menu.config.ts @@ -13,6 +13,7 @@ import { Plus, LayoutDashboard, Layers, + Factory, } from 'lucide-react'; import type { MenuGroup } from '@/types/menu'; @@ -75,6 +76,25 @@ export const menuConfig: MenuGroup[] = [ }, ], }, + { + id: 'distillery-management', + label: '증류소 관리', + icon: Factory, + children: [ + { + id: 'distillery-list', + label: '증류소 목록', + icon: List, + path: '/distilleries', + }, + { + id: 'distillery-create', + label: '증류소 추가', + icon: Plus, + path: '/distilleries/new', + }, + ], + }, ], }, ], diff --git a/src/hooks/__tests__/useDistilleries.test.ts b/src/hooks/__tests__/useDistilleries.test.ts new file mode 100644 index 0000000..2158cf0 --- /dev/null +++ b/src/hooks/__tests__/useDistilleries.test.ts @@ -0,0 +1,169 @@ +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 { + useDistilleryList, + useDistilleryDetail, + useDistilleryCreate, + useDistilleryUpdate, + useDistilleryDelete, +} from '../useDistilleries'; + +const BASE = '/admin/api/v1/distilleries'; + +describe('useDistilleries hooks', () => { + // ========================================== + // useDistilleryList + // ========================================== + describe('useDistilleryList', () => { + it('목록 데이터를 반환한다', async () => { + const { result } = renderHook(() => useDistilleryList()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data!.items.length).toBeGreaterThan(0); + expect(result.current.data!.meta.totalElements).toBeGreaterThan(0); + }); + + it('keyword로 필터링한다', async () => { + const { result } = renderHook(() => useDistilleryList({ keyword: '맥캘란' })); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data!.items).toHaveLength(1); + expect(result.current.data!.items[0]!.korName).toBe('맥캘란'); + }); + + it('API 에러 시 에러 상태가 된다', async () => { + server.use( + http.get(BASE, () => { + return HttpResponse.json(wrapApiError(500, 'SERVER_ERROR', '서버 오류'), { + status: 500, + }); + }) + ); + + const { result } = renderHook(() => useDistilleryList()); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + }); + + // ========================================== + // useDistilleryDetail + // ========================================== + describe('useDistilleryDetail', () => { + it('id가 undefined이면 쿼리가 비활성화된다', () => { + const { result } = renderHook(() => useDistilleryDetail(undefined)); + expect(result.current.fetchStatus).toBe('idle'); + }); + + it('상세 데이터를 반환한다', async () => { + const { result } = renderHook(() => useDistilleryDetail(1)); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data!.id).toBe(1); + expect(result.current.data!.korName).toBe('맥캘란'); + expect(result.current.data!.regionId).toBe(1); + expect(result.current.data!.korRegion).toBe('스페이사이드'); + }); + + it('존재하지 않는 ID는 에러 상태가 된다', async () => { + const { result } = renderHook(() => useDistilleryDetail(9999)); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + }); + + // ========================================== + // useDistilleryCreate + // ========================================== + describe('useDistilleryCreate', () => { + it('생성 mutation이 성공한다', async () => { + const onSuccess = vi.fn(); + const { result } = renderHook(() => useDistilleryCreate({ onSuccess })); + + result.current.mutate({ + korName: '새 증류소', + engName: 'New Distillery', + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(onSuccess).toHaveBeenCalled(); + }); + + it('에러 시 에러 상태가 된다', async () => { + server.use( + http.post(BASE, () => { + return HttpResponse.json( + wrapApiError(400, 'DUPLICATE_NAME', '이미 존재하는 증류소명입니다.'), + { status: 400 } + ); + }) + ); + + const { result } = renderHook(() => useDistilleryCreate()); + + result.current.mutate({ + korName: '맥캘란', + engName: 'Macallan', + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + }); + + // ========================================== + // useDistilleryUpdate + // ========================================== + describe('useDistilleryUpdate', () => { + it('수정 mutation이 성공한다', async () => { + const onSuccess = vi.fn(); + const { result } = renderHook(() => useDistilleryUpdate({ onSuccess })); + + result.current.mutate({ + id: 1, + data: { korName: '수정됨', engName: 'Updated' }, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(onSuccess).toHaveBeenCalled(); + }); + }); + + // ========================================== + // useDistilleryDelete + // ========================================== + describe('useDistilleryDelete', () => { + it('삭제 mutation이 성공한다', async () => { + const onSuccess = vi.fn(); + const { result } = renderHook(() => useDistilleryDelete({ onSuccess })); + + result.current.mutate(1); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(onSuccess).toHaveBeenCalled(); + }); + + it('에러 시 에러 상태가 된다', async () => { + server.use( + http.delete(`${BASE}/:id`, () => { + return HttpResponse.json( + wrapApiError(400, 'HAS_REFERENCES', '연관된 위스키가 있어 삭제할 수 없습니다.'), + { status: 400 } + ); + }) + ); + + const { result } = renderHook(() => useDistilleryDelete()); + + result.current.mutate(1); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + }); +}); diff --git a/src/hooks/useDistilleries.ts b/src/hooks/useDistilleries.ts index fb25841..9fc3a57 100644 --- a/src/hooks/useDistilleries.ts +++ b/src/hooks/useDistilleries.ts @@ -2,26 +2,24 @@ * Distillery API 커스텀 훅 */ +import { useQueryClient } from '@tanstack/react-query'; import { useApiQuery } from './useApiQuery'; +import { useApiMutation, type UseApiMutationOptions } from './useApiMutation'; import { distilleryService, distilleryKeys, type DistilleryListResponse, } from '@/services/distillery.service'; -import type { DistillerySearchParams } from '@/types/api'; +import type { + DistillerySearchParams, + DistilleryDetail, + DistilleryFormData, + DistilleryFormResponse, + DistilleryDeleteResponse, +} from '@/types/api'; /** * 증류소 목록 조회 훅 - * - * @example - * ```tsx - * const { data, isLoading } = useDistilleryList(); - * - * if (data) { - * console.log(data.items); // 증류소 목록 - * console.log(data.meta); // 페이지네이션 정보 - * } - * ``` */ export function useDistilleryList(params?: DistillerySearchParams) { return useApiQuery( @@ -32,3 +30,98 @@ export function useDistilleryList(params?: DistillerySearchParams) { } ); } + +/** + * 증류소 상세 조회 훅 + */ +export function useDistilleryDetail(id: number | undefined) { + return useApiQuery( + distilleryKeys.detail(id ?? 0), + () => distilleryService.detail(id!), + { + enabled: !!id, + staleTime: 1000 * 60 * 5, + } + ); +} + +/** + * 증류소 생성 훅 + */ +export function useDistilleryCreate( + options?: Omit< + UseApiMutationOptions, + 'successMessage' + > +) { + const queryClient = useQueryClient(); + const { onSuccess, ...restOptions } = options ?? {}; + + return useApiMutation( + distilleryService.create, + { + successMessage: '증류소가 등록되었습니다.', + ...restOptions, + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: distilleryKeys.lists() }); + if (onSuccess) { + (onSuccess as (data: DistilleryFormResponse, variables: DistilleryFormData, context: unknown) => void)(data, variables, context); + } + }, + } + ); +} + +export interface DistilleryUpdateVariables { + id: number; + data: DistilleryFormData; +} + +/** + * 증류소 수정 훅 + */ +export function useDistilleryUpdate( + options?: Omit, 'successMessage'> +) { + const queryClient = useQueryClient(); + const { onSuccess, ...restOptions } = options ?? {}; + + return useApiMutation( + ({ id, data }) => distilleryService.update(id, data), + { + successMessage: '증류소가 수정되었습니다.', + ...restOptions, + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: distilleryKeys.lists() }); + queryClient.invalidateQueries({ queryKey: distilleryKeys.details() }); + if (onSuccess) { + (onSuccess as (data: DistilleryFormResponse, variables: DistilleryUpdateVariables, context: unknown) => void)(data, variables, context); + } + }, + } + ); +} + +/** + * 증류소 삭제 훅 + */ +export function useDistilleryDelete( + options?: Omit, 'successMessage'> +) { + const queryClient = useQueryClient(); + const { onSuccess, ...restOptions } = options ?? {}; + + return useApiMutation( + distilleryService.delete, + { + successMessage: '증류소가 삭제되었습니다.', + ...restOptions, + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: distilleryKeys.lists() }); + if (onSuccess) { + (onSuccess as (data: DistilleryDeleteResponse, variables: number, context: unknown) => void)(data, variables, context); + } + }, + } + ); +} diff --git a/src/pages/distilleries/DistilleryDetail.tsx b/src/pages/distilleries/DistilleryDetail.tsx new file mode 100644 index 0000000..be89be5 --- /dev/null +++ b/src/pages/distilleries/DistilleryDetail.tsx @@ -0,0 +1,259 @@ +/** + * 증류소 상세 페이지 + * - 신규 등록 (id가 없거나 'new'인 경우) + * - 상세 조회 및 수정 (id가 숫자인 경우) + */ + +import { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router'; +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Save, Trash2 } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; + +import { DetailPageHeader } from '@/components/common/DetailPageHeader'; +import { ImageUpload } from '@/components/common/ImageUpload'; +import { FormField } from '@/components/common/FormField'; +import { DeleteConfirmDialog } from '@/components/common/DeleteConfirmDialog'; + +import { + useDistilleryDetail, + useDistilleryCreate, + useDistilleryUpdate, + useDistilleryDelete, +} from '@/hooks/useDistilleries'; +import { useRegionList } from '@/hooks/useRegions'; + +import { + distilleryFormSchema, + distilleryDefaultValues, + type DistilleryFormValues, +} from './distillery.schema'; + +export function DistilleryDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const isNewMode = !id || id === 'new'; + const distilleryId = isNewMode ? undefined : Number(id); + + // API 조회 + const { data: detailData, isLoading } = useDistilleryDetail(distilleryId); + const { data: regionData } = useRegionList({ size: 500 }); + + // Mutations + const createMutation = useDistilleryCreate({ + onSuccess: () => { + navigate('/distilleries'); + }, + }); + + const updateMutation = useDistilleryUpdate({ + onSuccess: () => { + // 수정 완료 후 페이지에 남아있음 + }, + }); + + const deleteMutation = useDistilleryDelete({ + onSuccess: () => { + navigate('/distilleries'); + }, + }); + + // 상태 + const [logoUrl, setLogoUrl] = useState(null); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + // React Hook Form 설정 + const form = useForm({ + resolver: zodResolver(distilleryFormSchema), + defaultValues: distilleryDefaultValues, + }); + + // API 데이터로 폼 초기화 + useEffect(() => { + if (isNewMode) { + form.reset(distilleryDefaultValues); + setLogoUrl(null); + } else if (detailData) { + form.reset({ + korName: detailData.korName, + engName: detailData.engName, + regionId: detailData.regionId, + }); + setLogoUrl(detailData.logoImgUrl); + } + }, [detailData, form, isNewMode]); + + // 로고 이미지 변경 + const handleLogoChange = (file: File | null, previewUrl: string | null) => { + if (file) { + const reader = new FileReader(); + reader.onloadend = () => { + setLogoUrl(reader.result as string); + }; + reader.readAsDataURL(file); + } else { + setLogoUrl(previewUrl); + } + }; + + const onSubmit = (data: DistilleryFormValues) => { + const formData = { + korName: data.korName, + engName: data.engName, + logoImgUrl: logoUrl, + regionId: data.regionId, + }; + + if (isNewMode) { + createMutation.mutate(formData); + } else if (distilleryId) { + updateMutation.mutate({ id: distilleryId, data: formData }); + } + }; + + const handleDeleteConfirm = () => { + if (distilleryId) { + deleteMutation.mutate(distilleryId); + } + }; + + const handleBack = () => navigate('/distilleries'); + + const isMutating = createMutation.isPending || updateMutation.isPending; + + const regions = regionData?.items ?? []; + + return ( +
+ {/* 헤더 */} + + {detailData && ( + + )} + + + } + /> + + {isLoading ? ( +
로딩 중...
+ ) : ( +
+ {/* 기본 정보 카드 */} + + + 기본 정보 + 증류소의 기본 정보를 입력합니다. + + + {/* 한글명 / 영문명 */} +
+ + + + + + +
+ + {/* 지역 선택 */} + + ( + + )} + /> + +
+
+ + {/* 로고 카드 */} + + + 로고 + + 증류소 로고 이미지를 업로드합니다. (선택) + + + + + + +
+ )} + + {/* 삭제 확인 다이얼로그 */} + +
+ ); +} diff --git a/src/pages/distilleries/DistilleryList.tsx b/src/pages/distilleries/DistilleryList.tsx new file mode 100644 index 0000000..aae6318 --- /dev/null +++ b/src/pages/distilleries/DistilleryList.tsx @@ -0,0 +1,222 @@ +/** + * 증류소 목록 페이지 + * - URL 쿼리파라미터로 검색/페이지네이션 상태 관리 + * - 테이블 형태 목록 (ID, 로고, 한글명, 영문명, 수정일) + */ + +import { useState, useEffect } from 'react'; +import { useNavigate, useSearchParams } from 'react-router'; +import { Search, Plus, ImageOff } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { Pagination } from '@/components/common/Pagination'; +import { useDistilleryList } from '@/hooks/useDistilleries'; +import type { DistillerySearchParams } from '@/types/api'; + +export function DistilleryListPage() { + const navigate = useNavigate(); + const [urlParams, setUrlParams] = useSearchParams(); + + // URL에서 검색 파라미터 읽기 + const keyword = urlParams.get('keyword') ?? ''; + const page = Number(urlParams.get('page')) || 0; + const size = Number(urlParams.get('size')) || 20; + const sortOrder = (urlParams.get('sortOrder') as 'ASC' | 'DESC') ?? 'ASC'; + + // 검색 입력 필드용 로컬 상태 + const [keywordInput, setKeywordInput] = useState(keyword); + + useEffect(() => { + setKeywordInput(keyword); + }, [keyword]); + + // API 요청용 파라미터 + const searchParams: DistillerySearchParams = { + keyword: keyword || undefined, + page, + size, + sortOrder, + }; + + const { data, isLoading } = useDistilleryList(searchParams); + + // URL 파라미터 업데이트 헬퍼 + const updateUrlParams = (updates: Record) => { + const newParams = new URLSearchParams(urlParams); + + Object.entries(updates).forEach(([key, value]) => { + if (value === undefined || value === '') { + newParams.delete(key); + } else { + newParams.set(key, value); + } + }); + + if (newParams.get('page') === '0') newParams.delete('page'); + if (newParams.get('size') === '20') newParams.delete('size'); + if (newParams.get('sortOrder') === 'ASC') newParams.delete('sortOrder'); + + setUrlParams(newParams); + }; + + const handleSearch = () => { + updateUrlParams({ + keyword: keywordInput || undefined, + page: '0', + }); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSearch(); + } + }; + + const handleSortOrderChange = (value: string) => { + updateUrlParams({ + sortOrder: value === 'ASC' ? undefined : value, + page: '0', + }); + }; + + const handlePageChange = (newPage: number) => { + updateUrlParams({ page: String(newPage) }); + }; + + const handlePageSizeChange = (newSize: number) => { + updateUrlParams({ size: String(newSize), page: '0' }); + }; + + const handleRowClick = (id: number) => { + navigate(`/distilleries/${id}`); + }; + + return ( +
+ {/* 헤더 */} +
+
+

증류소 목록

+

등록된 증류소를 관리합니다.

+
+ +
+ + {/* 필터 */} +
+
+ + setKeywordInput(e.target.value)} + onKeyDown={handleKeyDown} + className="pl-9" + /> +
+ + +
+ + {/* 테이블 */} +
+ + + + ID + 로고 + 한글명 + 영문명 + 수정일 + + + + {isLoading ? ( + + + 로딩 중... + + + ) : data?.items.length === 0 ? ( + + + + {keyword ? '검색 결과가 없습니다.' : '등록된 증류소가 없습니다.'} + + + + ) : ( + data?.items.map((item) => ( + handleRowClick(item.id)} + > + {item.id} + + {item.logoImgUrl ? ( + {item.korName} + ) : ( +
+ +
+ )} +
+ {item.korName} + {item.engName} + + {new Date(item.modifiedAt).toLocaleDateString('ko-KR')} + +
+ )) + )} +
+
+
+ + {/* 페이지네이션 */} + {data && data.items.length > 0 && ( + + )} +
+ ); +} diff --git a/src/pages/distilleries/distillery.schema.ts b/src/pages/distilleries/distillery.schema.ts new file mode 100644 index 0000000..81e7ad6 --- /dev/null +++ b/src/pages/distilleries/distillery.schema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const distilleryFormSchema = z.object({ + korName: z.string().min(1, '한글명은 필수입니다'), + engName: z.string().min(1, '영문명은 필수입니다'), + regionId: z.number().nullable(), +}); + +export type DistilleryFormValues = z.infer; + +export const distilleryDefaultValues: DistilleryFormValues = { + korName: '', + engName: '', + regionId: null, +}; diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 2590c71..eb53a57 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -22,6 +22,8 @@ import { CurationDetailPage } from '@/pages/curations/CurationDetail'; import { InquiryListPage } from '@/pages/inquiries/InquiryList'; import { PolicyListPage } from '@/pages/policies/PolicyList'; import { UserListPage } from '@/pages/users/UserList'; +import { DistilleryListPage } from '@/pages/distilleries/DistilleryList'; +import { DistilleryDetailPage } from '@/pages/distilleries/DistilleryDetail'; interface ProtectedRouteProps { children: React.ReactNode; @@ -177,6 +179,32 @@ export function AppRoutes() { } /> + {/* Distilleries - ROOT_ADMIN only */} + + + + } + /> + + + + } + /> + + + + } + /> + {/* Users - ROOT_ADMIN only */} => { + const endpoint = DistilleryApi.detail.endpoint.replace(':id', String(id)); + return apiClient.get(endpoint); + }, + + /** + * 증류소 생성 + */ + create: async (data: DistilleryFormData): Promise => { + return apiClient.post( + DistilleryApi.create.endpoint, + data + ); + }, + + /** + * 증류소 수정 + */ + update: async (id: number, data: DistilleryFormData): Promise => { + const endpoint = DistilleryApi.update.endpoint.replace(':id', String(id)); + return apiClient.put(endpoint, data); + }, + + /** + * 증류소 삭제 + */ + delete: async (id: number): Promise => { + const endpoint = DistilleryApi.delete.endpoint.replace(':id', String(id)); + return apiClient.delete(endpoint); + }, }; diff --git a/src/test/mocks/data.ts b/src/test/mocks/data.ts index 771bfcc..52036fb 100644 --- a/src/test/mocks/data.ts +++ b/src/test/mocks/data.ts @@ -16,6 +16,10 @@ import type { BannerUpdateStatusResponse, BannerUpdateSortOrderResponse, UserListItem, + DistilleryListItem, + DistilleryDetail, + DistilleryFormResponse, + DistilleryDeleteResponse, } from '@/types/api'; export const mockTastingTagListItems: TastingTagListItem[] = [ @@ -318,6 +322,55 @@ export const mockUserListItems: UserListItem[] = [ }, ]; +// ============================================ +// Distillery Mock Data +// ============================================ + +export const mockDistilleryListItems: DistilleryListItem[] = [ + { + id: 1, + korName: '맥캘란', + engName: 'Macallan', + logoImgUrl: 'https://example.com/macallan.jpg', + createdAt: '2024-01-01T00:00:00', + modifiedAt: '2024-06-01T00:00:00', + }, + { + id: 2, + korName: '글렌피딕', + engName: 'Glenfiddich', + logoImgUrl: null, + createdAt: '2024-02-01T00:00:00', + modifiedAt: '2024-06-01T00:00:00', + }, +]; + +export const mockDistilleryDetail: DistilleryDetail = { + id: 1, + korName: '맥캘란', + engName: 'Macallan', + logoImgUrl: 'https://example.com/macallan.jpg', + regionId: 1, + korRegion: '스페이사이드', + engRegion: 'Speyside', + createdAt: '2024-01-01T00:00:00', + modifiedAt: '2024-06-01T00:00:00', +}; + +export const mockDistilleryFormResponse: DistilleryFormResponse = { + code: 'DISTILLERY_CREATED', + message: '증류소가 생성되었습니다.', + targetId: 1, + responseAt: '2024-06-01T00:00:00', +}; + +export const mockDistilleryDeleteResponse: DistilleryDeleteResponse = { + code: 'DISTILLERY_DELETED', + message: '증류소가 삭제되었습니다.', + targetId: 1, + responseAt: '2024-06-01T00:00:00', +}; + // ============================================ // Utility Functions // ============================================ diff --git a/src/test/mocks/handlers.ts b/src/test/mocks/handlers.ts index d9551af..d30643b 100644 --- a/src/test/mocks/handlers.ts +++ b/src/test/mocks/handlers.ts @@ -17,6 +17,10 @@ import { mockBannerUpdateStatusResponse, mockBannerUpdateSortOrderResponse, mockUserListItems, + mockDistilleryListItems, + mockDistilleryDetail, + mockDistilleryFormResponse, + mockDistilleryDeleteResponse, wrapApiResponse, } from './data'; @@ -333,4 +337,91 @@ export const userHandlers = [ }), ]; -export const handlers = [...tastingTagHandlers, ...bannerHandlers, ...alcoholHandlers, ...userHandlers]; +// ============================================ +// Distillery Handlers +// ============================================ + +const DISTILLERY_BASE = '/admin/api/v1/distilleries'; + +export const distilleryHandlers = [ + // GET 목록 + http.get(DISTILLERY_BASE, ({ request }) => { + const url = new URL(request.url); + const keyword = url.searchParams.get('keyword'); + const size = Number(url.searchParams.get('size') ?? 20); + const page = Number(url.searchParams.get('page') ?? 0); + + let items = mockDistilleryListItems; + if (keyword) { + items = items.filter( + (d) => d.korName.includes(keyword) || d.engName.toLowerCase().includes(keyword.toLowerCase()) + ); + } + + return HttpResponse.json( + wrapApiResponse(items, { + page, + size, + totalElements: items.length, + totalPages: Math.ceil(items.length / size), + hasNext: false, + }) + ); + }), + + // GET 상세 + http.get(`${DISTILLERY_BASE}/:id`, ({ params }) => { + const id = Number(params.id); + if (id === mockDistilleryDetail.id) { + return HttpResponse.json(wrapApiResponse(mockDistilleryDetail)); + } + return HttpResponse.json( + { + success: false, + code: 404, + data: null, + errors: [{ code: 'DISTILLERY_NOT_FOUND', message: '증류소를 찾을 수 없습니다.' }], + meta: {}, + }, + { status: 404 } + ); + }), + + // POST 생성 + http.post(DISTILLERY_BASE, async ({ request }) => { + const body = (await request.json()) as Record; + return HttpResponse.json( + wrapApiResponse({ + ...mockDistilleryFormResponse, + code: 'DISTILLERY_CREATED', + targetId: 99, + message: `${body.korName} 증류소가 생성되었습니다.`, + }) + ); + }), + + // PUT 수정 + http.put(`${DISTILLERY_BASE}/:id`, async ({ params, request }) => { + const body = (await request.json()) as Record; + return HttpResponse.json( + wrapApiResponse({ + ...mockDistilleryFormResponse, + code: 'DISTILLERY_UPDATED', + targetId: Number(params.id), + message: `${body.korName} 증류소가 수정되었습니다.`, + }) + ); + }), + + // DELETE 삭제 + http.delete(`${DISTILLERY_BASE}/:id`, ({ params }) => { + return HttpResponse.json( + wrapApiResponse({ + ...mockDistilleryDeleteResponse, + targetId: Number(params.id), + }) + ); + }), +]; + +export const handlers = [...tastingTagHandlers, ...bannerHandlers, ...alcoholHandlers, ...userHandlers, ...distilleryHandlers]; diff --git a/src/types/api/distillery.api.ts b/src/types/api/distillery.api.ts index c594668..7b9e930 100644 --- a/src/types/api/distillery.api.ts +++ b/src/types/api/distillery.api.ts @@ -13,6 +13,26 @@ export const DistilleryApi = { endpoint: '/admin/api/v1/distilleries', method: 'GET', }, + /** 증류소 상세 조회 */ + detail: { + endpoint: '/admin/api/v1/distilleries/:id', + method: 'GET', + }, + /** 증류소 생성 */ + create: { + endpoint: '/admin/api/v1/distilleries', + method: 'POST', + }, + /** 증류소 수정 */ + update: { + endpoint: '/admin/api/v1/distilleries/:id', + method: 'PUT', + }, + /** 증류소 삭제 */ + delete: { + endpoint: '/admin/api/v1/distilleries/:id', + method: 'DELETE', + }, } as const; // ============================================ @@ -62,6 +82,69 @@ export interface DistilleryApiTypes { hasNext: boolean; }; }; + /** 증류소 상세 조회 */ + detail: { + /** 응답 */ + response: { + /** 증류소 ID */ + id: number; + /** 증류소 한글명 */ + korName: string; + /** 증류소 영문명 */ + engName: string; + /** 로고 이미지 URL */ + logoImgUrl: string | null; + /** 지역 ID */ + regionId: number | null; + /** 지역 한글명 */ + korRegion: string | null; + /** 지역 영문명 */ + engRegion: string | null; + /** 생성일시 */ + createdAt: string; + /** 수정일시 */ + modifiedAt: string; + }; + }; + /** 증류소 생성/수정 요청 */ + form: { + /** 요청 데이터 */ + request: { + /** 증류소 한글명 */ + korName: string; + /** 증류소 영문명 */ + engName: string; + /** 로고 이미지 URL */ + logoImgUrl?: string | null; + /** 지역 ID */ + regionId?: number | null; + }; + /** 응답 데이터 */ + response: { + /** 결과 코드 */ + code: string; + /** 결과 메시지 */ + message: string; + /** 생성/수정된 증류소 ID */ + targetId: number; + /** 응답 시간 */ + responseAt: string; + }; + }; + /** 증류소 삭제 */ + delete: { + /** 응답 데이터 */ + response: { + /** 결과 코드 */ + code: string; + /** 결과 메시지 */ + message: string; + /** 삭제된 증류소 ID */ + targetId: number; + /** 응답 시간 */ + responseAt: string; + }; + }; } // ============================================ @@ -76,3 +159,15 @@ export type DistilleryListItem = DistilleryApiTypes['list']['response']; /** 증류소 목록 페이지네이션 메타 */ export type DistilleryPageMeta = DistilleryApiTypes['list']['meta']; + +/** 증류소 상세 */ +export type DistilleryDetail = DistilleryApiTypes['detail']['response']; + +/** 증류소 폼 데이터 (생성/수정) */ +export type DistilleryFormData = DistilleryApiTypes['form']['request']; + +/** 증류소 생성/수정 응답 */ +export type DistilleryFormResponse = DistilleryApiTypes['form']['response']; + +/** 증류소 삭제 응답 */ +export type DistilleryDeleteResponse = DistilleryApiTypes['delete']['response']; From 24cbc53ec6fd048942e33c85869f83e991882cd3 Mon Sep 17 00:00:00 2001 From: hyejj19 Date: Mon, 11 May 2026 17:59:19 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EC=A6=9D=EB=A5=98=EC=86=8C=20API=20=EB=AA=85=EC=84=B8(PR=20#57?= =?UTF-8?q?8)=EC=97=90=20=EB=A7=9E=EC=B6=B0=20=ED=95=84=EB=93=9C/=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - logoImgUrl → imageUrl 로 필드명 변경 (목록/상세/폼) - 목록·상세 응답에 sortOrder 필드 추가 (백엔드 default 9999) - 상세/폼에서 regionId/korRegion/engRegion 제거 (백엔드 미지원) - 정렬 순서 변경 API(PATCH /:id/sort-order) 타입·서비스·훅 추가 - DistilleryDetail 페이지에서 지역 선택 UI 제거, sortOrder 입력 추가 - MSW 모킹 및 훅 테스트 응답을 새 스키마로 동기화 백엔드 참조: bottle-note/bottle-note-api-server#578 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/hooks/__tests__/useDistilleries.test.ts | 4 +- src/hooks/useDistilleries.ts | 39 ++++++++++ src/pages/distilleries/DistilleryDetail.tsx | 79 +++++++-------------- src/pages/distilleries/DistilleryList.tsx | 4 +- src/pages/distilleries/distillery.schema.ts | 7 +- src/services/distillery.service.ts | 16 +++++ src/test/mocks/data.ts | 12 ++-- src/test/mocks/handlers.ts | 12 ++++ src/types/api/distillery.api.ts | 56 +++++++++++---- 9 files changed, 151 insertions(+), 78 deletions(-) diff --git a/src/hooks/__tests__/useDistilleries.test.ts b/src/hooks/__tests__/useDistilleries.test.ts index 2158cf0..d5e2ec2 100644 --- a/src/hooks/__tests__/useDistilleries.test.ts +++ b/src/hooks/__tests__/useDistilleries.test.ts @@ -68,8 +68,8 @@ describe('useDistilleries hooks', () => { expect(result.current.data!.id).toBe(1); expect(result.current.data!.korName).toBe('맥캘란'); - expect(result.current.data!.regionId).toBe(1); - expect(result.current.data!.korRegion).toBe('스페이사이드'); + expect(result.current.data!.imageUrl).toBe('https://example.com/macallan.jpg'); + expect(result.current.data!.sortOrder).toBe(1); }); it('존재하지 않는 ID는 에러 상태가 된다', async () => { diff --git a/src/hooks/useDistilleries.ts b/src/hooks/useDistilleries.ts index 9fc3a57..75515a6 100644 --- a/src/hooks/useDistilleries.ts +++ b/src/hooks/useDistilleries.ts @@ -16,6 +16,8 @@ import type { DistilleryFormData, DistilleryFormResponse, DistilleryDeleteResponse, + DistillerySortOrderRequest, + DistillerySortOrderResponse, } from '@/types/api'; /** @@ -102,6 +104,43 @@ export function useDistilleryUpdate( ); } +export interface DistillerySortOrderVariables { + id: number; + data: DistillerySortOrderRequest; +} + +/** + * 증류소 정렬 순서 변경 훅 + */ +export function useDistillerySortOrderUpdate( + options?: Omit< + UseApiMutationOptions, + 'successMessage' + > +) { + const queryClient = useQueryClient(); + const { onSuccess, ...restOptions } = options ?? {}; + + return useApiMutation( + ({ id, data }) => distilleryService.updateSortOrder(id, data), + { + successMessage: '증류소 정렬 순서가 변경되었습니다.', + ...restOptions, + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: distilleryKeys.lists() }); + queryClient.invalidateQueries({ queryKey: distilleryKeys.details() }); + if (onSuccess) { + (onSuccess as ( + data: DistillerySortOrderResponse, + variables: DistillerySortOrderVariables, + context: unknown + ) => void)(data, variables, context); + } + }, + } + ); +} + /** * 증류소 삭제 훅 */ diff --git a/src/pages/distilleries/DistilleryDetail.tsx b/src/pages/distilleries/DistilleryDetail.tsx index be89be5..c4301ab 100644 --- a/src/pages/distilleries/DistilleryDetail.tsx +++ b/src/pages/distilleries/DistilleryDetail.tsx @@ -6,19 +6,12 @@ import { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router'; -import { useForm, Controller } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { Save, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { DetailPageHeader } from '@/components/common/DetailPageHeader'; @@ -32,7 +25,6 @@ import { useDistilleryUpdate, useDistilleryDelete, } from '@/hooks/useDistilleries'; -import { useRegionList } from '@/hooks/useRegions'; import { distilleryFormSchema, @@ -49,7 +41,6 @@ export function DistilleryDetailPage() { // API 조회 const { data: detailData, isLoading } = useDistilleryDetail(distilleryId); - const { data: regionData } = useRegionList({ size: 500 }); // Mutations const createMutation = useDistilleryCreate({ @@ -71,7 +62,7 @@ export function DistilleryDetailPage() { }); // 상태 - const [logoUrl, setLogoUrl] = useState(null); + const [imageUrl, setImageUrl] = useState(null); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); // React Hook Form 설정 @@ -84,27 +75,27 @@ export function DistilleryDetailPage() { useEffect(() => { if (isNewMode) { form.reset(distilleryDefaultValues); - setLogoUrl(null); + setImageUrl(null); } else if (detailData) { form.reset({ korName: detailData.korName, engName: detailData.engName, - regionId: detailData.regionId, + sortOrder: detailData.sortOrder, }); - setLogoUrl(detailData.logoImgUrl); + setImageUrl(detailData.imageUrl); } }, [detailData, form, isNewMode]); - // 로고 이미지 변경 - const handleLogoChange = (file: File | null, previewUrl: string | null) => { + // 이미지 변경 + const handleImageChange = (file: File | null, previewUrl: string | null) => { if (file) { const reader = new FileReader(); reader.onloadend = () => { - setLogoUrl(reader.result as string); + setImageUrl(reader.result as string); }; reader.readAsDataURL(file); } else { - setLogoUrl(previewUrl); + setImageUrl(previewUrl); } }; @@ -112,8 +103,8 @@ export function DistilleryDetailPage() { const formData = { korName: data.korName, engName: data.engName, - logoImgUrl: logoUrl, - regionId: data.regionId, + imageUrl, + sortOrder: data.sortOrder, }; if (isNewMode) { @@ -133,8 +124,6 @@ export function DistilleryDetailPage() { const isMutating = createMutation.isPending || updateMutation.isPending; - const regions = regionData?.items ?? []; - return (
{/* 헤더 */} @@ -196,48 +185,34 @@ export function DistilleryDetailPage() {
- {/* 지역 선택 */} - - ( - - )} + {/* 정렬 순서 */} + + - {/* 로고 카드 */} + {/* 이미지 카드 */} - 로고 + 이미지 - 증류소 로고 이미지를 업로드합니다. (선택) + 증류소 이미지를 업로드합니다. (선택) diff --git a/src/pages/distilleries/DistilleryList.tsx b/src/pages/distilleries/DistilleryList.tsx index aae6318..1070a38 100644 --- a/src/pages/distilleries/DistilleryList.tsx +++ b/src/pages/distilleries/DistilleryList.tsx @@ -180,9 +180,9 @@ export function DistilleryListPage() { > {item.id} - {item.logoImgUrl ? ( + {item.imageUrl ? ( {item.korName} diff --git a/src/pages/distilleries/distillery.schema.ts b/src/pages/distilleries/distillery.schema.ts index 81e7ad6..bfe6a3e 100644 --- a/src/pages/distilleries/distillery.schema.ts +++ b/src/pages/distilleries/distillery.schema.ts @@ -3,7 +3,10 @@ import { z } from 'zod'; export const distilleryFormSchema = z.object({ korName: z.string().min(1, '한글명은 필수입니다'), engName: z.string().min(1, '영문명은 필수입니다'), - regionId: z.number().nullable(), + sortOrder: z + .number({ message: '정렬 순서는 숫자여야 합니다' }) + .int('정렬 순서는 정수여야 합니다') + .min(0, '정렬 순서는 0 이상이어야 합니다'), }); export type DistilleryFormValues = z.infer; @@ -11,5 +14,5 @@ export type DistilleryFormValues = z.infer; export const distilleryDefaultValues: DistilleryFormValues = { korName: '', engName: '', - regionId: null, + sortOrder: 9999, }; diff --git a/src/services/distillery.service.ts b/src/services/distillery.service.ts index a4cf17c..91f2301 100644 --- a/src/services/distillery.service.ts +++ b/src/services/distillery.service.ts @@ -13,6 +13,8 @@ import { type DistilleryFormData, type DistilleryFormResponse, type DistilleryDeleteResponse, + type DistillerySortOrderRequest, + type DistillerySortOrderResponse, } from '@/types/api'; // ============================================ @@ -90,4 +92,18 @@ export const distilleryService = { const endpoint = DistilleryApi.delete.endpoint.replace(':id', String(id)); return apiClient.delete(endpoint); }, + + /** + * 증류소 정렬 순서 변경 + */ + updateSortOrder: async ( + id: number, + data: DistillerySortOrderRequest + ): Promise => { + const endpoint = DistilleryApi.updateSortOrder.endpoint.replace(':id', String(id)); + return apiClient.patch( + endpoint, + data + ); + }, }; diff --git a/src/test/mocks/data.ts b/src/test/mocks/data.ts index 52036fb..ac2bd3f 100644 --- a/src/test/mocks/data.ts +++ b/src/test/mocks/data.ts @@ -331,17 +331,19 @@ export const mockDistilleryListItems: DistilleryListItem[] = [ id: 1, korName: '맥캘란', engName: 'Macallan', - logoImgUrl: 'https://example.com/macallan.jpg', + imageUrl: 'https://example.com/macallan.jpg', createdAt: '2024-01-01T00:00:00', modifiedAt: '2024-06-01T00:00:00', + sortOrder: 1, }, { id: 2, korName: '글렌피딕', engName: 'Glenfiddich', - logoImgUrl: null, + imageUrl: null, createdAt: '2024-02-01T00:00:00', modifiedAt: '2024-06-01T00:00:00', + sortOrder: 9999, }, ]; @@ -349,12 +351,10 @@ export const mockDistilleryDetail: DistilleryDetail = { id: 1, korName: '맥캘란', engName: 'Macallan', - logoImgUrl: 'https://example.com/macallan.jpg', - regionId: 1, - korRegion: '스페이사이드', - engRegion: 'Speyside', + imageUrl: 'https://example.com/macallan.jpg', createdAt: '2024-01-01T00:00:00', modifiedAt: '2024-06-01T00:00:00', + sortOrder: 1, }; export const mockDistilleryFormResponse: DistilleryFormResponse = { diff --git a/src/test/mocks/handlers.ts b/src/test/mocks/handlers.ts index d30643b..6e10557 100644 --- a/src/test/mocks/handlers.ts +++ b/src/test/mocks/handlers.ts @@ -422,6 +422,18 @@ export const distilleryHandlers = [ }) ); }), + + // PATCH 정렬 순서 변경 + http.patch(`${DISTILLERY_BASE}/:id/sort-order`, async ({ params }) => { + return HttpResponse.json( + wrapApiResponse({ + code: 'DISTILLERY_SORT_ORDER_UPDATED', + message: '증류소 정렬 순서가 변경되었습니다.', + targetId: Number(params.id), + responseAt: '2024-06-01T00:00:00', + }) + ); + }), ]; export const handlers = [...tastingTagHandlers, ...bannerHandlers, ...alcoholHandlers, ...userHandlers, ...distilleryHandlers]; diff --git a/src/types/api/distillery.api.ts b/src/types/api/distillery.api.ts index 7b9e930..4fc79b9 100644 --- a/src/types/api/distillery.api.ts +++ b/src/types/api/distillery.api.ts @@ -33,6 +33,11 @@ export const DistilleryApi = { endpoint: '/admin/api/v1/distilleries/:id', method: 'DELETE', }, + /** 증류소 정렬 순서 변경 */ + updateSortOrder: { + endpoint: '/admin/api/v1/distilleries/:id/sort-order', + method: 'PATCH', + }, } as const; // ============================================ @@ -61,12 +66,14 @@ export interface DistilleryApiTypes { korName: string; /** 증류소 영문명 */ engName: string; - /** 로고 이미지 URL */ - logoImgUrl: string | null; + /** 이미지 URL */ + imageUrl: string | null; /** 생성일시 */ createdAt: string; /** 수정일시 */ modifiedAt: string; + /** 정렬 순서 (미설정: 9999) */ + sortOrder: number; }; /** 페이지네이션 메타 정보 */ meta: { @@ -92,18 +99,14 @@ export interface DistilleryApiTypes { korName: string; /** 증류소 영문명 */ engName: string; - /** 로고 이미지 URL */ - logoImgUrl: string | null; - /** 지역 ID */ - regionId: number | null; - /** 지역 한글명 */ - korRegion: string | null; - /** 지역 영문명 */ - engRegion: string | null; + /** 이미지 URL */ + imageUrl: string | null; /** 생성일시 */ createdAt: string; /** 수정일시 */ modifiedAt: string; + /** 정렬 순서 (미설정: 9999) */ + sortOrder: number; }; }; /** 증류소 생성/수정 요청 */ @@ -114,10 +117,10 @@ export interface DistilleryApiTypes { korName: string; /** 증류소 영문명 */ engName: string; - /** 로고 이미지 URL */ - logoImgUrl?: string | null; - /** 지역 ID */ - regionId?: number | null; + /** 이미지 URL */ + imageUrl?: string | null; + /** 정렬 순서 (미지정 시 백엔드 기본값 9999) */ + sortOrder?: number | null; }; /** 응답 데이터 */ response: { @@ -145,6 +148,25 @@ export interface DistilleryApiTypes { responseAt: string; }; }; + /** 증류소 정렬 순서 변경 */ + updateSortOrder: { + /** 요청 데이터 */ + request: { + /** 정렬 순서 (0 이상) */ + sortOrder: number; + }; + /** 응답 데이터 */ + response: { + /** 결과 코드 */ + code: string; + /** 결과 메시지 */ + message: string; + /** 대상 증류소 ID */ + targetId: number; + /** 응답 시간 */ + responseAt: string; + }; + }; } // ============================================ @@ -171,3 +193,9 @@ export type DistilleryFormResponse = DistilleryApiTypes['form']['response']; /** 증류소 삭제 응답 */ export type DistilleryDeleteResponse = DistilleryApiTypes['delete']['response']; + +/** 증류소 정렬 순서 변경 요청 */ +export type DistillerySortOrderRequest = DistilleryApiTypes['updateSortOrder']['request']; + +/** 증류소 정렬 순서 변경 응답 */ +export type DistillerySortOrderResponse = DistilleryApiTypes['updateSortOrder']['response']; From 177ec92d5e27628406e812ca1d7b184f8cd9c143 Mon Sep 17 00:00:00 2001 From: hyejj19 Date: Mon, 11 May 2026 18:21:24 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=EC=A6=9D=EB=A5=98=EC=86=8C=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=EB=A5=BC=20Banner=20=ED=8C=A8=ED=84=B4(S3=20Presigned=20URL)?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백엔드 PR #578에서 명시한 이미지 처리 규칙(Banner 패턴) 반영. 기존 FileReader.readAsDataURL 기반 base64 인라인 전송은 TastingTag.icon 특수 케이스로, 증류소에는 부적합이라고 명시되어 있음. - S3UploadPath.DISTILLERY ('admin/distillery') 상수 추가 - useImageUpload 훅 사용: presign-url 발급 → S3 직접 PUT → viewUrl 반환 - 폼 스키마에 imageUrl 필드 추가 (S3 viewUrl을 폼 값으로 보관) - 미리보기(imagePreviewUrl)와 저장값(form.imageUrl) 분리 - 업로드 진행 중 저장 버튼 비활성화 + "파일 업로드 중..." 안내 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/pages/distilleries/DistilleryDetail.tsx | 42 +++++++++++++-------- src/pages/distilleries/distillery.schema.ts | 2 + src/types/api/s3.api.ts | 2 + 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/pages/distilleries/DistilleryDetail.tsx b/src/pages/distilleries/DistilleryDetail.tsx index c4301ab..aff465d 100644 --- a/src/pages/distilleries/DistilleryDetail.tsx +++ b/src/pages/distilleries/DistilleryDetail.tsx @@ -25,6 +25,7 @@ import { useDistilleryUpdate, useDistilleryDelete, } from '@/hooks/useDistilleries'; +import { useImageUpload, S3UploadPath } from '@/hooks/useImageUpload'; import { distilleryFormSchema, @@ -42,6 +43,11 @@ export function DistilleryDetailPage() { // API 조회 const { data: detailData, isLoading } = useDistilleryDetail(distilleryId); + // 이미지 업로드 (S3 Presigned URL) + const { upload: uploadImage, isUploading: isImageUploading } = useImageUpload({ + rootPath: S3UploadPath.DISTILLERY, + }); + // Mutations const createMutation = useDistilleryCreate({ onSuccess: () => { @@ -62,7 +68,7 @@ export function DistilleryDetailPage() { }); // 상태 - const [imageUrl, setImageUrl] = useState(null); + const [imagePreviewUrl, setImagePreviewUrl] = useState(null); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); // React Hook Form 설정 @@ -75,27 +81,27 @@ export function DistilleryDetailPage() { useEffect(() => { if (isNewMode) { form.reset(distilleryDefaultValues); - setImageUrl(null); + setImagePreviewUrl(null); } else if (detailData) { form.reset({ korName: detailData.korName, engName: detailData.engName, + imageUrl: detailData.imageUrl, sortOrder: detailData.sortOrder, }); - setImageUrl(detailData.imageUrl); + setImagePreviewUrl(detailData.imageUrl); } }, [detailData, form, isNewMode]); - // 이미지 변경 - const handleImageChange = (file: File | null, previewUrl: string | null) => { + // 이미지 변경 (S3 업로드 후 viewUrl을 폼 값으로 설정) + const handleImageChange = async (file: File | null, previewUrl: string | null) => { + setImagePreviewUrl(previewUrl); + if (file) { - const reader = new FileReader(); - reader.onloadend = () => { - setImageUrl(reader.result as string); - }; - reader.readAsDataURL(file); + const viewUrl = await uploadImage(file); + form.setValue('imageUrl', viewUrl ?? previewUrl ?? null); } else { - setImageUrl(previewUrl); + form.setValue('imageUrl', previewUrl); } }; @@ -103,7 +109,7 @@ export function DistilleryDetailPage() { const formData = { korName: data.korName, engName: data.engName, - imageUrl, + imageUrl: data.imageUrl, sortOrder: data.sortOrder, }; @@ -142,7 +148,10 @@ export function DistilleryDetailPage() { 삭제 )} - @@ -209,12 +218,15 @@ export function DistilleryDetailPage() { 증류소 이미지를 업로드합니다. (선택) - + + {isImageUploading && ( +

파일 업로드 중...

+ )}
diff --git a/src/pages/distilleries/distillery.schema.ts b/src/pages/distilleries/distillery.schema.ts index bfe6a3e..1cb58a8 100644 --- a/src/pages/distilleries/distillery.schema.ts +++ b/src/pages/distilleries/distillery.schema.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; export const distilleryFormSchema = z.object({ korName: z.string().min(1, '한글명은 필수입니다'), engName: z.string().min(1, '영문명은 필수입니다'), + imageUrl: z.string().nullable(), sortOrder: z .number({ message: '정렬 순서는 숫자여야 합니다' }) .int('정렬 순서는 정수여야 합니다') @@ -14,5 +15,6 @@ export type DistilleryFormValues = z.infer; export const distilleryDefaultValues: DistilleryFormValues = { korName: '', engName: '', + imageUrl: null, sortOrder: 9999, }; diff --git a/src/types/api/s3.api.ts b/src/types/api/s3.api.ts index 45bc5fa..c8b3e67 100644 --- a/src/types/api/s3.api.ts +++ b/src/types/api/s3.api.ts @@ -69,6 +69,8 @@ export const S3UploadPath = { BANNER: 'admin/banner', /** 큐레이션 커버 이미지 */ CURATION: 'admin/curation', + /** 증류소 이미지 */ + DISTILLERY: 'admin/distillery', /** 테이스팅 태그 아이콘 */ TASTING_TAG: 'admin/tasting-tag', } as const; From 25296ee00ed16fccb6a9f6952f7f93c962836daa Mon Sep 17 00:00:00 2001 From: Hyejung Park Date: Mon, 11 May 2026 18:24:24 +0900 Subject: [PATCH 5/5] Bump version from 1.3.0 to 1.3.1 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index f0bb29e..3a3cd8c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.0 +1.3.1