)}
diff --git a/src/components/schedule/ScheduleTabs.jsx b/src/components/schedule/ScheduleTabs.jsx
index 9dd3725..a98a188 100644
--- a/src/components/schedule/ScheduleTabs.jsx
+++ b/src/components/schedule/ScheduleTabs.jsx
@@ -1,44 +1,24 @@
-import { VIEW_MODE } from '@/constants/schedule';
+const TABS = [
+ { value: 'calendar', label: '캘린더' },
+ { value: 'all', label: '전체일정' },
+];
-function ScheduleTabs({ viewMode, onChange, className = '' }) {
+function ScheduleTabs({ viewMode, onChange, className }) {
return (
-
-
-
+
+ {TABS.map((tab) => (
+
+ ))}
);
}
diff --git a/src/constants/routes.js b/src/constants/routes.js
index d205323..4591cbb 100644
--- a/src/constants/routes.js
+++ b/src/constants/routes.js
@@ -12,6 +12,8 @@ export const ROUTES = {
ADMIN_RECRUIT: '/admin/Memberpage',
// 관리자 로그인: URL 직접 입력으로만 접근. 방문자 내비에 링크하지 않는다.
ADMIN_LOGIN: '/admin/login',
+ // 관리자 캘린더: 연간 일정 관리 페이지
+ ADMIN_SCHEDULE: '/admin/schedule',
// 명부 관리: 외부에 노출하지 않는 admin 히든 경로 하위에 둔다. 방문자 내비에 링크하지 않는다.
ADMIN_MEMBER: '/admin/members',
// 부원 등록: 명부 관리에서 '부원 등록' 클릭 시 이동하는 폼 화면.
diff --git a/src/constants/schedule.js b/src/constants/schedule.js
index 31db516..d47f5ae 100644
--- a/src/constants/schedule.js
+++ b/src/constants/schedule.js
@@ -1,28 +1,6 @@
-export const SCHEDULES = [
- { startDate: '2026-03-01', endDate: '2026-03-01', title: '삼일절' },
- { startDate: '2026-03-02', endDate: '2026-03-02', title: '삼일절 대체휴일' },
- { startDate: '2026-03-03', endDate: '2026-03-03', title: '[2026학년도 1학기] 개강' },
- { startDate: '2026-03-06', endDate: '2026-03-09', title: '수강신청 정정' },
- {
- startDate: '2026-03-16',
- endDate: '2026-03-16',
- title: '졸업보류, 졸업유예 등록',
- status: '완료',
- },
- { startDate: '2026-04-01', endDate: '2026-04-01', title: '학기개시 후 30일차' },
- { startDate: '2026-04-15', endDate: '2026-04-16', title: '삽입절' },
- { startDate: '2026-04-21', endDate: '2026-04-21', title: 'Expo' },
- { startDate: '2026-04-30', endDate: '2026-04-30', title: '사이드 프로젝트 발표' },
- { startDate: '2026-04-30', endDate: '2026-04-30', title: 'Expo', status: '완료' },
- { startDate: '2026-05-05', endDate: '2026-05-05', title: '어린이날' },
- { startDate: '2026-05-11', endDate: '2026-05-11', title: '동아리 정기 회의' },
- { startDate: '2026-05-15', endDate: '2026-05-15', title: '프로젝트 중간 점검' },
- { startDate: '2026-05-22', endDate: '2026-05-22', title: '기술 세미나' },
- { startDate: '2026-05-29', endDate: '2026-05-29', title: '월간 회고' },
-];
-
export const WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
-export const MONTH_OPTIONS = Array.from({ length: 12 }, (_, index) => index);
+
+export const MONTH_OPTIONS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
export const CLUB_START_YEAR = 2022;
export const VIEW_MODE = {
@@ -34,3 +12,5 @@ export const DROPDOWN_TYPE = {
MONTH: 'month',
YEAR: 'year',
};
+
+export const SCHEDULES = [];
diff --git a/src/pages/admin/AdminSchedulePage.jsx b/src/pages/admin/AdminSchedulePage.jsx
new file mode 100644
index 0000000..6679bf4
--- /dev/null
+++ b/src/pages/admin/AdminSchedulePage.jsx
@@ -0,0 +1,298 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+
+import Header from '@/components/layout/Header';
+import AdminCalendarView from '@/components/schedule/AdminCalendarView';
+import AllScheduleView from '@/components/schedule/AllScheduleView';
+import ScheduleStyles from '@/styles/ScheduleStyles';
+import { CLUB_START_YEAR, DROPDOWN_TYPE, SCHEDULES, VIEW_MODE } from '@/constants/schedule';
+import {
+ formatAdminDate,
+ formatDateKey,
+ getCalendarDates,
+ getScheduleDateKeys,
+ groupSchedulesByMonth,
+ isScheduleInMonth,
+ parseLocalDate,
+ sortByStartDate,
+ updateScrollThumb,
+} from '@/utils/schedule';
+
+function AdminSchedulePage() {
+ const monthListRef = useRef(null);
+ const monthTrackRef = useRef(null);
+ const monthThumbRef = useRef(null);
+ const yearListRef = useRef(null);
+ const yearTrackRef = useRef(null);
+ const yearThumbRef = useRef(null);
+ const allScheduleListRef = useRef(null);
+ const allScheduleTrackRef = useRef(null);
+ const allScheduleThumbRef = useRef(null);
+
+ const today = useMemo(() => new Date(), []);
+ const todayKey = useMemo(() => formatDateKey(today), [today]);
+ const currentYear = today.getFullYear();
+ const yearOptions = useMemo(
+ () =>
+ Array.from(
+ { length: currentYear - CLUB_START_YEAR + 2 },
+ (_, index) => CLUB_START_YEAR + index
+ ),
+ [currentYear]
+ );
+
+ const [year, setYear] = useState(currentYear);
+ const [month, setMonth] = useState(today.getMonth());
+ const [openedDropdown, setOpenedDropdown] = useState(null);
+ const [viewMode, setViewMode] = useState(VIEW_MODE.CALENDAR);
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+ const [schedulesToDelete, setSchedulesToDelete] = useState([]);
+ const [localSchedules, setLocalSchedules] = useState([]);
+
+ const allSchedules = useMemo(() => [...SCHEDULES, ...localSchedules], [localSchedules]);
+
+ const calendarDates = useMemo(() => getCalendarDates(year, month), [year, month]);
+ const scheduleDateKeys = useMemo(() => getScheduleDateKeys(allSchedules), [allSchedules]);
+ const visibleSchedules = useMemo(() => {
+ const filteredSchedules =
+ viewMode === VIEW_MODE.ALL
+ ? allSchedules.filter(
+ (schedule) => parseLocalDate(schedule.startDate).getFullYear() === year
+ )
+ : allSchedules.filter((schedule) => isScheduleInMonth(schedule, year, month));
+ return [...filteredSchedules].sort(sortByStartDate);
+ }, [allSchedules, month, viewMode, year]);
+ const scheduleMonthEntries = useMemo(
+ () => Object.entries(groupSchedulesByMonth(visibleSchedules)),
+ [visibleSchedules]
+ );
+
+ const isMonthDropdownOpen = openedDropdown === DROPDOWN_TYPE.MONTH;
+ const isYearDropdownOpen = openedDropdown === DROPDOWN_TYPE.YEAR;
+
+ const handleMonthScroll = () => {
+ updateScrollThumb(monthListRef.current, monthTrackRef.current, monthThumbRef.current);
+ };
+ const handleYearScroll = () => {
+ updateScrollThumb(yearListRef.current, yearTrackRef.current, yearThumbRef.current);
+ };
+ const handleAllScheduleScroll = () => {
+ updateScrollThumb(
+ allScheduleListRef.current,
+ allScheduleTrackRef.current,
+ allScheduleThumbRef.current
+ );
+ };
+ const handleViewModeChange = (nextViewMode) => {
+ setViewMode(nextViewMode);
+ setOpenedDropdown(null);
+ };
+
+ const handleAddSchedule = ({ dateKey, endDateKey, title }) => {
+ setLocalSchedules((prev) => [
+ ...prev,
+ { id: `local-${Date.now()}`, startDate: dateKey, endDate: endDateKey ?? dateKey, title },
+ ]);
+ };
+
+ const handleSaveEdits = (updatedSchedules) => {
+ setLocalSchedules((prev) =>
+ prev.map((s) => {
+ const edited = updatedSchedules.find((u) => u.id === s.id);
+ return edited ? { ...s, title: edited.title } : s;
+ })
+ );
+ };
+ const handleDeleteSchedule = (toDelete) => {
+ if (!toDelete || toDelete.length === 0) return;
+ setSchedulesToDelete(toDelete);
+ setIsDeleteModalOpen(true);
+ };
+ const handleDeleteConfirm = () => {
+ setLocalSchedules((prev) =>
+ prev.filter(
+ (s) =>
+ !schedulesToDelete.some(
+ (d) => d.startDate === s.startDate && d.endDate === s.endDate && d.title === s.title
+ )
+ )
+ );
+ setIsDeleteModalOpen(false);
+ setSchedulesToDelete([]);
+ };
+ const handleDeleteCancel = () => {
+ setIsDeleteModalOpen(false);
+ };
+
+ useEffect(() => {
+ const rafIds = [];
+ if (isMonthDropdownOpen) rafIds.push(requestAnimationFrame(() => handleMonthScroll()));
+ if (isYearDropdownOpen) rafIds.push(requestAnimationFrame(() => handleYearScroll()));
+ if (viewMode === VIEW_MODE.ALL)
+ rafIds.push(requestAnimationFrame(() => handleAllScheduleScroll()));
+ return () => {
+ rafIds.forEach(cancelAnimationFrame);
+ };
+ }, [isMonthDropdownOpen, isYearDropdownOpen, viewMode, scheduleMonthEntries]);
+
+ return (
+ <>
+
+
+
+
+ 연간 계획 (관리자)
+
+
+
+
+ {viewMode === VIEW_MODE.ALL ? (
+
+ setOpenedDropdown(isYearDropdownOpen ? null : DROPDOWN_TYPE.YEAR),
+ onSelect: (yearOption) => {
+ setYear(yearOption);
+ setOpenedDropdown(null);
+ },
+ onScroll: handleYearScroll,
+ },
+ scrollRefs: {
+ listRef: yearListRef,
+ trackRef: yearTrackRef,
+ thumbRef: yearThumbRef,
+ },
+ }}
+ view={{ viewMode, onViewModeChange: handleViewModeChange }}
+ scheduleMonthEntries={scheduleMonthEntries}
+ scheduleScroll={{
+ listRef: allScheduleListRef,
+ trackRef: allScheduleTrackRef,
+ thumbRef: allScheduleThumbRef,
+ onScroll: handleAllScheduleScroll,
+ }}
+ onDeleteSchedule={handleDeleteSchedule}
+ />
+ ) : (
+
+ setOpenedDropdown(isMonthDropdownOpen ? null : DROPDOWN_TYPE.MONTH),
+ onSelect: (monthOption) => {
+ setMonth(monthOption);
+ setOpenedDropdown(null);
+ },
+ onScroll: handleMonthScroll,
+ },
+ scrollRefs: {
+ listRef: monthListRef,
+ trackRef: monthTrackRef,
+ thumbRef: monthThumbRef,
+ },
+ }}
+ yearDropdown={{
+ isOpen: isYearDropdownOpen,
+ handlers: {
+ onToggle: () =>
+ setOpenedDropdown(isYearDropdownOpen ? null : DROPDOWN_TYPE.YEAR),
+ onSelect: (yearOption) => {
+ setYear(yearOption);
+ setOpenedDropdown(null);
+ },
+ onScroll: handleYearScroll,
+ },
+ scrollRefs: {
+ listRef: yearListRef,
+ trackRef: yearTrackRef,
+ thumbRef: yearThumbRef,
+ },
+ }}
+ onAddSchedule={handleAddSchedule}
+ onDeleteSchedule={handleDeleteSchedule}
+ onSaveEdits={handleSaveEdits}
+ />
+ )}
+
+
+
+ {/* 일정 삭제 확인 모달 */}
+ {isDeleteModalOpen && (
+
+
+ {/* 헤더 */}
+
+
+ {schedulesToDelete.length > 0
+ ? `${formatAdminDate(schedulesToDelete[0].startDate)} ~ ${formatAdminDate(schedulesToDelete[0].endDate)}`
+ : `${month + 1}월 일정`}
+
+
+
+
+ {/* 본문 */}
+
+
+ {/* 푸터 버튼 */}
+
+
+
+
+
+
+ )}
+
+ >
+ );
+}
+
+export default AdminSchedulePage;
diff --git a/src/styles/ScheduleStyles.jsx b/src/styles/ScheduleStyles.jsx
index 9083bba..2201168 100644
--- a/src/styles/ScheduleStyles.jsx
+++ b/src/styles/ScheduleStyles.jsx
@@ -1,20 +1,9 @@
-function ScheduleStyles() {
- return (
-
- );
+function ScheduleStyles() {
+ return ;
}
export default ScheduleStyles;
diff --git a/src/utils/schedule.js b/src/utils/schedule.js
index b93785f..dcb7253 100644
--- a/src/utils/schedule.js
+++ b/src/utils/schedule.js
@@ -1,114 +1,115 @@
-export const parseLocalDate = (dateStr) => {
- const [year, month, day] = dateStr.split('-').map(Number);
- return new Date(year, month - 1, day);
-};
-
-export const formatDateKey = (date) => {
+export function formatDateKey(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
-
return `${year}-${month}-${day}`;
-};
-
-export const formatScheduleDate = (dateKey) => {
- const date = parseLocalDate(dateKey);
- const month = String(date.getMonth() + 1).padStart(2, '0');
- const day = String(date.getDate()).padStart(2, '0');
- const weekday = new Intl.DateTimeFormat('ko-KR', { weekday: 'short' }).format(date);
-
- return `${month}.${day} (${weekday})`;
-};
+}
-export const formatAllScheduleDate = ({ startDate, endDate }) => {
- const start = formatScheduleDate(startDate);
- const end = formatScheduleDate(endDate);
-
- return startDate === endDate ? start : `${start} ~ ${end}`;
-};
-
-const getMondayBasedDay = (date) => {
- const day = date.getDay();
-
- return day === 0 ? 6 : day - 1;
-};
+export function parseLocalDate(dateString) {
+ const [year, month, day] = dateString.split('-').map(Number);
+ return new Date(year, month - 1, day);
+}
+
+const KO_DAYS = ['일', '월', '화', '수', '목', '금', '토'];
+
+export function formatAdminDate(dateString) {
+ const [year, month, day] = dateString.split('-').map(Number);
+ const date = new Date(year, month - 1, day);
+ const mm = String(month).padStart(2, '0');
+ const dd = String(day).padStart(2, '0');
+ return `${mm}.${dd} (${KO_DAYS[date.getDay()]})`;
+}
+
+export function formatScheduleDate(dateString) {
+ const [, month, day] = dateString.split('-').map(Number);
+ return `${month}월 ${day}일`;
+}
+
+export function getCalendarDates(year, month) {
+ const firstDayOfWeek = new Date(year, month, 1).getDay();
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
+ const prevMonthDays = new Date(year, month, 0).getDate();
+ const dates = [];
+
+ const prevDaysCount = (firstDayOfWeek + 6) % 7;
+ for (let i = prevDaysCount - 1; i >= 0; i--) {
+ const day = prevMonthDays - i;
+ const prevMonth = month === 0 ? 11 : month - 1;
+ const prevYear = month === 0 ? year - 1 : year;
+ dates.push({
+ day,
+ key: `${prevYear}-${String(prevMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`,
+ isCurrentMonth: false,
+ });
+ }
-export const getCalendarDates = (year, month) => {
- const firstDate = new Date(year, month, 1);
- const startDate = new Date(firstDate);
- startDate.setDate(firstDate.getDate() - getMondayBasedDay(firstDate));
+ for (let day = 1; day <= daysInMonth; day++) {
+ dates.push({
+ day,
+ key: `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`,
+ isCurrentMonth: true,
+ });
+ }
- return Array.from({ length: 42 }, (_, index) => {
- const date = new Date(startDate);
- date.setDate(startDate.getDate() + index);
+ const remaining = (7 - (dates.length % 7)) % 7;
+ const nextMonth = month === 11 ? 0 : month + 1;
+ const nextYear = month === 11 ? year + 1 : year;
+ for (let day = 1; day <= remaining; day++) {
+ dates.push({
+ day,
+ key: `${nextYear}-${String(nextMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`,
+ isCurrentMonth: false,
+ });
+ }
- return {
- key: formatDateKey(date),
- day: date.getDate(),
- isCurrentMonth: date.getMonth() === month,
- };
+ return dates;
+}
+
+export function getScheduleDateKeys(schedules) {
+ const keys = new Set();
+ schedules.forEach(({ startDate, endDate }) => {
+ const start = parseLocalDate(startDate);
+ const end = parseLocalDate(endDate);
+ for (const current = new Date(start); current <= end; current.setDate(current.getDate() + 1)) {
+ keys.add(formatDateKey(current));
+ }
});
-};
-
-export const getScheduleDateKeys = (scheduleList) =>
- new Set(
- scheduleList.flatMap(({ startDate, endDate }) => {
- const start = parseLocalDate(startDate);
- const end = parseLocalDate(endDate);
- const keys = [];
-
- for (const date = new Date(start); date <= end; date.setDate(date.getDate() + 1)) {
- keys.push(formatDateKey(date));
- }
+ return keys;
+}
- return keys;
- })
- );
-
-export const isScheduleInMonth = ({ startDate, endDate }, year, month) => {
+export function isScheduleInMonth(schedule, year, month) {
+ const start = parseLocalDate(schedule.startDate);
+ const end = parseLocalDate(schedule.endDate);
const monthStart = new Date(year, month, 1);
const monthEnd = new Date(year, month + 1, 0);
- const scheduleStart = parseLocalDate(startDate);
- const scheduleEnd = parseLocalDate(endDate);
-
- return scheduleStart <= monthEnd && scheduleEnd >= monthStart;
-};
-
-export const sortByStartDate = (firstSchedule, secondSchedule) =>
- firstSchedule.startDate.localeCompare(secondSchedule.startDate);
-
-export const groupSchedulesByMonth = (scheduleList) =>
- scheduleList.reduce((groups, schedule) => {
- const scheduleMonth = parseInt(schedule.startDate.split('-')[1], 10);
- const key = `${scheduleMonth}월`;
-
- return {
- ...groups,
- [key]: [...(groups[key] || []), schedule],
- };
+ return start <= monthEnd && end >= monthStart;
+}
+
+export function groupSchedulesByMonth(schedules) {
+ return schedules.reduce((groups, schedule) => {
+ const monthKey = schedule.startDate.slice(0, 7);
+ if (!groups[monthKey]) groups[monthKey] = [];
+ groups[monthKey].push(schedule);
+ return groups;
}, {});
+}
-export const updateScrollThumb = (scrollElement, trackElement, thumbElement) => {
- if (!scrollElement || !trackElement || !thumbElement) return;
-
- const { scrollTop, scrollHeight, clientHeight } = scrollElement;
- const trackHeight = trackElement.clientHeight;
+export function sortByStartDate(a, b) {
+ return a.startDate.localeCompare(b.startDate);
+}
+export function updateScrollThumb(listElement, trackElement, thumbElement) {
+ if (!listElement || !trackElement || !thumbElement) return;
+ const { scrollHeight, clientHeight, scrollTop } = listElement;
if (scrollHeight <= clientHeight) {
- thumbElement.style.opacity = '1';
- thumbElement.style.height = `${trackHeight}px`;
- thumbElement.style.transform = 'translateY(0)';
+ thumbElement.style.display = 'none';
return;
}
-
- thumbElement.style.opacity = '1';
-
- const heightRatio = clientHeight / scrollHeight;
- const thumbHeight = Math.max(heightRatio * trackHeight, 36);
- const maxScrollTop = scrollHeight - clientHeight;
- const maxThumbTop = trackHeight - thumbHeight;
- const thumbTop = (scrollTop / maxScrollTop) * maxThumbTop;
-
+ thumbElement.style.display = 'block';
+ const ratio = clientHeight / scrollHeight;
+ const thumbHeight = Math.max(20, ratio * trackElement.clientHeight);
+ const thumbTop =
+ (scrollTop / (scrollHeight - clientHeight)) * (trackElement.clientHeight - thumbHeight);
thumbElement.style.height = `${thumbHeight}px`;
- thumbElement.style.transform = `translateY(${thumbTop}px)`;
-};
+ thumbElement.style.top = `${thumbTop}px`;
+}
diff --git a/tailwind.config.js b/tailwind.config.js
index 5932053..d38148f 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -22,7 +22,6 @@ export default {
'scroll-track': '#EDEDED',
'scroll-thumb': '#8A8A8A',
'schedule-divider': '#FFE3D1',
- 'schedule-inactive': '#9F9F9F',
'schedule-scroll-track': '#F0F0F0',
'schedule-scroll-thumb': '#888888',
'schedule-mark-red': '#F04438',
@@ -33,6 +32,7 @@ export default {
'schedule-mark-purple': '#D444F1',
'schedule-mark-pink': '#F63D68',
error: '#F94700', // 필수 표시(*)·오류 메시지(brand보다 붉은 톤)
+ 'schedule-inactive': '#9F9F9F', // 달력 비활성 날짜
},
// 명부 관리/부원 등록 화면 모서리 토큰(시안 실측).
borderRadius: {