코드 무한 스크롤 구현

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

@@ -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<CodeInfo | null>(null);
// 드래그 앤 드롭 훅 사용
const dragAndDrop = useDragAndDrop({
const dragAndDrop = useDragAndDrop<CodeInfo>({
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) {
</div>
</div>
{/* 코드 목록 */}
<div className="flex-1 overflow-y-auto">
{/* 코드 목록 (무한 스크롤) */}
<div className="h-96 overflow-y-auto" onScroll={handleScroll}>
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<LoadingSpinner />
</div>
) : filteredCodes.length === 0 ? (
<div className="p-4 text-center text-gray-500">
{searchTerm ? "검색 결과가 없습니다." : "코드가 없습니다."}
{codes.length === 0 ? "코드가 없습니다." : "검색 결과가 없습니다."}
</div>
) : (
<div className="p-2">
<DndContext {...dragAndDrop.dndContextProps}>
<SortableContext
items={filteredCodes.map((code) => code.code_value)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{filteredCodes.map((code) => (
<SortableCodeItem
key={code.code_value}
code={code}
categoryCode={categoryCode}
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 className="p-2">
<DndContext {...dragAndDrop.dndContextProps}>
<SortableContext
items={filteredCodes.map((code) => code.code_value)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{filteredCodes.map((code, index) => (
<SortableCodeItem
key={`${code.code_value}-${index}`}
code={code}
categoryCode={categoryCode}
onEdit={() => handleEditCode(code)}
onDelete={() => handleDeleteCode(code)}
/>
))}
</div>
) : null}
</DragOverlay>
</DndContext>
</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>
) : 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>