From 08d704da5ce070362c642e2e1cc3120f15e0868e Mon Sep 17 00:00:00 2001 From: caadiq Date: Fri, 23 Jan 2026 10:00:50 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20useScheduleSearch=20=ED=9B=85=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20-=20=EA=B2=80=EC=83=89=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=BA=A1=EC=8A=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useScheduleSearch.js 생성 (217줄) - 검색어 자동완성 API 호출 (debounce) - 무한 스크롤 검색 결과 (useInfiniteQuery) - 키보드 네비게이션 핸들러 - Schedules.jsx: 1139줄 → 1009줄 (130줄 감소) - 검색 관련 상태/로직을 훅으로 이동 - 컴포넌트 복잡도 감소 Co-Authored-By: Claude Opus 4.5 --- frontend-temp/src/hooks/pc/admin/index.js | 3 + .../src/hooks/pc/admin/useScheduleSearch.js | 217 ++++++++++++++++++ .../pages/pc/admin/schedules/Schedules.jsx | 192 +++------------- 3 files changed, 251 insertions(+), 161 deletions(-) create mode 100644 frontend-temp/src/hooks/pc/admin/useScheduleSearch.js diff --git a/frontend-temp/src/hooks/pc/admin/index.js b/frontend-temp/src/hooks/pc/admin/index.js index 02dd608..df72299 100644 --- a/frontend-temp/src/hooks/pc/admin/index.js +++ b/frontend-temp/src/hooks/pc/admin/index.js @@ -1,2 +1,5 @@ // 관리자 인증 export { useAdminAuth, useRedirectIfAuthenticated } from './useAdminAuth'; + +// 일정 검색 +export { useScheduleSearch } from './useScheduleSearch'; diff --git a/frontend-temp/src/hooks/pc/admin/useScheduleSearch.js b/frontend-temp/src/hooks/pc/admin/useScheduleSearch.js new file mode 100644 index 0000000..bc1db20 --- /dev/null +++ b/frontend-temp/src/hooks/pc/admin/useScheduleSearch.js @@ -0,0 +1,217 @@ +/** + * 일정 검색 관련 커스텀 훅 + * - 검색어 자동완성 + * - 무한 스크롤 검색 결과 + */ +import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useInView } from 'react-intersection-observer'; +import useScheduleStore from '@/stores/useScheduleStore'; +import * as schedulesApi from '@/api/admin/schedules'; + +const SEARCH_LIMIT = 20; + +export function useScheduleSearch() { + // Zustand 스토어에서 검색 상태 가져오기 + const { + searchInput, + setSearchInput, + searchTerm, + setSearchTerm, + isSearchMode, + setIsSearchMode, + } = useScheduleStore(); + + // 검색 추천 관련 상태 + const [showSuggestions, setShowSuggestions] = useState(false); + const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); + const [originalSearchQuery, setOriginalSearchQuery] = useState(''); + const [suggestions, setSuggestions] = useState([]); + const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false); + + // Intersection Observer for infinite scroll + const { ref: loadMoreRef, inView } = useInView({ + threshold: 0, + rootMargin: '100px', + }); + + // useInfiniteQuery for search + const { + data: searchData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading: searchLoading, + } = useInfiniteQuery({ + queryKey: ['adminScheduleSearch', searchTerm], + queryFn: async ({ pageParam = 0 }) => { + return schedulesApi.searchSchedules(searchTerm, { offset: pageParam, limit: SEARCH_LIMIT }); + }, + getNextPageParam: (lastPage) => { + if (lastPage.hasMore) { + return lastPage.offset + lastPage.schedules.length; + } + return undefined; + }, + enabled: !!searchTerm && isSearchMode, + }); + + // Flatten search results + const searchResults = useMemo(() => { + if (!searchData?.pages) return []; + return searchData.pages.flatMap((page) => page.schedules); + }, [searchData]); + + // Auto fetch next page when scrolled to bottom + const prevInViewRef = useRef(false); + useEffect(() => { + if (inView && !prevInViewRef.current && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) { + fetchNextPage(); + } + prevInViewRef.current = inView; + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]); + + // 검색어 자동완성 API 호출 (debounce 적용) + useEffect(() => { + if (!originalSearchQuery || originalSearchQuery.trim().length === 0) { + setSuggestions([]); + return; + } + + const timeoutId = setTimeout(async () => { + setIsLoadingSuggestions(true); + try { + const response = await fetch( + `/api/schedules/suggestions?q=${encodeURIComponent(originalSearchQuery)}&limit=10` + ); + if (response.ok) { + const data = await response.json(); + setSuggestions(data.suggestions || []); + } + } catch (error) { + console.error('추천 검색어 API 오류:', error); + setSuggestions([]); + } finally { + setIsLoadingSuggestions(false); + } + }, 200); + + return () => clearTimeout(timeoutId); + }, [originalSearchQuery]); + + // 검색 실행 + const handleSearch = useCallback((query) => { + const trimmedQuery = query?.trim() || ''; + if (trimmedQuery) { + setSearchTerm(trimmedQuery); + setIsSearchMode(true); + } + setShowSuggestions(false); + setSelectedSuggestionIndex(-1); + }, [setSearchTerm, setIsSearchMode]); + + // 검색 모드 종료 + const exitSearchMode = useCallback(() => { + setIsSearchMode(false); + setSearchTerm(''); + setSearchInput(''); + setShowSuggestions(false); + setSuggestions([]); + setSelectedSuggestionIndex(-1); + setOriginalSearchQuery(''); + }, [setIsSearchMode, setSearchTerm, setSearchInput]); + + // 검색어 입력 핸들러 + const handleSearchInputChange = useCallback((value) => { + setSearchInput(value); + setOriginalSearchQuery(value); + setSelectedSuggestionIndex(-1); + if (value.trim()) { + setShowSuggestions(true); + } else { + setShowSuggestions(false); + } + }, [setSearchInput]); + + // 추천 검색어 선택 + const handleSuggestionSelect = useCallback((suggestion) => { + setSearchInput(suggestion); + handleSearch(suggestion); + }, [setSearchInput, handleSearch]); + + // 키보드 네비게이션 핸들러 + const handleKeyDown = useCallback((e) => { + if (!showSuggestions || suggestions.length === 0) { + if (e.key === 'Enter') { + handleSearch(searchInput); + } + return; + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setSelectedSuggestionIndex((prev) => { + const next = prev < suggestions.length - 1 ? prev + 1 : prev; + if (next >= 0 && suggestions[next]) { + setSearchInput(suggestions[next]); + } + return next; + }); + break; + case 'ArrowUp': + e.preventDefault(); + setSelectedSuggestionIndex((prev) => { + const next = prev > 0 ? prev - 1 : -1; + if (next === -1) { + setSearchInput(originalSearchQuery); + } else if (suggestions[next]) { + setSearchInput(suggestions[next]); + } + return next; + }); + break; + case 'Enter': + e.preventDefault(); + if (selectedSuggestionIndex >= 0 && suggestions[selectedSuggestionIndex]) { + handleSuggestionSelect(suggestions[selectedSuggestionIndex]); + } else { + handleSearch(searchInput); + } + break; + case 'Escape': + setShowSuggestions(false); + setSelectedSuggestionIndex(-1); + break; + } + }, [showSuggestions, suggestions, selectedSuggestionIndex, searchInput, originalSearchQuery, handleSearch, handleSuggestionSelect, setSearchInput]); + + return { + // 상태 + searchInput, + searchTerm, + isSearchMode, + setIsSearchMode, + showSuggestions, + setShowSuggestions, + selectedSuggestionIndex, + suggestions, + isLoadingSuggestions, + searchResults, + searchLoading, + hasNextPage, + isFetchingNextPage, + + // 핸들러 + handleSearch, + exitSearchMode, + handleSearchInputChange, + handleSuggestionSelect, + handleKeyDown, + + // refs + loadMoreRef, + }; +} + +export default useScheduleSearch; diff --git a/frontend-temp/src/pages/pc/admin/schedules/Schedules.jsx b/frontend-temp/src/pages/pc/admin/schedules/Schedules.jsx index 9eb093e..9b038f2 100644 --- a/frontend-temp/src/pages/pc/admin/schedules/Schedules.jsx +++ b/frontend-temp/src/pages/pc/admin/schedules/Schedules.jsx @@ -14,15 +14,14 @@ import { ArrowLeft, Book, } from 'lucide-react'; -import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { useInView } from 'react-intersection-observer'; import { Toast } from '@/components/common'; import { AdminLayout, ConfirmDialog } from '@/components/pc/admin'; import { ScheduleItem, getEditPath } from '@/components/pc/admin/schedule'; import useScheduleStore from '@/stores/useScheduleStore'; -import { useAdminAuth } from '@/hooks/pc/admin'; +import { useAdminAuth, useScheduleSearch } from '@/hooks/pc/admin'; import { useToast } from '@/hooks/common'; import { getTodayKST, formatDate } from '@/utils'; import { getCategoryId, getScheduleDate } from '@/utils/schedule'; @@ -33,14 +32,8 @@ function Schedules() { const navigate = useNavigate(); const queryClient = useQueryClient(); - // Zustand 스토어에서 상태 가져오기 + // Zustand 스토어에서 상태 가져오기 (검색 제외) const { - searchInput, - setSearchInput, - searchTerm, - setSearchTerm, - isSearchMode, - setIsSearchMode, selectedCategories, setSelectedCategories, selectedDate, @@ -53,95 +46,36 @@ function Schedules() { const { user, isAuthenticated } = useAdminAuth(); + // 검색 관련 (커스텀 훅) + const { + searchInput, + searchTerm, + isSearchMode, + setIsSearchMode, + showSuggestions, + setShowSuggestions, + selectedSuggestionIndex, + suggestions, + isLoadingSuggestions, + searchResults, + searchLoading, + hasNextPage, + isFetchingNextPage, + handleSearch, + exitSearchMode, + handleSearchInputChange, + handleSuggestionSelect, + handleKeyDown: handleSearchKeyDown, + loadMoreRef, + } = useScheduleSearch(); + // 로컬 상태 (페이지 이동 시 유지할 필요 없는 것들) const { toast, setToast } = useToast(); const scrollContainerRef = useRef(null); const searchContainerRef = useRef(null); // 검색 컨테이너 (외부 클릭 감지용) - // 검색 추천 관련 상태 - const [showSuggestions, setShowSuggestions] = useState(false); - const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); - const [originalSearchQuery, setOriginalSearchQuery] = useState(''); // 필터링용 원본 쿼리 - const [suggestions, setSuggestions] = useState([]); // 추천 검색어 목록 - const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false); - - const SEARCH_LIMIT = 20; // 페이지당 20개 const ESTIMATED_ITEM_HEIGHT = 100; // 아이템 추정 높이 (동적 측정) - // Intersection Observer for infinite scroll - const { ref: loadMoreRef, inView } = useInView({ - threshold: 0, - rootMargin: '100px', - }); - - // useInfiniteQuery for search - const { - data: searchData, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isLoading: searchLoading, - } = useInfiniteQuery({ - queryKey: ['adminScheduleSearch', searchTerm], - queryFn: async ({ pageParam = 0 }) => { - return schedulesApi.searchSchedules(searchTerm, { offset: pageParam, limit: SEARCH_LIMIT }); - }, - getNextPageParam: (lastPage) => { - if (lastPage.hasMore) { - return lastPage.offset + lastPage.schedules.length; - } - return undefined; - }, - enabled: !!searchTerm && isSearchMode, - }); - - // Flatten search results - const searchResults = useMemo(() => { - if (!searchData?.pages) return []; - return searchData.pages.flatMap((page) => page.schedules); - }, [searchData]); - - // Auto fetch next page when scrolled to bottom - // inView가 true로 변경될 때만 fetch (중복 요청 방지) - const prevInViewRef = useRef(false); - useEffect(() => { - // inView가 false→true로 변경될 때만 fetch - if (inView && !prevInViewRef.current && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) { - fetchNextPage(); - } - prevInViewRef.current = inView; - }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]); - - // 검색어 자동완성 API 호출 (debounce 적용) - useEffect(() => { - // 검색어가 비어있으면 초기화 - if (!originalSearchQuery || originalSearchQuery.trim().length === 0) { - setSuggestions([]); - return; - } - - // debounce: 200ms 후에 API 호출 - const timeoutId = setTimeout(async () => { - setIsLoadingSuggestions(true); - try { - const response = await fetch( - `/api/schedules/suggestions?q=${encodeURIComponent(originalSearchQuery)}&limit=10` - ); - if (response.ok) { - const data = await response.json(); - setSuggestions(data.suggestions || []); - } - } catch (error) { - console.error('추천 검색어 API 오류:', error); - setSuggestions([]); - } finally { - setIsLoadingSuggestions(false); - } - }, 200); - - return () => clearTimeout(timeoutId); - }, [originalSearchQuery]); - // selectedDate가 없으면 오늘 날짜로 초기화 useEffect(() => { if (!selectedDate) { @@ -843,14 +777,7 @@ function Schedules() { className="flex items-center gap-3 flex-1" >