From 3fe219ea56254c8a2aca0b8622fb76a0b574c34f Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Wed, 17 Jun 2026 01:33:48 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20=EC=82=AC=EC=9E=A5=EB=8B=98=20?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=BA=98?= =?UTF-8?q?=EB=A6=B0=EB=8D=94=20=EA=B5=90=EC=B2=B4=20=EB=B0=8F=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=EB=B3=84=20=EA=B7=BC=EB=AC=B4=EC=9E=90=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/common/schedule/lib/summaryFormat.ts | 10 ++ .../home/hooks/useManagerHomeViewModel.ts | 30 +++-- .../hooks/useMonthlySchedulesViewModel.ts | 95 ------------- .../useWorkerScheduleCalendarViewModel.ts | 74 ++++++++++ .../hooks/useWorkerListViewModel.ts | 76 +++-------- .../manager/worker-list/lib/workerSchedule.ts | 70 ++++++++++ .../worker-list/types/workerSchedule.ts | 5 + .../worker-list/ui/WorkerScheduleCalendar.tsx | 89 +++++++++++- src/pages/manager/home/index.tsx | 85 +++++++----- src/shared/ui/common/Modal.tsx | 127 ++++++++++++++++++ 10 files changed, 456 insertions(+), 205 deletions(-) create mode 100644 src/features/home/common/schedule/lib/summaryFormat.ts delete mode 100644 src/features/manager/home/hooks/useMonthlySchedulesViewModel.ts create mode 100644 src/features/manager/home/hooks/useWorkerScheduleCalendarViewModel.ts create mode 100644 src/features/manager/worker-list/lib/workerSchedule.ts create mode 100644 src/shared/ui/common/Modal.tsx 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..3fba9a20 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,18 @@ 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, + handleDateClick: onScheduleDateClick, + closeModal: closeScheduleModal, + } = useWorkerScheduleCalendarViewModel(activeWorkspaceId) const { todayWorkers } = useTodaySchedulesViewModel(activeWorkspaceId) @@ -181,12 +186,17 @@ 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, + handleDateClick: onScheduleDateClick, + closeModal: closeScheduleModal, }, 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/useWorkerScheduleCalendarViewModel.ts b/src/features/manager/home/hooks/useWorkerScheduleCalendarViewModel.ts new file mode 100644 index 00000000..a7f94fc4 --- /dev/null +++ b/src/features/manager/home/hooks/useWorkerScheduleCalendarViewModel.ts @@ -0,0 +1,74 @@ +import { useCallback, useMemo, useState } from 'react' +import { format } from 'date-fns' +import { useWorkerListSchedulesQuery } from '@/features/manager/worker-list/hooks/query/useWorkerListSchedulesQuery' +import { + buildWorkerScheduleData, + getVisibleWorkers, +} from '@/features/manager/worker-list/lib/workerSchedule' +import { + formatEstimatedEarningsText, + formatTotalWorkHoursText, +} from '@/features/home/common/schedule/lib/summaryFormat' + +const DATE_KEY_FORMAT = 'yyyy-MM-dd' + +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 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) => { + setSelectedDateKey(dateKey) + setModalDateKey(dateKey) + }, []) + + const closeModal = useCallback(() => setModalDateKey(null), []) + + return { + baseDate, + scheduleData, + totalWorkHoursText, + estimatedEarningsText, + selectedDateKey, + isLoading: isPending && workspaceId !== null, + onMonthChange, + isModalOpen: modalDateKey !== null, + modalDateKey, + visibleWorkers, + handleDateClick, + closeModal, + } +} diff --git a/src/features/manager/worker-list/hooks/useWorkerListViewModel.ts b/src/features/manager/worker-list/hooks/useWorkerListViewModel.ts index ba5827cf..4804f843 100644 --- a/src/features/manager/worker-list/hooks/useWorkerListViewModel.ts +++ b/src/features/manager/worker-list/hooks/useWorkerListViewModel.ts @@ -2,15 +2,13 @@ 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 { + buildWorkerScheduleData, + getVisibleWorkers, +} from '@/features/manager/worker-list/lib/workerSchedule' +import type { WorkerListEntry } from '@/features/manager/worker-list/lib/workerSchedule' import { useWorkerListSchedulesQuery } from './query/useWorkerListSchedulesQuery' import { useDeleteScheduleWorker, @@ -19,6 +17,8 @@ import { import { managerWorkerSchedulePath } from '@/shared/constants/routes' import type { WorkerScheduleLocationState } from '@/features/manager' +export type { WorkerListEntry } + const DELETE_WORKER_ERROR_MESSAGES: Record = { B020: '요청한 리소스를 찾을 수 없습니다.', A002: '관리중인 업장이 아닙니다.', @@ -34,23 +34,6 @@ function getDeleteWorkerErrorMessage(error: unknown): string { 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() @@ -74,44 +57,15 @@ export function useWorkerListViewModel() { 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 scheduleData = useMemo( + () => buildWorkerScheduleData(rawData), + [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 visibleWorkers = useMemo( + () => getVisibleWorkers(rawData, selectedDate), + [rawData, selectedDate] + ) const handleDateClick = useCallback((dateKey: string) => { setSelectedDate(dateKey) 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..40c81652 --- /dev/null +++ b/src/features/manager/worker-list/lib/workerSchedule.ts @@ -0,0 +1,70 @@ +import { + toDateKey, + toTimeLabel, +} from '@/features/home/common/schedule/lib/date' +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/pages/manager/home/index.tsx b/src/pages/manager/home/index.tsx index 8ce8add0..18026684 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,11 @@ 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 ResignIcon from '@/assets/icons/home/resign.svg?react' export function ManagerHomePage() { @@ -171,43 +172,25 @@ export function ManagerHomePage() {
- { - 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 +305,34 @@ export function ManagerHomePage() { onConfirm={() => setIsResignErrorOpen(false)} onClose={() => setIsResignErrorOpen(false)} /> + + + {schedule.visibleWorkers.length > 0 ? ( +
+ {schedule.visibleWorkers.map(worker => ( + + ))} +
+ ) : ( +

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

+ )} +
) } diff --git a/src/shared/ui/common/Modal.tsx b/src/shared/ui/common/Modal.tsx new file mode 100644 index 00000000..98b5ea98 --- /dev/null +++ b/src/shared/ui/common/Modal.tsx @@ -0,0 +1,127 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react' + +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]) + + useEffect(() => { + const prev = document.body.style.overflow + if (isOpen) document.body.style.overflow = 'hidden' + return () => { + document.body.style.overflow = prev + } + }, [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 ( +
+
@@ -314,6 +310,11 @@ export function ManagerHomePage() { } ariaLabel="해당 날짜 근무자 목록" > + {schedule.deleteError && ( +

+ {schedule.deleteError} +

+ )} {schedule.visibleWorkers.length > 0 ? (
{schedule.visibleWorkers.map(worker => ( @@ -324,6 +325,8 @@ export function ManagerHomePage() { nextShiftTime={worker.nextShiftTime} scheduleColor={worker.scheduleColor} role={worker.role} + onEdit={() => schedule.handleEditWorker(worker)} + onDelete={() => setDeleteTargetWorker(worker)} /> ))}
@@ -333,6 +336,20 @@ export function ManagerHomePage() {

)} + + { + if (!deleteTargetWorker) return + schedule.handleDeleteWorker(deleteTargetWorker.shiftId) + setDeleteTargetWorker(null) + }} + onClose={() => setDeleteTargetWorker(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/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', }, From 2c7032b4f3877e50428533c60d0062631507ccd6 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Wed, 17 Jun 2026 01:37:00 +0900 Subject: [PATCH 03/11] =?UTF-8?q?fix:=20=EA=B7=BC=EB=AC=B4=EC=9E=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95/=EC=82=AD=EC=A0=9C=20=EC=95=A1=EC=85=98=20?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=ED=99=88=20=EB=AA=A8=EB=8B=AC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/hooks/useManagerHomeViewModel.ts | 6 + .../useWorkerScheduleCalendarViewModel.ts | 58 ++++++++++ .../hooks/useWorkerListViewModel.ts | 107 ------------------ 3 files changed, 64 insertions(+), 107 deletions(-) delete mode 100644 src/features/manager/worker-list/hooks/useWorkerListViewModel.ts diff --git a/src/features/manager/home/hooks/useManagerHomeViewModel.ts b/src/features/manager/home/hooks/useManagerHomeViewModel.ts index 3fba9a20..c122596a 100644 --- a/src/features/manager/home/hooks/useManagerHomeViewModel.ts +++ b/src/features/manager/home/hooks/useManagerHomeViewModel.ts @@ -135,8 +135,11 @@ export function useManagerHomeViewModel() { isModalOpen: isScheduleModalOpen, modalDateKey: scheduleModalDateKey, visibleWorkers: scheduleVisibleWorkers, + deleteError: scheduleDeleteError, handleDateClick: onScheduleDateClick, closeModal: closeScheduleModal, + handleDeleteWorker: handleScheduleDeleteWorker, + handleEditWorker: handleScheduleEditWorker, } = useWorkerScheduleCalendarViewModel(activeWorkspaceId) const { todayWorkers } = useTodaySchedulesViewModel(activeWorkspaceId) @@ -195,8 +198,11 @@ export function useManagerHomeViewModel() { 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/useWorkerScheduleCalendarViewModel.ts b/src/features/manager/home/hooks/useWorkerScheduleCalendarViewModel.ts index a7f94fc4..a778ab77 100644 --- a/src/features/manager/home/hooks/useWorkerScheduleCalendarViewModel.ts +++ b/src/features/manager/home/hooks/useWorkerScheduleCalendarViewModel.ts @@ -1,23 +1,54 @@ 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 @@ -57,6 +88,30 @@ export function useWorkerScheduleCalendarViewModel(workspaceId: number | null) { const closeModal = useCallback(() => 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, @@ -68,7 +123,10 @@ export function useWorkerScheduleCalendarViewModel(workspaceId: number | null) { 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 4804f843..00000000 --- a/src/features/manager/worker-list/hooks/useWorkerListViewModel.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { useCallback, useMemo, useState } from 'react' -import { format } from 'date-fns' -import axios from 'axios' -import { useNavigate } from 'react-router-dom' -import { useWorkspaceStore } from '@/shared/stores/useWorkspaceStore' -import type { WorkerScheduleData } from '@/features/manager/worker-list/types/workerSchedule' -import { - buildWorkerScheduleData, - getVisibleWorkers, -} from '@/features/manager/worker-list/lib/workerSchedule' -import type { WorkerListEntry } from '@/features/manager/worker-list/lib/workerSchedule' -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' - -export type { WorkerListEntry } - -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 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( - () => buildWorkerScheduleData(rawData), - [rawData] - ) - - const visibleWorkers = useMemo( - () => getVisibleWorkers(rawData, selectedDate), - [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, - } -} From 0f2ef85fca8212044f670628c99759120e7b3d4c Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Wed, 17 Jun 2026 02:01:48 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20=EC=95=8C=EB=B0=94=EC=83=9D=20?= =?UTF-8?q?=ED=99=88=20=EC=BA=98=EB=A6=B0=EB=8D=94=20=EB=82=A0=EC=A7=9C?= =?UTF-8?q?=EB=B3=84=20=EA=B7=BC=EB=AC=B4=20=EC=9D=BC=EC=A0=95=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/schedule/ui/DayScheduleModal.tsx | 73 ++++++++++++++++++ .../home/schedule/ui/HomeScheduleCalendar.tsx | 74 ++++++++++++------- .../home/schedule/ui/ShiftTimelineBar.tsx | 54 ++++++++++++++ 3 files changed, 173 insertions(+), 28 deletions(-) create mode 100644 src/features/user/home/schedule/ui/DayScheduleModal.tsx create mode 100644 src/features/user/home/schedule/ui/ShiftTimelineBar.tsx 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..0d7ee3a7 --- /dev/null +++ b/src/features/user/home/schedule/ui/DayScheduleModal.tsx @@ -0,0 +1,73 @@ +import { format } 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(new Date(dateKey), '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} + ))} +
+
+ ) +} From 1157c831f4b4cfbbb6accab7cef703b59b0d6e23 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Mon, 22 Jun 2026 15:44:31 +0900 Subject: [PATCH 05/11] =?UTF-8?q?fix:=20=EC=82=AD=EC=A0=9C=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=82=A0=EC=A7=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98/=EB=AA=A8=EB=8B=AC=20=EC=9E=AC=EC=98=A4?= =?UTF-8?q?=ED=94=88=20=ED=9B=84=EC=97=90=EB=8F=84=20=EC=9E=94=EB=A5=98=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/hooks/useWorkerScheduleCalendarViewModel.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/features/manager/home/hooks/useWorkerScheduleCalendarViewModel.ts b/src/features/manager/home/hooks/useWorkerScheduleCalendarViewModel.ts index a778ab77..9949e19c 100644 --- a/src/features/manager/home/hooks/useWorkerScheduleCalendarViewModel.ts +++ b/src/features/manager/home/hooks/useWorkerScheduleCalendarViewModel.ts @@ -82,11 +82,15 @@ export function useWorkerScheduleCalendarViewModel(workspaceId: number | null) { const onMonthChange = useCallback((date: Date) => setBaseDate(date), []) const handleDateClick = useCallback((dateKey: string) => { + setDeleteError(null) setSelectedDateKey(dateKey) setModalDateKey(dateKey) }, []) - const closeModal = useCallback(() => setModalDateKey(null), []) + const closeModal = useCallback(() => { + setDeleteError(null) + setModalDateKey(null) + }, []) const handleDeleteWorker = useCallback( async (shiftId: number) => { From c520c834877715813391c270e7ec2b0295a4679e Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Mon, 22 Jun 2026 15:48:02 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat:=20=EC=BA=98=EB=A6=B0=EB=8D=94?= =?UTF-8?q?=EC=97=90=20=EC=82=AC=EC=9A=A9=20=ED=95=A0=20=EB=82=A0=EC=A7=9C?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=EC=9C=A0=ED=8B=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/lib/calendarUtils.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/shared/lib/calendarUtils.ts b/src/shared/lib/calendarUtils.ts index c932465f..888f7972 100644 --- a/src/shared/lib/calendarUtils.ts +++ b/src/shared/lib/calendarUtils.ts @@ -7,6 +7,20 @@ 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 interface CalendarCell { date: Date isCurrentMonth: boolean From 6537dc1406f6f9a9c0a8ac944a9bcd0b6ebf9ede Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Mon, 22 Jun 2026 15:51:52 +0900 Subject: [PATCH 07/11] =?UTF-8?q?delete:=20=EB=82=A0=EC=A7=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=9C=A0=ED=8B=B8=20features=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/home/common/schedule/lib/date.ts | 47 ------------------- 1 file changed, 47 deletions(-) delete mode 100644 src/features/home/common/schedule/lib/date.ts 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)}` -} From 962504d2730d7bd372b2aafcc05333c0d7633ade Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Mon, 22 Jun 2026 15:52:09 +0900 Subject: [PATCH 08/11] =?UTF-8?q?refactor:=20=EB=82=A0=EC=A7=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=9C=A0=ED=8B=B8=20import=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/hooks/useTodaySchedulesViewModel.ts | 2 +- .../manager/worker-list/lib/workerSchedule.ts | 5 +---- .../hooks/useWorkerScheduleManageViewModel.ts | 2 +- .../user/home/schedule/api/schedule.ts | 2 +- .../user/home/schedule/lib/date.test.ts | 2 +- src/features/user/home/schedule/lib/date.ts | 2 +- .../home/workspace/api/workspaceSchedule.ts | 2 +- .../lib/adaptExchangeableSchedules.ts | 2 +- src/shared/lib/calendarUtils.ts | 20 +++++++++++++++++++ 9 files changed, 28 insertions(+), 11 deletions(-) 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/worker-list/lib/workerSchedule.ts b/src/features/manager/worker-list/lib/workerSchedule.ts index 40c81652..84aeaede 100644 --- a/src/features/manager/worker-list/lib/workerSchedule.ts +++ b/src/features/manager/worker-list/lib/workerSchedule.ts @@ -1,7 +1,4 @@ -import { - toDateKey, - toTimeLabel, -} from '@/features/home/common/schedule/lib/date' +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' 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/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/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/shared/lib/calendarUtils.ts b/src/shared/lib/calendarUtils.ts index 888f7972..01d1d30b 100644 --- a/src/shared/lib/calendarUtils.ts +++ b/src/shared/lib/calendarUtils.ts @@ -21,6 +21,26 @@ export function toTimeLabel(iso: string | null | undefined): string { 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 From 47dd91722bc1d72d190b456f37ad2d3dff742b49 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Mon, 22 Jun 2026 15:56:08 +0900 Subject: [PATCH 09/11] =?UTF-8?q?fix:=20=EB=82=A0=EC=A7=9C=20=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5=EC=8B=9C=20=EA=B8=B0=EC=A4=80=EC=9D=84=20UTC=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=A1=9C=EC=BB=AC=20=ED=83=80=EC=9E=84=EC=9D=84=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/user/home/schedule/ui/DayScheduleModal.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/features/user/home/schedule/ui/DayScheduleModal.tsx b/src/features/user/home/schedule/ui/DayScheduleModal.tsx index 0d7ee3a7..0986e4af 100644 --- a/src/features/user/home/schedule/ui/DayScheduleModal.tsx +++ b/src/features/user/home/schedule/ui/DayScheduleModal.tsx @@ -1,4 +1,4 @@ -import { format } from 'date-fns' +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' @@ -20,7 +20,9 @@ export function DayScheduleModal({ onClose, }: DayScheduleModalProps) { const title = dateKey - ? format(new Date(dateKey), 'M월 d일 (EEE)', { locale: ko }) + ? format(parse(dateKey, 'yyyy-MM-dd', new Date()), 'M월 d일 (EEE)', { + locale: ko, + }) : '' return ( From 2b91d3ce5f44716ef9a96a652e3dc50ae21f1551 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Mon, 22 Jun 2026 16:02:38 +0900 Subject: [PATCH 10/11] =?UTF-8?q?feat:=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=88=20=EC=9C=A0=ED=8B=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/lib/useScrollLock.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/shared/lib/useScrollLock.ts 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]) +} From 051423de7c2be73e6a0354cb84bfe1dea262e3d1 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Mon, 22 Jun 2026 16:04:11 +0900 Subject: [PATCH 11/11] =?UTF-8?q?refactor:=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20useScrollLock=EC=9D=84=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/applied-stores/ui/AppliedStoreDetailModal.tsx | 10 ++-------- .../components/ManagerSubstituteActionModal.tsx | 9 ++------- src/pages/my/profile/index.tsx | 9 ++------- .../components/SubstituteRejectReasonModal.tsx | 9 ++------- .../components/SubstituteStoreSelectModal.tsx | 9 ++------- .../components/SubstituteRequestModalFlow.tsx | 9 ++------- src/shared/ui/common/ConfirmModal.tsx | 9 ++------- src/shared/ui/common/Modal.tsx | 9 ++------- 8 files changed, 16 insertions(+), 57 deletions(-) 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/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/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/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 index 98b5ea98..3920c60b 100644 --- a/src/shared/ui/common/Modal.tsx +++ b/src/shared/ui/common/Modal.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, type ReactNode } from 'react' +import { useScrollLock } from '@/shared/lib/useScrollLock' interface ModalProps { isOpen: boolean @@ -34,13 +35,7 @@ export function Modal({ 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