diff --git a/src/features/Calendar/components/CustomCalendar/CalendarModals.tsx b/src/features/Calendar/components/CustomCalendar/CalendarModals.tsx index 3bf41dc..0c2d33d 100644 --- a/src/features/Calendar/components/CustomCalendar/CalendarModals.tsx +++ b/src/features/Calendar/components/CustomCalendar/CalendarModals.tsx @@ -1,5 +1,6 @@ import moment from 'moment' import { useMemo, useState } from 'react' +import { useLocation } from 'react-router-dom' import { useDetailEventQuery } from '@/shared/hooks/query/useCalendarQueries' import type { CalendarEvent } from '@/shared/types/calendar/types' @@ -8,6 +9,8 @@ import ScheduleEditorModal from '@/shared/ui/Modals/ScheduleEditor' import TodoEditorModal from '@/shared/ui/Modals/TodoEditor' import { buildDefaultItemEditorDraft } from '@/shared/utils' +import type { CalendarEventActions } from './CustomCalendar.types' + type CalendarModalsProps = { modalDate: string modalEventId: CalendarEvent['id'] | null @@ -15,19 +18,7 @@ type CalendarModalsProps = { isModalEditing: boolean modalMode: 'modal' | 'inline' onCloseModal: () => void - eventActions: { - onEventColorChange: (eventId: CalendarEvent['id'], color: CalendarEvent['color']) => void - onEventTitleConfirm: (eventId: CalendarEvent['id'], title: CalendarEvent['title']) => void - onEventSharedChange: (eventId: CalendarEvent['id'], isShared: boolean) => void - onEventTypeChange: (eventId: CalendarEvent['id'], type: 'todo' | 'schedule') => void - onEventTimingChange: ( - eventId: CalendarEvent['id'], - start: Date, - end: Date, - allDay: boolean, - occurrenceDate?: CalendarEvent['occurrenceDate'], - ) => void - } + eventActions: CalendarEventActions } type DraftBackedModalProps = { @@ -38,7 +29,7 @@ type DraftBackedModalProps = { isModalEditing: boolean modalMode: 'modal' | 'inline' onCloseModal: () => void - eventActions: CalendarModalsProps['eventActions'] + eventActions: CalendarEventActions } const DraftBackedModal = ({ @@ -108,7 +99,8 @@ const CalendarModals = ({ onCloseModal, eventActions, }: CalendarModalsProps) => { - const shouldRenderModal = modalEventId != null + const location = useLocation() + const shouldRenderModal = modalEventId != null && location.pathname.startsWith('/calendar') const isTodoModal = modalEvent?.type === 'todo' const safeDetailEventId = isModalEditing && !isTodoModal ? modalEventId : null const occurrenceDate = useMemo(() => { diff --git a/src/features/Calendar/components/CustomCalendar/CustomCalendar.tsx b/src/features/Calendar/components/CustomCalendar/CustomCalendar.tsx index 8f17620..1b84c93 100644 --- a/src/features/Calendar/components/CustomCalendar/CustomCalendar.tsx +++ b/src/features/Calendar/components/CustomCalendar/CustomCalendar.tsx @@ -3,49 +3,12 @@ import 'moment/locale/ko' import 'react-big-calendar/lib/css/react-big-calendar.css' import moment from 'moment' -import { - cloneElement, - type MouseEvent, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react' -import { Calendar, type DateCellWrapperProps, momentLocalizer } from 'react-big-calendar' -import type { EventInteractionArgs } from 'react-big-calendar/lib/addons/dragAndDrop' +import type { View } from 'react-big-calendar' +import { Calendar, momentLocalizer } from 'react-big-calendar' import withDragAndDrop from 'react-big-calendar/lib/addons/dragAndDrop' -import { - useCalendarApiEvents, - useCalendarCreateHandlers, - useCalendarDateRange, - useCalendarDraftEvent, - useCalendarDragDrop, - useCalendarKeyDelete, - useCalendarModal, - useCalendarNavigation, - useCalendarRbcProps, - useCalendarResponsive, - useCalendarSelection, - useDayViewHandlers, - useStoredCalendarView, -} from '@/features/Calendar/hooks' -import { buildRecurringGroupForFutureDrop } from '@/features/Calendar/hooks/useCalendarDragDrop' -import { useCalendarEvents } from '@/features/Calendar/hooks/useCalendarEvents' -import { - getEventOccurrenceKey, - resolveOccurrenceDateTime, -} from '@/features/Calendar/utils/helpers/dayViewHelpers' -import { getDetailTodo } from '@/shared/api/todo/api' -import { useCalendarMutation } from '@/shared/hooks/query/useCalendarMutation' -import { useTodoMutations } from '@/shared/hooks/query/useTodoMutations' +import { useCustomCalendarController } from '@/features/Calendar/hooks/useCustomCalendarController' import type { CalendarEvent } from '@/shared/types/calendar/types' -import type { - RecurrenceEventScope, - RecurrenceTodoScope, -} from '@/shared/types/recurrence/recurrence' -import type { EditConfirmOption } from '@/shared/ui/Modals' import CalendarModals from './CalendarModals' import * as S from './CustomCalendar.style' @@ -55,455 +18,34 @@ import CustomCalendarMobileActions from './CustomCalendarMobileActions' moment.locale('ko') const localizer = momentLocalizer(moment) const DragAndDropCalendar = withDragAndDrop(Calendar) -export type SelectDateSource = 'date-cell' | 'slot' | 'header' | 'date-header' +export type { SelectDateSource } from './CustomCalendar.types' type CustomCalendarProps = { + initialView?: View onSelectedDateChange?: (selectedDate: Date) => void } -const CustomCalendar = ({ onSelectedDateChange }: CustomCalendarProps) => { - // 사용자 뷰 상태(월/주/일) 저장 - const { view, setView } = useStoredCalendarView() - const [date, setDate] = useState(new Date()) - // 현재 뷰 기준으로 서버 조회 범위 계산 - const { startDate, endDate } = useCalendarDateRange(view, date) - // 서버 일정 목록 조회 - const { events: apiEvents, refetch: refetchEvents } = useCalendarApiEvents(startDate, endDate) - - const { usePatchEvent, useDeleteEvent } = useCalendarMutation() - const { mutate: patchEventMutate } = usePatchEvent() - const { mutate: deleteEventMutate } = useDeleteEvent() - const { usePatchCompleteTodo, usePatchTodo, useDeleteTodo } = useTodoMutations() - const { mutate: patchCompleteTodoMutate } = usePatchCompleteTodo() - const { mutate: patchTodoMutate } = usePatchTodo() - const { mutate: deleteTodoMutate } = useDeleteTodo() - const recurringTodoPatchSeqRef = useRef>(new Map()) - const [deleteConfirm, setDeleteConfirm] = useState<{ - isOpen: boolean - eventId: CalendarEvent['id'] | null - title: string - occurrenceDate: string - }>({ isOpen: false, eventId: null, title: '', occurrenceDate: '' }) - const [recurringDropConfirm, setRecurringDropConfirm] = useState<{ - isOpen: boolean - target: 'event' | 'todo' - args: EventInteractionArgs | null - }>({ isOpen: false, target: 'event', args: null }) - // 로컬 캘린더 이벤트 상태 및 동작 +const CustomCalendar = ({ initialView, onSelectedDateChange }: CustomCalendarProps) => { const { - events, - addEvent: enqueueEvent, - moveEvent, - resizeEvent, - updateEventTime: updateLocalEventTime, - updateEventColor, - updateEventTiming, - updateEventType, - updateEventTitle, - updateEventShared, - toggleEventDone, - removeEvent, - } = useCalendarEvents({ initialEvents: apiEvents }) - - // Todo 완료 토글 - const handleToggleTodo = useCallback( - (eventId: CalendarEvent['id']) => { - const target = events.find( - (eventItem) => eventItem.id === eventId && eventItem.type === 'todo', - ) - if (!target || target.type !== 'todo') { - return - } - const nextCompleted = !target.isDone - toggleEventDone(eventId, 'todo') - const occurrenceDate = moment(target.start).format('YYYY-MM-DD') - patchCompleteTodoMutate({ - todoId: eventId, - occurrenceDate, - isCompleted: nextCompleted, - }) - }, - [events, patchCompleteTodoMutate, toggleEventDone], - ) - - const resolveFutureTodoRecurrenceGroup = useCallback( - async (todoId: number, occurrenceDate: string, nextStart: Date) => { - try { - // "이후 일정만 변경"일 때는 현재 occurrence 기준의 상세 recurrence를 받아 - // 드롭한 날짜(nextStart)에 맞는 recurrenceGroup으로 재계산해 patch에 포함합니다. - const { result } = await getDetailTodo(todoId, occurrenceDate) - return buildRecurringGroupForFutureDrop(result?.recurrenceGroup ?? null, nextStart) - } catch (error) { - console.error('[CustomCalendar] failed to resolve todo recurrenceGroup', error) - return undefined - } - }, - [], - ) - - // Todo 일정 이동 시 시간 패치 - const patchTodoTiming = useCallback( - ( - todoEvent: CalendarEvent, - start: Date, - options?: { scope?: RecurrenceTodoScope; occurrenceDate?: string }, - ) => { - const startDate = moment(start).format('YYYY-MM-DD') - const occurrenceDate = - options?.occurrenceDate ?? - moment(todoEvent.occurrenceDate ?? todoEvent.start).format('YYYY-MM-DD') - const patchScope = options?.scope ?? (todoEvent.isRecurring ? 'THIS_TODO' : undefined) - const dueTime = todoEvent.isAllDay ? undefined : moment(start).format('HH:mm') - const submitPatch = (recurrenceGroup?: CalendarEvent['recurrenceGroup']) => { - patchTodoMutate({ - todoId: todoEvent.id, - occurrenceDate, - ...(patchScope ? { scope: patchScope } : {}), - requestBody: { - startDate, - dueTime, - isAllDay: todoEvent.isAllDay, - ...(recurrenceGroup ? { recurrenceGroup } : {}), - }, - }) - } - - if (patchScope === 'THIS_AND_FOLLOWING') { - const requestKey = `${todoEvent.id}-${occurrenceDate}` - const nextSequence = (recurringTodoPatchSeqRef.current.get(requestKey) ?? 0) + 1 - recurringTodoPatchSeqRef.current.set(requestKey, nextSequence) - // 반복 할 일을 "이후 항목" 범위로 이동한 경우 recurrenceGroup 보정이 필요합니다. - void resolveFutureTodoRecurrenceGroup(todoEvent.id, occurrenceDate, start).then( - (recurrenceGroup) => { - // 가장 마지막 드롭 요청만 반영해, 비동기 응답 역전으로 인한 역패치를 방지합니다. - const latestSequence = recurringTodoPatchSeqRef.current.get(requestKey) - if (latestSequence !== nextSequence) return - submitPatch(recurrenceGroup) - }, - ) - return - } - submitPatch() - }, - [patchTodoMutate, resolveFutureTodoRecurrenceGroup], - ) - - // 반응형 레이아웃 판단 - const isDesktop = useCalendarResponsive() - const isInlineMode = isDesktop - // 일정 삭제(반복 여부 포함) - const handleRemoveEvent = useCallback( - (eventId: CalendarEvent['id'], occurrenceDate: string, isRecurring: boolean) => { - const params = { - ...(isRecurring ? { scope: 'THIS_EVENT' as const } : {}), - occurrenceDate: moment(occurrenceDate).format('YYYY-MM-DDTHH:mm:ss'), - } - deleteEventMutate( - { - eventId, - params, - }, - { - onSuccess: () => { - refetchEvents() - }, - }, - ) - }, - [deleteEventMutate, refetchEvents], - ) - - // 반복 일정/할 일 판정 - const isRecurring = useCallback( - (eventId: CalendarEvent['id']) => { - const target = events.find((eventItem) => eventItem.id === eventId) - if (!target) return false - if (target.type === 'todo') { - return Boolean(target.isRecurring) - } - return target.recurrenceGroup != null - }, - [events], - ) - - // 모달 상태/핸들러 - const { modal, modalDate, isModalEditing, handleAddEvent, handleEventClick, handleCloseModal } = - useCalendarModal({ - currentDate: date, - removeEvent: handleRemoveEvent, - isRecurring, - }) - - const handleOpenEventFromCalendar = useCallback( - (event: CalendarEvent) => { - if (!isModalEditing && modal.isOpen && modal.eventId != null) { - removeEvent(modal.eventId) - } - handleEventClick(event) - }, - [handleEventClick, isModalEditing, modal.eventId, modal.isOpen, removeEvent], - ) - - // 선택 상태 관리 - const { - selectedDate, - setSelectedDate, - selectedEventId, - setSelectedEventId, - selectedEventKey, - setSelectedEventKey, - clearSelection, - selectEvent, - selectEventOnly, - } = useCalendarSelection({ - onOpenEvent: handleOpenEventFromCalendar, - }) - - const { handleCloseModalWithCleanup, enqueueDraftEvent } = useCalendarDraftEvent({ - events, - isModalEditing, - modal, - removeEvent, - handleCloseModal, - enqueueEvent, - updateEventTiming, - }) - - // 반복 삭제 확인 모달 닫기 - const handleCloseDeleteConfirm = useCallback(() => { - setDeleteConfirm({ isOpen: false, eventId: null, title: '', occurrenceDate: '' }) - }, []) - - // 일간 뷰에서 시간 변경 확정 처리 - const handleDayViewEventTimeChange = useCallback( - (eventId: CalendarEvent['id'], start: Date, end: Date, type?: CalendarEvent['type']) => { - updateLocalEventTime(eventId, start, end, type) - if (type === 'todo') { - const todoEvent = events.find( - (eventItem) => eventItem.id === eventId && eventItem.type === 'todo', - ) - if (todoEvent) { - patchTodoTiming(todoEvent, start) - } - return - } - const nextEnd = moment(end).format('YYYY-MM-DDTHH:mm:ss') - const targetEvent = events.find((eventItem) => eventItem.id === eventId) - const occurrenceDate = resolveOccurrenceDateTime( - targetEvent?.occurrenceDate, - targetEvent?.start ?? start, - ) - const patchScope = targetEvent?.recurrenceGroup != null ? ('THIS_EVENT' as const) : undefined - patchEventMutate({ - eventId, - params: { - occurrenceDate, - ...(patchScope ? { scope: patchScope } : {}), - }, - eventData: { - startTime: moment(start).format('YYYY-MM-DDTHH:mm:ss'), - endTime: nextEnd, - isAllDay: false, - }, - }) - }, - [events, patchEventMutate, patchTodoTiming, updateLocalEventTime], - ) - - // 일간 뷰에서 시간 변경 미리보기 - const handleDayViewEventTimePreview = useCallback( - (eventId: CalendarEvent['id'], start: Date, end: Date, type?: CalendarEvent['type']) => { - updateLocalEventTime(eventId, start, end, type) - }, - [updateLocalEventTime], - ) - - // 키보드 삭제(Backspace) 처리 - useCalendarKeyDelete({ - isModalOpen: modal.isOpen, - date, - events, - selectedEventId, - selectedEventKey, - selectedDate, - onClearSelection: clearSelection, - onOpenRecurringConfirm: ({ eventId, title, occurrenceDate }) => - setDeleteConfirm({ - isOpen: true, - eventId, - title, - occurrenceDate, - }), - onRemoveEvent: handleRemoveEvent, - onDeleteTodo: deleteTodoMutate, - }) - - // 뷰/날짜 이동 처리 - const { onView, onNavigate, onSelectDate } = useCalendarNavigation({ view, date, - isDesktop, - setView, - setDate, - setSelectedDate, - setSelectedEventId, - setSelectedEventKey, - }) - - // 슬롯 선택 및 일간 뷰 생성 처리 - const { handleDayViewCreateEvent, handleSelectSlotWrapper } = useCalendarCreateHandlers({ - view, - enqueueEvent: enqueueDraftEvent, - onAddEvent: handleAddEvent, - setSelectedDate, - setSelectedEventId, - setSelectedEventKey, - }) - const handleWeekViewCreateEvent = useCallback( - (slotDate: Date) => { - const start = moment(slotDate).startOf('day').set({ hour: 9, minute: 0, second: 0 }).toDate() - const createdId = enqueueDraftEvent(start, false) - if (createdId != null) { - handleAddEvent(start, createdId) - } - }, - [enqueueDraftEvent, handleAddEvent], - ) - const handleWeekViewSelectDate = useCallback( - (nextDate: Date) => { - setSelectedDate(nextDate) - setSelectedEventId(null) - setSelectedEventKey(null) - }, - [setSelectedDate, setSelectedEventId, setSelectedEventKey], - ) - - // 일간뷰 전용 핸들러 주입 - const dayViewWithHandlers = useDayViewHandlers({ - clearSelectedDate: () => setSelectedDate(null), - clearSelectedEvent: () => { - setSelectedEventId(null) - setSelectedEventKey(null) - }, - enqueueEvent, + calendarProps, + modalDate, + modalEventId, + modalEvent, + isModalEditing, + modalMode, + eventActions, + deleteConfirm, + recurringDropConfirm, + deleteEventMutate, handleAddEvent, - updateEventTime: handleDayViewEventTimeChange, - updateEventTimePreview: handleDayViewEventTimePreview, - onCreateEvent: handleDayViewCreateEvent, - onToggleTodo: handleToggleTodo, - selectedEventKey, - onEventSelect: selectEventOnly, - onEventClick: undefined, - onEventDoubleClick: selectEvent, - }) - - // 날짜 셀 클릭 시 날짜 이동(기본 이벤트 전파 제어) - const DateCellWrapper = useCallback( - ({ value, children }: DateCellWrapperProps) => - cloneElement(children, { - onClick: (event: MouseEvent) => { - event.stopPropagation() - setSelectedEventId(null) - setSelectedEventKey(null) - setDate(value) - if (typeof children.props.onClick === 'function') { - children.props.onClick(event) - } - }, - }), - [setDate, setSelectedEventId, setSelectedEventKey], - ) - - // drag & drop 처리 - const { handleEventDrop, applyEventDrop } = useCalendarDragDrop({ - view, - moveEvent, - patchEventMutate, - patchTodoTiming, - onRequireRecurringDropConfirm: (args, target) => { - setRecurringDropConfirm({ isOpen: true, target, args }) - }, - }) - const handleCloseRecurringDropConfirm = useCallback(() => { - setRecurringDropConfirm({ isOpen: false, target: 'event', args: null }) - }, []) - const handleConfirmRecurringDrop = useCallback( - (option: EditConfirmOption) => { - if (!recurringDropConfirm.args) return - if (recurringDropConfirm.target === 'todo') { - const todoScope: RecurrenceTodoScope = - option === 'future' ? 'THIS_AND_FOLLOWING' : 'THIS_TODO' - applyEventDrop(recurringDropConfirm.args, { todoScope }) - handleCloseRecurringDropConfirm() - return - } - const eventScope: RecurrenceEventScope = - option === 'future' ? 'THIS_AND_FOLLOWING_EVENTS' : 'THIS_EVENT' - applyEventDrop(recurringDropConfirm.args, { eventScope }) - handleCloseRecurringDropConfirm() - }, - [ - applyEventDrop, - handleCloseRecurringDropConfirm, - recurringDropConfirm.args, - recurringDropConfirm.target, - ], - ) - - // react-big-calendar props 구성 - const { calendarProps } = useCalendarRbcProps({ - view, - date, - events, - selectedEventKey, - effectiveSelectedDate: selectedDate, + handleCloseModalWithCleanup, + handleCloseDeleteConfirm, + handleCloseRecurringDropConfirm, + handleConfirmRecurringDrop, onView, - onNavigate, - onSelectDate, - onSelectEvent: selectEvent, - onSelectEventOnly: selectEventOnly, - onDoubleClickEvent: selectEvent, - onDoubleClickDate: handleWeekViewCreateEvent, - onSelectWeekDate: handleWeekViewSelectDate, - onToggleTodo: handleToggleTodo, - onSelectSlot: handleSelectSlotWrapper, - onEventDrop: handleEventDrop, - onEventResize: resizeEvent, - dateCellWrapper: DateCellWrapper, - dayViewComponent: dayViewWithHandlers, - localizer, - }) - - // 모달에 넘길 이벤트 조회 - const modalEvent = useMemo(() => { - if (modal.eventId == null) return null - - const selectedOccurrenceEvent = - selectedEventKey != null - ? (events.find((item) => getEventOccurrenceKey(item) === selectedEventKey) ?? null) - : null - - if (selectedOccurrenceEvent && selectedOccurrenceEvent.id === modal.eventId) { - return selectedOccurrenceEvent - } - - return events.find((item) => item.id === modal.eventId) ?? null - }, [events, modal.eventId, selectedEventKey]) - const modalMode: 'modal' | 'inline' = isInlineMode ? 'inline' : 'modal' - // 이벤트 수정 핸들러 묶음 - const eventActions = useMemo( - () => ({ - onEventColorChange: updateEventColor, - onEventTitleConfirm: updateEventTitle, - onEventSharedChange: updateEventShared, - onEventTypeChange: updateEventType, - onEventTimingChange: updateEventTiming, - }), - [updateEventColor, updateEventShared, updateEventTitle, updateEventType, updateEventTiming], - ) - useEffect(() => { - onSelectedDateChange?.(selectedDate ?? date) - }, [date, onSelectedDateChange, selectedDate]) + } = useCustomCalendarController({ localizer, initialView, onSelectedDateChange }) return (
@@ -518,7 +60,7 @@ const CustomCalendar = ({ onSelectedDateChange }: CustomCalendarProps) => { void + onEventTitleConfirm: (eventId: CalendarEvent['id'], title: CalendarEvent['title']) => void + onEventSharedChange: (eventId: CalendarEvent['id'], isShared: boolean) => void + onEventTypeChange: (eventId: CalendarEvent['id'], type: 'todo' | 'schedule') => void + onEventTimingChange: ( + eventId: CalendarEvent['id'], + start: Date, + end: Date, + allDay: boolean, + occurrenceDate?: CalendarEvent['occurrenceDate'], + ) => void +} + +export type DeleteConfirmState = { + isOpen: boolean + eventId: CalendarEvent['id'] | null + title: string + occurrenceDate: string +} + +export type RecurringDropConfirmState = { + isOpen: boolean + target: 'event' | 'todo' + args: EventInteractionArgs | null +} diff --git a/src/features/Calendar/components/CustomCalendar/CustomCalendarDialogs.tsx b/src/features/Calendar/components/CustomCalendar/CustomCalendarDialogs.tsx index 37f5cfd..45c403b 100644 --- a/src/features/Calendar/components/CustomCalendar/CustomCalendarDialogs.tsx +++ b/src/features/Calendar/components/CustomCalendar/CustomCalendarDialogs.tsx @@ -1,32 +1,19 @@ import type { UseMutateFunction } from '@tanstack/react-query' -import type { EventInteractionArgs } from 'react-big-calendar/lib/addons/dragAndDrop' -import type { CalendarEvent } from '@/shared/types/calendar/types' -import type { RecurrenceEventScope } from '@/shared/types/recurrence/recurrence' +import type { RecurrenceEventSeriesScope } from '@/shared/constants/recurrenceScope' import { EditConfirmModal, type EditConfirmOption } from '@/shared/ui/Modals' import DeleteConfirmModal from '@/shared/ui/Modals/DeleteConfirmModal/DeleteConfirmModal' +import type { DeleteConfirmState, RecurringDropConfirmState } from './CustomCalendar.types' + type EventDeleteVariables = { eventId: number params: { - scope?: RecurrenceEventScope + scope?: RecurrenceEventSeriesScope occurrenceDate: string } } -type DeleteConfirmState = { - isOpen: boolean - eventId: CalendarEvent['id'] | null - title: string - occurrenceDate: string -} - -type RecurringDropConfirmState = { - isOpen: boolean - target: 'event' | 'todo' - args: EventInteractionArgs | null -} - type CustomCalendarDialogsProps = { deleteConfirm: DeleteConfirmState onCloseDeleteConfirm: () => void diff --git a/src/features/Calendar/components/CustomEvent/CustomEvent.style.ts b/src/features/Calendar/components/CustomEvent/CustomEvent.style.ts index d067623..7b18b3f 100644 --- a/src/features/Calendar/components/CustomEvent/CustomEvent.style.ts +++ b/src/features/Calendar/components/CustomEvent/CustomEvent.style.ts @@ -5,6 +5,7 @@ import { theme } from '@/shared/styles/theme' export const Circle = styled.div<{ backgroundColor?: string }>` min-width: 10px; min-height: 10px; + flex: 0 0 auto; border-radius: 50%; background-color: ${({ backgroundColor }) => backgroundColor ?? 'transparent'}; ` @@ -14,6 +15,7 @@ export const TodoCheckbox = styled.input` max-height: 10px; width: 10px; height: 10px; + flex: 0 0 auto; aspect-ratio: 1 / 1; appearance: none; border: 1px dashed ${({ theme }) => theme.colors.textColor3}; @@ -66,6 +68,8 @@ export const MonthEventContainer = styled.div<{ align-items: center; justify-content: space-between; color: #1f1f1f; + overflow: hidden; + width: 100%; background-color: ${({ backgroundColor }) => backgroundColor ?? 'transparent'}; padding: 0 5px; border-radius: 8px; @@ -98,21 +102,25 @@ export const WeekEventContainer = styled.div<{ export const EventTitle = styled.div` font-weight: 400; font-size: 12px; - width: 75px; + width: 100%; + min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; ` export const EventMeta = styled.div` + flex: 0 0 auto; font-size: 8px; color: ${({ theme }) => theme.colors.black}; white-space: nowrap; min-width: fit-content; ` export const EventWeekMeta = styled.div` + flex: 0 0 auto; font-size: 10px; color: ${({ theme }) => theme.colors.black}; + white-space: nowrap; ` export const EventLocation = styled.div` @@ -121,13 +129,16 @@ export const EventLocation = styled.div` ` export const EventRow = styled.div` + flex: 1 1 auto; + min-width: 0; display: flex; align-items: center; gap: 3px; - max-width: 80%; + max-width: 100%; ` export const WeekEventRow = styled.div` + min-width: 0; display: flex; align-items: center; gap: 3px; diff --git a/src/features/Calendar/components/CustomView/CustomWeekView.tsx b/src/features/Calendar/components/CustomView/CustomWeekView.tsx index 9d6c6da..f03cc8d 100644 --- a/src/features/Calendar/components/CustomView/CustomWeekView.tsx +++ b/src/features/Calendar/components/CustomView/CustomWeekView.tsx @@ -6,18 +6,24 @@ import type { EventInteractionArgs } from 'react-big-calendar/lib/addons/dragAnd import People from '@/assets/icons/people.svg?react' import { getColorPalette } from '@/features/Calendar/utils/colorPalette' import { - compareByStart, - eventCoversDate, getEventOccurrenceKey, isDateOnlyString, } from '@/features/Calendar/utils/helpers/dayViewHelpers' +import { + buildAllDaySegments, + buildWeekDays, + buildWeekDropRange, + getAllDayLaneCount, + getDropDayIndex, + getTimedEventsForDate, + getWeeklyAllDayEvents, + KOREAN_WEEKDAYS, +} from '@/features/Calendar/utils/weekViewLayout' import type { CalendarEvent } from '@/shared/types/calendar/types' import { TodoCheckbox } from '../CustomEvent/CustomEvent.style' import * as S from './weekView' -const KOREAN_WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토'] as const - type WeekProps = { date?: Date events?: CalendarEvent[] @@ -31,14 +37,6 @@ type WeekProps = { selectedEventKey?: string | null } -type AllDaySegment = { - event: CalendarEvent - key: string - startIndex: number - endIndex: number - lane: number -} - const formatTime = (event: CalendarEvent) => { if (event.isAllDay || isDateOnlyString(event.start)) { return '종일' @@ -64,52 +62,19 @@ const CustomWeekView: React.ComponentType & ViewStatic = (({ onToggleTodo, selectedEventKey, }: WeekProps) => { - const weekStart = moment(date).startOf('week') - const weekDays = Array.from({ length: 7 }, (_, index) => weekStart.clone().add(index, 'day')) + const weekDays = React.useMemo(() => buildWeekDays(date), [date]) + const weekDayDates = React.useMemo( + () => weekDays.map((dayMoment) => dayMoment.toDate()), + [weekDays], + ) const allDaySectionRef = React.useRef(null) const today = moment() - const weeklyAllDayEvents = React.useMemo( - () => - events - .filter((event) => event.isAllDay || isDateOnlyString(event.start)) - .sort(compareByStart), - [events], - ) - const allDaySegments = React.useMemo(() => { - const laneLastEndIndexes: number[] = [] - const segments: AllDaySegment[] = [] - - weeklyAllDayEvents.forEach((event) => { - const coveredIndexes = weekDays - .map((dayMoment, index) => (eventCoversDate(event, dayMoment.toDate()) ? index : -1)) - .filter((index) => index >= 0) - if (coveredIndexes.length === 0) return - - const startIndex = coveredIndexes[0] - const endIndex = coveredIndexes[coveredIndexes.length - 1] - let lane = laneLastEndIndexes.findIndex((lastEnd) => startIndex > lastEnd) - if (lane === -1) { - lane = laneLastEndIndexes.length - laneLastEndIndexes.push(endIndex) - } else { - laneLastEndIndexes[lane] = endIndex - } - - segments.push({ - event, - key: getEventOccurrenceKey(event), - startIndex, - endIndex, - lane, - }) - }) - - return segments - }, [weekDays, weeklyAllDayEvents]) - const allDayLaneCount = React.useMemo( - () => allDaySegments.reduce((maxLane, segment) => Math.max(maxLane, segment.lane), -1) + 1, - [allDaySegments], + const weeklyAllDayEvents = React.useMemo(() => getWeeklyAllDayEvents(events), [events]) + const allDaySegments = React.useMemo( + () => buildAllDaySegments(weeklyAllDayEvents, weekDayDates), + [weekDayDates, weeklyAllDayEvents], ) + const allDayLaneCount = React.useMemo(() => getAllDayLaneCount(allDaySegments), [allDaySegments]) const eventsByOccurrenceKey = React.useMemo( () => new Map(events.map((event) => [getEventOccurrenceKey(event), event])), [events], @@ -125,39 +90,13 @@ const CustomWeekView: React.ComponentType & ViewStatic = (({ setDraggingEventKey(null) if (!draggingEvent) return - const originalStart = moment(draggingEvent.start) - const originalEnd = moment(draggingEvent.end) - const originalStartDay = originalStart.clone().startOf('day') - const originalEndDay = originalEnd.clone().startOf('day') - const originalAllDay = draggingEvent.isAllDay || isDateOnlyString(draggingEvent.start) - const durationMs = Math.max(originalEnd.diff(originalStart), 0) - const useAllDayTime = dropAsAllDay || originalAllDay - - const nextStart = useAllDayTime - ? moment(targetDate).startOf('day') - : moment(targetDate).set({ - hour: originalStart.hour(), - minute: originalStart.minute(), - second: originalStart.second(), - millisecond: originalStart.millisecond(), - }) - const nextEnd = useAllDayTime - ? (() => { - const spanDays = Math.max(originalEndDay.diff(originalStartDay, 'days') + 1, 1) - return nextStart - .clone() - .add(spanDays - 1, 'days') - .endOf('day') - })() - : durationMs > 0 - ? nextStart.clone().add(durationMs, 'milliseconds') - : nextStart.clone().add(1, 'hour') + const nextRange = buildWeekDropRange(draggingEvent, targetDate, dropAsAllDay) onEventDrop({ event: draggingEvent, - start: nextStart.toDate(), - end: nextEnd.toDate(), - allDay: useAllDayTime, + start: nextRange.start, + end: nextRange.end, + allDay: nextRange.allDay, } as EventInteractionArgs) }, [draggingEventKey, eventsByOccurrenceKey, onEventDrop], @@ -166,9 +105,7 @@ const CustomWeekView: React.ComponentType & ViewStatic = (({ (clientX: number) => { const sectionRect = allDaySectionRef.current?.getBoundingClientRect() if (!sectionRect || sectionRect.width <= 0) return - const relativeX = Math.max(0, Math.min(clientX - sectionRect.left, sectionRect.width - 1)) - const dayWidth = sectionRect.width / 7 - const dayIndex = Math.max(0, Math.min(6, Math.floor(relativeX / dayWidth))) + const dayIndex = getDropDayIndex(clientX, sectionRect, weekDays.length) handleDropToDate(weekDays[dayIndex].toDate(), true) }, [handleDropToDate, weekDays], @@ -309,10 +246,7 @@ const CustomWeekView: React.ComponentType & ViewStatic = (({ {weekDays.map((dayMoment) => { const dayDate = dayMoment.toDate() const isSelectedDay = selectedDate ? dayMoment.isSame(selectedDate, 'day') : false - const timedEvents = events - .filter((event) => eventCoversDate(event, dayDate)) - .filter((event) => !event.isAllDay && !isDateOnlyString(event.start)) - .sort(compareByStart) + const timedEvents = getTimedEventsForDate(events, dayDate) const renderEventCard = (event: CalendarEvent) => { const palette = getColorPalette(event.color) diff --git a/src/features/Calendar/components/CustomView/dayView/dragHandlers.ts b/src/features/Calendar/components/CustomView/dayView/dragHandlers.ts index deb0b23..9873526 100644 --- a/src/features/Calendar/components/CustomView/dayView/dragHandlers.ts +++ b/src/features/Calendar/components/CustomView/dayView/dragHandlers.ts @@ -1,47 +1,15 @@ import { type PointerEvent as ReactPointerEvent, useCallback, useRef, useState } from 'react' import type { CalendarEvent } from '../../../../../shared/types/calendar/types' -import { MIN_EVENT_DURATION_MINUTES } from './constants' import { - buildMoveRange, - buildResizeRange, - buildResizeStartRange, - getMinutesPerPixel, - shiftMinutesBy, - snapDateToMinutes, -} from './timeHelpers' - -const SNAP_MINUTES = 30 -const snapMinutes = (value: number) => Math.round(value / SNAP_MINUTES) * SNAP_MINUTES - -const snapMoveRange = (range: { nextStart: Date; nextEnd: Date }) => { - const snappedStart = snapDateToMinutes(range.nextStart, SNAP_MINUTES) - const snappedEnd = snapDateToMinutes(range.nextEnd, SNAP_MINUTES) - if (snappedEnd <= snappedStart) { - return { nextStart: snappedStart, nextEnd: shiftMinutesBy(snappedStart, SNAP_MINUTES) } - } - return { nextStart: snappedStart, nextEnd: snappedEnd } -} - -const snapResizeRange = (range: { nextStart: Date; nextEnd: Date }, start: Date) => { - const snappedEnd = snapDateToMinutes(range.nextEnd, SNAP_MINUTES) - const minEnd = shiftMinutesBy(start, MIN_EVENT_DURATION_MINUTES) - return { - nextStart: range.nextStart, - nextEnd: snappedEnd <= start ? minEnd : snappedEnd, - } -} - -const snapResizeStartRange = (range: { nextStart: Date; nextEnd: Date }, end: Date) => { - const snappedStart = snapDateToMinutes(range.nextStart, SNAP_MINUTES) - const maxStart = shiftMinutesBy(end, -MIN_EVENT_DURATION_MINUTES) - return { - nextStart: snappedStart >= end ? maxStart : snappedStart, - nextEnd: range.nextEnd, - } -} - -export type DragMode = 'move' | 'resize' | 'resize-start' + buildSnappedDragRange, + type DayViewDragMode, + getColumnShift, + snapMinutes, +} from './dragMath' +import { getMinutesPerPixel } from './timeHelpers' + +export type DragMode = DayViewDragMode export type DragState = { event: CalendarEvent startClientY: number @@ -139,22 +107,12 @@ export const useDayViewDragHandlers = ( if (Math.hypot(deltaX, deltaY) > 6) { dragThresholdPassedRef.current = true } - let columnShift = 0 - if (gridRect && typeof originColumnIndex === 'number') { - const gap = Math.max(columnGapPx, 0) - const columnWidth = (gridRect.width - gap) / 2 - const relativeX = moveEvent.clientX - gridRect.left - const clampedX = Math.min(Math.max(relativeX, 0), gridRect.width) - const leftBoundary = columnWidth - const rightBoundary = columnWidth + gap - let targetIndex = originColumnIndex - if (clampedX < leftBoundary) { - targetIndex = 0 - } else if (clampedX > rightBoundary) { - targetIndex = 1 - } - columnShift = targetIndex - originColumnIndex - } + const columnShift = getColumnShift({ + clientX: moveEvent.clientX, + gridRect, + originColumnIndex, + columnGapPx, + }) dragStateRef.current = { event: calendarEvent, startClientY: pointerStartY, @@ -171,14 +129,13 @@ export const useDayViewDragHandlers = ( preview: Boolean(onEventDragPreview), } if (onEventDragPreview) { - const totalMinutes = mode === 'move' ? deltaMinutes + columnShift * 12 * 60 : deltaMinutes - const snappedTotalMinutes = snapMinutes(totalMinutes) - const nextRange = - mode === 'move' - ? snapMoveRange(buildMoveRange(start, end, snappedTotalMinutes)) - : mode === 'resize' - ? snapResizeRange(buildResizeRange(start, end, snappedTotalMinutes), start) - : snapResizeStartRange(buildResizeStartRange(start, end, snappedTotalMinutes), end) + const { nextRange } = buildSnappedDragRange({ + mode, + start, + end, + deltaMinutes, + columnShift, + }) pendingPreviewRef.current = { event: calendarEvent, start: nextRange.nextStart, @@ -202,8 +159,13 @@ export const useDayViewDragHandlers = ( const currentState = dragStateRef.current const deltaMinutes = currentState?.deltaMinutes ?? 0 const columnShift = currentState?.columnShift ?? 0 - const totalMinutes = mode === 'move' ? deltaMinutes + columnShift * 12 * 60 : deltaMinutes - const snappedTotalMinutes = snapMinutes(totalMinutes) + const { snappedTotalMinutes, nextRange } = buildSnappedDragRange({ + mode, + start, + end, + deltaMinutes, + columnShift, + }) if (snappedTotalMinutes === 0) { if (pointerEvent.currentTarget instanceof HTMLElement) { pointerEvent.currentTarget.releasePointerCapture(pointerEvent.pointerId) @@ -211,12 +173,6 @@ export const useDayViewDragHandlers = ( cleanup() return } - const nextRange = - mode === 'move' - ? snapMoveRange(buildMoveRange(start, end, snappedTotalMinutes)) - : mode === 'resize' - ? snapResizeRange(buildResizeRange(start, end, snappedTotalMinutes), start) - : snapResizeStartRange(buildResizeStartRange(start, end, snappedTotalMinutes), end) if (pointerEvent.currentTarget instanceof HTMLElement) { pointerEvent.currentTarget.releasePointerCapture(pointerEvent.pointerId) } diff --git a/src/features/Calendar/components/CustomView/dayView/dragMath.ts b/src/features/Calendar/components/CustomView/dayView/dragMath.ts new file mode 100644 index 0000000..6aa8c3e --- /dev/null +++ b/src/features/Calendar/components/CustomView/dayView/dragMath.ts @@ -0,0 +1,100 @@ +import { MIN_EVENT_DURATION_MINUTES } from './constants' +import { + buildMoveRange, + buildResizeRange, + buildResizeStartRange, + shiftMinutesBy, + snapDateToMinutes, +} from './timeHelpers' + +const SNAP_MINUTES = 30 + +export type DayViewDragMode = 'move' | 'resize' | 'resize-start' +export type DateRange = { nextStart: Date; nextEnd: Date } + +export const snapMinutes = (value: number) => Math.round(value / SNAP_MINUTES) * SNAP_MINUTES + +export const snapMoveRange = (range: DateRange) => { + const snappedStart = snapDateToMinutes(range.nextStart, SNAP_MINUTES) + const snappedEnd = snapDateToMinutes(range.nextEnd, SNAP_MINUTES) + if (snappedEnd <= snappedStart) { + return { nextStart: snappedStart, nextEnd: shiftMinutesBy(snappedStart, SNAP_MINUTES) } + } + return { nextStart: snappedStart, nextEnd: snappedEnd } +} + +export const snapResizeRange = (range: DateRange, start: Date) => { + const snappedEnd = snapDateToMinutes(range.nextEnd, SNAP_MINUTES) + const minEnd = shiftMinutesBy(start, MIN_EVENT_DURATION_MINUTES) + return { + nextStart: range.nextStart, + nextEnd: snappedEnd <= start ? minEnd : snappedEnd, + } +} + +export const snapResizeStartRange = (range: DateRange, end: Date) => { + const snappedStart = snapDateToMinutes(range.nextStart, SNAP_MINUTES) + const maxStart = shiftMinutesBy(end, -MIN_EVENT_DURATION_MINUTES) + return { + nextStart: snappedStart >= end ? maxStart : snappedStart, + nextEnd: range.nextEnd, + } +} + +export const getColumnShift = ({ + clientX, + gridRect, + originColumnIndex, + columnGapPx, +}: { + clientX: number + gridRect: DOMRect | null + originColumnIndex?: number + columnGapPx: number +}) => { + if (!gridRect || typeof originColumnIndex !== 'number') return 0 + + const gap = Math.max(columnGapPx, 0) + const columnWidth = (gridRect.width - gap) / 2 + const relativeX = clientX - gridRect.left + const clampedX = Math.min(Math.max(relativeX, 0), gridRect.width) + const leftBoundary = columnWidth + const rightBoundary = columnWidth + gap + let targetIndex = originColumnIndex + + if (clampedX < leftBoundary) { + targetIndex = 0 + } else if (clampedX > rightBoundary) { + targetIndex = 1 + } + + return targetIndex - originColumnIndex +} + +export const buildSnappedDragRange = ({ + mode, + start, + end, + deltaMinutes, + columnShift, +}: { + mode: DayViewDragMode + start: Date + end: Date + deltaMinutes: number + columnShift: number +}) => { + const totalMinutes = mode === 'move' ? deltaMinutes + columnShift * 12 * 60 : deltaMinutes + const snappedTotalMinutes = snapMinutes(totalMinutes) + const nextRange = + mode === 'move' + ? snapMoveRange(buildMoveRange(start, end, snappedTotalMinutes)) + : mode === 'resize' + ? snapResizeRange(buildResizeRange(start, end, snappedTotalMinutes), start) + : snapResizeStartRange(buildResizeStartRange(start, end, snappedTotalMinutes), end) + + return { + snappedTotalMinutes, + nextRange, + } +} diff --git a/src/features/Calendar/components/CustomView/dayView/renderGeometry.ts b/src/features/Calendar/components/CustomView/dayView/renderGeometry.ts new file mode 100644 index 0000000..dd7e835 --- /dev/null +++ b/src/features/Calendar/components/CustomView/dayView/renderGeometry.ts @@ -0,0 +1,76 @@ +import { TIMED_SLOT_CONFIG } from '@/features/Calendar/domain/constants' + +import type { DragState } from './dragHandlers' + +const LANE_GAP = 6 + +export const getDayEventRenderGeometry = ({ + top, + height, + overflowTop, + overflowBottom, + laneIndex, + laneCount, + rowHeight, + columnWidth, + columnGapPx, + dragState, + eventId, +}: { + top: number + height: number + overflowTop?: boolean + overflowBottom?: boolean + laneIndex?: number + laneCount?: number + rowHeight: number + columnWidth: number + columnGapPx: number + dragState: DragState | null + eventId: number +}) => { + const rowHeightForCalc = rowHeight || TIMED_SLOT_CONFIG.SLOT_HEIGHT + const columnHeight = rowHeightForCalc * (TIMED_SLOT_CONFIG.MAX_VISUAL_HOURS - 1) + const minHeight = TIMED_SLOT_CONFIG.MIN_HEIGHT + const isDraggingEvent = dragState?.event.id === eventId + const isPreviewing = Boolean(dragState?.preview) + const columnShift = + isDraggingEvent && dragState?.mode === 'move' && !isPreviewing + ? (dragState?.columnShift ?? 0) + : 0 + const translateX = columnShift ? columnShift * (columnWidth + columnGapPx) : 0 + const moveDeltaMinutes = + isDraggingEvent && dragState?.mode === 'move' && !isPreviewing ? dragState.deltaMinutes : 0 + const resizeDeltaMinutes = + isDraggingEvent && dragState?.mode === 'resize' && !isPreviewing ? dragState.deltaMinutes : 0 + const moveOffset = (moveDeltaMinutes / 60) * rowHeightForCalc + const resizeOffset = (resizeDeltaMinutes / 60) * rowHeightForCalc + const baseTop = top + moveOffset + const clampedTop = Math.min(Math.max(baseTop, 0), columnHeight - minHeight) + const baseHeight = Math.max(height + resizeOffset, minHeight) + const clampedHeight = Math.min(baseHeight, Math.max(columnHeight - clampedTop, minHeight)) + const overflowTopResolved = Boolean(overflowTop) || baseTop < 0 + const overflowBottomResolved = Boolean(overflowBottom) || baseTop + baseHeight > columnHeight + const lanes = Math.max(laneCount ?? 1, 1) + const lane = Math.max(laneIndex ?? 0, 0) + const totalGap = lanes > 1 ? LANE_GAP * (lanes - 1) : 0 + const laneWidthCss = lanes > 1 ? `calc((100% - ${totalGap}px) / ${lanes})` : '100%' + const laneLeftCss = + lanes > 1 ? `calc(${lane} * (100% - ${totalGap}px) / ${lanes} + ${lane * LANE_GAP}px)` : '0px' + + return { + isDraggingEvent, + overflowTopResolved, + overflowBottomResolved, + style: { + top: clampedTop, + height: clampedHeight, + width: laneWidthCss, + left: laneLeftCss, + right: 'auto', + transform: translateX ? `translateX(${translateX}px)` : undefined, + transition: translateX ? 'transform 120ms ease' : undefined, + zIndex: isDraggingEvent ? 4 : undefined, + }, + } +} diff --git a/src/features/Calendar/components/CustomView/dayView/renderers.tsx b/src/features/Calendar/components/CustomView/dayView/renderers.tsx index d6121ed..8b46bee 100644 --- a/src/features/Calendar/components/CustomView/dayView/renderers.tsx +++ b/src/features/Calendar/components/CustomView/dayView/renderers.tsx @@ -10,6 +10,7 @@ import { getEventOccurrenceKey, type TimedSlotEvent } from '../../../utils/helpe import { TodoCheckbox } from '../../CustomEvent/CustomEvent.style' import * as S from '../dayView' import type { DragState, EventPointerDownHandler } from './dragHandlers' +import { getDayEventRenderGeometry } from './renderGeometry' import { normalizeDateValue } from './timeHelpers' /** 24시간을 12시간씩 두 컬럼으로 나누어 시간 라인을 렌더링합니다. */ @@ -137,14 +138,11 @@ export const renderTimeOverlayColumn = ({ columnIndex: number }) => { const rowHeightForCalc = rowHeight || TIMED_SLOT_CONFIG.SLOT_HEIGHT - const columnHeight = rowHeightForCalc * (TIMED_SLOT_CONFIG.MAX_VISUAL_HOURS - 1) - const minHeight = TIMED_SLOT_CONFIG.MIN_HEIGHT const gridRect = gridRef?.current?.getBoundingClientRect() ?? null const columnGapPx = gridRef?.current ? Number.parseFloat(getComputedStyle(gridRef.current).columnGap || '0') : 0 const columnWidth = gridRect ? (gridRect.width - columnGapPx) / 2 : 0 - const laneGap = 6 return ( {renderTimeSlotRows(startHour, date, handleSlotDoubleClick, slotRef)} @@ -153,42 +151,20 @@ export const renderTimeOverlayColumn = ({ ({ event, top, height, palette, overflowTop, overflowBottom, laneIndex, laneCount }) => { const eventStart = normalizeDateValue(event.start) const eventEnd = normalizeDateValue(event.end) - const currentDragState = dragStateRef.current - const isDraggingEvent = currentDragState?.event.id === event.id - const isPreviewing = Boolean(currentDragState?.preview) - const columnShift = - isDraggingEvent && currentDragState?.mode === 'move' && !isPreviewing - ? (currentDragState?.columnShift ?? 0) - : 0 - const translateX = columnShift ? columnShift * (columnWidth + columnGapPx) : 0 - const moveDeltaMinutes = - isDraggingEvent && currentDragState?.mode === 'move' && !isPreviewing - ? currentDragState.deltaMinutes - : 0 - const resizeDeltaMinutes = - isDraggingEvent && currentDragState?.mode === 'resize' && !isPreviewing - ? currentDragState.deltaMinutes - : 0 - const moveOffset = (moveDeltaMinutes / 60) * rowHeightForCalc - const resizeOffset = (resizeDeltaMinutes / 60) * rowHeightForCalc - const baseTop = top + moveOffset - const clampedTop = Math.min(Math.max(baseTop, 0), columnHeight - minHeight) - const baseHeight = Math.max(height + resizeOffset, minHeight) - const clampedHeight = Math.min( - baseHeight, - Math.max(columnHeight - clampedTop, minHeight), - ) - const overflowTopResolved = Boolean(overflowTop) || baseTop < 0 - const overflowBottomResolved = - Boolean(overflowBottom) || baseTop + baseHeight > columnHeight - const lanes = Math.max(laneCount ?? 1, 1) - const lane = Math.max(laneIndex ?? 0, 0) - const totalGap = lanes > 1 ? laneGap * (lanes - 1) : 0 - const laneWidthCss = lanes > 1 ? `calc((100% - ${totalGap}px) / ${lanes})` : '100%' - const laneLeftCss = - lanes > 1 - ? `calc(${lane} * (100% - ${totalGap}px) / ${lanes} + ${lane * laneGap}px)` - : '0px' + const { overflowTopResolved, overflowBottomResolved, style } = + getDayEventRenderGeometry({ + top, + height, + overflowTop, + overflowBottom, + laneIndex, + laneCount, + rowHeight: rowHeightForCalc, + columnWidth, + columnGapPx, + dragState: dragStateRef.current, + eventId: event.id, + }) return ( { if (dragThresholdPassedRef?.current) return onEventSelect?.(event) diff --git a/src/features/Calendar/components/CustomView/weekView.ts b/src/features/Calendar/components/CustomView/weekView.ts index 952da86..f5f04c0 100644 --- a/src/features/Calendar/components/CustomView/weekView.ts +++ b/src/features/Calendar/components/CustomView/weekView.ts @@ -201,17 +201,20 @@ export const EventHeader = styled.div` display: flex; align-items: center; gap: 4px; + min-width: 0; ` export const EventDot = styled.div<{ $color?: string }>` min-width: 10px; min-height: 10px; + flex: 0 0 auto; border-radius: 50%; background-color: ${({ $color }) => $color ?? theme.colors.textColor3}; ` export const EventTitle = styled.div` width: 100%; + min-width: 0; font-size: 12px; color: ${theme.colors.black}; white-space: nowrap; @@ -220,8 +223,10 @@ export const EventTitle = styled.div` ` export const EventMeta = styled.div` + flex: 0 0 auto; font-size: 10px; color: ${theme.colors.textColor3}; + white-space: nowrap; ` export const EmptyText = styled.div` diff --git a/src/features/Calendar/domain/config.ts b/src/features/Calendar/domain/config.ts index b1b5361..0c9219e 100644 --- a/src/features/Calendar/domain/config.ts +++ b/src/features/Calendar/domain/config.ts @@ -55,12 +55,13 @@ export const buildCalendarConfig = ({ localizer, culture: 'ko', views, - defaultView: Views.MONTH, + defaultView: view, view, date, events, startAccessor: (event) => normalizeDate(event.start), endAccessor: (event) => normalizeDate(event.end), + tooltipAccessor: () => '', onView, onNavigate, onSelectEvent, diff --git a/src/features/Calendar/hooks/index.ts b/src/features/Calendar/hooks/index.ts index f2a85fd..7ace55a 100644 --- a/src/features/Calendar/hooks/index.ts +++ b/src/features/Calendar/hooks/index.ts @@ -2,15 +2,17 @@ export { useCalendarApiEvents } from './useCalendarApiEvents' export { useCalendarCreateEvent } from './useCalendarCreateEvent' export { useCalendarCreateHandlers } from './useCalendarCreateHandlers' export { useCalendarDateRange } from './useCalendarDateRange' +export { useCalendarDayViewTiming } from './useCalendarDayViewTiming' export { useCalendarDraftEvent } from './useCalendarDraftEvent' export { useCalendarDragDrop } from './useCalendarDragDrop' export { useCalendarEvents } from './useCalendarEvents' export { useCalendarKeyDelete } from './useCalendarKeyDelete' export { useCalendarModal } from './useCalendarModal' export { useCalendarNavigation } from './useCalendarNavigation' -export { useCalendarPortals } from './useCalendarPortals' export { useCalendarRbcProps } from './useCalendarRbcProps' export { useCalendarResponsive } from './useCalendarResponsive' export { useCalendarSelection } from './useCalendarSelection' +export { useCalendarTodoTimingPatch } from './useCalendarTodoTimingPatch' +export { useCustomCalendarController } from './useCustomCalendarController' export { useDayViewHandlers } from './useDayViewHandlers' export { useStoredCalendarView } from './useStoredCalendarView' diff --git a/src/features/Calendar/hooks/useCalendarApiEvents.ts b/src/features/Calendar/hooks/useCalendarApiEvents.ts index 78b2e62..ffed916 100644 --- a/src/features/Calendar/hooks/useCalendarApiEvents.ts +++ b/src/features/Calendar/hooks/useCalendarApiEvents.ts @@ -47,7 +47,9 @@ const toTodoEvent = (todo: TodoType): CalendarEvent => { location: null, isAllDay: todo.isAllDay, color: todo.color ?? 'GRAY', + recurrenceGroup: null, + eventParticipantInfo: [], type: 'todo', isDone: todo.isCompleted, isRecurring: todo.isRecurring, diff --git a/src/features/Calendar/hooks/useCalendarDateCellWrapper.tsx b/src/features/Calendar/hooks/useCalendarDateCellWrapper.tsx new file mode 100644 index 0000000..0070fc2 --- /dev/null +++ b/src/features/Calendar/hooks/useCalendarDateCellWrapper.tsx @@ -0,0 +1,31 @@ +import { cloneElement, type MouseEvent, useCallback } from 'react' +import type { DateCellWrapperProps } from 'react-big-calendar' + +import type { CalendarEvent } from '@/shared/types/calendar/types' + +type UseCalendarDateCellWrapperArgs = { + setDate: (date: Date) => void + setSelectedEventId: (eventId: CalendarEvent['id'] | null) => void + setSelectedEventKey: (eventKey: string | null) => void +} + +export const useCalendarDateCellWrapper = ({ + setDate, + setSelectedEventId, + setSelectedEventKey, +}: UseCalendarDateCellWrapperArgs) => + useCallback( + ({ value, children }: DateCellWrapperProps) => + cloneElement(children, { + onClick: (event: MouseEvent) => { + event.stopPropagation() + setSelectedEventId(null) + setSelectedEventKey(null) + setDate(value) + if (typeof children.props.onClick === 'function') { + children.props.onClick(event) + } + }, + }), + [setDate, setSelectedEventId, setSelectedEventKey], + ) diff --git a/src/features/Calendar/hooks/useCalendarDayViewTiming.ts b/src/features/Calendar/hooks/useCalendarDayViewTiming.ts new file mode 100644 index 0000000..e6be172 --- /dev/null +++ b/src/features/Calendar/hooks/useCalendarDayViewTiming.ts @@ -0,0 +1,104 @@ +import moment from 'moment' +import { useCallback } from 'react' + +import { getEventOccurrenceScope } from '@/features/Calendar/utils/helpers/calendarRecurrenceScope' +import { resolveOccurrenceDateTime } from '@/features/Calendar/utils/helpers/dayViewHelpers' +import type { RecurrenceEventSeriesScope } from '@/shared/constants/recurrenceScope' +import type { CalendarEvent } from '@/shared/types/calendar/types' +import { useToastStore } from '@/store/useToastStore' + +import type { PatchTodoTiming } from './useCalendarTodoTimingPatch' + +type PatchEventPayload = { + eventId: number + params: { + occurrenceDate: string + scope?: RecurrenceEventSeriesScope + } + eventData: { + startTime: string + endTime: string + isAllDay: boolean + } +} + +type UseCalendarDayViewTimingArgs = { + events: CalendarEvent[] + patchEventMutate: (payload: PatchEventPayload) => void + patchTodoTiming: PatchTodoTiming + updateLocalEventTime: ( + eventId: CalendarEvent['id'], + start: Date, + end: Date, + type?: CalendarEvent['type'], + ) => void +} + +export const useCalendarDayViewTiming = ({ + events, + patchEventMutate, + patchTodoTiming, + updateLocalEventTime, +}: UseCalendarDayViewTimingArgs) => { + const showReadOnlyToast = useCallback(() => { + useToastStore.getState().showToast({ + title: '일정을 수정할 수 없습니다', + message: '일정 소유자만 수정할 수 있습니다.', + toastType: 'warning', + }) + }, []) + + const handleDayViewEventTimeChange = useCallback( + (eventId: CalendarEvent['id'], start: Date, end: Date, type?: CalendarEvent['type']) => { + if (type === 'todo') { + updateLocalEventTime(eventId, start, end, type) + const todoEvent = events.find( + (eventItem) => eventItem.id === eventId && eventItem.type === 'todo', + ) + if (todoEvent) { + patchTodoTiming(todoEvent, start) + } + return + } + + const targetEvent = events.find((eventItem) => eventItem.id === eventId) + if (targetEvent?.isOwner === false) { + showReadOnlyToast() + return + } + updateLocalEventTime(eventId, start, end, type) + const occurrenceDate = resolveOccurrenceDateTime( + targetEvent?.occurrenceDate, + targetEvent?.start ?? start, + ) + const patchScope = getEventOccurrenceScope(targetEvent?.recurrenceGroup != null) + patchEventMutate({ + eventId, + params: { + occurrenceDate, + ...(patchScope ? { scope: patchScope } : {}), + }, + eventData: { + startTime: moment(start).format('YYYY-MM-DDTHH:mm:ss'), + endTime: moment(end).format('YYYY-MM-DDTHH:mm:ss'), + isAllDay: false, + }, + }) + }, + [events, patchEventMutate, patchTodoTiming, showReadOnlyToast, updateLocalEventTime], + ) + + const handleDayViewEventTimePreview = useCallback( + (eventId: CalendarEvent['id'], start: Date, end: Date, type?: CalendarEvent['type']) => { + const targetEvent = events.find((eventItem) => eventItem.id === eventId) + if (type !== 'todo' && targetEvent?.isOwner === false) return + updateLocalEventTime(eventId, start, end, type) + }, + [events, updateLocalEventTime], + ) + + return { + handleDayViewEventTimeChange, + handleDayViewEventTimePreview, + } +} diff --git a/src/features/Calendar/hooks/useCalendarDeleteConfirm.ts b/src/features/Calendar/hooks/useCalendarDeleteConfirm.ts new file mode 100644 index 0000000..a4dd48e --- /dev/null +++ b/src/features/Calendar/hooks/useCalendarDeleteConfirm.ts @@ -0,0 +1,90 @@ +import moment from 'moment' +import { useCallback, useState } from 'react' + +import type { DeleteConfirmState } from '@/features/Calendar/components/CustomCalendar/CustomCalendar.types' +import { getEventOccurrenceScope } from '@/features/Calendar/utils/helpers/calendarRecurrenceScope' +import type { CalendarEvent } from '@/shared/types/calendar/types' + +type UseCalendarDeleteConfirmArgs = { + events: CalendarEvent[] + deleteEventMutate: ( + variables: { + eventId: number + params: { + scope?: ReturnType + occurrenceDate: string + } + }, + options?: { + onSuccess?: () => void + }, + ) => void + refetchEvents: () => void +} + +export const useCalendarDeleteConfirm = ({ + events, + deleteEventMutate, + refetchEvents, +}: UseCalendarDeleteConfirmArgs) => { + const [deleteConfirm, setDeleteConfirm] = useState({ + isOpen: false, + eventId: null, + title: '', + occurrenceDate: '', + }) + + const handleCloseDeleteConfirm = useCallback(() => { + setDeleteConfirm({ isOpen: false, eventId: null, title: '', occurrenceDate: '' }) + }, []) + + const openDeleteConfirm = useCallback( + ({ eventId, title, occurrenceDate }: Omit) => { + setDeleteConfirm({ + isOpen: true, + eventId, + title, + occurrenceDate, + }) + }, + [], + ) + + const isRecurring = useCallback( + (eventId: CalendarEvent['id']) => { + const target = events.find((eventItem) => eventItem.id === eventId) + if (!target) return false + if (target.type === 'todo') return Boolean(target.isRecurring) + return target.recurrenceGroup != null + }, + [events], + ) + + const handleRemoveEvent = useCallback( + (eventId: CalendarEvent['id'], occurrenceDate: string, isRecurringEvent: boolean) => { + deleteEventMutate( + { + eventId, + params: { + ...(isRecurringEvent ? { scope: getEventOccurrenceScope(isRecurringEvent) } : {}), + occurrenceDate: moment(occurrenceDate).format('YYYY-MM-DDTHH:mm:ss'), + }, + }, + { + onSuccess: () => { + refetchEvents() + }, + }, + ) + }, + [deleteEventMutate, refetchEvents], + ) + + return { + deleteConfirm, + isRecurring, + handleRemoveEvent, + openDeleteConfirm, + handleCloseDeleteConfirm, + } +} diff --git a/src/features/Calendar/hooks/useCalendarDragDrop.ts b/src/features/Calendar/hooks/useCalendarDragDrop.ts index 954957b..6cfed1f 100644 --- a/src/features/Calendar/hooks/useCalendarDragDrop.ts +++ b/src/features/Calendar/hooks/useCalendarDragDrop.ts @@ -5,6 +5,7 @@ import { Views } from 'react-big-calendar' import type { EventInteractionArgs } from 'react-big-calendar/lib/addons/dragAndDrop' import { resolveOccurrenceDateTime } from '@/features/Calendar/utils/helpers/dayViewHelpers' +import { RECURRENCE_EVENT_SCOPE } from '@/shared/constants/recurrenceScope' import type { CalendarEvent } from '@/shared/types/calendar/types' import type { RecurrenceEventScope, @@ -16,6 +17,7 @@ import { toWeekday, toWeekOfMonth, } from '@/shared/utils/recurrencePattern' +import { useToastStore } from '@/store/useToastStore' export const buildRecurringGroupForFutureDrop = ( recurrenceGroup: CalendarEvent['recurrenceGroup'], @@ -98,6 +100,14 @@ export const useCalendarDragDrop = ({ patchTodoTiming, onRequireRecurringDropConfirm, }: UseCalendarDragDropArgs) => { + const showReadOnlyToast = useCallback(() => { + useToastStore.getState().showToast({ + title: '일정을 수정할 수 없습니다', + message: '일정 소유자만 수정할 수 있습니다.', + toastType: 'warning', + }) + }, []) + const applyEventDrop = useCallback( ( args: EventInteractionArgs, @@ -106,9 +116,13 @@ export const useCalendarDragDrop = ({ todoScope?: RecurrenceTodoScope }, ) => { + const { event, start, end } = args + if (event.type !== 'todo' && event.isOwner === false) { + showReadOnlyToast() + return + } moveEvent(args) if (view !== Views.MONTH && view !== Views.WEEK) return - const { event, start, end } = args if (event.type === 'todo') { const defaultOccurrenceDate = moment(event.occurrenceDate ?? event.start).format( 'YYYY-MM-DD', @@ -132,7 +146,7 @@ export const useCalendarDragDrop = ({ startTime: nextStart, endTime: nextEnd, isAllDay: event.isAllDay ?? false, - ...(options?.eventScope === 'THIS_AND_FOLLOWING_EVENTS' + ...(options?.eventScope === RECURRENCE_EVENT_SCOPE.THIS_AND_FOLLOWING_EVENTS ? { recurrenceGroup: buildRecurringGroupForFutureDrop( event.recurrenceGroup, @@ -143,12 +157,16 @@ export const useCalendarDragDrop = ({ }, }) }, - [moveEvent, patchEventMutate, patchTodoTiming, view], + [moveEvent, patchEventMutate, patchTodoTiming, showReadOnlyToast, view], ) const handleEventDrop = useCallback( (args: EventInteractionArgs) => { const { event } = args + if (event.type !== 'todo' && event.isOwner === false) { + showReadOnlyToast() + return + } if ( (view === Views.MONTH || view === Views.WEEK) && event.type !== 'todo' && @@ -167,7 +185,7 @@ export const useCalendarDragDrop = ({ } applyEventDrop(args) }, - [applyEventDrop, onRequireRecurringDropConfirm, view], + [applyEventDrop, onRequireRecurringDropConfirm, showReadOnlyToast, view], ) return { handleEventDrop, applyEventDrop } diff --git a/src/features/Calendar/hooks/useCalendarKeyDelete.ts b/src/features/Calendar/hooks/useCalendarKeyDelete.ts index 657b7ff..56fbf7b 100644 --- a/src/features/Calendar/hooks/useCalendarKeyDelete.ts +++ b/src/features/Calendar/hooks/useCalendarKeyDelete.ts @@ -6,7 +6,9 @@ import { getEventOccurrenceKey, resolveOccurrenceDateTime, } from '@/features/Calendar/utils/helpers/dayViewHelpers' +import { RECURRENCE_TODO_SCOPE } from '@/shared/constants/recurrenceScope' import type { CalendarEvent } from '@/shared/types/calendar/types' +import type { RecurrenceTodoScope } from '@/shared/types/recurrence/recurrence' type UseCalendarKeyDeleteArgs = { isModalOpen: boolean @@ -26,7 +28,11 @@ type UseCalendarKeyDeleteArgs = { occurrenceDate: string, isRecurring: boolean, ) => void - onDeleteTodo: (payload: { todoId: number; occurrenceDate: string; scope?: 'THIS_TODO' }) => void + onDeleteTodo: (payload: { + todoId: number + occurrenceDate: string + scope?: RecurrenceTodoScope + }) => void } // input, textarea, contenteditable 요소에서는 백스페이스로 이벤트 삭제 안되도록 막기 @@ -81,7 +87,7 @@ export const useCalendarKeyDelete = ({ onDeleteTodo({ todoId: selectedEvent.id, occurrenceDate: selectedEvent.occurrenceDate ?? moment(baseDate).format('YYYY-MM-DD'), - scope: isRecurringEvent ? 'THIS_TODO' : undefined, + scope: isRecurringEvent ? RECURRENCE_TODO_SCOPE.THIS_TODO : undefined, }) onClearSelection() return diff --git a/src/features/Calendar/hooks/useCalendarPortals.ts b/src/features/Calendar/hooks/useCalendarPortals.ts deleted file mode 100644 index daa65e7..0000000 --- a/src/features/Calendar/hooks/useCalendarPortals.ts +++ /dev/null @@ -1,9 +0,0 @@ -// 캘린더 모달/카드 포털 DOM을 제공하는 훅 -export const useCalendarPortals = () => { - const modalPortalRoot = - typeof document === 'undefined' ? null : document.getElementById('modal-root') - const cardPortalRoot = - typeof document === 'undefined' ? null : document.getElementById('desktop-card-area') - - return { modalPortalRoot, cardPortalRoot } -} diff --git a/src/features/Calendar/hooks/useCalendarRecurringDropConfirm.ts b/src/features/Calendar/hooks/useCalendarRecurringDropConfirm.ts new file mode 100644 index 0000000..d8ecc9d --- /dev/null +++ b/src/features/Calendar/hooks/useCalendarRecurringDropConfirm.ts @@ -0,0 +1,68 @@ +import { useCallback, useState } from 'react' + +import type { RecurringDropConfirmState } from '@/features/Calendar/components/CustomCalendar/CustomCalendar.types' +import { + getEventScopeFromEditOption, + getTodoScopeFromEditOption, +} from '@/features/Calendar/utils/helpers/calendarRecurrenceScope' +import type { EditConfirmOption } from '@/shared/ui/Modals' + +import { useCalendarDragDrop } from './useCalendarDragDrop' + +type UseCalendarRecurringDropConfirmArgs = Omit< + Parameters[0], + 'onRequireRecurringDropConfirm' +> + +export const useCalendarRecurringDropConfirm = ({ + view, + moveEvent, + patchEventMutate, + patchTodoTiming, +}: UseCalendarRecurringDropConfirmArgs) => { + const [recurringDropConfirm, setRecurringDropConfirm] = useState({ + isOpen: false, + target: 'event', + args: null, + }) + + const { handleEventDrop, applyEventDrop } = useCalendarDragDrop({ + view, + moveEvent, + patchEventMutate, + patchTodoTiming, + onRequireRecurringDropConfirm: (args, target) => { + setRecurringDropConfirm({ isOpen: true, target, args }) + }, + }) + + const handleCloseRecurringDropConfirm = useCallback(() => { + setRecurringDropConfirm({ isOpen: false, target: 'event', args: null }) + }, []) + + const handleConfirmRecurringDrop = useCallback( + (option: EditConfirmOption) => { + if (!recurringDropConfirm.args) return + if (recurringDropConfirm.target === 'todo') { + applyEventDrop(recurringDropConfirm.args, { todoScope: getTodoScopeFromEditOption(option) }) + handleCloseRecurringDropConfirm() + return + } + applyEventDrop(recurringDropConfirm.args, { eventScope: getEventScopeFromEditOption(option) }) + handleCloseRecurringDropConfirm() + }, + [ + applyEventDrop, + handleCloseRecurringDropConfirm, + recurringDropConfirm.args, + recurringDropConfirm.target, + ], + ) + + return { + recurringDropConfirm, + handleEventDrop, + handleCloseRecurringDropConfirm, + handleConfirmRecurringDrop, + } +} diff --git a/src/features/Calendar/hooks/useCalendarSelectionBridge.ts b/src/features/Calendar/hooks/useCalendarSelectionBridge.ts new file mode 100644 index 0000000..a27cac0 --- /dev/null +++ b/src/features/Calendar/hooks/useCalendarSelectionBridge.ts @@ -0,0 +1,34 @@ +import { useCallback } from 'react' + +import { useCalendarSelection } from '@/features/Calendar/hooks/useCalendarSelection' +import type { CalendarEvent } from '@/shared/types/calendar/types' + +type UseCalendarSelectionBridgeArgs = { + isModalEditing: boolean + isModalOpen: boolean + modalEventId: CalendarEvent['id'] | null + removeEvent: (eventId: CalendarEvent['id']) => void + handleEventClick: (event: CalendarEvent) => void +} + +export const useCalendarSelectionBridge = ({ + isModalEditing, + isModalOpen, + modalEventId, + removeEvent, + handleEventClick, +}: UseCalendarSelectionBridgeArgs) => { + const handleOpenEventFromCalendar = useCallback( + (event: CalendarEvent) => { + if (!isModalEditing && isModalOpen && modalEventId != null) { + removeEvent(modalEventId) + } + handleEventClick(event) + }, + [handleEventClick, isModalEditing, isModalOpen, modalEventId, removeEvent], + ) + + return useCalendarSelection({ + onOpenEvent: handleOpenEventFromCalendar, + }) +} diff --git a/src/features/Calendar/hooks/useCalendarTodoActions.ts b/src/features/Calendar/hooks/useCalendarTodoActions.ts new file mode 100644 index 0000000..a0c37c8 --- /dev/null +++ b/src/features/Calendar/hooks/useCalendarTodoActions.ts @@ -0,0 +1,40 @@ +import moment from 'moment' +import { useCallback } from 'react' + +import type { CalendarEvent } from '@/shared/types/calendar/types' + +type UseCalendarTodoActionsArgs = { + events: CalendarEvent[] + toggleEventDone: (eventId: CalendarEvent['id'], type: 'todo') => void + patchCompleteTodoMutate: (variables: { + todoId: CalendarEvent['id'] + occurrenceDate: string + isCompleted: boolean + }) => void +} + +export const useCalendarTodoActions = ({ + events, + toggleEventDone, + patchCompleteTodoMutate, +}: UseCalendarTodoActionsArgs) => { + const handleToggleTodo = useCallback( + (eventId: CalendarEvent['id']) => { + const target = events.find( + (eventItem) => eventItem.id === eventId && eventItem.type === 'todo', + ) + if (!target || target.type !== 'todo') return + + const nextCompleted = !target.isDone + toggleEventDone(eventId, 'todo') + patchCompleteTodoMutate({ + todoId: eventId, + occurrenceDate: moment(target.start).format('YYYY-MM-DD'), + isCompleted: nextCompleted, + }) + }, + [events, patchCompleteTodoMutate, toggleEventDone], + ) + + return { handleToggleTodo } +} diff --git a/src/features/Calendar/hooks/useCalendarTodoTimingPatch.ts b/src/features/Calendar/hooks/useCalendarTodoTimingPatch.ts new file mode 100644 index 0000000..008d689 --- /dev/null +++ b/src/features/Calendar/hooks/useCalendarTodoTimingPatch.ts @@ -0,0 +1,95 @@ +import moment from 'moment' +import { useCallback, useRef } from 'react' + +import { buildRecurringGroupForFutureDrop } from '@/features/Calendar/hooks/useCalendarDragDrop' +import { + getTodoOccurrenceScope, + isTodoFollowingScope, +} from '@/features/Calendar/utils/helpers/calendarRecurrenceScope' +import { getDetailTodo } from '@/shared/api/todo/api' +import type { CalendarEvent } from '@/shared/types/calendar/types' +import type { RecurrenceGroup, RecurrenceTodoScope } from '@/shared/types/recurrence/recurrence' +import type { PatchTodoRequestDTO } from '@/shared/types/todo/types' + +type PatchTodoPayload = { + todoId: number + requestBody: PatchTodoRequestDTO + occurrenceDate?: string + scope?: RecurrenceTodoScope +} + +export type PatchTodoTiming = ( + todoEvent: CalendarEvent, + start: Date, + options?: { scope?: RecurrenceTodoScope; occurrenceDate?: string }, +) => void + +type UseCalendarTodoTimingPatchArgs = { + patchTodoMutate: (payload: PatchTodoPayload) => void +} + +export const useCalendarTodoTimingPatch = ({ patchTodoMutate }: UseCalendarTodoTimingPatchArgs) => { + const recurringTodoPatchSeqRef = useRef>(new Map()) + + const resolveFutureTodoRecurrenceGroup = useCallback( + async (todoId: number, occurrenceDate: string, nextStart: Date) => { + try { + const { result } = await getDetailTodo(todoId, occurrenceDate) + return buildRecurringGroupForFutureDrop(result?.recurrenceGroup ?? null, nextStart) + } catch (error) { + console.error('[useCalendarTodoTimingPatch] failed to resolve todo recurrenceGroup', error) + return undefined + } + }, + [], + ) + + const patchTodoTiming = useCallback( + ( + todoEvent: CalendarEvent, + start: Date, + options?: { scope?: RecurrenceTodoScope; occurrenceDate?: string }, + ) => { + const startDate = moment(start).format('YYYY-MM-DD') + const occurrenceDate = + options?.occurrenceDate ?? + moment(todoEvent.occurrenceDate ?? todoEvent.start).format('YYYY-MM-DD') + const patchScope = options?.scope ?? getTodoOccurrenceScope(Boolean(todoEvent.isRecurring)) + const dueTime = todoEvent.isAllDay ? undefined : moment(start).format('HH:mm') + + const submitPatch = (recurrenceGroup?: RecurrenceGroup) => { + patchTodoMutate({ + todoId: todoEvent.id, + occurrenceDate, + ...(patchScope ? { scope: patchScope } : {}), + requestBody: { + startDate, + dueTime, + isAllDay: todoEvent.isAllDay, + ...(recurrenceGroup ? { recurrenceGroup } : {}), + }, + }) + } + + if (isTodoFollowingScope(patchScope)) { + const requestKey = `${todoEvent.id}-${occurrenceDate}` + const nextSequence = (recurringTodoPatchSeqRef.current.get(requestKey) ?? 0) + 1 + recurringTodoPatchSeqRef.current.set(requestKey, nextSequence) + + void resolveFutureTodoRecurrenceGroup(todoEvent.id, occurrenceDate, start).then( + (recurrenceGroup) => { + const latestSequence = recurringTodoPatchSeqRef.current.get(requestKey) + if (latestSequence !== nextSequence) return + submitPatch(recurrenceGroup) + }, + ) + return + } + + submitPatch() + }, + [patchTodoMutate, resolveFutureTodoRecurrenceGroup], + ) + + return { patchTodoTiming } +} diff --git a/src/features/Calendar/hooks/useCalendarViewCreationHandlers.ts b/src/features/Calendar/hooks/useCalendarViewCreationHandlers.ts new file mode 100644 index 0000000..aecc88d --- /dev/null +++ b/src/features/Calendar/hooks/useCalendarViewCreationHandlers.ts @@ -0,0 +1,100 @@ +import moment from 'moment' +import { useCallback } from 'react' + +import { useCalendarCreateHandlers } from '@/features/Calendar/hooks/useCalendarCreateHandlers' +import { useDayViewHandlers } from '@/features/Calendar/hooks/useDayViewHandlers' +import type { CalendarEvent } from '@/shared/types/calendar/types' + +type UseCalendarViewCreationHandlersArgs = { + view: Parameters[0]['view'] + enqueueEvent: (date: Date, allDay: boolean) => CalendarEvent['id'] | null + handleAddEvent: (referenceDate?: Date | string, eventId?: CalendarEvent['id'] | null) => void + setSelectedDate: (date: Date | null) => void + setSelectedEventId: (eventId: CalendarEvent['id'] | null) => void + setSelectedEventKey: (eventKey: string | null) => void + updateEventTime: ( + eventId: CalendarEvent['id'], + start: Date, + end: Date, + type?: CalendarEvent['type'], + ) => void + updateEventTimePreview: ( + eventId: CalendarEvent['id'], + start: Date, + end: Date, + type?: CalendarEvent['type'], + ) => void + onToggleTodo: (eventId: CalendarEvent['id']) => void + selectedEventKey: string | null + selectEventOnly: (event: CalendarEvent) => void + selectEvent: (event: CalendarEvent) => void +} + +export const useCalendarViewCreationHandlers = ({ + view, + enqueueEvent, + handleAddEvent, + setSelectedDate, + setSelectedEventId, + setSelectedEventKey, + updateEventTime, + updateEventTimePreview, + onToggleTodo, + selectedEventKey, + selectEventOnly, + selectEvent, +}: UseCalendarViewCreationHandlersArgs) => { + const { handleDayViewCreateEvent, handleSelectSlotWrapper } = useCalendarCreateHandlers({ + view, + enqueueEvent, + onAddEvent: handleAddEvent, + setSelectedDate, + setSelectedEventId, + setSelectedEventKey, + }) + + const handleWeekViewCreateEvent = useCallback( + (slotDate: Date) => { + const start = moment(slotDate).startOf('day').set({ hour: 9, minute: 0, second: 0 }).toDate() + const createdId = enqueueEvent(start, false) + if (createdId != null) { + handleAddEvent(start, createdId) + } + }, + [enqueueEvent, handleAddEvent], + ) + + const handleWeekViewSelectDate = useCallback( + (nextDate: Date) => { + setSelectedDate(nextDate) + setSelectedEventId(null) + setSelectedEventKey(null) + }, + [setSelectedDate, setSelectedEventId, setSelectedEventKey], + ) + + const dayViewWithHandlers = useDayViewHandlers({ + clearSelectedDate: () => setSelectedDate(null), + clearSelectedEvent: () => { + setSelectedEventId(null) + setSelectedEventKey(null) + }, + enqueueEvent, + handleAddEvent, + updateEventTime, + updateEventTimePreview, + onCreateEvent: handleDayViewCreateEvent, + onToggleTodo, + selectedEventKey, + onEventSelect: selectEventOnly, + onEventClick: undefined, + onEventDoubleClick: selectEvent, + }) + + return { + dayViewWithHandlers, + handleSelectSlotWrapper, + handleWeekViewCreateEvent, + handleWeekViewSelectDate, + } +} diff --git a/src/features/Calendar/hooks/useCustomCalendarController.tsx b/src/features/Calendar/hooks/useCustomCalendarController.tsx new file mode 100644 index 0000000..fdcb31d --- /dev/null +++ b/src/features/Calendar/hooks/useCustomCalendarController.tsx @@ -0,0 +1,253 @@ +import { useEffect, useMemo, useState } from 'react' +import type { DateLocalizer, View } from 'react-big-calendar' + +import type { CalendarEventActions } from '@/features/Calendar/components/CustomCalendar/CustomCalendar.types' +import { useCalendarApiEvents } from '@/features/Calendar/hooks/useCalendarApiEvents' +import { useCalendarDateCellWrapper } from '@/features/Calendar/hooks/useCalendarDateCellWrapper' +import { useCalendarDateRange } from '@/features/Calendar/hooks/useCalendarDateRange' +import { useCalendarDayViewTiming } from '@/features/Calendar/hooks/useCalendarDayViewTiming' +import { useCalendarDeleteConfirm } from '@/features/Calendar/hooks/useCalendarDeleteConfirm' +import { useCalendarDraftEvent } from '@/features/Calendar/hooks/useCalendarDraftEvent' +import { useCalendarEvents } from '@/features/Calendar/hooks/useCalendarEvents' +import { useCalendarKeyDelete } from '@/features/Calendar/hooks/useCalendarKeyDelete' +import { useCalendarModal } from '@/features/Calendar/hooks/useCalendarModal' +import { useCalendarNavigation } from '@/features/Calendar/hooks/useCalendarNavigation' +import { useCalendarRbcProps } from '@/features/Calendar/hooks/useCalendarRbcProps' +import { useCalendarRecurringDropConfirm } from '@/features/Calendar/hooks/useCalendarRecurringDropConfirm' +import { useCalendarResponsive } from '@/features/Calendar/hooks/useCalendarResponsive' +import { useCalendarSelectionBridge } from '@/features/Calendar/hooks/useCalendarSelectionBridge' +import { useCalendarTodoActions } from '@/features/Calendar/hooks/useCalendarTodoActions' +import { useCalendarViewCreationHandlers } from '@/features/Calendar/hooks/useCalendarViewCreationHandlers' +import { useCustomCalendarMutations } from '@/features/Calendar/hooks/useCustomCalendarMutations' +import { useStoredCalendarView } from '@/features/Calendar/hooks/useStoredCalendarView' +import { getCalendarModalEvent } from '@/features/Calendar/utils/helpers/calendarModalEvent' + +type UseCustomCalendarControllerArgs = { + localizer: DateLocalizer + initialView?: View + onSelectedDateChange?: (selectedDate: Date) => void +} + +export const useCustomCalendarController = ({ + localizer, + initialView, + onSelectedDateChange, +}: UseCustomCalendarControllerArgs) => { + const { view, setView } = useStoredCalendarView({ initialView }) + const [date, setDate] = useState(new Date()) + const { startDate, endDate } = useCalendarDateRange(view, date) + const { events: apiEvents, refetch: refetchEvents } = useCalendarApiEvents(startDate, endDate) + const { + patchEventMutate, + deleteEventMutate, + patchCompleteTodoMutate, + patchTodoTiming, + deleteTodoMutate, + } = useCustomCalendarMutations() + + const { + events, + addEvent: enqueueEvent, + moveEvent, + resizeEvent, + updateEventTime: updateLocalEventTime, + updateEventColor, + updateEventTiming, + updateEventType, + updateEventTitle, + updateEventShared, + toggleEventDone, + removeEvent, + } = useCalendarEvents({ initialEvents: apiEvents }) + + const isDesktop = useCalendarResponsive() + const modalMode: 'modal' | 'inline' = isDesktop ? 'inline' : 'modal' + const { handleToggleTodo } = useCalendarTodoActions({ + events, + toggleEventDone, + patchCompleteTodoMutate, + }) + const { + deleteConfirm, + isRecurring, + handleRemoveEvent, + openDeleteConfirm, + handleCloseDeleteConfirm, + } = useCalendarDeleteConfirm({ + events, + deleteEventMutate, + refetchEvents, + }) + + const { modal, modalDate, isModalEditing, handleAddEvent, handleEventClick, handleCloseModal } = + useCalendarModal({ + currentDate: date, + removeEvent: handleRemoveEvent, + isRecurring, + }) + + const { + selectedDate, + setSelectedDate, + selectedEventId, + setSelectedEventId, + selectedEventKey, + setSelectedEventKey, + clearSelection, + selectEvent, + selectEventOnly, + } = useCalendarSelectionBridge({ + isModalEditing, + isModalOpen: modal.isOpen, + modalEventId: modal.eventId, + removeEvent, + handleEventClick, + }) + + const { handleCloseModalWithCleanup, enqueueDraftEvent } = useCalendarDraftEvent({ + events, + isModalEditing, + modal, + removeEvent, + handleCloseModal, + enqueueEvent, + updateEventTiming, + }) + + const { handleDayViewEventTimeChange, handleDayViewEventTimePreview } = useCalendarDayViewTiming({ + events, + patchEventMutate, + patchTodoTiming, + updateLocalEventTime, + }) + + useCalendarKeyDelete({ + isModalOpen: modal.isOpen, + date, + events, + selectedEventId, + selectedEventKey, + selectedDate, + onClearSelection: clearSelection, + onOpenRecurringConfirm: openDeleteConfirm, + onRemoveEvent: handleRemoveEvent, + onDeleteTodo: deleteTodoMutate, + }) + + const { onView, onNavigate, onSelectDate } = useCalendarNavigation({ + view, + date, + isDesktop, + setView, + setDate, + setSelectedDate, + setSelectedEventId, + setSelectedEventKey, + }) + + const { + dayViewWithHandlers, + handleSelectSlotWrapper, + handleWeekViewCreateEvent, + handleWeekViewSelectDate, + } = useCalendarViewCreationHandlers({ + view, + enqueueEvent: enqueueDraftEvent, + handleAddEvent, + setSelectedDate, + setSelectedEventId, + setSelectedEventKey, + updateEventTime: handleDayViewEventTimeChange, + updateEventTimePreview: handleDayViewEventTimePreview, + onToggleTodo: handleToggleTodo, + selectedEventKey, + selectEventOnly, + selectEvent, + }) + + const DateCellWrapper = useCalendarDateCellWrapper({ + setDate, + setSelectedEventId, + setSelectedEventKey, + }) + + const { + recurringDropConfirm, + handleEventDrop, + handleCloseRecurringDropConfirm, + handleConfirmRecurringDrop, + } = useCalendarRecurringDropConfirm({ + view, + moveEvent, + patchEventMutate, + patchTodoTiming, + }) + + const { calendarProps } = useCalendarRbcProps({ + view, + date, + events, + selectedEventKey, + effectiveSelectedDate: selectedDate, + onView, + onNavigate, + onSelectDate, + onSelectEvent: selectEvent, + onSelectEventOnly: selectEventOnly, + onDoubleClickEvent: selectEvent, + onDoubleClickDate: handleWeekViewCreateEvent, + onSelectWeekDate: handleWeekViewSelectDate, + onToggleTodo: handleToggleTodo, + onSelectSlot: handleSelectSlotWrapper, + onEventDrop: handleEventDrop, + onEventResize: resizeEvent, + dateCellWrapper: DateCellWrapper, + dayViewComponent: dayViewWithHandlers, + localizer, + }) + + const modalEvent = useMemo( + () => + getCalendarModalEvent({ + events, + modalEventId: modal.eventId, + selectedEventKey, + }), + [events, modal.eventId, selectedEventKey], + ) + const eventActions = useMemo( + () => ({ + onEventColorChange: updateEventColor, + onEventTitleConfirm: updateEventTitle, + onEventSharedChange: updateEventShared, + onEventTypeChange: updateEventType, + onEventTimingChange: updateEventTiming, + }), + [updateEventColor, updateEventShared, updateEventTitle, updateEventType, updateEventTiming], + ) + + useEffect(() => { + onSelectedDateChange?.(selectedDate ?? date) + }, [date, onSelectedDateChange, selectedDate]) + + return { + view, + date, + calendarProps, + modalDate, + modalEventId: modal.eventId, + modalEvent, + isModalEditing, + modalMode, + eventActions, + deleteConfirm, + recurringDropConfirm, + deleteEventMutate, + handleAddEvent, + handleCloseModalWithCleanup, + handleCloseDeleteConfirm, + handleCloseRecurringDropConfirm, + handleConfirmRecurringDrop, + onView, + } +} diff --git a/src/features/Calendar/hooks/useCustomCalendarMutations.ts b/src/features/Calendar/hooks/useCustomCalendarMutations.ts new file mode 100644 index 0000000..3a7310d --- /dev/null +++ b/src/features/Calendar/hooks/useCustomCalendarMutations.ts @@ -0,0 +1,24 @@ +import { useCalendarMutation } from '@/shared/hooks/query/useCalendarMutation' +import { useTodoMutations } from '@/shared/hooks/query/useTodoMutations' + +import { useCalendarTodoTimingPatch } from './useCalendarTodoTimingPatch' + +export const useCustomCalendarMutations = () => { + const { usePatchEvent, useDeleteEvent } = useCalendarMutation() + const { mutate: patchEventMutate } = usePatchEvent() + const { mutate: deleteEventMutate } = useDeleteEvent() + + const { usePatchCompleteTodo, usePatchTodo, useDeleteTodo } = useTodoMutations() + const { mutate: patchCompleteTodoMutate } = usePatchCompleteTodo() + const { mutate: patchTodoMutate } = usePatchTodo() + const { mutate: deleteTodoMutate } = useDeleteTodo() + const { patchTodoTiming } = useCalendarTodoTimingPatch({ patchTodoMutate }) + + return { + patchEventMutate, + deleteEventMutate, + patchCompleteTodoMutate, + patchTodoTiming, + deleteTodoMutate, + } +} diff --git a/src/features/Calendar/hooks/useStoredCalendarView.ts b/src/features/Calendar/hooks/useStoredCalendarView.ts index 36b30a6..fa3b93f 100644 --- a/src/features/Calendar/hooks/useStoredCalendarView.ts +++ b/src/features/Calendar/hooks/useStoredCalendarView.ts @@ -3,8 +3,16 @@ import { useEffect, useState } from 'react' import type { View } from 'react-big-calendar' import { Views } from 'react-big-calendar' +import type { CalendarView } from '@/shared/types/settings/settings' + const VIEW_STORAGE_KEY = 'calendar.view' +const SETTINGS_VIEW_MAP: Record = { + MONTH: Views.MONTH, + WEEK: Views.WEEK, + DAY: Views.DAY, +} + const getStoredView = (): View | null => { if (typeof window === 'undefined') return null const stored = window.localStorage.getItem(VIEW_STORAGE_KEY) @@ -14,16 +22,19 @@ const getStoredView = (): View | null => { return null } +export const mapSettingsDefaultView = (defaultView: CalendarView): View => + SETTINGS_VIEW_MAP[defaultView] ?? Views.MONTH + type UseStoredCalendarViewArgs = { initialView?: View | null } export const useStoredCalendarView = ({ initialView }: UseStoredCalendarViewArgs = {}) => { - const storedView = getStoredView() - const [userView, setUserView] = useState(() => storedView ?? null) + const [userView, setUserView] = useState(() => initialView ?? getStoredView()) const view = userView ?? initialView ?? Views.MONTH useEffect(() => { + if (typeof window === 'undefined') return window.localStorage.setItem(VIEW_STORAGE_KEY, view) }, [view]) diff --git a/src/features/Calendar/utils/helpers/calendarModalEvent.ts b/src/features/Calendar/utils/helpers/calendarModalEvent.ts new file mode 100644 index 0000000..7c6e49d --- /dev/null +++ b/src/features/Calendar/utils/helpers/calendarModalEvent.ts @@ -0,0 +1,26 @@ +import type { CalendarEvent } from '@/shared/types/calendar/types' + +import { getEventOccurrenceKey } from './dayViewHelpers' + +export const getCalendarModalEvent = ({ + events, + modalEventId, + selectedEventKey, +}: { + events: CalendarEvent[] + modalEventId: CalendarEvent['id'] | null + selectedEventKey: string | null +}) => { + if (modalEventId == null) return null + + const selectedOccurrenceEvent = + selectedEventKey != null + ? (events.find((item) => getEventOccurrenceKey(item) === selectedEventKey) ?? null) + : null + + if (selectedOccurrenceEvent && selectedOccurrenceEvent.id === modalEventId) { + return selectedOccurrenceEvent + } + + return events.find((item) => item.id === modalEventId) ?? null +} diff --git a/src/features/Calendar/utils/helpers/calendarPageHelpers.ts b/src/features/Calendar/utils/helpers/calendarPageHelpers.ts index f96a282..7ca43ed 100644 --- a/src/features/Calendar/utils/helpers/calendarPageHelpers.ts +++ b/src/features/Calendar/utils/helpers/calendarPageHelpers.ts @@ -32,6 +32,7 @@ export const createEvent = (date: Date, index: number, allDay = false): Calendar isAllDay: allDay, color: 'GRAY', recurrenceGroup: null, + friendIds: [], type: 'schedule', } } diff --git a/src/features/Calendar/utils/helpers/calendarRecurrenceScope.ts b/src/features/Calendar/utils/helpers/calendarRecurrenceScope.ts new file mode 100644 index 0000000..74e7cb9 --- /dev/null +++ b/src/features/Calendar/utils/helpers/calendarRecurrenceScope.ts @@ -0,0 +1,25 @@ +import type { RecurrenceEventSeriesScope } from '@/shared/constants/recurrenceScope' +import { RECURRENCE_EVENT_SCOPE, RECURRENCE_TODO_SCOPE } from '@/shared/constants/recurrenceScope' +import type { RecurrenceTodoScope } from '@/shared/types/recurrence/recurrence' +import type { EditConfirmOption } from '@/shared/ui/Modals' + +export const getEventOccurrenceScope = ( + isRecurring: boolean, +): RecurrenceEventSeriesScope | undefined => + isRecurring ? RECURRENCE_EVENT_SCOPE.THIS_EVENT : undefined + +export const getTodoOccurrenceScope = (isRecurring: boolean): RecurrenceTodoScope | undefined => + isRecurring ? RECURRENCE_TODO_SCOPE.THIS_TODO : undefined + +export const getEventScopeFromEditOption = ( + option: EditConfirmOption, +): RecurrenceEventSeriesScope => + option === 'future' + ? RECURRENCE_EVENT_SCOPE.THIS_AND_FOLLOWING_EVENTS + : RECURRENCE_EVENT_SCOPE.THIS_EVENT + +export const getTodoScopeFromEditOption = (option: EditConfirmOption): RecurrenceTodoScope => + option === 'future' ? RECURRENCE_TODO_SCOPE.THIS_AND_FOLLOWING : RECURRENCE_TODO_SCOPE.THIS_TODO + +export const isTodoFollowingScope = (scope?: RecurrenceTodoScope): boolean => + scope === RECURRENCE_TODO_SCOPE.THIS_AND_FOLLOWING diff --git a/src/features/Calendar/utils/weekViewLayout.ts b/src/features/Calendar/utils/weekViewLayout.ts new file mode 100644 index 0000000..2686099 --- /dev/null +++ b/src/features/Calendar/utils/weekViewLayout.ts @@ -0,0 +1,118 @@ +import moment from 'moment' + +import type { CalendarEvent } from '@/shared/types/calendar/types' + +import { + compareByStart, + eventCoversDate, + getEventOccurrenceKey, + isDateOnlyString, +} from './helpers/dayViewHelpers' + +export const KOREAN_WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토'] as const + +export type AllDaySegment = { + event: CalendarEvent + key: string + startIndex: number + endIndex: number + lane: number +} + +export const buildWeekDays = (date: Date) => { + const weekStart = moment(date).startOf('week') + return Array.from({ length: 7 }, (_, index) => weekStart.clone().add(index, 'day')) +} + +export const getWeeklyAllDayEvents = (events: CalendarEvent[]) => + events.filter((event) => event.isAllDay || isDateOnlyString(event.start)).sort(compareByStart) + +export const buildAllDaySegments = ( + weeklyAllDayEvents: CalendarEvent[], + weekDays: Date[], +): AllDaySegment[] => { + const laneLastEndIndexes: number[] = [] + const segments: AllDaySegment[] = [] + + weeklyAllDayEvents.forEach((event) => { + const coveredIndexes = weekDays + .map((dayDate, index) => (eventCoversDate(event, dayDate) ? index : -1)) + .filter((index) => index >= 0) + if (coveredIndexes.length === 0) return + + const startIndex = coveredIndexes[0] + const endIndex = coveredIndexes[coveredIndexes.length - 1] + let lane = laneLastEndIndexes.findIndex((lastEnd) => startIndex > lastEnd) + if (lane === -1) { + lane = laneLastEndIndexes.length + laneLastEndIndexes.push(endIndex) + } else { + laneLastEndIndexes[lane] = endIndex + } + + segments.push({ + event, + key: getEventOccurrenceKey(event), + startIndex, + endIndex, + lane, + }) + }) + + return segments +} + +export const getAllDayLaneCount = (segments: AllDaySegment[]) => + segments.reduce((maxLane, segment) => Math.max(maxLane, segment.lane), -1) + 1 + +export const getTimedEventsForDate = (events: CalendarEvent[], date: Date) => + events + .filter((event) => eventCoversDate(event, date)) + .filter((event) => !event.isAllDay && !isDateOnlyString(event.start)) + .sort(compareByStart) + +export const getDropDayIndex = (clientX: number, sectionRect: DOMRect, dayCount = 7) => { + const relativeX = Math.max(0, Math.min(clientX - sectionRect.left, sectionRect.width - 1)) + const dayWidth = sectionRect.width / dayCount + return Math.max(0, Math.min(dayCount - 1, Math.floor(relativeX / dayWidth))) +} + +export const buildWeekDropRange = ( + draggingEvent: CalendarEvent, + targetDate: Date, + dropAsAllDay: boolean, +) => { + const originalStart = moment(draggingEvent.start) + const originalEnd = moment(draggingEvent.end) + const originalStartDay = originalStart.clone().startOf('day') + const originalEndDay = originalEnd.clone().startOf('day') + const originalAllDay = draggingEvent.isAllDay || isDateOnlyString(draggingEvent.start) + const durationMs = Math.max(originalEnd.diff(originalStart), 0) + const useAllDayTime = dropAsAllDay || originalAllDay + + const nextStart = useAllDayTime + ? moment(targetDate).startOf('day') + : moment(targetDate).set({ + hour: originalStart.hour(), + minute: originalStart.minute(), + second: originalStart.second(), + millisecond: originalStart.millisecond(), + }) + const nextEnd = useAllDayTime + ? (() => { + const spanDays = Math.max(originalEndDay.diff(originalStartDay, 'days') + 1, 1) + return nextStart + .clone() + .add(spanDays - 1, 'days') + .endOf('day') + })() + : durationMs > 0 + ? nextStart.clone().add(durationMs, 'milliseconds') + : nextStart.clone().add(1, 'hour') + + return { + start: nextStart.toDate(), + end: nextEnd.toDate(), + allDay: useAllDayTime, + } +} diff --git a/src/pages/main/CalendarPage/CalendarPage.tsx b/src/pages/main/CalendarPage/CalendarPage.tsx index df10c20..8040d0f 100644 --- a/src/pages/main/CalendarPage/CalendarPage.tsx +++ b/src/pages/main/CalendarPage/CalendarPage.tsx @@ -3,14 +3,24 @@ import { useRef, useState } from 'react' import CustomCalendar from '@/features/Calendar/components/CustomCalendar/CustomCalendar' import EventsCard from '@/features/Calendar/components/EventsCard/EventsCard' +import { mapSettingsDefaultView } from '@/features/Calendar/hooks/useStoredCalendarView' +import { SettingsAPI } from '@/shared/api/settings/settings' +import { useCustomSuspenseQuery } from '@/shared/hooks/common/customQuery' import * as S from './CalendarPage.styles' const CalendarPage = () => { const [selectedDate, setSelectedDate] = useState(new Date()) const cardAreaRef = useRef(null) + const { data: settings } = useCustomSuspenseQuery(['settings'], async () => { + const res = await SettingsAPI.getSettings() + if (!res.isSuccess) throw new Error('설정 불러오기 실패') + return res.result + }) + const initialView = mapSettingsDefaultView(settings.defaultView) + return ( - +
{ const { data } = await axiosInstance.post('/events', eventData) return data @@ -55,10 +57,11 @@ export const patchEvent = async ( color?: EventColorType isAllDay?: boolean recurrenceGroup?: RecurrenceGroup | null + friendIds?: number[] | null }, params: { occurrenceDate: string - scope?: Extract + scope?: RecurrenceEventScope }, ) => { const { data } = await axiosInstance.patch(`/events/${eventId}`, eventData, { params }) @@ -69,7 +72,7 @@ export const deleteEvent = async ( eventId: number, params: { occurrenceDate: string - scope?: Extract + scope?: RecurrenceEventSeriesScope }, ) => { const { data } = await axiosInstance.delete(`/events/${eventId}`, { params }) diff --git a/src/shared/api/friend/api.ts b/src/shared/api/friend/api.ts new file mode 100644 index 0000000..7b3546a --- /dev/null +++ b/src/shared/api/friend/api.ts @@ -0,0 +1,15 @@ +import type { TCommonResponse } from '@/shared/types/common/common' +import type { FriendDetail } from '@/shared/types/friend/types' + +import axiosInstance from '../axios' + +export const getFriendSearch = async ( + keyword: string, +): Promise> => { + const { data } = await axiosInstance.get('/friends/search', { + params: { + keyword, + }, + }) + return data +} diff --git a/src/shared/api/friend/queryKeys.ts b/src/shared/api/friend/queryKeys.ts new file mode 100644 index 0000000..e0d1d1d --- /dev/null +++ b/src/shared/api/friend/queryKeys.ts @@ -0,0 +1,10 @@ +import { createQueryKeys } from '@lukemorales/query-key-factory' + +import { getFriendSearch } from './api' + +export const friendKeys = createQueryKeys('friend', { + search: (keyword: string) => ({ + queryKey: [{ keyword }], + queryFn: () => getFriendSearch(keyword), + }), +}) diff --git a/src/shared/api/queryKeys/index.ts b/src/shared/api/queryKeys/index.ts index fa1198c..e244ad3 100644 --- a/src/shared/api/queryKeys/index.ts +++ b/src/shared/api/queryKeys/index.ts @@ -1,2 +1,3 @@ export * from '../calendar/queryKeys' +export * from '../friend/queryKeys' export * from '../todo/queryKeys' diff --git a/src/shared/constants/recurrenceScope.ts b/src/shared/constants/recurrenceScope.ts new file mode 100644 index 0000000..efe3d69 --- /dev/null +++ b/src/shared/constants/recurrenceScope.ts @@ -0,0 +1,18 @@ +export const RECURRENCE_EVENT_SCOPE = { + THIS_EVENT: 'THIS_EVENT', + THIS_AND_FOLLOWING_EVENTS: 'THIS_AND_FOLLOWING_EVENTS', + THIS_AND_FOLLOWING: 'THIS_AND_FOLLOWING', +} as const + +export const RECURRENCE_TODO_SCOPE = { + THIS_TODO: 'THIS_TODO', + THIS_AND_FOLLOWING: 'THIS_AND_FOLLOWING', +} as const + +export type RecurrenceEventScope = + (typeof RECURRENCE_EVENT_SCOPE)[keyof typeof RECURRENCE_EVENT_SCOPE] +export type RecurrenceEventSeriesScope = + | typeof RECURRENCE_EVENT_SCOPE.THIS_EVENT + | typeof RECURRENCE_EVENT_SCOPE.THIS_AND_FOLLOWING_EVENTS + +export type RecurrenceTodoScope = (typeof RECURRENCE_TODO_SCOPE)[keyof typeof RECURRENCE_TODO_SCOPE] diff --git a/src/shared/hooks/addSchedule/useScheduleFooter.tsx b/src/shared/hooks/addSchedule/useScheduleFooter.tsx index ba48d71..a36964f 100644 --- a/src/shared/hooks/addSchedule/useScheduleFooter.tsx +++ b/src/shared/hooks/addSchedule/useScheduleFooter.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { type UseFormGetValues } from 'react-hook-form' +import { RECURRENCE_EVENT_SCOPE } from '@/shared/constants/recurrenceScope' import { useCalendarMutation } from '@/shared/hooks/query/useCalendarMutation' import type { CalendarEvent } from '@/shared/types/calendar/types' import type { EventColorType, ScheduleEditorFormValues } from '@/shared/types/event/event' @@ -28,6 +29,8 @@ type UseScheduleFooterProps = { eventColor: EventColorType closeModal: () => void occurrenceDate: string + canEdit?: boolean + onReadOnlyAttempt?: () => void } export const useScheduleFooter = ({ @@ -44,6 +47,8 @@ export const useScheduleFooter = ({ eventColor, closeModal, occurrenceDate, + canEdit = true, + onReadOnlyAttempt, }: UseScheduleFooterProps) => { const [deleteWarningVisible, setDeleteWarningVisible] = useState(false) const { useDeleteEvent } = useCalendarMutation() @@ -53,24 +58,34 @@ export const useScheduleFooter = ({ const occurrenceDateRef = useRef(occurrenceDate) const closeModalRef = useRef(closeModal) const deleteEventMutateRef = useRef(deleteEventMutate) + const canEditRef = useRef(canEdit) + const onReadOnlyAttemptRef = useRef(onReadOnlyAttempt) + const eventColorRef = useRef(eventColor) + const setEventColorRef = useRef(setEventColor) + const onEventColorChangeRef = useRef(onEventColorChange) + const isEditingRef = useRef(isEditing) + const getValuesRef = useRef(getValues) + const patchScheduleRef = useRef(patchSchedule) - useEffect(() => { - repeatConfigRef.current = repeatConfig - }, [repeatConfig]) - useEffect(() => { - eventIdRef.current = eventId - }, [eventId]) - useEffect(() => { - occurrenceDateRef.current = occurrenceDate - }, [occurrenceDate]) - useEffect(() => { - closeModalRef.current = closeModal - }, [closeModal]) - useEffect(() => { - deleteEventMutateRef.current = deleteEventMutate - }, [deleteEventMutate]) + repeatConfigRef.current = repeatConfig + eventIdRef.current = eventId + occurrenceDateRef.current = occurrenceDate + closeModalRef.current = closeModal + deleteEventMutateRef.current = deleteEventMutate + canEditRef.current = canEdit + onReadOnlyAttemptRef.current = onReadOnlyAttempt + eventColorRef.current = eventColor + setEventColorRef.current = setEventColor + onEventColorChangeRef.current = onEventColorChange + isEditingRef.current = isEditing + getValuesRef.current = getValues + patchScheduleRef.current = patchSchedule const handleDelete = useCallback(() => { + if (!canEditRef.current) { + onReadOnlyAttemptRef.current?.() + return + } if (repeatConfigRef.current.repeatType !== 'none') { setDeleteWarningVisible(true) return @@ -101,45 +116,43 @@ export const useScheduleFooter = ({ () => initialEvent?.recurrenceGroup != null, [initialEvent?.recurrenceGroup], ) + const isExistingRecurringRef = useRef(isExistingRecurring) + isExistingRecurringRef.current = isExistingRecurring - const handleColorChange = useCallback( - (value: EventColorType) => { - const previousColor = eventColor - setEventColor(value) - if (eventId != null && eventId !== 0) { - onEventColorChange?.(eventId, value) - } - if (!isEditing) { - return - } - const nextValues = { ...getValues(), eventColor: value } - void (async () => { - try { - await patchSchedule(nextValues, isExistingRecurring ? 'THIS_EVENT' : undefined) - } catch (error) { - setEventColor(previousColor) - if (eventId != null && eventId !== 0) { - onEventColorChange?.(eventId, previousColor) - } - console.error('[ScheduleEditorForm] color patch failed', error) + const handleColorChange = useCallback((value: EventColorType) => { + if (!canEditRef.current) { + onReadOnlyAttemptRef.current?.() + return + } + const previousColor = eventColorRef.current + const currentEventId = eventIdRef.current + setEventColorRef.current(value) + if (currentEventId != null && currentEventId !== 0) { + onEventColorChangeRef.current?.(currentEventId, value) + } + if (!isEditingRef.current) { + return + } + const nextValues = { ...getValuesRef.current(), eventColor: value } + void (async () => { + try { + await patchScheduleRef.current( + nextValues, + isExistingRecurringRef.current ? RECURRENCE_EVENT_SCOPE.THIS_EVENT : undefined, + ) + } catch (error) { + setEventColorRef.current(previousColor) + if (currentEventId != null && currentEventId !== 0) { + onEventColorChangeRef.current?.(currentEventId, previousColor) } - })() - }, - [ - eventColor, - eventId, - getValues, - isEditing, - isExistingRecurring, - onEventColorChange, - patchSchedule, - setEventColor, - ], - ) + console.error('[ScheduleEditorForm] color patch failed', error) + } + })() + }, []) const footerNode = useMemo( - () => , - [eventColor, handleColorChange], + () => (canEdit ? : null), + [canEdit, eventColor, handleColorChange], ) // 하단 컬러 선택기를 footer에 등록 diff --git a/src/shared/hooks/addSchedule/useSchedulePatch.ts b/src/shared/hooks/addSchedule/useSchedulePatch.ts index 7190b28..4c66d92 100644 --- a/src/shared/hooks/addSchedule/useSchedulePatch.ts +++ b/src/shared/hooks/addSchedule/useSchedulePatch.ts @@ -1,9 +1,11 @@ // 일정 수정 payload를 생성하고 patch 요청을 보내는 훅 import { useCallback } from 'react' +import { RECURRENCE_EVENT_SCOPE } from '@/shared/constants/recurrenceScope' import type { CalendarEvent, Event } from '@/shared/types/calendar/types' import type { RepeatConfigSchema, ScheduleEditorFormValues } from '@/shared/types/event/event' import type { RecurrenceEventScope, RecurrenceGroup } from '@/shared/types/recurrence/recurrence' +import { getEventFriendIds } from '@/shared/utils/eventParticipants' import { mapRepeatConfigToRecurrenceGroup } from '@/shared/utils/recurrenceGroup' import { isSameYmd, @@ -34,7 +36,7 @@ type PatchEventMutate = (params: { eventId: number params: { occurrenceDate: string - scope?: Extract + scope?: RecurrenceEventScope } eventData: { title?: string @@ -46,9 +48,17 @@ type PatchEventMutate = (params: { color?: Event['color'] isAllDay?: boolean recurrenceGroup?: Event['recurrenceGroup'] | null + friendIds?: Event['friendIds'] } }) => Promise +const areSameFriendIds = (left: number[] = [], right: number[] = []) => { + if (left.length !== right.length) return false + const leftSorted = [...left].sort((a, b) => a - b) + const rightSorted = [...right].sort((a, b) => a - b) + return leftSorted.every((friendId, index) => friendId === rightSorted[index]) +} + type UseSchedulePatchArgs = { eventId: number | null date: string @@ -95,10 +105,16 @@ export const useSchedulePatch = ({ const shouldSendRecurrenceGroup = JSON.stringify(nextRecurrenceGroupPayload ?? null) !== JSON.stringify(initialRecurrenceGroupPayload ?? null) + const isConvertingSingleToRecurring = + initialEvent?.recurrenceGroup == null && + nextRecurrenceGroupPayload != null && + shouldSendRecurrenceGroup const patchScope = shouldSendRecurrenceGroup - ? 'THIS_AND_FOLLOWING_EVENTS' + ? isConvertingSingleToRecurring + ? undefined + : RECURRENCE_EVENT_SCOPE.THIS_AND_FOLLOWING_EVENTS : isRecurring - ? (scope ?? 'THIS_EVENT') + ? (scope ?? RECURRENCE_EVENT_SCOPE.THIS_EVENT) : undefined const initialTitle = initialEvent?.title ?? '' @@ -107,6 +123,7 @@ export const useSchedulePatch = ({ const initialAddress = initialEvent?.address ?? null const initialColor = initialEvent?.color const initialIsAllday = initialEvent?.isAllDay ?? false + const initialFriendIds = getEventFriendIds(initialEvent) const initialStart = initialEvent?.start != null ? formatDateTime(new Date(initialEvent.start)) : undefined const initialEnd = @@ -118,10 +135,11 @@ export const useSchedulePatch = ({ const nextAddress = values.address?.trim() || null const nextStart = formatDateTime(start) const nextEnd = formatDateTime(end) + const nextFriendIds = values.friendIds ?? [] const hasStartDateChanged = initialEvent?.start != null && !isSameYmd(new Date(initialEvent.start), start) const shouldSendMonthlySinglePattern = - patchScope === 'THIS_AND_FOLLOWING_EVENTS' && + patchScope === RECURRENCE_EVENT_SCOPE.THIS_AND_FOLLOWING_EVENTS && !shouldSendRecurrenceGroup && hasStartDateChanged && isMonthlyPatternWithFlexibleWeekdayRule(initialRecurrenceGroupPayload) @@ -151,6 +169,7 @@ export const useSchedulePatch = ({ ...(initialEnd && nextEnd !== initialEnd ? { endTime: nextEnd } : {}), ...(initialColor && values.eventColor !== initialColor ? { color: values.eventColor } : {}), ...(values.isAllday !== initialIsAllday ? { isAllDay: values.isAllday } : {}), + ...(!areSameFriendIds(nextFriendIds, initialFriendIds) ? { friendIds: nextFriendIds } : {}), ...(recurrenceGroupPayload !== undefined ? { recurrenceGroup: recurrenceGroupPayload, diff --git a/src/shared/hooks/addSchedule/useSchedulePatchController.ts b/src/shared/hooks/addSchedule/useSchedulePatchController.ts index 3b3f2a2..09781ca 100644 --- a/src/shared/hooks/addSchedule/useSchedulePatchController.ts +++ b/src/shared/hooks/addSchedule/useSchedulePatchController.ts @@ -6,6 +6,7 @@ import { useCalendarMutation } from '@/shared/hooks/query/useCalendarMutation' import type { CalendarEvent } from '@/shared/types/calendar/types' import type { RepeatConfigSchema, ScheduleEditorFormValues } from '@/shared/types/event/event' import { defaultRepeatConfig } from '@/shared/types/recurrence/repeat' +import { buildDateTime as buildEditorDateTime } from '@/shared/utils/editorDateTime' import { mapRecurrenceGroupToRepeatConfig, mapRepeatConfigToRecurrenceGroup, @@ -45,16 +46,10 @@ export const useSchedulePatchController = ({ }, [initialEvent]) // 날짜 + 시간 문자열을 Date로 합성 - const buildDateTime = useCallback((dateValue: Date | null, timeValue?: string) => { - const nextDate = dateValue ? new Date(dateValue) : new Date() - if (!timeValue) { - nextDate.setHours(0, 0, 0, 0) - return nextDate - } - const [hour, minute] = timeValue.split(':').map((value) => Number.parseInt(value, 10)) - nextDate.setHours(Number.isNaN(hour) ? 0 : hour, Number.isNaN(minute) ? 0 : minute, 0, 0) - return nextDate - }, []) + const buildDateTime = useCallback( + (dateValue: Date | null, timeValue?: string) => buildEditorDateTime(dateValue, timeValue), + [], + ) // 변경된 필드만 추려 patch 요청 생성 const patchSchedule = useSchedulePatch({ @@ -96,6 +91,7 @@ export const useSchedulePatchController = ({ isAllDay: values.isAllday, color: values.eventColor, recurrenceGroup, + friendIds: values.friendIds ?? [], }) }, [buildDateTime, date, formatDateTime, postEventMutation], diff --git a/src/shared/hooks/addSchedule/useScheduleSubmitFlow.ts b/src/shared/hooks/addSchedule/useScheduleSubmitFlow.ts index d81801b..ad0771e 100644 --- a/src/shared/hooks/addSchedule/useScheduleSubmitFlow.ts +++ b/src/shared/hooks/addSchedule/useScheduleSubmitFlow.ts @@ -1,6 +1,7 @@ import { useCallback, useState } from 'react' import type { UseFormHandleSubmit, UseFormSetValue } from 'react-hook-form' +import { RECURRENCE_EVENT_SCOPE } from '@/shared/constants/recurrenceScope' import { useRepeatChangeGuard } from '@/shared/hooks/repeat/useRepeatChangeGuard' import type { CalendarEvent } from '@/shared/types/calendar/types' import type { ScheduleEditorFormValues } from '@/shared/types/event/event' @@ -28,6 +29,8 @@ type UseScheduleSubmitFlowProps = { buildDateTime: (dateValue: Date | null, timeValue?: string) => Date formatDateTime: (value: Date) => string repeatConfig: ScheduleEditorFormValues['repeatConfig'] + canEdit?: boolean + onReadOnlyAttempt?: () => void } export const useScheduleSubmitFlow = ({ @@ -45,6 +48,8 @@ export const useScheduleSubmitFlow = ({ buildDateTime, formatDateTime, repeatConfig, + canEdit = true, + onReadOnlyAttempt, }: UseScheduleSubmitFlowProps) => { // 반복 변경 시 편집 확인 모달을 띄우는 guard 훅 const { @@ -100,6 +105,10 @@ export const useScheduleSubmitFlow = ({ shouldConfirmChange?: boolean }, ) => { + if (!canEdit) { + onReadOnlyAttempt?.() + return + } if (options.shouldConfirmChange) { confirmChange() } @@ -125,12 +134,14 @@ export const useScheduleSubmitFlow = ({ } }, [ + canEdit, clearApplyConfirm, confirmChange, confirmTitle, createSchedule, onClose, patchSchedule, + onReadOnlyAttempt, showToast, syncEventTiming, ], @@ -139,6 +150,10 @@ export const useScheduleSubmitFlow = ({ // 폼 제출 처리(일반/반복 분기) const handleFormSubmit = handleSubmit( async (values) => { + if (!canEdit) { + onReadOnlyAttempt?.() + return + } if (isExistingRecurring && requestConfirmation()) { setPendingScheduleValues(values) return @@ -152,6 +167,10 @@ export const useScheduleSubmitFlow = ({ }) }, (errors) => { + if (!canEdit) { + onReadOnlyAttempt?.() + return + } showToast({ title: '일정 입력을 확인해주세요', message: getFormErrorMessage(errors, '필수 입력 항목을 다시 확인해주세요.'), @@ -164,6 +183,10 @@ export const useScheduleSubmitFlow = ({ const handleConfirmedSubmit = useCallback( async (option: EditConfirmOption) => { if (!pendingScheduleValues) return + if (!canEdit) { + onReadOnlyAttempt?.() + return + } const fallbackStartDate = pendingScheduleValues.eventStartDate ?? new Date(date) const fallbackOccurrenceDate = formatDateTime( buildDateTime(fallbackStartDate, pendingScheduleValues.eventStartTime), @@ -173,7 +196,10 @@ export const useScheduleSubmitFlow = ({ const occurrenceDate = initialEvent?.occurrenceDate ? formatDateTime(new Date(initialEvent.occurrenceDate)) : fallbackOccurrenceDate - const scope = option === 'future' ? 'THIS_AND_FOLLOWING_EVENTS' : 'THIS_EVENT' + const scope = + option === 'future' + ? RECURRENCE_EVENT_SCOPE.THIS_AND_FOLLOWING_EVENTS + : RECURRENCE_EVENT_SCOPE.THIS_EVENT await submitScheduleValues(pendingScheduleValues, { mode: 'patch', scope, @@ -183,10 +209,12 @@ export const useScheduleSubmitFlow = ({ }, [ buildDateTime, + canEdit, date, formatDateTime, initialEvent, isEditConfirmOpen, + onReadOnlyAttempt, pendingScheduleValues, submitScheduleValues, ], diff --git a/src/shared/hooks/addTodo/useTodoFooter.tsx b/src/shared/hooks/addTodo/useTodoFooter.tsx index dab52ff..6140ff0 100644 --- a/src/shared/hooks/addTodo/useTodoFooter.tsx +++ b/src/shared/hooks/addTodo/useTodoFooter.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { RECURRENCE_TODO_SCOPE } from '@/shared/constants/recurrenceScope' import { useTodoMutations } from '@/shared/hooks/query/useTodoMutations' import type { CalendarEvent } from '@/shared/types/calendar/types' import type { EventColorType } from '@/shared/types/event/event' @@ -105,7 +106,7 @@ export const useTodoFooter = ({ { todoId: eventIdRef.current, occurrenceDate: occurrenceDateRef.current, - ...(hasExistingRecurrenceRef.current ? { scope: 'THIS_TODO' as const } : {}), + ...(hasExistingRecurrenceRef.current ? { scope: RECURRENCE_TODO_SCOPE.THIS_TODO } : {}), requestBody: { color: value, }, diff --git a/src/shared/hooks/addTodo/useTodoSubmitFlow.ts b/src/shared/hooks/addTodo/useTodoSubmitFlow.ts index 0613e04..d8a1369 100644 --- a/src/shared/hooks/addTodo/useTodoSubmitFlow.ts +++ b/src/shared/hooks/addTodo/useTodoSubmitFlow.ts @@ -1,6 +1,7 @@ import { useCallback, useState } from 'react' import type { UseFormHandleSubmit, UseFormSetValue } from 'react-hook-form' +import { RECURRENCE_TODO_SCOPE } from '@/shared/constants/recurrenceScope' import { useRepeatChangeGuard } from '@/shared/hooks/repeat/useRepeatChangeGuard' import type { CalendarEvent } from '@/shared/types/calendar/types' import type { TodoEditorFormValues } from '@/shared/types/event/event' @@ -150,7 +151,10 @@ export const useTodoSubmitFlow = ({ const handleConfirmedSubmit = useCallback( async (option: EditConfirmOption) => { if (!pendingTodoValues) return - const scope: RecurrenceTodoScope = option === 'future' ? 'THIS_AND_FOLLOWING' : 'THIS_TODO' + const scope: RecurrenceTodoScope = + option === 'future' + ? RECURRENCE_TODO_SCOPE.THIS_AND_FOLLOWING + : RECURRENCE_TODO_SCOPE.THIS_TODO await submitTodoValues(pendingTodoValues, { scope, shouldConfirmChange: isEditConfirmOpen, diff --git a/src/shared/hooks/common/useUnsavedCloseGuard.ts b/src/shared/hooks/common/useUnsavedCloseGuard.ts index 6389779..ab79f58 100644 --- a/src/shared/hooks/common/useUnsavedCloseGuard.ts +++ b/src/shared/hooks/common/useUnsavedCloseGuard.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' type UseUnsavedCloseGuardArgs = { isDirty: boolean @@ -14,7 +15,10 @@ export const useUnsavedCloseGuard = ({ registerCloseGuard, }: UseUnsavedCloseGuardArgs) => { const allowCloseRef = useRef(false) + const pendingPathRef = useRef(null) const [isUnsavedConfirmOpen, setIsUnsavedConfirmOpen] = useState(false) + const location = useLocation() + const navigate = useNavigate() const requestClose = useCallback( (force?: boolean) => { @@ -39,14 +43,56 @@ export const useUnsavedCloseGuard = ({ }, [isDirty]) const handleCloseUnsavedConfirm = useCallback(() => { + pendingPathRef.current = null setIsUnsavedConfirmOpen(false) }, []) const handleLeaveUnsavedForm = useCallback(() => { + const pendingPath = pendingPathRef.current + pendingPathRef.current = null setIsUnsavedConfirmOpen(false) onDiscard?.() + if (pendingPath) { + navigate(pendingPath) + return + } requestClose(true) - }, [onDiscard, requestClose]) + }, [navigate, onDiscard, requestClose]) + + useEffect(() => { + if (!isDirty) return undefined + + const handleDocumentClick = (event: MouseEvent) => { + if (event.defaultPrevented) return + if (event.button !== 0) return + if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return + + const target = event.target + if (!(target instanceof Element)) return + + const anchor = target.closest('a[href]') + if (!(anchor instanceof HTMLAnchorElement)) return + if (anchor.target && anchor.target !== '_self') return + + const nextUrl = new URL(anchor.href, window.location.href) + if (nextUrl.origin !== window.location.origin) return + + const currentPath = `${location.pathname}${location.search}${location.hash}` + const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}` + if (nextPath === currentPath) return + + event.preventDefault() + event.stopPropagation() + pendingPathRef.current = nextPath + setIsUnsavedConfirmOpen(true) + } + + document.addEventListener('click', handleDocumentClick, true) + + return () => { + document.removeEventListener('click', handleDocumentClick, true) + } + }, [isDirty, location.hash, location.pathname, location.search]) useEffect(() => { if (!registerCloseGuard) return diff --git a/src/shared/hooks/form/index.ts b/src/shared/hooks/form/index.ts index d4d6fd4..3dc93cf 100644 --- a/src/shared/hooks/form/index.ts +++ b/src/shared/hooks/form/index.ts @@ -1,4 +1,5 @@ export * from './useCalendarFieldPicker' +export * from './useEditorFormLifecycle' export * from './useScheduleEditorForm' export * from './useScheduleFormFields' export * from './useSearchPlaceToggle' diff --git a/src/shared/hooks/form/useEditorFormLifecycle.ts b/src/shared/hooks/form/useEditorFormLifecycle.ts new file mode 100644 index 0000000..723b70e --- /dev/null +++ b/src/shared/hooks/form/useEditorFormLifecycle.ts @@ -0,0 +1,54 @@ +import { useEffect, useRef } from 'react' +import type { FieldValues, Path, UseFormReturn } from 'react-hook-form' + +import type { ItemEditorDraft } from '@/shared/types/modal/itemEditor' + +type UseEditorFormLifecycleArgs = { + formMethods: UseFormReturn + registeredFields: Array> + resetKey: string + isEditing: boolean + initialValues: TValues + editingResetValues?: TValues | null + onDraftChange?: (draft: ItemEditorDraft) => void + mapDraft: (values: TValues) => ItemEditorDraft +} + +export const useEditorFormLifecycle = ({ + formMethods, + registeredFields, + resetKey, + isEditing, + initialValues, + editingResetValues, + onDraftChange, + mapDraft, +}: UseEditorFormLifecycleArgs) => { + const previousResetKeyRef = useRef(resetKey) + const { register, reset } = formMethods + + useEffect(() => { + registeredFields.forEach((field) => register(field)) + }, [register, registeredFields]) + + useEffect(() => { + if (!isEditing || !editingResetValues) return + reset(editingResetValues) + }, [editingResetValues, isEditing, reset]) + + useEffect(() => { + if (isEditing) return + if (previousResetKeyRef.current === resetKey) return + previousResetKeyRef.current = resetKey + reset(initialValues) + }, [initialValues, isEditing, reset, resetKey]) + + useEffect(() => { + if (isEditing || !onDraftChange) return + const subscription = formMethods.watch((values) => { + onDraftChange(mapDraft(values as TValues)) + }) + + return () => subscription.unsubscribe() + }, [formMethods, isEditing, mapDraft, onDraftChange]) +} diff --git a/src/shared/hooks/form/useScheduleEditorForm.ts b/src/shared/hooks/form/useScheduleEditorForm.ts index 10d3343..b397a40 100644 --- a/src/shared/hooks/form/useScheduleEditorForm.ts +++ b/src/shared/hooks/form/useScheduleEditorForm.ts @@ -60,7 +60,12 @@ export const useScheduleEditorForm = ({ const { handleRepeatType, updateConfig, setEventColor } = useRepeatConfigController({ repeatConfig, - setValue, + onRepeatConfigChange: (value) => { + setValue('repeatConfig', value, { shouldValidate: true }) + }, + onEventColorChange: (value) => { + setValue('eventColor', value, { shouldValidate: true }) + }, }) return { diff --git a/src/shared/hooks/form/useScheduleFormFields.ts b/src/shared/hooks/form/useScheduleFormFields.ts index be0903f..0632e14 100644 --- a/src/shared/hooks/form/useScheduleFormFields.ts +++ b/src/shared/hooks/form/useScheduleFormFields.ts @@ -1,7 +1,8 @@ import { yupResolver } from '@hookform/resolvers/yup' -import { useEffect, useMemo, useRef } from 'react' +import { useCallback, useMemo } from 'react' import { type Control, type Resolver, useForm, type UseFormReturn, useWatch } from 'react-hook-form' +import { useEditorFormLifecycle } from '@/shared/hooks/form/useEditorFormLifecycle' import { addScheduleSchema } from '@/shared/schemas/schedule' import type { CalendarEvent } from '@/shared/types/calendar/types' import { @@ -11,6 +12,13 @@ import { } from '@/shared/types/event/event' import type { ItemEditorDraft } from '@/shared/types/modal/itemEditor' import { defaultRepeatConfig } from '@/shared/types/recurrence/repeat' +import { + formatTimeFromDate, + getDefaultEndDate, + normalizeScheduleTimeRange, + toDate, +} from '@/shared/utils/editorDateTime' +import { getEventFriendIds } from '@/shared/utils/eventParticipants' import { mapRecurrenceGroupToRepeatConfig } from '@/shared/utils/recurrenceGroup' type UseScheduleFormFieldsProps = { @@ -36,26 +44,6 @@ export type UseScheduleFormFieldsResult = { eventTitle: string | undefined } -const pad2 = (value: number) => String(value).padStart(2, '0') - -const formatTimeFromDate = (value: Date) => `${pad2(value.getHours())}:${pad2(value.getMinutes())}` - -const toDate = (value: string | Date) => new Date(value) - -const isSameDateTime = (left: string | Date, right: string | Date) => - toDate(left).getTime() === toDate(right).getTime() - -const getDefaultEndDate = ( - defaultStart: Date, - initialStart?: CalendarEvent['start'], - initialEnd?: CalendarEvent['end'], -) => { - if (initialEnd && initialStart && !isSameDateTime(initialEnd, initialStart)) { - return toDate(initialEnd) - } - return new Date(defaultStart.getTime() + 60 * 60 * 1000) -} - const buildScheduleDefaultValues = ({ date, initialEvent, @@ -75,7 +63,11 @@ const buildScheduleDefaultValues = ({ const initialAddress = initialEvent?.address ?? null const initialColor = initialEvent?.color ?? 'BLUE' const initialIsAllDay = initialEvent?.isAllDay ?? false + const defaultStartTime = draftValues?.startTime ?? formatTimeFromDate(defaultStart) + const defaultEndTime = draftValues?.endTime ?? formatTimeFromDate(defaultEnd) + const { startTime, endTime } = normalizeScheduleTimeRange(defaultStartTime, defaultEndTime) const mappedRepeatConfig = mapRecurrenceGroupToRepeatConfig(initialEvent?.recurrenceGroup) + const initialFriendIds = getEventFriendIds(initialEvent) const initialRepeatConfig: RepeatConfigSchema = { ...defaultRepeatConfig, ...mappedRepeatConfig, @@ -91,11 +83,12 @@ const buildScheduleDefaultValues = ({ address: draftValues?.address ?? initialAddress, eventStartDate: draftValues?.startDate ?? defaultStart, eventEndDate: draftValues?.endDate ?? defaultEnd, - eventStartTime: draftValues?.startTime ?? formatTimeFromDate(defaultStart), - eventEndTime: draftValues?.endTime ?? formatTimeFromDate(defaultEnd), + eventStartTime: startTime, + eventEndTime: endTime, isAllday: draftValues?.isAllday ?? initialIsAllDay, eventColor: draftValues?.eventColor ?? initialColor, repeatConfig: draftValues?.repeatConfig ?? initialRepeatConfig, + friendIds: initialFriendIds, } } @@ -113,12 +106,19 @@ export const useScheduleFormFields = ({ () => buildScheduleDefaultValues({ date, initialEvent, draftValues }), [date, draftValues, initialEvent], ) - const previousResetKeyRef = useRef(`${date}::${String(initialEvent?.id ?? 'new')}`) + const editingResetValues = useMemo( + () => + isEditing && initialStart + ? buildScheduleDefaultValues({ date, initialEvent, draftValues: null }) + : null, + [date, initialEvent, initialStart, isEditing], + ) + const resetKey = `${date}::${String(initialEvent?.id ?? 'new')}` const formMethods = useForm({ resolver, defaultValues: initialValues, }) - const { control, register, reset, setValue, handleSubmit } = formMethods + const { control, setValue, handleSubmit } = formMethods const eventStartDate = useWatch({ control, name: 'eventStartDate' }) const eventEndDate = useWatch({ control, name: 'eventEndDate' }) @@ -130,53 +130,46 @@ export const useScheduleFormFields = ({ const eventColor = (useWatch({ control, name: 'eventColor' }) ?? 'BLUE') as EventColorType const eventTitle = useWatch({ control, name: 'eventTitle' }) - useEffect(() => { - register('eventStartDate') - register('eventEndDate') - register('eventStartTime') - register('eventEndTime') - register('location') - register('address') - register('isAllday') - register('repeatConfig') - register('eventColor') - }, [register]) - - useEffect(() => { - if (!isEditing || !initialStart) return - reset(buildScheduleDefaultValues({ date, initialEvent, draftValues: null })) - }, [date, initialEvent, initialStart, isEditing, reset]) - - useEffect(() => { - if (isEditing) return - const nextResetKey = `${date}::${String(initialEvent?.id ?? 'new')}` - if (previousResetKeyRef.current === nextResetKey) return - previousResetKeyRef.current = nextResetKey - reset(initialValues) - }, [date, initialEvent?.id, initialValues, isEditing, reset]) - - useEffect(() => { - if (isEditing || !onDraftChange) return - const subscription = formMethods.watch((values) => { - onDraftChange({ - title: values.eventTitle ?? '', - description: values.eventDescription ?? '', - startDate: values.eventStartDate ?? null, - endDate: values.eventEndDate ?? values.eventStartDate ?? null, - startTime: values.eventStartTime, - endTime: values.eventEndTime, - isAllday: values.isAllday ?? false, - eventColor: (values.eventColor ?? 'BLUE') as EventColorType, - repeatConfig: - (values.repeatConfig as RepeatConfigSchema | undefined) ?? - (defaultRepeatConfig as RepeatConfigSchema), - location: values.location ?? '', - address: values.address ?? null, - }) - }) + const mapDraft = useCallback( + (values: ScheduleEditorFormValues): ItemEditorDraft => ({ + title: values.eventTitle ?? '', + description: values.eventDescription ?? '', + startDate: values.eventStartDate ?? null, + endDate: values.eventEndDate ?? values.eventStartDate ?? null, + startTime: values.eventStartTime, + endTime: values.eventEndTime, + isAllday: values.isAllday ?? false, + eventColor: (values.eventColor ?? 'BLUE') as EventColorType, + repeatConfig: + (values.repeatConfig as RepeatConfigSchema | undefined) ?? + (defaultRepeatConfig as RepeatConfigSchema), + location: values.location ?? '', + address: values.address ?? null, + }), + [], + ) - return () => subscription.unsubscribe() - }, [formMethods, isEditing, onDraftChange]) + useEditorFormLifecycle({ + formMethods, + registeredFields: [ + 'eventStartDate', + 'eventEndDate', + 'eventStartTime', + 'eventEndTime', + 'location', + 'address', + 'isAllday', + 'repeatConfig', + 'eventColor', + 'friendIds', + ], + resetKey, + isEditing, + initialValues, + editingResetValues, + onDraftChange, + mapDraft, + }) return { formMethods, diff --git a/src/shared/hooks/form/useTodoEditorForm.ts b/src/shared/hooks/form/useTodoEditorForm.ts index 1cbab63..8c7150a 100644 --- a/src/shared/hooks/form/useTodoEditorForm.ts +++ b/src/shared/hooks/form/useTodoEditorForm.ts @@ -1,7 +1,8 @@ -import { useCallback } from 'react' import { type Control, type UseFormReturn } from 'react-hook-form' +import { RECURRENCE_TODO_SCOPE } from '@/shared/constants/recurrenceScope' import { useTodoMutations } from '@/shared/hooks/query/useTodoMutations' +import { useRepeatConfigController } from '@/shared/hooks/repeat/useRepeatConfigController' import type { CalendarEvent } from '@/shared/types/calendar/types' import type { EventColorType, @@ -10,7 +11,7 @@ import type { } from '@/shared/types/event/event' import type { ItemEditorDraft } from '@/shared/types/modal/itemEditor' import type { RecurrenceTodoScope } from '@/shared/types/recurrence/recurrence' -import type { CustomRepeatBasis, RepeatConfig, RepeatType } from '@/shared/types/recurrence/repeat' +import type { RepeatConfig, RepeatType } from '@/shared/types/recurrence/repeat' import { formatIsoDate } from '@/shared/utils/date' import { mapRepeatConfigToRecurrenceGroup } from '@/shared/utils/recurrenceGroup' import { isSameYmd, toWeekday, toWeekOfMonth } from '@/shared/utils/recurrencePattern' @@ -45,9 +46,6 @@ export type UseTodoEditorFormResult = { todoTitle: string | undefined } -const isCustomBasis = (value: RepeatType): value is CustomRepeatBasis => - value !== 'none' && value !== 'custom' - const parseYmd = (value?: string) => { if (!value) return null const [year, month, day] = value.split('-').map((item) => Number.parseInt(item, 10)) @@ -80,57 +78,15 @@ export const useTodoEditorForm = ({ eventColor, } = useTodoFormFields({ date, initialEvent, isEditing, draftValues, onDraftChange }) - const handleRepeatType = useCallback( - (value: RepeatType) => { - if (value === 'custom') { - if (repeatConfig.repeatType === 'custom') { - setValue( - 'repeatConfig', - { ...repeatConfig, repeatType: 'none', customBasis: null }, - { shouldValidate: true }, - ) - return - } - setValue( - 'repeatConfig', - { - ...repeatConfig, - repeatType: 'custom', - customBasis: repeatConfig.customBasis ?? 'daily', - }, - { shouldValidate: true }, - ) - return - } - - if (repeatConfig.repeatType === 'custom' && isCustomBasis(value)) { - setValue('repeatConfig', { ...repeatConfig, customBasis: value }, { shouldValidate: true }) - return - } - - const nextType = repeatConfig.repeatType === value ? 'none' : value - setValue( - 'repeatConfig', - { ...repeatConfig, repeatType: nextType, customBasis: null }, - { shouldValidate: true }, - ) - }, - [repeatConfig, setValue], - ) - - const updateConfig = useCallback( - (changes: Partial) => { - setValue('repeatConfig', { ...repeatConfig, ...changes }, { shouldValidate: true }) + const { handleRepeatType, updateConfig, setEventColor } = useRepeatConfigController({ + repeatConfig, + onRepeatConfigChange: (value) => { + setValue('repeatConfig', value, { shouldValidate: true }) }, - [repeatConfig, setValue], - ) - - const setEventColor = useCallback( - (value: EventColorType) => { + onEventColorChange: (value) => { setValue('eventColor', value, { shouldValidate: true }) }, - [setValue], - ) + }) const onSubmit = ( values: TodoEditorFormValues, @@ -143,7 +99,7 @@ export const useTodoEditorForm = ({ const currentDate = values.todoDate ? new Date(values.todoDate) : null const targetOccurrenceDate = parseYmd(options?.occurrenceDate) const shouldAdjustFutureMonthlyPattern = - options?.scope === 'THIS_AND_FOLLOWING' && + options?.scope === RECURRENCE_TODO_SCOPE.THIS_AND_FOLLOWING && mappedRecurrenceGroup?.frequency === 'MONTHLY' && mappedRecurrenceGroup?.monthlyType === 'DAY_OF_WEEK' && mappedRecurrenceGroup?.weekdayRule != null && diff --git a/src/shared/hooks/form/useTodoFormFields.ts b/src/shared/hooks/form/useTodoFormFields.ts index 0197c5c..64be5f1 100644 --- a/src/shared/hooks/form/useTodoFormFields.ts +++ b/src/shared/hooks/form/useTodoFormFields.ts @@ -1,7 +1,8 @@ import { yupResolver } from '@hookform/resolvers/yup' -import { useEffect, useMemo, useRef } from 'react' +import { useCallback, useMemo } from 'react' import { type Control, type Resolver, useForm, type UseFormReturn, useWatch } from 'react-hook-form' +import { useEditorFormLifecycle } from '@/shared/hooks/form/useEditorFormLifecycle' import { addTodoSchema } from '@/shared/schemas/todo' import type { CalendarEvent } from '@/shared/types/calendar/types' import { @@ -11,6 +12,7 @@ import { } from '@/shared/types/event/event' import type { ItemEditorDraft } from '@/shared/types/modal/itemEditor' import { defaultRepeatConfig } from '@/shared/types/recurrence/repeat' +import { formatTimeFromDate } from '@/shared/utils/editorDateTime' type UseTodoFormFieldsProps = { date: string @@ -33,10 +35,6 @@ export type UseTodoFormFieldsResult = { eventColor: EventColorType } -const pad2 = (value: number) => String(value).padStart(2, '0') - -const formatTimeFromDate = (value: Date) => `${pad2(value.getHours())}:${pad2(value.getMinutes())}` - const buildTodoDefaultValues = ({ date, initialEvent, @@ -73,12 +71,12 @@ export const useTodoFormFields = ({ () => buildTodoDefaultValues({ date, initialEvent, draftValues }), [date, draftValues, initialEvent], ) - const previousResetKeyRef = useRef(`${date}::${String(initialEvent?.id ?? 'new')}`) + const resetKey = `${date}::${String(initialEvent?.id ?? 'new')}` const formMethods = useForm({ resolver, defaultValues: initialValues, }) - const { control, register, reset, setValue, handleSubmit } = formMethods + const { control, setValue, handleSubmit } = formMethods const todoDate = useWatch({ control, name: 'todoDate' }) const todoEndTime = useWatch({ control, name: 'todoEndTime' }) @@ -88,45 +86,41 @@ export const useTodoFormFields = ({ const todoTitle = useWatch({ control, name: 'todoTitle' }) const eventColor = (useWatch({ control, name: 'eventColor' }) ?? 'GRAY') as EventColorType - useEffect(() => { - register('todoDate') - register('todoEndTime') - register('isAllday') - register('eventColor') - register('todoPriority') - register('repeatConfig') - }, [register]) - - useEffect(() => { - if (isEditing) return - const nextResetKey = `${date}::${String(initialEvent?.id ?? 'new')}` - if (previousResetKeyRef.current === nextResetKey) return - previousResetKeyRef.current = nextResetKey - reset(initialValues) - }, [date, initialEvent?.id, initialValues, isEditing, reset]) - - useEffect(() => { - if (isEditing || !onDraftChange) return - const subscription = formMethods.watch((values) => { - onDraftChange({ - title: values.todoTitle ?? '', - description: values.todoDescription ?? '', - startDate: values.todoDate ?? null, - endDate: values.todoDate ?? null, - startTime: values.todoEndTime, - endTime: values.todoEndTime, - isAllday: values.isAllday ?? false, - eventColor: (values.eventColor ?? 'GRAY') as EventColorType, - repeatConfig: - (values.repeatConfig as RepeatConfigSchema | undefined) ?? - (defaultRepeatConfig as RepeatConfigSchema), - location: '', - address: null, - }) - }) + const mapDraft = useCallback( + (values: TodoEditorFormValues): ItemEditorDraft => ({ + title: values.todoTitle ?? '', + description: values.todoDescription ?? '', + startDate: values.todoDate ?? null, + endDate: values.todoDate ?? null, + startTime: values.todoEndTime, + endTime: values.todoEndTime, + isAllday: values.isAllday ?? false, + eventColor: (values.eventColor ?? 'GRAY') as EventColorType, + repeatConfig: + (values.repeatConfig as RepeatConfigSchema | undefined) ?? + (defaultRepeatConfig as RepeatConfigSchema), + location: '', + address: null, + }), + [], + ) - return () => subscription.unsubscribe() - }, [formMethods, isEditing, onDraftChange]) + useEditorFormLifecycle({ + formMethods, + registeredFields: [ + 'todoDate', + 'todoEndTime', + 'isAllday', + 'eventColor', + 'todoPriority', + 'repeatConfig', + ], + resetKey, + isEditing, + initialValues, + onDraftChange, + mapDraft, + }) return { formMethods, diff --git a/src/shared/hooks/query/useCalendarMutation.ts b/src/shared/hooks/query/useCalendarMutation.ts index 1e87855..6616625 100644 --- a/src/shared/hooks/query/useCalendarMutation.ts +++ b/src/shared/hooks/query/useCalendarMutation.ts @@ -1,7 +1,7 @@ import { useQueryClient } from '@tanstack/react-query' import { deleteEvent, patchEvent, postEvents } from '@/shared/api/calendar/api' -import type { RecurrenceEventScope } from '@/shared/types/recurrence/recurrence' +import type { RecurrenceEventSeriesScope } from '@/shared/constants/recurrenceScope' import { getErrorMessage, markErrorToastHandled } from '@/shared/utils' import { useToastStore } from '@/store/useToastStore' @@ -72,7 +72,7 @@ export function useCalendarMutation() { }: { eventId: number params: { - scope?: RecurrenceEventScope + scope?: RecurrenceEventSeriesScope occurrenceDate: string } }) => deleteEvent(eventId, params), diff --git a/src/shared/hooks/query/useFriendQueries.ts b/src/shared/hooks/query/useFriendQueries.ts new file mode 100644 index 0000000..3fa0399 --- /dev/null +++ b/src/shared/hooks/query/useFriendQueries.ts @@ -0,0 +1,7 @@ +import { friendKeys } from '@/shared/api/queryKeys' +import { useCustomQuery } from '@/shared/hooks/common/customQuery' + +export function useFriendSearchQuery(keyword: string, enabled = true) { + const query = friendKeys.search(keyword) + return useCustomQuery(query.queryKey, query.queryFn, { enabled }) +} diff --git a/src/shared/hooks/repeat/useRepeatConfigController.ts b/src/shared/hooks/repeat/useRepeatConfigController.ts index 098eebe..8f63e95 100644 --- a/src/shared/hooks/repeat/useRepeatConfigController.ts +++ b/src/shared/hooks/repeat/useRepeatConfigController.ts @@ -1,11 +1,6 @@ import { useCallback } from 'react' -import { type UseFormReturn } from 'react-hook-form' -import { - type EventColorType, - type RepeatConfigSchema, - type ScheduleEditorFormValues, -} from '@/shared/types/event/event' +import { type EventColorType, type RepeatConfigSchema } from '@/shared/types/event/event' import { type CustomRepeatBasis, type RepeatConfig, @@ -14,7 +9,8 @@ import { type UseRepeatConfigControllerProps = { repeatConfig: RepeatConfigSchema - setValue: UseFormReturn['setValue'] + onRepeatConfigChange: (value: RepeatConfigSchema) => void + onEventColorChange: (value: EventColorType) => void } export type UseRepeatConfigControllerResult = { @@ -25,22 +21,19 @@ export type UseRepeatConfigControllerResult = { export const useRepeatConfigController = ({ repeatConfig, - setValue, + onRepeatConfigChange, + onEventColorChange, }: UseRepeatConfigControllerProps): UseRepeatConfigControllerResult => { - const handleRepeatConfigChange = (value: RepeatConfigSchema) => { - setValue('repeatConfig', value, { shouldValidate: true }) - } - const isCustomBasis = (value: RepeatType): value is CustomRepeatBasis => value !== 'none' && value !== 'custom' const handleRepeatType = (value: RepeatType) => { if (value === 'custom') { if (repeatConfig.repeatType === 'custom') { - handleRepeatConfigChange({ ...repeatConfig, repeatType: 'none', customBasis: null }) + onRepeatConfigChange({ ...repeatConfig, repeatType: 'none', customBasis: null }) return } - handleRepeatConfigChange({ + onRepeatConfigChange({ ...repeatConfig, repeatType: 'custom', customBasis: repeatConfig.customBasis ?? 'daily', @@ -49,12 +42,12 @@ export const useRepeatConfigController = ({ } if (repeatConfig.repeatType === 'custom' && isCustomBasis(value)) { - handleRepeatConfigChange({ ...repeatConfig, customBasis: value }) + onRepeatConfigChange({ ...repeatConfig, customBasis: value }) return } const nextType = repeatConfig.repeatType === value ? 'none' : value - handleRepeatConfigChange({ + onRepeatConfigChange({ ...repeatConfig, repeatType: nextType, customBasis: null, @@ -62,13 +55,13 @@ export const useRepeatConfigController = ({ } const updateConfig = (changes: Partial) => - handleRepeatConfigChange({ ...repeatConfig, ...changes }) + onRepeatConfigChange({ ...repeatConfig, ...changes }) const setEventColor = useCallback( (value: EventColorType) => { - setValue('eventColor', value, { shouldValidate: true }) + onEventColorChange(value) }, - [setValue], + [onEventColorChange], ) return { diff --git a/src/shared/schemas/schedule.ts b/src/shared/schemas/schedule.ts index 11ff267..2885df4 100644 --- a/src/shared/schemas/schedule.ts +++ b/src/shared/schemas/schedule.ts @@ -57,4 +57,5 @@ export const addScheduleSchema = yup.object().shape({ isAllday, eventColor, repeatConfig: repeatConfigSchema, + friendIds: yup.array().of(yup.number().required()).default([]).defined(), }) diff --git a/src/shared/types/calendar/types.ts b/src/shared/types/calendar/types.ts index 72242ee..3661414 100644 --- a/src/shared/types/calendar/types.ts +++ b/src/shared/types/calendar/types.ts @@ -15,6 +15,14 @@ export type Event = { color: EventColorType recurrenceGroup: RecurrenceGroup | null isShared?: boolean + isOwner?: boolean + friendIds?: number[] | null + eventParticipantInfo?: Array<{ + eventParticipantId: number + friendId?: number + email: string + name: string + }> } export type CalendarEvent = Omit & { diff --git a/src/shared/types/friend/types.ts b/src/shared/types/friend/types.ts new file mode 100644 index 0000000..53f8dbd --- /dev/null +++ b/src/shared/types/friend/types.ts @@ -0,0 +1,5 @@ +export type FriendDetail = { + id: number + opponentName: string + opponentEmail: string +} diff --git a/src/shared/types/modal/scheduleEditor.ts b/src/shared/types/modal/scheduleEditor.ts index a1ae774..f47d0a8 100644 --- a/src/shared/types/modal/scheduleEditor.ts +++ b/src/shared/types/modal/scheduleEditor.ts @@ -14,9 +14,10 @@ export type ScheduleEditorFormProps = { eventId: CalendarEvent['id'] onClose: () => void isEditing?: boolean - isShared?: boolean + isShared: boolean headerTitlePortalTarget?: HTMLElement | null initialEvent?: CalendarEvent | null + invitedParticipants?: CalendarEvent['eventParticipantInfo'] onEventColorChange?: (eventId: CalendarEvent['id'], color: EventColorType) => void onEventTitleConfirm?: (eventId: CalendarEvent['id'], title: string) => void onSharedChange?: (isShared: boolean) => void diff --git a/src/shared/types/recurrence/recurrence.ts b/src/shared/types/recurrence/recurrence.ts index 3845e91..e4d5d97 100644 --- a/src/shared/types/recurrence/recurrence.ts +++ b/src/shared/types/recurrence/recurrence.ts @@ -1,4 +1,5 @@ import type { Week } from '../event/event' +export type { RecurrenceEventScope, RecurrenceTodoScope } from '@/shared/constants/recurrenceScope' export type MonthlyWeekDayRule = 'SINGLE' | 'WEEKDAY' | 'WEEKEND' | 'ALL_DAYS' @@ -16,6 +17,3 @@ export interface RecurrenceGroup { dayOfWeekInMonth?: Week | null monthOfYear?: number } - -export type RecurrenceEventScope = 'THIS_EVENT' | 'THIS_AND_FOLLOWING_EVENTS' -export type RecurrenceTodoScope = 'THIS_TODO' | 'THIS_AND_FOLLOWING' diff --git a/src/shared/types/scheduleTodo/titleSuggestionInput.ts b/src/shared/types/scheduleTodo/titleSuggestionInput.ts index 604ce1b..25a977d 100644 --- a/src/shared/types/scheduleTodo/titleSuggestionInput.ts +++ b/src/shared/types/scheduleTodo/titleSuggestionInput.ts @@ -17,10 +17,12 @@ export type TitleSuggestionInputProps = { placeholder?: string suggestions?: string[] autoFocus?: boolean + readOnly?: boolean formController?: TitleSuggestionInputFormController inputColor?: string onConfirm?: (value: string) => void onLiveChange?: (value: string) => void + onReadOnlyAttempt?: () => void } export type HighlightedSegment = { diff --git a/src/shared/ui/Modals/DeleteConfirmModal/DeleteConfirmModal.tsx b/src/shared/ui/Modals/DeleteConfirmModal/DeleteConfirmModal.tsx index e398782..0e297df 100644 --- a/src/shared/ui/Modals/DeleteConfirmModal/DeleteConfirmModal.tsx +++ b/src/shared/ui/Modals/DeleteConfirmModal/DeleteConfirmModal.tsx @@ -2,10 +2,12 @@ import type { UseMutateFunction } from '@tanstack/react-query' import { useId, useState } from 'react' import { createPortal } from 'react-dom' -import type { - RecurrenceEventScope, - RecurrenceTodoScope, -} from '@/shared/types/recurrence/recurrence' +import { + RECURRENCE_EVENT_SCOPE, + RECURRENCE_TODO_SCOPE, + type RecurrenceEventSeriesScope, + type RecurrenceTodoScope, +} from '@/shared/constants/recurrenceScope' import Modal from '../../common/Modal/Modal' import * as S from './DeleteConfirmModal.style' @@ -13,7 +15,7 @@ import * as S from './DeleteConfirmModal.style' type EventDeleteVariables = { eventId: number params: { - scope?: RecurrenceEventScope + scope?: RecurrenceEventSeriesScope occurrenceDate: string } } @@ -67,8 +69,10 @@ const DeleteConfirmModal = (props: DeleteConfirmModalProps) => { const handleDelete = () => { if (isEventDeleteProps(props)) { - const scope: RecurrenceEventScope = - selectedOption === 'single' ? 'THIS_EVENT' : 'THIS_AND_FOLLOWING_EVENTS' + const scope: RecurrenceEventSeriesScope = + selectedOption === 'single' + ? RECURRENCE_EVENT_SCOPE.THIS_EVENT + : RECURRENCE_EVENT_SCOPE.THIS_AND_FOLLOWING_EVENTS const params = { scope, occurrenceDate: props.target.occurrenceDate } props.mutate( { eventId: props.target.id, params }, @@ -81,7 +85,9 @@ const DeleteConfirmModal = (props: DeleteConfirmModalProps) => { return } const scope: RecurrenceTodoScope = - selectedOption === 'single' ? 'THIS_TODO' : 'THIS_AND_FOLLOWING' + selectedOption === 'single' + ? RECURRENCE_TODO_SCOPE.THIS_TODO + : RECURRENCE_TODO_SCOPE.THIS_AND_FOLLOWING props.mutate( { todoId: props.target.id, occurrenceDate: props.target.occurrenceDate, scope }, { diff --git a/src/shared/ui/Modals/ItemEditorModal/EditorModalLayout.tsx b/src/shared/ui/Modals/ItemEditorModal/EditorModalLayout.tsx index fdc3282..5567773 100644 --- a/src/shared/ui/Modals/ItemEditorModal/EditorModalLayout.tsx +++ b/src/shared/ui/Modals/ItemEditorModal/EditorModalLayout.tsx @@ -16,6 +16,7 @@ const EditorModalLayout = ({ handleDelete, headerExtras, submitButtonLabel, + hideActions = false, mode, headerTitleContainerRef, modalWrapperRef, @@ -30,6 +31,7 @@ const EditorModalLayout = ({ handleDelete?: () => void headerExtras?: React.ReactNode submitButtonLabel?: string + hideActions?: boolean headerTitleContainerRef?: Ref modalWrapperRef?: Ref }) => { @@ -53,23 +55,25 @@ const EditorModalLayout = ({ {children} {footerChildren} - - - { - onSubmit() - } - } - > - {submitButtonLabel && {submitButtonLabel}} - - - + {!hideActions && ( + + + { + onSubmit() + } + } + > + {submitButtonLabel && {submitButtonLabel}} + + + + )} diff --git a/src/shared/ui/Modals/ItemEditorModal/index.tsx b/src/shared/ui/Modals/ItemEditorModal/index.tsx index cfea258..cd2b578 100644 --- a/src/shared/ui/Modals/ItemEditorModal/index.tsx +++ b/src/shared/ui/Modals/ItemEditorModal/index.tsx @@ -8,6 +8,7 @@ import TodoEditorForm from '@/shared/ui/Modals/TodoEditor/TodoEditorForm' import { useEditorDraft } from '@/shared/utils/useEditorDraft' import { useEditorRegistry } from '@/shared/utils/useEditorRegistry' import { useEditorTypeSync } from '@/shared/utils/useEditorTypeSync' +import { useToastStore } from '@/store/useToastStore' import EditorModalLayout from './EditorModalLayout' import * as S from './ItemEditorModal.style' @@ -84,6 +85,15 @@ const ItemEditorModal = ({ const [modalWrapperElement, setModalWrapperElement] = useState(null) const modalWrapperRef = useRef(null) + const isReadOnlySchedule = + isEditing && initialType === 'schedule' && initialEvent?.isOwner === false + const showReadOnlyScheduleToast = useCallback(() => { + useToastStore.getState().showToast({ + title: '일정을 수정할 수 없습니다', + message: '일정 소유자만 수정할 수 있습니다.', + toastType: 'warning', + }) + }, []) const handleSubmit = useCallback(() => { const submitFormId = activeType === 'todo' ? 'add-todo-form' : 'add-schedule-form' @@ -105,14 +115,26 @@ const ItemEditorModal = ({ setActiveType('todo')} + onClick={() => { + if (isReadOnlySchedule) { + showReadOnlyScheduleToast() + return + } + setActiveType('todo') + }} > 할 일 setActiveType('schedule')} + onClick={() => { + if (isReadOnlySchedule) { + showReadOnlyScheduleToast() + return + } + setActiveType('schedule') + }} > 일정 @@ -154,6 +176,7 @@ const ItemEditorModal = ({ submitFormId={activeType === 'todo' ? 'add-todo-form' : 'add-schedule-form'} handleDelete={deleteHandler} footerChildren={footerChildren} + hideActions={isReadOnlySchedule && activeType === 'schedule'} submitButtonLabel={ activeType === 'schedule' && isScheduleShared ? '저장 및 초대 전송' : undefined } diff --git a/src/shared/ui/Modals/ScheduleEditor/ScheduleDateTimeSection.tsx b/src/shared/ui/Modals/ScheduleEditor/ScheduleDateTimeSection.tsx index 4b246cc..ed0340d 100644 --- a/src/shared/ui/Modals/ScheduleEditor/ScheduleDateTimeSection.tsx +++ b/src/shared/ui/Modals/ScheduleEditor/ScheduleDateTimeSection.tsx @@ -14,11 +14,17 @@ import { formatDisplayDate } from '@/shared/utils/date' type ScheduleDateTimeSectionProps = Pick & { handleAllDayToggle: () => void + readOnly?: boolean + onReadOnlyAttempt?: () => void + onUserEdit?: () => void } const ScheduleDateTimeSection = ({ mode = 'modal', handleAllDayToggle, + readOnly = false, + onReadOnlyAttempt, + onUserEdit, }: ScheduleDateTimeSectionProps) => { const { control, setValue } = useFormContext() const isAllday = useWatch({ control, name: 'isAllday' }) ?? false @@ -55,27 +61,43 @@ const ScheduleDateTimeSection = ({ - + {startDate} {!isAllday && ( handleTimeChange('start', value)} + onChange={(value) => { + onUserEdit?.() + handleTimeChange('start', value) + }} + readOnly={readOnly} + onReadOnlyAttempt={onReadOnlyAttempt} /> )} {isAllday && '-'} - + {endDate} {!isAllday && ( handleTimeChange('end', value)} + onChange={(value) => { + onUserEdit?.() + handleTimeChange('end', value) + }} + readOnly={readOnly} + onReadOnlyAttempt={onReadOnlyAttempt} /> )} @@ -93,13 +115,22 @@ const ScheduleDateTimeSection = ({ ? (eventStartDate ?? null) : (eventEndDate ?? null) } - onSelectDate={handleDateSelect} + onSelectDate={(selectedDate) => { + onUserEdit?.() + handleDateSelect(selectedDate) + }} /> , document.getElementById('modal-root')!, )} - + ) } diff --git a/src/shared/ui/Modals/ScheduleEditor/ScheduleDetailsSection.tsx b/src/shared/ui/Modals/ScheduleEditor/ScheduleDetailsSection.tsx index c6b48ef..dbf1d99 100644 --- a/src/shared/ui/Modals/ScheduleEditor/ScheduleDetailsSection.tsx +++ b/src/shared/ui/Modals/ScheduleEditor/ScheduleDetailsSection.tsx @@ -8,13 +8,21 @@ import type { ScheduleEditorFormProps } from '@/shared/types/modal/scheduleEdito import * as S from '@/shared/ui/Modals/ScheduleEditor/index.style' import SearchPlace from '@/shared/ui/scheduleTodo/SearchPlace/SearchPlace' -type ScheduleDetailsSectionProps = Pick +type ScheduleDetailsSectionProps = Pick & { + readOnly?: boolean + onReadOnlyAttempt?: () => void + onUserEdit?: () => void +} const ScheduleDetailsSection = ({ modalWrapperElement, mode = 'modal', + readOnly = false, + onReadOnlyAttempt, + onUserEdit, }: ScheduleDetailsSectionProps) => { const { control, register, setValue } = useFormContext() + const descriptionField = register('eventDescription') const location = useWatch({ control, name: 'location' }) ?? '' const { closeSearchPlace, @@ -37,12 +45,28 @@ const ScheduleDetailsSection = ({ 메모 - + { + if (readOnly) onReadOnlyAttempt?.() + }} + onChange={(event) => { + if (readOnly) { + event.preventDefault() + onReadOnlyAttempt?.() + return + } + onUserEdit?.() + descriptionField.onChange(event) + }} + /> openSearchPlace()} + onClick={readOnly ? onReadOnlyAttempt : () => openSearchPlace()} $hasValue={Boolean(location)} title={location || '장소 추가'} > @@ -55,6 +79,7 @@ const ScheduleDetailsSection = ({ { + onUserEdit?.() setValue('location', nextLocation, { shouldDirty: true, shouldValidate: true, diff --git a/src/shared/ui/Modals/ScheduleEditor/ScheduleEditorContent.tsx b/src/shared/ui/Modals/ScheduleEditor/ScheduleEditorContent.tsx index a080d93..7be4349 100644 --- a/src/shared/ui/Modals/ScheduleEditor/ScheduleEditorContent.tsx +++ b/src/shared/ui/Modals/ScheduleEditor/ScheduleEditorContent.tsx @@ -1,7 +1,8 @@ // 일정 편집 본문을 조합하고 제출, 패치, 삭제, 닫기 보호 흐름을 연결합니다. -import { useCallback, useEffect, useRef } from 'react' +import { type FormEvent, useCallback, useEffect, useRef, useState } from 'react' import { useFormContext } from 'react-hook-form' +import { RECURRENCE_EVENT_SCOPE } from '@/shared/constants/recurrenceScope' import { useScheduleEventSync, useScheduleFooter, @@ -16,6 +17,7 @@ import type { ScheduleEditorFormProps } from '@/shared/types/modal/scheduleEdito import { UnsavedChangesConfirmModal } from '@/shared/ui/Modals' import ScheduleEditorConfirmModals from '@/shared/ui/Modals/ScheduleEditor/ScheduleEditorConfirmModals' import ScheduleEditorFields from '@/shared/ui/Modals/ScheduleEditor/ScheduleEditorFields' +import { useToastStore } from '@/store/useToastStore' type ScheduleEditorContentProps = ScheduleEditorFormProps & { schedule: UseScheduleEditorFormResult @@ -33,6 +35,7 @@ const ScheduleEditorContent = ({ headerTitlePortalTarget, modalWrapperElement, initialEvent, + invitedParticipants, eventId, onEventColorChange, onEventTitleConfirm, @@ -54,9 +57,20 @@ const ScheduleEditorContent = ({ setEventColor, eventTitle, } = schedule - const { setValue, getValues, formState } = useFormContext() - const { isDirty } = formState + const { setValue, getValues } = useFormContext() + const [hasUserEdited, setHasUserEdited] = useState(false) const originalTitleRef = useRef('') + const canEdit = !isEditing || initialEvent?.isOwner !== false + const markUserEdited = useCallback(() => { + setHasUserEdited(true) + }, []) + const showReadOnlyToast = useCallback(() => { + useToastStore.getState().showToast({ + title: '일정을 수정할 수 없습니다', + message: '일정 소유자만 수정할 수 있습니다.', + toastType: 'warning', + }) + }, []) useEffect(() => { if (!isEditing) { @@ -64,7 +78,7 @@ const ScheduleEditorContent = ({ return } originalTitleRef.current = initialEvent?.title ?? '' - }, [eventId, initialEvent?.occurrenceDate, initialEvent?.start, isEditing]) + }, [eventId, initialEvent?.occurrenceDate, initialEvent?.start, initialEvent?.title, isEditing]) const handleDiscardDraftTitle = useCallback(() => { if (!isEditing || eventId == null || eventId === 0) return @@ -73,7 +87,7 @@ const ScheduleEditorContent = ({ const { isUnsavedConfirmOpen, requestClose, handleCloseUnsavedConfirm, handleLeaveUnsavedForm } = useUnsavedCloseGuard({ - isDirty, + isDirty: hasUserEdited, onClose, onDiscard: handleDiscardDraftTitle, registerCloseGuard, @@ -89,7 +103,12 @@ const ScheduleEditorContent = ({ // 종일 토글 처리 (시간 필드 초기화 포함) const handleAllDayToggle = useCallback(() => { + if (!canEdit) { + showReadOnlyToast() + return + } const nextIsAllDay = !isAllday + markUserEdited() const isExistingRecurring = initialEvent?.recurrenceGroup != null setValue('isAllday', nextIsAllDay, { shouldDirty: true, @@ -106,10 +125,20 @@ const ScheduleEditorContent = ({ isAllday: nextIsAllDay, ...(nextIsAllDay ? { eventStartTime: undefined, eventEndTime: undefined } : {}), }, - isExistingRecurring ? 'THIS_EVENT' : undefined, + isExistingRecurring ? RECURRENCE_EVENT_SCOPE.THIS_EVENT : undefined, ) } - }, [getValues, initialEvent?.recurrenceGroup, isAllday, isEditing, patchSchedule, setValue]) + }, [ + canEdit, + getValues, + initialEvent?.recurrenceGroup, + isAllday, + isEditing, + patchSchedule, + setValue, + markUserEdited, + showReadOnlyToast, + ]) // 로컬 이벤트 동기화(타이틀/시간) const { syncEventTiming, handleTitleConfirm } = useScheduleEventSync({ @@ -156,6 +185,8 @@ const ScheduleEditorContent = ({ handleTitleConfirm, buildDateTime, formatDateTime, + canEdit, + onReadOnlyAttempt: showReadOnlyToast, }) // 하단 컬러 선택/삭제 핸들러 @@ -173,22 +204,41 @@ const ScheduleEditorContent = ({ eventColor, closeModal: () => requestClose(true), occurrenceDate: initialEvent?.occurrenceDate ?? date, + canEdit, + onReadOnlyAttempt: showReadOnlyToast, }) + const handleReadOnlyFormSubmit = useCallback( + (event: FormEvent) => { + if (canEdit) { + void handleFormSubmit(event) + return + } + event.preventDefault() + event.stopPropagation() + showReadOnlyToast() + }, + [canEdit, handleFormSubmit, showReadOnlyToast], + ) + return ( <> -
+ & { updateConfig: (changes: Partial) => void handleRepeatType: (value: RepeatType) => void handleAllDayToggle: () => void onTitleConfirm: (value: string) => void onSharedChange?: (isShared: boolean) => void + readOnly?: boolean + onReadOnlyAttempt?: () => void + onUserEdit?: () => void } const ScheduleEditorFields = ({ headerTitlePortalTarget, isEditing = false, isShared = false, + invitedParticipants, modalWrapperElement, mode = 'modal', updateConfig, @@ -31,6 +40,9 @@ const ScheduleEditorFields = ({ handleAllDayToggle, onTitleConfirm, onSharedChange, + readOnly = false, + onReadOnlyAttempt, + onUserEdit, }: ScheduleEditorFieldsProps) => { return ( <> @@ -40,12 +52,40 @@ const ScheduleEditorFields = ({ autoFocus={!isEditing} isShared={isShared} onTitleConfirm={onTitleConfirm} + readOnly={readOnly} + onReadOnlyAttempt={onReadOnlyAttempt} + onUserEdit={onUserEdit} + /> + + - - - - + + ) } diff --git a/src/shared/ui/Modals/ScheduleEditor/ScheduleRepeatSection.tsx b/src/shared/ui/Modals/ScheduleEditor/ScheduleRepeatSection.tsx index af8cdf7..1c99074 100644 --- a/src/shared/ui/Modals/ScheduleEditor/ScheduleRepeatSection.tsx +++ b/src/shared/ui/Modals/ScheduleEditor/ScheduleRepeatSection.tsx @@ -1,5 +1,5 @@ // 일정 반복 규칙과 종료 조건을 편집하는 섹션입니다. -import { useState } from 'react' +import { useCallback, useState } from 'react' import { useFormContext, useWatch } from 'react-hook-form' import type { RepeatConfigSchema, ScheduleEditorFormValues } from '@/shared/types/event/event' @@ -12,25 +12,50 @@ import TerminationPanel from '@/shared/ui/scheduleTodo/TerminationPanel/Terminat type ScheduleRepeatSectionProps = { updateConfig: (changes: Partial) => void handleRepeatType: (value: RepeatType) => void + readOnly?: boolean + onReadOnlyAttempt?: () => void + onUserEdit?: () => void } -const ScheduleRepeatSection = ({ updateConfig, handleRepeatType }: ScheduleRepeatSectionProps) => { +const ScheduleRepeatSection = ({ + updateConfig, + handleRepeatType, + readOnly = false, + onReadOnlyAttempt, + onUserEdit, +}: ScheduleRepeatSectionProps) => { const { control } = useFormContext() const repeatConfig = useWatch({ control, name: 'repeatConfig' }) as RepeatConfigSchema const eventEndDate = useWatch({ control, name: 'eventEndDate' }) ?? null const [isRepeatDetailOpen, setIsRepeatDetailOpen] = useState(false) const hasRepeatType = Boolean(repeatConfig && repeatConfig.repeatType !== 'none') + const handleReadOnlyConfigChange = useCallback(() => { + onReadOnlyAttempt?.() + }, [onReadOnlyAttempt]) + const resolvedUpdateConfig = readOnly + ? handleReadOnlyConfigChange + : (changes: Partial) => { + onUserEdit?.() + updateConfig(changes) + } if (!repeatConfig) return null const handleToggleRepeatType = (value: RepeatType) => { + if (readOnly) { + onReadOnlyAttempt?.() + return + } const willClearRepeatType = repeatConfig.repeatType === value && value !== 'custom' const willClearCustomRepeat = repeatConfig.repeatType === 'custom' && value === 'custom' + onUserEdit?.() handleRepeatType(value) if (willClearRepeatType || willClearCustomRepeat) { setIsRepeatDetailOpen(false) + return } + setIsRepeatDetailOpen(true) } return ( @@ -52,13 +77,13 @@ const ScheduleRepeatSection = ({ updateConfig, handleRepeatType }: ScheduleRepea )} {repeatConfig.repeatType !== 'none' && ( )} diff --git a/src/shared/ui/Modals/ScheduleEditor/ScheduleTitleField.tsx b/src/shared/ui/Modals/ScheduleEditor/ScheduleTitleField.tsx index 06c54c4..09f3c01 100644 --- a/src/shared/ui/Modals/ScheduleEditor/ScheduleTitleField.tsx +++ b/src/shared/ui/Modals/ScheduleEditor/ScheduleTitleField.tsx @@ -8,25 +8,33 @@ import { theme } from '@/shared/styles/theme' import type { ScheduleEditorFormValues } from '@/shared/types/event/event' import TitleSuggestionInput from '@/shared/ui/scheduleTodo/TitleSuggestionInput/TitleSuggestionInput' +const TITLE_SEARCH_THROTTLE_MS = 300 + type ScheduleTitleFieldProps = { portalTarget?: HTMLElement | null autoFocus?: boolean isShared?: boolean + readOnly?: boolean onTitleConfirm: (value: string) => void + onReadOnlyAttempt?: () => void + onUserEdit?: () => void } const ScheduleTitleField = ({ portalTarget, autoFocus = true, isShared = false, + readOnly = false, onTitleConfirm, + onReadOnlyAttempt, + onUserEdit, }: ScheduleTitleFieldProps) => { const { control } = useFormContext() const eventTitleKeyword = (useWatch({ control, name: 'eventTitle' }) ?? '').trim() - const throttledEventTitleKeyword = useThrottledValue(eventTitleKeyword, 150) + const throttledEventTitleKeyword = useThrottledValue(eventTitleKeyword, TITLE_SEARCH_THROTTLE_MS) const { data: eventTitleHistoryData } = useEventTitleHistoryQuery( throttledEventTitleKeyword, - Boolean(throttledEventTitleKeyword), + !readOnly && Boolean(throttledEventTitleKeyword), ) const suggestions = eventTitleHistoryData?.result.titleHistory ?? [] @@ -34,11 +42,27 @@ const ScheduleTitleField = ({ { + onUserEdit?.() + onTitleConfirm(value) + } + } + onConfirm={ + readOnly + ? undefined + : (value) => { + onUserEdit?.() + onTitleConfirm(value) + } + } + onReadOnlyAttempt={onReadOnlyAttempt} /> ) diff --git a/src/shared/ui/Modals/ScheduleEditor/ShareSchedulePanel.tsx b/src/shared/ui/Modals/ScheduleEditor/ShareSchedulePanel.tsx index 24ec701..7a3c26c 100644 --- a/src/shared/ui/Modals/ScheduleEditor/ShareSchedulePanel.tsx +++ b/src/shared/ui/Modals/ScheduleEditor/ShareSchedulePanel.tsx @@ -1,37 +1,114 @@ -import { useEffect, useState } from 'react' +import { useState } from 'react' +import { useFormContext, useWatch } from 'react-hook-form' import Arrow from '@/shared/assets/icons/chevron.svg?react' import Close from '@/shared/assets/icons/close.svg?react' +import type { CalendarEvent } from '@/shared/types/calendar/types' +import type { ScheduleEditorFormValues } from '@/shared/types/event/event' import type { ScheduleShareFriend } from '@/shared/types/schedule/shareFriend' import SearchFriend from '@/shared/ui/scheduleTodo/SearchFriend/SearchFriend' +import { getEventParticipantFriendId } from '@/shared/utils/eventParticipants' import * as S from './index.style' +type DisplayedFriend = { + id: string + name: string +} + +const dedupeFriends = (friends: DisplayedFriend[]) => { + const seenIds = new Set() + + return friends.filter((friend) => { + if (seenIds.has(friend.id)) return false + seenIds.add(friend.id) + return true + }) +} + type ShareSchedulePanelProps = { + isShared?: boolean + invitedParticipants?: CalendarEvent['eventParticipantInfo'] onSharedChange?: (isShared: boolean) => void + readOnly?: boolean + onReadOnlyAttempt?: () => void + onUserEdit?: () => void } -const ShareSchedulePanel = ({ onSharedChange }: ShareSchedulePanelProps) => { +const ShareSchedulePanel = ({ + isShared = false, + invitedParticipants = [], + onSharedChange, + readOnly = false, + onReadOnlyAttempt, + onUserEdit, +}: ShareSchedulePanelProps) => { + const { control, setValue } = useFormContext() const [isOpen, setIsOpen] = useState(false) const [selectedFriends, setSelectedFriends] = useState([]) + const selectedFriendIds = useWatch({ control, name: 'friendIds' }) ?? [] + const visibleInvitedParticipants = readOnly + ? invitedParticipants + : invitedParticipants.filter((participant) => { + const friendId = getEventParticipantFriendId(participant) + + return friendId != null && selectedFriendIds.includes(friendId) + }) + const displayedFriends = dedupeFriends([ + ...visibleInvitedParticipants.map((participant) => { + const friendId = getEventParticipantFriendId(participant) - useEffect(() => { - onSharedChange?.(selectedFriends.length > 0) - }, [onSharedChange, selectedFriends.length]) + return { + id: String(friendId ?? participant.eventParticipantId), + name: participant.name, + } + }), + ...selectedFriends.map((friend) => ({ + id: friend.userId, + name: friend.userName, + })), + ]) const handleToggleFriend = (friend: ScheduleShareFriend) => { - setSelectedFriends((previous) => { - const isSelected = previous.some((selectedFriend) => selectedFriend.userId === friend.userId) + if (readOnly) { + onReadOnlyAttempt?.() + return + } + const friendId = Number(friend.userId) + const isSelected = selectedFriendIds.includes(friendId) + const nextFriendIds = isSelected + ? selectedFriendIds.filter((selectedFriendId) => selectedFriendId !== friendId) + : [...selectedFriendIds, friendId] + + onUserEdit?.() + setValue('friendIds', nextFriendIds, { shouldDirty: true, shouldValidate: true }) + onSharedChange?.(nextFriendIds.length > 0) + setSelectedFriends((previous) => { if (isSelected) { return previous.filter((selectedFriend) => selectedFriend.userId !== friend.userId) } + if (previous.some((selectedFriend) => selectedFriend.userId === friend.userId)) { + return previous + } + return [...previous, friend] }) } const handleRemoveFriend = (friendId: ScheduleShareFriend['userId']) => { + if (readOnly) { + onReadOnlyAttempt?.() + return + } + const numericFriendId = Number(friendId) + const nextFriendIds = selectedFriendIds.filter( + (selectedFriendId) => selectedFriendId !== numericFriendId, + ) + onUserEdit?.() + setValue('friendIds', nextFriendIds, { shouldDirty: true, shouldValidate: true }) + onSharedChange?.(nextFriendIds.length > 0) setSelectedFriends((previous) => previous.filter((friend) => friend.userId !== friendId)) } @@ -41,7 +118,7 @@ const ShareSchedulePanel = ({ onSharedChange }: ShareSchedulePanelProps) => { type="button" onClick={() => setIsOpen(!isOpen)} isOpen={isOpen} - isShared={selectedFriends.length > 0} + isShared={isShared || selectedFriendIds.length > 0} >
@@ -51,27 +128,36 @@ const ShareSchedulePanel = ({ onSharedChange }: ShareSchedulePanelProps) => { {isOpen && ( - - -
- {selectedFriends.map((friend) => ( -
- {friend.userName} - -
- ))} -
+ {!readOnly && ( + + )} + + {displayedFriends.length > 0 && ( +
+ {displayedFriends.map((friend) => ( +
+ {friend.name} + {!readOnly && ( + + )} +
+ ))} +
+ )}
)} diff --git a/src/shared/ui/Modals/ScheduleEditor/index.style.ts b/src/shared/ui/Modals/ScheduleEditor/index.style.ts index 4235a4c..a3e7a18 100644 --- a/src/shared/ui/Modals/ScheduleEditor/index.style.ts +++ b/src/shared/ui/Modals/ScheduleEditor/index.style.ts @@ -204,7 +204,7 @@ export const FriendSection = styled.div` border: 1px solid #d5d4e3; background: #fff; display: flex; - padding: 6px 8px 6px 12px; + padding: 6px 8px; justify-content: center; align-items: center; gap: 5px; diff --git a/src/shared/ui/Modals/TodoEditor/TodoEditorContent.tsx b/src/shared/ui/Modals/TodoEditor/TodoEditorContent.tsx index 2fec70d..81f0c4f 100644 --- a/src/shared/ui/Modals/TodoEditor/TodoEditorContent.tsx +++ b/src/shared/ui/Modals/TodoEditor/TodoEditorContent.tsx @@ -10,6 +10,7 @@ import type { TodoEditorFormProps } from '@/shared/types/modal/todoEditor' import { UnsavedChangesConfirmModal } from '@/shared/ui/Modals' import TodoEditorConfirmModals from '@/shared/ui/Modals/TodoEditor/TodoEditorConfirmModals' import TodoEditorFields from '@/shared/ui/Modals/TodoEditor/TodoEditorFields' +import { buildDateTime as buildEditorDateTime } from '@/shared/utils/editorDateTime' type TodoEditorContentProps = TodoEditorFormProps & { todo: UseTodoEditorFormResult @@ -71,16 +72,10 @@ const TodoEditorContent = ({ }) const repeatGuardEnabled = isEditing && hasExistingRecurrence - const buildDateTime = useCallback((dateValue: Date | null, timeValue?: string) => { - const nextDate = dateValue ? new Date(dateValue) : new Date() - if (!timeValue) { - nextDate.setHours(0, 0, 0, 0) - return nextDate - } - const [hour, minute] = timeValue.split(':').map((value) => Number.parseInt(value, 10)) - nextDate.setHours(Number.isNaN(hour) ? 0 : hour, Number.isNaN(minute) ? 0 : minute, 0, 0) - return nextDate - }, []) + const buildDateTime = useCallback( + (dateValue: Date | null, timeValue?: string) => buildEditorDateTime(dateValue, timeValue), + [], + ) const syncEventTiming = useCallback( (values: TodoEditorFormValues) => { diff --git a/src/shared/ui/Modals/TodoEditor/TodoRepeatSection.tsx b/src/shared/ui/Modals/TodoEditor/TodoRepeatSection.tsx index f9c11fe..a7b53c6 100644 --- a/src/shared/ui/Modals/TodoEditor/TodoRepeatSection.tsx +++ b/src/shared/ui/Modals/TodoEditor/TodoRepeatSection.tsx @@ -30,7 +30,9 @@ const TodoRepeatSection = ({ updateConfig, handleRepeatType }: TodoRepeatSection handleRepeatType(value) if (willClearRepeatType || willClearCustomRepeat) { setIsRepeatDetailOpen(false) + return } + setIsRepeatDetailOpen(true) } const repeatEndDate = todoDate ? new Date(todoDate) : null diff --git a/src/shared/ui/calendar/CustomTimePicker/CustomTimePicker.tsx b/src/shared/ui/calendar/CustomTimePicker/CustomTimePicker.tsx index 87bd130..f1f3f49 100644 --- a/src/shared/ui/calendar/CustomTimePicker/CustomTimePicker.tsx +++ b/src/shared/ui/calendar/CustomTimePicker/CustomTimePicker.tsx @@ -9,7 +9,17 @@ const getTimeParts = (time?: string) => { return { nextHour, nextMinute } } -const CustomTimePicker = ({ value = '09:00', onChange }: TimePickerRenderProps) => { +type CustomTimePickerProps = TimePickerRenderProps & { + readOnly?: boolean + onReadOnlyAttempt?: () => void +} + +const CustomTimePicker = ({ + value = '09:00', + onChange, + readOnly = false, + onReadOnlyAttempt, +}: CustomTimePickerProps) => { const hourRef = useRef(null) const minuteRef = useRef(null) const { nextHour: initialHour, nextMinute: initialMinute } = getTimeParts(value) @@ -29,6 +39,10 @@ const CustomTimePicker = ({ value = '09:00', onChange }: TimePickerRenderProps) const formatTwoDigits = (value?: string) => value?.padStart(2, '0') ?? '00' const handleInputChange = (e: ChangeEvent, type: 'hour' | 'minute') => { + if (readOnly) { + onReadOnlyAttempt?.() + return + } const max = type === 'hour' ? 23 : 59 const digits = sanitizeDigits(e.target.value) let val = digits @@ -54,6 +68,7 @@ const CustomTimePicker = ({ value = '09:00', onChange }: TimePickerRenderProps) // 포커스 아웃 시 1자리 숫자를 2자리로 보정 (예: '9' -> '09') const handleBlur = () => { + if (readOnly) return const formattedHour = formatTwoDigits(hourRef.current?.value ?? initialHour) const formattedMin = formatTwoDigits(minuteRef.current?.value ?? initialMinute) if (hourRef.current) hourRef.current.value = formattedHour @@ -70,6 +85,11 @@ const CustomTimePicker = ({ value = '09:00', onChange }: TimePickerRenderProps) pattern="[0-9]*" defaultValue={initialHour} ref={hourRef} + readOnly={readOnly} + aria-readonly={readOnly} + onFocus={() => { + if (readOnly) onReadOnlyAttempt?.() + }} onChange={(e) => handleInputChange(e, 'hour')} onBlur={handleBlur} placeholder="HH" @@ -81,6 +101,11 @@ const CustomTimePicker = ({ value = '09:00', onChange }: TimePickerRenderProps) pattern="[0-9]*" defaultValue={initialMinute} ref={minuteRef} + readOnly={readOnly} + aria-readonly={readOnly} + onFocus={() => { + if (readOnly) onReadOnlyAttempt?.() + }} onChange={(e) => handleInputChange(e, 'minute')} onBlur={handleBlur} placeholder="mm" diff --git a/src/shared/ui/common/Checkbox/Checkbox.tsx b/src/shared/ui/common/Checkbox/Checkbox.tsx index 57a9b1d..1d8b771 100644 --- a/src/shared/ui/common/Checkbox/Checkbox.tsx +++ b/src/shared/ui/common/Checkbox/Checkbox.tsx @@ -8,10 +8,14 @@ const Checkbox = ({ checked, onChange, label, + readOnly = false, + onReadOnlyAttempt, }: { label?: string checked: boolean onChange: (e: React.ChangeEvent) => void + readOnly?: boolean + onReadOnlyAttempt?: () => void }) => { const id = useId() return ( @@ -22,7 +26,19 @@ const Checkbox = ({ isChecked={checked} type="checkbox" checked={checked} - onChange={onChange} + onChange={(event) => { + if (readOnly) { + event.preventDefault() + onReadOnlyAttempt?.() + return + } + onChange(event) + }} + onClick={(event) => { + if (!readOnly) return + event.preventDefault() + onReadOnlyAttempt?.() + }} /> diff --git a/src/shared/ui/scheduleTodo/SearchFriend/SearchFriend.style.ts b/src/shared/ui/scheduleTodo/SearchFriend/SearchFriend.style.ts index ba72700..9f53d30 100644 --- a/src/shared/ui/scheduleTodo/SearchFriend/SearchFriend.style.ts +++ b/src/shared/ui/scheduleTodo/SearchFriend/SearchFriend.style.ts @@ -90,11 +90,12 @@ export const SearchResultItem = styled.button<{ isAdded: boolean }>` background-color 0.2s ease, color 0.2s ease; background-color: ${(props) => (props.isAdded ? theme.colors.share.base : '#f1f1f1')}; + border: 1px solid ${(props) => (props.isAdded ? theme.colors.share.point : 'transparent')}; .divider { width: 1px; height: 16px; - background-color: #d2d3d2; + background-color: ${(props) => (props.isAdded ? theme.colors.share.point : '#d2d3d2')}; margin: 0 8px; } .plus { @@ -117,10 +118,10 @@ export const EmptySearchResult = styled.div` ` export const Name = styled.div<{ isAdded: boolean }>` - color: ${(props) => (props.isAdded ? '#484569' : '#655446')}; + color: ${(props) => (props.isAdded ? theme.colors.share.point : '#655446')}; ` -export const Email = styled.div` - color: ${theme.colors.textColor2}; +export const Email = styled.div<{ isAdded?: boolean }>` + color: ${(props) => (props.isAdded ? theme.colors.share.point : theme.colors.textColor2)}; font-weight: 400; ` diff --git a/src/shared/ui/scheduleTodo/SearchFriend/SearchFriend.tsx b/src/shared/ui/scheduleTodo/SearchFriend/SearchFriend.tsx index cf69d2c..29dca5b 100644 --- a/src/shared/ui/scheduleTodo/SearchFriend/SearchFriend.tsx +++ b/src/shared/ui/scheduleTodo/SearchFriend/SearchFriend.tsx @@ -4,36 +4,47 @@ import { createPortal } from 'react-dom' import Close from '@/shared/assets/icons/close.svg?react' import Plus from '@/shared/assets/icons/plus.svg?react' import Search from '@/shared/assets/icons/search.svg?react' -import { MOCK_SCHEDULE_SHARE_FRIENDS } from '@/shared/constants/scheduleShareFriend' +import { useThrottledValue } from '@/shared/hooks/common/useThrottledValue' +import { useFriendSearchQuery } from '@/shared/hooks/query/useFriendQueries' import { theme } from '@/shared/styles' import type { ScheduleShareFriend } from '@/shared/types/schedule/shareFriend' import { isElementVisible } from '@/shared/utils/domVisibility' -import { filterScheduleShareFriends } from '@/shared/utils/scheduleShareFriend' import * as S from './SearchFriend.style' type SearchFriendProps = { - selectedFriends: ScheduleShareFriend[] + selectedFriendIds: number[] onToggleFriend: (friend: ScheduleShareFriend) => void } -const SearchFriend = ({ selectedFriends, onToggleFriend }: SearchFriendProps) => { +const SearchFriend = ({ selectedFriendIds, onToggleFriend }: SearchFriendProps) => { const [keyword, setKeyword] = useState('') const [isOpen, setIsOpen] = useState(false) const [resultPosition, setResultPosition] = useState({ left: 0, top: 0, width: 0 }) const inputWrapperRef = useRef(null) const resultRef = useRef(null) - const selectedFriendIds = useMemo( - () => new Set(selectedFriends.map((friend) => friend.userId)), - [selectedFriends], - ) - const trimmedKeyword = keyword.trim().toLowerCase() - const filteredFriends = useMemo( - () => filterScheduleShareFriends(MOCK_SCHEDULE_SHARE_FRIENDS, trimmedKeyword), - [trimmedKeyword], + const selectedFriendIdSet = useMemo(() => new Set(selectedFriendIds), [selectedFriendIds]) + const trimmedKeyword = keyword.trim() + const throttledKeyword = useThrottledValue(trimmedKeyword, 300) + const { + data: friendSearchData, + isError, + isFetching, + } = useFriendSearchQuery(throttledKeyword, Boolean(throttledKeyword)) + const searchedFriends = useMemo( + () => + (friendSearchData?.result.friendDetailList ?? []).map( + (friend): ScheduleShareFriend => ({ + userId: String(friend.id), + userName: friend.opponentName, + email: friend.opponentEmail, + }), + ), + [friendSearchData?.result.friendDetailList], ) - const shouldShowResult = isOpen && Boolean(trimmedKeyword) && filteredFriends.length > 0 + const shouldShowResult = isOpen && Boolean(trimmedKeyword) + const isWaitingForThrottledSearch = trimmedKeyword !== throttledKeyword || isFetching const updateResultPosition = () => { const inputWrapper = inputWrapperRef.current @@ -58,41 +69,49 @@ const SearchFriend = ({ selectedFriends, onToggleFriend }: SearchFriendProps) => return createPortal( - {filteredFriends.map((friend) => { - const isSelected = selectedFriendIds.has(friend.userId) - - return ( - onToggleFriend(friend)} - > - {friend.userName} -
- {friend.email} - {isSelected ? ( -