코드 무한 스크롤 구현

This commit is contained in:
hyeonsu 2025-09-03 18:23:23 +09:00
parent ce4a25a10b
commit 55f6925b06
11 changed files with 237 additions and 97 deletions

View File

@ -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) {

View File

@ -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;

View File

@ -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>

View File

@ -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 = {

View File

@ -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;

View File

@ -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),
}); });
}, },
}); });

View File

@ -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;
},
});
}

View File

@ -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 사용)

View File

@ -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}` : ""}`;

View File

@ -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,

View File

@ -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> {