feat(frontend): Phase 5 - 커스텀 훅 구현
- useMediaQuery, useIsMobile, useIsDesktop: 반응형 레이아웃 - useScheduleData, useCategories: 스케줄/카테고리 데이터 조회 - useScheduleSearch: 무한 스크롤 검색 - useScheduleFiltering, useCategoryCounts: 필터링 및 정렬 - useCalendar: 캘린더 로직 (월 이동, 날짜 선택) - useAdminAuth: 토큰 검증 및 리다이렉트 - utils/schedule.js: 스케줄 유틸리티 함수 추가 - constants: SEARCH_LIMIT, MIN_YEAR, MONTH_NAMES 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
fe067ca8c8
commit
27c41b0af0
12 changed files with 659 additions and 75 deletions
|
|
@ -1,50 +1,37 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import { isMobile } from "react-device-detect";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { cn, getTodayKST, formatFullDate } from "@/utils";
|
import { cn, getTodayKST, formatFullDate } from "@/utils";
|
||||||
import { useAuthStore, useUIStore } from "@/stores";
|
import { useAuthStore, useUIStore } from "@/stores";
|
||||||
import { memberApi, scheduleApi } from "@/api";
|
import { useIsMobile, useCategories, useCalendar } from "@/hooks";
|
||||||
|
import { memberApi } from "@/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 프로미스나인 팬사이트 메인 앱
|
* 프로미스나인 팬사이트 메인 앱
|
||||||
*
|
*
|
||||||
* Phase 4: API 계층 완료
|
* Phase 5: 커스텀 훅 완료
|
||||||
* - api/client.js: 기본 fetch 클라이언트 (공개/인증)
|
* - useMediaQuery, useIsMobile, useIsDesktop
|
||||||
* - api/schedules.js: 스케줄 API
|
* - useScheduleData, useScheduleDetail, useCategories
|
||||||
* - api/albums.js: 앨범 API
|
* - useScheduleSearch (무한 스크롤)
|
||||||
* - api/members.js: 멤버 API
|
* - useScheduleFiltering, useCategoryCounts
|
||||||
* - api/auth.js: 인증 API
|
* - useCalendar
|
||||||
|
* - useAdminAuth
|
||||||
*/
|
*/
|
||||||
function App() {
|
function App() {
|
||||||
const today = getTodayKST();
|
const today = getTodayKST();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const { isAuthenticated } = useAuthStore();
|
const { isAuthenticated } = useAuthStore();
|
||||||
const { showSuccess, showError, toasts } = useUIStore();
|
const { showSuccess, toasts } = useUIStore();
|
||||||
const [apiTest, setApiTest] = useState(null);
|
|
||||||
|
|
||||||
// React Query로 멤버 데이터 조회
|
// 커스텀 훅 사용
|
||||||
|
const { data: categories, isLoading: categoriesLoading } = useCategories();
|
||||||
|
const calendar = useCalendar();
|
||||||
|
|
||||||
|
// 멤버 데이터 (기존 방식 유지)
|
||||||
const { data: members, isLoading: membersLoading } = useQuery({
|
const { data: members, isLoading: membersLoading } = useQuery({
|
||||||
queryKey: ["members"],
|
queryKey: ["members"],
|
||||||
queryFn: memberApi.getMembers,
|
queryFn: memberApi.getMembers,
|
||||||
});
|
});
|
||||||
|
|
||||||
// React Query로 카테고리 데이터 조회
|
|
||||||
const { data: categories, isLoading: categoriesLoading } = useQuery({
|
|
||||||
queryKey: ["categories"],
|
|
||||||
queryFn: scheduleApi.getCategories,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleTestApi = async () => {
|
|
||||||
try {
|
|
||||||
const data = await memberApi.getMembers();
|
|
||||||
setApiTest(`멤버 ${data.length}명 조회 성공!`);
|
|
||||||
showSuccess("API 호출 성공!");
|
|
||||||
} catch (error) {
|
|
||||||
setApiTest(`에러: ${error.message}`);
|
|
||||||
showError("API 호출 실패");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|
@ -56,9 +43,9 @@ function App() {
|
||||||
<h1 className="text-2xl font-bold text-primary mb-2">
|
<h1 className="text-2xl font-bold text-primary mb-2">
|
||||||
fromis_9 Frontend Refactoring
|
fromis_9 Frontend Refactoring
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600">Phase 4 완료 - API 계층</p>
|
<p className="text-gray-600">Phase 5 완료 - 커스텀 훅</p>
|
||||||
<p className={cn("text-sm", isMobile ? "text-blue-500" : "text-green-500")}>
|
<p className={cn("text-sm", isMobile ? "text-blue-500" : "text-green-500")}>
|
||||||
디바이스: {isMobile ? "모바일" : "PC"}
|
디바이스: {isMobile ? "모바일" : "PC"} (useIsMobile 훅)
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-6 p-4 bg-white rounded-lg shadow text-left text-sm space-y-3">
|
<div className="mt-6 p-4 bg-white rounded-lg shadow text-left text-sm space-y-3">
|
||||||
|
|
@ -66,31 +53,12 @@ function App() {
|
||||||
<p><strong>인증:</strong> {isAuthenticated ? "✅" : "❌"}</p>
|
<p><strong>인증:</strong> {isAuthenticated ? "✅" : "❌"}</p>
|
||||||
|
|
||||||
<div className="border-t pt-3">
|
<div className="border-t pt-3">
|
||||||
<p className="font-semibold mb-2">API 테스트 (React Query)</p>
|
<p className="font-semibold mb-2">useCategories 훅</p>
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p>
|
<p>
|
||||||
<strong>멤버:</strong>{" "}
|
{categoriesLoading ? "로딩 중..." : `${categories?.length || 0}개 카테고리`}
|
||||||
{membersLoading ? "로딩 중..." : `${members?.length || 0}명`}
|
|
||||||
</p>
|
|
||||||
{members && (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{members.map((m) => (
|
|
||||||
<span key={m.id} className="px-2 py-0.5 bg-gray-100 rounded text-xs">
|
|
||||||
{m.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 mt-3">
|
|
||||||
<p>
|
|
||||||
<strong>카테고리:</strong>{" "}
|
|
||||||
{categoriesLoading ? "로딩 중..." : `${categories?.length || 0}개`}
|
|
||||||
</p>
|
</p>
|
||||||
{categories && (
|
{categories && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
{categories.map((c) => (
|
{categories.map((c) => (
|
||||||
<span
|
<span
|
||||||
key={c.id}
|
key={c.id}
|
||||||
|
|
@ -103,17 +71,49 @@ function App() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<p className="font-semibold mb-2">useCalendar 훅</p>
|
||||||
|
<p><strong>현재:</strong> {calendar.year}년 {calendar.monthName}</p>
|
||||||
|
<p><strong>선택:</strong> {calendar.selectedDate}</p>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={calendar.goToPrevMonth}
|
||||||
|
disabled={!calendar.canGoPrevMonth}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1 rounded text-xs",
|
||||||
|
calendar.canGoPrevMonth ? "bg-primary text-white" : "bg-gray-200 text-gray-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
이전
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={calendar.goToToday}
|
||||||
|
className="px-3 py-1 bg-gray-500 text-white rounded text-xs"
|
||||||
|
>
|
||||||
|
오늘
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={calendar.goToNextMonth}
|
||||||
|
className="px-3 py-1 bg-primary text-white rounded text-xs"
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t pt-3">
|
<div className="border-t pt-3">
|
||||||
<p className="font-semibold mb-2">직접 API 호출 테스트</p>
|
<p className="font-semibold mb-2">멤버 데이터</p>
|
||||||
<button
|
<p>{membersLoading ? "로딩 중..." : `${members?.length || 0}명`}</p>
|
||||||
onClick={handleTestApi}
|
{members && (
|
||||||
className="px-3 py-1 bg-primary text-white rounded text-xs"
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
>
|
{members.map((m) => (
|
||||||
멤버 API 호출
|
<span key={m.id} className="px-2 py-0.5 bg-gray-100 rounded text-xs">
|
||||||
</button>
|
{m.name}
|
||||||
{apiTest && <p className="mt-2 text-xs text-gray-600">{apiTest}</p>}
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -43,5 +43,17 @@ export const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
/** 기본 페이지 크기 */
|
/** 기본 페이지 크기 */
|
||||||
export const DEFAULT_PAGE_SIZE = 20;
|
export const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
/** 검색 결과 페이지 크기 */
|
||||||
|
export const SEARCH_LIMIT = 20;
|
||||||
|
|
||||||
/** API 기본 URL */
|
/** API 기본 URL */
|
||||||
export const API_BASE_URL = '/api';
|
export const API_BASE_URL = '/api';
|
||||||
|
|
||||||
|
/** 캘린더 최소 년도 */
|
||||||
|
export const MIN_YEAR = 2017;
|
||||||
|
|
||||||
|
/** 월 이름 */
|
||||||
|
export const MONTH_NAMES = [
|
||||||
|
'1월', '2월', '3월', '4월', '5월', '6월',
|
||||||
|
'7월', '8월', '9월', '10월', '11월', '12월',
|
||||||
|
];
|
||||||
|
|
|
||||||
26
frontend-temp/src/hooks/index.js
Normal file
26
frontend-temp/src/hooks/index.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
/**
|
||||||
|
* 훅 통합 export
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 미디어 쿼리
|
||||||
|
export { useMediaQuery, useIsMobile, useIsDesktop, useIsTablet } from './useMediaQuery';
|
||||||
|
|
||||||
|
// 스케줄 데이터
|
||||||
|
export {
|
||||||
|
useScheduleData,
|
||||||
|
useScheduleDetail,
|
||||||
|
useUpcomingSchedules,
|
||||||
|
useCategories,
|
||||||
|
} from './useScheduleData';
|
||||||
|
|
||||||
|
// 스케줄 검색
|
||||||
|
export { useScheduleSearch } from './useScheduleSearch';
|
||||||
|
|
||||||
|
// 스케줄 필터링
|
||||||
|
export { useScheduleFiltering, useCategoryCounts } from './useScheduleFiltering';
|
||||||
|
|
||||||
|
// 캘린더
|
||||||
|
export { useCalendar } from './useCalendar';
|
||||||
|
|
||||||
|
// 인증
|
||||||
|
export { useAdminAuth, useRedirectIfAuthenticated } from './useAdminAuth';
|
||||||
72
frontend-temp/src/hooks/useAdminAuth.js
Normal file
72
frontend-temp/src/hooks/useAdminAuth.js
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useAuthStore } from '@/stores';
|
||||||
|
import { authApi } from '@/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 어드민 인증 훅
|
||||||
|
* 토큰 유효성 검증 및 미인증 시 리다이렉트
|
||||||
|
* @param {object} options - 옵션
|
||||||
|
* @param {string} options.redirectTo - 미인증 시 리다이렉트 경로 (기본: /admin)
|
||||||
|
* @param {boolean} options.required - 인증 필수 여부 (기본: true)
|
||||||
|
*/
|
||||||
|
export function useAdminAuth(options = {}) {
|
||||||
|
const { redirectTo = '/admin', required = true } = options;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { token, user, logout, isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
|
// 토큰 검증 쿼리
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['admin', 'auth'],
|
||||||
|
queryFn: authApi.verifyToken,
|
||||||
|
enabled: !!token,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 1000 * 60 * 5, // 5분 캐시
|
||||||
|
});
|
||||||
|
|
||||||
|
// 토큰 없거나 검증 실패 시 처리
|
||||||
|
// 리다이렉트는 DOM 조작이므로 useEffect 사용 허용
|
||||||
|
useEffect(() => {
|
||||||
|
if (required && (!token || isError)) {
|
||||||
|
logout();
|
||||||
|
navigate(redirectTo);
|
||||||
|
}
|
||||||
|
}, [token, isError, required, logout, navigate, redirectTo]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: data?.user || user,
|
||||||
|
isLoading: !token ? false : isLoading,
|
||||||
|
isAuthenticated: !!data?.valid || isAuthenticated,
|
||||||
|
isError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 페이지에서 사용하는 훅
|
||||||
|
* 이미 인증된 경우 리다이렉트
|
||||||
|
* @param {string} redirectTo - 인증된 경우 리다이렉트 경로
|
||||||
|
*/
|
||||||
|
export function useRedirectIfAuthenticated(redirectTo = '/admin/dashboard') {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { isAuthenticated, token } = useAuthStore();
|
||||||
|
|
||||||
|
// 토큰 검증
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'auth'],
|
||||||
|
queryFn: authApi.verifyToken,
|
||||||
|
enabled: !!token,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.valid) {
|
||||||
|
navigate(redirectTo);
|
||||||
|
}
|
||||||
|
}, [data, navigate, redirectTo]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoading: !!token && isLoading,
|
||||||
|
isAuthenticated: !!data?.valid,
|
||||||
|
};
|
||||||
|
}
|
||||||
104
frontend-temp/src/hooks/useCalendar.js
Normal file
104
frontend-temp/src/hooks/useCalendar.js
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
|
import { MIN_YEAR, WEEKDAYS, MONTH_NAMES } from '@/constants';
|
||||||
|
import { getTodayKST } from '@/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캘린더 훅
|
||||||
|
* 날짜 선택, 월 이동 등 캘린더 로직 제공
|
||||||
|
* @param {Date} initialDate - 초기 날짜
|
||||||
|
*/
|
||||||
|
export function useCalendar(initialDate = new Date()) {
|
||||||
|
const [currentDate, setCurrentDate] = useState(initialDate);
|
||||||
|
const [selectedDate, setSelectedDate] = useState(getTodayKST());
|
||||||
|
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
const month = currentDate.getMonth();
|
||||||
|
|
||||||
|
// 캘린더 데이터 계산
|
||||||
|
const calendarData = useMemo(() => {
|
||||||
|
const firstDay = new Date(year, month, 1).getDay();
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
const prevMonthDays = new Date(year, month, 0).getDate();
|
||||||
|
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
monthName: MONTH_NAMES[month],
|
||||||
|
firstDay,
|
||||||
|
daysInMonth,
|
||||||
|
prevMonthDays,
|
||||||
|
weekdays: WEEKDAYS,
|
||||||
|
};
|
||||||
|
}, [year, month]);
|
||||||
|
|
||||||
|
// 이전 월로 이동 가능 여부
|
||||||
|
const canGoPrevMonth = !(year === MIN_YEAR && month === 0);
|
||||||
|
|
||||||
|
// 선택 날짜 업데이트 헬퍼
|
||||||
|
const updateSelectedDate = useCallback((newDate) => {
|
||||||
|
const today = new Date();
|
||||||
|
if (
|
||||||
|
newDate.getFullYear() === today.getFullYear() &&
|
||||||
|
newDate.getMonth() === today.getMonth()
|
||||||
|
) {
|
||||||
|
setSelectedDate(getTodayKST());
|
||||||
|
} else {
|
||||||
|
const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`;
|
||||||
|
setSelectedDate(firstDay);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 이전 월로 이동
|
||||||
|
const goToPrevMonth = useCallback(() => {
|
||||||
|
if (!canGoPrevMonth) return;
|
||||||
|
const newDate = new Date(year, month - 1, 1);
|
||||||
|
setCurrentDate(newDate);
|
||||||
|
updateSelectedDate(newDate);
|
||||||
|
}, [year, month, canGoPrevMonth, updateSelectedDate]);
|
||||||
|
|
||||||
|
// 다음 월로 이동
|
||||||
|
const goToNextMonth = useCallback(() => {
|
||||||
|
const newDate = new Date(year, month + 1, 1);
|
||||||
|
setCurrentDate(newDate);
|
||||||
|
updateSelectedDate(newDate);
|
||||||
|
}, [year, month, updateSelectedDate]);
|
||||||
|
|
||||||
|
// 특정 월로 이동
|
||||||
|
const goToMonth = useCallback(
|
||||||
|
(newYear, newMonth) => {
|
||||||
|
const newDate = new Date(newYear, newMonth, 1);
|
||||||
|
setCurrentDate(newDate);
|
||||||
|
updateSelectedDate(newDate);
|
||||||
|
},
|
||||||
|
[updateSelectedDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 오늘로 이동
|
||||||
|
const goToToday = useCallback(() => {
|
||||||
|
const today = new Date();
|
||||||
|
setCurrentDate(today);
|
||||||
|
setSelectedDate(getTodayKST());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 날짜 선택
|
||||||
|
const selectDate = useCallback(
|
||||||
|
(day) => {
|
||||||
|
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
setSelectedDate(dateStr);
|
||||||
|
},
|
||||||
|
[year, month]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...calendarData,
|
||||||
|
currentDate,
|
||||||
|
selectedDate,
|
||||||
|
canGoPrevMonth,
|
||||||
|
goToPrevMonth,
|
||||||
|
goToNextMonth,
|
||||||
|
goToMonth,
|
||||||
|
goToToday,
|
||||||
|
selectDate,
|
||||||
|
setSelectedDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
41
frontend-temp/src/hooks/useMediaQuery.js
Normal file
41
frontend-temp/src/hooks/useMediaQuery.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미디어 쿼리 훅
|
||||||
|
* @param {string} query - CSS 미디어 쿼리 문자열
|
||||||
|
* @returns {boolean} 쿼리 매칭 여부
|
||||||
|
*/
|
||||||
|
export function useMediaQuery(query) {
|
||||||
|
const [matches, setMatches] = useState(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return window.matchMedia(query).matches;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// DOM 이벤트 리스너이므로 useEffect 사용 허용
|
||||||
|
useEffect(() => {
|
||||||
|
const mediaQuery = window.matchMedia(query);
|
||||||
|
const handler = (e) => setMatches(e.matches);
|
||||||
|
|
||||||
|
mediaQuery.addEventListener('change', handler);
|
||||||
|
return () => mediaQuery.removeEventListener('change', handler);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모바일 여부 확인 훅
|
||||||
|
*/
|
||||||
|
export const useIsMobile = () => useMediaQuery('(max-width: 768px)');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데스크탑 여부 확인 훅
|
||||||
|
*/
|
||||||
|
export const useIsDesktop = () => useMediaQuery('(min-width: 769px)');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 태블릿 여부 확인 훅
|
||||||
|
*/
|
||||||
|
export const useIsTablet = () => useMediaQuery('(min-width: 769px) and (max-width: 1024px)');
|
||||||
49
frontend-temp/src/hooks/useScheduleData.js
Normal file
49
frontend-temp/src/hooks/useScheduleData.js
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { scheduleApi } from '@/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 목록 조회 훅 (월별)
|
||||||
|
* @param {number} year - 년도
|
||||||
|
* @param {number} month - 월 (1-12)
|
||||||
|
*/
|
||||||
|
export function useScheduleData(year, month) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['schedules', year, month],
|
||||||
|
queryFn: () => scheduleApi.getSchedules(year, month),
|
||||||
|
enabled: !!year && !!month,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 상세 조회 훅
|
||||||
|
* @param {number} id - 스케줄 ID
|
||||||
|
*/
|
||||||
|
export function useScheduleDetail(id) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['schedule', id],
|
||||||
|
queryFn: () => scheduleApi.getSchedule(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다가오는 스케줄 조회 훅
|
||||||
|
* @param {number} limit - 조회 개수
|
||||||
|
*/
|
||||||
|
export function useUpcomingSchedules(limit = 3) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['schedules', 'upcoming', limit],
|
||||||
|
queryFn: () => scheduleApi.getUpcomingSchedules(limit),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 목록 조회 훅
|
||||||
|
*/
|
||||||
|
export function useCategories() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['categories'],
|
||||||
|
queryFn: scheduleApi.getCategories,
|
||||||
|
staleTime: 1000 * 60 * 10, // 10분 캐시
|
||||||
|
});
|
||||||
|
}
|
||||||
71
frontend-temp/src/hooks/useScheduleFiltering.js
Normal file
71
frontend-temp/src/hooks/useScheduleFiltering.js
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { formatDate } from '@/utils';
|
||||||
|
import { getCategoryId, getScheduleDate, isBirthdaySchedule } from '@/utils/schedule';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 필터링 훅
|
||||||
|
* @param {Array} schedules - 스케줄 배열
|
||||||
|
* @param {object} options - 필터 옵션
|
||||||
|
* @param {number[]} options.categoryIds - 선택된 카테고리 ID 배열
|
||||||
|
* @param {string} options.selectedDate - 선택된 날짜 (YYYY-MM-DD)
|
||||||
|
* @param {boolean} options.birthdayFirst - 생일 우선 정렬 여부
|
||||||
|
* @returns {Array} 필터링된 스케줄 배열
|
||||||
|
*/
|
||||||
|
export function useScheduleFiltering(schedules, options = {}) {
|
||||||
|
const {
|
||||||
|
categoryIds = [],
|
||||||
|
selectedDate = null,
|
||||||
|
birthdayFirst = true,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!schedules || schedules.length === 0) return [];
|
||||||
|
|
||||||
|
let result = schedules.filter((schedule) => {
|
||||||
|
// 카테고리 필터
|
||||||
|
if (categoryIds.length > 0) {
|
||||||
|
const catId = getCategoryId(schedule);
|
||||||
|
if (!categoryIds.includes(catId)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜 필터
|
||||||
|
if (selectedDate) {
|
||||||
|
const scheduleDate = getScheduleDate(schedule);
|
||||||
|
if (scheduleDate !== selectedDate) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 생일 우선 정렬
|
||||||
|
if (birthdayFirst) {
|
||||||
|
result = [...result].sort((a, b) => {
|
||||||
|
const aIsBirthday = isBirthdaySchedule(a);
|
||||||
|
const bIsBirthday = isBirthdaySchedule(b);
|
||||||
|
if (aIsBirthday && !bIsBirthday) return -1;
|
||||||
|
if (!aIsBirthday && bIsBirthday) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [schedules, categoryIds, selectedDate, birthdayFirst]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리별 개수 계산 훅
|
||||||
|
* @param {Array} schedules - 스케줄 배열
|
||||||
|
* @returns {object} 카테고리 ID별 개수 객체
|
||||||
|
*/
|
||||||
|
export function useCategoryCounts(schedules) {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!schedules || schedules.length === 0) return {};
|
||||||
|
|
||||||
|
const counts = {};
|
||||||
|
for (const schedule of schedules) {
|
||||||
|
const categoryId = getCategoryId(schedule);
|
||||||
|
counts[categoryId] = (counts[categoryId] || 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [schedules]);
|
||||||
|
}
|
||||||
75
frontend-temp/src/hooks/useScheduleSearch.js
Normal file
75
frontend-temp/src/hooks/useScheduleSearch.js
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import { fetchApi } from '@/api';
|
||||||
|
import { SEARCH_LIMIT } from '@/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 검색 훅
|
||||||
|
* 무한 스크롤, 검색어 추천 지원
|
||||||
|
*/
|
||||||
|
export function useScheduleSearch() {
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [isSearchMode, setIsSearchMode] = useState(false);
|
||||||
|
|
||||||
|
// 검색 결과 (무한 스크롤)
|
||||||
|
const searchQuery = useInfiniteQuery({
|
||||||
|
queryKey: ['scheduleSearch', searchTerm],
|
||||||
|
queryFn: async ({ pageParam = 0, signal }) => {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/schedules?search=${encodeURIComponent(searchTerm)}&offset=${pageParam}&limit=${SEARCH_LIMIT}`,
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error('Search failed');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
return lastPage.hasMore
|
||||||
|
? lastPage.offset + lastPage.schedules.length
|
||||||
|
: undefined;
|
||||||
|
},
|
||||||
|
enabled: !!searchTerm && isSearchMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 검색 결과 평탄화
|
||||||
|
const searchResults = useMemo(() => {
|
||||||
|
return searchQuery.data?.pages?.flatMap((page) => page.schedules) || [];
|
||||||
|
}, [searchQuery.data]);
|
||||||
|
|
||||||
|
const searchTotal = searchQuery.data?.pages?.[0]?.total || 0;
|
||||||
|
|
||||||
|
// 검색 실행
|
||||||
|
const executeSearch = () => {
|
||||||
|
if (searchInput.trim()) {
|
||||||
|
setSearchTerm(searchInput.trim());
|
||||||
|
setIsSearchMode(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 검색 초기화
|
||||||
|
const clearSearch = () => {
|
||||||
|
setSearchInput('');
|
||||||
|
setSearchTerm('');
|
||||||
|
setIsSearchMode(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 상태
|
||||||
|
searchInput,
|
||||||
|
searchTerm,
|
||||||
|
isSearchMode,
|
||||||
|
searchResults,
|
||||||
|
searchTotal,
|
||||||
|
isLoading: searchQuery.isLoading,
|
||||||
|
isFetching: searchQuery.isFetching,
|
||||||
|
hasNextPage: searchQuery.hasNextPage,
|
||||||
|
isFetchingNextPage: searchQuery.isFetchingNextPage,
|
||||||
|
|
||||||
|
// 액션
|
||||||
|
setSearchInput,
|
||||||
|
setIsSearchMode,
|
||||||
|
executeSearch,
|
||||||
|
clearSearch,
|
||||||
|
fetchNextPage: searchQuery.fetchNextPage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -32,3 +32,15 @@ export {
|
||||||
formatDuration,
|
formatDuration,
|
||||||
truncateText,
|
truncateText,
|
||||||
} from './format';
|
} from './format';
|
||||||
|
|
||||||
|
// 스케줄 관련
|
||||||
|
export {
|
||||||
|
getCategoryId,
|
||||||
|
getCategoryInfo,
|
||||||
|
getScheduleDate,
|
||||||
|
getScheduleTime,
|
||||||
|
getMemberList,
|
||||||
|
isBirthdaySchedule,
|
||||||
|
groupSchedulesByDate,
|
||||||
|
countByCategory,
|
||||||
|
} from './schedule';
|
||||||
|
|
|
||||||
122
frontend-temp/src/utils/schedule.js
Normal file
122
frontend-temp/src/utils/schedule.js
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
/**
|
||||||
|
* 스케줄 관련 유틸리티 함수
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄에서 카테고리 ID 추출
|
||||||
|
* 검색 결과와 일반 데이터의 형식 차이를 처리
|
||||||
|
* @param {object} schedule - 스케줄 객체
|
||||||
|
* @returns {number|null} 카테고리 ID
|
||||||
|
*/
|
||||||
|
export function getCategoryId(schedule) {
|
||||||
|
return schedule.category?.id ?? schedule.category_id ?? schedule.categoryId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄에서 카테고리 정보 추출
|
||||||
|
* @param {object} schedule - 스케줄 객체
|
||||||
|
* @returns {{ id: number, name: string, color: string }}
|
||||||
|
*/
|
||||||
|
export function getCategoryInfo(schedule) {
|
||||||
|
return {
|
||||||
|
id: getCategoryId(schedule),
|
||||||
|
name: schedule.category?.name ?? schedule.category_name ?? schedule.categoryName ?? '미분류',
|
||||||
|
color: schedule.category?.color ?? schedule.category_color ?? schedule.categoryColor ?? '#9CA3AF',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄에서 날짜 추출
|
||||||
|
* datetime 형식과 date 형식 모두 처리
|
||||||
|
* @param {object} schedule - 스케줄 객체
|
||||||
|
* @returns {string} YYYY-MM-DD 형식 날짜
|
||||||
|
*/
|
||||||
|
export function getScheduleDate(schedule) {
|
||||||
|
const datetime = schedule.datetime || schedule.date;
|
||||||
|
if (!datetime) return '';
|
||||||
|
return datetime.split(' ')[0].split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄에서 시간 추출
|
||||||
|
* @param {object} schedule - 스케줄 객체
|
||||||
|
* @returns {string|null} HH:mm 형식 시간 또는 null
|
||||||
|
*/
|
||||||
|
export function getScheduleTime(schedule) {
|
||||||
|
if (schedule.time) {
|
||||||
|
return schedule.time.slice(0, 5);
|
||||||
|
}
|
||||||
|
const datetime = schedule.datetime;
|
||||||
|
if (datetime && (datetime.includes(' ') || datetime.includes('T'))) {
|
||||||
|
const timePart = datetime.includes(' ')
|
||||||
|
? datetime.split(' ')[1]
|
||||||
|
: datetime.split('T')[1];
|
||||||
|
return timePart?.slice(0, 5) || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄에서 멤버 목록 추출
|
||||||
|
* 다양한 형식 처리 (문자열 배열, 객체 배열)
|
||||||
|
* @param {object} schedule - 스케줄 객체
|
||||||
|
* @returns {Array<{ id: number, name: string }>}
|
||||||
|
*/
|
||||||
|
export function getMemberList(schedule) {
|
||||||
|
const members = schedule.members || [];
|
||||||
|
|
||||||
|
if (members.length === 0) return [];
|
||||||
|
|
||||||
|
// 문자열 배열인 경우 (검색 결과)
|
||||||
|
if (typeof members[0] === 'string') {
|
||||||
|
return members.map((name, idx) => ({ id: idx, name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 객체 배열인 경우
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생일 스케줄인지 확인
|
||||||
|
* @param {object} schedule - 스케줄 객체
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isBirthdaySchedule(schedule) {
|
||||||
|
const title = schedule.title || '';
|
||||||
|
return title.includes('생일') || title.includes('birthday');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜별로 스케줄 그룹화
|
||||||
|
* @param {Array} schedules - 스케줄 배열
|
||||||
|
* @returns {Map<string, Array>} 날짜별 그룹화된 맵
|
||||||
|
*/
|
||||||
|
export function groupSchedulesByDate(schedules) {
|
||||||
|
const groups = new Map();
|
||||||
|
|
||||||
|
for (const schedule of schedules) {
|
||||||
|
const date = getScheduleDate(schedule);
|
||||||
|
if (!groups.has(date)) {
|
||||||
|
groups.set(date, []);
|
||||||
|
}
|
||||||
|
groups.get(date).push(schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리별 스케줄 수 계산
|
||||||
|
* @param {Array} schedules - 스케줄 배열
|
||||||
|
* @returns {Map<number, number>} 카테고리 ID별 개수
|
||||||
|
*/
|
||||||
|
export function countByCategory(schedules) {
|
||||||
|
const counts = new Map();
|
||||||
|
|
||||||
|
for (const schedule of schedules) {
|
||||||
|
const categoryId = getCategoryId(schedule);
|
||||||
|
counts.set(categoryId, (counts.get(categoryId) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue