diff --git a/frontend-temp/src/App.jsx b/frontend-temp/src/App.jsx
index fb2c379..bf1e760 100644
--- a/frontend-temp/src/App.jsx
+++ b/frontend-temp/src/App.jsx
@@ -1,25 +1,17 @@
-import { BrowserRouter, Routes, Route } from "react-router-dom";
+import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
import { cn, getTodayKST, formatFullDate } from "@/utils";
import { useUIStore } from "@/stores";
import { useIsMobile, useCategories, useScheduleData } from "@/hooks";
-import { ErrorBoundary, Loading, ToastContainer, ScheduleCard } from "@/components";
+import { ErrorBoundary, Loading, ToastContainer, ScheduleCard, Layout } from "@/components";
+import { Schedule } from "@/pages";
/**
- * 프로미스나인 팬사이트 메인 앱
- *
- * Phase 6: 공통 컴포넌트 완료
- * - ErrorBoundary: 에러 경계
- * - Loading, FullPageLoading, InlineLoading: 로딩 스피너
- * - Toast, ToastContainer: 토스트 알림
- * - Lightbox: 이미지 라이트박스
- * - ScheduleCard: 스케줄 카드 (list/card/compact)
+ * 홈 페이지 (임시)
*/
-function App() {
+function Home() {
const today = getTodayKST();
const isMobile = useIsMobile();
const { showSuccess, showError } = useUIStore();
-
- // 커스텀 훅 사용
const { data: categories, isLoading: categoriesLoading } = useCategories();
const currentDate = new Date();
const { data: schedules, isLoading: schedulesLoading } = useScheduleData(
@@ -27,115 +19,171 @@ function App() {
currentDate.getMonth() + 1
);
+ return (
+
+
+
+
+ fromis_9 Frontend Refactoring
+
+
Phase 7 진행 중 - 스케줄 페이지
+
+ 디바이스: {isMobile ? "모바일" : "PC"}
+
+
+
+
+
오늘: {formatFullDate(today)}
+
+
+
카테고리 ({categories?.length || 0}개)
+ {categoriesLoading ? (
+
+ ) : (
+
+ {categories?.map((c) => (
+
+ {c.name}
+
+ ))}
+
+ )}
+
+
+
+
토스트 테스트
+
+
+
+
+
+
+
+
페이지 이동
+
+
+ 일정 페이지
+
+
+
+
+
+ {/* ScheduleCard 컴포넌트 테스트 */}
+ {schedulesLoading ? (
+
+
+
+ ) : schedules?.length > 0 ? (
+ <>
+ {/* Public Variant (공개 페이지용) */}
+
+
variant="public" (공개 페이지용)
+
+ {schedules.slice(0, 2).map((schedule) => (
+ showSuccess(`${schedule.title} 클릭`)}
+ />
+ ))}
+
+
+
+ {/* Admin Variant (관리자 페이지용) */}
+
+
variant="admin" (관리자 페이지용)
+
+ {schedules.slice(0, 2).map((schedule) => (
+ showSuccess(`${schedule.title} 클릭`)}
+ onEdit={(s) => showSuccess(`${s.title} 수정`)}
+ onDelete={(s) => showError(`${s.title} 삭제`)}
+ />
+ ))}
+
+
+ >
+ ) : (
+
+ )}
+
+
+ );
+}
+
+/**
+ * 프로미스나인 팬사이트 메인 앱
+ *
+ * Phase 7: 스케줄 페이지 마이그레이션
+ * - Layout 컴포넌트 (PC/Mobile 통합)
+ * - Header, Footer, MobileNav 컴포넌트
+ * - Schedule 페이지 (기본 구조)
+ */
+function App() {
return (
+ {/* 홈 */}
-
-
-
- fromis_9 Frontend Refactoring
-
-
Phase 6 완료 - 공통 컴포넌트
-
- 디바이스: {isMobile ? "모바일" : "PC"}
-
-
-
-
-
오늘: {formatFullDate(today)}
-
-
-
카테고리 ({categories?.length || 0}개)
- {categoriesLoading ? (
-
- ) : (
-
- {categories?.map((c) => (
-
- {c.name}
-
- ))}
-
- )}
-
-
-
-
토스트 테스트
-
-
-
-
-
-
-
- {/* ScheduleCard 컴포넌트 테스트 */}
- {schedulesLoading ? (
-
-
-
- ) : schedules?.length > 0 ? (
- <>
- {/* Public Variant (공개 페이지용) */}
-
-
variant="public" (공개 페이지용)
-
- {schedules.slice(0, 2).map((schedule) => (
- showSuccess(`${schedule.title} 클릭`)}
- />
- ))}
-
-
-
- {/* Admin Variant (관리자 페이지용) */}
-
-
variant="admin" (관리자 페이지용)
-
- {schedules.slice(0, 2).map((schedule) => (
- showSuccess(`${schedule.title} 클릭`)}
- onEdit={(s) => showSuccess(`${s.title} 수정`)}
- onDelete={(s) => showError(`${s.title} 삭제`)}
- />
- ))}
-
-
- >
- ) : (
-
- )}
-
-
- {/* 토스트 컨테이너 */}
+
+
-
+
+ }
+ />
+
+ {/* 스케줄 */}
+
+
+
+
+ }
+ />
+
+ {/* 임시 페이지들 */}
+
+ 멤버 페이지 (준비 중)
+
+ }
+ />
+
+ 앨범 페이지 (준비 중)
+
}
/>
diff --git a/frontend-temp/src/components/index.js b/frontend-temp/src/components/index.js
index 07f0599..c799ef5 100644
--- a/frontend-temp/src/components/index.js
+++ b/frontend-temp/src/components/index.js
@@ -5,5 +5,8 @@
// 공통 컴포넌트
export * from './common';
+// 레이아웃 컴포넌트
+export * from './layout';
+
// 스케줄 컴포넌트
export * from './schedule';
diff --git a/frontend-temp/src/components/layout/Footer.jsx b/frontend-temp/src/components/layout/Footer.jsx
new file mode 100644
index 0000000..afa25fc
--- /dev/null
+++ b/frontend-temp/src/components/layout/Footer.jsx
@@ -0,0 +1,18 @@
+/**
+ * PC용 푸터 컴포넌트
+ */
+function Footer() {
+ const currentYear = new Date().getFullYear();
+
+ return (
+
+ );
+}
+
+export default Footer;
diff --git a/frontend-temp/src/components/layout/Header.jsx b/frontend-temp/src/components/layout/Header.jsx
new file mode 100644
index 0000000..40dc75f
--- /dev/null
+++ b/frontend-temp/src/components/layout/Header.jsx
@@ -0,0 +1,77 @@
+import { NavLink } from 'react-router-dom';
+import { Instagram, Youtube } from 'lucide-react';
+import { SOCIAL_LINKS, NAV_ITEMS } from '@/constants';
+
+/**
+ * X (Twitter) 아이콘 컴포넌트
+ */
+const XIcon = ({ size = 20 }) => (
+
+);
+
+/**
+ * PC용 헤더 컴포넌트
+ */
+function Header() {
+ return (
+
+
+
+ {/* 로고 */}
+
+ fromis_9
+
+
+ {/* 네비게이션 */}
+
+
+ {/* SNS 링크 */}
+
+
+
+
+ );
+}
+
+export default Header;
diff --git a/frontend-temp/src/components/layout/Layout.jsx b/frontend-temp/src/components/layout/Layout.jsx
new file mode 100644
index 0000000..6a4e683
--- /dev/null
+++ b/frontend-temp/src/components/layout/Layout.jsx
@@ -0,0 +1,104 @@
+import { useEffect } from 'react';
+import { useLocation, NavLink } from 'react-router-dom';
+import { useIsMobile } from '@/hooks';
+import Header from './Header';
+import Footer from './Footer';
+import MobileNav from './MobileNav';
+
+/**
+ * 모바일 헤더 컴포넌트
+ */
+function MobileHeader({ title, noShadow = false }) {
+ return (
+
+ );
+}
+
+/**
+ * 통합 레이아웃 컴포넌트
+ * PC/Mobile 자동 분기
+ *
+ * @param {object} props
+ * @param {React.ReactNode} props.children - 페이지 컨텐츠
+ * @param {string} props.pageTitle - 모바일 헤더 타이틀 (없으면 fromis_9)
+ * @param {boolean} props.hideHeader - 헤더 숨김 여부 (자체 헤더가 있는 페이지)
+ * @param {boolean} props.hideFooter - 푸터 숨김 여부
+ * @param {boolean} props.useCustomLayout - 자체 레이아웃 사용 (모바일)
+ * @param {boolean} props.noShadow - 모바일 헤더 그림자 숨김
+ */
+function Layout({
+ children,
+ pageTitle,
+ hideHeader = false,
+ hideFooter = false,
+ useCustomLayout = false,
+ noShadow = false,
+}) {
+ const isMobile = useIsMobile();
+ const location = useLocation();
+
+ // 모바일 레이아웃 활성화 (body 스크롤 방지)
+ useEffect(() => {
+ if (isMobile) {
+ document.documentElement.classList.add('mobile-layout');
+ return () => {
+ document.documentElement.classList.remove('mobile-layout');
+ };
+ }
+ }, [isMobile]);
+
+ // PC 레이아웃
+ if (!isMobile) {
+ // Footer 숨김 페이지 (화면 고정 레이아웃)
+ const hideFooterPages = ['/schedule', '/members', '/album'];
+ const shouldHideFooter = hideFooter || hideFooterPages.some(
+ (path) => location.pathname === path || location.pathname.startsWith(path + '/')
+ );
+
+ // 일정 페이지에서는 스크롤바도 숨김 (내부에서 자체 스크롤 처리)
+ const isSchedulePage = location.pathname === '/schedule';
+
+ return (
+
+ {!hideHeader &&
}
+
+
+ {children}
+
+ {!shouldHideFooter && }
+
+
+ );
+ }
+
+ // 모바일 레이아웃 - 자체 레이아웃 사용 시 (Schedule 페이지 등)
+ if (useCustomLayout) {
+ return (
+
+ {children}
+
+
+ );
+ }
+
+ // 모바일 레이아웃 - 기본
+ return (
+
+ {!hideHeader && }
+ {children}
+
+
+ );
+}
+
+export default Layout;
diff --git a/frontend-temp/src/components/layout/MobileNav.jsx b/frontend-temp/src/components/layout/MobileNav.jsx
new file mode 100644
index 0000000..dab72e3
--- /dev/null
+++ b/frontend-temp/src/components/layout/MobileNav.jsx
@@ -0,0 +1,44 @@
+import { NavLink, useLocation } from 'react-router-dom';
+import { Home, Users, Disc3, Calendar } from 'lucide-react';
+
+/**
+ * 모바일 하단 네비게이션 컴포넌트
+ */
+function MobileNav() {
+ const location = useLocation();
+
+ const navItems = [
+ { path: '/', label: '홈', icon: Home },
+ { path: '/members', label: '멤버', icon: Users },
+ { path: '/album', label: '앨범', icon: Disc3 },
+ { path: '/schedule', label: '일정', icon: Calendar },
+ ];
+
+ return (
+
+ );
+}
+
+export default MobileNav;
diff --git a/frontend-temp/src/components/layout/index.js b/frontend-temp/src/components/layout/index.js
new file mode 100644
index 0000000..f43f04a
--- /dev/null
+++ b/frontend-temp/src/components/layout/index.js
@@ -0,0 +1,7 @@
+/**
+ * 레이아웃 컴포넌트 export
+ */
+export { default as Layout } from './Layout';
+export { default as Header } from './Header';
+export { default as Footer } from './Footer';
+export { default as MobileNav } from './MobileNav';
diff --git a/frontend-temp/src/constants/index.js b/frontend-temp/src/constants/index.js
index 7b52724..53824e1 100644
--- a/frontend-temp/src/constants/index.js
+++ b/frontend-temp/src/constants/index.js
@@ -57,3 +57,11 @@ export const MONTH_NAMES = [
'1월', '2월', '3월', '4월', '5월', '6월',
'7월', '8월', '9월', '10월', '11월', '12월',
];
+
+/** 네비게이션 메뉴 항목 */
+export const NAV_ITEMS = [
+ { path: '/', label: '홈' },
+ { path: '/members', label: '멤버' },
+ { path: '/album', label: '앨범' },
+ { path: '/schedule', label: '일정' },
+];
diff --git a/frontend-temp/src/pages/index.js b/frontend-temp/src/pages/index.js
new file mode 100644
index 0000000..3b7c75b
--- /dev/null
+++ b/frontend-temp/src/pages/index.js
@@ -0,0 +1,4 @@
+/**
+ * 페이지 export
+ */
+export * from './schedule';
diff --git a/frontend-temp/src/pages/schedule/Schedule.jsx b/frontend-temp/src/pages/schedule/Schedule.jsx
new file mode 100644
index 0000000..874377d
--- /dev/null
+++ b/frontend-temp/src/pages/schedule/Schedule.jsx
@@ -0,0 +1,329 @@
+import { useState, useMemo, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { ChevronLeft, ChevronRight, Search } from 'lucide-react';
+import { useIsMobile, useScheduleData, useCategories, useCalendar } from '@/hooks';
+import { useScheduleStore } from '@/stores';
+import { Loading, ScheduleCard } from '@/components';
+import { cn, getTodayKST, decodeHtmlEntities } from '@/utils';
+import { WEEKDAYS, MIN_YEAR } from '@/constants';
+
+/**
+ * PC 캘린더 컴포넌트
+ */
+function PCCalendar({ selectedDate, schedules, categories, onSelectDate, onMonthChange }) {
+ const { days, year, month, canGoPrev } = useCalendar(selectedDate);
+
+ // 날짜별 일정 맵
+ const scheduleDates = useMemo(() => {
+ const dateMap = {};
+ schedules?.forEach((schedule) => {
+ const date = schedule.date?.split('T')[0];
+ if (!dateMap[date]) dateMap[date] = [];
+ const cat = categories?.find((c) => c.id === (schedule.category_id || schedule.category?.id));
+ dateMap[date].push(cat?.color || '#6b7280');
+ });
+ return dateMap;
+ }, [schedules, categories]);
+
+ const isToday = (date) => {
+ const today = new Date();
+ return (
+ date.getDate() === today.getDate() &&
+ date.getMonth() === today.getMonth() &&
+ date.getFullYear() === today.getFullYear()
+ );
+ };
+
+ const isSelected = (date) => {
+ const sel = new Date(selectedDate);
+ return (
+ date.getDate() === sel.getDate() &&
+ date.getMonth() === sel.getMonth() &&
+ date.getFullYear() === sel.getFullYear()
+ );
+ };
+
+ const formatDateStr = (date) => {
+ const y = date.getFullYear();
+ const m = String(date.getMonth() + 1).padStart(2, '0');
+ const d = String(date.getDate()).padStart(2, '0');
+ return `${y}-${m}-${d}`;
+ };
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+ {year}년 {month + 1}월
+
+
+
+
+ {/* 요일 헤더 */}
+
+ {WEEKDAYS.map((day, i) => (
+
+ {day}
+
+ ))}
+
+
+ {/* 날짜 그리드 */}
+
+ {days.map((item, index) => {
+ const dayOfWeek = index % 7;
+ const dateStr = formatDateStr(item.date);
+ const colors = scheduleDates[dateStr] || [];
+
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
+/**
+ * 스케줄 페이지 (PC/Mobile 통합)
+ */
+function Schedule() {
+ const navigate = useNavigate();
+ const isMobile = useIsMobile();
+
+ // Zustand store
+ const { currentDate, setCurrentDate, selectedDate, setSelectedDate } = useScheduleStore();
+
+ // 초기값 설정
+ const today = getTodayKST();
+ const actualSelectedDate = selectedDate || today;
+
+ // 데이터 로드
+ const year = currentDate.getFullYear();
+ const month = currentDate.getMonth() + 1;
+ const { data: schedules, isLoading: schedulesLoading } = useScheduleData(year, month);
+ const { data: categories } = useCategories();
+
+ // 선택된 날짜의 일정
+ const selectedDateSchedules = useMemo(() => {
+ if (!schedules) return [];
+ const sel = new Date(actualSelectedDate);
+ const dateStr = `${sel.getFullYear()}-${String(sel.getMonth() + 1).padStart(2, '0')}-${String(sel.getDate()).padStart(2, '0')}`;
+ return schedules
+ .filter((s) => s.date?.split('T')[0] === dateStr)
+ .sort((a, b) => {
+ const aIsBirthday = a.is_birthday || String(a.id).startsWith('birthday-');
+ const bIsBirthday = b.is_birthday || String(b.id).startsWith('birthday-');
+ if (aIsBirthday && !bIsBirthday) return -1;
+ if (!aIsBirthday && bIsBirthday) return 1;
+ return 0;
+ });
+ }, [schedules, actualSelectedDate]);
+
+ // 월 변경
+ const changeMonth = (delta) => {
+ const newDate = new Date(currentDate);
+ newDate.setMonth(newDate.getMonth() + delta);
+
+ // 2017년 1월 이전으로 이동 불가
+ if (newDate.getFullYear() < MIN_YEAR || (newDate.getFullYear() === MIN_YEAR && newDate.getMonth() < 0)) {
+ return;
+ }
+
+ setCurrentDate(newDate);
+
+ // 이번 달이면 오늘 날짜, 다른 달이면 1일 선택
+ const now = new Date();
+ if (newDate.getFullYear() === now.getFullYear() && newDate.getMonth() === now.getMonth()) {
+ setSelectedDate(getTodayKST());
+ } else {
+ const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`;
+ setSelectedDate(firstDay);
+ }
+ };
+
+ // 날짜 선택
+ const handleSelectDate = (date) => {
+ const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
+ setSelectedDate(dateStr);
+
+ // 월이 다르면 currentDate도 변경
+ if (date.getMonth() !== currentDate.getMonth() || date.getFullYear() !== currentDate.getFullYear()) {
+ setCurrentDate(date);
+ }
+ };
+
+ // PC 레이아웃
+ if (!isMobile) {
+ return (
+
+ {/* 좌측: 캘린더 */}
+
+
+ {/* 우측: 일정 목록 */}
+
+ {/* 헤더 */}
+
+
+
+ {new Date(actualSelectedDate).getMonth() + 1}월{' '}
+ {new Date(actualSelectedDate).getDate()}일{' '}
+ {WEEKDAYS[new Date(actualSelectedDate).getDay()]}요일
+
+
+ {selectedDateSchedules.length}개의 일정
+
+
+
+
+
+ {/* 일정 목록 */}
+
+ {schedulesLoading ? (
+
+
+
+ ) : selectedDateSchedules.length === 0 ? (
+
+ {new Date(actualSelectedDate).getMonth() + 1}월{' '}
+ {new Date(actualSelectedDate).getDate()}일 일정이 없습니다
+
+ ) : (
+
+ {selectedDateSchedules.map((schedule) => (
+ navigate(`/schedule/${schedule.id}`)}
+ />
+ ))}
+
+ )}
+
+
+
+ );
+ }
+
+ // 모바일 레이아웃 (간소화된 버전)
+ return (
+ <>
+ {/* 모바일 툴바 */}
+
+
+
+
+ {currentDate.getFullYear()}년 {currentDate.getMonth() + 1}월
+
+
+
+
+
+
+
+
+ {/* 모바일 컨텐츠 */}
+
+
+ {schedulesLoading ? (
+
+
+
+ ) : selectedDateSchedules.length === 0 ? (
+
+ {new Date(actualSelectedDate).getMonth() + 1}월{' '}
+ {new Date(actualSelectedDate).getDate()}일 일정이 없습니다
+
+ ) : (
+
+ {selectedDateSchedules.map((schedule) => (
+ navigate(`/schedule/${schedule.id}`)}
+ />
+ ))}
+
+ )}
+
+
+ >
+ );
+}
+
+export default Schedule;
diff --git a/frontend-temp/src/pages/schedule/index.js b/frontend-temp/src/pages/schedule/index.js
new file mode 100644
index 0000000..b11430b
--- /dev/null
+++ b/frontend-temp/src/pages/schedule/index.js
@@ -0,0 +1,4 @@
+/**
+ * 스케줄 페이지 export
+ */
+export { default as Schedule } from './Schedule';