diff --git a/src/features/home/common/schedule/lib/date.ts b/src/features/home/common/schedule/lib/date.ts index 007853a..5cf7344 100644 --- a/src/features/home/common/schedule/lib/date.ts +++ b/src/features/home/common/schedule/lib/date.ts @@ -17,22 +17,8 @@ export function getDurationHours(startIso: string, endIso: string) { 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 { splitClockToParts } from '@/shared/lib/clock' +import { splitClockToParts } from '@/shared/lib/clock' export function formatClockRangeLabel(startClock: string, endClock: string) { const s = splitClockToParts(startClock) diff --git a/src/features/manager/home/api/posting.ts b/src/features/manager/api/posting.ts similarity index 100% rename from src/features/manager/home/api/posting.ts rename to src/features/manager/api/posting.ts diff --git a/src/features/manager/home/api/schedule.ts b/src/features/manager/api/schedule.ts similarity index 100% rename from src/features/manager/home/api/schedule.ts rename to src/features/manager/api/schedule.ts diff --git a/src/features/manager/home/api/substitute.ts b/src/features/manager/api/substitute.ts similarity index 100% rename from src/features/manager/home/api/substitute.ts rename to src/features/manager/api/substitute.ts diff --git a/src/features/manager/home/api/worker.ts b/src/features/manager/api/worker.ts similarity index 63% rename from src/features/manager/home/api/worker.ts rename to src/features/manager/api/worker.ts index 4d69218..4b4e389 100644 --- a/src/features/manager/home/api/worker.ts +++ b/src/features/manager/api/worker.ts @@ -1,4 +1,5 @@ import axiosInstance from '@/shared/lib/axiosInstance' +import type { UpdateWorkspaceWorkerColorRequest } from '@/features/manager/worker-schedule/types/workerColor' import type { WorkersApiResponse, WorkspaceWorkersQueryParams, @@ -21,3 +22,14 @@ export async function fetchWorkspaceWorkers( ) return response.data } + +export async function patchWorkspaceWorkerColor( + workspaceId: number, + workerId: number, + body: UpdateWorkspaceWorkerColorRequest +): Promise { + await axiosInstance.patch( + `/manager/workspaces/${workspaceId}/workers/${workerId}/color`, + body + ) +} diff --git a/src/features/manager/home/api/workspace.ts b/src/features/manager/api/workspace.ts similarity index 100% rename from src/features/manager/home/api/workspace.ts rename to src/features/manager/api/workspace.ts diff --git a/src/features/manager/home/api/workerFixedSchedule.ts b/src/features/manager/home/api/workerFixedSchedule.ts deleted file mode 100644 index 3fbedc2..0000000 --- a/src/features/manager/home/api/workerFixedSchedule.ts +++ /dev/null @@ -1,16 +0,0 @@ -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/hooks/useManagedPostingsViewModel.ts b/src/features/manager/home/hooks/useManagedPostingsViewModel.ts index f21f4da..f9514ef 100644 --- a/src/features/manager/home/hooks/useManagedPostingsViewModel.ts +++ b/src/features/manager/home/hooks/useManagedPostingsViewModel.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react' import { useInfiniteQuery } from '@tanstack/react-query' -import { fetchManagedPostings } from '@/features/manager/home/api/posting' +import { fetchManagedPostings } from '@/features/manager/api/posting' import { adaptPostingDto } from '@/features/manager/home/types/posting' import { queryKeys } from '@/shared/lib/queryKeys' diff --git a/src/features/manager/home/hooks/useManagedWorkspacesQuery.ts b/src/features/manager/home/hooks/useManagedWorkspacesQuery.ts index 2d57944..971d716 100644 --- a/src/features/manager/home/hooks/useManagedWorkspacesQuery.ts +++ b/src/features/manager/home/hooks/useManagedWorkspacesQuery.ts @@ -1,6 +1,6 @@ import { useEffect, useMemo } from 'react' import { useQuery } from '@tanstack/react-query' -import { fetchManagedWorkspaces } from '@/features/manager/home/api/workspace' +import { fetchManagedWorkspaces } from '@/features/manager/api/workspace' import { useWorkspaceStore } from '@/shared/stores/useWorkspaceStore' import { queryKeys } from '@/shared/lib/queryKeys' diff --git a/src/features/manager/home/hooks/useMonthlySchedulesViewModel.ts b/src/features/manager/home/hooks/useMonthlySchedulesViewModel.ts index 487600d..bf12916 100644 --- a/src/features/manager/home/hooks/useMonthlySchedulesViewModel.ts +++ b/src/features/manager/home/hooks/useMonthlySchedulesViewModel.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo, useState } from 'react' import { addMonths, format } from 'date-fns' import { useQuery } from '@tanstack/react-query' -import { fetchMonthlySchedules } from '@/features/manager/home/api/schedule' +import { fetchMonthlySchedules } from '@/features/manager/api/schedule' import type { ManagerScheduleApiResponse, ManagerScheduleShiftDto, diff --git a/src/features/manager/home/hooks/useSubstituteRequestsViewModel.ts b/src/features/manager/home/hooks/useSubstituteRequestsViewModel.ts index 5488c50..196ca3f 100644 --- a/src/features/manager/home/hooks/useSubstituteRequestsViewModel.ts +++ b/src/features/manager/home/hooks/useSubstituteRequestsViewModel.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react' import { useInfiniteQuery } from '@tanstack/react-query' -import { fetchSubstituteRequests } from '@/features/manager/home/api/substitute' +import { fetchSubstituteRequests } from '@/features/manager/api/substitute' import { adaptSubstituteRequestDto } from '@/features/manager/home/types/substitute' import { queryKeys } from '@/shared/lib/queryKeys' diff --git a/src/features/manager/home/hooks/useTodaySchedulesViewModel.ts b/src/features/manager/home/hooks/useTodaySchedulesViewModel.ts index 0c71f27..212ac84 100644 --- a/src/features/manager/home/hooks/useTodaySchedulesViewModel.ts +++ b/src/features/manager/home/hooks/useTodaySchedulesViewModel.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react' import { useQuery } from '@tanstack/react-query' -import { fetchTodaySchedules } from '@/features/manager/home/api/schedule' +import { fetchTodaySchedules } from '@/features/manager/api/schedule' import { queryKeys } from '@/shared/lib/queryKeys' import { formatIsoTimeRangeLabel } from '@/features/home/common/schedule/lib/date' import type { TodayWorkerItem } from '@/features/manager/home/ui/TodayWorkerList' diff --git a/src/features/manager/home/hooks/useWorkerScheduleManageViewModel.ts b/src/features/manager/home/hooks/useWorkerScheduleManageViewModel.ts deleted file mode 100644 index 063b509..0000000 --- a/src/features/manager/home/hooks/useWorkerScheduleManageViewModel.ts +++ /dev/null @@ -1,160 +0,0 @@ -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 DEFAULT_SELECTED_DAYS: ManagerWeekdayKo[] = ['수', '금'] - -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 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]) - - const [selectedDays, setSelectedDays] = useState( - DEFAULT_SELECTED_DAYS - ) - const [startHour, setStartHour] = useState('') - const [startMinute, setStartMinute] = useState('') - const [endHour, setEndHour] = useState('') - const [endMinute, setEndMinute] = useState('') - - 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' - const sm = startMinute || '00' - const eh = endHour || '00' - const em = endMinute || '00' - return `${sh}:${sm} ~ ${eh}:${em}` - }, [startHour, startMinute, endHour, endMinute]) - - function toggleDay(day: string) { - setSelectedDays(prev => - prev.includes(day) ? prev.filter(item => item !== day) : [...prev, day] - ) - } - - function goToWorker(nextWorkerId: number) { - navigate(managerWorkerSchedulePath(workspaceId, nextWorkerId)) - } - - return { - worker, - workers, - workersLoading, - fixedScheduleLoading, - fixedScheduleError, - goToWorker, - workdayOptions: MANAGER_WEEKDAY_KO_ORDER, - selectedDays, - workTimeRangeLabel, - startHour, - startMinute, - endHour, - endMinute, - setStartHour, - setStartMinute, - setEndHour, - setEndMinute, - toggleDay, - } -} diff --git a/src/features/manager/home/hooks/useWorkspaceDetailQuery.ts b/src/features/manager/home/hooks/useWorkspaceDetailQuery.ts index c9e5eb3..f53fe6c 100644 --- a/src/features/manager/home/hooks/useWorkspaceDetailQuery.ts +++ b/src/features/manager/home/hooks/useWorkspaceDetailQuery.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react' import { useQuery } from '@tanstack/react-query' -import { fetchWorkspaceDetail } from '@/features/manager/home/api/workspace' +import { fetchWorkspaceDetail } from '@/features/manager/api/workspace' import { queryKeys } from '@/shared/lib/queryKeys' export function useWorkspaceDetailQuery(workspaceId: number | null) { diff --git a/src/features/manager/home/hooks/useWorkspaceWorkersViewModel.ts b/src/features/manager/home/hooks/useWorkspaceWorkersViewModel.ts index aa36120..8606924 100644 --- a/src/features/manager/home/hooks/useWorkspaceWorkersViewModel.ts +++ b/src/features/manager/home/hooks/useWorkspaceWorkersViewModel.ts @@ -1,10 +1,10 @@ import { useMemo } from 'react' import { useInfiniteQuery } from '@tanstack/react-query' -import { fetchWorkspaceWorkers } from '@/features/manager/home/api/worker' +import { fetchWorkspaceWorkers } from '@/features/manager/api/worker' import { adaptWorkerDto } from '@/features/manager/home/lib/worker' import { queryKeys } from '@/shared/lib/queryKeys' -const PAGE_SIZE = 20 +const PAGE_SIZE = 50 export function useWorkspaceWorkersViewModel( workspaceId: number | null, diff --git a/src/features/manager/home/lib/worker.ts b/src/features/manager/home/lib/worker.ts index 1597da5..471558e 100644 --- a/src/features/manager/home/lib/worker.ts +++ b/src/features/manager/home/lib/worker.ts @@ -25,6 +25,8 @@ export function adaptWorkerDto(dto: WorkerDto): ManagerWorkerItem { id: dto.id, name: dto.user.name, role: mapPositionTypeToRole(dto.position.type), + position: dto.position.description || dto.position.type, + colorCode: dto.colorCode, nextWorkDate: formatNextShiftDate(dto.nextShiftDateTime), } } diff --git a/src/features/manager/home/types/managerHomeLocationState.ts b/src/features/manager/home/types/managerHomeLocationState.ts new file mode 100644 index 0000000..e4aba25 --- /dev/null +++ b/src/features/manager/home/types/managerHomeLocationState.ts @@ -0,0 +1,3 @@ +export type ManagerHomeLocationState = { + workerScheduleSaveSuccess?: boolean +} diff --git a/src/features/manager/home/types/schedule.ts b/src/features/manager/home/types/schedule.ts index a1e723c..3cccbfd 100644 --- a/src/features/manager/home/types/schedule.ts +++ b/src/features/manager/home/types/schedule.ts @@ -35,7 +35,7 @@ export interface ManagerScheduleStatusDto { export interface ManagerScheduleShiftDto { shiftId: number workspace: ManagerScheduleWorkspaceDto - assignedWorker: ManagerScheduleWorkerDto + assignedWorker?: ManagerScheduleWorkerDto | null startDateTime: string endDateTime: string position: string diff --git a/src/features/manager/home/types/worker.ts b/src/features/manager/home/types/worker.ts index bff93a2..8565599 100644 --- a/src/features/manager/home/types/worker.ts +++ b/src/features/manager/home/types/worker.ts @@ -25,6 +25,7 @@ export interface WorkerDto { user: WorkerUserDto status: WorkerStatusDto position: WorkerPositionDto + colorCode: string employedAt: string resignedAt: string | null nextShiftDateTime: string | null @@ -55,6 +56,10 @@ export interface ManagerWorkerItem { id: number name: string role: StoreWorkerRole + /** 스케줄 생성·수정 API `position` 필드 */ + position: string + /** `PATCH …/workers/{id}/color` */ + colorCode: string nextWorkDate: string profileImageUrl?: string } diff --git a/src/features/manager/index.ts b/src/features/manager/index.ts index 3312e30..e98f4c8 100644 --- a/src/features/manager/index.ts +++ b/src/features/manager/index.ts @@ -1,7 +1,33 @@ +export { + getFixedWorkerSchdules, + postFixedWorkerSchdules, + deleteFixedWorkerSchdule, + patchFixedWorkerSchdule, +} from '@/features/manager/worker-schedule/api/fixedWorkerSchdule' +export type { + ResponseGetFixedWorkerSchdules, + RequestPostFixedWorkerSchdules, + ResponsePostFixedWorkerSchdules, + ResponseDeleteFixedWorkerSchdules, + RequestPatchFixedWorkerSchdules, + FixedWorkerScheduleDto, +} from '@/features/manager/worker-schedule/types/fixedWorkerSchdules' +export { + postManagerWorkSchedule, + putManagerWorkSchedule, + postAssignWorkerToSchedule, +} from '@/features/manager/worker-schedule/api/managerWorkSchedule' export { StoreWorkerListItem } from '@/features/manager/home/ui/StoreWorkerListItem' export { WorkspaceChangeList } from '@/features/manager/home/ui/WorkspaceChangeList' export { WorkspaceChangeCard } from '@/features/manager/home/ui/WorkspaceChangeCard' export { TodayWorkerList } from '@/features/manager/home/ui/TodayWorkerList' export { useManagerHomeViewModel } from '@/features/manager/home/hooks/useManagerHomeViewModel' +export { useWorkerScheduleManageViewModel } from '@/features/manager/worker-schedule/hooks/useWorkerScheduleManageViewModel' +export { ScheduleColor } from '@/features/manager/worker-schedule/types/scheduleColor' +export { + WORKER_DEFAULT_COLOR_CODE, + resolveSchedulePickerColor, +} from '@/features/manager/worker-schedule/types/workerColor' +export { patchWorkspaceWorkerColor } from '@/features/manager/api/worker' export type { ScheduleTab } from '@/features/manager/schedule/types/workerSchedule' export { SCHEDULE_TABS } from '@/features/manager/schedule/constants/workerSchedule' diff --git a/src/features/manager/worker-schedule/api/fixedWorkerSchdule.ts b/src/features/manager/worker-schedule/api/fixedWorkerSchdule.ts new file mode 100644 index 0000000..37b99f5 --- /dev/null +++ b/src/features/manager/worker-schedule/api/fixedWorkerSchdule.ts @@ -0,0 +1,50 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { + ResponseGetFixedWorkerSchdules, + RequestPostFixedWorkerSchdules, + ResponsePostFixedWorkerSchdules, + ResponseDeleteFixedWorkerSchdules, + RequestPatchFixedWorkerSchdules, +} from '@/features/manager' + +export async function getFixedWorkerSchdules( + workspaceId: number +): Promise { + const response = await axiosInstance.get( + `/manager/workspaces/${workspaceId}/fixed-worker-schedules` + ) + return response.data +} + +export async function postFixedWorkerSchdules( + workspaceId: number, + body: RequestPostFixedWorkerSchdules +): Promise { + const response = await axiosInstance.post( + `/manager/workspaces/${workspaceId}/fixed-worker-schedules`, + body + ) + return response.data +} + +export async function deleteFixedWorkerSchdule( + workspaceId: number, + workerScheduleId: number +): Promise { + const response = + await axiosInstance.delete( + `/manager/workspaces/${workspaceId}/fixed-worker-schedules/${workerScheduleId}` + ) + return response.data +} + +export async function patchFixedWorkerSchdule( + workspaceId: number, + workerScheduleId: number, + body: RequestPatchFixedWorkerSchdules +): Promise { + await axiosInstance.patch( + `/manager/workspaces/${workspaceId}/fixed-worker-schedules/${workerScheduleId}`, + body + ) +} diff --git a/src/features/manager/worker-schedule/api/managerWorkSchedule.ts b/src/features/manager/worker-schedule/api/managerWorkSchedule.ts new file mode 100644 index 0000000..01c8c42 --- /dev/null +++ b/src/features/manager/worker-schedule/api/managerWorkSchedule.ts @@ -0,0 +1,26 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { + AssignWorkerToScheduleRequest, + CreateWorkScheduleRequest, + UpdateWorkScheduleRequest, +} from '@/features/manager/worker-schedule/types/managerWorkSchedule' + +export async function postManagerWorkSchedule( + body: CreateWorkScheduleRequest +): Promise { + await axiosInstance.post('/manager/schedules', body) +} + +export async function putManagerWorkSchedule( + workShiftId: number, + body: UpdateWorkScheduleRequest +): Promise { + await axiosInstance.put(`/manager/schedules/${workShiftId}`, body) +} + +export async function postAssignWorkerToSchedule( + workShiftId: number, + body: AssignWorkerToScheduleRequest +): Promise { + await axiosInstance.post(`/manager/schedules/${workShiftId}/workers`, body) +} diff --git a/src/features/manager/worker-schedule/hooks/mutation/index.ts b/src/features/manager/worker-schedule/hooks/mutation/index.ts new file mode 100644 index 0000000..0c46798 --- /dev/null +++ b/src/features/manager/worker-schedule/hooks/mutation/index.ts @@ -0,0 +1,3 @@ +export { useCreateFixedWorkerSchedule } from './useCreateFixedWorkerSchedule' +export { useUpdateFixedWorkerSchedule } from './useUpdateFixedWorkerSchedule' +export { useDeleteFixedWorkerSchedule } from './useDeleteFixedWorkerSchedule' diff --git a/src/features/manager/worker-schedule/hooks/mutation/useCreateFixedWorkerSchedule.ts b/src/features/manager/worker-schedule/hooks/mutation/useCreateFixedWorkerSchedule.ts new file mode 100644 index 0000000..8be73be --- /dev/null +++ b/src/features/manager/worker-schedule/hooks/mutation/useCreateFixedWorkerSchedule.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { postFixedWorkerSchdules } from '@/features/manager/worker-schedule/api/fixedWorkerSchdule' +import type { RequestPostFixedWorkerSchdules } from '@/features/manager/worker-schedule/types/fixedWorkerSchdules' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useCreateFixedWorkerSchedule(workspaceId: number) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (body: RequestPostFixedWorkerSchdules) => + postFixedWorkerSchdules(workspaceId, body), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: queryKeys.fixedWorkerSchedule.list(workspaceId), + }) + void queryClient.invalidateQueries({ + queryKey: ['manager', 'schedules', workspaceId], + }) + }, + }) +} diff --git a/src/features/manager/worker-schedule/hooks/mutation/useDeleteFixedWorkerSchedule.ts b/src/features/manager/worker-schedule/hooks/mutation/useDeleteFixedWorkerSchedule.ts new file mode 100644 index 0000000..038c449 --- /dev/null +++ b/src/features/manager/worker-schedule/hooks/mutation/useDeleteFixedWorkerSchedule.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { deleteFixedWorkerSchdule } from '@/features/manager/worker-schedule/api/fixedWorkerSchdule' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useDeleteFixedWorkerSchedule(workspaceId: number) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (workerScheduleId: number) => + deleteFixedWorkerSchdule(workspaceId, workerScheduleId), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: queryKeys.fixedWorkerSchedule.list(workspaceId), + }) + void queryClient.invalidateQueries({ + queryKey: ['manager', 'schedules', workspaceId], + }) + }, + }) +} diff --git a/src/features/manager/worker-schedule/hooks/mutation/useUpdateFixedWorkerSchedule.ts b/src/features/manager/worker-schedule/hooks/mutation/useUpdateFixedWorkerSchedule.ts new file mode 100644 index 0000000..b786d36 --- /dev/null +++ b/src/features/manager/worker-schedule/hooks/mutation/useUpdateFixedWorkerSchedule.ts @@ -0,0 +1,26 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { patchFixedWorkerSchdule } from '@/features/manager/worker-schedule/api/fixedWorkerSchdule' +import { queryKeys } from '@/shared/lib/queryKeys' +import type { RequestPatchFixedWorkerSchdules } from '@/features/manager/worker-schedule/types/fixedWorkerSchdules' + +interface UpdateFixedWorkerScheduleParams { + workerScheduleId: number + body: RequestPatchFixedWorkerSchdules +} + +export function useUpdateFixedWorkerSchedule(workspaceId: number) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ workerScheduleId, body }: UpdateFixedWorkerScheduleParams) => + patchFixedWorkerSchdule(workspaceId, workerScheduleId, body), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: queryKeys.fixedWorkerSchedule.list(workspaceId), + }) + void queryClient.invalidateQueries({ + queryKey: ['manager', 'schedules', workspaceId], + }) + }, + }) +} diff --git a/src/features/manager/worker-schedule/hooks/query/index.ts b/src/features/manager/worker-schedule/hooks/query/index.ts new file mode 100644 index 0000000..bd3f4c5 --- /dev/null +++ b/src/features/manager/worker-schedule/hooks/query/index.ts @@ -0,0 +1,2 @@ +export { useWorkspaceWorkers } from './useWorkspaceWorkers' +export { useFixedWorkerSchedules } from './useFixedWorkerSchedules' diff --git a/src/features/manager/worker-schedule/hooks/query/useFixedWorkerSchedules.ts b/src/features/manager/worker-schedule/hooks/query/useFixedWorkerSchedules.ts new file mode 100644 index 0000000..17438eb --- /dev/null +++ b/src/features/manager/worker-schedule/hooks/query/useFixedWorkerSchedules.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query' +import { getFixedWorkerSchdules } from '@/features/manager/worker-schedule/api/fixedWorkerSchdule' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useFixedWorkerSchedules(workspaceId: number) { + return useQuery({ + queryKey: queryKeys.fixedWorkerSchedule.list(workspaceId), + queryFn: () => getFixedWorkerSchdules(workspaceId), + enabled: !!workspaceId, + }) +} diff --git a/src/features/manager/worker-schedule/hooks/query/useWorkspaceWorkers.ts b/src/features/manager/worker-schedule/hooks/query/useWorkspaceWorkers.ts new file mode 100644 index 0000000..c7a8a09 --- /dev/null +++ b/src/features/manager/worker-schedule/hooks/query/useWorkspaceWorkers.ts @@ -0,0 +1,34 @@ +import { useQuery } from '@tanstack/react-query' +import { fetchWorkspaceWorkers } from '@/features/manager/api/worker' +import { queryKeys } from '@/shared/lib/queryKeys' +import type { WorkersApiResponse } from '@/features/manager/home/types/worker' + +const PAGE_SIZE = 50 + +interface UseWorkspaceWorkersParams { + workspaceId?: number + status?: string + name?: string +} + +export function useWorkspaceWorkers({ + workspaceId, + status, + name, +}: UseWorkspaceWorkersParams) { + return useQuery({ + queryKey: queryKeys.managerWorkspace.workers(workspaceId ?? 0, { + status, + name, + pageSize: PAGE_SIZE, + }), + queryFn: () => + fetchWorkspaceWorkers({ + workspaceId: workspaceId ?? 0, + pageSize: PAGE_SIZE, + status, + name, + }), + enabled: !!workspaceId, + }) +} diff --git a/src/features/manager/worker-schedule/hooks/useWorkerScheduleManageViewModel.ts b/src/features/manager/worker-schedule/hooks/useWorkerScheduleManageViewModel.ts new file mode 100644 index 0000000..ed3e6ea --- /dev/null +++ b/src/features/manager/worker-schedule/hooks/useWorkerScheduleManageViewModel.ts @@ -0,0 +1,461 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { format } from 'date-fns' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' +import { fetchMonthlySchedules } from '@/features/manager/api/schedule' +import { patchWorkspaceWorkerColor } from '@/features/manager/api/worker' +import { getFixedWorkerSchdules } from '@/features/manager/worker-schedule/api/fixedWorkerSchdule' +import type { ScheduleTab } from '@/features/manager/schedule/types/workerSchedule' +import { useWorkspaceWorkersViewModel } from '@/features/manager/home/hooks/useWorkspaceWorkersViewModel' +import type { ManagerWeekdayKo } from '@/features/manager/home/constants/managerWeekdayKo' +import { MANAGER_WEEKDAY_KO_ORDER } from '@/features/manager/home/constants/managerWeekdayKo' +import { + displayTimesFromSlots, + listFixedSchedulesForWorker, + selectedDaysFromSlots, + type WorkerFixedSlotByWeekday, +} from '@/features/manager/worker-schedule/lib/fixedScheduleForWorker' +import { invalidateManagerScheduleQueries } from '@/features/manager/worker-schedule/lib/invalidateScheduleQueries' +import { saveFixedWorkerSchedules } from '@/features/manager/worker-schedule/lib/saveFixedWorkerSchedules' +import { saveGeneralWorkerSchedule } from '@/features/manager/worker-schedule/lib/saveGeneralWorkerSchedule' +import { dateTimeToHourMinute } from '@/features/manager/worker-schedule/lib/scheduleDateTime' +import type { ManagerHomeLocationState } from '@/features/manager/home/types/managerHomeLocationState' +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 type { ManagerScheduleShiftDto } from '@/features/manager/home/types/schedule' +import type { ScheduleColor } from '@/features/manager/worker-schedule/types/scheduleColor' +import { + colorCodesEqual, + isValidWorkerColorCode, + normalizeColorCodeForApi, + resolveSchedulePickerColor, +} from '@/features/manager/worker-schedule/types/workerColor' + +const DEFAULT_SELECTED_DAYS: ManagerWeekdayKo[] = ['수', '금'] + +type ScheduleFormState = { + selectedDays: ManagerWeekdayKo[] + startHour: string + startMinute: string + endHour: string + endMinute: string + generalShiftId: number | null +} + +const EMPTY_SCHEDULE_FORM: ScheduleFormState = { + selectedDays: DEFAULT_SELECTED_DAYS, + startHour: '00', + startMinute: '00', + endHour: '00', + endMinute: '00', + generalShiftId: null, +} + +function buildFixedFormFromSlots( + slots: WorkerFixedSlotByWeekday[] +): ScheduleFormState { + const selectedDays = + slots.length > 0 ? selectedDaysFromSlots(slots) : DEFAULT_SELECTED_DAYS + const times = displayTimesFromSlots(slots, selectedDays) + return { selectedDays, ...times, generalShiftId: null } +} + +function findWorkerShiftOnDate( + schedules: ManagerScheduleShiftDto[], + workerId: number, + date: Date +): ManagerScheduleShiftDto | null { + const dateKey = format(date, 'yyyy-MM-dd') + return ( + schedules.find( + s => + toDateKey(s.startDateTime) === dateKey && + s.assignedWorker?.workerId === workerId && + s.status.value !== 'DELETED' && + s.status.value !== 'CANCELLED' + ) ?? null + ) +} + +export function useWorkerScheduleManageViewModel(args: { + workspaceId: number + workerId: number + activeTab: ScheduleTab + generalSelectedDate: Date | null +}) { + const navigate = useNavigate() + const queryClient = useQueryClient() + const { workspaceId, workerId, activeTab, generalSelectedDate } = 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 worker = workers[selectedWorkerIndex] + + const { + data: fixedScheduleResponse, + isPending: fixedScheduleLoading, + isError: fixedScheduleError, + } = useQuery({ + queryKey: queryKeys.fixedWorkerSchedule.list(workspaceId), + queryFn: () => getFixedWorkerSchdules(workspaceId), + enabled: workspaceId > 0, + }) + + const generalYear = + generalSelectedDate?.getFullYear() ?? new Date().getFullYear() + const generalMonth = + (generalSelectedDate?.getMonth() ?? new Date().getMonth()) + 1 + + const { data: monthlyScheduleResponse, isPending: monthlyScheduleLoading } = + useQuery({ + queryKey: queryKeys.manager.schedules( + workspaceId, + generalYear, + generalMonth + ), + queryFn: () => + fetchMonthlySchedules({ + workspaceId, + year: generalYear, + month: generalMonth, + }), + enabled: + workspaceId > 0 && activeTab === '일반' && generalSelectedDate !== null, + }) + + const loadedFixedSlots = useMemo((): WorkerFixedSlotByWeekday[] => { + const list = fixedScheduleResponse?.data ?? [] + return listFixedSchedulesForWorker(list, workerId) + }, [fixedScheduleResponse, workerId]) + + const [fixedFormOverride, setFixedFormOverride] = useState<{ + key: string + form: ScheduleFormState + } | null>(null) + const [generalFormOverride, setGeneralFormOverride] = useState<{ + key: string + form: ScheduleFormState + } | null>(null) + const [colorOverride, setColorOverride] = useState<{ + workerId: number + color: ScheduleColor + } | null>(null) + const [saveError, setSaveError] = useState(null) + + const fixedDataKey = useMemo( + () => `${workerId}:${loadedFixedSlots.map(s => s.id).join(',')}`, + [workerId, loadedFixedSlots] + ) + + const serverFixedForm = useMemo((): ScheduleFormState | null => { + if (fixedScheduleLoading) return null + return buildFixedFormFromSlots(loadedFixedSlots) + }, [fixedScheduleLoading, loadedFixedSlots]) + + const fixedForm = useMemo((): ScheduleFormState => { + if (fixedFormOverride?.key === fixedDataKey) { + return fixedFormOverride.form + } + return serverFixedForm ?? EMPTY_SCHEDULE_FORM + }, [fixedFormOverride, fixedDataKey, serverFixedForm]) + + const generalDataKey = useMemo(() => { + if (!generalSelectedDate) return null + const shift = findWorkerShiftOnDate( + monthlyScheduleResponse?.data.schedules ?? [], + workerId, + generalSelectedDate + ) + return `${workerId}:${format(generalSelectedDate, 'yyyy-MM-dd')}:${shift?.shiftId ?? 'none'}` + }, [generalSelectedDate, monthlyScheduleResponse, workerId]) + + const serverGeneralForm = useMemo((): ScheduleFormState | null => { + if ( + activeTab !== '일반' || + !generalSelectedDate || + monthlyScheduleLoading || + generalDataKey === null + ) { + return null + } + const shift = findWorkerShiftOnDate( + monthlyScheduleResponse?.data.schedules ?? [], + workerId, + generalSelectedDate + ) + if (shift) { + const start = dateTimeToHourMinute(shift.startDateTime) + const end = dateTimeToHourMinute(shift.endDateTime) + return { + selectedDays: fixedForm.selectedDays, + startHour: start.hour, + startMinute: start.minute, + endHour: end.hour, + endMinute: end.minute, + generalShiftId: shift.shiftId, + } + } + const times = displayTimesFromSlots( + loadedFixedSlots, + fixedForm.selectedDays + ) + return { + selectedDays: fixedForm.selectedDays, + ...times, + generalShiftId: null, + } + }, [ + activeTab, + generalSelectedDate, + monthlyScheduleLoading, + generalDataKey, + monthlyScheduleResponse, + workerId, + loadedFixedSlots, + fixedForm.selectedDays, + ]) + + const generalForm = useMemo((): ScheduleFormState => { + if (generalDataKey === null) return fixedForm + if (generalFormOverride?.key === generalDataKey) { + return generalFormOverride.form + } + return serverGeneralForm ?? fixedForm + }, [generalDataKey, generalFormOverride, serverGeneralForm, fixedForm]) + + const activeForm = activeTab === '고정' ? fixedForm : generalForm + + const serverColor = useMemo( + () => + worker + ? resolveSchedulePickerColor(worker.colorCode) + : resolveSchedulePickerColor(''), + [worker] + ) + + const selectedColor = + worker && colorOverride?.workerId === worker.id + ? colorOverride.color + : serverColor + + const { + selectedDays, + startHour, + startMinute, + endHour, + endMinute, + generalShiftId, + } = activeForm + + 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]) + + const workTimeRangeLabel = useMemo(() => { + const sh = startHour || '00' + const sm = startMinute || '00' + const eh = endHour || '00' + const em = endMinute || '00' + return `${sh}:${sm} ~ ${eh}:${em}` + }, [startHour, startMinute, endHour, endMinute]) + + const patchFixedForm = useCallback( + (patch: Partial) => { + setFixedFormOverride({ + key: fixedDataKey, + form: { ...fixedForm, ...patch }, + }) + }, + [fixedDataKey, fixedForm] + ) + + const patchGeneralForm = useCallback( + (patch: Partial) => { + if (generalDataKey === null) return + setGeneralFormOverride({ + key: generalDataKey, + form: { ...generalForm, ...patch }, + }) + }, + [generalDataKey, generalForm] + ) + + const patchActiveForm = useCallback( + (patch: Partial) => { + if (activeTab === '고정') { + patchFixedForm(patch) + } else { + patchGeneralForm(patch) + } + }, + [activeTab, patchFixedForm, patchGeneralForm] + ) + + function toggleDay(day: string) { + const ko = day as ManagerWeekdayKo + patchFixedForm({ + selectedDays: selectedDays.includes(ko) + ? selectedDays.filter(item => item !== ko) + : [...selectedDays, ko], + }) + } + + function goToWorker(nextWorkerId: number) { + setFixedFormOverride(null) + setGeneralFormOverride(null) + setColorOverride(null) + navigate(managerWorkerSchedulePath(workspaceId, nextWorkerId)) + } + + const setSelectedColor = useCallback( + (color: ScheduleColor) => { + setColorOverride({ workerId, color }) + }, + [workerId] + ) + + const saveMutation = useMutation({ + mutationFn: async () => { + setSaveError(null) + if (!worker) throw new Error('근무자 정보를 불러오지 못했습니다.') + + if (activeTab === '고정') { + if (selectedDays.length === 0) { + throw new Error('최소 한 개의 요일을 선택해 주세요.') + } + await saveFixedWorkerSchedules({ + workspaceId, + workspaceWorkerId: workerId, + loadedSlots: loadedFixedSlots, + selectedDays, + startHour, + startMinute, + endHour, + endMinute, + onRollback: async () => { + await queryClient.invalidateQueries({ + queryKey: queryKeys.fixedWorkerSchedule.list(workspaceId), + }) + setFixedFormOverride(null) + }, + }) + } else { + if (!generalSelectedDate) { + throw new Error('날짜를 선택해 주세요.') + } + + const shiftId = await saveGeneralWorkerSchedule({ + workspaceId, + workerId, + position: worker.position, + shiftId: generalShiftId, + date: generalSelectedDate, + startHour, + startMinute, + endHour, + endMinute, + }) + if (shiftId !== null && generalDataKey !== null) { + setGeneralFormOverride({ + key: generalDataKey, + form: { ...generalForm, generalShiftId: shiftId }, + }) + } + } + + const nextColorCode = normalizeColorCodeForApi(selectedColor) + if (!isValidWorkerColorCode(nextColorCode)) { + throw new Error('올바른 색상 형식이 아닙니다.') + } + if (!colorCodesEqual(nextColorCode, worker.colorCode)) { + await patchWorkspaceWorkerColor(workspaceId, workerId, { + colorCode: nextColorCode, + }) + } + }, + onSuccess: async () => { + const year = + generalSelectedDate?.getFullYear() ?? new Date().getFullYear() + const month = + (generalSelectedDate?.getMonth() ?? new Date().getMonth()) + 1 + await invalidateManagerScheduleQueries( + queryClient, + workspaceId, + year, + month + ) + await queryClient.invalidateQueries({ + queryKey: ['managerWorkspace', 'workers', workspaceId], + }) + setFixedFormOverride(null) + setGeneralFormOverride(null) + setColorOverride(null) + + navigate(ROUTES.MANAGER.HOME, { + replace: true, + state: { + workerScheduleSaveSuccess: true, + } satisfies ManagerHomeLocationState, + }) + }, + onError: async (error: unknown) => { + setSaveError(getAxiosErrorMessage(error, '저장에 실패했습니다.')) + if (activeTab === '고정') { + await queryClient.invalidateQueries({ + queryKey: queryKeys.fixedWorkerSchedule.list(workspaceId), + }) + setFixedFormOverride(null) + } + }, + }) + + const handleSave = useCallback(() => { + saveMutation.mutate() + }, [saveMutation]) + + return { + workersLoading, + worker, + workers, + fixedScheduleLoading, + fixedScheduleError, + monthlyScheduleLoading, + goToWorker, + workdayOptions: MANAGER_WEEKDAY_KO_ORDER, + selectedDays, + toggleDay, + workTime: { + rangeLabel: workTimeRangeLabel, + startHour, + startMinute, + endHour, + endMinute, + setStartHour: (hour: string) => patchActiveForm({ startHour: hour }), + setStartMinute: (minute: string) => + patchActiveForm({ startMinute: minute }), + setEndHour: (hour: string) => patchActiveForm({ endHour: hour }), + setEndMinute: (minute: string) => patchActiveForm({ endMinute: minute }), + }, + handleSave, + isSaving: saveMutation.isPending, + saveError, + selectedColor, + setSelectedColor, + } +} diff --git a/src/features/manager/worker-schedule/lib/fixedScheduleForWorker.ts b/src/features/manager/worker-schedule/lib/fixedScheduleForWorker.ts new file mode 100644 index 0000000..b22dae2 --- /dev/null +++ b/src/features/manager/worker-schedule/lib/fixedScheduleForWorker.ts @@ -0,0 +1,119 @@ +import type { ManagerWeekdayKo } from '@/features/manager/home/constants/managerWeekdayKo' +import { MANAGER_WEEKDAY_KO_ORDER } from '@/features/manager/home/constants/managerWeekdayKo' +import type { FixedWorkerScheduleDto } from '@/features/manager/worker-schedule/types/fixedWorkerSchdules' +import { + nextWeekdayKoToApi, + sortWeekdaysKo, + weekdayApiToKo, + weekdayKoToApi, +} from '@/features/manager/worker-schedule/lib/weekdayMapping' +import { + fromApiLocalTime, + toApiLocalTime, +} from '@/features/manager/worker-schedule/lib/scheduleDateTime' +import type { FixedWorkerScheduleSlotInput } from '@/features/manager/worker-schedule/types/fixedWorkerSchdules' + +export type WorkerFixedSlotByWeekday = { + id: number + weekdayKo: ManagerWeekdayKo + startDayOfWeek: FixedWorkerScheduleDto['startDayOfWeek'] + endDayOfWeek: FixedWorkerScheduleDto['endDayOfWeek'] + startTime: string + endTime: string +} + +export function listFixedSchedulesForWorker( + all: FixedWorkerScheduleDto[], + workspaceWorkerId: number +): WorkerFixedSlotByWeekday[] { + return all + .filter( + s => s.workspaceWorkerId === workspaceWorkerId && s.status === 'ACTIVATED' + ) + .map(s => { + const weekdayKo = weekdayApiToKo(s.startDayOfWeek) + if (!weekdayKo) return null + return { + id: s.id, + weekdayKo, + startDayOfWeek: s.startDayOfWeek, + endDayOfWeek: s.endDayOfWeek, + startTime: s.startTime, + endTime: s.endTime, + } + }) + .filter((s): s is WorkerFixedSlotByWeekday => s !== null) +} + +export function primaryWeekdayAmongSelected( + selectedDays: ManagerWeekdayKo[] +): ManagerWeekdayKo | null { + for (const d of MANAGER_WEEKDAY_KO_ORDER) { + if (selectedDays.includes(d)) return d + } + return null +} + +export function displayTimesFromSlots( + slots: WorkerFixedSlotByWeekday[], + selectedDays: ManagerWeekdayKo[] +): { + startHour: string + startMinute: string + endHour: string + endMinute: string +} { + const primary = primaryWeekdayAmongSelected(selectedDays) + if (!primary) { + return { + startHour: '00', + startMinute: '00', + endHour: '00', + endMinute: '00', + } + } + const slot = slots.find(s => s.weekdayKo === primary) + if (!slot) { + return { + startHour: '00', + startMinute: '00', + endHour: '00', + endMinute: '00', + } + } + const start = fromApiLocalTime(slot.startTime) + const end = fromApiLocalTime(slot.endTime) + return { + startHour: start.hour, + startMinute: start.minute, + endHour: end.hour, + endMinute: end.minute, + } +} + +export function buildSlotInputForWeekday( + weekdayKo: ManagerWeekdayKo, + startHour: string, + startMinute: string, + endHour: string, + endMinute: string +): FixedWorkerScheduleSlotInput { + const startDayOfWeek = weekdayKoToApi(weekdayKo) + const startTime = toApiLocalTime(startHour, startMinute) + const endTime = toApiLocalTime(endHour, endMinute) + const endDayOfWeek = + endTime <= startTime ? nextWeekdayKoToApi(weekdayKo) : startDayOfWeek + + return { + startDayOfWeek, + startTime, + endDayOfWeek, + endTime, + } +} + +export function selectedDaysFromSlots( + slots: WorkerFixedSlotByWeekday[] +): ManagerWeekdayKo[] { + return sortWeekdaysKo(slots.map(s => s.weekdayKo)) +} diff --git a/src/features/manager/worker-schedule/lib/invalidateScheduleQueries.ts b/src/features/manager/worker-schedule/lib/invalidateScheduleQueries.ts new file mode 100644 index 0000000..4ee1eae --- /dev/null +++ b/src/features/manager/worker-schedule/lib/invalidateScheduleQueries.ts @@ -0,0 +1,21 @@ +import type { QueryClient } from '@tanstack/react-query' +import { queryKeys } from '@/shared/lib/queryKeys' + +export async function invalidateManagerScheduleQueries( + queryClient: QueryClient, + workspaceId: number, + year: number, + month: number +): Promise { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.fixedWorkerSchedule.list(workspaceId), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.manager.schedules(workspaceId, year, month), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.manager.todaySchedules(workspaceId), + }), + ]) +} diff --git a/src/features/manager/worker-schedule/lib/saveFixedWorkerSchedules.ts b/src/features/manager/worker-schedule/lib/saveFixedWorkerSchedules.ts new file mode 100644 index 0000000..cd54263 --- /dev/null +++ b/src/features/manager/worker-schedule/lib/saveFixedWorkerSchedules.ts @@ -0,0 +1,105 @@ +import { + deleteFixedWorkerSchdule, + patchFixedWorkerSchdule, + postFixedWorkerSchdules, +} from '@/features/manager/worker-schedule/api/fixedWorkerSchdule' +import { + buildSlotInputForWeekday, + type WorkerFixedSlotByWeekday, +} from '@/features/manager/worker-schedule/lib/fixedScheduleForWorker' +import type { ManagerWeekdayKo } from '@/features/manager/home/constants/managerWeekdayKo' +import type { FixedWorkerScheduleSlotInput } from '@/features/manager/worker-schedule/types/fixedWorkerSchdules' + +function slotNeedsUpdate( + existing: WorkerFixedSlotByWeekday, + startHour: string, + startMinute: string, + endHour: string, + endMinute: string +): boolean { + const desired = buildSlotInputForWeekday( + existing.weekdayKo, + startHour, + startMinute, + endHour, + endMinute + ) + return ( + desired.startTime !== existing.startTime || + desired.endTime !== existing.endTime || + desired.startDayOfWeek !== existing.startDayOfWeek || + desired.endDayOfWeek !== existing.endDayOfWeek + ) +} + +export async function saveFixedWorkerSchedules(args: { + workspaceId: number + workspaceWorkerId: number + loadedSlots: WorkerFixedSlotByWeekday[] + selectedDays: ManagerWeekdayKo[] + startHour: string + startMinute: string + endHour: string + endMinute: string + /** 부분 실패 시 서버 데이터로 폼을 되돌리기 위한 콜백 */ + onRollback?: () => Promise +}): Promise { + const { + workspaceId, + workspaceWorkerId, + loadedSlots, + selectedDays, + startHour, + startMinute, + endHour, + endMinute, + onRollback, + } = args + + const selectedSet = new Set(selectedDays) + const loadedByDay = new Map(loadedSlots.map(s => [s.weekdayKo, s] as const)) + + const toDelete = loadedSlots.filter(s => !selectedSet.has(s.weekdayKo)) + + const toCreate: FixedWorkerScheduleSlotInput[] = [] + const toUpdate: { id: number; body: FixedWorkerScheduleSlotInput }[] = [] + + for (const day of selectedDays) { + const existing = loadedByDay.get(day) + const body = buildSlotInputForWeekday( + day, + startHour, + startMinute, + endHour, + endMinute + ) + if (!existing) { + toCreate.push(body) + continue + } + if (slotNeedsUpdate(existing, startHour, startMinute, endHour, endMinute)) { + toUpdate.push({ id: existing.id, body }) + } + } + + try { + await Promise.all([ + ...toDelete.map(slot => deleteFixedWorkerSchdule(workspaceId, slot.id)), + ...toUpdate.map(({ id, body }) => + patchFixedWorkerSchdule(workspaceId, id, body) + ), + ]) + + if (toCreate.length > 0) { + await postFixedWorkerSchdules(workspaceId, { + workspaceWorkerId, + schedules: toCreate, + }) + } + } catch (error) { + if (onRollback) { + await onRollback() + } + throw error + } +} diff --git a/src/features/manager/worker-schedule/lib/saveGeneralWorkerSchedule.ts b/src/features/manager/worker-schedule/lib/saveGeneralWorkerSchedule.ts new file mode 100644 index 0000000..74098cd --- /dev/null +++ b/src/features/manager/worker-schedule/lib/saveGeneralWorkerSchedule.ts @@ -0,0 +1,78 @@ +import { fetchMonthlySchedules } from '@/features/manager/api/schedule' +import { + postAssignWorkerToSchedule, + postManagerWorkSchedule, + putManagerWorkSchedule, +} from '@/features/manager/worker-schedule/api/managerWorkSchedule' +import { toApiLocalDateTime } from '@/features/manager/worker-schedule/lib/scheduleDateTime' + +export async function saveGeneralWorkerSchedule(args: { + workspaceId: number + workerId: number + position: string + shiftId: number | null + date: Date + startHour: string + startMinute: string + endHour: string + endMinute: string +}): Promise { + const { + workspaceId, + workerId, + position, + shiftId, + date, + startHour, + startMinute, + endHour, + endMinute, + } = args + + const startDateTime = toApiLocalDateTime(date, startHour, startMinute) + let endDate = date + const startH = Number.parseInt(startHour || '0', 10) + const endH = Number.parseInt(endHour || '0', 10) + const startM = Number.parseInt(startMinute || '0', 10) + const endM = Number.parseInt(endMinute || '0', 10) + if (endH < startH || (endH === startH && endM <= startM)) { + endDate = new Date(date) + endDate.setDate(endDate.getDate() + 1) + } + const endDateTime = toApiLocalDateTime(endDate, endHour, endMinute) + + if (shiftId !== null) { + await putManagerWorkSchedule(shiftId, { + startDateTime, + endDateTime, + position, + }) + return shiftId + } + + await postManagerWorkSchedule({ + workspaceId, + startDateTime, + endDateTime, + position, + }) + + const year = date.getFullYear() + const month = date.getMonth() + 1 + const monthly = await fetchMonthlySchedules({ workspaceId, year, month }) + + const created = monthly.data.schedules.find( + s => + s.startDateTime === startDateTime && + s.endDateTime === endDateTime && + s.status.value === 'PLANNED' && + !s.assignedWorker + ) + + if (!created) { + return null + } + + await postAssignWorkerToSchedule(created.shiftId, { workerId }) + return created.shiftId +} diff --git a/src/features/manager/worker-schedule/lib/scheduleDateTime.ts b/src/features/manager/worker-schedule/lib/scheduleDateTime.ts new file mode 100644 index 0000000..756da6e --- /dev/null +++ b/src/features/manager/worker-schedule/lib/scheduleDateTime.ts @@ -0,0 +1,40 @@ +import { format } from 'date-fns' +import { splitClockToParts } from '@/shared/lib/clock' +import { snapMinuteToTen } from '@/shared/lib/snapMinuteToTen' + +/** UI 시·분 → API LocalTime (`HH:mm:ss`) */ +export function toApiLocalTime(hour: string, minute: string): string { + const { hour: h, minute: m } = splitClockToParts( + `${hour || '0'}:${snapMinuteToTen(minute)}` + ) + return `${h}:${m}:00` +} + +/** API LocalTime → UI 시·분 (두 자리) */ +export function fromApiLocalTime(time: string): { + hour: string + minute: string +} { + return splitClockToParts(time) +} + +/** 날짜 + 시·분 → API LocalDateTime (`yyyy-MM-dd'T'HH:mm:ss`) */ +export function toApiLocalDateTime( + date: Date, + hour: string, + minute: string +): string { + const { hour: h, minute: m } = splitClockToParts( + `${hour || '0'}:${snapMinuteToTen(minute)}` + ) + const datePart = format(date, 'yyyy-MM-dd') + return `${datePart}T${h}:${m}:00` +} + +export function dateTimeToHourMinute(iso: string): { + hour: string + minute: string +} { + const timePart = iso.includes('T') ? (iso.split('T')[1] ?? '') : iso + return splitClockToParts(timePart) +} diff --git a/src/features/manager/worker-schedule/lib/weekdayMapping.ts b/src/features/manager/worker-schedule/lib/weekdayMapping.ts new file mode 100644 index 0000000..d609f78 --- /dev/null +++ b/src/features/manager/worker-schedule/lib/weekdayMapping.ts @@ -0,0 +1,51 @@ +import type { ManagerWeekdayKo } from '@/features/manager/home/constants/managerWeekdayKo' +import { MANAGER_WEEKDAY_KO_ORDER } from '@/features/manager/home/constants/managerWeekdayKo' +import type { ManagerFixedScheduleWorkingDay } from '@/features/manager/home/types/workerFixedSchedule' + +const API_DAY_TO_KO: Record = + { + MONDAY: '월', + TUESDAY: '화', + WEDNESDAY: '수', + THURSDAY: '목', + FRIDAY: '금', + SATURDAY: '토', + SUNDAY: '일', + } + +const KO_TO_API: Record = { + 월: 'MONDAY', + 화: 'TUESDAY', + 수: 'WEDNESDAY', + 목: 'THURSDAY', + 금: 'FRIDAY', + 토: 'SATURDAY', + 일: 'SUNDAY', +} + +export function weekdayKoToApi( + ko: ManagerWeekdayKo +): ManagerFixedScheduleWorkingDay { + return KO_TO_API[ko] +} + +export function nextWeekdayKoToApi( + ko: ManagerWeekdayKo +): ManagerFixedScheduleWorkingDay { + const idx = MANAGER_WEEKDAY_KO_ORDER.indexOf(ko) + const nextKo = MANAGER_WEEKDAY_KO_ORDER[ + (idx + 1) % MANAGER_WEEKDAY_KO_ORDER.length + ] as ManagerWeekdayKo + return weekdayKoToApi(nextKo) +} + +export function weekdayApiToKo( + day: ManagerFixedScheduleWorkingDay +): ManagerWeekdayKo | null { + return API_DAY_TO_KO[day] ?? null +} + +export function sortWeekdaysKo(days: ManagerWeekdayKo[]): ManagerWeekdayKo[] { + const set = new Set(days) + return MANAGER_WEEKDAY_KO_ORDER.filter(d => set.has(d)) +} diff --git a/src/features/manager/worker-schedule/types/fixedWorkerSchdules.ts b/src/features/manager/worker-schedule/types/fixedWorkerSchdules.ts new file mode 100644 index 0000000..c16f7e6 --- /dev/null +++ b/src/features/manager/worker-schedule/types/fixedWorkerSchdules.ts @@ -0,0 +1,36 @@ +import type { CommonApiResponse } from '@/shared/types/common' +import type { ManagerFixedScheduleWorkingDay } from '@/features/manager/home/types/workerFixedSchedule' + +export type WorkspaceWorkerScheduleStatus = 'ACTIVATED' | 'DELETED' + +export interface FixedWorkerScheduleDto { + id: number + workspaceWorkerId: number + startDayOfWeek: ManagerFixedScheduleWorkingDay + startTime: string + endDayOfWeek: ManagerFixedScheduleWorkingDay + endTime: string + status: WorkspaceWorkerScheduleStatus +} + +export type ResponseGetFixedWorkerSchdules = CommonApiResponse< + FixedWorkerScheduleDto[] +> + +export type FixedWorkerScheduleSlotInput = { + startDayOfWeek: ManagerFixedScheduleWorkingDay + startTime: string + endDayOfWeek: ManagerFixedScheduleWorkingDay + endTime: string +} + +export type RequestPostFixedWorkerSchdules = { + workspaceWorkerId: number + schedules: FixedWorkerScheduleSlotInput[] +} + +export type ResponsePostFixedWorkerSchdules = CommonApiResponse + +export type ResponseDeleteFixedWorkerSchdules = CommonApiResponse + +export type RequestPatchFixedWorkerSchdules = FixedWorkerScheduleSlotInput diff --git a/src/features/manager/worker-schedule/types/managerWorkSchedule.ts b/src/features/manager/worker-schedule/types/managerWorkSchedule.ts new file mode 100644 index 0000000..61d3470 --- /dev/null +++ b/src/features/manager/worker-schedule/types/managerWorkSchedule.ts @@ -0,0 +1,16 @@ +export type CreateWorkScheduleRequest = { + workspaceId: number + startDateTime: string + endDateTime: string + position: string +} + +export type UpdateWorkScheduleRequest = { + startDateTime: string + endDateTime: string + position: string +} + +export type AssignWorkerToScheduleRequest = { + workerId: number +} diff --git a/src/features/manager/worker-schedule/types/workerColor.ts b/src/features/manager/worker-schedule/types/workerColor.ts new file mode 100644 index 0000000..daa3fe3 --- /dev/null +++ b/src/features/manager/worker-schedule/types/workerColor.ts @@ -0,0 +1,34 @@ +import { ScheduleColor } from '@/features/manager/worker-schedule/types/scheduleColor' + +/** API 기본 근무자 색상 — 업장 내 중복 허용 */ +export const WORKER_DEFAULT_COLOR_CODE = '#9CA3AF' + +export type UpdateWorkspaceWorkerColorRequest = { + colorCode: string +} + +const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/ + +export function isValidWorkerColorCode(colorCode: string): boolean { + return HEX_COLOR.test(colorCode) +} + +/** 서버 `colorCode` → 색상 선택 UI 팔레트 값 */ +export function resolveSchedulePickerColor(colorCode: string): ScheduleColor { + const upper = colorCode.trim().toUpperCase() + const palette = Object.values(ScheduleColor) as string[] + const match = palette.find(c => c.toUpperCase() === upper) + if (match) return match as ScheduleColor + if (upper === WORKER_DEFAULT_COLOR_CODE.toUpperCase()) { + return ScheduleColor.Gray + } + return ScheduleColor.Pink +} + +export function normalizeColorCodeForApi(color: ScheduleColor): string { + return color.toUpperCase() +} + +export function colorCodesEqual(a: string, b: string): boolean { + return a.trim().toUpperCase() === b.trim().toUpperCase() +} diff --git a/src/pages/manager/home/index.tsx b/src/pages/manager/home/index.tsx index 0b2be3b..14010b9 100644 --- a/src/pages/manager/home/index.tsx +++ b/src/pages/manager/home/index.tsx @@ -1,7 +1,9 @@ +import { useEffect, useRef } from 'react' import { format } from 'date-fns' import { ko } from 'date-fns/locale' import { Navbar } from '@/shared/ui/common/Navbar' -import { useNavigate } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' +import type { ManagerHomeLocationState } from '@/features/manager/home/types/managerHomeLocationState' import { TodayWorkerList, StoreWorkerListItem, @@ -20,8 +22,17 @@ import managerScheduleEditIcon from '@/assets/icons/home/edit.svg' import { shouldShowInfiniteListLoadMore } from '@/shared/lib/listLoadMoreVisibility' import { ROUTES, managerWorkerSchedulePath } from '@/shared/constants/routes' +const STORE_WORKERS_SECTION_ID = 'manager-store-workers' +const SCHEDULE_SAVE_SUCCESS_MESSAGE = '스케줄이 성공적으로 저장되었습니다.' + export function ManagerHomePage() { const navigate = useNavigate() + const location = useLocation() + const scheduleSaveScrollHandled = useRef(false) + + const navigationState = location.state as ManagerHomeLocationState | null + const showScheduleSaveSuccess = + navigationState?.workerScheduleSaveSuccess === true const { todayWorkers, storeWorkers, @@ -46,10 +57,30 @@ export function ManagerHomePage() { selectWorkspace, } = useManagerHomeViewModel() + useEffect(() => { + if (!showScheduleSaveSuccess || scheduleSaveScrollHandled.current) return + + scheduleSaveScrollHandled.current = true + requestAnimationFrame(() => { + document + .getElementById(STORE_WORKERS_SECTION_ID) + ?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }) + }, [showScheduleSaveSuccess]) + return (
+ {showScheduleSaveSuccess ? ( +

+ {SCHEDULE_SAVE_SUCCESS_MESSAGE} +

+ ) : null} +
-
+

우리 매장 근무자

diff --git a/src/pages/manager/worker-schedule/components/CollapsibleScheduleSection.tsx b/src/pages/manager/worker-schedule/components/CollapsibleScheduleSection.tsx new file mode 100644 index 0000000..422d286 --- /dev/null +++ b/src/pages/manager/worker-schedule/components/CollapsibleScheduleSection.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from 'react' +import chevronDownIcon from '@/assets/icons/home/chevron-down.svg' +import { cn } from '@/shared/lib/utils' + +interface CollapsibleScheduleSectionProps { + title: string + icon?: ReactNode + isOpen: boolean + onToggle: () => void + children: ReactNode + className?: string +} + +export function CollapsibleScheduleSection({ + title, + icon, + isOpen, + onToggle, + children, + className, +}: CollapsibleScheduleSectionProps) { + return ( +
+ + {isOpen ? children : null} +
+ ) +} diff --git a/src/pages/manager/worker-schedule/components/ColorSelectSection.tsx b/src/pages/manager/worker-schedule/components/ColorSelectSection.tsx new file mode 100644 index 0000000..b9d456e --- /dev/null +++ b/src/pages/manager/worker-schedule/components/ColorSelectSection.tsx @@ -0,0 +1,76 @@ +import chevronDownIcon from '@/assets/icons/home/chevron-down.svg' +import { ScheduleColor } from '@/features/manager' +import { cn } from '@/shared/lib/utils' + +interface ColorSelectSectionProps { + selectedColor: ScheduleColor + onColorChange: (color: ScheduleColor) => void + isOpen: boolean + onToggle: () => void +} + +const COLOR_GRID_PADDING = 'px-[30px] pt-[29px] pb-[29px]' + +export function ColorSelectSection({ + selectedColor, + onColorChange, + isOpen, + onToggle, +}: ColorSelectSectionProps) { + const colors = Object.values(ScheduleColor) + + return ( +
+ + {isOpen ? ( +
+ {colors.map(color => ( + + ))} +
+ ) : null} +
+ ) +} diff --git a/src/pages/manager/worker-schedule/components/FixedScheduleDateSection.tsx b/src/pages/manager/worker-schedule/components/FixedScheduleDateSection.tsx new file mode 100644 index 0000000..6eb50bc --- /dev/null +++ b/src/pages/manager/worker-schedule/components/FixedScheduleDateSection.tsx @@ -0,0 +1,133 @@ +import { useState } from 'react' +import calendarIcon from '@/assets/icons/schedule/schedule_calendar.svg' +import { CollapsibleScheduleSection } from '@/pages/manager/worker-schedule/components/CollapsibleScheduleSection' +import { RecurrenceSelect } from '@/pages/manager/worker-schedule/components/RecurrenceSelect' +import type { ScheduleRecurrence } from '@/pages/manager/worker-schedule/components/RecurrenceSelect' +import { ScheduleDateDisplayRow } from '@/pages/manager/worker-schedule/components/ScheduleDateDisplayRow' +import { ScheduleDatePickerDrawer } from '@/pages/manager/worker-schedule/components/ScheduleDatePickerDrawer' +import { WeekdayPicker } from '@/pages/manager/worker-schedule/components/WeekdayPicker' +import { WorkTimeRangeField } from '@/pages/manager/worker-schedule/components/WorkTimeRangeField' +import type { WorkTimeEditorState } from '@/pages/manager/worker-schedule/types/workTime' + +type DatePickerTarget = 'start' | 'end' | null + +interface FixedScheduleDateSectionProps { + isOpen: boolean + onToggle: () => void + workdayOptions: readonly string[] + selectedDays: string[] + onToggleDay: (day: string) => void + recurrence: ScheduleRecurrence + onRecurrenceChange: (value: ScheduleRecurrence) => void + startDate: Date + endDate: Date + onStartDateChange: (date: Date) => void + onEndDateChange: (date: Date) => void + workTime: WorkTimeEditorState + fixedScheduleLoading?: boolean +} + +export function FixedScheduleDateSection({ + isOpen, + onToggle, + workdayOptions, + selectedDays, + onToggleDay, + recurrence, + onRecurrenceChange, + startDate, + endDate, + onStartDateChange, + onEndDateChange, + workTime, + fixedScheduleLoading, +}: FixedScheduleDateSectionProps) { + const [datePickerTarget, setDatePickerTarget] = + useState(null) + + const pickerOpen = datePickerTarget !== null + const pickerDate = datePickerTarget === 'end' ? endDate : startDate + const isWeekly = recurrence === '매주' + + return ( + <> + + + { + if (!open) setDatePickerTarget(null) + }} + selectedDate={pickerDate} + onDateChange={date => { + if (datePickerTarget === 'end') { + onEndDateChange(date) + } else { + onStartDateChange(date) + } + }} + /> + + ) +} diff --git a/src/pages/manager/worker-schedule/components/GeneralScheduleDateSection.tsx b/src/pages/manager/worker-schedule/components/GeneralScheduleDateSection.tsx new file mode 100644 index 0000000..fff95e3 --- /dev/null +++ b/src/pages/manager/worker-schedule/components/GeneralScheduleDateSection.tsx @@ -0,0 +1,47 @@ +import calendarIcon from '@/assets/icons/schedule/schedule_calendar.svg' +import { CollapsibleScheduleSection } from '@/pages/manager/worker-schedule/components/CollapsibleScheduleSection' +import { WorkTimeRangeField } from '@/pages/manager/worker-schedule/components/WorkTimeRangeField' +import type { WorkTimeEditorState } from '@/pages/manager/worker-schedule/types/workTime' +import { ManagerMonthCalendar } from '@/shared/ui/schedule/ManagerMonthCalendar' + +interface GeneralScheduleDateSectionProps { + isOpen: boolean + onToggle: () => void + selectedDate: Date | null + onDateChange: (date: Date) => void + workTime: WorkTimeEditorState +} + +export function GeneralScheduleDateSection({ + isOpen, + onToggle, + selectedDate, + onDateChange, + workTime, +}: GeneralScheduleDateSectionProps) { + return ( + + ) +} diff --git a/src/pages/manager/worker-schedule/components/RecurrenceSelect.tsx b/src/pages/manager/worker-schedule/components/RecurrenceSelect.tsx new file mode 100644 index 0000000..27871bf --- /dev/null +++ b/src/pages/manager/worker-schedule/components/RecurrenceSelect.tsx @@ -0,0 +1,45 @@ +import { Fragment } from 'react' +import { cn } from '@/shared/lib/utils' + +export type ScheduleRecurrence = '매주' | '매월' + +const RECURRENCE_OPTIONS: ScheduleRecurrence[] = ['매주', '매월'] + +interface RecurrenceSelectProps { + value: ScheduleRecurrence + onChange: (value: ScheduleRecurrence) => void +} + +export function RecurrenceSelect({ value, onChange }: RecurrenceSelectProps) { + return ( +
+ {RECURRENCE_OPTIONS.map((option, index) => ( + + {index > 0 ? ( + + ) : null} + + + ))} +
+ ) +} diff --git a/src/pages/manager/worker-schedule/components/ScheduleDateDisplayRow.tsx b/src/pages/manager/worker-schedule/components/ScheduleDateDisplayRow.tsx new file mode 100644 index 0000000..158a1c3 --- /dev/null +++ b/src/pages/manager/worker-schedule/components/ScheduleDateDisplayRow.tsx @@ -0,0 +1,26 @@ +import { formatKoreanScheduleDate } from '@/pages/manager/worker-schedule/lib/scheduleDateParts' + +interface ScheduleDateDisplayRowProps { + label: string + date: Date + onPress: () => void +} + +export function ScheduleDateDisplayRow({ + label, + date, + onPress, +}: ScheduleDateDisplayRowProps) { + return ( + + ) +} diff --git a/src/pages/manager/worker-schedule/components/ScheduleDatePickerDrawer.tsx b/src/pages/manager/worker-schedule/components/ScheduleDatePickerDrawer.tsx new file mode 100644 index 0000000..f9688eb --- /dev/null +++ b/src/pages/manager/worker-schedule/components/ScheduleDatePickerDrawer.tsx @@ -0,0 +1,41 @@ +import { Drawer } from 'vaul' +import { ManagerMonthCalendar } from '@/shared/ui/schedule/ManagerMonthCalendar' + +interface ScheduleDatePickerDrawerProps { + open: boolean + onOpenChange: (open: boolean) => void + selectedDate: Date + onDateChange: (date: Date) => void +} + +export function ScheduleDatePickerDrawer({ + open, + onOpenChange, + selectedDate, + onDateChange, +}: ScheduleDatePickerDrawerProps) { + const handleDateChange = (date: Date) => { + onDateChange(date) + onOpenChange(false) + } + + return ( + + + + + + + + + ) +} diff --git a/src/pages/manager/worker-schedule/components/ScheduleSaveButton.tsx b/src/pages/manager/worker-schedule/components/ScheduleSaveButton.tsx new file mode 100644 index 0000000..b2340f9 --- /dev/null +++ b/src/pages/manager/worker-schedule/components/ScheduleSaveButton.tsx @@ -0,0 +1,20 @@ +interface ScheduleSaveButtonProps { + onClick?: () => void + disabled?: boolean +} + +export function ScheduleSaveButton({ + onClick, + disabled, +}: ScheduleSaveButtonProps) { + return ( + + ) +} diff --git a/src/pages/manager/worker-schedule/components/ScheduleTabBar.tsx b/src/pages/manager/worker-schedule/components/ScheduleTabBar.tsx new file mode 100644 index 0000000..d3fe462 --- /dev/null +++ b/src/pages/manager/worker-schedule/components/ScheduleTabBar.tsx @@ -0,0 +1,36 @@ +import type { ScheduleTab } from '@/features/manager' +import { SCHEDULE_TABS } from '@/features/manager' +import { cn } from '@/shared/lib/utils' + +interface ScheduleTabBarProps { + activeTab: ScheduleTab + onTabChange: (tab: ScheduleTab) => void +} + +export function ScheduleTabBar({ + activeTab, + onTabChange, +}: ScheduleTabBarProps) { + return ( +
+ {SCHEDULE_TABS.map(tab => { + const isActive = activeTab === tab + return ( + + ) + })} +
+ ) +} diff --git a/src/pages/manager/worker-schedule/components/WeekdayPicker.tsx b/src/pages/manager/worker-schedule/components/WeekdayPicker.tsx new file mode 100644 index 0000000..051d7c8 --- /dev/null +++ b/src/pages/manager/worker-schedule/components/WeekdayPicker.tsx @@ -0,0 +1,37 @@ +import { cn } from '@/shared/lib/utils' + +interface WeekdayPickerProps { + options: readonly string[] + selectedDays: string[] + onToggleDay: (day: string) => void +} + +export function WeekdayPicker({ + options, + selectedDays, + onToggleDay, +}: WeekdayPickerProps) { + return ( +
+ {options.map(day => { + const selected = selectedDays.includes(day) + return ( + + ) + })} +
+ ) +} diff --git a/src/pages/manager/worker-schedule/components/WheelPicker.tsx b/src/pages/manager/worker-schedule/components/WheelPicker.tsx new file mode 100644 index 0000000..5457300 --- /dev/null +++ b/src/pages/manager/worker-schedule/components/WheelPicker.tsx @@ -0,0 +1,140 @@ +import { useRef, useState } from 'react' +import { cn } from '@/shared/lib/utils' + +const DEFAULT_ITEM_HEIGHT = 52 +const DEFAULT_VISIBLE = 5 + +export interface WheelPickerProps { + items: readonly string[] + selectedIndex: number + onChange: (index: number) => void + itemHeight?: number + visibleCount?: number + className?: string + 'aria-label'?: string +} + +export function WheelPicker({ + items, + selectedIndex, + onChange, + itemHeight = DEFAULT_ITEM_HEIGHT, + visibleCount = DEFAULT_VISIBLE, + className, + 'aria-label': ariaLabel, +}: WheelPickerProps) { + const center = Math.floor(visibleCount / 2) + const [dragDelta, setDragDelta] = useState(0) + const [isDragging, setIsDragging] = useState(false) + const startY = useRef(0) + + const baseTranslate = (center - selectedIndex) * itemHeight + const rawTranslate = baseTranslate + dragDelta + const minTranslate = (center - (items.length - 1)) * itemHeight + const maxTranslate = center * itemHeight + const currentTranslate = Math.max( + minTranslate, + Math.min(maxTranslate, rawTranslate) + ) + + /** 드래그 중에는 중앙 슬롯에 온 항목을 선택 스타일로 표시 */ + const highlightedIndex = Math.max( + 0, + Math.min( + items.length - 1, + Math.round(selectedIndex - dragDelta / itemHeight) + ) + ) + + const handlePointerDown = (e: React.PointerEvent) => { + setIsDragging(true) + startY.current = e.clientY + setDragDelta(0) + e.currentTarget.setPointerCapture(e.pointerId) + } + + const handlePointerMove = (e: React.PointerEvent) => { + if (!isDragging) return + setDragDelta(e.clientY - startY.current) + } + + const handlePointerUp = (e: React.PointerEvent) => { + if (!isDragging) return + setIsDragging(false) + const totalDelta = e.clientY - startY.current + const rawIndex = selectedIndex - totalDelta / itemHeight + const snapped = Math.round( + Math.max(0, Math.min(items.length - 1, rawIndex)) + ) + setDragDelta(0) + onChange(snapped) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowUp') { + e.preventDefault() + e.stopPropagation() + setDragDelta(0) + onChange(Math.max(0, selectedIndex - 1)) + } else if (e.key === 'ArrowDown') { + e.preventDefault() + e.stopPropagation() + setDragDelta(0) + onChange(Math.min(items.length - 1, selectedIndex + 1)) + } + } + + return ( +
+
+
+ {items.map((item, i) => ( +
+ {item} +
+ ))} +
+
+
+
+ ) +} diff --git a/src/pages/manager/worker-schedule/components/WorkTimePickerDrawer.tsx b/src/pages/manager/worker-schedule/components/WorkTimePickerDrawer.tsx new file mode 100644 index 0000000..37a4dab --- /dev/null +++ b/src/pages/manager/worker-schedule/components/WorkTimePickerDrawer.tsx @@ -0,0 +1,107 @@ +import { Drawer } from 'vaul' +import { WheelPicker } from '@/pages/manager/worker-schedule/components/WheelPicker' +import { + hour24To12Parts, + partsToHour24, + snapMinuteToTen, + minuteToTenMinuteIndex, + WORK_TIME_MINUTE_OPTIONS, + type TimePeriod, +} from '@/pages/manager/worker-schedule/lib/formatKoreanWorkTime' +import type { WorkTimeEditorState } from '@/pages/manager/worker-schedule/types/workTime' + +const PERIOD_ITEMS = ['오전', '오후'] as const +const HOUR_ITEMS = Array.from({ length: 12 }, (_, i) => `${i + 1}시`) +const MINUTE_ITEMS = WORK_TIME_MINUTE_OPTIONS.map(m => `${m}분`) + +type TimeTarget = 'start' | 'end' + +interface WorkTimePickerDrawerProps { + open: boolean + target: TimeTarget | null + workTime: WorkTimeEditorState + onOpenChange: (open: boolean) => void +} + +export function WorkTimePickerDrawer({ + open, + target, + workTime, + onOpenChange, +}: WorkTimePickerDrawerProps) { + if (!open || !target) return null + + const hour = target === 'start' ? workTime.startHour : workTime.endHour + const minute = target === 'start' ? workTime.startMinute : workTime.endMinute + const setHour = + target === 'start' ? workTime.setStartHour : workTime.setEndHour + const setMinute = + target === 'start' ? workTime.setStartMinute : workTime.setEndMinute + + const { period, hour12 } = hour24To12Parts(hour) + const periodIndex = period === '오후' ? 1 : 0 + const hourIndex = Math.min(11, Math.max(0, hour12 - 1)) + const minuteIndex = Math.max(0, minuteToTenMinuteIndex(minute)) + + const applyPeriod = (index: number) => { + const nextPeriod: TimePeriod = index === 1 ? '오후' : '오전' + setHour(partsToHour24(nextPeriod, hour12)) + } + + const applyHour = (index: number) => { + setHour(partsToHour24(period, index + 1)) + } + + const applyMinute = (index: number) => { + setMinute(snapMinuteToTen(WORK_TIME_MINUTE_OPTIONS[index] ?? '00')) + } + + return ( + + + + +

+ 근무 시간 선택 +

+ +
+ + + + +
+
+
+
+ ) +} diff --git a/src/pages/manager/worker-schedule/components/WorkTimeRangeField.tsx b/src/pages/manager/worker-schedule/components/WorkTimeRangeField.tsx new file mode 100644 index 0000000..c75a3b6 --- /dev/null +++ b/src/pages/manager/worker-schedule/components/WorkTimeRangeField.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react' +import { formatKoreanTimePart } from '@/pages/manager/worker-schedule/lib/formatKoreanWorkTime' +import { WorkTimePickerDrawer } from '@/pages/manager/worker-schedule/components/WorkTimePickerDrawer' +import type { WorkTimeEditorState } from '@/pages/manager/worker-schedule/types/workTime' +import { cn } from '@/shared/lib/utils' + +type EditTarget = 'start' | 'end' + +interface WorkTimeRangeFieldProps { + workTime: WorkTimeEditorState + className?: string +} + +export function WorkTimeRangeField({ + workTime, + className, +}: WorkTimeRangeFieldProps) { + const [pickerTarget, setPickerTarget] = useState(null) + + const startLabel = formatKoreanTimePart( + workTime.startHour, + workTime.startMinute + ) + const endLabel = formatKoreanTimePart(workTime.endHour, workTime.endMinute) + + return ( + <> +
+

근무 시간

+ +
+ + + +
+
+ + { + if (!open) setPickerTarget(null) + }} + /> + + ) +} diff --git a/src/pages/manager/worker-schedule/components/WorkerSelectSection.tsx b/src/pages/manager/worker-schedule/components/WorkerSelectSection.tsx new file mode 100644 index 0000000..d9a0b6e --- /dev/null +++ b/src/pages/manager/worker-schedule/components/WorkerSelectSection.tsx @@ -0,0 +1,121 @@ +import { WorkerRoleBadge } from '@/shared/ui/home/WorkerRoleBadge' +import chevronDownIcon from '@/assets/icons/home/chevron-down.svg' +import type { ManagerWorkerItem } from '@/features/manager/home/types/worker' +import { cn } from '@/shared/lib/utils' + +interface WorkerSelectSectionProps { + worker: ManagerWorkerItem + workers: ManagerWorkerItem[] + isOpen: boolean + onToggle: () => void + onSelectWorker: (workerId: number) => void +} + +function WorkerAvatar() { + return ( +