From 2a8d2e0841e5b8f1432b78d8ea0dce9790696d9b Mon Sep 17 00:00:00 2001 From: "bottlenote-app[bot]" <289617182+bottlenote-app[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:55:06 +0900 Subject: [PATCH 1/4] feat(admin): add region and sorting filters to whisky list --- src/hooks/useRegions.ts | 16 +- src/pages/whisky/WhiskyList.tsx | 143 ++++++++++++++--- .../whisky/__tests__/WhiskyList.test.tsx | 150 ++++++++++++++++++ src/types/api/alcohol.api.ts | 8 +- 4 files changed, 291 insertions(+), 26 deletions(-) create mode 100644 src/pages/whisky/__tests__/WhiskyList.test.tsx diff --git a/src/hooks/useRegions.ts b/src/hooks/useRegions.ts index 6fc16a6..049851b 100644 --- a/src/hooks/useRegions.ts +++ b/src/hooks/useRegions.ts @@ -2,10 +2,11 @@ * Region API 커스텀 훅 */ -import { useQueryClient } from '@tanstack/react-query'; +import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; import { useApiQuery } from './useApiQuery'; import { useApiMutation, type UseApiMutationOptions } from './useApiMutation'; import { regionService, regionKeys, type RegionListResponse } from '@/services/region.service'; +import type { ApiError } from '@/lib/api-error'; import type { RegionSearchParams, RegionDetail, @@ -38,6 +39,19 @@ export function useRegionList(params?: RegionSearchParams) { ); } +/** 지역 선택기의 서버 검색·페이지네이션. 목록과 동일한 API 응답을 사용한다. */ +export function useRegionListInfinite(keyword: string, enabled: boolean) { + return useInfiniteQuery({ + queryKey: regionKeys.list({ keyword, size: 20, infinite: true }), + queryFn: ({ pageParam }) => + regionService.list({ keyword: keyword || undefined, page: pageParam as number, size: 20 }), + initialPageParam: 0, + getNextPageParam: (lastPage) => (lastPage.meta.hasNext ? lastPage.meta.page + 1 : undefined), + enabled, + staleTime: 1000 * 60 * 5, + }); +} + /** * 지역 상세 조회 훅 */ diff --git a/src/pages/whisky/WhiskyList.tsx b/src/pages/whisky/WhiskyList.tsx index 218d728..70c0af5 100644 --- a/src/pages/whisky/WhiskyList.tsx +++ b/src/pages/whisky/WhiskyList.tsx @@ -7,7 +7,7 @@ import { useState, useEffect } from 'react'; import { useNavigate, useSearchParams } from 'react-router'; -import { Search, ImageOff } from 'lucide-react'; +import { Search, ImageOff, X } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Select, @@ -29,7 +29,10 @@ 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 { LookupSearchSelect } from '@/components/common/LookupSearchSelect'; import { useAdminAlcoholList } from '@/hooks/useAdminAlcohols'; +import { useRegionDetail, useRegionListInfinite } from '@/hooks/useRegions'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { isNonComposingEnterKey } from '@/lib/keyboard'; import type { AlcoholSearchParams, AlcoholCategory } from '@/types/api'; import { ALCOHOL_CATEGORIES, CATEGORY_GROUP_LABELS } from '@/types/api'; @@ -42,6 +45,16 @@ const CATEGORY_OPTIONS: { value: AlcoholCategory | 'ALL'; label: string }[] = [ })), ]; +const SORT_OPTIONS = [ + { value: 'DEFAULT', label: '기본 정렬' }, + { value: 'KOR_NAME', label: '한글명' }, + { value: 'ENG_NAME', label: '영문명' }, + { value: 'KOR_CATEGORY', label: '한글 카테고리' }, + { value: 'ENG_CATEGORY', label: '영문 카테고리' }, + { value: 'CREATED_AT', label: '생성일' }, + { value: 'UPDATED_AT', label: '수정일' }, +] as const; + export function WhiskyListPage() { const navigate = useNavigate(); const [urlParams, setUrlParams] = useSearchParams(); @@ -52,9 +65,18 @@ export function WhiskyListPage() { const page = Number(urlParams.get('page')) || 0; const size = Number(urlParams.get('size')) || 20; const includeDeleted = urlParams.get('includeDeleted') === 'true'; + const rawRegionId = Number(urlParams.get('regionId')); + const regionId = Number.isSafeInteger(rawRegionId) && rawRegionId > 0 ? rawRegionId : undefined; + const sortType = SORT_OPTIONS.find((option) => option.value === urlParams.get('sortType'))?.value; + const sortOrder = urlParams.get('sortOrder') === 'DESC' ? 'DESC' : 'ASC'; // 검색 입력 필드용 로컬 상태 (Enter/버튼 클릭 시에만 URL 반영) const [keywordInput, setKeywordInput] = useState(keyword); + const [regionSearch, setRegionSearch] = useState(''); + const [regionOpen, setRegionOpen] = useState(false); + const debouncedRegionSearch = useDebouncedValue(regionSearch.trim(), 300); + const regions = useRegionListInfinite(debouncedRegionSearch, regionOpen); + const selectedRegion = useRegionDetail(regionId); // URL의 keyword가 변경되면 입력 필드도 동기화 useEffect(() => { @@ -68,6 +90,9 @@ export function WhiskyListPage() { page, size, includeDeleted: includeDeleted || undefined, + regionId, + sortType: sortType && sortType !== 'DEFAULT' ? sortType : undefined, + sortOrder: sortType && sortType !== 'DEFAULT' ? sortOrder : undefined, }; const { data, isLoading } = useAdminAlcoholList(searchParams); @@ -121,6 +146,23 @@ export function WhiskyListPage() { }); }; + const handleRegionChange = (id?: number) => { + setRegionSearch(''); + updateUrlParams({ regionId: id ? String(id) : undefined, page: '0' }); + }; + + const handleSortTypeChange = (value: string) => { + updateUrlParams({ + sortType: value === 'DEFAULT' ? undefined : value, + sortOrder: value === 'DEFAULT' ? undefined : 'ASC', + page: '0', + }); + }; + + const handleSortOrderChange = (value: string) => { + updateUrlParams({ sortOrder: value, page: '0' }); + }; + const handlePageChange = (newPage: number) => { updateUrlParams({ page: String(newPage), @@ -147,8 +189,8 @@ export function WhiskyListPage() { {/* 필터 */} -
-
+
+
- @@ -186,6 +225,72 @@ export function WhiskyListPage() {
+
+
+ result.items) ?? []) + : [] + } + getItemKey={(region) => region.id} + getItemAriaLabel={(region) => `${region.korName} 지역 선택`} + renderItem={(region) => {region.korName}} + onSelect={(region) => handleRegionChange(region.id)} + placeholder="지역 검색..." + ariaLabel="지역 검색" + showOnFocus + isLoading={regions.isLoading || regionSearch.trim() !== debouncedRegionSearch} + isError={regions.isError} + onRetry={() => regions.refetch()} + hasNextPage={regions.hasNextPage} + isFetchingNextPage={regions.isFetchingNextPage} + onLoadMore={() => regions.fetchNextPage()} + className="min-w-0 flex-1" + /> + {regionId && ( + + )} +
+ + +
+ {/* 테이블 */}
@@ -197,24 +302,20 @@ export function WhiskyListPage() { 영문명카테고리수정일 - {includeDeleted && ( - 상태 - )} + {includeDeleted && 상태} {isLoading ? ( - + 로딩 중... ) : data?.items.length === 0 ? ( - - - 검색 결과가 없습니다. - + + 검색 결과가 없습니다. ) : ( @@ -226,9 +327,7 @@ export function WhiskyListPage() { className={`cursor-pointer hover:bg-muted/50 ${isDeleted ? 'opacity-50' : ''}`} onClick={() => handleRowClick(item.alcoholId)} > - - {item.alcoholId} - + {item.alcoholId} {item.imageUrl ? ( {item.korName} - - {item.engName} - + {item.engName} {item.korCategoryName} {new Date(item.modifiedAt).toLocaleDateString('ko-KR')} {includeDeleted && ( - {isDeleted && ( - 삭제됨 - )} + {isDeleted && 삭제됨} )} diff --git a/src/pages/whisky/__tests__/WhiskyList.test.tsx b/src/pages/whisky/__tests__/WhiskyList.test.tsx new file mode 100644 index 0000000..12262aa --- /dev/null +++ b/src/pages/whisky/__tests__/WhiskyList.test.tsx @@ -0,0 +1,150 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { HttpResponse, http } from 'msw'; +import { createMemoryRouter, RouterProvider, useLocation } from 'react-router'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import { ToastContext, useToastState } from '@/hooks/useToast'; +import { mockAlcoholListItems, wrapApiResponse } from '@/test/mocks/data'; +import { server } from '@/test/mocks/server'; +import { WhiskyListPage } from '../WhiskyList'; + +function Location() { + const location = useLocation(); + return {location.search}; +} + +function renderList(initialEntry = '/whisky') { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const router = createMemoryRouter( + [ + { + path: '/whisky', + element: ( + <> + + + + ), + }, + ], + { initialEntries: [initialEntry] } + ); + function Providers() { + const toast = useToastState(); + return ( + + + + + + ); + } + render(); + return router; +} + +function listRequests() { + const requests: URL[] = []; + server.use( + http.get('/admin/api/v1/alcohols', ({ request }) => { + const url = new URL(request.url); + requests.push(url); + const page = Number(url.searchParams.get('page') ?? 0); + return HttpResponse.json( + wrapApiResponse([mockAlcoholListItems[0]], { + page, + size: 20, + totalElements: 80, + totalPages: 4, + hasNext: page < 3, + }) + ); + }) + ); + return requests; +} + +const latest = (requests: URL[]) => requests[requests.length - 1]!.searchParams; + +beforeAll(() => { + Element.prototype.hasPointerCapture = () => false; + Element.prototype.setPointerCapture = () => {}; + Element.prototype.releasePointerCapture = () => {}; +}); + +describe('위스키 목록 지역·정렬', () => { + it('기본 조회는 새 조건 없이 수행하고 기존 검색·카테고리·삭제 필터를 유지한다', async () => { + const requests = listRequests(); + renderList('/whisky?keyword=글렌&category=SINGLE_MALT&includeDeleted=true'); + await waitFor(() => expect(requests).toHaveLength(1)); + expect(Object.fromEntries(latest(requests))).toMatchObject({ + keyword: '글렌', + category: 'SINGLE_MALT', + includeDeleted: 'true', + page: '0', + }); + expect(latest(requests).has('regionId')).toBe(false); + expect(latest(requests).has('sortType')).toBe(false); + expect(latest(requests).has('sortOrder')).toBe(false); + }); + + it('API 지역을 선택하면 ID 그대로 조회하고 정렬·페이지 이동·뒤로가기에서 URL과 요청을 유지한다', async () => { + const user = userEvent.setup(); + const requests = listRequests(); + const router = renderList( + '/whisky?keyword=글렌&category=SINGLE_MALT&includeDeleted=true&page=2' + ); + await waitFor(() => expect(requests).toHaveLength(1)); + + await user.click(screen.getByRole('combobox', { name: '지역 검색' })); + await user.type(screen.getByRole('combobox', { name: '지역 검색' }), '스코틀랜드'); + await user.click(await screen.findByRole('button', { name: '스코틀랜드 지역 선택' })); + await waitFor(() => expect(latest(requests).get('regionId')).toBe('1')); + expect(latest(requests).get('page')).toBe('0'); + expect(screen.getByTestId('location')).toHaveTextContent('regionId=1'); + expect(screen.getByTestId('location')).not.toHaveTextContent('page=2'); + + await user.click(screen.getByRole('combobox', { name: '정렬 기준' })); + await user.click(await screen.findByRole('option', { name: '생성일' })); + await user.click(screen.getByRole('combobox', { name: '정렬 방향' })); + await user.click(await screen.findByRole('option', { name: '내림차순' })); + await waitFor(() => expect(latest(requests).get('sortOrder')).toBe('DESC')); + expect(latest(requests).get('sortType')).toBe('CREATED_AT'); + expect(latest(requests).get('regionId')).toBe('1'); + expect(latest(requests).get('keyword')).toBe('글렌'); + expect(latest(requests).get('category')).toBe('SINGLE_MALT'); + expect(latest(requests).get('includeDeleted')).toBe('true'); + + await user.click(screen.getByRole('button', { name: '다음' })); + await waitFor(() => expect(latest(requests).get('page')).toBe('1')); + expect(latest(requests).get('regionId')).toBe('1'); + expect(latest(requests).get('sortType')).toBe('CREATED_AT'); + + await act(async () => { + await router.navigate(-1); + }); + expect(screen.getByTestId('location')).not.toHaveTextContent('page=1'); + expect(screen.getByRole('combobox', { name: '정렬 기준' })).toHaveTextContent('생성일'); + expect(screen.getByRole('combobox', { name: '정렬 방향' })).toHaveTextContent('내림차순'); + expect(screen.getByTestId('location')).toHaveTextContent('regionId=1'); + }); + + it('새로고침에 해당하는 URL 재진입 시 지역명·정렬 선택을 복원하고 기본 정렬로 되돌릴 수 있다', async () => { + const user = userEvent.setup(); + const requests = listRequests(); + renderList('/whisky?regionId=1&sortType=UPDATED_AT&sortOrder=DESC&page=1'); + await waitFor(() => expect(latest(requests).get('sortType')).toBe('UPDATED_AT')); + await waitFor(() => + expect(screen.getByRole('button', { name: '지역 필터 해제' })).toHaveTextContent('스코틀랜드') + ); + expect(screen.getByRole('combobox', { name: '정렬 기준' })).toHaveTextContent('수정일'); + await user.click(screen.getByRole('combobox', { name: '정렬 기준' })); + await user.click(await screen.findByRole('option', { name: '기본 정렬' })); + await waitFor(() => expect(latest(requests).get('page')).toBe('0')); + expect(latest(requests).has('sortType')).toBe(false); + expect(latest(requests).has('sortOrder')).toBe(false); + expect(latest(requests).get('regionId')).toBe('1'); + }); +}); diff --git a/src/types/api/alcohol.api.ts b/src/types/api/alcohol.api.ts index 38173b9..a43d986 100644 --- a/src/types/api/alcohol.api.ts +++ b/src/types/api/alcohol.api.ts @@ -91,7 +91,13 @@ export type AlcoholType = | 'ETC'; /** 어드민 술 정렬 기준 */ -export type AdminAlcoholSortType = 'KOR_NAME' | 'ENG_NAME' | 'KOR_CATEGORY' | 'ENG_CATEGORY'; +export type AdminAlcoholSortType = + | 'KOR_NAME' + | 'ENG_NAME' + | 'KOR_CATEGORY' + | 'ENG_CATEGORY' + | 'CREATED_AT' + | 'UPDATED_AT'; /** 정렬 방향 */ export type SortOrder = 'ASC' | 'DESC'; From 05df22bef334647773e559060a2e3ec24d7bffe7 Mon Sep 17 00:00:00 2001 From: "bottlenote-app[bot]" <289617182+bottlenote-app[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:01:52 +0900 Subject: [PATCH 2/4] style(admin): align whisky list search and sort controls --- src/pages/whisky/WhiskyList.tsx | 59 +++++++++++++++++---------------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/src/pages/whisky/WhiskyList.tsx b/src/pages/whisky/WhiskyList.tsx index 70c0af5..3dc585f 100644 --- a/src/pages/whisky/WhiskyList.tsx +++ b/src/pages/whisky/WhiskyList.tsx @@ -188,9 +188,9 @@ export function WhiskyListPage() {

등록된 위스키를 관리합니다.

- {/* 필터 */} + {/* 검색과 주요 필터 */}
-
+
- - -
- - -
-
- -
-
+
)}
+ + +
+ + {/* 정렬과 추가 필터 */} +
+
+ + +
{/* 테이블 */} From 64f3a82109ceef2929d211685596431241ab823e Mon Sep 17 00:00:00 2001 From: "bottlenote-app[bot]" <289617182+bottlenote-app[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:21:01 +0900 Subject: [PATCH 3/4] style(admin): fit all whisky list filters on one row --- src/pages/whisky/WhiskyList.tsx | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/src/pages/whisky/WhiskyList.tsx b/src/pages/whisky/WhiskyList.tsx index 3dc585f..90cfd32 100644 --- a/src/pages/whisky/WhiskyList.tsx +++ b/src/pages/whisky/WhiskyList.tsx @@ -188,19 +188,19 @@ export function WhiskyListPage() {

등록된 위스키를 관리합니다.

- {/* 검색과 주요 필터 */} -
-
+ {/* 검색 · 필터 · 정렬 */} +
+
setKeywordInput(e.target.value)} onKeyDown={handleKeyDown} className="pl-9" />
-
+
handleRegionChange()} - className="max-w-[150px] shrink-0 gap-1" + className="max-w-[150px] shrink-0 gap-1 xl:max-w-[100px]" > {selectedRegion.data?.korName ?? `#${regionId}`} @@ -239,7 +239,7 @@ export function WhiskyListPage() { )}
- -
- - {/* 정렬과 추가 필터 */} -
+ -
+
Date: Wed, 30 Sep 2026 18:33:37 +0900 Subject: [PATCH 4/4] revert(admin): restore initial whisky list filter layout --- src/pages/whisky/WhiskyList.tsx | 67 +++++++++++++++++---------------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/src/pages/whisky/WhiskyList.tsx b/src/pages/whisky/WhiskyList.tsx index 90cfd32..70c0af5 100644 --- a/src/pages/whisky/WhiskyList.tsx +++ b/src/pages/whisky/WhiskyList.tsx @@ -188,19 +188,45 @@ export function WhiskyListPage() {

등록된 위스키를 관리합니다.

- {/* 검색 · 필터 · 정렬 */} -
-
+ {/* 필터 */} +
+
setKeywordInput(e.target.value)} onKeyDown={handleKeyDown} className="pl-9" />
-
+ + +
+ + +
+
+ +
+
handleRegionChange()} - className="max-w-[150px] shrink-0 gap-1 xl:max-w-[100px]" + className="max-w-[150px] shrink-0 gap-1" > {selectedRegion.data?.korName ?? `#${regionId}`} )}
- - -
- - -
{/* 테이블 */}