카테고리 무한 스크롤 구현
This commit is contained in:
@@ -8,8 +8,8 @@ import { CodeCategoryFormModal } from "./CodeCategoryFormModal";
|
|||||||
import { CategoryItem } from "./CategoryItem";
|
import { CategoryItem } from "./CategoryItem";
|
||||||
import { AlertModal } from "@/components/common/AlertModal";
|
import { AlertModal } from "@/components/common/AlertModal";
|
||||||
import { Search, Plus } from "lucide-react";
|
import { Search, Plus } from "lucide-react";
|
||||||
import { useCategories, useDeleteCategory } from "@/hooks/queries/useCategories";
|
import { useDeleteCategory } from "@/hooks/queries/useCategories";
|
||||||
import { useSearchAndFilter } from "@/hooks/useSearchAndFilter";
|
import { useCategoriesInfinite } from "@/hooks/queries/useCategoriesInfinite";
|
||||||
|
|
||||||
interface CodeCategoryPanelProps {
|
interface CodeCategoryPanelProps {
|
||||||
selectedCategoryCode: string;
|
selectedCategoryCode: string;
|
||||||
@@ -17,20 +17,23 @@ interface CodeCategoryPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: CodeCategoryPanelProps) {
|
export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: CodeCategoryPanelProps) {
|
||||||
// React Query로 카테고리 데이터 관리
|
// 검색 및 필터 상태 (먼저 선언)
|
||||||
const { data: categories = [], isLoading, error } = useCategories();
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const deleteCategoryMutation = useDeleteCategory();
|
const [showActiveOnly, setShowActiveOnly] = useState(false);
|
||||||
|
|
||||||
// 검색 및 필터링 훅 사용
|
// React Query로 카테고리 데이터 관리 (무한 스크롤)
|
||||||
const {
|
const {
|
||||||
searchTerm,
|
data: categories = [],
|
||||||
setSearchTerm,
|
isLoading,
|
||||||
showActiveOnly,
|
error,
|
||||||
setShowActiveOnly,
|
handleScroll,
|
||||||
filteredItems: filteredCategories,
|
isFetchingNextPage,
|
||||||
} = useSearchAndFilter(categories, {
|
hasNextPage,
|
||||||
searchFields: ["category_name", "category_code"],
|
} = useCategoriesInfinite({
|
||||||
|
search: searchTerm || undefined,
|
||||||
|
active: showActiveOnly || undefined, // isActive -> active로 수정
|
||||||
});
|
});
|
||||||
|
const deleteCategoryMutation = useDeleteCategory();
|
||||||
|
|
||||||
// 모달 상태
|
// 모달 상태
|
||||||
const [showFormModal, setShowFormModal] = useState(false);
|
const [showFormModal, setShowFormModal] = useState(false);
|
||||||
@@ -125,29 +128,44 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 카테고리 목록 */}
|
{/* 카테고리 목록 (무한 스크롤) */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="h-96 overflow-y-auto" onScroll={handleScroll}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-32 items-center justify-center">
|
<div className="flex h-32 items-center justify-center">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
</div>
|
</div>
|
||||||
) : filteredCategories.length === 0 ? (
|
) : categories.length === 0 ? (
|
||||||
<div className="p-4 text-center text-gray-500">
|
<div className="p-4 text-center text-gray-500">
|
||||||
{searchTerm ? "검색 결과가 없습니다." : "카테고리가 없습니다."}
|
{searchTerm ? "검색 결과가 없습니다." : "카테고리가 없습니다."}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1 p-2">
|
<>
|
||||||
{filteredCategories.map((category) => (
|
<div className="space-y-1 p-2">
|
||||||
<CategoryItem
|
{categories.map((category, index) => (
|
||||||
key={category.category_code}
|
<CategoryItem
|
||||||
category={category}
|
key={`${category.category_code}-${index}`}
|
||||||
isSelected={selectedCategoryCode === category.category_code}
|
category={category}
|
||||||
onSelect={() => onSelectCategory(category.category_code)}
|
isSelected={selectedCategoryCode === category.category_code}
|
||||||
onEdit={() => handleEditCategory(category.category_code)}
|
onSelect={() => onSelectCategory(category.category_code)}
|
||||||
onDelete={() => handleDeleteCategory(category.category_code)}
|
onEdit={() => handleEditCategory(category.category_code)}
|
||||||
/>
|
onDelete={() => handleDeleteCategory(category.category_code)}
|
||||||
))}
|
/>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 추가 로딩 표시 */}
|
||||||
|
{isFetchingNextPage && (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<LoadingSpinner size="sm" />
|
||||||
|
<span className="ml-2 text-sm text-gray-500">추가 로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 더 이상 데이터가 없을 때 */}
|
||||||
|
{!hasNextPage && categories.length > 0 && (
|
||||||
|
<div className="py-4 text-center text-sm text-gray-400">모든 카테고리를 불러왔습니다.</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ export function useUpdateCategory() {
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.categories.detail(variables.categoryCode),
|
queryKey: queryKeys.categories.detail(variables.categoryCode),
|
||||||
});
|
});
|
||||||
// 모든 카테고리 목록 쿼리 무효화
|
// 모든 카테고리 관련 쿼리 무효화 (일반 목록 + 무한 스크롤)
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.categories.lists() });
|
queryClient.invalidateQueries({ queryKey: queryKeys.categories.all });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("카테고리 수정 실패:", error);
|
console.error("카테고리 수정 실패:", error);
|
||||||
|
|||||||
42
frontend/hooks/queries/useCategoriesInfinite.ts
Normal file
42
frontend/hooks/queries/useCategoriesInfinite.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||||
|
import { queryKeys } from "@/lib/queryKeys";
|
||||||
|
import { useInfiniteScroll } from "@/hooks/useInfiniteScroll";
|
||||||
|
import type { CategoryFilter } from "@/lib/schemas/commonCode";
|
||||||
|
import type { CodeCategory } from "@/types/commonCode";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 무한 스크롤 훅
|
||||||
|
*/
|
||||||
|
export function useCategoriesInfinite(filters?: CategoryFilter) {
|
||||||
|
return useInfiniteScroll<CodeCategory, CategoryFilter>({
|
||||||
|
queryKey: queryKeys.categories.infinite(filters),
|
||||||
|
queryFn: async ({ pageParam, ...params }) => {
|
||||||
|
// API 호출 시 페이지 정보 포함
|
||||||
|
const expectedSize = pageParam === 1 ? 20 : 10; // 첫 페이지는 20개, 이후는 10개씩
|
||||||
|
const response = await commonCodeApi.categories.getList({
|
||||||
|
...params,
|
||||||
|
page: pageParam,
|
||||||
|
size: expectedSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: response.data || [],
|
||||||
|
total: response.total,
|
||||||
|
hasMore: (response.data?.length || 0) >= expectedSize, // 예상 크기와 같거나 크면 더 있을 수 있음
|
||||||
|
};
|
||||||
|
},
|
||||||
|
initialPageParam: 1,
|
||||||
|
pageSize: 20, // 첫 페이지 기준
|
||||||
|
params: filters,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5분 캐싱
|
||||||
|
// 커스텀 getNextPageParam 제공
|
||||||
|
getNextPageParam: (lastPage, allPages, lastPageParam) => {
|
||||||
|
// 마지막 페이지의 데이터 개수가 요청한 크기보다 작으면 더 이상 페이지 없음
|
||||||
|
const expectedSize = lastPageParam === 1 ? 20 : 10;
|
||||||
|
if ((lastPage.data?.length || 0) < expectedSize) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return lastPageParam + 1;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
107
frontend/hooks/useInfiniteScroll.ts
Normal file
107
frontend/hooks/useInfiniteScroll.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { useMemo, useCallback } from "react";
|
||||||
|
|
||||||
|
export interface InfiniteScrollConfig<TData, TParams = Record<string, any>> {
|
||||||
|
queryKey: any[];
|
||||||
|
queryFn: (params: { pageParam: number } & TParams) => Promise<{
|
||||||
|
data: TData[];
|
||||||
|
total?: number;
|
||||||
|
hasMore?: boolean;
|
||||||
|
}>;
|
||||||
|
initialPageParam?: number;
|
||||||
|
getNextPageParam?: (lastPage: any, allPages: any[], lastPageParam: number) => number | undefined;
|
||||||
|
pageSize?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
staleTime?: number;
|
||||||
|
params?: TParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInfiniteScroll<TData, TParams = Record<string, any>>({
|
||||||
|
queryKey,
|
||||||
|
queryFn,
|
||||||
|
initialPageParam = 1,
|
||||||
|
getNextPageParam,
|
||||||
|
pageSize = 20,
|
||||||
|
enabled = true,
|
||||||
|
staleTime = 5 * 60 * 1000, // 5분
|
||||||
|
params = {} as TParams,
|
||||||
|
}: InfiniteScrollConfig<TData, TParams>) {
|
||||||
|
// React Query의 useInfiniteQuery 사용
|
||||||
|
const infiniteQuery = useInfiniteQuery({
|
||||||
|
queryKey: [...queryKey, params],
|
||||||
|
queryFn: ({ pageParam }) => queryFn({ pageParam, ...params }),
|
||||||
|
initialPageParam,
|
||||||
|
getNextPageParam:
|
||||||
|
getNextPageParam ||
|
||||||
|
((lastPage, allPages, lastPageParam) => {
|
||||||
|
// 기본 페이지네이션 로직
|
||||||
|
if (lastPage.data.length < pageSize) {
|
||||||
|
return undefined; // 더 이상 페이지 없음
|
||||||
|
}
|
||||||
|
return lastPageParam + 1;
|
||||||
|
}),
|
||||||
|
enabled,
|
||||||
|
staleTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 모든 페이지의 데이터를 평탄화
|
||||||
|
const flatData = useMemo(() => {
|
||||||
|
return infiniteQuery.data?.pages.flatMap((page) => page.data) || [];
|
||||||
|
}, [infiniteQuery.data]);
|
||||||
|
|
||||||
|
// 총 개수 계산 (첫 번째 페이지의 total 사용)
|
||||||
|
const totalCount = useMemo(() => {
|
||||||
|
return infiniteQuery.data?.pages[0]?.total || 0;
|
||||||
|
}, [infiniteQuery.data]);
|
||||||
|
|
||||||
|
// 다음 페이지 로드 함수
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (infiniteQuery.hasNextPage && !infiniteQuery.isFetchingNextPage) {
|
||||||
|
infiniteQuery.fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [infiniteQuery]);
|
||||||
|
|
||||||
|
// 스크롤 이벤트 핸들러
|
||||||
|
const handleScroll = useCallback(
|
||||||
|
(e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||||
|
|
||||||
|
// 스크롤이 하단에서 100px 이내에 도달하면 다음 페이지 로드
|
||||||
|
if (scrollHeight - scrollTop <= clientHeight + 100) {
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadMore],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 무한 스크롤 상태 정보
|
||||||
|
const infiniteScrollState = {
|
||||||
|
// 데이터
|
||||||
|
data: flatData,
|
||||||
|
totalCount,
|
||||||
|
|
||||||
|
// 로딩 상태
|
||||||
|
isLoading: infiniteQuery.isLoading,
|
||||||
|
isFetchingNextPage: infiniteQuery.isFetchingNextPage,
|
||||||
|
hasNextPage: infiniteQuery.hasNextPage,
|
||||||
|
|
||||||
|
// 에러 상태
|
||||||
|
error: infiniteQuery.error,
|
||||||
|
isError: infiniteQuery.isError,
|
||||||
|
|
||||||
|
// 기타 상태
|
||||||
|
isSuccess: infiniteQuery.isSuccess,
|
||||||
|
isFetching: infiniteQuery.isFetching,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...infiniteScrollState,
|
||||||
|
loadMore,
|
||||||
|
handleScroll,
|
||||||
|
refetch: infiniteQuery.refetch,
|
||||||
|
invalidate: infiniteQuery.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 편의를 위한 타입 정의
|
||||||
|
export type InfiniteScrollReturn<TData> = ReturnType<typeof useInfiniteScroll<TData>>;
|
||||||
@@ -9,6 +9,8 @@ export const queryKeys = {
|
|||||||
all: ["categories"] as const,
|
all: ["categories"] as const,
|
||||||
lists: () => [...queryKeys.categories.all, "list"] as const,
|
lists: () => [...queryKeys.categories.all, "list"] as const,
|
||||||
list: (filters?: { active?: boolean; search?: string }) => [...queryKeys.categories.lists(), filters] as const,
|
list: (filters?: { active?: boolean; search?: string }) => [...queryKeys.categories.lists(), filters] as const,
|
||||||
|
infinite: (filters?: { active?: boolean; search?: string }) =>
|
||||||
|
[...queryKeys.categories.all, "infinite", filters] as const,
|
||||||
details: () => [...queryKeys.categories.all, "detail"] as const,
|
details: () => [...queryKeys.categories.all, "detail"] as const,
|
||||||
detail: (categoryCode: string) => [...queryKeys.categories.details(), categoryCode] as const,
|
detail: (categoryCode: string) => [...queryKeys.categories.details(), categoryCode] as const,
|
||||||
},
|
},
|
||||||
@@ -19,6 +21,8 @@ export const queryKeys = {
|
|||||||
lists: () => [...queryKeys.codes.all, "list"] as const,
|
lists: () => [...queryKeys.codes.all, "list"] as const,
|
||||||
list: (categoryCode: string, filters?: { active?: boolean; search?: string }) =>
|
list: (categoryCode: string, filters?: { active?: boolean; search?: string }) =>
|
||||||
[...queryKeys.codes.lists(), categoryCode, filters] as const,
|
[...queryKeys.codes.lists(), categoryCode, filters] as const,
|
||||||
|
infinite: (categoryCode: string, filters?: { active?: boolean; search?: string }) =>
|
||||||
|
[...queryKeys.codes.all, "infinite", categoryCode, filters] as const,
|
||||||
details: () => [...queryKeys.codes.all, "detail"] as const,
|
details: () => [...queryKeys.codes.all, "detail"] as const,
|
||||||
detail: (categoryCode: string, codeValue: string) =>
|
detail: (categoryCode: string, codeValue: string) =>
|
||||||
[...queryKeys.codes.details(), categoryCode, codeValue] as const,
|
[...queryKeys.codes.details(), categoryCode, codeValue] as const,
|
||||||
|
|||||||
Reference in New Issue
Block a user