diff --git a/src/app/App.tsx b/src/app/App.tsx index 956f4956..9b6ae465 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -27,7 +27,6 @@ import { SubstituteRequestPage } from '@/pages/user/substitute-request' import { ManagerSubstituteRequestPage } from '@/pages/manager/substitute-request' import { StoreRegisterPage } from '@/pages/manager/store-register' import { ManagerWorkerInvitePage } from '@/pages/manager/worker-invite' -import { WorkerListPage } from '@/pages/manager/worker-list' import { WorkspaceJoinPage } from '@/pages/user/workspace-join' import { NotificationPage } from '@/pages/notification' import { NotificationSettingsPage } from '@/pages/notification/settings' @@ -147,10 +146,6 @@ export function App() { path={ROUTES.MANAGER.WORKER_INVITE} element={} /> - } - /> }> diff --git a/src/features/home/common/schedule/lib/date.ts b/src/features/home/common/schedule/lib/date.ts deleted file mode 100644 index a17e8a53..00000000 --- a/src/features/home/common/schedule/lib/date.ts +++ /dev/null @@ -1,47 +0,0 @@ -const ISO_DATE_LENGTH = 10 -const ISO_TIME_START = 11 -const ISO_TIME_END = 16 - -export function toDateKey(iso: string | null | undefined) { - if (iso == null || iso === '') return '' - return iso.slice(0, ISO_DATE_LENGTH) -} - -export function toTimeLabel(iso: string | null | undefined) { - if (iso == null || iso === '' || iso.length < ISO_TIME_END) { - return '--:--' - } - return iso.slice(ISO_TIME_START, ISO_TIME_END) -} - -export function getDurationHours( - startIso: string | null | undefined, - endIso: string | null | undefined -) { - if (startIso == null || endIso == null || startIso === '' || endIso === '') { - return 0 - } - const start = new Date(startIso).getTime() - const end = new Date(endIso).getTime() - const diffHours = Math.max((end - start) / (1000 * 60 * 60), 0) - return Number(diffHours.toFixed(1)) -} - -export { splitClockToParts } from '@/shared/lib/clock' -import { splitClockToParts } from '@/shared/lib/clock' - -export function formatClockRangeLabel( - startClock: string | null | undefined, - endClock: string | null | undefined -) { - const s = splitClockToParts(startClock ?? '') - const e = splitClockToParts(endClock ?? '') - return `${s.hour}:${s.minute} ~ ${e.hour}:${e.minute}` -} - -export function formatIsoTimeRangeLabel( - startIso: string | null | undefined, - endIso: string | null | undefined -) { - return `${toTimeLabel(startIso)} ~ ${toTimeLabel(endIso)}` -} diff --git a/src/features/home/common/schedule/lib/summaryFormat.ts b/src/features/home/common/schedule/lib/summaryFormat.ts new file mode 100644 index 00000000..f795b559 --- /dev/null +++ b/src/features/home/common/schedule/lib/summaryFormat.ts @@ -0,0 +1,10 @@ +export function formatTotalWorkHoursText(totalWorkHours?: number): string { + return String(Math.round(totalWorkHours ?? 0)).padStart(2, '0') +} + +export function formatEstimatedEarningsText( + estimatedLaborCost?: number +): string | undefined { + if (estimatedLaborCost == null) return undefined + return `약 ${estimatedLaborCost.toLocaleString()}원` +} diff --git a/src/features/manager/home/hooks/useManagerHomeViewModel.ts b/src/features/manager/home/hooks/useManagerHomeViewModel.ts index eac6f798..c122596a 100644 --- a/src/features/manager/home/hooks/useManagerHomeViewModel.ts +++ b/src/features/manager/home/hooks/useManagerHomeViewModel.ts @@ -4,7 +4,7 @@ import { useWorkspaceDetailQuery } from '@/features/manager/home/hooks/useWorksp import { useWorkspaceWorkersViewModel } from '@/features/manager/home/hooks/useWorkspaceWorkersViewModel' import { useManagedPostingsViewModel } from '@/features/manager/home/hooks/useManagedPostingsViewModel' import { useSubstituteRequestsViewModel } from '@/features/manager/home/hooks/useSubstituteRequestsViewModel' -import { useMonthlySchedulesViewModel } from '@/features/manager/home/hooks/useMonthlySchedulesViewModel' +import { useWorkerScheduleCalendarViewModel } from '@/features/manager/home/hooks/useWorkerScheduleCalendarViewModel' import { useTodaySchedulesViewModel } from '@/features/manager/home/hooks/useTodaySchedulesViewModel' export function useManagerHomeViewModel() { @@ -126,13 +126,21 @@ export function useManagerHomeViewModel() { const { baseDate: scheduleBaseDate, - calendarData, + scheduleData, + totalWorkHoursText, + estimatedEarningsText, selectedDateKey, isLoading: isScheduleLoading, - onDateChange: onScheduleDateChange, - goToPrevMonth, - goToNextMonth, - } = useMonthlySchedulesViewModel(activeWorkspaceId) + onMonthChange: onScheduleMonthChange, + isModalOpen: isScheduleModalOpen, + modalDateKey: scheduleModalDateKey, + visibleWorkers: scheduleVisibleWorkers, + deleteError: scheduleDeleteError, + handleDateClick: onScheduleDateClick, + closeModal: closeScheduleModal, + handleDeleteWorker: handleScheduleDeleteWorker, + handleEditWorker: handleScheduleEditWorker, + } = useWorkerScheduleCalendarViewModel(activeWorkspaceId) const { todayWorkers } = useTodaySchedulesViewModel(activeWorkspaceId) @@ -181,12 +189,20 @@ export function useManagerHomeViewModel() { hasMoreSubstitutes, schedule: { baseDate: scheduleBaseDate, + scheduleData, + totalWorkHoursText, + estimatedEarningsText, selectedDateKey, - data: calendarData, isLoading: isScheduleLoading, - onDateChange: onScheduleDateChange, - goToPrevMonth, - goToNextMonth, + onMonthChange: onScheduleMonthChange, + isModalOpen: isScheduleModalOpen, + modalDateKey: scheduleModalDateKey, + visibleWorkers: scheduleVisibleWorkers, + deleteError: scheduleDeleteError, + handleDateClick: onScheduleDateClick, + closeModal: closeScheduleModal, + handleDeleteWorker: handleScheduleDeleteWorker, + handleEditWorker: handleScheduleEditWorker, }, workspaceDetail, workspaceChangeModal: { diff --git a/src/features/manager/home/hooks/useMonthlySchedulesViewModel.ts b/src/features/manager/home/hooks/useMonthlySchedulesViewModel.ts deleted file mode 100644 index bf12916a..00000000 --- a/src/features/manager/home/hooks/useMonthlySchedulesViewModel.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { useCallback, useMemo, useState } from 'react' -import { addMonths, format } from 'date-fns' -import { useQuery } from '@tanstack/react-query' -import { fetchMonthlySchedules } from '@/features/manager/api/schedule' -import type { - ManagerScheduleApiResponse, - ManagerScheduleShiftDto, -} from '@/features/manager/home/types/schedule' -import type { - CalendarEvent, - CalendarViewData, -} from '@/features/home/common/schedule/types/calendarView' -import type { StatusEnum } from '@/shared/types/enums' -import { - toDateKey, - toTimeLabel, - getDurationHours, -} from '@/features/home/common/schedule/lib/date' -import { queryKeys } from '@/shared/lib/queryKeys' - -function adaptManagerScheduleResponse( - response: ManagerScheduleApiResponse -): CalendarViewData { - const { totalWorkHours, estimatedLaborCost, schedules } = response.data - const events = schedules.map( - (shift: ManagerScheduleShiftDto): CalendarEvent => ({ - shiftId: shift.shiftId, - workspaceName: shift.workspace.workspaceName, - position: shift.position, - status: shift.status.value as StatusEnum, - startDateTime: shift.startDateTime, - endDateTime: shift.endDateTime, - dateKey: toDateKey(shift.startDateTime), - startTimeLabel: toTimeLabel(shift.startDateTime), - endTimeLabel: toTimeLabel(shift.endDateTime), - durationHours: getDurationHours(shift.startDateTime, shift.endDateTime), - }) - ) - return { - summary: { totalWorkHours, eventCount: events.length, estimatedLaborCost }, - events, - } -} - -const DATE_KEY_FORMAT = 'yyyy-MM-dd' - -export function useMonthlySchedulesViewModel(workspaceId: number | null) { - const [baseDate, setBaseDate] = useState(() => new Date()) - - const year = baseDate.getFullYear() - const month = baseDate.getMonth() + 1 - - const { data: rawData, isPending } = useQuery({ - queryKey: queryKeys.manager.schedules(workspaceId ?? 0, year, month), - queryFn: () => - fetchMonthlySchedules({ workspaceId: workspaceId!, year, month }), - enabled: workspaceId !== null, - }) - - const calendarData = useMemo( - () => (rawData ? adaptManagerScheduleResponse(rawData) : null), - [rawData] - ) - - // 선택된 날짜: 오늘이 현재 월이면 오늘, 아니면 해당 월 1일 - const selectedDateKey = useMemo(() => { - const today = new Date() - const todayKey = format(today, DATE_KEY_FORMAT) - const isSameMonth = - today.getFullYear() === year && today.getMonth() + 1 === month - return isSameMonth ? todayKey : format(baseDate, 'yyyy-MM-01') - }, [baseDate, year, month]) - - const onDateChange = useCallback((nextDate: Date) => { - setBaseDate(nextDate) - }, []) - - const goToPrevMonth = useCallback(() => { - setBaseDate(prev => addMonths(prev, -1)) - }, []) - - const goToNextMonth = useCallback(() => { - setBaseDate(prev => addMonths(prev, 1)) - }, []) - - return { - baseDate, - calendarData, - selectedDateKey, - isLoading: isPending && workspaceId !== null, - onDateChange, - goToPrevMonth, - goToNextMonth, - } -} diff --git a/src/features/manager/home/hooks/useTodaySchedulesViewModel.ts b/src/features/manager/home/hooks/useTodaySchedulesViewModel.ts index 5788c478..214362e9 100644 --- a/src/features/manager/home/hooks/useTodaySchedulesViewModel.ts +++ b/src/features/manager/home/hooks/useTodaySchedulesViewModel.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import { fetchTodaySchedules } from '@/features/manager/api/schedule' import { queryKeys } from '@/shared/lib/queryKeys' -import { formatIsoTimeRangeLabel } from '@/features/home/common/schedule/lib/date' +import { formatIsoTimeRangeLabel } from '@/shared/lib/calendarUtils' import type { TodayWorkerItem } from '@/features/manager/home/ui/TodayWorkerList' export function useTodaySchedulesViewModel(workspaceId: number | null) { diff --git a/src/features/manager/home/hooks/useWorkerScheduleCalendarViewModel.ts b/src/features/manager/home/hooks/useWorkerScheduleCalendarViewModel.ts new file mode 100644 index 00000000..9949e19c --- /dev/null +++ b/src/features/manager/home/hooks/useWorkerScheduleCalendarViewModel.ts @@ -0,0 +1,136 @@ +import { useCallback, useMemo, useState } from 'react' +import { format } from 'date-fns' +import axios from 'axios' +import { useNavigate } from 'react-router-dom' +import { useWorkerListSchedulesQuery } from '@/features/manager/worker-list/hooks/query/useWorkerListSchedulesQuery' +import { + buildWorkerScheduleData, + getVisibleWorkers, +} from '@/features/manager/worker-list/lib/workerSchedule' +import type { WorkerListEntry } from '@/features/manager/worker-list/lib/workerSchedule' +import { + formatEstimatedEarningsText, + formatTotalWorkHoursText, +} from '@/features/home/common/schedule/lib/summaryFormat' +import { + useDeleteScheduleWorker, + useDeleteSchedule, +} from '@/features/manager/schedule/hooks/mutation' +import { managerWorkerSchedulePath } from '@/shared/constants/routes' +import type { WorkerScheduleLocationState } from '@/features/manager' + +const DATE_KEY_FORMAT = 'yyyy-MM-dd' + +const DELETE_WORKER_ERROR_MESSAGES: Record = { + B020: '요청한 리소스를 찾을 수 없습니다.', + A002: '관리중인 업장이 아닙니다.', +} + +function getDeleteWorkerErrorMessage(error: unknown): string { + if (axios.isAxiosError(error)) { + const code = (error.response?.data as { code?: string } | undefined)?.code + if (code && DELETE_WORKER_ERROR_MESSAGES[code]) { + return DELETE_WORKER_ERROR_MESSAGES[code] + } + } + return '삭제 중 오류가 발생했습니다. 다시 시도해 주세요.' +} + +export function useWorkerScheduleCalendarViewModel(workspaceId: number | null) { + const [baseDate, setBaseDate] = useState(() => new Date()) + const [selectedDateKey, setSelectedDateKey] = useState(() => + format(new Date(), DATE_KEY_FORMAT) + ) + const [modalDateKey, setModalDateKey] = useState(null) + const [deleteError, setDeleteError] = useState(null) + + const navigate = useNavigate() + const { mutateAsync: deleteWorker } = useDeleteScheduleWorker( + workspaceId ?? 0 + ) + const { mutateAsync: deleteShift } = useDeleteSchedule(workspaceId ?? 0) + + const year = baseDate.getFullYear() + const month = baseDate.getMonth() + 1 + + const { data: rawData, isPending } = useWorkerListSchedulesQuery( + workspaceId, + year, + month + ) + + const scheduleData = useMemo( + () => buildWorkerScheduleData(rawData), + [rawData] + ) + + const totalWorkHoursText = useMemo( + () => formatTotalWorkHoursText(rawData?.data.totalWorkHours), + [rawData] + ) + + const estimatedEarningsText = useMemo( + () => formatEstimatedEarningsText(rawData?.data.estimatedLaborCost), + [rawData] + ) + + const visibleWorkers = useMemo( + () => (modalDateKey ? getVisibleWorkers(rawData, modalDateKey) : []), + [rawData, modalDateKey] + ) + + const onMonthChange = useCallback((date: Date) => setBaseDate(date), []) + + const handleDateClick = useCallback((dateKey: string) => { + setDeleteError(null) + setSelectedDateKey(dateKey) + setModalDateKey(dateKey) + }, []) + + const closeModal = useCallback(() => { + setDeleteError(null) + setModalDateKey(null) + }, []) + + const handleDeleteWorker = useCallback( + async (shiftId: number) => { + try { + setDeleteError(null) + await deleteWorker(shiftId) + await deleteShift(shiftId) + } catch (error) { + setDeleteError(getDeleteWorkerErrorMessage(error)) + } + }, + [deleteWorker, deleteShift] + ) + + const handleEditWorker = useCallback( + (worker: WorkerListEntry) => { + navigate(managerWorkerSchedulePath(workspaceId ?? 0, worker.workerId), { + state: { + editDate: modalDateKey ?? selectedDateKey, + } satisfies WorkerScheduleLocationState, + }) + }, + [navigate, workspaceId, modalDateKey, selectedDateKey] + ) + + return { + baseDate, + scheduleData, + totalWorkHoursText, + estimatedEarningsText, + selectedDateKey, + isLoading: isPending && workspaceId !== null, + onMonthChange, + isModalOpen: modalDateKey !== null, + modalDateKey, + visibleWorkers, + deleteError, + handleDateClick, + closeModal, + handleDeleteWorker, + handleEditWorker, + } +} diff --git a/src/features/manager/worker-list/hooks/useWorkerListViewModel.ts b/src/features/manager/worker-list/hooks/useWorkerListViewModel.ts deleted file mode 100644 index ba5827cf..00000000 --- a/src/features/manager/worker-list/hooks/useWorkerListViewModel.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { useCallback, useMemo, useState } from 'react' -import { format } from 'date-fns' -import axios from 'axios' -import { useNavigate } from 'react-router-dom' -import { - toDateKey, - toTimeLabel, -} from '@/features/home/common/schedule/lib/date' -import { useWorkspaceStore } from '@/shared/stores/useWorkspaceStore' -import type { WorkerScheduleData } from '@/features/manager/worker-list/types/workerSchedule' -import type { ScheduleColor } from '@/features/manager' -import { resolveSchedulePickerColor } from '@/features/manager' -import type { WorkerRole } from '@/shared/types/workerRole' -import { useWorkerListSchedulesQuery } from './query/useWorkerListSchedulesQuery' -import { - useDeleteScheduleWorker, - useDeleteSchedule, -} from '@/features/manager/schedule/hooks/mutation' -import { managerWorkerSchedulePath } from '@/shared/constants/routes' -import type { WorkerScheduleLocationState } from '@/features/manager' - -const DELETE_WORKER_ERROR_MESSAGES: Record = { - B020: '요청한 리소스를 찾을 수 없습니다.', - A002: '관리중인 업장이 아닙니다.', -} - -function getDeleteWorkerErrorMessage(error: unknown): string { - if (axios.isAxiosError(error)) { - const code = (error.response?.data as { code?: string } | undefined)?.code - if (code && DELETE_WORKER_ERROR_MESSAGES[code]) { - return DELETE_WORKER_ERROR_MESSAGES[code] - } - } - return '삭제 중 오류가 발생했습니다. 다시 시도해 주세요.' -} - -function positionToRole(position: string): WorkerRole { - const lower = position.toLowerCase() - if (lower === 'manager') return 'manager' - if (lower === 'owner') return 'owner' - return 'staff' -} - -export interface WorkerListEntry { - workerId: number - shiftId: number - name: string - workspaceName: string - nextShiftTime: string - scheduleColor: ScheduleColor - role: WorkerRole -} - -export function useWorkerListViewModel() { - const navigate = useNavigate() - const { activeWorkspaceId } = useWorkspaceStore() - const workspaceId = activeWorkspaceId ?? 0 - const [baseDate] = useState(() => new Date()) - - const year = baseDate.getFullYear() - const month = baseDate.getMonth() + 1 - - const [selectedDate, setSelectedDate] = useState(() => - format(new Date(), 'yyyy-MM-dd') - ) - const [deleteError, setDeleteError] = useState(null) - - const { data: rawData, isPending } = useWorkerListSchedulesQuery( - activeWorkspaceId, - year, - month - ) - - const { mutateAsync: deleteWorker } = useDeleteScheduleWorker(workspaceId) - const { mutateAsync: deleteShift } = useDeleteSchedule(workspaceId) - - const scheduleData = useMemo(() => { - if (!rawData) return null - const result: WorkerScheduleData = {} - rawData.data.schedules.forEach(shift => { - const colorCode = shift.assignedWorker?.colorCode - if (!colorCode) return - const dateKey = toDateKey(shift.startDateTime) - if (!result[dateKey]) result[dateKey] = [] - if (!result[dateKey].includes(colorCode)) result[dateKey].push(colorCode) - }) - return result - }, [rawData]) - - const visibleWorkers = useMemo(() => { - if (!rawData) return [] - const seen = new Set() - return rawData.data.schedules - .filter( - shift => - toDateKey(shift.startDateTime) === selectedDate && - shift.assignedWorker != null - ) - .reduce((acc, shift) => { - const worker = shift.assignedWorker! - if (seen.has(worker.workerId)) return acc - seen.add(worker.workerId) - acc.push({ - workerId: worker.workerId, - shiftId: shift.shiftId, - name: worker.workerName, - workspaceName: shift.workspace.workspaceName, - nextShiftTime: `${toTimeLabel(shift.startDateTime)} ~ ${toTimeLabel(shift.endDateTime)}`, - scheduleColor: resolveSchedulePickerColor(worker.colorCode), - role: positionToRole(shift.position), - }) - return acc - }, []) - }, [rawData, selectedDate]) - - const handleDateClick = useCallback((dateKey: string) => { - setSelectedDate(dateKey) - }, []) - - const handleDeleteWorker = useCallback( - async (shiftId: number) => { - try { - setDeleteError(null) - await deleteWorker(shiftId) - await deleteShift(shiftId) - } catch (error) { - setDeleteError(getDeleteWorkerErrorMessage(error)) - } - }, - [deleteWorker, deleteShift] - ) - - const handleEditWorker = useCallback( - (worker: WorkerListEntry) => { - navigate(managerWorkerSchedulePath(workspaceId, worker.workerId), { - state: { editDate: selectedDate } satisfies WorkerScheduleLocationState, - }) - }, - [navigate, workspaceId, selectedDate] - ) - - return { - baseDate, - scheduleData, - visibleWorkers, - selectedDate, - isLoading: isPending && activeWorkspaceId !== null, - deleteError, - handleDateClick, - handleDeleteWorker, - handleEditWorker, - } -} diff --git a/src/features/manager/worker-list/lib/workerSchedule.ts b/src/features/manager/worker-list/lib/workerSchedule.ts new file mode 100644 index 00000000..84aeaede --- /dev/null +++ b/src/features/manager/worker-list/lib/workerSchedule.ts @@ -0,0 +1,67 @@ +import { toDateKey, toTimeLabel } from '@/shared/lib/calendarUtils' +import type { ManagerScheduleApiResponse } from '@/features/manager/home/types/schedule' +import type { WorkerScheduleData } from '@/features/manager/worker-list/types/workerSchedule' +import type { ScheduleColor } from '@/features/manager' +import { resolveSchedulePickerColor } from '@/features/manager' +import type { WorkerRole } from '@/shared/types/workerRole' + +export interface WorkerListEntry { + workerId: number + shiftId: number + name: string + workspaceName: string + nextShiftTime: string + scheduleColor: ScheduleColor + role: WorkerRole +} + +export function positionToRole(position: string): WorkerRole { + const lower = position.toLowerCase() + if (lower === 'manager') return 'manager' + if (lower === 'owner') return 'owner' + return 'staff' +} + +export function buildWorkerScheduleData( + rawData: ManagerScheduleApiResponse | undefined +): WorkerScheduleData | null { + if (!rawData) return null + const result: WorkerScheduleData = {} + rawData.data.schedules.forEach(shift => { + const colorCode = shift.assignedWorker?.colorCode + if (!colorCode) return + const dateKey = toDateKey(shift.startDateTime) + if (!result[dateKey]) result[dateKey] = [] + if (!result[dateKey].includes(colorCode)) result[dateKey].push(colorCode) + }) + return result +} + +export function getVisibleWorkers( + rawData: ManagerScheduleApiResponse | undefined, + selectedDate: string +): WorkerListEntry[] { + if (!rawData) return [] + const seen = new Set() + return rawData.data.schedules + .filter( + shift => + toDateKey(shift.startDateTime) === selectedDate && + shift.assignedWorker != null + ) + .reduce((acc, shift) => { + const worker = shift.assignedWorker! + if (seen.has(worker.workerId)) return acc + seen.add(worker.workerId) + acc.push({ + workerId: worker.workerId, + shiftId: shift.shiftId, + name: worker.workerName, + workspaceName: shift.workspace.workspaceName, + nextShiftTime: `${toTimeLabel(shift.startDateTime)} ~ ${toTimeLabel(shift.endDateTime)}`, + scheduleColor: resolveSchedulePickerColor(worker.colorCode), + role: positionToRole(shift.position), + }) + return acc + }, []) +} diff --git a/src/features/manager/worker-list/types/workerSchedule.ts b/src/features/manager/worker-list/types/workerSchedule.ts index f927ceea..53abdfce 100644 --- a/src/features/manager/worker-list/types/workerSchedule.ts +++ b/src/features/manager/worker-list/types/workerSchedule.ts @@ -6,4 +6,9 @@ export interface WorkerScheduleCalendarProps { selectedDate?: string | null onEditClick?: () => void onDateClick?: (dateKey: string) => void + onMonthChange?: (date: Date) => void + totalWorkHoursText?: string + estimatedEarningsText?: string + isLoading?: boolean + showTitle?: boolean } diff --git a/src/features/manager/worker-list/ui/WorkerScheduleCalendar.tsx b/src/features/manager/worker-list/ui/WorkerScheduleCalendar.tsx index 4049d87d..8486ba13 100644 --- a/src/features/manager/worker-list/ui/WorkerScheduleCalendar.tsx +++ b/src/features/manager/worker-list/ui/WorkerScheduleCalendar.tsx @@ -1,5 +1,8 @@ import EditIcon from '@/assets/icons/home/edit.svg' +import DownIcon from '@/assets/icons/home/chevron-down.svg?react' import { DATE_KEY_FORMAT } from '@/features/home/common/schedule/constants/calendar' +import { useMonthYearPickerViewModel } from '@/features/home/common/schedule/hooks/useMonthYearPickerViewModel' +import { MonthYearPickerModal } from '@/features/home/common/schedule/ui/MonthYearPickerModal' import { WEEKDAY_LABELS } from '@/shared/constants/calendar' import { getCalendarCells } from '@/shared/lib/calendarUtils' import { format, getDay } from 'date-fns' @@ -12,14 +15,58 @@ export function WorkerScheduleCalendar({ selectedDate, onEditClick, onDateClick, + onMonthChange, + totalWorkHoursText, + estimatedEarningsText, + isLoading = false, + showTitle = true, }: WorkerScheduleCalendarProps) { const cells = getCalendarCells(baseDate, 0) const monthLabel = `${format(baseDate, 'M')}월 스케줄표` + const { + isPickerOpen, + openPicker, + closePicker, + yearItems, + yearIndex, + monthIndex, + onYearIndexChange, + onMonthIndexChange, + onPickerConfirm, + } = useMonthYearPickerViewModel({ currentDate: baseDate, onMonthChange }) + + if (isLoading) { + return ( +
+

월간 일정을 불러오는 중...

+
+ ) + } + + const showMonthPickerInline = !showTitle && Boolean(onMonthChange) + const hasHeaderLeftContent = showTitle || showMonthPickerInline + return (
-
-

{monthLabel}

+
+ {showTitle && ( +

{monthLabel}

+ )} + {showMonthPickerInline && ( + + )}
+ {onMonthChange && showTitle && ( +
+ +
+ )} + + {totalWorkHoursText && ( +
+
+ {totalWorkHoursText} + 시간 근무해요 +
+ {estimatedEarningsText && ( + + {estimatedEarningsText} + + )} +
+ )} +
{WEEKDAY_LABELS.map(label => (
+ +
) } diff --git a/src/features/manager/worker-schedule/hooks/useWorkerScheduleManageViewModel.ts b/src/features/manager/worker-schedule/hooks/useWorkerScheduleManageViewModel.ts index d26549f0..36dc901c 100644 --- a/src/features/manager/worker-schedule/hooks/useWorkerScheduleManageViewModel.ts +++ b/src/features/manager/worker-schedule/hooks/useWorkerScheduleManageViewModel.ts @@ -22,7 +22,7 @@ import { dateTimeToHourMinute } from '@/features/manager/worker-schedule/lib/sch import { ROUTES, managerWorkerSchedulePath } from '@/shared/constants/routes' import { queryKeys } from '@/shared/lib/queryKeys' import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage' -import { toDateKey } from '@/features/home/common/schedule/lib/date' +import { toDateKey } from '@/shared/lib/calendarUtils' import type { ManagerScheduleShiftDto } from '@/features/manager/home/types/schedule' import type { ScheduleColor } from '@/features/manager/worker-schedule/types/scheduleColor' import { diff --git a/src/features/user/home/applied-stores/ui/AppliedStoreDetailModal.tsx b/src/features/user/home/applied-stores/ui/AppliedStoreDetailModal.tsx index c9cfb591..60e0b410 100644 --- a/src/features/user/home/applied-stores/ui/AppliedStoreDetailModal.tsx +++ b/src/features/user/home/applied-stores/ui/AppliedStoreDetailModal.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react' +import { useScrollLock } from '@/shared/lib/useScrollLock' import { WEEKDAY_LABELS, type AppliedApplicationDetail, @@ -23,14 +24,7 @@ export function AppliedStoreDetailModal({ onCancel, isCancelling = false, }: AppliedStoreDetailModalProps) { - useEffect(() => { - if (!isOpen) return - const prev = document.body.style.overflow - document.body.style.overflow = 'hidden' - return () => { - document.body.style.overflow = prev - } - }, [isOpen]) + useScrollLock(isOpen) useEffect(() => { if (!isOpen) return diff --git a/src/features/user/home/schedule/api/schedule.ts b/src/features/user/home/schedule/api/schedule.ts index e12cd8ba..fc03cc56 100644 --- a/src/features/user/home/schedule/api/schedule.ts +++ b/src/features/user/home/schedule/api/schedule.ts @@ -11,7 +11,7 @@ import { getDurationHours, toDateKey, toTimeLabel, -} from '@/features/home/common/schedule/lib/date' +} from '@/shared/lib/calendarUtils' import type { SelfScheduleQueryParams } from '@/shared/types/schedule' function mapToCalendarEvent( diff --git a/src/features/user/home/schedule/lib/date.test.ts b/src/features/user/home/schedule/lib/date.test.ts index c5f16493..7eabcd8e 100644 --- a/src/features/user/home/schedule/lib/date.test.ts +++ b/src/features/user/home/schedule/lib/date.test.ts @@ -17,7 +17,7 @@ import { toDateKey, toTimeLabel, getDurationHours, -} from '@/features/home/common/schedule/lib/date' +} from '@/shared/lib/calendarUtils' describe('toDateKey', () => { it('ISO 문자열에서 날짜 부분만 반환한다', () => { diff --git a/src/features/user/home/schedule/lib/date.ts b/src/features/user/home/schedule/lib/date.ts index 9c4d388c..da5eb13b 100644 --- a/src/features/user/home/schedule/lib/date.ts +++ b/src/features/user/home/schedule/lib/date.ts @@ -19,7 +19,7 @@ import { toDateKey, toTimeLabel, getDurationHours, -} from '@/features/home/common/schedule/lib/date' +} from '@/shared/lib/calendarUtils' export function getMonthlyDateCells(baseDate: Date) { const monthStart = startOfMonth(baseDate) diff --git a/src/features/user/home/schedule/ui/DayScheduleModal.tsx b/src/features/user/home/schedule/ui/DayScheduleModal.tsx new file mode 100644 index 00000000..0986e4af --- /dev/null +++ b/src/features/user/home/schedule/ui/DayScheduleModal.tsx @@ -0,0 +1,75 @@ +import { format, parse } from 'date-fns' +import { ko } from 'date-fns/locale' +import { DAILY_STATUS_STYLE_MAP } from '@/features/home/common/schedule/constants/calendar' +import type { CalendarEvent } from '@/features/user/home/schedule/types/schedule' +import { formatScheduleTimeRange } from '@/features/user/home/schedule/lib/date' +import { ShiftTimelineBar } from '@/features/user/home/schedule/ui/ShiftTimelineBar' +import { Modal } from '@/shared/ui/common/Modal' + +interface DayScheduleModalProps { + isOpen: boolean + dateKey: string | null + events: CalendarEvent[] + onClose: () => void +} + +export function DayScheduleModal({ + isOpen, + dateKey, + events, + onClose, +}: DayScheduleModalProps) { + const title = dateKey + ? format(parse(dateKey, 'yyyy-MM-dd', new Date()), 'M월 d일 (EEE)', { + locale: ko, + }) + : '' + + return ( + + {events.length > 0 ? ( +
+ {events.map(event => { + const { time, hours } = formatScheduleTimeRange( + event.startDateTime, + event.endDateTime + ) + const colorClassName = + DAILY_STATUS_STYLE_MAP[event.status] ?? 'bg-main/70' + + return ( +
+
+ + {event.workspaceName} + + + {hours} + +
+

{time}

+
+ +
+
+ ) + })} +
+ ) : ( +

+ 해당 날짜에 근무 일정이 없어요 +

+ )} +
+ ) +} diff --git a/src/features/user/home/schedule/ui/HomeScheduleCalendar.tsx b/src/features/user/home/schedule/ui/HomeScheduleCalendar.tsx index 779efbed..0c013fd5 100644 --- a/src/features/user/home/schedule/ui/HomeScheduleCalendar.tsx +++ b/src/features/user/home/schedule/ui/HomeScheduleCalendar.tsx @@ -1,9 +1,11 @@ +import { useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { CalendarViewData, HomeCalendarMode, } from '@/features/user/home/schedule/types/schedule' import { DailyCalendar } from '@/features/user/home/schedule/ui/DailyCalendar' +import { DayScheduleModal } from '@/features/user/home/schedule/ui/DayScheduleModal' import { MonthlyCalendar } from '@/features/user/home/schedule/ui/MonthlyCalendar' import { WeeklyCalendar } from '@/features/user/home/schedule/ui/WeeklyCalendar' @@ -27,34 +29,50 @@ export function HomeScheduleCalendar({ isLoading = false, onDateChange, }: HomeScheduleCalendarProps) { + const [selectedDateKey, setSelectedDateKey] = useState(null) + + const selectedEvents = useMemo( + () => data?.events.filter(event => event.dateKey === selectedDateKey) ?? [], + [data, selectedDateKey] + ) + return ( -
- {mode === 'monthly' && ( - - )} - {mode === 'weekly' && ( - - )} - {mode === 'daily' && ( - - )} -
+ <> +
+ {mode === 'monthly' && ( + + )} + {mode === 'weekly' && ( + + )} + {mode === 'daily' && ( + + )} +
+ setSelectedDateKey(null)} + /> + ) } diff --git a/src/features/user/home/schedule/ui/ShiftTimelineBar.tsx b/src/features/user/home/schedule/ui/ShiftTimelineBar.tsx new file mode 100644 index 00000000..97c349a2 --- /dev/null +++ b/src/features/user/home/schedule/ui/ShiftTimelineBar.tsx @@ -0,0 +1,54 @@ +interface ShiftTimelineBarProps { + startDateTime: string + endDateTime: string + colorClassName: string +} + +const TICK_HOURS = [6, 12, 18] +const LABEL_HOURS = [0, 6, 12, 18, 24] + +export function ShiftTimelineBar({ + startDateTime, + endDateTime, + colorClassName, +}: ShiftTimelineBarProps) { + const startDate = new Date(startDateTime) + const endDate = new Date(endDateTime) + + let start = startDate.getHours() + startDate.getMinutes() / 60 + let end = endDate.getHours() + endDate.getMinutes() / 60 + + if (end <= start) { + end += 24 + } + + start = Math.min(Math.max(start, 0), 24) + end = Math.min(Math.max(end, 0), 24) + + const leftPct = (start / 24) * 100 + const widthPct = ((end - start) / 24) * 100 + + return ( +
+
+ {TICK_HOURS.map(hour => ( + + ))} + +
+
+ {LABEL_HOURS.map(hour => ( + {hour} + ))} +
+
+ ) +} diff --git a/src/features/user/home/workspace/api/workspaceSchedule.ts b/src/features/user/home/workspace/api/workspaceSchedule.ts index aa91bb52..9b928cfa 100644 --- a/src/features/user/home/workspace/api/workspaceSchedule.ts +++ b/src/features/user/home/workspace/api/workspaceSchedule.ts @@ -7,7 +7,7 @@ import { getDurationHours, toDateKey, toTimeLabel, -} from '@/features/home/common/schedule/lib/date' +} from '@/shared/lib/calendarUtils' import { formatScheduleTimeRange } from '@/features/user/home/schedule/lib/date' import type { WorkspaceScheduleApiResponse, diff --git a/src/features/user/substitute/lib/adaptExchangeableSchedules.ts b/src/features/user/substitute/lib/adaptExchangeableSchedules.ts index a6bae3a7..f3e27030 100644 --- a/src/features/user/substitute/lib/adaptExchangeableSchedules.ts +++ b/src/features/user/substitute/lib/adaptExchangeableSchedules.ts @@ -2,7 +2,7 @@ import { getDurationHours, toDateKey, toTimeLabel, -} from '@/features/home/common/schedule/lib/date' +} from '@/shared/lib/calendarUtils' import type { CalendarViewData } from '@/features/home/common/schedule/types/calendarView' import type { ExchangeableSchedulesApiResponse, diff --git a/src/pages/manager/home/index.tsx b/src/pages/manager/home/index.tsx index 8ce8add0..0dddb14b 100644 --- a/src/pages/manager/home/index.tsx +++ b/src/pages/manager/home/index.tsx @@ -10,7 +10,7 @@ import { useManagerHomeViewModel, } from '@/features/manager' import { useNavbarNotificationProps } from '@/features/notification' -import { MonthlyCalendar } from '@/features/home/common/schedule/ui/MonthlyCalendar' +import { WorkerScheduleCalendar } from '@/features/manager/worker-list/ui/WorkerScheduleCalendar' import { OngoingPostingCard } from '@/shared/ui/manager/OngoingPostingCard' import { SubstituteApprovalCard } from '@/shared/ui/manager/SubstituteApprovalCard' import { MoreButton } from '@/shared/ui/common/MoreButton' @@ -18,10 +18,12 @@ import { WorkCategoryBadge } from '@/shared/ui/home/WorkCategoryBadge' import managerHomeBannerImage from '@/assets/manager-home-banner.jpg' import managerHomeBannerListIcon from '@/assets/icons/home/manager-home-banner-list.svg' import managerWorkspaceModalPlusIcon from '@/assets/icons/home/manager-workspace-modal-plus.svg' -import managerScheduleEditIcon from '@/assets/icons/home/edit.svg' import { ROUTES, managerWorkerSchedulePath } from '@/shared/constants/routes' import { useResignWorkerMutation } from '@/features/manager/worker-list/hooks/mutation/useResignWorkerMutation' import { ConfirmModal } from '@/shared/ui/common/ConfirmModal' +import { Modal } from '@/shared/ui/common/Modal' +import { WorkerListItem } from '@/features/manager/worker-list/ui/WorkerListItem' +import type { WorkerListEntry } from '@/features/manager/worker-list/lib/workerSchedule' import ResignIcon from '@/assets/icons/home/resign.svg?react' export function ManagerHomePage() { @@ -31,6 +33,8 @@ export function ManagerHomePage() { number | null >(null) const [isResignErrorOpen, setIsResignErrorOpen] = useState(false) + const [deleteTargetWorker, setDeleteTargetWorker] = + useState(null) const { todayWorkers, storeWorkers, @@ -158,56 +162,31 @@ export function ManagerHomePage() {
{format(new Date(), 'M월 d일', { locale: ko })}
-
- { - if ( - activeWorkspaceId === null || - storeWorkers[0] === undefined - ) { - navigate(ROUTES.MANAGER.WORKER_SCHEDULE) - return - } - navigate( - managerWorkerSchedulePath( - activeWorkspaceId, - storeWorkers[0].id - ) - ) - }} - > - - - } + showTitle={false} + selectedDate={schedule.selectedDateKey} + totalWorkHoursText={schedule.totalWorkHoursText} + estimatedEarningsText={schedule.estimatedEarningsText} + onMonthChange={schedule.onMonthChange} + onDateClick={schedule.handleDateClick} + onEditClick={() => { + if (activeWorkspaceId === null || storeWorkers[0] === undefined) { + navigate(ROUTES.MANAGER.WORKER_SCHEDULE) + return + } + navigate( + managerWorkerSchedulePath(activeWorkspaceId, storeWorkers[0].id) + ) + }} />
@@ -322,6 +301,55 @@ export function ManagerHomePage() { onConfirm={() => setIsResignErrorOpen(false)} onClose={() => setIsResignErrorOpen(false)} /> + + + {schedule.deleteError && ( +

+ {schedule.deleteError} +

+ )} + {schedule.visibleWorkers.length > 0 ? ( +
+ {schedule.visibleWorkers.map(worker => ( + schedule.handleEditWorker(worker)} + onDelete={() => setDeleteTargetWorker(worker)} + /> + ))} +
+ ) : ( +

+ 해당 날짜에 근무자가 없습니다 +

+ )} +
+ + { + if (!deleteTargetWorker) return + schedule.handleDeleteWorker(deleteTargetWorker.shiftId) + setDeleteTargetWorker(null) + }} + onClose={() => setDeleteTargetWorker(null)} + /> ) } diff --git a/src/pages/manager/substitute-request/components/ManagerSubstituteActionModal.tsx b/src/pages/manager/substitute-request/components/ManagerSubstituteActionModal.tsx index 1896b8b7..491dd672 100644 --- a/src/pages/manager/substitute-request/components/ManagerSubstituteActionModal.tsx +++ b/src/pages/manager/substitute-request/components/ManagerSubstituteActionModal.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react' +import { useScrollLock } from '@/shared/lib/useScrollLock' export type SubstituteActionType = 'approve' | 'reject' @@ -55,13 +56,7 @@ export function ManagerSubstituteActionModal({ return () => window.removeEventListener('keydown', onKeyDown) }, [open, handleClose]) - useEffect(() => { - const prev = document.body.style.overflow - if (open) document.body.style.overflow = 'hidden' - return () => { - document.body.style.overflow = prev - } - }, [open]) + useScrollLock(open) if (!open) return null diff --git a/src/pages/manager/worker-list/index.tsx b/src/pages/manager/worker-list/index.tsx deleted file mode 100644 index 2496d318..00000000 --- a/src/pages/manager/worker-list/index.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useNavigate } from 'react-router-dom' - -import { WorkerScheduleCalendar } from '@/features/manager/worker-list/ui/WorkerScheduleCalendar' -import { WorkerListItem } from '@/features/manager/worker-list/ui/WorkerListItem' -import { Navbar } from '@/shared/ui/common/Navbar' -import PlusIcon from '@/assets/icons/Plus.svg' -import { useWorkerListViewModel } from '@/features/manager/worker-list/hooks/useWorkerListViewModel' - -export function WorkerListPage() { - const navigate = useNavigate() - const { - baseDate, - scheduleData, - visibleWorkers, - selectedDate, - deleteError, - handleDateClick, - handleDeleteWorker, - handleEditWorker, - } = useWorkerListViewModel() - - return ( -
- navigate(-1)} - rightAction={ - - } - /> - -
- {/* 스케줄표 카드 */} -
- navigate('/manager/worker-schedule')} - onDateClick={handleDateClick} - /> -
- - {/* 근무자 목록 카드 */} -
-
-

근무자 목록

-
- {deleteError && ( -

- {deleteError} -

- )} -
- {visibleWorkers.length > 0 ? ( - visibleWorkers.map(worker => ( - handleEditWorker(worker)} - onDelete={() => { - void handleDeleteWorker(worker.shiftId) - }} - /> - )) - ) : ( -

- 해당 날짜에 근무자가 없습니다 -

- )} -
-
-
-
- ) -} - -export default WorkerListPage diff --git a/src/pages/my/profile/index.tsx b/src/pages/my/profile/index.tsx index 05e6f405..9764a366 100644 --- a/src/pages/my/profile/index.tsx +++ b/src/pages/my/profile/index.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react' +import { useScrollLock } from '@/shared/lib/useScrollLock' import { useNavigate } from 'react-router-dom' import useAuthStore from '@/shared/stores/useAuthStore' import { useProfileImageEditor, useUserMe } from '@/features/user/me' @@ -31,13 +32,7 @@ function DeleteProfileImageModal({ return () => window.removeEventListener('keydown', onKeyDown) }, [open, onClose]) - useEffect(() => { - const prev = document.body.style.overflow - if (open) document.body.style.overflow = 'hidden' - return () => { - document.body.style.overflow = prev - } - }, [open]) + useScrollLock(open) if (!open) return null diff --git a/src/pages/user/substitute-request/components/SubstituteRejectReasonModal.tsx b/src/pages/user/substitute-request/components/SubstituteRejectReasonModal.tsx index 135c28e7..652934a0 100644 --- a/src/pages/user/substitute-request/components/SubstituteRejectReasonModal.tsx +++ b/src/pages/user/substitute-request/components/SubstituteRejectReasonModal.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react' +import { useScrollLock } from '@/shared/lib/useScrollLock' import ChevronLeftIcon from '@/assets/icons/nav/chevron-left.svg' @@ -33,13 +34,7 @@ export function SubstituteRejectReasonModal({ return () => window.removeEventListener('keydown', onKeyDown) }, [open, handleClose]) - useEffect(() => { - const prev = document.body.style.overflow - if (open) document.body.style.overflow = 'hidden' - return () => { - document.body.style.overflow = prev - } - }, [open]) + useScrollLock(open) if (!open) return null diff --git a/src/pages/user/substitute-request/components/SubstituteStoreSelectModal.tsx b/src/pages/user/substitute-request/components/SubstituteStoreSelectModal.tsx index 4865159d..8c8f9843 100644 --- a/src/pages/user/substitute-request/components/SubstituteStoreSelectModal.tsx +++ b/src/pages/user/substitute-request/components/SubstituteStoreSelectModal.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react' +import { useScrollLock } from '@/shared/lib/useScrollLock' import type { WorkingStoreItem } from '@/features/user/home/workspace/types/workingStore' import { Avatar } from '@/shared/ui/common/Avatar' @@ -38,13 +39,7 @@ export function SubstituteStoreSelectModal({ return () => window.removeEventListener('keydown', onKeyDown) }, [open, handleClose]) - useEffect(() => { - const prev = document.body.style.overflow - if (open) document.body.style.overflow = 'hidden' - return () => { - document.body.style.overflow = prev - } - }, [open]) + useScrollLock(open) if (!open) return null diff --git a/src/pages/user/workspace-detail/components/SubstituteRequestModalFlow.tsx b/src/pages/user/workspace-detail/components/SubstituteRequestModalFlow.tsx index fb4ba3ac..d9f4899b 100644 --- a/src/pages/user/workspace-detail/components/SubstituteRequestModalFlow.tsx +++ b/src/pages/user/workspace-detail/components/SubstituteRequestModalFlow.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react' +import { useScrollLock } from '@/shared/lib/useScrollLock' import ChevronLeftIcon from '@/assets/icons/nav/chevron-left.svg' @@ -52,13 +53,7 @@ export function SubstituteRequestModalFlow({ onClose, }) - useEffect(() => { - const prev = document.body.style.overflow - document.body.style.overflow = 'hidden' - return () => { - document.body.style.overflow = prev - } - }, []) + useScrollLock(true) useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { diff --git a/src/shared/constants/routes.ts b/src/shared/constants/routes.ts index 235a735c..79022f6b 100644 --- a/src/shared/constants/routes.ts +++ b/src/shared/constants/routes.ts @@ -33,7 +33,6 @@ export const ROUTES = { STORE_REGISTER: '/manager/store-register', SUBSTITUTE_REQUEST: '/manager/substitute-request', WORKER_INVITE: '/manager/worker-invite', - WORKER_LIST: '/manager/worker-list', SOCIAL: '/manager/social', SOCIAL_CHAT: '/manager/social/chat', }, diff --git a/src/shared/lib/calendarUtils.ts b/src/shared/lib/calendarUtils.ts index c932465f..01d1d30b 100644 --- a/src/shared/lib/calendarUtils.ts +++ b/src/shared/lib/calendarUtils.ts @@ -7,6 +7,40 @@ import { startOfWeek, } from 'date-fns' +const ISO_DATE_LENGTH = 10 +const ISO_TIME_START = 11 +const ISO_TIME_END = 16 + +export function toDateKey(iso: string | null | undefined): string { + if (iso == null || iso === '') return '' + return iso.slice(0, ISO_DATE_LENGTH) +} + +export function toTimeLabel(iso: string | null | undefined): string { + if (iso == null || iso === '' || iso.length < ISO_TIME_END) return '--:--' + return iso.slice(ISO_TIME_START, ISO_TIME_END) +} + +export function getDurationHours( + startIso: string | null | undefined, + endIso: string | null | undefined +): number { + if (startIso == null || endIso == null || startIso === '' || endIso === '') { + return 0 + } + const start = new Date(startIso).getTime() + const end = new Date(endIso).getTime() + const diffHours = Math.max((end - start) / (1000 * 60 * 60), 0) + return Number(diffHours.toFixed(1)) +} + +export function formatIsoTimeRangeLabel( + startIso: string | null | undefined, + endIso: string | null | undefined +): string { + return `${toTimeLabel(startIso)} ~ ${toTimeLabel(endIso)}` +} + export interface CalendarCell { date: Date isCurrentMonth: boolean diff --git a/src/shared/lib/useScrollLock.ts b/src/shared/lib/useScrollLock.ts new file mode 100644 index 00000000..e6ecfade --- /dev/null +++ b/src/shared/lib/useScrollLock.ts @@ -0,0 +1,21 @@ +import { useEffect } from 'react' + +let lockCount = 0 +let savedOverflow = '' + +export function useScrollLock(isLocked: boolean) { + useEffect(() => { + if (!isLocked) return + lockCount++ + if (lockCount === 1) { + savedOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + } + return () => { + lockCount-- + if (lockCount === 0) { + document.body.style.overflow = savedOverflow + } + } + }, [isLocked]) +} diff --git a/src/shared/ui/common/ConfirmModal.tsx b/src/shared/ui/common/ConfirmModal.tsx index 77ba35d6..03457940 100644 --- a/src/shared/ui/common/ConfirmModal.tsx +++ b/src/shared/ui/common/ConfirmModal.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react' +import { useScrollLock } from '@/shared/lib/useScrollLock' interface ConfirmModalProps { isOpen: boolean @@ -30,13 +31,7 @@ export function ConfirmModal({ return () => window.removeEventListener('keydown', onKeyDown) }, [isOpen, onClose]) - useEffect(() => { - const prev = document.body.style.overflow - if (isOpen) document.body.style.overflow = 'hidden' - return () => { - document.body.style.overflow = prev - } - }, [isOpen]) + useScrollLock(isOpen) if (!isOpen) return null diff --git a/src/shared/ui/common/Modal.tsx b/src/shared/ui/common/Modal.tsx new file mode 100644 index 00000000..3920c60b --- /dev/null +++ b/src/shared/ui/common/Modal.tsx @@ -0,0 +1,122 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react' +import { useScrollLock } from '@/shared/lib/useScrollLock' + +interface ModalProps { + isOpen: boolean + onClose: () => void + children: ReactNode + title?: string + ariaLabel?: string + variant?: 'center' | 'bottomSheet' + className?: string +} + +const DRAG_CLOSE_THRESHOLD = 80 + +export function Modal({ + isOpen, + onClose, + children, + title, + ariaLabel, + variant = 'bottomSheet', + className = '', +}: ModalProps) { + const [dragY, setDragY] = useState(0) + const [isDragging, setIsDragging] = useState(false) + const startYRef = useRef(0) + + useEffect(() => { + if (!isOpen) return + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [isOpen, onClose]) + + useScrollLock(isOpen) + + if (!isOpen) return null + + const handleDragStart = (e: React.PointerEvent) => { + startYRef.current = e.clientY + setIsDragging(true) + e.currentTarget.setPointerCapture(e.pointerId) + } + + const handleDragMove = (e: React.PointerEvent) => { + if (!isDragging) return + const delta = e.clientY - startYRef.current + setDragY(Math.max(0, delta)) + } + + const handleDragEnd = () => { + if (!isDragging) return + setIsDragging(false) + if (dragY > DRAG_CLOSE_THRESHOLD) { + setDragY(0) + onClose() + } else { + setDragY(0) + } + } + + const containerClassName = + variant === 'bottomSheet' + ? 'fixed inset-0 z-[90] flex items-end justify-center' + : 'fixed inset-0 z-[90] flex items-center justify-center px-4' + + const panelClassName = + variant === 'bottomSheet' + ? 'relative w-full max-w-[428px] min-h-[40dvh] max-h-[80dvh] overflow-y-auto rounded-t-2xl bg-white px-5 pt-7 pb-[calc(env(safe-area-inset-bottom)+24px)]' + : 'relative w-full max-w-[358px] max-h-[80dvh] overflow-y-auto rounded-2xl bg-white p-6' + + return ( +
+