Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/hooks/useRegions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -38,6 +39,19 @@ export function useRegionList(params?: RegionSearchParams) {
);
}

/** 지역 선택기의 서버 검색·페이지네이션. 목록과 동일한 API 응답을 사용한다. */
export function useRegionListInfinite(keyword: string, enabled: boolean) {
return useInfiniteQuery<RegionListResponse, ApiError>({
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,
});
}

/**
* 지역 상세 조회 훅
*/
Expand Down
143 changes: 119 additions & 24 deletions src/pages/whisky/WhiskyList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand All @@ -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();
Expand All @@ -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(() => {
Expand All @@ -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);
Expand Down Expand Up @@ -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),
Expand All @@ -147,8 +189,8 @@ export function WhiskyListPage() {
</div>

{/* 필터 */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<div className="relative min-w-[220px] flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="위스키 이름으로 검색..."
Expand All @@ -158,10 +200,7 @@ export function WhiskyListPage() {
className="pl-9"
/>
</div>
<Select
value={category ?? 'ALL'}
onValueChange={handleCategoryChange}
>
<Select value={category ?? 'ALL'} onValueChange={handleCategoryChange}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="카테고리" />
</SelectTrigger>
Expand All @@ -186,6 +225,72 @@ export function WhiskyListPage() {
</div>
</div>

<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<div className="flex min-w-[220px] flex-1 items-center gap-2 sm:max-w-[360px]">
<LookupSearchSelect
value={regionSearch}
onValueChange={setRegionSearch}
open={regionOpen}
onOpenChange={setRegionOpen}
items={
regionSearch.trim() === debouncedRegionSearch
? (regions.data?.pages.flatMap((result) => result.items) ?? [])
: []
}
getItemKey={(region) => region.id}
getItemAriaLabel={(region) => `${region.korName} 지역 선택`}
renderItem={(region) => <span className="truncate">{region.korName}</span>}
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 && (
<Button
variant="outline"
aria-label="지역 필터 해제"
onClick={() => handleRegionChange()}
className="max-w-[150px] shrink-0 gap-1"
>
<span className="truncate">{selectedRegion.data?.korName ?? `#${regionId}`}</span>
<X className="h-4 w-4" />
</Button>
)}
</div>
<Select value={sortType ?? 'DEFAULT'} onValueChange={handleSortTypeChange}>
<SelectTrigger aria-label="정렬 기준" className="w-full sm:w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={sortOrder}
onValueChange={handleSortOrderChange}
disabled={!sortType || sortType === 'DEFAULT'}
>
<SelectTrigger aria-label="정렬 방향" className="w-full sm:w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ASC">오름차순</SelectItem>
<SelectItem value="DESC">내림차순</SelectItem>
</SelectContent>
</Select>
</div>

{/* 테이블 */}
<div className="rounded-lg border">
<Table>
Expand All @@ -197,24 +302,20 @@ export function WhiskyListPage() {
<TableHead>영문명</TableHead>
<TableHead className="w-[100px]">카테고리</TableHead>
<TableHead className="w-[100px]">수정일</TableHead>
{includeDeleted && (
<TableHead className="w-[80px]">상태</TableHead>
)}
{includeDeleted && <TableHead className="w-[80px]">상태</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columnCount} className="text-center py-8">
<TableCell colSpan={columnCount} className="py-8 text-center">
<span className="text-muted-foreground">로딩 중...</span>
</TableCell>
</TableRow>
) : data?.items.length === 0 ? (
<TableRow>
<TableCell colSpan={columnCount} className="text-center py-8">
<span className="text-muted-foreground">
검색 결과가 없습니다.
</span>
<TableCell colSpan={columnCount} className="py-8 text-center">
<span className="text-muted-foreground">검색 결과가 없습니다.</span>
</TableCell>
</TableRow>
) : (
Expand All @@ -226,9 +327,7 @@ export function WhiskyListPage() {
className={`cursor-pointer hover:bg-muted/50 ${isDeleted ? 'opacity-50' : ''}`}
onClick={() => handleRowClick(item.alcoholId)}
>
<TableCell className="font-mono text-sm">
{item.alcoholId}
</TableCell>
<TableCell className="font-mono text-sm">{item.alcoholId}</TableCell>
<TableCell>
{item.imageUrl ? (
<img
Expand All @@ -243,18 +342,14 @@ export function WhiskyListPage() {
)}
</TableCell>
<TableCell className="font-medium">{item.korName}</TableCell>
<TableCell className="text-muted-foreground">
{item.engName}
</TableCell>
<TableCell className="text-muted-foreground">{item.engName}</TableCell>
<TableCell>{item.korCategoryName}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(item.modifiedAt).toLocaleDateString('ko-KR')}
</TableCell>
{includeDeleted && (
<TableCell>
{isDeleted && (
<Badge variant="destructive">삭제됨</Badge>
)}
{isDeleted && <Badge variant="destructive">삭제됨</Badge>}
</TableCell>
)}
</TableRow>
Expand Down
Loading
Loading