From c54de2ba82d46cf5f74aba188feb9ee519181882 Mon Sep 17 00:00:00 2001 From: caadiq Date: Tue, 6 Jan 2026 19:48:43 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20useInfiniteQuery=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EB=AC=B4=ED=95=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - react-infinite-scroll-component를 useInfiniteQuery + useInView로 대체 - Schedule.jsx, AdminSchedule.jsx에 안정적인 무한 스크롤 적용 - source_name에 Link2 아이콘 추가 (카테고리 오른쪽 인라인 표시) - 멤버 5명 이상일 경우 '프로미스나인'으로 표시 (탈퇴 멤버 고려) - AdminSchedule 일반 모드에서 members 배열도 확인하여 멤버 표시 - QueryClientProvider 설정 추가 (main.jsx) --- backend/routes/schedules.js | 15 +- backend/services/meilisearch.js | 20 +- frontend/package-lock.json | 65 +++ frontend/package.json | 3 + frontend/src/main.jsx | 15 +- frontend/src/pages/pc/Schedule.jsx | 344 ++++++++++----- frontend/src/pages/pc/admin/AdminSchedule.jsx | 399 ++++++++++++------ 7 files changed, 613 insertions(+), 248 deletions(-) diff --git a/backend/routes/schedules.js b/backend/routes/schedules.js index 84c5cee..170758b 100644 --- a/backend/routes/schedules.js +++ b/backend/routes/schedules.js @@ -11,8 +11,19 @@ router.get("/", async (req, res) => { // 검색어가 있으면 Meilisearch 사용 if (search && search.trim()) { - const results = await searchSchedules(search.trim()); - return res.json(results); + const offset = parseInt(req.query.offset) || 0; + const pageLimit = parseInt(req.query.limit) || 20; + const results = await searchSchedules(search.trim(), { + offset, + limit: pageLimit, + }); + return res.json({ + schedules: results.hits, + total: results.total, + offset: results.offset, + limit: results.limit, + hasMore: results.offset + results.hits.length < results.total, + }); } // 날짜 필터 및 제한 조건 구성 diff --git a/backend/services/meilisearch.js b/backend/services/meilisearch.js index c41997c..482329d 100644 --- a/backend/services/meilisearch.js +++ b/backend/services/meilisearch.js @@ -53,6 +53,11 @@ export async function initMeilisearch() { }, }); + // 페이징 설정 (기본 1000개 제한 해제) + await index.updatePagination({ + maxTotalHits: 10000, // 최대 10000개까지 조회 가능 + }); + console.log("[Meilisearch] 인덱스 초기화 완료"); } catch (error) { console.error("[Meilisearch] 초기화 오류:", error.message); @@ -106,14 +111,15 @@ export async function deleteSchedule(scheduleId) { } /** - * 일정 검색 + * 일정 검색 (페이징 지원) */ export async function searchSchedules(query, options = {}) { try { const index = client.index(SCHEDULE_INDEX); const searchOptions = { - limit: options.limit || 50, + limit: options.limit || 1000, // 기본 1000개 (Meilisearch 최대) + offset: options.offset || 0, // 페이징용 offset attributesToRetrieve: ["*"], }; @@ -129,10 +135,16 @@ export async function searchSchedules(query, options = {}) { const results = await index.search(query, searchOptions); - return results.hits; + // 페이징 정보 포함 반환 + return { + hits: results.hits, + total: results.estimatedTotalHits, // 전체 결과 수 + offset: searchOptions.offset, + limit: searchOptions.limit, + }; } catch (error) { console.error("[Meilisearch] 검색 오류:", error.message); - return []; + return { hits: [], total: 0, offset: 0, limit: 0 }; } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 58c99df..ea4f274 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,12 +8,15 @@ "name": "fromis9-frontend", "version": "1.0.0", "dependencies": { + "@tanstack/react-query": "^5.90.16", "framer-motion": "^11.0.8", "lucide-react": "^0.344.0", "react": "^18.2.0", "react-colorful": "^5.6.1", "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", + "react-infinite-scroll-component": "^6.1.1", + "react-intersection-observer": "^10.0.0", "react-ios-time-picker": "^0.2.2", "react-photo-album": "^3.4.0", "react-router-dom": "^6.22.3", @@ -1128,6 +1131,32 @@ "win32" ] }, + "node_modules/@tanstack/query-core": { + "version": "5.90.16", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz", + "integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.16", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz", + "integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2271,6 +2300,33 @@ "react": "^18.3.1" } }, + "node_modules/react-infinite-scroll-component": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.1.tgz", + "integrity": "sha512-R8YoOyiNDynSWmfVme5LHslsKrP+/xcRUWR2ies8UgUab9dtyw5ECnMCVPPmnmjjF4MWQmfVdRwRWcWaDgeyMA==", + "license": "MIT", + "dependencies": { + "throttle-debounce": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16.0.0" + } + }, + "node_modules/react-intersection-observer": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-10.0.0.tgz", + "integrity": "sha512-JJRgcnFQoVXmbE5+GXr1OS1NDD1gHk0HyfpLcRf0575IbJz+io8yzs4mWVlfaqOQq1FiVjLvuYAdEEcrrCfveg==", + "license": "MIT", + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/react-ios-time-picker": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/react-ios-time-picker/-/react-ios-time-picker-0.2.2.tgz", @@ -2623,6 +2679,15 @@ "node": ">=0.8" } }, + "node_modules/throttle-debounce": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-2.3.0.tgz", + "integrity": "sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", diff --git a/frontend/package.json b/frontend/package.json index 843e9b9..37770ba 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,12 +9,15 @@ "preview": "vite preview" }, "dependencies": { + "@tanstack/react-query": "^5.90.16", "framer-motion": "^11.0.8", "lucide-react": "^0.344.0", "react": "^18.2.0", "react-colorful": "^5.6.1", "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", + "react-infinite-scroll-component": "^6.1.1", + "react-intersection-observer": "^10.0.0", "react-ios-time-picker": "^0.2.2", "react-photo-album": "^3.4.0", "react-router-dom": "^6.22.3", diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index c57caa6..b5521d3 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,10 +1,23 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import App from './App'; import './index.css'; +// React Query 클라이언트 생성 +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5분간 캐시 유지 + refetchOnWindowFocus: false, + }, + }, +}); + ReactDOM.createRoot(document.getElementById('root')).render( - + + + ); diff --git a/frontend/src/pages/pc/Schedule.jsx b/frontend/src/pages/pc/Schedule.jsx index f6effd0..2de8734 100644 --- a/frontend/src/pages/pc/Schedule.jsx +++ b/frontend/src/pages/pc/Schedule.jsx @@ -1,7 +1,9 @@ import { useState, useEffect, useRef, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; -import { Clock, ChevronLeft, ChevronRight, ChevronDown, Tag, Search, ArrowLeft } from 'lucide-react'; +import { Clock, ChevronLeft, ChevronRight, ChevronDown, Tag, Search, ArrowLeft, Link2 } from 'lucide-react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useInView } from 'react-intersection-observer'; function Schedule() { const navigate = useNavigate(); @@ -36,8 +38,54 @@ function Schedule() { const [isSearchMode, setIsSearchMode] = useState(false); const [searchInput, setSearchInput] = useState(''); const [searchTerm, setSearchTerm] = useState(''); - const [searchResults, setSearchResults] = useState([]); - const [searchLoading, setSearchLoading] = useState(false); + const SEARCH_LIMIT = 5; // 테스트용 5개 + + // 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, + refetch: refetchSearch, + } = useInfiniteQuery({ + queryKey: ['scheduleSearch', searchTerm], + queryFn: async ({ pageParam = 0 }) => { + const response = await fetch( + `/api/schedules?search=${encodeURIComponent(searchTerm)}&offset=${pageParam}&limit=${SEARCH_LIMIT}` + ); + if (!response.ok) throw new Error('Search failed'); + return response.json(); + }, + 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]); + + const searchTotal = searchData?.pages?.[0]?.total || 0; + + // Auto fetch next page when scrolled to bottom + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]); // 데이터 로드 useEffect(() => { @@ -70,26 +118,6 @@ function Schedule() { console.error('카테고리 로드 오류:', error); } }; - - // 검색 함수 (Meilisearch API 호출) - const searchSchedules = async (term) => { - if (!term.trim()) { - setSearchResults([]); - return; - } - setSearchLoading(true); - try { - const response = await fetch(`/api/schedules?search=${encodeURIComponent(term)}`); - if (response.ok) { - const data = await response.json(); - setSearchResults(data); - } - } catch (error) { - console.error('검색 오류:', error); - } finally { - setSearchLoading(false); - } - }; // 외부 클릭시 팝업 닫기 useEffect(() => { @@ -465,6 +493,13 @@ function Schedule() { const eventColor = getScheduleColor(day); const dayOfWeek = (firstDay + i) % 7; const isToday = new Date().toDateString() === new Date(year, month, day).toDateString(); + + // 해당 날짜의 일정 목록 (점 표시용, 최대 3개) + const daySchedules = schedules.filter(s => { + const scheduleDate = s.date ? s.date.split('T')[0] : ''; + const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + return scheduleDate === dateStr; + }).slice(0, 3); return ( ); })} @@ -717,110 +759,184 @@ function Schedule() { - {loading ? (
로딩 중...
) : filteredSchedules.length > 0 ? ( + isSearchMode && searchTerm ? ( + /* 검색 모드: useInView 기반 무한 스크롤 */ + <> + {filteredSchedules.map((schedule, index) => { + const formatted = formatDate(schedule.date); + const categoryColor = getCategoryColor(schedule.category_id); + const categoryName = getCategoryName(schedule.category_id); + + return ( + handleScheduleClick(schedule)} + className="flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer" + > + {/* 날짜 영역 */} +
+ + {new Date(schedule.date).getFullYear()}.{new Date(schedule.date).getMonth() + 1} + + {formatted.day} + {formatted.weekday} +
- filteredSchedules.map((schedule, index) => { - const formatted = formatDate(schedule.date); - const categoryColor = getCategoryColor(schedule.category_id); - const categoryName = getCategoryName(schedule.category_id); - - return ( - handleScheduleClick(schedule)} - className="flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer" - > - {/* 날짜 영역 */} -
- {/* 검색 모드일 때 년.월 표시, 일반 모드에서는 월 표시 안함 */} - {isSearchMode && searchTerm && ( - - {new Date(schedule.date).getFullYear()}.{new Date(schedule.date).getMonth() + 1} - - )} - {formatted.day} - {formatted.weekday} -
- - {/* 스케줄 내용 */} -
-

{schedule.title}

- -
- {schedule.time && ( -
- - {schedule.time.slice(0, 5)} -
- )} - {categoryName && ( -
- - + {/* 스케줄 내용 */} +
+

{schedule.title}

+ +
+ {schedule.time && ( + + + {schedule.time.slice(0, 5)} + + )} + + {categoryName} - {schedule.source_name && ` · ${schedule.source_name}`} + {schedule.source_name && ( + + + {schedule.source_name} + + )}
- )} + + {(() => { + const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || ''; + const memberList = memberNames.split(',').filter(name => name.trim()); + if (memberList.length === 0) return null; + if (memberList.length === 5) { + return ( +
+ + 프로미스나인 + +
+ ); + } + return ( +
+ {memberList.map((name, i) => ( + + {name} + + ))} +
+ ); + })()} +
+ + ); + })} + + {/* 무한 스크롤 트리거 & 로딩 인디케이터 */} +
+ {isFetchingNextPage && ( +
+
- {/* 멤버 태그 (별도 줄) */} - {(() => { - const memberList = schedule.members - ? schedule.members.map(m => m.name) - : (schedule.member_names ? schedule.member_names.split(',') : []); - if (memberList.length === 0) return null; - - // 5명 이상이면 '프로미스나인' 단일 태그 - if (memberList.length >= 5) { + )} + {!hasNextPage && searchResults.length > 0 && ( +
+ {searchResults.length} / {searchTotal}개 표시 (모두 로드됨) +
+ )} +
+ + ) : ( + /* 일반 모드: 기존 렌더링 */ + filteredSchedules.map((schedule, index) => { + const formatted = formatDate(schedule.date); + const categoryColor = getCategoryColor(schedule.category_id); + const categoryName = getCategoryName(schedule.category_id); + + return ( + handleScheduleClick(schedule)} + className="flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer" + > +
+ {formatted.day} + {formatted.weekday} +
+
+

{schedule.title}

+
+ {schedule.time && ( + + + {schedule.time.slice(0, 5)} + + )} + + + {categoryName} + + {schedule.source_name && ( + + + {schedule.source_name} + + )} +
+ {(() => { + const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || ''; + const memberList = memberNames.split(',').filter(name => name.trim()); + if (memberList.length === 0) return null; + if (memberList.length === 5) { + return ( +
+ + 프로미스나인 + +
+ ); + } return (
- - 프로미스나인 - + {memberList.map((name, i) => ( + + {name} + + ))}
); - } - - // 그 외에는 멤버별 개별 태그 - return ( -
- {memberList.map((name, i) => ( - - {name} - - ))} -
- ); - })()} -
-
- ); - }) + })()} +
+ + ); + }) + ) ) : (
{selectedDate ? '선택한 날짜에 일정이 없습니다.' : '예정된 일정이 없습니다.'}
)} - +
diff --git a/frontend/src/pages/pc/admin/AdminSchedule.jsx b/frontend/src/pages/pc/admin/AdminSchedule.jsx index 2062bab..f698fe7 100644 --- a/frontend/src/pages/pc/admin/AdminSchedule.jsx +++ b/frontend/src/pages/pc/admin/AdminSchedule.jsx @@ -3,8 +3,10 @@ import { useNavigate, Link } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { LogOut, Home, ChevronRight, Calendar, Plus, Edit2, Trash2, - ChevronLeft, Search, ChevronDown, AlertTriangle, Bot, Tag, ArrowLeft, ExternalLink + ChevronLeft, Search, ChevronDown, AlertTriangle, Bot, Tag, ArrowLeft, ExternalLink, Clock, Link2 } from 'lucide-react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useInView } from 'react-intersection-observer'; import Toast from '../../../components/Toast'; import Tooltip from '../../../components/Tooltip'; @@ -35,8 +37,53 @@ function AdminSchedule() { const [loading, setLoading] = useState(false); const [user, setUser] = useState(null); const [toast, setToast] = useState(null); - const [searchResults, setSearchResults] = useState([]); - const [searchLoading, setSearchLoading] = useState(false); + const SEARCH_LIMIT = 5; // 테스트용 5개 + + // 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 }) => { + const response = await fetch( + `/api/schedules?search=${encodeURIComponent(searchTerm)}&offset=${pageParam}&limit=${SEARCH_LIMIT}` + ); + if (!response.ok) throw new Error('Search failed'); + return response.json(); + }, + 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]); + + const searchTotal = searchData?.pages?.[0]?.total || 0; + + // Auto fetch next page when scrolled to bottom + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]); // selectedDate가 없으면 오늘 날짜로 초기화 useEffect(() => { @@ -218,24 +265,6 @@ function AdminSchedule() { setLoading(false); } }; - - // 검색 함수 (Meilisearch API 호출) - const searchSchedules = async (term) => { - if (!term.trim()) { - setSearchResults([]); - return; - } - setSearchLoading(true); - try { - const res = await fetch(`/api/schedules?search=${encodeURIComponent(term)}`); - const data = await res.json(); - setSearchResults(data); - } catch (error) { - console.error('검색 오류:', error); - } finally { - setSearchLoading(false); - } - }; // 외부 클릭 시 피커 닫기 @@ -726,6 +755,13 @@ function AdminSchedule() { const eventColor = getScheduleColor(day); const dayOfWeek = (firstDay + i) % 7; const isToday = new Date().toDateString() === new Date(year, month, day).toDateString(); + + // 해당 날짜의 일정 목록 (점 표시용, 최대 3개) + const daySchedules = schedules.filter(s => { + const scheduleDate = s.date ? s.date.split('T')[0] : ''; + const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + return scheduleDate === dateStr; + }).slice(0, 3); return ( ); })} @@ -975,113 +1018,215 @@ function AdminSchedule() {

등록된 일정이 없습니다

) : ( -
- - {filteredSchedules.map((schedule, index) => ( - -
- {/* 날짜 */} -
- {/* 검색 모드일 때 년/월 표시 */} - {isSearchMode && searchTerm && ( -
- {new Date(schedule.date).getFullYear()}.{new Date(schedule.date).getMonth() + 1} +
+ {isSearchMode && searchTerm ? ( + /* 검색 모드: useInView 기반 무한 스크롤 */ + <> + {filteredSchedules.map((schedule, index) => ( + +
+
+
+ {new Date(schedule.date).getFullYear()}.{new Date(schedule.date).getMonth() + 1} +
+
+ {new Date(schedule.date).getDate()} +
+
+ {['일', '월', '화', '수', '목', '금', '토'][new Date(schedule.date).getDay()]}요일 +
- )} -
- {new Date(schedule.date).getDate()} -
-
- {new Date(schedule.date).toLocaleDateString('ko-KR', { weekday: 'short' })} -
-
- {/* 내용 */} -
-
- - {schedule.category_name || '미지정'} - - {schedule.source_name && ( - - {schedule.source_name} - - )} - {schedule.time?.slice(0, 5)} -
-

{schedule.title}

- {schedule.description && ( -

{schedule.description}

- )} - {/* 멤버 태그 */} - {(() => { - // members 배열 또는 member_names 문자열 처리 - const memberList = schedule.members?.length > 0 - ? schedule.members - : schedule.member_names - ? schedule.member_names.split(',').filter(n => n.trim()).map((name, idx) => ({ id: idx, name: name.trim() })) - : []; - if (memberList.length === 0) return null; - return ( -
- {memberList.length >= 5 ? ( - - 프로미스나인 +
c.id === schedule.category_id)?.color)?.style?.backgroundColor || '#6b7280' }} + /> + +
+

{schedule.title}

+
+ {schedule.time && ( + + + {schedule.time.slice(0, 5)} - ) : ( - memberList.map((member) => ( - - {member.name} - - )) + )} + + + {categories.find(c => c.id === schedule.category_id)?.name || '미분류'} + + {schedule.source_name && ( + + + {schedule.source_name} + )}
- ); - })()} - -
- - {/* 액션 버튼 */} -
- {schedule.source_url && ( - e.stopPropagation()} - className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500" - > - - - )} - - -
+ {schedule.member_names && ( +
+ {schedule.member_names.split(',').length >= 5 ? ( + + 프로미스나인 + + ) : ( + schedule.member_names.split(',').map((name, i) => ( + + {name.trim()} + + )) + )} +
+ )} +
+
+ {schedule.source_url && ( + e.stopPropagation()} + className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500" + > + + + )} + + +
+
+ + ))} + + {/* 무한 스크롤 트리거 & 로딩 인디케이터 */} +
+ {isFetchingNextPage && ( +
+
+
+ )} + {!hasNextPage && searchResults.length > 0 && ( +
+ {searchResults.length} / {searchTotal}개 표시 (모두 로드됨) +
+ )}
- - ))} + + ) : ( + /* 일반 모드: 기존 렌더링 */ + filteredSchedules.map((schedule, index) => ( + +
+
+
+ {new Date(schedule.date).getDate()} +
+
+ {['일', '월', '화', '수', '목', '금', '토'][new Date(schedule.date).getDay()]}요일 +
+
+ +
c.id === schedule.category_id)?.color)?.style?.backgroundColor || '#6b7280' }} + /> + +
+

{schedule.title}

+
+ {schedule.time && ( + + + {schedule.time.slice(0, 5)} + + )} + + + {categories.find(c => c.id === schedule.category_id)?.name || '미분류'} + + {schedule.source_name && ( + + + {schedule.source_name} + + )} +
+ {(() => { + const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || ''; + const memberList = memberNames.split(',').filter(name => name.trim()); + if (memberList.length === 0) return null; + return ( +
+ {memberList.length >= 5 ? ( + + 프로미스나인 + + ) : ( + memberList.map((name, i) => ( + + {name.trim()} + + )) + )} +
+ ); + })()} +
+ +
+ {schedule.source_url && ( + e.stopPropagation()} + className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500" + > + + + )} + + +
+
+ + )) + )}
)}