diff --git a/backend-node/src/controllers/commonCodeController.ts b/backend-node/src/controllers/commonCodeController.ts index 7ceb544f..cf9637d3 100644 --- a/backend-node/src/controllers/commonCodeController.ts +++ b/backend-node/src/controllers/commonCodeController.ts @@ -53,17 +53,20 @@ export class CommonCodeController { async getCodes(req: AuthenticatedRequest, res: Response) { try { 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, isActive: isActive === "true" ? true : isActive === "false" ? false : undefined, + page: page ? parseInt(page as string) : undefined, + size: size ? parseInt(size as string) : undefined, }); return res.json({ success: true, - data: codes, + data: result.data, + total: result.total, message: `코드 목록 조회 성공 (${categoryCode})`, }); } catch (error) { diff --git a/backend-node/src/services/commonCodeService.ts b/backend-node/src/services/commonCodeService.ts index 4214087a..fca352ff 100644 --- a/backend-node/src/services/commonCodeService.ts +++ b/backend-node/src/services/commonCodeService.ts @@ -40,6 +40,8 @@ export interface GetCategoriesParams { export interface GetCodesParams { search?: string; isActive?: boolean; + page?: number; + size?: number; } export interface CreateCategoryData { @@ -112,7 +114,7 @@ export class CommonCodeService { */ async getCodes(categoryCode: string, params: GetCodesParams) { try { - const { search, isActive } = params; + const { search, isActive, page = 1, size = 20 } = params; let whereClause: any = { code_category: categoryCode, @@ -129,14 +131,23 @@ export class CommonCodeService { whereClause.is_active = isActive ? "Y" : "N"; } - const codes = await prisma.code_info.findMany({ - where: whereClause, - orderBy: [{ sort_order: "asc" }, { code_value: "asc" }], - }); + const offset = (page - 1) * size; - 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) { logger.error(`코드 조회 중 오류 (${categoryCode}):`, error); throw error; diff --git a/frontend/components/admin/CodeDetailPanel.tsx b/frontend/components/admin/CodeDetailPanel.tsx index fea7fe20..2b0fa431 100644 --- a/frontend/components/admin/CodeDetailPanel.tsx +++ b/frontend/components/admin/CodeDetailPanel.tsx @@ -10,7 +10,8 @@ import { SortableCodeItem } from "./SortableCodeItem"; import { AlertModal } from "@/components/common/AlertModal"; import { Search, Plus } from "lucide-react"; 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"; // Drag and Drop @@ -24,19 +25,27 @@ interface 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 reorderCodesMutation = useReorderCodes(); - // 검색 및 필터링 훅 사용 - const { - searchTerm, - setSearchTerm, - showActiveOnly, - setShowActiveOnly, - filteredItems: filteredCodes, - } = useSearchAndFilter(codes, { + // 드래그앤드롭을 위해 필터링된 코드 목록 사용 + const { filteredItems: filteredCodes } = useSearchAndFilter(codes, { searchFields: ["code_name", "code_value"], }); @@ -47,7 +56,7 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) { const [deletingCode, setDeletingCode] = useState(null); // 드래그 앤 드롭 훅 사용 - const dragAndDrop = useDragAndDrop({ + const dragAndDrop = useDragAndDrop({ items: filteredCodes, onReorder: async (reorderedItems) => { 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) { - {/* 코드 목록 */} -
+ {/* 코드 목록 (무한 스크롤) */} +
{isLoading ? (
) : filteredCodes.length === 0 ? (
- {searchTerm ? "검색 결과가 없습니다." : "코드가 없습니다."} + {codes.length === 0 ? "코드가 없습니다." : "검색 결과가 없습니다."}
) : ( -
- - code.code_value)} - strategy={verticalListSortingStrategy} - > -
- {filteredCodes.map((code) => ( - handleEditCode(code)} - onDelete={() => handleDeleteCode(code)} - /> - ))} -
-
- - - {dragAndDrop.activeItem ? ( -
- {(() => { - const activeCode = dragAndDrop.activeItem; - if (!activeCode) return null; - return ( -
-
-
-

{activeCode.code_name}

- - {activeCode.is_active === "Y" ? "활성" : "비활성"} - -
-

{activeCode.code_value}

- {activeCode.description && ( -

{activeCode.description}

- )} -
-
- ); - })()} + <> +
+ + code.code_value)} + strategy={verticalListSortingStrategy} + > +
+ {filteredCodes.map((code, index) => ( + handleEditCode(code)} + onDelete={() => handleDeleteCode(code)} + /> + ))}
- ) : null} - -
-
+ + + + {dragAndDrop.activeItem ? ( +
+ {(() => { + const activeCode = dragAndDrop.activeItem; + if (!activeCode) return null; + return ( +
+
+
+

{activeCode.code_name}

+ + {activeCode.is_active === "Y" ? "활성" : "비활성"} + +
+

{activeCode.code_value}

+ {activeCode.description && ( +

{activeCode.description}

+ )} +
+
+ ); + })()} +
+ ) : null} +
+ +
+ + {/* 무한 스크롤 로딩 인디케이터 */} + {isFetchingNextPage && ( +
+ + 코드를 더 불러오는 중... +
+ )} + + {/* 모든 코드 로드 완료 메시지 */} + {!hasNextPage && codes.length > 0 && ( +
모든 코드를 불러왔습니다.
+ )} + )}
diff --git a/frontend/components/admin/SortableCodeItem.tsx b/frontend/components/admin/SortableCodeItem.tsx index dd473757..4c552a3e 100644 --- a/frontend/components/admin/SortableCodeItem.tsx +++ b/frontend/components/admin/SortableCodeItem.tsx @@ -15,10 +15,20 @@ interface SortableCodeItemProps { categoryCode: string; onEdit: () => void; onDelete: () => void; + isDragOverlay?: boolean; } -export function SortableCodeItem({ code, categoryCode, onEdit, onDelete }: SortableCodeItemProps) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: code.code_value }); +export function SortableCodeItem({ + 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 style = { diff --git a/frontend/hooks/queries/useCategoriesInfinite.ts b/frontend/hooks/queries/useCategoriesInfinite.ts index 691b03fb..4ca4f9fe 100644 --- a/frontend/hooks/queries/useCategoriesInfinite.ts +++ b/frontend/hooks/queries/useCategoriesInfinite.ts @@ -9,20 +9,21 @@ import type { CodeCategory } from "@/types/commonCode"; */ export function useCategoriesInfinite(filters?: CategoryFilter) { return useInfiniteScroll({ - queryKey: queryKeys.categories.infinite(filters), + queryKey: queryKeys.categories.infiniteList(filters), queryFn: async ({ pageParam, ...params }) => { - // API 호출 시 페이지 정보 포함 - const expectedSize = pageParam === 1 ? 20 : 10; // 첫 페이지는 20개, 이후는 10개씩 + // 첫 페이지는 20개, 이후는 10개씩 + const pageSize = pageParam === 1 ? 20 : 10; const response = await commonCodeApi.categories.getList({ ...params, page: pageParam, - size: expectedSize, + size: pageSize, }); return { data: response.data || [], total: response.total, - hasMore: (response.data?.length || 0) >= expectedSize, // 예상 크기와 같거나 크면 더 있을 수 있음 + currentPage: pageParam, + pageSize: pageSize, }; }, initialPageParam: 1, @@ -31,9 +32,9 @@ export function useCategoriesInfinite(filters?: CategoryFilter) { staleTime: 5 * 60 * 1000, // 5분 캐싱 // 커스텀 getNextPageParam 제공 getNextPageParam: (lastPage, allPages, lastPageParam) => { - // 마지막 페이지의 데이터 개수가 요청한 크기보다 작으면 더 이상 페이지 없음 - const expectedSize = lastPageParam === 1 ? 20 : 10; - if ((lastPage.data?.length || 0) < expectedSize) { + // 마지막 페이지의 데이터 개수가 요청한 페이지 크기보다 작으면 더 이상 페이지 없음 + const currentPageSize = lastPage.pageSize || (lastPageParam === 1 ? 20 : 10); + if ((lastPage.data?.length || 0) < currentPageSize) { return undefined; } return lastPageParam + 1; diff --git a/frontend/hooks/queries/useCodes.ts b/frontend/hooks/queries/useCodes.ts index 32c1b7bf..48b20641 100644 --- a/frontend/hooks/queries/useCodes.ts +++ b/frontend/hooks/queries/useCodes.ts @@ -25,9 +25,13 @@ export function useCreateCode() { mutationFn: ({ categoryCode, data }: { categoryCode: string; data: CreateCodeData }) => commonCodeApi.codes.create(categoryCode, data), onSuccess: (_, variables) => { - // 해당 카테고리의 모든 코드 쿼리 무효화 + // 해당 카테고리의 모든 코드 관련 쿼리 무효화 (일반 목록 + 무한 스크롤) queryClient.invalidateQueries({ - queryKey: queryKeys.codes.list(variables.categoryCode), + queryKey: queryKeys.codes.all, + }); + // 무한 스크롤 쿼리도 명시적으로 무효화 + queryClient.invalidateQueries({ + queryKey: queryKeys.codes.infiniteList(variables.categoryCode), }); }, onError: (error) => { @@ -57,9 +61,13 @@ export function useUpdateCode() { queryClient.invalidateQueries({ queryKey: queryKeys.codes.detail(variables.categoryCode, variables.codeValue), }); - // 해당 카테고리의 코드 목록 쿼리 무효화 + // 해당 카테고리의 모든 코드 관련 쿼리 무효화 (일반 목록 + 무한 스크롤) queryClient.invalidateQueries({ - queryKey: queryKeys.codes.list(variables.categoryCode), + queryKey: queryKeys.codes.all, + }); + // 무한 스크롤 쿼리도 명시적으로 무효화 + queryClient.invalidateQueries({ + queryKey: queryKeys.codes.infiniteList(variables.categoryCode), }); }, onError: (error) => { @@ -80,7 +88,11 @@ export function useDeleteCode() { onSuccess: (_, variables) => { // 해당 코드 관련 쿼리 무효화 및 캐시 제거 queryClient.invalidateQueries({ - queryKey: queryKeys.codes.list(variables.categoryCode), + queryKey: queryKeys.codes.all, + }); + // 무한 스크롤 쿼리도 명시적으로 무효화 + queryClient.invalidateQueries({ + queryKey: queryKeys.codes.infiniteList(variables.categoryCode), }); queryClient.removeQueries({ queryKey: queryKeys.codes.detail(variables.categoryCode, variables.codeValue), @@ -146,7 +158,11 @@ export function useReorderCodes() { onSettled: (_, __, variables) => { // 성공/실패와 관계없이 최종적으로 서버 데이터로 동기화 queryClient.invalidateQueries({ - queryKey: queryKeys.codes.list(variables.categoryCode), + queryKey: queryKeys.codes.all, + }); + // 무한 스크롤 쿼리도 명시적으로 무효화 + queryClient.invalidateQueries({ + queryKey: queryKeys.codes.infiniteList(variables.categoryCode), }); }, }); diff --git a/frontend/hooks/queries/useCodesInfinite.ts b/frontend/hooks/queries/useCodesInfinite.ts new file mode 100644 index 00000000..281dbe16 --- /dev/null +++ b/frontend/hooks/queries/useCodesInfinite.ts @@ -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({ + 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; + }, + }); +} diff --git a/frontend/hooks/useInfiniteScroll.ts b/frontend/hooks/useInfiniteScroll.ts index dec4b25d..53d504c7 100644 --- a/frontend/hooks/useInfiniteScroll.ts +++ b/frontend/hooks/useInfiniteScroll.ts @@ -46,7 +46,25 @@ export function useInfiniteScroll>({ // 모든 페이지의 데이터를 평탄화 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]); // 총 개수 계산 (첫 번째 페이지의 total 사용) diff --git a/frontend/lib/api/commonCode.ts b/frontend/lib/api/commonCode.ts index 77051786..2f465880 100644 --- a/frontend/lib/api/commonCode.ts +++ b/frontend/lib/api/commonCode.ts @@ -71,6 +71,8 @@ export const commonCodeApi = { if (params?.search) searchParams.append("search", params.search); 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 url = `/common-codes/categories/${categoryCode}/codes${queryString ? `?${queryString}` : ""}`; diff --git a/frontend/lib/queryKeys.ts b/frontend/lib/queryKeys.ts index 67ead15d..a74f0e51 100644 --- a/frontend/lib/queryKeys.ts +++ b/frontend/lib/queryKeys.ts @@ -11,6 +11,8 @@ export const queryKeys = { 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, + infiniteList: (filters?: { active?: boolean; search?: string }) => + [...queryKeys.categories.all, "infiniteList", filters] as const, details: () => [...queryKeys.categories.all, "detail"] 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, infinite: (categoryCode: string, filters?: { active?: boolean; search?: string }) => [...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, detail: (categoryCode: string, codeValue: string) => [...queryKeys.codes.details(), categoryCode, codeValue] as const, diff --git a/frontend/types/commonCode.ts b/frontend/types/commonCode.ts index 265b5772..cf4dbb40 100644 --- a/frontend/types/commonCode.ts +++ b/frontend/types/commonCode.ts @@ -85,6 +85,8 @@ export interface GetCategoriesQuery { export interface GetCodesQuery { search?: string; isActive?: boolean; + page?: number; + size?: number; } export interface ApiResponse {