From 4b08f84eb03a77cdb5033e92ececcc598806aec2 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 5 Apr 2026 18:31:19 +0900 Subject: [PATCH 01/22] =?UTF-8?q?chore:=20mockServiceWorker.js=EB=A5=BC=20?= =?UTF-8?q?=EB=AC=B4=EC=8B=9C=ED=95=98=EA=B8=B0=EC=9C=84=ED=95=9C=20.eslin?= =?UTF-8?q?tignore=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/.eslintignore | 1 + frontend/public/mockServiceWorker.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 frontend/.eslintignore diff --git a/frontend/.eslintignore b/frontend/.eslintignore new file mode 100644 index 000000000..168f09961 --- /dev/null +++ b/frontend/.eslintignore @@ -0,0 +1 @@ +frontend/public/mockServiceWorker.js \ No newline at end of file diff --git a/frontend/public/mockServiceWorker.js b/frontend/public/mockServiceWorker.js index b9c997161..7627caf1f 100644 --- a/frontend/public/mockServiceWorker.js +++ b/frontend/public/mockServiceWorker.js @@ -1,4 +1,4 @@ - +/* eslint-disable */ /* tslint:disable */ /** From ea8e75154154c2950e5c4e0387d1450927e83391 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 5 Apr 2026 18:34:52 +0900 Subject: [PATCH 02/22] =?UTF-8?q?docs:=20CLAUDE.md=EC=97=90=EC=84=9C=20API?= =?UTF-8?q?=20=ED=97=AC=ED=8D=BC=20=ED=95=A8=EC=88=98=20=EC=84=A4=EB=AA=85?= =?UTF-8?q?=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `withErrorHandling()` 함수에 대한 설명을 문서에서 제거하여 일관성 유지 --- frontend/CLAUDE.md | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 19e1b19d1..9fb87dbd1 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -95,7 +95,6 @@ npm run generate:sitemap # sitemap.xml 생성 API는 `src/apis/utils/apiHelpers.ts`의 헬퍼 함수를 사용하는 일관된 패턴을 따름: - `handleResponse()` - 응답 파싱, `{ data: {...} }` 형식 자동 언래핑 -- `withErrorHandling()` - API 호출을 에러 로깅으로 래핑 - `secureFetch()` - 인증된 요청, 403 시 토큰 자동 갱신 쿼리 키는 `src/constants/queryKeys.ts`에 중앙 관리. From fcbdf0ba28c6547db01b44d05e49161db46e6b65 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 5 Apr 2026 18:36:44 +0900 Subject: [PATCH 03/22] =?UTF-8?q?style:=20CalendarSyncTab=20styles?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20theme=EC=83=81=EC=88=98=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminPage/tabs/CalendarSyncTab/CalendarSyncTab.styles.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/CalendarSyncTab.styles.ts b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/CalendarSyncTab.styles.ts index 9e27d182f..429b94949 100644 --- a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/CalendarSyncTab.styles.ts +++ b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/CalendarSyncTab.styles.ts @@ -1,4 +1,5 @@ import styled from 'styled-components'; +import { colors } from '@/styles/theme/colors'; export const Container = styled.div` display: flex; @@ -9,7 +10,7 @@ export const Container = styled.div` export const Description = styled.p` font-size: 0.94rem; line-height: 1.5; - color: #4b5563; + color: ${colors.gray[600]}; `; export const ConfigGrid = styled.div` @@ -23,7 +24,7 @@ export const ConfigGrid = styled.div` `; export const Block = styled.div` - border: 1px solid #e5e7eb; + border: 1px solid ${colors.gray[300]}; border-radius: 12px; padding: 14px; `; From 8ee243163f0a8de5b7d5879136d57250518519a0 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 5 Apr 2026 18:38:15 +0900 Subject: [PATCH 04/22] =?UTF-8?q?style:=20media=EC=83=81=EC=88=98=EB=A1=9C?= =?UTF-8?q?=20media=20Query=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminPage/tabs/CalendarSyncTab/CalendarSyncTab.styles.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/CalendarSyncTab.styles.ts b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/CalendarSyncTab.styles.ts index 429b94949..c03a4d62f 100644 --- a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/CalendarSyncTab.styles.ts +++ b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/CalendarSyncTab.styles.ts @@ -1,4 +1,5 @@ import styled from 'styled-components'; +import { media } from '@/styles/mediaQuery'; import { colors } from '@/styles/theme/colors'; export const Container = styled.div` @@ -18,7 +19,7 @@ export const ConfigGrid = styled.div` grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; - @media (max-width: 1024px) { + ${media.laptop} { grid-template-columns: 1fr; } `; From f91e505208c9be580a0eaa3f30b08a81e62a7953 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 5 Apr 2026 18:43:40 +0900 Subject: [PATCH 05/22] =?UTF-8?q?fix:=20Notion=20=EC=BA=98=EB=A6=B0?= =?UTF-8?q?=EB=8D=94=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 응답에 유효한 데이터베이스 ID가 있는 경우 선택한 Notion 데이터베이스 ID를 설정 --- .../tabs/CalendarSyncTab/hooks/useNotionCalendarData.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarData.ts b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarData.ts index 9857688f9..264a43d3b 100644 --- a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarData.ts +++ b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarData.ts @@ -33,7 +33,11 @@ export const useNotionCalendarData = ({ const applyPagesResponse = useCallback((response: NotionPagesResponse) => { setNotionItems(response.items); setNotionTotalResults(response.totalResults); - setNotionDatabaseSourceId(response.databaseId ?? ''); + const databaseId = response.databaseId ?? ''; + setNotionDatabaseSourceId(databaseId); + if (databaseId) { + setSelectedNotionDatabaseId(databaseId); + } }, []); const loadNotionPages = useCallback(async () => { From eb6e4d7272bb26d70e2cc2037ee1db4dffc721d1 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 5 Apr 2026 18:45:20 +0900 Subject: [PATCH 06/22] =?UTF-8?q?feat:=20Google=20Calendar=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Google Calendar 관련 쿼리 키 및 훅 추가 - Google Calendar 캘린더 및 이벤트 조회 기능 구현 - 선택된 캘린더 관리 및 연결 해제 기능 추가 - 기존 코드 리팩토링 및 상태 관리 개선 --- frontend/src/constants/queryKeys.ts | 6 + .../src/hooks/Queries/useGoogleCalendar.ts | 70 +++++ .../hooks/useGoogleCalendarData.ts | 267 +++++++----------- 3 files changed, 172 insertions(+), 171 deletions(-) create mode 100644 frontend/src/hooks/Queries/useGoogleCalendar.ts diff --git a/frontend/src/constants/queryKeys.ts b/frontend/src/constants/queryKeys.ts index f6451c989..54b6f231c 100644 --- a/frontend/src/constants/queryKeys.ts +++ b/frontend/src/constants/queryKeys.ts @@ -1,4 +1,10 @@ export const queryKeys = { + googleCalendar: { + all: ['googleCalendar'] as const, + calendars: () => ['googleCalendar', 'calendars'] as const, + events: (calendarId: string, timeMin: string, timeMax: string) => + ['googleCalendar', 'events', calendarId, timeMin, timeMax] as const, + }, applicants: { all: ['clubApplicants'] as const, detail: (applicationFormId: string) => diff --git a/frontend/src/hooks/Queries/useGoogleCalendar.ts b/frontend/src/hooks/Queries/useGoogleCalendar.ts new file mode 100644 index 000000000..e1cc703e1 --- /dev/null +++ b/frontend/src/hooks/Queries/useGoogleCalendar.ts @@ -0,0 +1,70 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + disconnectGoogleCalendar, + fetchGoogleCalendarEvents, + fetchGoogleCalendars, + selectGoogleCalendar, +} from '@/apis/calendarOAuth'; +import { queryKeys } from '@/constants/queryKeys'; +import { ApiError } from '@/errors'; +import type { GoogleCalendarListResponse } from '@/types/google'; + +export const useGetGoogleCalendars = () => { + return useQuery({ + queryKey: queryKeys.googleCalendar.calendars(), + queryFn: async () => { + try { + return await fetchGoogleCalendars(); + } catch (error) { + if (error instanceof ApiError && error.errorCode === '960-4') { + return null; + } + throw error; + } + }, + staleTime: 5 * 60 * 1000, + }); +}; + +export const useGetGoogleCalendarEvents = ( + calendarId: string, + timeMin: string, + timeMax: string, +) => { + return useQuery({ + queryKey: queryKeys.googleCalendar.events(calendarId, timeMin, timeMax), + queryFn: () => fetchGoogleCalendarEvents(calendarId, timeMin, timeMax), + staleTime: 5 * 60 * 1000, + enabled: !!calendarId, + }); +}; + +export const useSelectGoogleCalendar = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + calendarId, + calendarName, + }: { + calendarId: string; + calendarName: string; + }) => selectGoogleCalendar(calendarId, calendarName), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.googleCalendar.calendars(), + }); + }, + }); +}; + +export const useDisconnectGoogleCalendar = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: disconnectGoogleCalendar, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.googleCalendar.all, + }); + }, + }); +}; diff --git a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useGoogleCalendarData.ts b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useGoogleCalendarData.ts index 781883d08..3f4c52102 100644 --- a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useGoogleCalendarData.ts +++ b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useGoogleCalendarData.ts @@ -1,13 +1,11 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { fetchGoogleAuthorizeUrl } from '@/apis/calendarOAuth'; import { - disconnectGoogleCalendar, - fetchGoogleAuthorizeUrl, - fetchGoogleCalendarEvents, - fetchGoogleCalendars, - selectGoogleCalendar, -} from '@/apis/calendarOAuth'; -import { ApiError } from '@/errors'; -import type { GoogleCalendarEvent, GoogleCalendarItem } from '@/types/google'; + useDisconnectGoogleCalendar, + useGetGoogleCalendarEvents, + useGetGoogleCalendars, + useSelectGoogleCalendar, +} from '@/hooks/Queries/useGoogleCalendar'; import { createState } from '@/utils/calendarSyncUtils'; const GOOGLE_STATE_KEY = 'admin_calendar_sync_google_state'; @@ -25,109 +23,76 @@ export const useGoogleCalendarData = ({ onStatus, clearError, }: UseGoogleCalendarDataParams) => { - const [isGoogleConnected, setIsGoogleConnected] = useState(false); - const [googleCalendars, setGoogleCalendars] = useState( - [], + const [selectedCalendarId, setSelectedCalendarId] = useState(''); + const [isOAuthLoading, setIsOAuthLoading] = useState(false); + + const calendarsQuery = useGetGoogleCalendars(); + const selectMutation = useSelectGoogleCalendar(); + const disconnectMutation = useDisconnectGoogleCalendar(); + + const eventTimeRange = useMemo(() => { + const now = new Date(); + return { + timeMin: new Date(now.getFullYear(), now.getMonth() - 3, 1).toISOString(), + timeMax: new Date(now.getFullYear(), now.getMonth() + 4, 1).toISOString(), + }; + }, []); + + const eventsQuery = useGetGoogleCalendarEvents( + selectedCalendarId, + eventTimeRange.timeMin, + eventTimeRange.timeMax, ); - const [selectedCalendarId, setSelectedCalendarId] = useState(''); - const [googleCalendarEvents, setGoogleCalendarEvents] = useState< - GoogleCalendarEvent[] - >([]); - const [isGoogleLoading, setIsGoogleLoading] = useState(false); - const [isInitialChecking, setIsInitialChecking] = useState(true); - const eventLoadRequestIdRef = useRef(0); - - const loadGoogleCalendars = useCallback( - async (isInitial = false) => { - if (!isInitial) { - setIsGoogleLoading(true); - } - clearError(); - try { - const response = await fetchGoogleCalendars(); - setGoogleCalendars(response.items); - setIsGoogleConnected(true); - - // 서버가 제공한 선택값 우선, 없으면 primary, 없으면 첫 번째 - if (response.selectedCalendarId) { - setSelectedCalendarId(response.selectedCalendarId); - } else { - const primaryCalendar = response.items.find((cal) => cal.primary); - if (primaryCalendar) { - setSelectedCalendarId(primaryCalendar.id); - } else if (response.items.length > 0) { - setSelectedCalendarId(response.items[0].id); - } - } - } catch (error) { - if (error instanceof ApiError && error.errorCode === '960-4') { - setIsGoogleConnected(false); - setGoogleCalendars([]); - return; - } - if (error instanceof Error) { - onError(error.message); - } - } finally { - if (isInitial) { - setIsInitialChecking(false); - } else { - setIsGoogleLoading(false); - } - } - }, - [clearError, onError], - ); + const isGoogleConnected = calendarsQuery.data != null; + const googleCalendars = calendarsQuery.data?.items ?? []; + const googleCalendarEvents = eventsQuery.data ?? []; + const isGoogleLoading = + isOAuthLoading || selectMutation.isPending || disconnectMutation.isPending; - const loadGoogleCalendarEvents = useCallback( - async (calendarId: string) => { - if (!calendarId) { - setGoogleCalendarEvents([]); - return; + // 서버 데이터 기반 selectedCalendarId 초기화 (서버 선택값 → primary → 첫 번째) + useEffect(() => { + if (!selectedCalendarId && calendarsQuery.data) { + const { items, selectedCalendarId: serverSelected } = calendarsQuery.data; + if (serverSelected) { + setSelectedCalendarId(serverSelected); + } else { + const primary = items.find((cal) => cal.primary); + setSelectedCalendarId(primary?.id ?? items[0]?.id ?? ''); } + } + }, [calendarsQuery.data, selectedCalendarId]); - // 새 요청 ID 생성 및 저장 - const requestId = ++eventLoadRequestIdRef.current; - // 새 캘린더 로드 시작 - 이전 이벤트 즉시 제거 - setGoogleCalendarEvents([]); - clearError(); + useEffect(() => { + if (calendarsQuery.error instanceof Error) { + onError(calendarsQuery.error.message); + } + }, [calendarsQuery.error, onError]); - try { - // 3개월 전부터 3개월 후까지 이벤트 조회 - const now = new Date(); - const threeMonthsAgo = new Date( - now.getFullYear(), - now.getMonth() - 3, - 1, - ); - const timeMax = new Date(now.getFullYear(), now.getMonth() + 4, 1); - - const events = await fetchGoogleCalendarEvents( - calendarId, - threeMonthsAgo.toISOString(), - timeMax.toISOString(), - ); - - // 응답이 최신 요청인지 확인 (stale response 무시) - if (requestId === eventLoadRequestIdRef.current) { - setGoogleCalendarEvents(events); - } - } catch (error) { - // 에러도 최신 요청인 경우에만 처리 - if (requestId === eventLoadRequestIdRef.current) { - if (error instanceof Error) { - onError(error.message); - } - setGoogleCalendarEvents([]); - } - } - }, - [clearError, onError], - ); + useEffect(() => { + if (eventsQuery.error instanceof Error) { + onError(eventsQuery.error.message); + } + }, [eventsQuery.error, onError]); + + // OAuth 콜백 처리 + useEffect(() => { + const errorMessage = sessionStorage.getItem(GOOGLE_OAUTH_ERROR_KEY); + if (errorMessage) { + onError(errorMessage); + sessionStorage.removeItem(GOOGLE_OAUTH_ERROR_KEY); + return; + } + + const successFlag = sessionStorage.getItem(GOOGLE_OAUTH_SUCCESS_KEY); + if (successFlag) { + sessionStorage.removeItem(GOOGLE_OAUTH_SUCCESS_KEY); + onStatus('Google OAuth 인증이 완료되었습니다.'); + } + }, [onError, onStatus]); const startGoogleOAuth = useCallback(async () => { - setIsGoogleLoading(true); + setIsOAuthLoading(true); clearError(); try { @@ -139,82 +104,44 @@ export const useGoogleCalendarData = ({ if (error instanceof Error) { onError(error.message); } - setIsGoogleLoading(false); + setIsOAuthLoading(false); } }, [clearError, onError]); const handleSelectCalendar = useCallback( - async (calendarId: string) => { + (calendarId: string) => { const calendar = googleCalendars.find((cal) => cal.id === calendarId); if (!calendar) return; - setIsGoogleLoading(true); clearError(); - - try { - await selectGoogleCalendar(calendarId, calendar.summary || ''); - setSelectedCalendarId(calendarId); - onStatus('캘린더가 선택되었습니다.'); - // selectedCalendarId 변경 시 useEffect에서 자동으로 이벤트 로드 - } catch (error) { - if (error instanceof Error) { - onError(error.message); - } - } finally { - setIsGoogleLoading(false); - } + selectMutation.mutate( + { calendarId, calendarName: calendar.summary || '' }, + { + onSuccess: () => { + setSelectedCalendarId(calendarId); + onStatus('캘린더가 선택되었습니다.'); + }, + onError: (error) => { + if (error instanceof Error) onError(error.message); + }, + }, + ); }, - [clearError, googleCalendars, onError, onStatus], + [clearError, googleCalendars, onError, onStatus, selectMutation], ); - const handleDisconnect = useCallback(async () => { - setIsGoogleLoading(true); + const handleDisconnect = useCallback(() => { clearError(); - - try { - await disconnectGoogleCalendar(); - // 진행 중인 모든 이벤트 로드 요청 무효화 - eventLoadRequestIdRef.current++; - setIsGoogleConnected(false); - setGoogleCalendars([]); - setSelectedCalendarId(''); - setGoogleCalendarEvents([]); - onStatus('Google Calendar 연결이 해제되었습니다.'); - } catch (error) { - if (error instanceof Error) { - onError(error.message); - } - } finally { - setIsGoogleLoading(false); - } - }, [clearError, onError, onStatus]); - - useEffect(() => { - const successFlag = sessionStorage.getItem(GOOGLE_OAUTH_SUCCESS_KEY); - const errorMessage = sessionStorage.getItem(GOOGLE_OAUTH_ERROR_KEY); - - if (errorMessage) { - onError(errorMessage); - sessionStorage.removeItem(GOOGLE_OAUTH_ERROR_KEY); - setIsInitialChecking(false); - return; - } - - if (successFlag) { - sessionStorage.removeItem(GOOGLE_OAUTH_SUCCESS_KEY); - onStatus('Google OAuth 인증이 완료되었습니다.'); - loadGoogleCalendars(true); - return; - } - - loadGoogleCalendars(true); - }, [loadGoogleCalendars, onError, onStatus]); - - useEffect(() => { - if (selectedCalendarId && isGoogleConnected) { - loadGoogleCalendarEvents(selectedCalendarId); - } - }, [selectedCalendarId, isGoogleConnected, loadGoogleCalendarEvents]); + disconnectMutation.mutate(undefined, { + onSuccess: () => { + setSelectedCalendarId(''); + onStatus('Google Calendar 연결이 해제되었습니다.'); + }, + onError: (error) => { + if (error instanceof Error) onError(error.message); + }, + }); + }, [clearError, disconnectMutation, onError, onStatus]); return { isGoogleConnected, @@ -222,11 +149,9 @@ export const useGoogleCalendarData = ({ selectedCalendarId, googleCalendarEvents, isGoogleLoading, - isInitialChecking, + isInitialChecking: calendarsQuery.isPending, startGoogleOAuth, selectCalendar: handleSelectCalendar, disconnectGoogle: handleDisconnect, - loadGoogleCalendars, - loadGoogleCalendarEvents, }; }; From 5785f6ec73135b9da6048aac591ab7097e1ce371 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 5 Apr 2026 18:50:58 +0900 Subject: [PATCH 07/22] =?UTF-8?q?fix:=20Notion=20=EC=BA=98=EB=A6=B0?= =?UTF-8?q?=EB=8D=94=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EA=B4=80=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 요청 ID를 사용하여 이전 요청의 응답을 무효화하는 로직 추가 - 페이지 로드 및 데이터베이스 페이지 적용 시 요청 ID 확인 추가 --- .../CalendarSyncTab/hooks/useNotionCalendarData.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarData.ts b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarData.ts index 264a43d3b..3c3432e53 100644 --- a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarData.ts +++ b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarData.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { fetchNotionDatabasePages, fetchNotionDatabases, @@ -30,6 +30,8 @@ export const useNotionCalendarData = ({ const [isNotionDatabaseApplying, setIsNotionDatabaseApplying] = useState(false); + const pagesRequestIdRef = useRef(0); + const applyPagesResponse = useCallback((response: NotionPagesResponse) => { setNotionItems(response.items); setNotionTotalResults(response.totalResults); @@ -41,9 +43,13 @@ export const useNotionCalendarData = ({ }, []); const loadNotionPages = useCallback(async () => { + const requestId = ++pagesRequestIdRef.current; setIsNotionLoading(true); try { const response = await fetchNotionPages(); + if (requestId !== pagesRequestIdRef.current) { + return null; + } applyPagesResponse(response); return response; } catch (error: unknown) { @@ -77,10 +83,14 @@ export const useNotionCalendarData = ({ setIsNotionDatabaseApplying(true); clearError(); + const requestId = ++pagesRequestIdRef.current; fetchNotionDatabasePages({ databaseId: selectedNotionDatabaseId, }) .then((pagesResponse) => { + if (requestId !== pagesRequestIdRef.current) { + return; + } applyPagesResponse(pagesResponse); onStatus('선택한 Notion 데이터베이스를 연결했습니다.'); }) From 58fff527e2bc370ddf5f7883baeef203e8b9ad3d Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 5 Apr 2026 18:53:19 +0900 Subject: [PATCH 08/22] =?UTF-8?q?fix:=20=EA=B0=9C=EC=84=A0=EB=90=9C=20Noti?= =?UTF-8?q?on=20=EC=BA=98=EB=A6=B0=EB=8D=94=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useRef를 사용하여 초기화 상태를 추적하는 로직 추가 - notionCalendarEvents가 비어있거나 초기화가 완료된 경우 useEffect에서 조기 반환하도록 수정 --- .../tabs/CalendarSyncTab/hooks/useNotionCalendarUiState.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarUiState.ts b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarUiState.ts index 354ed056f..5d0aa71a2 100644 --- a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarUiState.ts +++ b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarUiState.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { NotionSearchItem } from '@/apis/calendarOAuth'; import { buildMonthCalendarDays, @@ -22,6 +22,7 @@ export const useNotionCalendarUiState = ({ const [notionEventEnabledMap, setNotionEventEnabledMap] = useState< Record >({}); + const didInitVisibleMonthRef = useRef(false); const notionCalendarEvents = useMemo( () => @@ -62,12 +63,14 @@ export const useNotionCalendarUiState = ({ ); useEffect(() => { - if (notionCalendarEvents.length === 0) return; + if (notionCalendarEvents.length === 0 || didInitVisibleMonthRef.current) + return; const firstEventDate = dateFromKey(notionCalendarEvents[0].dateKey); setVisibleMonth( new Date(firstEventDate.getFullYear(), firstEventDate.getMonth(), 1), ); + didInitVisibleMonthRef.current = true; }, [notionCalendarEvents]); useEffect(() => { From 021db50e9bfb0d2240f8e752ca0b4c1498601be8 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 5 Apr 2026 18:54:41 +0900 Subject: [PATCH 09/22] =?UTF-8?q?fix:=20Notion=20OAuth=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=A0=95=EB=B3=B4=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 오류 발생 시 사용자에게 명확한 메시지 제공 - 세션 스토리지에서 상태 키 제거 및 URL에서 OAuth 매개변수 정리 로직 추가 --- .../AdminPage/tabs/CalendarSyncTab/hooks/useNotionOAuth.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionOAuth.ts b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionOAuth.ts index 7b764bddd..3a1be1172 100644 --- a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionOAuth.ts +++ b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionOAuth.ts @@ -64,8 +64,10 @@ export const useNotionOAuth = ({ if (!code || !state || !expectedState || state !== expectedState) { if (hasOAuthParams) { - clearOAuthParamsFromUrl(); + onError('Notion OAuth 인증 정보가 올바르지 않습니다.'); } + sessionStorage.removeItem(NOTION_STATE_KEY); + if (hasOAuthParams) clearOAuthParamsFromUrl(); return; } From bc5a81761cbe1af0013a097a38f1776ef87f4280 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 5 Apr 2026 18:55:32 +0900 Subject: [PATCH 10/22] =?UTF-8?q?fix:=20CalendarSyncTab=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useRef를 사용하여 초기화 상태를 추적하는 로직 추가 - allUnifiedEvents가 비어있거나 초기화가 완료된 경우 useEffect에서 조기 반환하도록 수정 --- .../tabs/CalendarSyncTab/hooks/useUnifiedCalendarUiState.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useUnifiedCalendarUiState.ts b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useUnifiedCalendarUiState.ts index cbdb7598d..671c705b8 100644 --- a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useUnifiedCalendarUiState.ts +++ b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useUnifiedCalendarUiState.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import type { GoogleCalendarEvent } from '@/apis/calendarOAuth'; import { buildMonthCalendarDays, @@ -29,6 +29,7 @@ export const useUnifiedCalendarUiState = ({ const [googleEventEnabledMap, setGoogleEventEnabledMap] = useState< Record >({}); + const didInitVisibleMonthRef = useRef(false); const unifiedNotionEvents = useMemo( () => notionCalendarEvents.map(convertNotionEventToUnified), @@ -91,7 +92,7 @@ export const useUnifiedCalendarUiState = ({ ); useEffect(() => { - if (allUnifiedEvents.length === 0) return; + if (allUnifiedEvents.length === 0 || didInitVisibleMonthRef.current) return; const lastEventDate = dateFromKey( allUnifiedEvents[allUnifiedEvents.length - 1].dateKey, @@ -99,6 +100,7 @@ export const useUnifiedCalendarUiState = ({ setVisibleMonth( new Date(lastEventDate.getFullYear(), lastEventDate.getMonth(), 1), ); + didInitVisibleMonthRef.current = true; }, [allUnifiedEvents]); useEffect(() => { From 2594106fde83c539fe1c65fd3beec2b4efd44cfd Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 5 Apr 2026 19:00:28 +0900 Subject: [PATCH 11/22] =?UTF-8?q?fix:=20=EA=B0=9C=EC=84=A0=EB=90=9C=20acti?= =?UTF-8?q?veTab=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20useEffect=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - activeTab 상태를 useMemo를 사용하여 계산하도록 변경 - SCHEDULE 탭에서 캘린더 이벤트가 없을 경우 기본 탭으로 돌아가는 useEffect 추가 - 코드 가독성을 높이기 위해 불필요한 초기화 로직 제거 --- .../pages/ClubDetailPage/ClubDetailPage.tsx | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx index 85b2d347a..9357139e9 100644 --- a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx +++ b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useParams, useSearchParams } from 'react-router-dom'; import Footer from '@/components/common/Footer/Footer'; import Header from '@/components/common/Header/Header'; @@ -38,11 +38,6 @@ const ClubDetailPage = () => { const [searchParams, setSearchParams] = useSearchParams(); const tabParam = searchParams.get('tab') as TabType | null; - const activeTab: TabType = - tabParam && Object.values(TAB_TYPE).includes(tabParam) - ? tabParam - : TAB_TYPE.INTRO; - const { clubId, clubName } = useParams<{ clubId: string; clubName: string; @@ -56,6 +51,22 @@ const ClubDetailPage = () => { const hasCalendarEvents = clubDetail?.hasCalendarEvents ?? false; + const activeTab: TabType = useMemo(() => { + if (!tabParam || !Object.values(TAB_TYPE).includes(tabParam)) { + return TAB_TYPE.INTRO; + } + if (tabParam === TAB_TYPE.SCHEDULE && !hasCalendarEvents) { + return TAB_TYPE.INTRO; + } + return tabParam; + }, [tabParam, hasCalendarEvents]); + + useEffect(() => { + if (clubDetail && tabParam === TAB_TYPE.SCHEDULE && !hasCalendarEvents) { + setSearchParams({ tab: TAB_TYPE.INTRO }, { replace: true }); + } + }, [clubDetail, tabParam, hasCalendarEvents, setSearchParams]); + const { data: calendarEvents = [] } = useGetClubCalendarEvents( (clubName ?? clubId) || '', { enabled: hasCalendarEvents && activeTab === TAB_TYPE.SCHEDULE }, From 4d0b10a1cc3b7021db9295e52644155bcda7663a Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 5 Apr 2026 19:03:50 +0900 Subject: [PATCH 12/22] =?UTF-8?q?fix:=20=EA=B0=9C=EC=84=A0=EB=90=9C=20Club?= =?UTF-8?q?ScheduleCalendar=20=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useRef를 사용하여 초기화 상태를 추적하는 로직 추가 - parsedEvents가 비어있거나 초기화가 완료된 경우 useEffect에서 조기 반환하도록 수정 - 캘린더의 첫 번째 이벤트에 따라 기본 선택 날짜 및 표시 월 설정 로직 추가 --- .../ClubScheduleCalendar/ClubScheduleCalendar.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/ClubDetailPage/components/ClubScheduleCalendar/ClubScheduleCalendar.tsx b/frontend/src/pages/ClubDetailPage/components/ClubScheduleCalendar/ClubScheduleCalendar.tsx index 5007ff17e..a06420539 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubScheduleCalendar/ClubScheduleCalendar.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubScheduleCalendar/ClubScheduleCalendar.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import type { ClubCalendarEvent } from '@/types/club'; import { buildDateKeyFromDate, @@ -73,6 +73,8 @@ const formatSelectedDate = (dateKey: string) => { }; const ClubScheduleCalendar = ({ events }: ClubScheduleCalendarProps) => { + const didInitFromEventsRef = useRef(false); + const parsedEvents = useMemo(() => { const normalizedEvents = events.flatMap((event) => { const dateKey = parseDateKey(event.start); @@ -142,6 +144,14 @@ const ClubScheduleCalendar = ({ events }: ClubScheduleCalendarProps) => { return buildDateKeyFromDate(new Date()); }); + useEffect(() => { + if (didInitFromEventsRef.current || parsedEvents.length === 0) return; + const firstDate = dateFromKey(parsedEvents[0].dateKey); + setVisibleMonth(new Date(firstDate.getFullYear(), firstDate.getMonth(), 1)); + setSelectedDateKey(parsedEvents[0].dateKey); + didInitFromEventsRef.current = true; + }, [parsedEvents]); + const calendarDays = useMemo( () => buildMonthCalendarDays(visibleMonth), [visibleMonth], From 5ff89682c2d0bde6537acefc46b1e4188f931ec6 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 5 Apr 2026 19:04:21 +0900 Subject: [PATCH 13/22] =?UTF-8?q?fix:=20URL=20=EC=9D=B8=EC=BD=94=EB=94=A9?= =?UTF-8?q?=EC=9D=84=20=ED=86=B5=ED=95=9C=20=ED=81=B4=EB=9F=BD=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=84=A4=EB=B9=84?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=EC=85=98=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 클럽 이름을 URL에 안전하게 포함하기 위해 encodeURIComponent 사용 --- .../components/detail/PromotionClubCTA/PromotionClubCTA.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/PromotionPage/components/detail/PromotionClubCTA/PromotionClubCTA.tsx b/frontend/src/pages/PromotionPage/components/detail/PromotionClubCTA/PromotionClubCTA.tsx index 690662892..bc1e2a0c0 100644 --- a/frontend/src/pages/PromotionPage/components/detail/PromotionClubCTA/PromotionClubCTA.tsx +++ b/frontend/src/pages/PromotionPage/components/detail/PromotionClubCTA/PromotionClubCTA.tsx @@ -10,7 +10,7 @@ const PromotionClubCTA = ({ clubName }: Props) => { const navigate = useNavigate(); const handleNavigate = () => { - navigate(`/clubDetail/@${clubName}`); + navigate(`/clubDetail/@${encodeURIComponent(clubName)}`); }; return ( From db56fed38d1917f7692f6b200c613f644d8bccff Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 5 Apr 2026 19:08:18 +0900 Subject: [PATCH 14/22] =?UTF-8?q?fix:=20=EA=B0=9C=EC=84=A0=EB=90=9C=20prom?= =?UTF-8?q?otionNotification=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - articles 배열에서 유효한 timestamp를 필터링하여 최대값을 반환하도록 수정 - 빈 timestamps 배열에 대한 처리를 추가하여 0을 반환하도록 변경 --- .../utils/promotionNotification.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/frontend/src/pages/PromotionPage/utils/promotionNotification.ts b/frontend/src/pages/PromotionPage/utils/promotionNotification.ts index dd8c6ed14..926a4437d 100644 --- a/frontend/src/pages/PromotionPage/utils/promotionNotification.ts +++ b/frontend/src/pages/PromotionPage/utils/promotionNotification.ts @@ -5,16 +5,18 @@ export const getLatestPromotionTime = ( ): number => { if (!articles || articles.length === 0) return 0; - const timestamps = articles.map((article) => { - if (article.id && article.id.length === 24) { - const timestamp = parseInt(article.id.substring(0, 8), 16) * 1000; - if (!isNaN(timestamp)) return timestamp; - } + const timestamps = articles + .map((article) => { + if (article.id && article.id.length === 24) { + const timestamp = parseInt(article.id.substring(0, 8), 16) * 1000; + if (!isNaN(timestamp)) return timestamp; + } - return new Date(article.eventStartDate).getTime(); - }); + return new Date(article.eventStartDate).getTime(); + }) + .filter((time) => Number.isFinite(time)); - return Math.max(...timestamps); + return timestamps.length > 0 ? Math.max(...timestamps) : 0; }; export const getLastCheckedTime = (): number | null => { From dac2e7b451c0f40b880df24f7948b05e1d70f459 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 5 Apr 2026 19:13:19 +0900 Subject: [PATCH 15/22] =?UTF-8?q?fix:=20=EA=B0=9C=EC=84=A0=EB=90=9C=20crea?= =?UTF-8?q?teState=20=ED=95=A8=EC=88=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - globalThis.crypto.randomUUID가 지원되지 않는 경우를 대비하여 대체 로직 추가 - Uint8Array를 사용하여 안전한 난수 문자열 생성 방식으로 변경 --- frontend/src/utils/calendarSyncUtils.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/utils/calendarSyncUtils.ts b/frontend/src/utils/calendarSyncUtils.ts index 5dfd1f968..1d3e35b7b 100644 --- a/frontend/src/utils/calendarSyncUtils.ts +++ b/frontend/src/utils/calendarSyncUtils.ts @@ -25,8 +25,14 @@ export const buildDefaultRedirectUri = () => `${window.location.origin}/admin/calendar-sync`; /** OAuth state용 난수 문자열을 생성한다. */ -export const createState = () => - globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2); +export const createState = () => { + if (globalThis.crypto?.randomUUID) { + return globalThis.crypto.randomUUID(); + } + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +}; /** 토큰 표시용 마스킹 문자열을 만든다. */ export const maskToken = (token: string) => { From 6e95dda22f01ff1fbfd2049c66201db4236419ec Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 5 Apr 2026 19:17:06 +0900 Subject: [PATCH 16/22] =?UTF-8?q?fix:=20=EA=B0=9C=EC=84=A0=EB=90=9C=20buil?= =?UTF-8?q?dMonthCalendarDays=20=ED=95=A8=EC=88=98=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 날짜 배열 생성을 위한 로직을 addDays 함수를 사용하여 간결하게 변경 - 코드 가독성을 높이기 위해 불필요한 변수 제거 --- frontend/src/utils/calendarSyncUtils.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/utils/calendarSyncUtils.ts b/frontend/src/utils/calendarSyncUtils.ts index 1d3e35b7b..bb508cf09 100644 --- a/frontend/src/utils/calendarSyncUtils.ts +++ b/frontend/src/utils/calendarSyncUtils.ts @@ -1,3 +1,4 @@ +import { addDays } from 'date-fns'; import type { GoogleCalendarEvent, NotionSearchItem, @@ -124,13 +125,12 @@ export const buildMonthCalendarDays = (month: Date) => { gridEnd.setDate(monthEnd.getDate() + (6 - monthEnd.getDay())); const days: Date[] = []; - const oneDayMs = 24 * 60 * 60 * 1000; for ( - let timestamp = gridStart.getTime(); - timestamp <= gridEnd.getTime(); - timestamp += oneDayMs + let current = gridStart; + current <= gridEnd; + current = addDays(current, 1) ) { - days.push(new Date(timestamp)); + days.push(current); } return days; }; From 6f897393074839dbc48ef796cebcaf2528fb7da2 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 5 Apr 2026 19:18:19 +0900 Subject: [PATCH 17/22] =?UTF-8?q?fix:=20=EA=B0=9C=EC=84=A0=EB=90=9C=20form?= =?UTF-8?q?atKSTDateTime=20=ED=95=A8=EC=88=98=EC=9D=98=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 유효하지 않은 날짜 문자열에 대한 처리를 추가하여 NaN 반환 시 빈 문자열을 반환하도록 수정 --- frontend/src/utils/formatKSTDateTime.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/utils/formatKSTDateTime.ts b/frontend/src/utils/formatKSTDateTime.ts index 307165dcc..648e37aa1 100644 --- a/frontend/src/utils/formatKSTDateTime.ts +++ b/frontend/src/utils/formatKSTDateTime.ts @@ -4,6 +4,7 @@ export const formatKSTDateTime = ( ) => { if (!dateStr) return ''; const date = new Date(dateStr); + if (Number.isNaN(date.getTime())) return ''; const formatter = new Intl.DateTimeFormat('ko-KR', { timeZone: 'Asia/Seoul', From 5e545ce3565b8ed2fd546a1a5783e79387906d74 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 5 Apr 2026 19:30:29 +0900 Subject: [PATCH 18/22] =?UTF-8?q?fix:=20=EA=B0=9C=EC=84=A0=EB=90=9C=20sort?= =?UTF-8?q?Promotions=20=ED=95=A8=EC=88=98=EC=9D=98=20=EC=A0=95=EB=A0=AC?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기본 정렬 로직을 수정하여 aStart와 bStart의 차이를 반환하도록 변경하여 정렬 정확도 향상 --- frontend/src/pages/PromotionPage/utils/sortPromotions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/PromotionPage/utils/sortPromotions.ts b/frontend/src/pages/PromotionPage/utils/sortPromotions.ts index 356e96d02..7a3a7296a 100644 --- a/frontend/src/pages/PromotionPage/utils/sortPromotions.ts +++ b/frontend/src/pages/PromotionPage/utils/sortPromotions.ts @@ -22,6 +22,6 @@ export const sortPromotions = ( if (aStatusWeight !== bStatusWeight) return aStatusWeight - bStatusWeight; if (aStatusWeight === 2) return aStart - bStart; if (aStatusWeight === 3) return bStart - aStart; - return 0; + return aStart - bStart; }); }; From ca2a297631c23b4424d512845580c70e89b69632 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 5 Apr 2026 20:41:05 +0900 Subject: [PATCH 19/22] =?UTF-8?q?fix:=20=ED=81=B4=EB=9F=BD=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=9D=98=20=EC=BA=98?= =?UTF-8?q?=EB=A6=B0=EB=8D=94=20=EC=97=B0=EA=B2=B0=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hasCalendarEvents를 hasCalendarConnection으로 변경하여 클럽의 캘린더 연결 상태를 정확하게 반영 --- .../pages/ClubDetailPage/ClubDetailPage.tsx | 22 +++++++++---------- frontend/src/types/club.ts | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx index 9357139e9..4f275bacb 100644 --- a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx +++ b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx @@ -49,27 +49,27 @@ const ClubDetailPage = () => { (clubName ?? clubId) || '', ); - const hasCalendarEvents = clubDetail?.hasCalendarEvents ?? false; + const hasCalendarConnection = clubDetail?.hasCalendarConnection ?? false; const activeTab: TabType = useMemo(() => { if (!tabParam || !Object.values(TAB_TYPE).includes(tabParam)) { return TAB_TYPE.INTRO; } - if (tabParam === TAB_TYPE.SCHEDULE && !hasCalendarEvents) { + if (tabParam === TAB_TYPE.SCHEDULE && !hasCalendarConnection) { return TAB_TYPE.INTRO; } return tabParam; - }, [tabParam, hasCalendarEvents]); + }, [tabParam, hasCalendarConnection]); useEffect(() => { - if (clubDetail && tabParam === TAB_TYPE.SCHEDULE && !hasCalendarEvents) { + if (clubDetail && tabParam === TAB_TYPE.SCHEDULE && !hasCalendarConnection) { setSearchParams({ tab: TAB_TYPE.INTRO }, { replace: true }); } - }, [clubDetail, tabParam, hasCalendarEvents, setSearchParams]); + }, [clubDetail, tabParam, hasCalendarConnection, setSearchParams]); const { data: calendarEvents = [] } = useGetClubCalendarEvents( (clubName ?? clubId) || '', - { enabled: hasCalendarEvents && activeTab === TAB_TYPE.SCHEDULE }, + { enabled: hasCalendarConnection && activeTab === TAB_TYPE.SCHEDULE }, ); const tabs = useMemo( @@ -77,11 +77,11 @@ const ClubDetailPage = () => { [ { key: TAB_TYPE.INTRO, label: '소개 내용' }, { key: TAB_TYPE.PHOTOS, label: '활동사진' }, - hasCalendarEvents + hasCalendarConnection ? { key: TAB_TYPE.SCHEDULE, label: '일정 보기' } : null, ].filter(Boolean) as Array<{ key: TabType; label: string }>, - [hasCalendarEvents], + [hasCalendarConnection], ); const topBarTabs = useMemo( @@ -89,11 +89,11 @@ const ClubDetailPage = () => { [ { key: TAB_TYPE.INTRO, label: '소개내용' }, { key: TAB_TYPE.PHOTOS, label: '활동사진' }, - hasCalendarEvents + hasCalendarConnection ? { key: TAB_TYPE.SCHEDULE, label: '일정 보기' } : null, ].filter(Boolean) as Array<{ key: TabType; label: string }>, - [hasCalendarEvents], + [hasCalendarConnection], ); useTrackPageView( @@ -182,7 +182,7 @@ const ClubDetailPage = () => { > - {hasCalendarEvents && ( + {hasCalendarConnection && (
; externalApplicationUrl?: string; - hasCalendarEvents?: boolean; + hasCalendarConnection?: boolean; } export interface ClubCalendarEvent { From 0d967a53855a7ad1253e1854cd0645376026a4d4 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 5 Apr 2026 20:52:03 +0900 Subject: [PATCH 20/22] =?UTF-8?q?fix:=20=EA=B0=9C=EC=84=A0=EB=90=9C=20useG?= =?UTF-8?q?oogleCalendarData=20=ED=9B=85=EC=9D=98=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useRef를 사용하여 캘린더 데이터 로딩 상태를 추적하는 hasLoadedOnce 추가 - isInitialChecking 상태를 개선하여 초기 로딩 후에만 true로 설정되도록 수정 --- .../CalendarSyncTab/hooks/useGoogleCalendarData.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useGoogleCalendarData.ts b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useGoogleCalendarData.ts index 3f4c52102..c054af3ce 100644 --- a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useGoogleCalendarData.ts +++ b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useGoogleCalendarData.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { fetchGoogleAuthorizeUrl } from '@/apis/calendarOAuth'; import { useDisconnectGoogleCalendar, @@ -25,6 +25,7 @@ export const useGoogleCalendarData = ({ }: UseGoogleCalendarDataParams) => { const [selectedCalendarId, setSelectedCalendarId] = useState(''); const [isOAuthLoading, setIsOAuthLoading] = useState(false); + const hasLoadedOnce = useRef(false); const calendarsQuery = useGetGoogleCalendars(); const selectMutation = useSelectGoogleCalendar(); @@ -63,6 +64,12 @@ export const useGoogleCalendarData = ({ } }, [calendarsQuery.data, selectedCalendarId]); + useEffect(() => { + if (!calendarsQuery.isPending) { + hasLoadedOnce.current = true; + } + }, [calendarsQuery.isPending]); + useEffect(() => { if (calendarsQuery.error instanceof Error) { onError(calendarsQuery.error.message); @@ -149,7 +156,7 @@ export const useGoogleCalendarData = ({ selectedCalendarId, googleCalendarEvents, isGoogleLoading, - isInitialChecking: calendarsQuery.isPending, + isInitialChecking: calendarsQuery.isPending && !hasLoadedOnce.current, startGoogleOAuth, selectCalendar: handleSelectCalendar, disconnectGoogle: handleDisconnect, From a7a036864b6d6f5e98c43ebcd29385ffc678edb8 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 5 Apr 2026 20:52:59 +0900 Subject: [PATCH 21/22] =?UTF-8?q?fix:=20ClubScheduleCalendar=EC=97=90=20ke?= =?UTF-8?q?y=20prop=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clubId 또는 clubName을 key prop으로 사용하여 컴포넌트의 재렌더링 최적화 --- frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx index 4f275bacb..3a7d78b95 100644 --- a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx +++ b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx @@ -188,7 +188,7 @@ const ClubDetailPage = () => { display: activeTab === TAB_TYPE.SCHEDULE ? 'block' : 'none', }} > - +
)} From db02c9790717ff198af060417b810477316c2e45 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 5 Apr 2026 21:11:35 +0900 Subject: [PATCH 22/22] fix: lint error --- frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx index 3a7d78b95..85d617166 100644 --- a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx +++ b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx @@ -62,7 +62,11 @@ const ClubDetailPage = () => { }, [tabParam, hasCalendarConnection]); useEffect(() => { - if (clubDetail && tabParam === TAB_TYPE.SCHEDULE && !hasCalendarConnection) { + if ( + clubDetail && + tabParam === TAB_TYPE.SCHEDULE && + !hasCalendarConnection + ) { setSearchParams({ tab: TAB_TYPE.INTRO }, { replace: true }); } }, [clubDetail, tabParam, hasCalendarConnection, setSearchParams]); @@ -188,7 +192,10 @@ const ClubDetailPage = () => { display: activeTab === TAB_TYPE.SCHEDULE ? 'block' : 'none', }} > - + )}