diff --git a/src/App.jsx b/src/App.jsx index ae3381c..d344e49 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -15,6 +15,7 @@ import MemberPage from '@/pages/admin/MemberPage'; import MemberRegisterPage from '@/pages/admin/MemberRegisterPage'; import MemberEditPage from '@/pages/admin/MemberEditPage'; import NotFoundPage from '@/pages/visitor/NotFoundPage'; +import AdminSchedulePage from '@/pages/admin/AdminSchedulePage'; function App() { return ( @@ -36,6 +37,7 @@ function App() { } /> } /> } /> + } /> ); diff --git a/src/components/schedule/AdminCalendarView.jsx b/src/components/schedule/AdminCalendarView.jsx new file mode 100644 index 0000000..68bcf6f --- /dev/null +++ b/src/components/schedule/AdminCalendarView.jsx @@ -0,0 +1,335 @@ +import { useState } from 'react'; +import { MONTH_OPTIONS, WEEKDAYS } from '@/constants/schedule'; +import { formatAdminDate } from '@/utils/schedule'; +import ScheduleCheckbox from '@/components/schedule/ScheduleCheckbox'; +import ScheduleDropdown from '@/components/schedule/ScheduleDropdown'; +import ScheduleTabs from '@/components/schedule/ScheduleTabs'; + +function AdminCalendarView({ + calendar, + view, + schedules, + monthDropdown, + yearDropdown, + onAddSchedule, + onDeleteSchedule, + onSaveEdits, +}) { + const { year, month, yearOptions, calendarDates, todayKey } = calendar; + const weeksCount = calendarDates.length / 7; + const { viewMode, onViewModeChange } = view; + const { scheduleDateKeys, visibleSchedules } = schedules; + + const [selectedDateKey, setSelectedDateKey] = useState(todayKey); + const [selectedEndDateKey, setSelectedEndDateKey] = useState(todayKey); + const [activeDateField, setActiveDateField] = useState('start'); + const [isAddingSchedule, setIsAddingSchedule] = useState(false); + const [newScheduleTitle, setNewScheduleTitle] = useState(''); + const [selectedIndexes, setSelectedIndexes] = useState(new Set()); + const [isEditMode, setIsEditMode] = useState(false); + const [editedSchedules, setEditedSchedules] = useState([]); + + const handleDateClick = (dateKey) => { + if (!isAddingSchedule) { + setSelectedDateKey(dateKey); + return; + } + + if (activeDateField === 'end') { + if (dateKey < selectedDateKey) { + setSelectedDateKey(dateKey); + setSelectedEndDateKey(selectedDateKey); + } else { + setSelectedEndDateKey(dateKey); + } + setActiveDateField('start'); + } else { + setSelectedDateKey(dateKey); + setSelectedEndDateKey(dateKey); + setActiveDateField('end'); + } + }; + + const handleAddClick = () => { + setIsEditMode(false); + setEditedSchedules([]); + setIsAddingSchedule(true); + setNewScheduleTitle(''); + setSelectedEndDateKey(selectedDateKey); + setActiveDateField('start'); + }; + + const handleConfirm = () => { + if (isAddingSchedule) { + if (newScheduleTitle.trim()) { + onAddSchedule?.({ + dateKey: selectedDateKey, + endDateKey: selectedEndDateKey, + title: newScheduleTitle, + }); + } + setIsAddingSchedule(false); + setNewScheduleTitle(''); + } else if (isEditMode) { + onSaveEdits?.(editedSchedules); + setIsEditMode(false); + setEditedSchedules([]); + } else { + setEditedSchedules([...visibleSchedules]); + setIsEditMode(true); + } + }; + + const handleToggleSelect = (index) => { + setSelectedIndexes((prev) => { + const next = new Set(prev); + if (next.has(index)) next.delete(index); + else next.add(index); + return next; + }); + }; + + const handleDeleteClick = () => { + const toDelete = visibleSchedules.filter((_, i) => selectedIndexes.has(i)); + onDeleteSchedule?.(toDelete); + setSelectedIndexes(new Set()); + }; + + const handleCancel = () => { + setIsAddingSchedule(false); + setNewScheduleTitle(''); + setIsEditMode(false); + setEditedSchedules([]); + setActiveDateField('start'); + }; + + const handleEditTitle = (index, value) => { + setEditedSchedules((prev) => { + const next = [...prev]; + next[index] = { ...next[index], title: value }; + return next; + }); + }; + + const displayList = isEditMode ? editedSchedules : visibleSchedules; + + return ( + /* h-full + flex-col: 카드 전체 높이를 채워 수정 버튼을 하단에 고정 */ +
+ {/* 콘텐츠 그리드: flex-1 로 남은 높이 전부 차지 */} +
+ {/* 왼쪽: 드롭다운 + 탭 + 캘린더 */} +
+
+ `${monthOption + 1}월`, + }} + state={{ value: `${month + 1}월`, isOpen: monthDropdown.isOpen }} + handlers={monthDropdown.handlers} + scrollRefs={monthDropdown.scrollRefs} + classNames={{ + buttonWidthClass: 'w-[58px]', + itemHeightClass: 'h-[40px]', + itemTextClass: 'text-[20px]', + panelWidthClass: 'w-[424px]', + scrollTrackRightClass: 'right-[6px]', + }} + /> + yearOption, + }} + state={{ value: year, isOpen: yearDropdown.isOpen }} + handlers={yearDropdown.handlers} + scrollRefs={yearDropdown.scrollRefs} + classNames={{ + buttonWidthClass: 'w-[70px]', + itemHeightClass: 'h-[73px]', + itemTextClass: 'text-[20px]', + panelWidthClass: 'w-[455px]', + scrollTrackRightClass: 'right-[22px]', + }} + /> + +
+ +
+ {WEEKDAYS.map((weekday) => ( +
+ {weekday} +
+ ))} + + {calendarDates.map((date) => { + const isRangeStart = isAddingSchedule && date.key === selectedDateKey; + const isRangeEnd = + isAddingSchedule && + date.key === selectedEndDateKey && + selectedEndDateKey !== selectedDateKey; + const isInRange = + isAddingSchedule && date.key > selectedDateKey && date.key < selectedEndDateKey; + const isSelected = isAddingSchedule + ? isRangeStart || isRangeEnd + : date.key === selectedDateKey; + const hasSchedule = date.isCurrentMonth && scheduleDateKeys.has(date.key); + + return ( + + ); + })} +
+
+ +
+ + {/* 오른쪽: 일정 목록 + 수정 버튼 */} + {/* h-full + flex-col: 그리드 셀 전체 높이를 채워 수정 버튼을 하단에 고정 */} +
+ {/* 추가 / 삭제 버튼 */} +
+ + +
+ + {/* 일정 목록: flex-1 로 남은 공간 채우고 넘치면 스크롤 */} +
    +
    + {displayList.map((schedule, index) => { + const isChecked = selectedIndexes.has(index); + return ( +
  1. + {/* 편집 모드일 때 체크박스 숨김 */} + {!isEditMode && ( + handleToggleSelect(index)} + label={`${schedule.title} 선택`} + /> + )} + + {isEditMode ? ( + handleEditTitle(index, e.target.value)} + className="min-w-0 flex-1 border-b border-brand bg-transparent text-[16px] text-ink outline-none" + /> + ) : ( + + {schedule.title} + + )} +
  2. + ); + })} + + {isAddingSchedule && ( +
  3. +
    + + + +
    + + setNewScheduleTitle(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleConfirm()} + placeholder="새로운 일정을 작성하세요" + className="min-w-0 flex-1 border-b border-brand bg-transparent text-[16px] text-ink outline-none placeholder:text-[#9f9f9f]" + /> +
  4. + )} +
    +
+ + {/* 취소/수정/완료 버튼: shrink-0 으로 항상 하단 고정 */} +
+ {(isEditMode || isAddingSchedule) && ( + + )} + +
+
+
+
+ ); +} + +export default AdminCalendarView; diff --git a/src/components/schedule/AllScheduleView.jsx b/src/components/schedule/AllScheduleView.jsx index a70d349..c4e8d23 100644 --- a/src/components/schedule/AllScheduleView.jsx +++ b/src/components/schedule/AllScheduleView.jsx @@ -1,129 +1,153 @@ -import { formatAllScheduleDate } from '@/utils/schedule'; -import tailwindConfig from '../../../tailwind.config'; +import { useState } from 'react'; +import ScheduleCheckbox from '@/components/schedule/ScheduleCheckbox'; +import ScheduleDropdown from '@/components/schedule/ScheduleDropdown'; +import ScheduleTabs from '@/components/schedule/ScheduleTabs'; +import { VIEW_MODE } from '@/constants/schedule'; +import { formatScheduleDate } from '@/utils/schedule'; -import ScheduleDropdown from './ScheduleDropdown'; -import ScheduleTabs from './ScheduleTabs'; +const ACCENT_COLORS = ['#FFBA88', '#D95D03', '#F96B03', '#953E00', '#6C3E1E']; -const colors = tailwindConfig.theme.extend.colors; - -const SCHEDULE_MARK_COLORS = [ - colors['schedule-mark-red'], - colors.brand, - colors['schedule-mark-orange'], - colors['schedule-mark-green'], - colors['schedule-mark-blue'], - colors['schedule-mark-violet'], - colors['schedule-mark-purple'], - colors['schedule-mark-pink'], -]; +function getScheduleKey(schedule) { + return schedule.id ?? `${schedule.startDate}-${schedule.endDate}-${schedule.title}`; +} -function AllScheduleView({ yearDropdown, view, scheduleMonthEntries, scheduleScroll }) { - const { year, yearOptions } = yearDropdown; +function AllScheduleView({ + yearDropdown, + view, + scheduleMonthEntries, + scheduleScroll, + onDeleteSchedule, +}) { + const { year, yearOptions, isOpen: isYearDropdownOpen, handlers, scrollRefs } = yearDropdown; const { viewMode, onViewModeChange } = view; - const { - listRef: allScheduleListRef, - trackRef: allScheduleTrackRef, - thumbRef: allScheduleThumbRef, - onScroll: onAllScheduleScroll, - onWheel: onAllScheduleWheel, - } = scheduleScroll; + const { listRef, trackRef, thumbRef, onScroll } = scheduleScroll; + + const [selectedKeys, setSelectedKeys] = useState(new Set()); + + const handleToggleSelect = (key) => { + setSelectedKeys((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const handleAddClick = () => { + onViewModeChange(VIEW_MODE.CALENDAR); + }; + + const handleDeleteClick = () => { + const toDelete = scheduleMonthEntries + .flatMap(([, monthSchedules]) => monthSchedules) + .filter((schedule) => selectedKeys.has(getScheduleKey(schedule))); + onDeleteSchedule?.(toDelete); + setSelectedKeys(new Set()); + }; + return ( -
-
+
+
yearOption, + itemLabel: (option) => option, }} - state={{ value: year, isOpen: yearDropdown.isOpen }} - handlers={yearDropdown.handlers} - scrollRefs={yearDropdown.scrollRefs} + state={{ value: year, isOpen: isYearDropdownOpen }} + handlers={handlers} + scrollRefs={scrollRefs} classNames={{ buttonWidthClass: 'w-[70px]', - buttonPaddingClass: 'pr-4', - itemHeightClass: 'h-[73px] leading-[73px]', - itemTextClass: 'text-[18px]', + itemHeightClass: 'h-[73px]', + itemTextClass: 'text-[20px]', + panelWidthClass: 'w-[455px]', + scrollTrackRightClass: 'right-[22px]', }} /> - - + +
+ + +
-
-
+
    - {scheduleMonthEntries.length === 0 ? ( -
    - 일정이 없습니다. -
    - ) : ( - scheduleMonthEntries.map(([monthLabel, monthSchedules], monthIndex) => { - const isLastMonth = monthIndex === scheduleMonthEntries.length - 1; - - return ( -
    -
    -

    {monthLabel}

    -
      - {monthSchedules.map((schedule, scheduleIndex) => { - const isLastSchedule = scheduleIndex === monthSchedules.length - 1; - const markerColor = - SCHEDULE_MARK_COLORS[ - (monthIndex * monthSchedules.length + scheduleIndex) % - SCHEDULE_MARK_COLORS.length - ]; - - return ( -
    1. -
    2. - ); - })} -
    -
    -
    - ); - }) + {scheduleMonthEntries.length === 0 && ( +
  1. 등록된 일정이 없습니다.
  2. )} -
+ {scheduleMonthEntries.map(([monthKey, monthSchedules]) => { + const month = Number(monthKey.split('-')[1]); + return ( +
  • +

    {month}월

    +
    +
      + {monthSchedules.map((schedule, index) => { + const key = getScheduleKey(schedule); + const isSelected = selectedKeys.has(key); + const isRange = schedule.startDate !== schedule.endDate; + return ( +
    1. + handleToggleSelect(key)} + label={`${schedule.title} 선택`} + /> + + + + {schedule.title} + +
    2. + ); + })} +
    + +
    +
  • + ); + })} + diff --git a/src/components/schedule/ScheduleCheckbox.jsx b/src/components/schedule/ScheduleCheckbox.jsx new file mode 100644 index 0000000..0bf3b53 --- /dev/null +++ b/src/components/schedule/ScheduleCheckbox.jsx @@ -0,0 +1,23 @@ +function ScheduleCheckbox({ checked, onClick, label }) { + return ( + + ); +} + +export default ScheduleCheckbox; diff --git a/src/components/schedule/ScheduleDropdown.jsx b/src/components/schedule/ScheduleDropdown.jsx index 993ee0c..87e10fa 100644 --- a/src/components/schedule/ScheduleDropdown.jsx +++ b/src/components/schedule/ScheduleDropdown.jsx @@ -7,75 +7,70 @@ function ScheduleDropdown({ config, state, handlers, scrollRefs, classNames }) { buttonWidthClass, itemHeightClass, itemTextClass, - buttonPaddingClass = 'px-2', + panelWidthClass, + scrollTrackRightClass, } = classNames; + return (
    {isOpen && (
    -
      - {options.map((option) => ( -
    • - -
    • - ))} -
    -