diff --git a/src/app/App.tsx b/src/app/App.tsx index dc6133f..dc7e8b2 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -8,6 +8,7 @@ import { } from 'react-router-dom' import { ManagerHomePage } from '@/pages/manager/home' import { ManagerWorkerSchedulePage } from '@/pages/manager/worker-schedule' +import { ManagerWorkerScheduleLegacyEntryRedirect } from '@/pages/manager/worker-schedule/LegacyEntryRedirect' import { SocialPage } from '@/pages/manager/social' import { SocialChatPage } from '@/pages/manager/social-chat' import { LoginPage } from '@/pages/login' @@ -94,6 +95,10 @@ export function App() { } /> } + /> + } /> + + diff --git a/src/features/home/common/schedule/lib/date.ts b/src/features/home/common/schedule/lib/date.ts index 464737e..007853a 100644 --- a/src/features/home/common/schedule/lib/date.ts +++ b/src/features/home/common/schedule/lib/date.ts @@ -16,3 +16,30 @@ export function getDurationHours(startIso: string, endIso: string) { const diffHours = Math.max((end - start) / (1000 * 60 * 60), 0) return Number(diffHours.toFixed(1)) } + +/** "9:0", "09:00:00" 등 → 시·분 두 자리 */ +export function splitClockToParts(clock: string): { + hour: string + minute: string +} { + const [hRaw = '0', mRaw = '0'] = clock.trim().split(':') + const hourNum = Number.parseInt(hRaw, 10) + const minuteNum = Number.parseInt(mRaw, 10) + const hour = Number.isFinite(hourNum) + ? String(hourNum).padStart(2, '0') + : '00' + const minute = Number.isFinite(minuteNum) + ? String(minuteNum).padStart(2, '0') + : '00' + return { hour, minute } +} + +export function formatClockRangeLabel(startClock: string, endClock: string) { + const s = splitClockToParts(startClock) + const e = splitClockToParts(endClock) + return `${s.hour}:${s.minute} ~ ${e.hour}:${e.minute}` +} + +export function formatIsoTimeRangeLabel(startIso: string, endIso: string) { + return `${toTimeLabel(startIso)} ~ ${toTimeLabel(endIso)}` +} diff --git a/src/features/home/common/schedule/ui/MonthYearPickerModal.tsx b/src/features/home/common/schedule/ui/MonthYearPickerModal.tsx index ba98b2e..65cd5dd 100644 --- a/src/features/home/common/schedule/ui/MonthYearPickerModal.tsx +++ b/src/features/home/common/schedule/ui/MonthYearPickerModal.tsx @@ -122,7 +122,7 @@ export function MonthYearPickerModal({ if (!isOpen) return null return ( -
+
+ ) + } + + return
{body}
} diff --git a/src/features/manager/home/api/workerFixedSchedule.ts b/src/features/manager/home/api/workerFixedSchedule.ts new file mode 100644 index 0000000..3fbedc2 --- /dev/null +++ b/src/features/manager/home/api/workerFixedSchedule.ts @@ -0,0 +1,16 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { WorkerFixedScheduleApiResponse } from '@/features/manager/home/types/workerFixedSchedule' + +/** + * 매장 소속 근무자의 고정(반복) 근무 스케줄 조회. + * 백엔드 경로가 다르면 이 파일만 수정하면 된다. + */ +export async function fetchWorkerFixedSchedules( + workspaceId: number, + workerId: number +): Promise { + const response = await axiosInstance.get( + `/manager/workspaces/${workspaceId}/workers/${workerId}/fixed-schedules` + ) + return response.data +} diff --git a/src/features/manager/home/constants/managerWeekdayKo.ts b/src/features/manager/home/constants/managerWeekdayKo.ts new file mode 100644 index 0000000..2fafab8 --- /dev/null +++ b/src/features/manager/home/constants/managerWeekdayKo.ts @@ -0,0 +1,12 @@ +/** 고정 스케줄 UI·API 매핑에서 공통으로 쓰는 한글 요일 순서 */ +export const MANAGER_WEEKDAY_KO_ORDER = [ + '월', + '화', + '수', + '목', + '금', + '토', + '일', +] as const + +export type ManagerWeekdayKo = (typeof MANAGER_WEEKDAY_KO_ORDER)[number] diff --git a/src/features/manager/home/hooks/useManagerHomeViewModel.ts b/src/features/manager/home/hooks/useManagerHomeViewModel.ts index 2c351e3..98e640c 100644 --- a/src/features/manager/home/hooks/useManagerHomeViewModel.ts +++ b/src/features/manager/home/hooks/useManagerHomeViewModel.ts @@ -18,6 +18,7 @@ export function useManagerHomeViewModel() { const { workers: storeWorkers, + totalCount: storeWorkersTotalCount, fetchNextPage: fetchMoreWorkers, hasNextPage: hasMoreWorkers, isFetchingNextPage: isFetchingMoreWorkers, @@ -77,8 +78,10 @@ export function useManagerHomeViewModel() { }, [isWorkspaceChangeModalOpen]) return { + activeWorkspaceId, todayWorkers, storeWorkers, + storeWorkersTotalCount, fetchMoreWorkers, hasMoreWorkers, isFetchingMoreWorkers, diff --git a/src/features/manager/home/hooks/useTodaySchedulesViewModel.ts b/src/features/manager/home/hooks/useTodaySchedulesViewModel.ts index 72098ee..0c71f27 100644 --- a/src/features/manager/home/hooks/useTodaySchedulesViewModel.ts +++ b/src/features/manager/home/hooks/useTodaySchedulesViewModel.ts @@ -2,13 +2,9 @@ import { useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import { fetchTodaySchedules } from '@/features/manager/home/api/schedule' import { queryKeys } from '@/shared/lib/queryKeys' -import { toTimeLabel } from '@/features/home/common/schedule/lib/date' +import { formatIsoTimeRangeLabel } from '@/features/home/common/schedule/lib/date' import type { TodayWorkerItem } from '@/features/manager/home/ui/TodayWorkerList' -function formatWorkTime(startDateTime: string, endDateTime: string): string { - return `${toTimeLabel(startDateTime)} ~ ${toTimeLabel(endDateTime)}` -} - export function useTodaySchedulesViewModel(workspaceId: number | null) { const { data, isPending } = useQuery({ queryKey: queryKeys.manager.todaySchedules(workspaceId ?? 0), @@ -23,7 +19,7 @@ export function useTodaySchedulesViewModel(workspaceId: number | null) { name: worker.workerName, profileImageUrl: worker.profileImageUrl, workTime: worker.shifts[0] - ? formatWorkTime( + ? formatIsoTimeRangeLabel( worker.shifts[0].startDateTime, worker.shifts[0].endDateTime ) diff --git a/src/features/manager/home/hooks/useWorkerScheduleManageViewModel.ts b/src/features/manager/home/hooks/useWorkerScheduleManageViewModel.ts index 5aa2de7..063b509 100644 --- a/src/features/manager/home/hooks/useWorkerScheduleManageViewModel.ts +++ b/src/features/manager/home/hooks/useWorkerScheduleManageViewModel.ts @@ -1,18 +1,92 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' +import { splitClockToParts } from '@/features/home/common/schedule/lib/date' +import { fetchWorkerFixedSchedules } from '@/features/manager/home/api/workerFixedSchedule' +import { MANAGER_WEEKDAY_KO_ORDER } from '@/features/manager/home/constants/managerWeekdayKo' +import type { ManagerWeekdayKo } from '@/features/manager/home/constants/managerWeekdayKo' +import { mapFixedScheduleSlotsToByWeekdayKo } from '@/features/manager/home/lib/mapWorkerFixedScheduleSlots' +import { useWorkspaceWorkersViewModel } from '@/features/manager/home/hooks/useWorkspaceWorkersViewModel' +import { ROUTES, managerWorkerSchedulePath } from '@/shared/constants/routes' +import { queryKeys } from '@/shared/lib/queryKeys' -const WORKDAY_OPTIONS = ['월', '화', '수', '목', '금', '토', '일'] as const +const DEFAULT_SELECTED_DAYS: ManagerWeekdayKo[] = ['수', '금'] -const DEFAULT_SELECTED_DAYS = ['수', '금'] +const ZERO_DISPLAY = { + startHour: '00', + startMinute: '00', + endHour: '00', + endMinute: '00', +} + +function primaryWeekdayAmongSelected( + selectedDays: string[], + order: readonly ManagerWeekdayKo[] +): ManagerWeekdayKo | null { + for (const d of order) { + if (selectedDays.includes(d)) return d + } + return null +} + +function displayFromSchedule(slot: { startTime: string; endTime: string }) { + const sh = splitClockToParts(slot.startTime) + const eh = splitClockToParts(slot.endTime) + return { + startHour: sh.hour, + startMinute: sh.minute, + endHour: eh.hour, + endMinute: eh.minute, + } +} + +export function useWorkerScheduleManageViewModel(args: { + workspaceId: number + workerId: number +}) { + const navigate = useNavigate() + const { workspaceId, workerId } = args + + const { workers, isLoading: workersLoading } = + useWorkspaceWorkersViewModel(workspaceId) + + const selectedWorkerIndex = useMemo(() => { + const idx = workers.findIndex(w => w.id === workerId) + return idx >= 0 ? idx : 0 + }, [workers, workerId]) -const MOCK_WORKERS = [ - { name: '이름임', role: 'manager' as const }, - { name: '김민준', role: 'staff' as const }, - { name: '박지은', role: 'staff' as const }, - { name: '이수호', role: 'staff' as const }, - { name: '정하나', role: 'manager' as const }, -] + const worker = workers[selectedWorkerIndex] + + const { + data: fixedScheduleApi, + isPending: fixedScheduleLoading, + isError: fixedScheduleError, + } = useQuery({ + queryKey: queryKeys.managerWorkspace.workerFixedSchedule( + workspaceId, + workerId + ), + queryFn: () => fetchWorkerFixedSchedules(workspaceId, workerId), + enabled: workspaceId > 0 && workerId > 0, + }) + + const scheduleByWeekday = useMemo( + () => mapFixedScheduleSlotsToByWeekdayKo(fixedScheduleApi?.data ?? []), + [fixedScheduleApi] + ) + + useEffect(() => { + if (workersLoading) return + if (workers.length === 0) { + navigate(ROUTES.MANAGER.HOME, { replace: true }) + return + } + if (workers.some(w => w.id === workerId)) return + navigate(managerWorkerSchedulePath(workspaceId, workers[0].id), { + replace: true, + }) + }, [navigate, workerId, workers, workersLoading, workspaceId]) -export function useWorkerScheduleManageViewModel() { const [selectedDays, setSelectedDays] = useState( DEFAULT_SELECTED_DAYS ) @@ -20,7 +94,30 @@ export function useWorkerScheduleManageViewModel() { const [startMinute, setStartMinute] = useState('') const [endHour, setEndHour] = useState('') const [endMinute, setEndMinute] = useState('') - const [selectedWorkerIndex, setSelectedWorkerIndex] = useState(0) + + const templateTimes = useMemo(() => { + if (selectedDays.length === 0) return ZERO_DISPLAY + const primary = primaryWeekdayAmongSelected( + selectedDays, + MANAGER_WEEKDAY_KO_ORDER + ) + if (!primary) return ZERO_DISPLAY + const slot = scheduleByWeekday[primary] + if (!slot) return ZERO_DISPLAY + return displayFromSchedule(slot) + }, [scheduleByWeekday, selectedDays]) + + const templateKey = `${templateTimes.startHour}:${templateTimes.startMinute}:${templateTimes.endHour}:${templateTimes.endMinute}` + const [syncedTemplateKey, setSyncedTemplateKey] = useState( + null + ) + if (syncedTemplateKey !== templateKey) { + setSyncedTemplateKey(templateKey) + setStartHour(templateTimes.startHour) + setStartMinute(templateTimes.startMinute) + setEndHour(templateTimes.endHour) + setEndMinute(templateTimes.endMinute) + } const workTimeRangeLabel = useMemo(() => { const sh = startHour || '00' @@ -36,12 +133,18 @@ export function useWorkerScheduleManageViewModel() { ) } + function goToWorker(nextWorkerId: number) { + navigate(managerWorkerSchedulePath(workspaceId, nextWorkerId)) + } + return { - worker: MOCK_WORKERS[selectedWorkerIndex], - workers: MOCK_WORKERS, - selectedWorkerIndex, - setSelectedWorkerIndex, - workdayOptions: WORKDAY_OPTIONS, + worker, + workers, + workersLoading, + fixedScheduleLoading, + fixedScheduleError, + goToWorker, + workdayOptions: MANAGER_WEEKDAY_KO_ORDER, selectedDays, workTimeRangeLabel, startHour, diff --git a/src/features/manager/home/hooks/useWorkspaceWorkersViewModel.ts b/src/features/manager/home/hooks/useWorkspaceWorkersViewModel.ts index 78b6ce3..aa36120 100644 --- a/src/features/manager/home/hooks/useWorkspaceWorkersViewModel.ts +++ b/src/features/manager/home/hooks/useWorkspaceWorkersViewModel.ts @@ -41,8 +41,11 @@ export function useWorkspaceWorkersViewModel( [data] ) + const totalCount = data?.pages[0]?.data.page.totalCount ?? 0 + return { workers, + totalCount, fetchNextPage, hasNextPage: !!hasNextPage, isFetchingNextPage, diff --git a/src/features/manager/home/lib/mapWorkerFixedScheduleSlots.ts b/src/features/manager/home/lib/mapWorkerFixedScheduleSlots.ts new file mode 100644 index 0000000..8ad3a27 --- /dev/null +++ b/src/features/manager/home/lib/mapWorkerFixedScheduleSlots.ts @@ -0,0 +1,47 @@ +import { MANAGER_WEEKDAY_KO_ORDER } from '@/features/manager/home/constants/managerWeekdayKo' +import type { ManagerWeekdayKo } from '@/features/manager/home/constants/managerWeekdayKo' +import type { WorkerFixedScheduleSlotDto } from '@/features/manager/home/types/workerFixedSchedule' + +const API_WORKING_DAY_TO_KO: Record = { + MONDAY: '월', + TUESDAY: '화', + WEDNESDAY: '수', + THURSDAY: '목', + FRIDAY: '금', + SATURDAY: '토', + SUNDAY: '일', +} + +export type WeekdayTimeSlot = { startTime: string; endTime: string } + +/** + * 고정 근무 슬롯을 요일(한글) → 대표 시간대로 묶는다. + * 같은 요일에 여러 슬롯이 있으면 시작 시각이 가장 이른 것을 대표로 쓴다 (UI는 단일 구간만 지원). + */ +export function mapFixedScheduleSlotsToByWeekdayKo( + slots: WorkerFixedScheduleSlotDto[] +): Partial> { + const buckets = new Map() + + for (const slot of slots) { + const ko = API_WORKING_DAY_TO_KO[slot.workingDay] + if (!ko) continue + const list = buckets.get(ko) ?? [] + list.push(slot) + buckets.set(ko, list) + } + + const result: Partial> = {} + + for (const ko of MANAGER_WEEKDAY_KO_ORDER) { + const list = buckets.get(ko) + if (!list?.length) continue + const sorted = [...list].sort((a, b) => + a.startTime.localeCompare(b.startTime) + ) + const first = sorted[0] + result[ko] = { startTime: first.startTime, endTime: first.endTime } + } + + return result +} diff --git a/src/features/manager/home/types/workerFixedSchedule.ts b/src/features/manager/home/types/workerFixedSchedule.ts new file mode 100644 index 0000000..63770bf --- /dev/null +++ b/src/features/manager/home/types/workerFixedSchedule.ts @@ -0,0 +1,22 @@ +import type { CommonApiResponse } from '@/shared/types/common' + +/** 서버 고정 근무 요일 (공고 스케줄 등과 동일 키) */ +export type ManagerFixedScheduleWorkingDay = + | 'MONDAY' + | 'TUESDAY' + | 'WEDNESDAY' + | 'THURSDAY' + | 'FRIDAY' + | 'SATURDAY' + | 'SUNDAY' + +export interface WorkerFixedScheduleSlotDto { + workingDay: ManagerFixedScheduleWorkingDay + /** "HH:mm" 또는 "HH:mm:ss" */ + startTime: string + endTime: string +} + +export type WorkerFixedScheduleApiResponse = CommonApiResponse< + WorkerFixedScheduleSlotDto[] +> diff --git a/src/features/user/home/applied-stores/hooks/useAppliedStoresViewModel.ts b/src/features/user/home/applied-stores/hooks/useAppliedStoresViewModel.ts index a342c2c..0825255 100644 --- a/src/features/user/home/applied-stores/hooks/useAppliedStoresViewModel.ts +++ b/src/features/user/home/applied-stores/hooks/useAppliedStoresViewModel.ts @@ -74,6 +74,8 @@ export function useAppliedStoresViewModel() { [data] ) + const totalCount = data?.pages[0]?.page.totalCount ?? 0 + const grouped = useMemo( () => STATUS_SECTIONS.map(section => ({ @@ -116,6 +118,7 @@ export function useAppliedStoresViewModel() { toggleDropdown, selectFilter, getCardStatus, + totalCount, fetchNextPage, hasNextPage: !!hasNextPage, isFetchingNextPage, diff --git a/src/features/user/home/schedule/hooks/useDailyCalendarViewModel.ts b/src/features/user/home/schedule/hooks/useDailyCalendarViewModel.ts index dedf242..a25deab 100644 --- a/src/features/user/home/schedule/hooks/useDailyCalendarViewModel.ts +++ b/src/features/user/home/schedule/hooks/useDailyCalendarViewModel.ts @@ -88,6 +88,12 @@ export function useDailyCalendarViewModel({ totalMinutes, }) + const estimatedLaborCost = data?.summary.estimatedLaborCost + const estimatedEarningsText = + estimatedLaborCost != null + ? `약 ${estimatedLaborCost.toLocaleString()}원` + : undefined + return { title: workspaceName ?? '오늘의 아르바이트', dateLabel: format(baseDate, 'yyyy년 M월 d일'), @@ -95,6 +101,7 @@ export function useDailyCalendarViewModel({ 2, '0' ), + ...(estimatedEarningsText != null ? { estimatedEarningsText } : {}), timelineHeight, timelineLines, eventBlocks, diff --git a/src/features/user/home/schedule/hooks/useWeeklyCalendarViewModel.ts b/src/features/user/home/schedule/hooks/useWeeklyCalendarViewModel.ts index 4fc0f37..fb596f1 100644 --- a/src/features/user/home/schedule/hooks/useWeeklyCalendarViewModel.ts +++ b/src/features/user/home/schedule/hooks/useWeeklyCalendarViewModel.ts @@ -55,9 +55,16 @@ export function useWeeklyCalendarViewModel({ } }) + const estimatedLaborCost = data?.summary.estimatedLaborCost + const estimatedEarningsText = + estimatedLaborCost != null + ? `약 ${estimatedLaborCost.toLocaleString()}원` + : undefined + return { title: workspaceName ?? `${format(baseDate, 'M월')} 주간 아르바이트`, totalWorkHoursText: String(Math.round(data?.summary.totalWorkHours ?? 0)), + ...(estimatedEarningsText != null ? { estimatedEarningsText } : {}), dayCells, } }, [baseDate, data, workspaceName]) diff --git a/src/features/user/home/schedule/types/dailyCalendar.ts b/src/features/user/home/schedule/types/dailyCalendar.ts index 608aada..f317410 100644 --- a/src/features/user/home/schedule/types/dailyCalendar.ts +++ b/src/features/user/home/schedule/types/dailyCalendar.ts @@ -20,6 +20,7 @@ export interface DailyCalendarViewModel { title: string dateLabel: string totalWorkHoursText: string + estimatedEarningsText?: string timelineHeight: number timelineLines: DailyTimelineLineViewModel[] eventBlocks: DailyEventBlockViewModel[] diff --git a/src/features/user/home/schedule/types/weeklyCalendar.ts b/src/features/user/home/schedule/types/weeklyCalendar.ts index f40a3be..70cfa4e 100644 --- a/src/features/user/home/schedule/types/weeklyCalendar.ts +++ b/src/features/user/home/schedule/types/weeklyCalendar.ts @@ -16,5 +16,6 @@ export interface WeeklyDayCellViewModel { export interface WeeklyCalendarViewModel { title: string totalWorkHoursText: string + estimatedEarningsText?: string dayCells: WeeklyDayCellViewModel[] } diff --git a/src/features/user/home/schedule/ui/DailyCalendar.tsx b/src/features/user/home/schedule/ui/DailyCalendar.tsx index 098a8b3..32df0cd 100644 --- a/src/features/user/home/schedule/ui/DailyCalendar.tsx +++ b/src/features/user/home/schedule/ui/DailyCalendar.tsx @@ -16,6 +16,7 @@ export function DailyCalendar({ title, dateLabel, totalWorkHoursText, + estimatedEarningsText, timelineHeight, timelineLines, eventBlocks, @@ -41,13 +42,20 @@ export function DailyCalendar({

{dateLabel}

-
- - {totalWorkHoursText} - - - 시간 근무해요 - +
+
+ + {totalWorkHoursText} + + + 시간 근무해요 + +
+ {estimatedEarningsText ? ( + + {estimatedEarningsText} + + ) : null}
diff --git a/src/features/user/home/schedule/ui/HomeScheduleCalendar.tsx b/src/features/user/home/schedule/ui/HomeScheduleCalendar.tsx index 987bad1..779efbe 100644 --- a/src/features/user/home/schedule/ui/HomeScheduleCalendar.tsx +++ b/src/features/user/home/schedule/ui/HomeScheduleCalendar.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react' import type { CalendarViewData, HomeCalendarMode, @@ -11,6 +12,8 @@ interface HomeScheduleCalendarProps { baseDate: Date data: CalendarViewData | null workspaceName?: string + /** 월간 뷰에서 제목(업장명) 줄 우측 */ + calendarTitleRightAction?: ReactNode isLoading?: boolean onDateChange: (nextDate: Date) => void } @@ -20,6 +23,7 @@ export function HomeScheduleCalendar({ baseDate, data, workspaceName, + calendarTitleRightAction, isLoading = false, onDateChange, }: HomeScheduleCalendarProps) { @@ -30,6 +34,7 @@ export function HomeScheduleCalendar({ baseDate={baseDate} data={data} workspaceName={workspaceName} + titleRightAction={calendarTitleRightAction} isLoading={isLoading} onMonthChange={onDateChange} /> diff --git a/src/features/user/home/schedule/ui/WeeklyCalendar.tsx b/src/features/user/home/schedule/ui/WeeklyCalendar.tsx index 8b84bca..94358cb 100644 --- a/src/features/user/home/schedule/ui/WeeklyCalendar.tsx +++ b/src/features/user/home/schedule/ui/WeeklyCalendar.tsx @@ -11,11 +11,12 @@ export function WeeklyCalendar({ workspaceName, isLoading = false, }: WeeklyCalendarProps) { - const { title, totalWorkHoursText, dayCells } = useWeeklyCalendarViewModel({ - baseDate, - data, - workspaceName, - }) + const { title, totalWorkHoursText, estimatedEarningsText, dayCells } = + useWeeklyCalendarViewModel({ + baseDate, + data, + workspaceName, + }) if (isLoading) { return ( @@ -29,14 +30,21 @@ export function WeeklyCalendar({

{title}

-
- - - {totalWorkHoursText} - - - 시간 근무해요 - +
+
+ + + {totalWorkHoursText} + + + 시간 근무해요 + +
+ {estimatedEarningsText ? ( + + {estimatedEarningsText} + + ) : null}
diff --git a/src/features/user/home/workspace/api/exchangeableWorkers.ts b/src/features/user/home/workspace/api/exchangeableWorkers.ts new file mode 100644 index 0000000..3f6a0f5 --- /dev/null +++ b/src/features/user/home/workspace/api/exchangeableWorkers.ts @@ -0,0 +1,27 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { ExchangeableWorkersApiResponse } from '@/features/user/home/workspace/types/exchangeableWorkers' + +export interface GetExchangeableWorkersParams { + scheduleId: number + pageSize: number + cursor?: string +} + +/** GET /app/schedules/{scheduleId}/exchangeable-workers */ +export async function getExchangeableWorkers( + params: GetExchangeableWorkersParams +): Promise { + const response = await axiosInstance.get( + `/app/schedules/${params.scheduleId}/exchangeable-workers`, + { + params: { + pageSize: params.pageSize, + ...(params.cursor != null && + params.cursor !== '' && { + cursor: params.cursor, + }), + }, + } + ) + return response.data +} diff --git a/src/features/user/home/workspace/api/substituteRequests.ts b/src/features/user/home/workspace/api/substituteRequests.ts new file mode 100644 index 0000000..88f0374 --- /dev/null +++ b/src/features/user/home/workspace/api/substituteRequests.ts @@ -0,0 +1,21 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { + CreateSubstituteRequestApiResponse, + CreateSubstituteRequestBody, +} from '@/features/user/home/workspace/types/substituteRequests' + +export interface CreateSubstituteRequestParams { + scheduleId: number + body: CreateSubstituteRequestBody +} + +/** POST /app/schedules/{scheduleId}/substitute-requests */ +export async function createSubstituteRequest({ + scheduleId, + body, +}: CreateSubstituteRequestParams): Promise { + await axiosInstance.post( + `/app/schedules/${scheduleId}/substitute-requests`, + body + ) +} diff --git a/src/features/user/home/workspace/api/workspaceSchedule.ts b/src/features/user/home/workspace/api/workspaceSchedule.ts index 9c973f7..aa91bb5 100644 --- a/src/features/user/home/workspace/api/workspaceSchedule.ts +++ b/src/features/user/home/workspace/api/workspaceSchedule.ts @@ -11,23 +11,58 @@ import { import { formatScheduleTimeRange } from '@/features/user/home/schedule/lib/date' import type { WorkspaceScheduleApiResponse, + WorkspaceScheduleDataPayload, WorkspaceScheduleQueryParams, + WorkspaceShiftDto, WorkspaceShiftItem, WorkspaceWorkerItem, } from '@/features/user/home/workspace/types/workspaceSchedule' +function normalizeWorkspaceShifts( + payload: WorkspaceScheduleDataPayload | null | undefined +): WorkspaceShiftDto[] { + if (payload == null) return [] + if (Array.isArray(payload)) return payload + if (typeof payload === 'object') { + const rec = payload as { + schedules?: WorkspaceShiftDto[] + shifts?: WorkspaceShiftDto[] + } + if (Array.isArray(rec.schedules)) return rec.schedules + if (Array.isArray(rec.shifts)) return rec.shifts + } + return [] +} + +function extractWorkspaceScheduleTotals(payload: WorkspaceScheduleDataPayload) { + if (payload == null || Array.isArray(payload)) return {} + if (typeof payload !== 'object') return {} + const totalWorkHours = + typeof payload.totalWorkHours === 'number' + ? payload.totalWorkHours + : undefined + const estimatedSalary = + typeof payload.estimatedSalary === 'number' + ? payload.estimatedSalary + : undefined + return { totalWorkHours, estimatedSalary } +} + async function fetchWorkspaceScheduleByMonth( workspaceId: number, year?: number, month?: number, day?: number ): Promise { + if (year === undefined || month === undefined) { + throw new Error('업장 스케줄 조회에는 year와 month가 필요합니다.') + } const response = await axiosInstance.get( `/app/schedules/workspaces/${workspaceId}`, { params: { - ...(year !== undefined && { year }), - ...(month !== undefined && { month }), + year, + month, ...(day !== undefined && { day }), }, } @@ -57,7 +92,38 @@ export async function getWorkspaceSchedule( ), fetchWorkspaceScheduleByMonth(workspaceId, params.toYear, params.toMonth), ]) - return { ...fromData, data: [...fromData.data, ...toData.data] } + const shiftsFrom = normalizeWorkspaceShifts(fromData.data) + const shiftsTo = normalizeWorkspaceShifts(toData.data) + const fromTotals = extractWorkspaceScheduleTotals(fromData.data) + const toTotals = extractWorkspaceScheduleTotals(toData.data) + + const hasHours = + fromTotals.totalWorkHours !== undefined || + toTotals.totalWorkHours !== undefined + const hasSalary = + fromTotals.estimatedSalary !== undefined || + toTotals.estimatedSalary !== undefined + + return { + ...fromData, + data: { + ...(hasHours + ? { + totalWorkHours: + (fromTotals.totalWorkHours ?? 0) + + (toTotals.totalWorkHours ?? 0), + } + : {}), + ...(hasSalary + ? { + estimatedSalary: + (fromTotals.estimatedSalary ?? 0) + + (toTotals.estimatedSalary ?? 0), + } + : {}), + schedules: [...shiftsFrom, ...shiftsTo], + }, + } } return fetchWorkspaceScheduleByMonth( workspaceId, @@ -70,7 +136,8 @@ export async function getWorkspaceSchedule( export function adaptWorkspaceScheduleToCalendar( response: WorkspaceScheduleApiResponse ): CalendarViewData { - const shifts = response.data + const shifts = normalizeWorkspaceShifts(response.data) + const apiTotals = extractWorkspaceScheduleTotals(response.data) const events: CalendarEvent[] = shifts.map(shift => ({ shiftId: shift.shiftId, @@ -85,10 +152,16 @@ export function adaptWorkspaceScheduleToCalendar( durationHours: getDurationHours(shift.startDateTime, shift.endDateTime), })) - const totalWorkHours = events.reduce((acc, e) => acc + e.durationHours, 0) + const computedHours = events.reduce((acc, e) => acc + e.durationHours, 0) return { - summary: { totalWorkHours, eventCount: events.length }, + summary: { + totalWorkHours: apiTotals.totalWorkHours ?? computedHours, + eventCount: events.length, + ...(apiTotals.estimatedSalary !== undefined + ? { estimatedLaborCost: apiTotals.estimatedSalary } + : {}), + }, events, } } @@ -96,7 +169,7 @@ export function adaptWorkspaceScheduleToCalendar( export function adaptWorkspaceScheduleToShifts( response: WorkspaceScheduleApiResponse ): WorkspaceShiftItem[] { - return response.data.map(shift => { + return normalizeWorkspaceShifts(response.data).map(shift => { const { time } = formatScheduleTimeRange( shift.startDateTime, shift.endDateTime @@ -121,7 +194,7 @@ export function deriveWorkerList( const workerMap = new Map() const now = Date.now() - const sorted = response.data + const sorted = normalizeWorkspaceShifts(response.data) .filter(shift => new Date(shift.startDateTime).getTime() >= now) .sort( (a, b) => diff --git a/src/features/user/home/workspace/hooks/useSubstituteRequestFlow.ts b/src/features/user/home/workspace/hooks/useSubstituteRequestFlow.ts new file mode 100644 index 0000000..ddd7807 --- /dev/null +++ b/src/features/user/home/workspace/hooks/useSubstituteRequestFlow.ts @@ -0,0 +1,302 @@ +import { useMemo, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { format, parse } from 'date-fns' + +import type { CalendarViewData } from '@/features/home/common/schedule/types/calendarView' +import { DATE_KEY_FORMAT } from '@/features/home/common/schedule/constants/calendar' +import { getExchangeableWorkers } from '@/features/user/home/workspace/api/exchangeableWorkers' +import { createSubstituteRequest } from '@/features/user/home/workspace/api/substituteRequests' +import { WEEKDAY_LABELS } from '@/features/user/home/applied-stores/types/appliedStore' +import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage' +import { queryKeys } from '@/shared/lib/queryKeys' + +export type SubstituteRequestStepId = 1 | 2 | 3 | 4 | 5 + +const EXCHANGEABLE_WORKERS_PAGE_SIZE = 50 + +export function timeDigits(raw: string, maxLen: number) { + return raw.replace(/\D/g, '').slice(0, maxLen) +} + +export function normalizeHourInput(raw: string) { + const d = timeDigits(raw, 2) + if (d === '') return '00' + const n = parseInt(d, 10) + if (Number.isNaN(n)) return '00' + return String(Math.min(23, Math.max(0, n))).padStart(2, '0') +} + +export function normalizeMinuteInput(raw: string) { + const d = timeDigits(raw, 2) + if (d === '') return '00' + const n = parseInt(d, 10) + if (Number.isNaN(n)) return '00' + return String(Math.min(59, Math.max(0, n))).padStart(2, '0') +} + +function workerIdFromCandidateKey(key: string): number | undefined { + if (!key.startsWith('ew-')) return undefined + const id = Number(key.slice(3)) + return Number.isFinite(id) ? id : undefined +} + +function pickScheduleIdForSelectedDate( + calendarData: CalendarViewData | null | undefined, + selected: Date | null +): number | null { + if (selected == null || !calendarData?.events?.length) return null + const key = format(selected, DATE_KEY_FORMAT) + const sameDay = calendarData.events.filter(e => e.dateKey === key) + if (sameDay.length === 0) return null + sameDay.sort( + (a, b) => + new Date(a.startDateTime).getTime() - new Date(b.startDateTime).getTime() + ) + const first = sameDay[0] + return first != null && + typeof first.shiftId === 'number' && + Number.isFinite(first.shiftId) + ? first.shiftId + : null +} + +interface UseSubstituteRequestFlowParams { + calendarData: CalendarViewData | null + initialMonth?: Date + summarySelfIntroduction?: string + workspaceId?: number + onClose: () => void +} + +export function useSubstituteRequestFlow({ + calendarData, + initialMonth, + summarySelfIntroduction, + workspaceId, + onClose, +}: UseSubstituteRequestFlowParams) { + const queryClient = useQueryClient() + const [step, setStep] = useState(1) + const [substituteReason, setSubstituteReason] = useState('') + const [substituteSubmitLocalError, setSubstituteSubmitLocalError] = useState< + string | null + >(null) + const [selfIntroduction, setSelfIntroduction] = useState( + () => summarySelfIntroduction?.trim() ?? '' + ) + const [substituteCalendarBaseDate, setSubstituteCalendarBaseDate] = useState( + () => initialMonth ?? new Date() + ) + const [selectedCalendarDate, setSelectedCalendarDate] = useState( + null + ) + const [startHour, setStartHour] = useState('18') + const [startMin, setStartMin] = useState('00') + const [endHour, setEndHour] = useState('20') + const [endMin, setEndMin] = useState('00') + const [selectedCandidateKeys, setSelectedCandidateKeys] = useState< + Set + >(new Set()) + + const selectedWeekdayLabel = useMemo(() => { + if (selectedCalendarDate == null) return null + const idx = (selectedCalendarDate.getDay() + 6) % 7 + return WEEKDAY_LABELS[idx] + }, [selectedCalendarDate]) + + const summarySelectedTimeLabel = useMemo(() => { + const sh = normalizeHourInput(startHour) + const sm = normalizeMinuteInput(startMin) + const eh = normalizeHourInput(endHour) + const em = normalizeMinuteInput(endMin) + return `${sh}:${sm} ~ ${eh}:${em}` + }, [startHour, startMin, endHour, endMin]) + + const substituteScheduleId = useMemo( + () => pickScheduleIdForSelectedDate(calendarData, selectedCalendarDate), + [calendarData, selectedCalendarDate] + ) + + const { + data: exchangeableResponse, + isPending: exchangeableLoading, + isError: exchangeableError, + refetch: refetchExchangeable, + } = useQuery({ + queryKey: + substituteScheduleId != null + ? queryKeys.workspace.exchangeableWorkers( + substituteScheduleId, + EXCHANGEABLE_WORKERS_PAGE_SIZE + ) + : ['workspace', 'exchangeableWorkers', 'disabled'], + queryFn: () => + getExchangeableWorkers({ + scheduleId: substituteScheduleId!, + pageSize: EXCHANGEABLE_WORKERS_PAGE_SIZE, + }), + enabled: + step === 4 && substituteScheduleId != null && substituteScheduleId > 0, + }) + + const exchangeableWorkers = exchangeableResponse?.data.data ?? [] + + const substituteRequestMutation = useMutation({ + mutationFn: (vars: { + scheduleId: number + targetId: number + requestReason: string + }) => + createSubstituteRequest({ + scheduleId: vars.scheduleId, + body: { + requestType: 'SPECIFIC', + targetId: vars.targetId, + requestReason: vars.requestReason, + }, + }), + onSuccess: async (_, vars) => { + if (workspaceId != null && workspaceId > 0) { + await queryClient.invalidateQueries({ + queryKey: ['workspace', 'schedules', workspaceId], + }) + } + await queryClient.invalidateQueries({ + queryKey: ['workspace', 'exchangeableWorkers', vars.scheduleId], + }) + onClose() + }, + }) + + const goNext = () => { + setStep(s => (s < 5 ? ((s + 1) as SubstituteRequestStepId) : s)) + } + + const goBack = () => { + setStep(s => { + if (s <= 1) return s + return (s - 1) as SubstituteRequestStepId + }) + } + + const clearSubstituteSubmitFeedback = () => { + setSubstituteSubmitLocalError(null) + substituteRequestMutation.reset() + } + + const onSubstituteReasonChange = (value: string) => { + setSubstituteReason(value) + clearSubstituteSubmitFeedback() + } + + const submitSubstituteRequest = () => { + setSubstituteSubmitLocalError(null) + substituteRequestMutation.reset() + + if (substituteScheduleId == null || substituteScheduleId <= 0) { + setSubstituteSubmitLocalError('스케줄 정보를 찾을 수 없습니다.') + return + } + + const reasonTrim = substituteReason.trim() + if (reasonTrim === '') { + setSubstituteSubmitLocalError('대타 사유를 입력해 주세요.') + return + } + + if (selectedCandidateKeys.size !== 1) { + setSubstituteSubmitLocalError( + selectedCandidateKeys.size === 0 + ? '교환할 근무자를 선택해 주세요.' + : '교환 근무자는 한 명만 선택해 주세요.' + ) + return + } + + const [onlyKey] = [...selectedCandidateKeys] + const targetId = + onlyKey != null ? workerIdFromCandidateKey(onlyKey) : undefined + if (targetId == null) { + setSubstituteSubmitLocalError('선택한 근무자 정보가 올바르지 않습니다.') + return + } + + substituteRequestMutation.mutate({ + scheduleId: substituteScheduleId, + targetId, + requestReason: reasonTrim, + }) + } + + const substituteSubmitErrorDisplay = + substituteSubmitLocalError ?? + (substituteRequestMutation.isError + ? getAxiosErrorMessage( + substituteRequestMutation.error, + '대타 요청에 실패했습니다.' + ) + : null) + + const toggleCandidate = (key: string) => { + setSelectedCandidateKeys(prev => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + } + + const modalMaxWidthClass = + step === 1 ? 'max-w-[min(358px,calc(100vw-32px))]' : 'max-w-[318px]' + + const selectedDateKey = + selectedCalendarDate == null + ? '' + : format(selectedCalendarDate, DATE_KEY_FORMAT) + + const onSubstituteCalendarDaySelect = (dateKey: string) => { + setSelectedCalendarDate(parse(dateKey, DATE_KEY_FORMAT, new Date())) + } + + const clearCandidatesAndGoNextFromSummary = () => { + setSelectedCandidateKeys(new Set()) + goNext() + } + + return { + step, + goNext, + goBack, + substituteReason, + onSubstituteReasonChange, + selfIntroduction, + setSelfIntroduction, + substituteCalendarBaseDate, + setSubstituteCalendarBaseDate, + selectedCalendarDate, + selectedDateKey, + onSubstituteCalendarDaySelect, + startHour, + setStartHour, + startMin, + setStartMin, + endHour, + setEndHour, + endMin, + setEndMin, + selectedWeekdayLabel, + summarySelectedTimeLabel, + substituteScheduleId, + exchangeableWorkers, + exchangeableLoading, + exchangeableError, + refetchExchangeable, + selectedCandidateKeys, + toggleCandidate, + substituteSubmitErrorDisplay, + substituteRequestPending: substituteRequestMutation.isPending, + submitSubstituteRequest, + modalMaxWidthClass, + clearCandidatesAndGoNextFromSummary, + } +} diff --git a/src/features/user/home/workspace/hooks/useWorkspaceManagersViewModel.ts b/src/features/user/home/workspace/hooks/useWorkspaceManagersViewModel.ts index 8af5e4a..9e07942 100644 --- a/src/features/user/home/workspace/hooks/useWorkspaceManagersViewModel.ts +++ b/src/features/user/home/workspace/hooks/useWorkspaceManagersViewModel.ts @@ -31,8 +31,11 @@ export function useWorkspaceManagersViewModel( const managers = data?.pages.flatMap(page => page.data.data.map(adaptManagerDto)) ?? [] + const totalCount = data?.pages[0]?.data.page.totalCount ?? 0 + return { managers, + totalCount, fetchNextPage, hasNextPage: !!hasNextPage, isFetchingNextPage, diff --git a/src/features/user/home/workspace/hooks/useWorkspaceWorkersViewModel.ts b/src/features/user/home/workspace/hooks/useWorkspaceWorkersViewModel.ts index 6aab3dd..07bac51 100644 --- a/src/features/user/home/workspace/hooks/useWorkspaceWorkersViewModel.ts +++ b/src/features/user/home/workspace/hooks/useWorkspaceWorkersViewModel.ts @@ -31,8 +31,11 @@ export function useWorkspaceWorkersViewModel( const workers = data?.pages.flatMap(page => page.data.data.map(adaptWorkerDto)) ?? [] + const totalCount = data?.pages[0]?.data.page.totalCount ?? 0 + return { workers, + totalCount, fetchNextPage, hasNextPage: !!hasNextPage, isFetchingNextPage, diff --git a/src/features/user/home/workspace/hooks/useWorkspacesViewModel.ts b/src/features/user/home/workspace/hooks/useWorkspacesViewModel.ts index d04a2d9..b66800f 100644 --- a/src/features/user/home/workspace/hooks/useWorkspacesViewModel.ts +++ b/src/features/user/home/workspace/hooks/useWorkspacesViewModel.ts @@ -34,8 +34,11 @@ export function useWorkspacesViewModel() { const workspaces = data?.pages.flatMap(page => adaptWorkspaceListResponse(page)) ?? [] + const totalCount = data?.pages[0]?.data.page.totalCount ?? 0 + return { workspaces, + totalCount, fetchNextPage, hasNextPage, isFetchingNextPage, diff --git a/src/features/user/home/workspace/types/exchangeableWorkers.ts b/src/features/user/home/workspace/types/exchangeableWorkers.ts new file mode 100644 index 0000000..0d55283 --- /dev/null +++ b/src/features/user/home/workspace/types/exchangeableWorkers.ts @@ -0,0 +1,20 @@ +import type { CommonApiResponse } from '@/shared/types/common' + +export interface ExchangeableWorkerDto { + workerId: number + workerName: string +} + +export interface ExchangeableWorkersPageDto { + cursor: string | null + pageSize: number + totalCount: number +} + +export interface ExchangeableWorkersPayload { + page: ExchangeableWorkersPageDto + data: ExchangeableWorkerDto[] +} + +export type ExchangeableWorkersApiResponse = + CommonApiResponse diff --git a/src/features/user/home/workspace/types/substituteRequests.ts b/src/features/user/home/workspace/types/substituteRequests.ts new file mode 100644 index 0000000..d9b5c8a --- /dev/null +++ b/src/features/user/home/workspace/types/substituteRequests.ts @@ -0,0 +1,12 @@ +import type { CommonApiResponse } from '@/shared/types/common' + +/** 대타 요청 생성 body — `POST /app/schedules/{scheduleId}/substitute-requests` */ +export interface CreateSubstituteRequestBody { + requestType: 'ALL' | 'SPECIFIC' + targetId: number + requestReason: string +} + +export type CreateSubstituteRequestApiResponse = CommonApiResponse< + Record +> diff --git a/src/features/user/home/workspace/types/workspaceSchedule.ts b/src/features/user/home/workspace/types/workspaceSchedule.ts index c680762..5e1a4d9 100644 --- a/src/features/user/home/workspace/types/workspaceSchedule.ts +++ b/src/features/user/home/workspace/types/workspaceSchedule.ts @@ -16,9 +16,26 @@ export interface WorkspaceShiftDto { status: StatusEnum } -export type WorkspaceScheduleApiResponse = CommonApiResponse< - WorkspaceShiftDto[] -> +/** GET /app/schedules/workspaces/{id} 기본 응답의 data 객체 */ +export interface WorkspaceScheduleDataDto { + totalWorkHours: number + estimatedSalary: number + schedules: WorkspaceShiftDto[] +} + +/** 구버전/부분 형태 호환 포함 */ +export type WorkspaceScheduleDataPayload = + | WorkspaceScheduleDataDto + | WorkspaceShiftDto[] + | { + totalWorkHours?: number + estimatedSalary?: number + schedules?: WorkspaceShiftDto[] + shifts?: WorkspaceShiftDto[] + } + +export type WorkspaceScheduleApiResponse = + CommonApiResponse // ---- Query Params ---- export interface WorkspaceScheduleQueryParams { diff --git a/src/features/user/index.ts b/src/features/user/index.ts index 60dfab7..77a1d56 100644 --- a/src/features/user/index.ts +++ b/src/features/user/index.ts @@ -8,6 +8,13 @@ export type { AppliedStoreData } from '@/features/user/home/applied-stores/types export { useHomeScheduleViewModel } from '@/features/user/home/schedule/hooks/useHomeScheduleViewModel' export { useWorkspacesViewModel } from '@/features/user/home/workspace/hooks/useWorkspacesViewModel' export { useWorkspaceScheduleViewModel } from '@/features/user/home/workspace/hooks/useWorkspaceScheduleViewModel' +export { + useSubstituteRequestFlow, + timeDigits, + normalizeHourInput, + normalizeMinuteInput, +} from '@/features/user/home/workspace/hooks/useSubstituteRequestFlow' +export type { SubstituteRequestStepId } from '@/features/user/home/workspace/hooks/useSubstituteRequestFlow' export { useWorkspaceWorkersViewModel } from '@/features/user/home/workspace/hooks/useWorkspaceWorkersViewModel' export { useWorkspaceManagersViewModel } from '@/features/user/home/workspace/hooks/useWorkspaceManagersViewModel' export { WorkingStoresList } from '@/features/user/home/workspace/ui/WorkingStoresList' diff --git a/src/pages/manager/home/index.tsx b/src/pages/manager/home/index.tsx index f9079d3..0b2be3b 100644 --- a/src/pages/manager/home/index.tsx +++ b/src/pages/manager/home/index.tsx @@ -17,12 +17,15 @@ import managerHomeBannerImage from '@/assets/manager-home-banner.jpg' import managerHomeBannerPlusIcon from '@/assets/icons/home/manager-home-banner-plus.svg' import managerWorkspaceModalPlusIcon from '@/assets/icons/home/manager-workspace-modal-plus.svg' import managerScheduleEditIcon from '@/assets/icons/home/edit.svg' +import { shouldShowInfiniteListLoadMore } from '@/shared/lib/listLoadMoreVisibility' +import { ROUTES, managerWorkerSchedulePath } from '@/shared/constants/routes' export function ManagerHomePage() { const navigate = useNavigate() const { todayWorkers, storeWorkers, + storeWorkersTotalCount, fetchMoreWorkers, hasMoreWorkers, isFetchingMoreWorkers, @@ -35,6 +38,7 @@ export function ManagerHomePage() { fetchMoreSubstitutes, hasMoreSubstitutes, schedule, + activeWorkspaceId, workspaceDetail, workspaceChangeModal, openWorkspaceChangeModal, @@ -163,7 +167,21 @@ export function ManagerHomePage() { type="button" aria-label="업장 스케줄 수정" className="flex h-6 w-6 items-center justify-center" - onClick={() => navigate('/manager/worker-schedule')} + onClick={() => { + if ( + activeWorkspaceId === null || + storeWorkers[0] === undefined + ) { + navigate(ROUTES.MANAGER.WORKER_SCHEDULE) + return + } + navigate( + managerWorkerSchedulePath( + activeWorkspaceId, + storeWorkers[0].id + ) + ) + }} > {}} /> ))} - {hasMoreWorkers && ( + {shouldShowInfiniteListLoadMore( + hasMoreWorkers, + storeWorkersTotalCount + ) && ( fetchMoreWorkers()} disabled={isFetchingMoreWorkers} @@ -210,7 +231,14 @@ export function ManagerHomePage() {
fetchMorePostings() : undefined} + onViewMore={ + shouldShowInfiniteListLoadMore( + hasMorePostings, + postingsTotalCount + ) + ? () => fetchMorePostings() + : undefined + } onPostingClick={() => {}} />
@@ -224,7 +252,12 @@ export function ManagerHomePage() { fetchMoreSubstitutes() : undefined + shouldShowInfiniteListLoadMore( + hasMoreSubstitutes, + substituteTotalCount + ) + ? () => fetchMoreSubstitutes() + : undefined } onRequestClick={() => {}} /> diff --git a/src/pages/manager/worker-schedule/LegacyEntryRedirect.tsx b/src/pages/manager/worker-schedule/LegacyEntryRedirect.tsx new file mode 100644 index 0000000..747c851 --- /dev/null +++ b/src/pages/manager/worker-schedule/LegacyEntryRedirect.tsx @@ -0,0 +1,34 @@ +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { useManagedWorkspacesQuery } from '@/features/manager/home/hooks/useManagedWorkspacesQuery' +import { useWorkspaceWorkersViewModel } from '@/features/manager/home/hooks/useWorkspaceWorkersViewModel' +import { ROUTES, managerWorkerSchedulePath } from '@/shared/constants/routes' + +/** + * 구 경로 `/manager/worker-schedule` 진입 시 활성 매장·첫 근무자 기준으로 신규 URL로 치환 + */ +export function ManagerWorkerScheduleLegacyEntryRedirect() { + const navigate = useNavigate() + const { activeWorkspaceId, isLoading: workspacesLoading } = + useManagedWorkspacesQuery() + const { workers, isLoading: workersLoading } = + useWorkspaceWorkersViewModel(activeWorkspaceId) + + useEffect(() => { + if (workspacesLoading) return + if (activeWorkspaceId === null) { + navigate(ROUTES.MANAGER.HOME, { replace: true }) + return + } + if (workersLoading) return + if (workers.length === 0) { + navigate(ROUTES.MANAGER.HOME, { replace: true }) + return + } + navigate(managerWorkerSchedulePath(activeWorkspaceId, workers[0].id), { + replace: true, + }) + }, [activeWorkspaceId, navigate, workers, workersLoading, workspacesLoading]) + + return null +} diff --git a/src/pages/manager/worker-schedule/index.tsx b/src/pages/manager/worker-schedule/index.tsx index 82ca5e7..187ca44 100644 --- a/src/pages/manager/worker-schedule/index.tsx +++ b/src/pages/manager/worker-schedule/index.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { Navigate, useParams } from 'react-router-dom' import { WorkerRoleBadge } from '@/shared/ui/home/WorkerRoleBadge' import { useWorkerScheduleManageViewModel } from '@/features/manager/home/hooks/useWorkerScheduleManageViewModel' import chevronDownIcon from '@/assets/icons/home/chevron-down.svg' @@ -9,6 +10,13 @@ import { ColorPickerDropdown } from '@/shared/ui/schedule/ColorPickerDropdown' import type { ScheduleTab } from '@/features/manager' import { SCHEDULE_TABS } from '@/features/manager' import { ScheduleColor } from '@/features/manager/worker-schedule/types/scheduleColor' +import { ROUTES } from '@/shared/constants/routes' + +function parsePositiveRouteInt(raw: string | undefined): number | null { + if (raw === undefined) return null + const n = Number.parseInt(raw, 10) + return Number.isFinite(n) && n > 0 ? n : null +} interface TimeSelectBoxProps { value: string @@ -51,6 +59,34 @@ function TimeSelectBox({ value, unit, onChange }: TimeSelectBoxProps) { } export function ManagerWorkerSchedulePage() { + const { workspaceId: workspaceIdParam, workerId: workerIdParam } = useParams<{ + workspaceId: string + workerId: string + }>() + const workspaceId = parsePositiveRouteInt(workspaceIdParam) + const workerId = parsePositiveRouteInt(workerIdParam) + + if (workspaceId === null || workerId === null) { + return + } + + return ( + + ) +} + +type ManagerWorkerSchedulePageContentProps = { + workspaceId: number + workerId: number +} + +function ManagerWorkerSchedulePageContent({ + workspaceId, + workerId, +}: ManagerWorkerSchedulePageContentProps) { const [activeTab, setActiveTab] = useState('고정') const [showCalendar, setShowCalendar] = useState(false) const [selectedDate, setSelectedDate] = useState(null) @@ -62,8 +98,10 @@ export function ManagerWorkerSchedulePage() { const { worker, workers, - selectedWorkerIndex, - setSelectedWorkerIndex, + workersLoading, + fixedScheduleLoading, + fixedScheduleError, + goToWorker, workdayOptions, selectedDays, workTimeRangeLabel, @@ -76,7 +114,18 @@ export function ManagerWorkerSchedulePage() { setEndHour, setEndMinute, toggleDay, - } = useWorkerScheduleManageViewModel() + } = useWorkerScheduleManageViewModel({ workspaceId, workerId }) + + if (workersLoading || !worker) { + return ( +
+ +

+ 불러오는 중… +

+
+ ) + } return (
@@ -134,18 +183,17 @@ export function ManagerWorkerSchedulePage() { {isWorkerDropdownOpen && (
- {workers.map((w, index) => ( + {workers.map(w => ( +

+ {format(baseDate, MONTH_LABEL_FORMAT, { locale: ko })} +

+ +
+ +
+ {/* 요일 — 흰 둥근 바 안에 문자만 메인색 (Figma 1:546) */} +
+ {WEEKDAY_LABELS.map(d => ( +
+ {d} +
+ ))} +
+ +
+ {rows.map(row => ( +
c.dateKey).join('-')} + className="flex h-[50px] w-full items-center gap-0.5 overflow-hidden rounded-[20px] bg-white" + > + {row.map(cell => { + const selected = + cell.inMonth && + selectedDateKey !== '' && + cell.dateKey === selectedDateKey + + return ( +
+ {cell.inMonth ? ( + + ) : ( + + {cell.dayNum} + + )} +
+ ) + })} +
+ ))} +
+
+
+ ) +} diff --git a/src/pages/user/workspace-detail/components/SubstituteRequestModalFlow.tsx b/src/pages/user/workspace-detail/components/SubstituteRequestModalFlow.tsx new file mode 100644 index 0000000..479f8b7 --- /dev/null +++ b/src/pages/user/workspace-detail/components/SubstituteRequestModalFlow.tsx @@ -0,0 +1,504 @@ +import { useEffect } from 'react' + +import ChevronLeftIcon from '@/assets/icons/nav/chevron-left.svg' + +import { WEEKDAY_LABELS } from '@/features/user/home/applied-stores/types/appliedStore' +import { SubstituteCalendarPickerPanel } from './SubstituteCalendarPickerPanel' +import { + normalizeHourInput, + normalizeMinuteInput, + timeDigits, + useSubstituteRequestFlow, +} from '@/features/user' +import { WorkerRoleBadge } from '@/shared/ui/home/WorkerRoleBadge' +import { cn } from '@/shared/lib/utils' + +import type { CalendarViewData } from '@/features/home/common/schedule/types/calendarView' + +const timeFieldInputClass = + 'min-w-0 flex-1 bg-transparent text-center tabular-nums typography-body01-semibold text-text-90 outline-none placeholder:text-text-50' + +const timeSegmentLabelClass = + 'flex h-[50px] min-w-0 flex-1 cursor-text items-center justify-center gap-1.5 rounded-2xl bg-bg-dark px-3 outline-none transition focus-within:ring-2 focus-within:ring-main' + +interface SubstituteRequestModalFlowProps { + onClose: () => void + /** 모달 상단에 표시할 업장명 */ + storeName: string + /** 스케줄 ID(교환 가능 근무자 API) — 선택일에 맞는 이벤트는 캘린더 데이터에서 매칭 */ + calendarData: CalendarViewData | null + /** 캘린더 단계 초기 표시 월(페이지 스케줄 `baseDate`와 동기) */ + initialMonth?: Date + /** 요약(3단계) 자기소개 초기값(비워 두면 textarea는 비우고 플레이스홀더로 기본 문구 노출) */ + summarySelfIntroduction?: string + /** 대타 생성 성공 시 스케줄 목록 무효화용 */ + workspaceId?: number +} + +/** 대타 요청 다단계 모달: 일 선택 달력 → 근무 시간 → 요약 → 근무자 → 사유 */ +export function SubstituteRequestModalFlow({ + onClose, + storeName, + calendarData, + initialMonth, + summarySelfIntroduction, + workspaceId, +}: SubstituteRequestModalFlowProps) { + const flow = useSubstituteRequestFlow({ + calendarData, + initialMonth, + summarySelfIntroduction, + workspaceId, + onClose, + }) + + useEffect(() => { + const prev = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.body.style.overflow = prev + } + }, []) + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [onClose]) + + return ( +
+ +
+ + ) : null} + + {/* 2 — 근무 시간 (Figma 1:815 — 출근·퇴근 시간 시:분 단위 표기) */} + {flow.step === 2 ? ( + <> +
+ +

+ 근무 시간 선택 +

+
+
+ +
+
+

+ 출근 시간 +

+
+ + + : + + +
+
+ +
+

+ 퇴근 시간 +

+
+ + + : + + +
+
+
+ +
+ +
+ + ) : null} + + {/* 3 — 요약 (지원 상세와 동일 패턴) */} + {flow.step === 3 ? ( + <> +
+ +

+ {storeName} +

+
+
+ +
+

+ 요일 +

+
+ {WEEKDAY_LABELS.map(day => { + const selected = + flow.selectedWeekdayLabel != null && + day === flow.selectedWeekdayLabel + return ( +
+ {selected ? ( + + {day} + + ) : ( + + {day} + + )} +
+ ) + })} +
+
+ +
+

+ 시간 +

+
+

+ {flow.summarySelectedTimeLabel} +

+
+
+ +
+ +