diff --git a/package-lock.json b/package-lock.json index 516104af..69f22928 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@storybook/addon-docs": "10.3.6", "@storybook/addon-vitest": "10.3.6", "@storybook/react-vite": "10.3.6", + "@tanstack/react-query-devtools": "^5.101.1", "@types/node": "^24.10.1", "@types/react": "^18.3.27", "@types/react-dom": "^18.3.7", @@ -3036,9 +3037,20 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.90.20", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", - "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "version": "5.101.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz", + "integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.101.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.101.1.tgz", + "integrity": "sha512-37RQ9U2PxlXQiv1era2t+uHgVhmiyvxqTMu30+KoVf0rufiucu6rpGRKFJk61Wh5OAZFKqCQd6lxTzFWfLZiuQ==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -3046,18 +3058,36 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.21", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", - "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "version": "5.101.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz", + "integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.101.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.101.1.tgz", + "integrity": "sha512-OXFR9XKdEslraq3cpl3kCUeNvTIq/xGWEZiFZdn2bLB/q4WxSALMEDKYZ5yYjMQytsfnQxwQYqV4qtVEf0nuog==", + "dev": true, "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.90.20" + "@tanstack/query-devtools": "5.101.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { + "@tanstack/react-query": "^5.101.1", "react": "^18 || ^19" } }, diff --git a/package.json b/package.json index 52341968..5b1f3fe3 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@storybook/addon-docs": "10.3.6", "@storybook/addon-vitest": "10.3.6", "@storybook/react-vite": "10.3.6", + "@tanstack/react-query-devtools": "^5.101.1", "@types/node": "^24.10.1", "@types/react": "^18.3.27", "@types/react-dom": "^18.3.7", diff --git a/src/app/providers/index.tsx b/src/app/providers/index.tsx index cfe90e2d..cda14c6a 100644 --- a/src/app/providers/index.tsx +++ b/src/app/providers/index.tsx @@ -1,5 +1,6 @@ import { useEffect, type ReactNode } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { initKakaoSDK, initAppleSDK } from '@/shared/lib/socialLogin' const queryClient = new QueryClient() @@ -55,6 +56,14 @@ export function AppProviders({ children }: AppProvidersProps) { }, []) return ( - {children} + + {children} + {import.meta.env.DEV ? ( + + ) : null} + ) } diff --git a/src/features/manager/api/substitute.ts b/src/features/manager/api/substitute.ts index 709e7047..6dc3d23b 100644 --- a/src/features/manager/api/substitute.ts +++ b/src/features/manager/api/substitute.ts @@ -6,11 +6,15 @@ import type { export async function approveSubstituteRequest( requestId: number, - body: { approvalComment: string } + body: { approvalComment?: string } = {} ): Promise { + const payload = + body.approvalComment != null && body.approvalComment.trim() !== '' + ? { approvalComment: body.approvalComment.trim() } + : {} await axiosInstance.post( `/manager/substitute-requests/${requestId}/approve`, - body + payload ) } @@ -27,6 +31,15 @@ export async function rejectSubstituteRequest( export async function fetchSubstituteRequests( params: SubstituteRequestsQueryParams ): Promise { + const status = + params.status == null + ? undefined + : Array.isArray(params.status) + ? params.status.length > 0 + ? params.status + : undefined + : params.status + const response = await axiosInstance.get( '/manager/substitute-requests', { @@ -35,7 +48,7 @@ export async function fetchSubstituteRequests( ...(params.workspaceId !== undefined && { workspaceId: params.workspaceId, }), - ...(params.status && { status: params.status }), + ...(status != null && { status }), ...(params.cursor !== undefined && { cursor: params.cursor }), }, } diff --git a/src/features/manager/home/hooks/useSubstituteRequestsViewModel.ts b/src/features/manager/home/hooks/useSubstituteRequestsViewModel.ts index 14ad5210..f9d58b0d 100644 --- a/src/features/manager/home/hooks/useSubstituteRequestsViewModel.ts +++ b/src/features/manager/home/hooks/useSubstituteRequestsViewModel.ts @@ -2,13 +2,18 @@ import { useMemo } from 'react' import { useInfiniteQuery } from '@tanstack/react-query' import { fetchSubstituteRequests } from '@/features/manager/api/substitute' import { adaptSubstituteRequestDto } from '@/features/manager/home/types/substitute' +import { resolveManagerApiStatuses } from '@/features/manager/substitute/lib/managerSubstituteListFilters' +import type { SubstituteListStatusFilter } from '@/features/user/substitute/lib/substituteListFilters' import { queryKeys } from '@/shared/lib/queryKeys' export function useSubstituteRequestsViewModel( workspaceId: number | null, - params?: { status?: string }, + params?: { statusFilter?: SubstituteListStatusFilter }, pageSize = 10 ) { + const statusFilter = params?.statusFilter ?? 'all' + const apiStatuses = resolveManagerApiStatuses(statusFilter) + const { data, fetchNextPage, @@ -19,14 +24,14 @@ export function useSubstituteRequestsViewModel( } = useInfiniteQuery({ queryKey: queryKeys.substitute.list({ workspaceId: workspaceId ?? undefined, - status: params?.status, + statusFilter, pageSize, }), queryFn: ({ pageParam }) => fetchSubstituteRequests({ pageSize, workspaceId: workspaceId ?? undefined, - status: params?.status, + status: apiStatuses.length > 0 ? apiStatuses : undefined, cursor: pageParam as string | undefined, }), initialPageParam: undefined as string | undefined, diff --git a/src/features/manager/home/types/substitute.ts b/src/features/manager/home/types/substitute.ts index 09455e62..c5e54b4d 100644 --- a/src/features/manager/home/types/substitute.ts +++ b/src/features/manager/home/types/substitute.ts @@ -51,7 +51,7 @@ export type SubstituteListApiResponse = CommonApiResponse<{ // ---- Query Params ---- export interface SubstituteRequestsQueryParams { workspaceId?: number - status?: string + status?: string | string[] cursor?: string pageSize: number } diff --git a/src/features/manager/substitute/hooks/useManagerSubstituteRequestViewModel.ts b/src/features/manager/substitute/hooks/useManagerSubstituteRequestViewModel.ts index 328d6811..a0a95eb4 100644 --- a/src/features/manager/substitute/hooks/useManagerSubstituteRequestViewModel.ts +++ b/src/features/manager/substitute/hooks/useManagerSubstituteRequestViewModel.ts @@ -7,10 +7,11 @@ import { approveSubstituteRequest, rejectSubstituteRequest, } from '@/features/manager/api/substitute' +import type { ManagerSubstituteListFilters } from '@/features/manager/substitute/lib/managerSubstituteListFilters' +import type { SubstituteListStatusFilter } from '@/features/user/substitute/lib/substituteListFilters' import type { SubstituteRequestItem } from '@/shared/types/substituteRequest' import type { SubstituteActionType } from '@/pages/manager/substitute-request/components/ManagerSubstituteActionModal' import { SubstituteApiStatus } from '@/shared/types/substituteStatus' -import { queryKeys } from '@/shared/lib/queryKeys' const SUBSTITUTE_ACTION_ERROR_MESSAGES: Record = { B001: '이미 처리되었거나 승인/거절할 수 없는 상태입니다.', @@ -30,8 +31,28 @@ function getSubstituteActionErrorMessage(error: unknown): string { type ActionTarget = { id: number; type: SubstituteActionType } +export type ManagerSubstituteSectionKey = 'pending' | 'accepted' | 'cancelled' + +export type ManagerSubstituteSection = { + key: ManagerSubstituteSectionKey + title: string + items: SubstituteRequestItem[] +} + const EMPTY_GROUPS = { pending: [], accepted: [], cancelled: [] } +const SECTION_TITLE: Record = { + pending: '요청됨', + accepted: '수락됨', + cancelled: '취소됨', +} + +const SECTION_ORDER: ManagerSubstituteSectionKey[] = [ + 'pending', + 'accepted', + 'cancelled', +] + // ACCEPTED = 워커가 수락해 사장 승인 대기 중 → 요청됨 // APPROVED = 사장이 승인 → 수락됨 // REJECTED_BY_APPROVER = 사장이 거절 → 취소됨 @@ -53,24 +74,49 @@ function groupByStatus(requests: SubstituteRequestItem[]) { return { pending, accepted, cancelled } } -export function useManagerSubstituteRequestViewModel() { +function buildSections( + groups: ReturnType, + statusFilter: SubstituteListStatusFilter +): ManagerSubstituteSection[] { + const allSections = SECTION_ORDER.map(key => ({ + key, + title: SECTION_TITLE[key], + items: groups[key], + })) + + if (statusFilter !== 'all') { + return allSections.filter( + section => section.key === statusFilter && section.items.length > 0 + ) + } + + return allSections.filter(section => section.items.length > 0) +} + +export function useManagerSubstituteRequestViewModel( + filters: ManagerSubstituteListFilters = { statusFilter: 'all' } +) { const queryClient = useQueryClient() const { activeWorkspaceId } = useManagedWorkspacesQuery() - const { requests, isLoading, isError } = - useSubstituteRequestsViewModel(activeWorkspaceId) + const { statusFilter } = filters + const { requests, isLoading, isError } = useSubstituteRequestsViewModel( + activeWorkspaceId, + { statusFilter } + ) const [actionTarget, setActionTarget] = useState(null) const [actionError, setActionError] = useState(null) const invalidate = () => queryClient.invalidateQueries({ - queryKey: queryKeys.substitute.list({ - workspaceId: activeWorkspaceId ?? undefined, - }), + queryKey: ['substitute', 'list'], }) const approveMutation = useMutation({ mutationFn: ({ id, comment }: { id: number; comment: string }) => - approveSubstituteRequest(id, { approvalComment: comment }), + approveSubstituteRequest( + id, + comment !== '' ? { approvalComment: comment } : {} + ), onSuccess: async () => { await invalidate() setActionTarget(null) @@ -94,10 +140,10 @@ export function useManagerSubstituteRequestViewModel() { }, }) - const { pending, accepted, cancelled } = useMemo( - () => groupByStatus(requests), - [requests] - ) + const sections = useMemo(() => { + const groups = groupByStatus(requests) + return buildSections(groups, statusFilter) + }, [requests, statusFilter]) const handleModalSubmit = (comment: string) => { if (actionTarget === null) return @@ -117,10 +163,8 @@ export function useManagerSubstituteRequestViewModel() { return { isLoading, isError, - isEmpty: requests.length === 0, - pending, - accepted, - cancelled, + isEmpty: sections.length === 0, + sections, actionsDisabled: approveMutation.isPending || rejectMutation.isPending, actionModal: { open: actionTarget !== null, diff --git a/src/features/manager/substitute/lib/managerSubstituteListFilters.ts b/src/features/manager/substitute/lib/managerSubstituteListFilters.ts new file mode 100644 index 00000000..2f64cd07 --- /dev/null +++ b/src/features/manager/substitute/lib/managerSubstituteListFilters.ts @@ -0,0 +1,23 @@ +import type { SubstituteListStatusFilter } from '@/features/user/substitute/lib/substituteListFilters' +import { SubstituteApiStatus } from '@/shared/types/substituteStatus' + +export type ManagerSubstituteListFilters = { + statusFilter: SubstituteListStatusFilter +} + +/** 매니저 UI 필터 → API status (G5 기준) */ +export const MANAGER_FILTER_TO_API_STATUS: Record< + SubstituteListStatusFilter, + SubstituteApiStatus[] +> = { + all: [], + pending: [SubstituteApiStatus.ACCEPTED], + accepted: [SubstituteApiStatus.APPROVED], + cancelled: [SubstituteApiStatus.REJECTED_BY_APPROVER], +} + +export function resolveManagerApiStatuses( + filter: SubstituteListStatusFilter +): SubstituteApiStatus[] { + return MANAGER_FILTER_TO_API_STATUS[filter] +} diff --git a/src/features/user/home/workspace/hooks/useSubstituteRequestFlow.ts b/src/features/user/home/workspace/hooks/useSubstituteRequestFlow.ts index d439effe..e2404276 100644 --- a/src/features/user/home/workspace/hooks/useSubstituteRequestFlow.ts +++ b/src/features/user/home/workspace/hooks/useSubstituteRequestFlow.ts @@ -2,7 +2,10 @@ 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 type { + CalendarEvent, + CalendarViewData, +} from '@/features/home/common/schedule/types/calendarView' import { DATE_KEY_FORMAT } from '@/features/home/common/schedule/constants/calendar' import { adaptExchangeableSchedulesToCalendar, @@ -11,6 +14,7 @@ import { import { getExchangeableWorkers } from '@/features/user/home/workspace/api/exchangeableWorkers' import { createSubstituteRequest } from '@/features/user/home/workspace/api/substituteRequests' import { WEEKDAY_LABELS } from '@/shared/constants/calendar' +import { splitClockToParts } from '@/shared/lib/clock' import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage' import { queryKeys } from '@/shared/lib/queryKeys' @@ -44,10 +48,10 @@ function workerIdFromCandidateKey(key: string): number | undefined { return Number.isFinite(id) ? id : undefined } -function pickScheduleIdForSelectedDate( +function pickSelectedScheduleEvent( calendarData: CalendarViewData | null | undefined, selected: Date | null -): number | null { +): CalendarEvent | 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) @@ -56,12 +60,21 @@ function pickScheduleIdForSelectedDate( (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 + return sameDay[0] ?? null +} + +function scheduleTimeParts(event: CalendarEvent | null) { + if (event == null) { + return { startHour: '--', startMin: '--', endHour: '--', endMin: '--' } + } + const start = splitClockToParts(event.startTimeLabel) + const end = splitClockToParts(event.endTimeLabel) + return { + startHour: start.hour, + startMin: start.minute, + endHour: end.hour, + endMin: end.minute, + } } interface UseSubstituteRequestFlowParams { @@ -95,10 +108,6 @@ export function useSubstituteRequestFlow({ 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()) @@ -134,24 +143,32 @@ export function useSubstituteRequestFlow({ return calendarData ?? null }, [workspaceId, exchangeableSchedulesResponse, calendarData]) + const selectedScheduleEvent = useMemo( + () => pickSelectedScheduleEvent(resolvedCalendarData, selectedCalendarDate), + [resolvedCalendarData, selectedCalendarDate] + ) + + const { startHour, startMin, endHour, endMin } = useMemo( + () => scheduleTimeParts(selectedScheduleEvent), + [selectedScheduleEvent] + ) + const selectedWeekdayLabel = useMemo(() => { if (selectedCalendarDate == null) return null return WEEKDAY_LABELS[selectedCalendarDate.getDay()] }, [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(resolvedCalendarData, selectedCalendarDate), - [resolvedCalendarData, selectedCalendarDate] - ) + if (selectedScheduleEvent == null) return '—' + return `${selectedScheduleEvent.startTimeLabel} ~ ${selectedScheduleEvent.endTimeLabel}` + }, [selectedScheduleEvent]) + + const substituteScheduleId = useMemo(() => { + const shiftId = selectedScheduleEvent?.shiftId + return shiftId != null && Number.isFinite(shiftId) ? shiftId : null + }, [selectedScheduleEvent]) + + const hasSelectedSchedule = selectedScheduleEvent != null const { data: exchangeableResponse, @@ -316,13 +333,10 @@ export function useSubstituteRequestFlow({ selectedDateKey, onSubstituteCalendarDaySelect, startHour, - setStartHour, startMin, - setStartMin, endHour, - setEndHour, endMin, - setEndMin, + hasSelectedSchedule, selectedWeekdayLabel, summarySelectedTimeLabel, substituteScheduleId, diff --git a/src/features/user/substitute/api/userSubstituteRequests.ts b/src/features/user/substitute/api/userSubstituteRequests.ts index 3229f4ed..0619760b 100644 --- a/src/features/user/substitute/api/userSubstituteRequests.ts +++ b/src/features/user/substitute/api/userSubstituteRequests.ts @@ -11,9 +11,18 @@ import type { const DEFAULT_PAGE_SIZE = 20 function listParams(params: SubstituteListQueryParams) { + const status = + params.status == null + ? undefined + : Array.isArray(params.status) + ? params.status.length > 0 + ? params.status + : undefined + : params.status + return { pageSize: params.pageSize ?? DEFAULT_PAGE_SIZE, - ...(params.status && { status: params.status }), + ...(status != null && { status }), ...(params.cursor != null && params.cursor !== '' && { cursor: params.cursor }), ...(params.workspaceId != null && { workspaceId: params.workspaceId }), diff --git a/src/features/user/substitute/hooks/useUserSubstituteRequestsViewModel.ts b/src/features/user/substitute/hooks/useUserSubstituteRequestsViewModel.ts index 118313dd..a5ce05d1 100644 --- a/src/features/user/substitute/hooks/useUserSubstituteRequestsViewModel.ts +++ b/src/features/user/substitute/hooks/useUserSubstituteRequestsViewModel.ts @@ -6,6 +6,10 @@ import { fetchSentSubstituteRequests, } from '@/features/user/substitute/api/userSubstituteRequests' import { adaptUserSubstituteListItem } from '@/features/user/substitute/lib/adaptUserSubstituteRequest' +import { + resolveApiStatuses, + type SubstituteListFilters, +} from '@/features/user/substitute/lib/substituteListFilters' import type { ReceivedSubstituteListApiResponse, SentSubstituteListApiResponse, @@ -35,18 +39,42 @@ const SECTION_TITLE: Record = { cancelled: '취소됨', } +function buildSections( + items: UserSubstituteListItem[], + statusFilter: SubstituteListFilters['statusFilter'] +): SubstituteListSection[] { + if (statusFilter !== 'all') { + return [ + { + key: statusFilter, + title: SECTION_TITLE[statusFilter], + items, + }, + ].filter(section => section.items.length > 0) + } + + const grouped = new Map() + for (const status of SECTION_ORDER) { + grouped.set(status, []) + } + for (const item of items) { + grouped.get(item.uiStatus)?.push(item) + } + return SECTION_ORDER.map(status => ({ + key: status, + title: SECTION_TITLE[status], + items: grouped.get(status) ?? [], + })).filter(section => section.items.length > 0) +} + export function useUserSubstituteRequestsViewModel( - direction: SubstituteRequestDirection + direction: SubstituteRequestDirection, + filters: SubstituteListFilters = { statusFilter: 'all' } ) { - const { - data, - isPending, - isError, - refetch, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - } = useInfiniteQuery< + const { statusFilter } = filters + const apiStatuses = resolveApiStatuses(statusFilter) + + const { data, isPending, isError, refetch } = useInfiniteQuery< SubstituteListPage, Error, { pages: SubstituteListPage[]; pageParams: (string | undefined)[] }, @@ -56,11 +84,13 @@ export function useUserSubstituteRequestsViewModel( queryKey: queryKeys.userSubstitute.list({ direction, pageSize: PAGE_LIMIT, + statusFilter, }), queryFn: ({ pageParam }) => { const params = { pageSize: PAGE_LIMIT, cursor: pageParam as string | undefined, + ...(apiStatuses.length > 0 && { status: apiStatuses }), } return direction === 'RECEIVED' ? fetchReceivedSubstituteRequests(params) @@ -81,20 +111,10 @@ export function useUserSubstituteRequestsViewModel( [data, direction] ) - const sections = useMemo(() => { - const grouped = new Map() - for (const status of SECTION_ORDER) { - grouped.set(status, []) - } - for (const item of items) { - grouped.get(item.uiStatus)?.push(item) - } - return SECTION_ORDER.map(status => ({ - key: status, - title: SECTION_TITLE[status], - items: grouped.get(status) ?? [], - })).filter(section => section.items.length > 0) - }, [items]) + const sections = useMemo( + () => buildSections(items, statusFilter), + [items, statusFilter] + ) const totalCount = data?.pages?.[0]?.data?.page?.totalCount ?? 0 @@ -105,8 +125,5 @@ export function useUserSubstituteRequestsViewModel( isLoading: isPending, isError, refetch, - fetchNextPage, - hasNextPage: !!hasNextPage, - isFetchingNextPage, } } diff --git a/src/features/user/substitute/lib/adaptUserSubstituteRequest.test.ts b/src/features/user/substitute/lib/adaptUserSubstituteRequest.test.ts index bb8748ba..de70ff78 100644 --- a/src/features/user/substitute/lib/adaptUserSubstituteRequest.test.ts +++ b/src/features/user/substitute/lib/adaptUserSubstituteRequest.test.ts @@ -146,6 +146,29 @@ describe('대타요청 프로필 이미지 데이터 흐름', () => { ).toBeNull() }) + it('RECEIVED 목록은 enum 래퍼 status를 언래핑한다', () => { + const item = adaptUserSubstituteListItem( + { + ...received(), + status: { value: 'PENDING', description: '대기중' }, + requestType: { value: 'SPECIFIC', description: '특정 대상' }, + }, + 'RECEIVED' + ) + expect(item.rawStatus).toBe('PENDING') + expect(item.uiStatus).toBe('pending') + expect(item.statusLabel).toBe('확인중') + }) + + it('RECEIVED 상세는 enum 래퍼 status로 canRespond를 판단한다', () => { + const detail = adaptReceivedSubstituteDetail({ + ...received(), + status: { value: 'PENDING', description: '대기중' }, + }) + expect(detail.rawStatus).toBe('PENDING') + expect(detail.canRespond).toBe(true) + }) + it('normalize는 API target의 profileImageUrl을 정규화 target으로 전달한다', () => { const api: SentSubstituteRequestDetailApiDto = { id: 4, diff --git a/src/features/user/substitute/lib/adaptUserSubstituteRequest.ts b/src/features/user/substitute/lib/adaptUserSubstituteRequest.ts index 3197d655..85cea525 100644 --- a/src/features/user/substitute/lib/adaptUserSubstituteRequest.ts +++ b/src/features/user/substitute/lib/adaptUserSubstituteRequest.ts @@ -197,7 +197,7 @@ function adaptReceivedListItem( ): UserSubstituteListItem { const storeName = dto.workspace.workspaceName?.trim() ?? '매장' const personName = dto.requester.workerName?.trim() ?? '이름' - const rawStatus = String(dto.status) + const rawStatus = unwrapSubstituteEnum(dto.status) const uiStatus = mapApiStatusToUi(rawStatus) return { @@ -252,7 +252,7 @@ function detailFromSchedulePerson( dto: { schedule: { startDateTime: string; endDateTime: string; position: string } workspace: { workspaceName: string } - status: string + status: SubstituteEnumValueDto | string requestReason?: string | null }, personName: string, @@ -260,7 +260,7 @@ function detailFromSchedulePerson( direction: SubstituteRequestDirection ): UserSubstituteDetailViewModel { const storeName = dto.workspace.workspaceName?.trim() ?? '매장' - const rawStatus = String(dto.status) + const rawStatus = unwrapSubstituteEnum(dto.status) const uiStatus = mapApiStatusToUi(rawStatus) const upper = normalizeSubstituteStatus(rawStatus) diff --git a/src/features/user/substitute/lib/substituteListFilters.ts b/src/features/user/substitute/lib/substituteListFilters.ts new file mode 100644 index 00000000..dfb90946 --- /dev/null +++ b/src/features/user/substitute/lib/substituteListFilters.ts @@ -0,0 +1,48 @@ +import type { + SubstituteRequestStatus, + SubstituteUiStatus, +} from '@/features/user/substitute/types' + +export type SubstituteListStatusFilter = 'all' | SubstituteUiStatus + +export type SubstituteListFilters = { + statusFilter: SubstituteListStatusFilter +} + +export const SUBSTITUTE_STATUS_FILTER_OPTIONS: { + key: SubstituteListStatusFilter + label: string +}[] = [ + { key: 'all', label: '전체' }, + { key: 'pending', label: '요청됨' }, + { key: 'accepted', label: '수락됨' }, + { key: 'cancelled', label: '취소됨' }, +] + +export const FILTER_TO_API_STATUS: Record< + SubstituteListStatusFilter, + SubstituteRequestStatus[] +> = { + all: [], + pending: ['PENDING'], + accepted: ['ACCEPTED', 'APPROVED'], + cancelled: [ + 'CANCELLED', + 'REJECTED_BY_TARGET', + 'REJECTED_BY_APPROVER', + 'EXPIRED', + ], +} + +export function resolveApiStatuses( + filter: SubstituteListStatusFilter +): SubstituteRequestStatus[] { + return FILTER_TO_API_STATUS[filter] +} + +export function statusFilterLabel(filter: SubstituteListStatusFilter): string { + return ( + SUBSTITUTE_STATUS_FILTER_OPTIONS.find(option => option.key === filter) + ?.label ?? '전체' + ) +} diff --git a/src/features/user/substitute/types.ts b/src/features/user/substitute/types.ts index ddc09b49..1f3190fc 100644 --- a/src/features/user/substitute/types.ts +++ b/src/features/user/substitute/types.ts @@ -78,9 +78,14 @@ export interface ReceivedSubstituteRequestDto { schedule: SubstituteScheduleDto workspace: SubstituteWorkspaceDto requester: SubstituteRequesterDto - requestType: SubstituteRequestType + requestType: + | SubstituteRequestType + | SubstituteEnumValueDto acceptedWorker?: SubstituteAcceptedWorkerDto | null - status: SubstituteRequestStatus | string + status: + | SubstituteRequestStatus + | SubstituteEnumValueDto + | string requestReason?: string | null createdAt: string acceptedAt?: string | null @@ -163,7 +168,7 @@ export type SentSubstituteDetailApiResponse = CommonApiResponse export interface SubstituteListQueryParams { - status?: string + status?: string | string[] cursor?: string pageSize?: number workspaceId?: number diff --git a/src/pages/manager/substitute-request/components/ManagerSubstituteActionModal.tsx b/src/pages/manager/substitute-request/components/ManagerSubstituteActionModal.tsx index 1896b8b7..ce1f5631 100644 --- a/src/pages/manager/substitute-request/components/ManagerSubstituteActionModal.tsx +++ b/src/pages/manager/substitute-request/components/ManagerSubstituteActionModal.tsx @@ -17,7 +17,7 @@ const ACTION_CONFIG: Record< > = { approve: { title: '승인 코멘트', - placeholder: '승인 코멘트를 입력해 주세요.', + placeholder: '승인 코멘트를 입력해 주세요. (선택)', submitLabel: '승인하기', }, reject: { @@ -67,7 +67,7 @@ export function ManagerSubstituteActionModal({ const handleSubmit = () => { const trimmed = comment.trim() - if (trimmed === '') { + if (type === 'reject' && trimmed === '') { setError('내용을 입력해 주세요.') return } diff --git a/src/pages/manager/substitute-request/index.tsx b/src/pages/manager/substitute-request/index.tsx index 8b3aa1e9..1d9ae5d3 100644 --- a/src/pages/manager/substitute-request/index.tsx +++ b/src/pages/manager/substitute-request/index.tsx @@ -1,11 +1,18 @@ -import DownIcon from '@/assets/icons/home/chevron-down.svg?react' +import { useState } from 'react' + import { Navbar } from '@/shared/ui/common/Navbar' import { Spinner } from '@/shared/ui/Spinner' import { Avatar } from '@/shared/ui/common/Avatar' import { SubstituteRequestResponseActions } from '@/pages/user/substitute-request/components/SubstituteRequestResponseActions' import { SubstituteRequestStatusBadge } from '@/pages/user/substitute-request/components/SubstituteRequestStatusBadge' +import { SubstituteStatusFilterDropdown } from '@/pages/user/substitute-request/components/SubstituteStatusFilterDropdown' import { useNavbarNotificationProps } from '@/features/notification' -import { useManagerSubstituteRequestViewModel } from '@/features/manager/substitute/hooks/useManagerSubstituteRequestViewModel' +import { + useManagerSubstituteRequestViewModel, + type ManagerSubstituteSection, +} from '@/features/manager/substitute/hooks/useManagerSubstituteRequestViewModel' +import type { SubstituteListStatusFilter } from '@/features/user/substitute/lib/substituteListFilters' +import { statusFilterLabel } from '@/features/user/substitute/lib/substituteListFilters' import { ManagerSubstituteActionModal } from '@/pages/manager/substitute-request/components/ManagerSubstituteActionModal' import { WorkerRoleBadge } from '@/shared/ui/home/WorkerRoleBadge' import type { SubstituteRequestItem } from '@/shared/types/substituteRequest' @@ -74,51 +81,74 @@ function StatusCard({ ) } -function Section({ - title, +function SectionList({ + section, showFilter, - children, + statusFilter, + onStatusFilterChange, + actionsDisabled, + onApproveClick, + onRejectClick, }: { - title: string - showFilter?: boolean - children: React.ReactNode + section: ManagerSubstituteSection + showFilter: boolean + statusFilter: SubstituteListStatusFilter + onStatusFilterChange: (value: SubstituteListStatusFilter) => void + actionsDisabled: boolean + onApproveClick: (id: number) => void + onRejectClick: (id: number) => void }) { return (
-

{title}

+

{section.title}

{showFilter ? ( - + ) : null}
-
{children}
+
+ {section.items.map(item => + section.key === 'pending' ? ( + onApproveClick(item.id)} + onReject={() => onRejectClick(item.id)} + disabled={actionsDisabled} + /> + ) : ( + + ) + )} +
) } export function ManagerSubstituteRequestPage() { const notificationProps = useNavbarNotificationProps() + const [statusFilter, setStatusFilter] = + useState('all') const { isLoading, isError, isEmpty, - pending, - accepted, - cancelled, + sections, actionsDisabled, actionModal, onApproveClick, onRejectClick, onActionModalClose, onActionModalSubmit, - } = useManagerSubstituteRequestViewModel() + } = useManagerSubstituteRequestViewModel({ statusFilter }) return (
@@ -136,52 +166,36 @@ export function ManagerSubstituteRequestPage() {

) : isEmpty ? ( -
-

- 대타 요청이 없습니다. -

+
+
+

+ {statusFilterLabel(statusFilter)} +

+ +
+
+

+ 대타 요청이 없습니다. +

+
) : ( <> - {pending.length > 0 && ( -
- {pending.map(item => ( - onApproveClick(item.id)} - onReject={() => onRejectClick(item.id)} - disabled={actionsDisabled} - /> - ))} -
- )} - - {accepted.length > 0 && ( -
- {accepted.map(item => ( - - ))} -
- )} - - {cancelled.length > 0 && ( -
- {cancelled.map(item => ( - - ))} -
- )} + {sections.map((section, index) => ( + + ))} )} diff --git a/src/pages/user/substitute-request/components/SubstituteRequestListSections.tsx b/src/pages/user/substitute-request/components/SubstituteRequestListSections.tsx index 06447b89..c14b6eed 100644 --- a/src/pages/user/substitute-request/components/SubstituteRequestListSections.tsx +++ b/src/pages/user/substitute-request/components/SubstituteRequestListSections.tsx @@ -1,13 +1,18 @@ -import DownIcon from '@/assets/icons/home/chevron-down.svg?react' - import { SubstituteRequestCard } from '@/pages/user/substitute-request/components/SubstituteRequestCard' +import { SubstituteStatusFilterDropdown } from '@/pages/user/substitute-request/components/SubstituteStatusFilterDropdown' import type { SubstituteDirectionTab } from '@/pages/user/substitute-request/components/SubstituteRequestTabs' import type { SubstituteListSection } from '@/features/user/substitute/hooks/useUserSubstituteRequestsViewModel' +import { + statusFilterLabel, + type SubstituteListStatusFilter, +} from '@/features/user/substitute/lib/substituteListFilters' import type { UserSubstituteListItem } from '@/features/user/substitute/types' interface SubstituteRequestListSectionsProps { sections: SubstituteListSection[] directionTab: SubstituteDirectionTab + statusFilter?: SubstituteListStatusFilter + onStatusFilterChange?: (value: SubstituteListStatusFilter) => void onItemClick: (item: UserSubstituteListItem) => void onAccept?: (item: UserSubstituteListItem) => void onReject?: (item: UserSubstituteListItem) => void @@ -17,38 +22,51 @@ interface SubstituteRequestListSectionsProps { export function SubstituteRequestListSections({ sections, directionTab, + statusFilter, + onStatusFilterChange, onItemClick, onAccept, onReject, actionsDisabled, }: SubstituteRequestListSectionsProps) { + const showStatusFilter = statusFilter != null && onStatusFilterChange != null + if (sections.length === 0) { return ( -
-

- 대타 요청 내역이 없습니다. -

+
+ {showStatusFilter ? ( +
+

+ {statusFilterLabel(statusFilter)} +

+ +
+ ) : null} +
+

+ 대타 요청 내역이 없습니다. +

+
) } return (
- {sections.map(section => ( + {sections.map((section, index) => (

{section.title}

- {section.key === 'pending' ? ( - + {showStatusFilter && index === 0 ? ( + ) : null}
diff --git a/src/pages/user/substitute-request/components/SubstituteStatusFilterDropdown.tsx b/src/pages/user/substitute-request/components/SubstituteStatusFilterDropdown.tsx new file mode 100644 index 00000000..54aa1886 --- /dev/null +++ b/src/pages/user/substitute-request/components/SubstituteStatusFilterDropdown.tsx @@ -0,0 +1,80 @@ +import { useEffect, useRef, useState } from 'react' + +import DownIcon from '@/assets/icons/home/chevron-down.svg?react' + +import { + SUBSTITUTE_STATUS_FILTER_OPTIONS, + statusFilterLabel, + type SubstituteListStatusFilter, +} from '@/features/user/substitute/lib/substituteListFilters' + +interface SubstituteStatusFilterDropdownProps { + value: SubstituteListStatusFilter + onChange: (value: SubstituteListStatusFilter) => void +} + +export function SubstituteStatusFilterDropdown({ + value, + onChange, +}: SubstituteStatusFilterDropdownProps) { + const [isOpen, setIsOpen] = useState(false) + const containerRef = useRef(null) + + useEffect(() => { + function handleOutsideClick(e: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setIsOpen(false) + } + } + if (isOpen) { + document.addEventListener('mousedown', handleOutsideClick) + } + return () => document.removeEventListener('mousedown', handleOutsideClick) + }, [isOpen]) + + return ( +
+ + + {isOpen ? ( +
    + {SUBSTITUTE_STATUS_FILTER_OPTIONS.map(option => ( +
  • + +
  • + ))} +
+ ) : null} +
+ ) +} diff --git a/src/pages/user/substitute-request/index.tsx b/src/pages/user/substitute-request/index.tsx index b04e40e8..43d8d55b 100644 --- a/src/pages/user/substitute-request/index.tsx +++ b/src/pages/user/substitute-request/index.tsx @@ -16,6 +16,7 @@ import type { UserSubstituteListItem, } from '@/features/user/substitute/types' import { useUserSubstituteRequestsViewModel } from '@/features/user/substitute/hooks/useUserSubstituteRequestsViewModel' +import type { SubstituteListStatusFilter } from '@/features/user/substitute/lib/substituteListFilters' import { useWorkspacesViewModel } from '@/features/user' import { SubstituteCreateFab } from '@/pages/user/substitute-request/components/SubstituteCreateFab' import { SubstituteRejectReasonModal } from '@/pages/user/substitute-request/components/SubstituteRejectReasonModal' @@ -90,6 +91,10 @@ export function SubstituteRequestPage() { locationState?.directionTab ?? locationState?.direction ) ?? 'sent' ) + const [sentStatusFilter, setSentStatusFilter] = + useState('all') + const [receivedStatusFilter, setReceivedStatusFilter] = + useState('all') const [storePickerOpen, setStorePickerOpen] = useState(false) const [createFlow, setCreateFlow] = useState(null) const [rejectRequestId, setRejectRequestId] = useState(null) @@ -97,12 +102,17 @@ export function SubstituteRequestPage() { const { workspaces, isLoading: workspacesLoading } = useWorkspacesViewModel() const apiDirection = directionTab === 'sent' ? 'SENT' : 'RECEIVED' + const statusFilter = + directionTab === 'sent' ? sentStatusFilter : receivedStatusFilter + const setStatusFilter = + directionTab === 'sent' ? setSentStatusFilter : setReceivedStatusFilter + const { sections, isLoading: listLoading, isError: listError, refetch, - } = useUserSubstituteRequestsViewModel(apiDirection) + } = useUserSubstituteRequestsViewModel(apiDirection, { statusFilter }) const invalidateLists = async () => { await queryClient.invalidateQueries({ @@ -257,6 +267,8 @@ export function SubstituteRequestPage() { void @@ -136,64 +131,34 @@ export function SubstituteRequestModalFlow({ 출근 시간

- +
: - +
@@ -202,70 +167,49 @@ export function SubstituteRequestModalFlow({ 퇴근 시간

- +
: - +
+ + {!flow.hasSelectedSchedule ? ( +

+ 선택한 날짜에 등록된 근무 스케줄이 없습니다. +

+ ) : null}