코드 무한 스크롤 구현
This commit is contained in:
parent
ce4a25a10b
commit
55f6925b06
|
|
@ -53,17 +53,20 @@ export class CommonCodeController {
|
||||||
async getCodes(req: AuthenticatedRequest, res: Response) {
|
async getCodes(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const { categoryCode } = req.params;
|
const { categoryCode } = req.params;
|
||||||
const { search, isActive } = req.query;
|
const { search, isActive, page, size } = req.query;
|
||||||
|
|
||||||
const codes = await this.commonCodeService.getCodes(categoryCode, {
|
const result = await this.commonCodeService.getCodes(categoryCode, {
|
||||||
search: search as string,
|
search: search as string,
|
||||||
isActive:
|
isActive:
|
||||||
isActive === "true" ? true : isActive === "false" ? false : undefined,
|
isActive === "true" ? true : isActive === "false" ? false : undefined,
|
||||||
|
page: page ? parseInt(page as string) : undefined,
|
||||||
|
size: size ? parseInt(size as string) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: codes,
|
data: result.data,
|
||||||
|
total: result.total,
|
||||||
message: `코드 목록 조회 성공 (${categoryCode})`,
|
message: `코드 목록 조회 성공 (${categoryCode})`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ export interface GetCategoriesParams {
|
||||||
export interface GetCodesParams {
|
export interface GetCodesParams {
|
||||||
search?: string;
|
search?: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateCategoryData {
|
export interface CreateCategoryData {
|
||||||
|
|
@ -112,7 +114,7 @@ export class CommonCodeService {
|
||||||
*/
|
*/
|
||||||
async getCodes(categoryCode: string, params: GetCodesParams) {
|
async getCodes(categoryCode: string, params: GetCodesParams) {
|
||||||
try {
|
try {
|
||||||
const { search, isActive } = params;
|
const { search, isActive, page = 1, size = 20 } = params;
|
||||||
|
|
||||||
let whereClause: any = {
|
let whereClause: any = {
|
||||||
code_category: categoryCode,
|
code_category: categoryCode,
|
||||||
|
|
@ -129,14 +131,23 @@ export class CommonCodeService {
|
||||||
whereClause.is_active = isActive ? "Y" : "N";
|
whereClause.is_active = isActive ? "Y" : "N";
|
||||||
}
|
}
|
||||||
|
|
||||||
const codes = await prisma.code_info.findMany({
|
const offset = (page - 1) * size;
|
||||||
where: whereClause,
|
|
||||||
orderBy: [{ sort_order: "asc" }, { code_value: "asc" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`코드 조회 완료: ${categoryCode} - ${codes.length}개`);
|
const [codes, total] = await Promise.all([
|
||||||
|
prisma.code_info.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
orderBy: [{ sort_order: "asc" }, { code_value: "asc" }],
|
||||||
|
skip: offset,
|
||||||
|
take: size,
|
||||||
|
}),
|
||||||
|
prisma.code_info.count({ where: whereClause }),
|
||||||
|
]);
|
||||||
|
|
||||||
return codes;
|
logger.info(
|
||||||
|
`코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개`
|
||||||
|
);
|
||||||
|
|
||||||
|
return { data: codes, total };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`코드 조회 중 오류 (${categoryCode}):`, error);
|
logger.error(`코드 조회 중 오류 (${categoryCode}):`, error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ import { SortableCodeItem } from "./SortableCodeItem";
|
||||||
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 { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useCodes, useDeleteCode, useReorderCodes } from "@/hooks/queries/useCodes";
|
import { useDeleteCode, useReorderCodes } from "@/hooks/queries/useCodes";
|
||||||
|
import { useCodesInfinite } from "@/hooks/queries/useCodesInfinite";
|
||||||
import type { CodeInfo } from "@/types/commonCode";
|
import type { CodeInfo } from "@/types/commonCode";
|
||||||
|
|
||||||
// Drag and Drop
|
// Drag and Drop
|
||||||
|
|
@ -24,19 +25,27 @@ interface CodeDetailPanelProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
||||||
// React Query로 코드 데이터 관리
|
// 검색 및 필터 상태 (먼저 선언)
|
||||||
const { data: codes = [], isLoading, error } = useCodes(categoryCode);
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [showActiveOnly, setShowActiveOnly] = useState(false);
|
||||||
|
|
||||||
|
// React Query로 코드 데이터 관리 (무한 스크롤)
|
||||||
|
const {
|
||||||
|
data: codes = [],
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
handleScroll,
|
||||||
|
isFetchingNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
} = useCodesInfinite(categoryCode, {
|
||||||
|
search: searchTerm || undefined,
|
||||||
|
active: showActiveOnly || undefined,
|
||||||
|
});
|
||||||
const deleteCodeMutation = useDeleteCode();
|
const deleteCodeMutation = useDeleteCode();
|
||||||
const reorderCodesMutation = useReorderCodes();
|
const reorderCodesMutation = useReorderCodes();
|
||||||
|
|
||||||
// 검색 및 필터링 훅 사용
|
// 드래그앤드롭을 위해 필터링된 코드 목록 사용
|
||||||
const {
|
const { filteredItems: filteredCodes } = useSearchAndFilter(codes, {
|
||||||
searchTerm,
|
|
||||||
setSearchTerm,
|
|
||||||
showActiveOnly,
|
|
||||||
setShowActiveOnly,
|
|
||||||
filteredItems: filteredCodes,
|
|
||||||
} = useSearchAndFilter(codes, {
|
|
||||||
searchFields: ["code_name", "code_value"],
|
searchFields: ["code_name", "code_value"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -47,7 +56,7 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
||||||
const [deletingCode, setDeletingCode] = useState<CodeInfo | null>(null);
|
const [deletingCode, setDeletingCode] = useState<CodeInfo | null>(null);
|
||||||
|
|
||||||
// 드래그 앤 드롭 훅 사용
|
// 드래그 앤 드롭 훅 사용
|
||||||
const dragAndDrop = useDragAndDrop({
|
const dragAndDrop = useDragAndDrop<CodeInfo>({
|
||||||
items: filteredCodes,
|
items: filteredCodes,
|
||||||
onReorder: async (reorderedItems) => {
|
onReorder: async (reorderedItems) => {
|
||||||
await reorderCodesMutation.mutateAsync({
|
await reorderCodesMutation.mutateAsync({
|
||||||
|
|
@ -58,7 +67,7 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getItemId: (code) => code.code_value,
|
getItemId: (code: CodeInfo) => code.code_value,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 새 코드 생성
|
// 새 코드 생성
|
||||||
|
|
@ -158,72 +167,87 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
||||||
</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>
|
||||||
) : filteredCodes.length === 0 ? (
|
) : filteredCodes.length === 0 ? (
|
||||||
<div className="p-4 text-center text-gray-500">
|
<div className="p-4 text-center text-gray-500">
|
||||||
{searchTerm ? "검색 결과가 없습니다." : "코드가 없습니다."}
|
{codes.length === 0 ? "코드가 없습니다." : "검색 결과가 없습니다."}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2">
|
<>
|
||||||
<DndContext {...dragAndDrop.dndContextProps}>
|
<div className="p-2">
|
||||||
<SortableContext
|
<DndContext {...dragAndDrop.dndContextProps}>
|
||||||
items={filteredCodes.map((code) => code.code_value)}
|
<SortableContext
|
||||||
strategy={verticalListSortingStrategy}
|
items={filteredCodes.map((code) => code.code_value)}
|
||||||
>
|
strategy={verticalListSortingStrategy}
|
||||||
<div className="space-y-1">
|
>
|
||||||
{filteredCodes.map((code) => (
|
<div className="space-y-1">
|
||||||
<SortableCodeItem
|
{filteredCodes.map((code, index) => (
|
||||||
key={code.code_value}
|
<SortableCodeItem
|
||||||
code={code}
|
key={`${code.code_value}-${index}`}
|
||||||
categoryCode={categoryCode}
|
code={code}
|
||||||
onEdit={() => handleEditCode(code)}
|
categoryCode={categoryCode}
|
||||||
onDelete={() => handleDeleteCode(code)}
|
onEdit={() => handleEditCode(code)}
|
||||||
/>
|
onDelete={() => handleDeleteCode(code)}
|
||||||
))}
|
/>
|
||||||
</div>
|
))}
|
||||||
</SortableContext>
|
|
||||||
|
|
||||||
<DragOverlay dropAnimation={null}>
|
|
||||||
{dragAndDrop.activeItem ? (
|
|
||||||
<div className="cursor-grabbing rounded-lg border border-gray-300 bg-white p-3 shadow-lg">
|
|
||||||
{(() => {
|
|
||||||
const activeCode = dragAndDrop.activeItem;
|
|
||||||
if (!activeCode) return null;
|
|
||||||
return (
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-medium text-gray-900">{activeCode.code_name}</h3>
|
|
||||||
<Badge
|
|
||||||
variant={activeCode.is_active === "Y" ? "default" : "secondary"}
|
|
||||||
className={cn(
|
|
||||||
"transition-colors",
|
|
||||||
activeCode.is_active === "Y"
|
|
||||||
? "bg-green-100 text-green-800"
|
|
||||||
: "bg-gray-100 text-gray-600",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{activeCode.is_active === "Y" ? "활성" : "비활성"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-sm text-gray-600">{activeCode.code_value}</p>
|
|
||||||
{activeCode.description && (
|
|
||||||
<p className="mt-1 text-sm text-gray-500">{activeCode.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</SortableContext>
|
||||||
</DragOverlay>
|
|
||||||
</DndContext>
|
<DragOverlay dropAnimation={null}>
|
||||||
</div>
|
{dragAndDrop.activeItem ? (
|
||||||
|
<div className="cursor-grabbing rounded-lg border border-gray-300 bg-white p-3 shadow-lg">
|
||||||
|
{(() => {
|
||||||
|
const activeCode = dragAndDrop.activeItem;
|
||||||
|
if (!activeCode) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-medium text-gray-900">{activeCode.code_name}</h3>
|
||||||
|
<Badge
|
||||||
|
variant={activeCode.is_active === "Y" ? "default" : "secondary"}
|
||||||
|
className={cn(
|
||||||
|
"transition-colors",
|
||||||
|
activeCode.is_active === "Y"
|
||||||
|
? "bg-green-100 text-green-800"
|
||||||
|
: "bg-gray-100 text-gray-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{activeCode.is_active === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-gray-600">{activeCode.code_value}</p>
|
||||||
|
{activeCode.description && (
|
||||||
|
<p className="mt-1 text-sm text-gray-500">{activeCode.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 무한 스크롤 로딩 인디케이터 */}
|
||||||
|
{isFetchingNextPage && (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<LoadingSpinner size="sm" />
|
||||||
|
<span className="ml-2 text-sm text-gray-500">코드를 더 불러오는 중...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 모든 코드 로드 완료 메시지 */}
|
||||||
|
{!hasNextPage && codes.length > 0 && (
|
||||||
|
<div className="py-4 text-center text-sm text-gray-500">모든 코드를 불러왔습니다.</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,20 @@ interface SortableCodeItemProps {
|
||||||
categoryCode: string;
|
categoryCode: string;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
isDragOverlay?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SortableCodeItem({ code, categoryCode, onEdit, onDelete }: SortableCodeItemProps) {
|
export function SortableCodeItem({
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: code.code_value });
|
code,
|
||||||
|
categoryCode,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
isDragOverlay = false,
|
||||||
|
}: SortableCodeItemProps) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: code.code_value,
|
||||||
|
disabled: isDragOverlay,
|
||||||
|
});
|
||||||
const updateCodeMutation = useUpdateCode();
|
const updateCodeMutation = useUpdateCode();
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
|
|
|
||||||
|
|
@ -9,20 +9,21 @@ import type { CodeCategory } from "@/types/commonCode";
|
||||||
*/
|
*/
|
||||||
export function useCategoriesInfinite(filters?: CategoryFilter) {
|
export function useCategoriesInfinite(filters?: CategoryFilter) {
|
||||||
return useInfiniteScroll<CodeCategory, CategoryFilter>({
|
return useInfiniteScroll<CodeCategory, CategoryFilter>({
|
||||||
queryKey: queryKeys.categories.infinite(filters),
|
queryKey: queryKeys.categories.infiniteList(filters),
|
||||||
queryFn: async ({ pageParam, ...params }) => {
|
queryFn: async ({ pageParam, ...params }) => {
|
||||||
// API 호출 시 페이지 정보 포함
|
// 첫 페이지는 20개, 이후는 10개씩
|
||||||
const expectedSize = pageParam === 1 ? 20 : 10; // 첫 페이지는 20개, 이후는 10개씩
|
const pageSize = pageParam === 1 ? 20 : 10;
|
||||||
const response = await commonCodeApi.categories.getList({
|
const response = await commonCodeApi.categories.getList({
|
||||||
...params,
|
...params,
|
||||||
page: pageParam,
|
page: pageParam,
|
||||||
size: expectedSize,
|
size: pageSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: response.data || [],
|
data: response.data || [],
|
||||||
total: response.total,
|
total: response.total,
|
||||||
hasMore: (response.data?.length || 0) >= expectedSize, // 예상 크기와 같거나 크면 더 있을 수 있음
|
currentPage: pageParam,
|
||||||
|
pageSize: pageSize,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
initialPageParam: 1,
|
initialPageParam: 1,
|
||||||
|
|
@ -31,9 +32,9 @@ export function useCategoriesInfinite(filters?: CategoryFilter) {
|
||||||
staleTime: 5 * 60 * 1000, // 5분 캐싱
|
staleTime: 5 * 60 * 1000, // 5분 캐싱
|
||||||
// 커스텀 getNextPageParam 제공
|
// 커스텀 getNextPageParam 제공
|
||||||
getNextPageParam: (lastPage, allPages, lastPageParam) => {
|
getNextPageParam: (lastPage, allPages, lastPageParam) => {
|
||||||
// 마지막 페이지의 데이터 개수가 요청한 크기보다 작으면 더 이상 페이지 없음
|
// 마지막 페이지의 데이터 개수가 요청한 페이지 크기보다 작으면 더 이상 페이지 없음
|
||||||
const expectedSize = lastPageParam === 1 ? 20 : 10;
|
const currentPageSize = lastPage.pageSize || (lastPageParam === 1 ? 20 : 10);
|
||||||
if ((lastPage.data?.length || 0) < expectedSize) {
|
if ((lastPage.data?.length || 0) < currentPageSize) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return lastPageParam + 1;
|
return lastPageParam + 1;
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,13 @@ export function useCreateCode() {
|
||||||
mutationFn: ({ categoryCode, data }: { categoryCode: string; data: CreateCodeData }) =>
|
mutationFn: ({ categoryCode, data }: { categoryCode: string; data: CreateCodeData }) =>
|
||||||
commonCodeApi.codes.create(categoryCode, data),
|
commonCodeApi.codes.create(categoryCode, data),
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
// 해당 카테고리의 모든 코드 쿼리 무효화
|
// 해당 카테고리의 모든 코드 관련 쿼리 무효화 (일반 목록 + 무한 스크롤)
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.codes.list(variables.categoryCode),
|
queryKey: queryKeys.codes.all,
|
||||||
|
});
|
||||||
|
// 무한 스크롤 쿼리도 명시적으로 무효화
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.codes.infiniteList(variables.categoryCode),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
|
@ -57,9 +61,13 @@ export function useUpdateCode() {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.codes.detail(variables.categoryCode, variables.codeValue),
|
queryKey: queryKeys.codes.detail(variables.categoryCode, variables.codeValue),
|
||||||
});
|
});
|
||||||
// 해당 카테고리의 코드 목록 쿼리 무효화
|
// 해당 카테고리의 모든 코드 관련 쿼리 무효화 (일반 목록 + 무한 스크롤)
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.codes.list(variables.categoryCode),
|
queryKey: queryKeys.codes.all,
|
||||||
|
});
|
||||||
|
// 무한 스크롤 쿼리도 명시적으로 무효화
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.codes.infiniteList(variables.categoryCode),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
|
@ -80,7 +88,11 @@ export function useDeleteCode() {
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
// 해당 코드 관련 쿼리 무효화 및 캐시 제거
|
// 해당 코드 관련 쿼리 무효화 및 캐시 제거
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.codes.list(variables.categoryCode),
|
queryKey: queryKeys.codes.all,
|
||||||
|
});
|
||||||
|
// 무한 스크롤 쿼리도 명시적으로 무효화
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.codes.infiniteList(variables.categoryCode),
|
||||||
});
|
});
|
||||||
queryClient.removeQueries({
|
queryClient.removeQueries({
|
||||||
queryKey: queryKeys.codes.detail(variables.categoryCode, variables.codeValue),
|
queryKey: queryKeys.codes.detail(variables.categoryCode, variables.codeValue),
|
||||||
|
|
@ -146,7 +158,11 @@ export function useReorderCodes() {
|
||||||
onSettled: (_, __, variables) => {
|
onSettled: (_, __, variables) => {
|
||||||
// 성공/실패와 관계없이 최종적으로 서버 데이터로 동기화
|
// 성공/실패와 관계없이 최종적으로 서버 데이터로 동기화
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.codes.list(variables.categoryCode),
|
queryKey: queryKeys.codes.all,
|
||||||
|
});
|
||||||
|
// 무한 스크롤 쿼리도 명시적으로 무효화
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.codes.infiniteList(variables.categoryCode),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||||
|
import { queryKeys } from "@/lib/queryKeys";
|
||||||
|
import { useInfiniteScroll } from "@/hooks/useInfiniteScroll";
|
||||||
|
import type { CodeFilter } from "@/lib/schemas/commonCode";
|
||||||
|
import type { CodeInfo } from "@/types/commonCode";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코드 목록 무한 스크롤 훅
|
||||||
|
* 카테고리별로 코드 목록을 무한 스크롤로 로드
|
||||||
|
*/
|
||||||
|
export function useCodesInfinite(categoryCode: string, filters?: CodeFilter) {
|
||||||
|
return useInfiniteScroll<CodeInfo, CodeFilter>({
|
||||||
|
queryKey: queryKeys.codes.infiniteList(categoryCode, filters),
|
||||||
|
queryFn: async ({ pageParam, ...params }) => {
|
||||||
|
// 첫 페이지는 20개, 이후는 10개씩
|
||||||
|
const pageSize = pageParam === 1 ? 20 : 10;
|
||||||
|
const response = await commonCodeApi.codes.getList(categoryCode, {
|
||||||
|
...params,
|
||||||
|
page: pageParam,
|
||||||
|
size: pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: response.data || [],
|
||||||
|
total: response.total,
|
||||||
|
currentPage: pageParam,
|
||||||
|
pageSize: pageSize,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
initialPageParam: 1,
|
||||||
|
pageSize: 20, // 첫 페이지 기준
|
||||||
|
params: filters,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5분 캐싱
|
||||||
|
enabled: !!categoryCode, // categoryCode가 있을 때만 실행
|
||||||
|
// 커스텀 getNextPageParam 제공
|
||||||
|
getNextPageParam: (lastPage, allPages, lastPageParam) => {
|
||||||
|
// 마지막 페이지의 데이터 개수가 요청한 페이지 크기보다 작으면 더 이상 페이지 없음
|
||||||
|
const currentPageSize = lastPageParam === 1 ? 20 : 10;
|
||||||
|
const dataLength = lastPage.data?.length || 0;
|
||||||
|
|
||||||
|
// 받은 데이터가 요청한 크기보다 작으면 마지막 페이지
|
||||||
|
if (dataLength < currentPageSize) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastPageParam + 1;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -46,7 +46,25 @@ export function useInfiniteScroll<TData, TParams = Record<string, any>>({
|
||||||
|
|
||||||
// 모든 페이지의 데이터를 평탄화
|
// 모든 페이지의 데이터를 평탄화
|
||||||
const flatData = useMemo(() => {
|
const flatData = useMemo(() => {
|
||||||
return infiniteQuery.data?.pages.flatMap((page) => page.data) || [];
|
if (!infiniteQuery.data?.pages) return [];
|
||||||
|
|
||||||
|
const allData = infiniteQuery.data.pages.flatMap((page) => page.data);
|
||||||
|
|
||||||
|
// 중복 제거 - code_value 또는 category_code를 기준으로
|
||||||
|
const uniqueData = allData.filter((item, index, self) => {
|
||||||
|
const key = (item as any).code_value || (item as any).category_code;
|
||||||
|
if (!key) return true; // key가 없으면 그대로 유지
|
||||||
|
|
||||||
|
return (
|
||||||
|
index ===
|
||||||
|
self.findIndex((t) => {
|
||||||
|
const tKey = (t as any).code_value || (t as any).category_code;
|
||||||
|
return tKey === key;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return uniqueData;
|
||||||
}, [infiniteQuery.data]);
|
}, [infiniteQuery.data]);
|
||||||
|
|
||||||
// 총 개수 계산 (첫 번째 페이지의 total 사용)
|
// 총 개수 계산 (첫 번째 페이지의 total 사용)
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,8 @@ export const commonCodeApi = {
|
||||||
|
|
||||||
if (params?.search) searchParams.append("search", params.search);
|
if (params?.search) searchParams.append("search", params.search);
|
||||||
if (params?.isActive !== undefined) searchParams.append("isActive", params.isActive.toString());
|
if (params?.isActive !== undefined) searchParams.append("isActive", params.isActive.toString());
|
||||||
|
if (params?.page !== undefined) searchParams.append("page", params.page.toString());
|
||||||
|
if (params?.size !== undefined) searchParams.append("size", params.size.toString());
|
||||||
|
|
||||||
const queryString = searchParams.toString();
|
const queryString = searchParams.toString();
|
||||||
const url = `/common-codes/categories/${categoryCode}/codes${queryString ? `?${queryString}` : ""}`;
|
const url = `/common-codes/categories/${categoryCode}/codes${queryString ? `?${queryString}` : ""}`;
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ export const queryKeys = {
|
||||||
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 }) =>
|
infinite: (filters?: { active?: boolean; search?: string }) =>
|
||||||
[...queryKeys.categories.all, "infinite", filters] as const,
|
[...queryKeys.categories.all, "infinite", filters] as const,
|
||||||
|
infiniteList: (filters?: { active?: boolean; search?: string }) =>
|
||||||
|
[...queryKeys.categories.all, "infiniteList", 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,
|
||||||
},
|
},
|
||||||
|
|
@ -23,6 +25,8 @@ export const queryKeys = {
|
||||||
[...queryKeys.codes.lists(), categoryCode, filters] as const,
|
[...queryKeys.codes.lists(), categoryCode, filters] as const,
|
||||||
infinite: (categoryCode: string, filters?: { active?: boolean; search?: string }) =>
|
infinite: (categoryCode: string, filters?: { active?: boolean; search?: string }) =>
|
||||||
[...queryKeys.codes.all, "infinite", categoryCode, filters] as const,
|
[...queryKeys.codes.all, "infinite", categoryCode, filters] as const,
|
||||||
|
infiniteList: (categoryCode: string, filters?: { active?: boolean; search?: string }) =>
|
||||||
|
[...queryKeys.codes.all, "infiniteList", 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,
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,8 @@ export interface GetCategoriesQuery {
|
||||||
export interface GetCodesQuery {
|
export interface GetCodesQuery {
|
||||||
search?: string;
|
search?: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiResponse<T = any> {
|
export interface ApiResponse<T = any> {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue