From 353c7190077af334df6cc0a1060a2d21c0aa2c4e Mon Sep 17 00:00:00 2001 From: kimtaewoo <70637743+kim3360@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:47:28 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat=20:=20tanstackquery=20devtools=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 44 +++++++++++++++++++++++++++++++------ package.json | 1 + src/app/providers/index.tsx | 11 +++++++++- 3 files changed, 48 insertions(+), 8 deletions(-) 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} + ) } From 20431ab536570c202859ca6782460b44a7806f8f Mon Sep 17 00:00:00 2001 From: kimtaewoo <70637743+kim3360@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:48:46 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix=20:=20G3=20=EA=B7=BC=EB=AC=B4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9=20=EB=B6=80?= =?UTF-8?q?=EB=B6=84=20=EC=8A=A4=EC=BC=80=EC=A4=84=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/useSubstituteRequestFlow.ts | 72 +++++---- .../components/SubstituteRequestModalFlow.tsx | 146 ++++++------------ 2 files changed, 88 insertions(+), 130 deletions(-) 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/pages/user/workspace-detail/components/SubstituteRequestModalFlow.tsx b/src/pages/user/workspace-detail/components/SubstituteRequestModalFlow.tsx index fb4ba3ac..2afd2050 100644 --- a/src/pages/user/workspace-detail/components/SubstituteRequestModalFlow.tsx +++ b/src/pages/user/workspace-detail/components/SubstituteRequestModalFlow.tsx @@ -4,22 +4,17 @@ import ChevronLeftIcon from '@/assets/icons/nav/chevron-left.svg' import { WEEKDAY_LABELS } from '@/shared/constants/calendar' import { SubstituteCalendarPickerPanel } from './SubstituteCalendarPickerPanel' -import { - normalizeHourInput, - normalizeMinuteInput, - timeDigits, - useSubstituteRequestFlow, -} from '@/features/user' +import { useSubstituteRequestFlow } from '@/features/user' import { WorkerRoleBadge } from '@/shared/ui/home/WorkerRoleBadge' import { cn } from '@/shared/lib/utils' import type { CalendarViewData } from '@/features/home/common/schedule/types/calendarView' -const timeFieldInputClass = - 'min-w-0 flex-1 bg-transparent text-center tabular-nums typography-body01-semibold text-text-90 outline-none placeholder:text-text-50' +const timeFieldReadOnlyClass = + 'min-w-0 flex-1 text-center tabular-nums typography-body01-semibold text-text-90' -const timeSegmentLabelClass = - 'flex h-[50px] min-w-0 flex-1 cursor-text items-center justify-center gap-1.5 rounded-2xl bg-bg-dark px-3 outline-none transition focus-within:ring-2 focus-within:ring-main' +const timeSegmentReadOnlyClass = + 'flex h-[50px] min-w-0 flex-1 items-center justify-center gap-1.5 rounded-2xl bg-bg-dark px-3' interface SubstituteRequestModalFlowProps { onClose: () => void @@ -136,64 +131,34 @@ export function SubstituteRequestModalFlow({ 출근 시간

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

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

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

+ ) : null}
+ {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() { ['substitute', 'list', params] as const, }, userSubstitute: { - list: (params: { direction: string; pageSize: number }) => - ['userSubstitute', 'list', params] as const, + list: (params: { + direction: string + pageSize: number + statusFilter?: string + }) => ['userSubstitute', 'list', params] as const, sentDetail: (requestId: number) => ['userSubstitute', 'sentDetail', requestId] as const, }, From c7fa4f4044e09db035065b466cccde3b09ed1d54 Mon Sep 17 00:00:00 2001 From: kimtaewoo <70637743+kim3360@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:26:07 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix=20:=20G6=20=EB=A7=A4=EB=8B=88=EC=A0=80?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8=20=EC=BD=94=EB=A9=98=ED=8A=B8=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=EC=A0=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/manager/api/substitute.ts | 8 ++++++-- .../hooks/useManagerSubstituteRequestViewModel.ts | 5 ++++- .../components/ManagerSubstituteActionModal.tsx | 4 ++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/features/manager/api/substitute.ts b/src/features/manager/api/substitute.ts index 709e7047..d0813026 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 ) } diff --git a/src/features/manager/substitute/hooks/useManagerSubstituteRequestViewModel.ts b/src/features/manager/substitute/hooks/useManagerSubstituteRequestViewModel.ts index 328d6811..f1f38e2e 100644 --- a/src/features/manager/substitute/hooks/useManagerSubstituteRequestViewModel.ts +++ b/src/features/manager/substitute/hooks/useManagerSubstituteRequestViewModel.ts @@ -70,7 +70,10 @@ export function useManagerSubstituteRequestViewModel() { 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) 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 } From 3dd79da7796de0d2017f35889f42100934243122 Mon Sep 17 00:00:00 2001 From: kimtaewoo <70637743+kim3360@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:34:44 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix=20:=20G4=20=EB=A7=A4=EB=8B=88=EC=A0=80?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=ED=95=84=ED=84=B0=20=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/manager/api/substitute.ts | 11 +- .../hooks/useSubstituteRequestsViewModel.ts | 11 +- src/features/manager/home/types/substitute.ts | 2 +- .../useManagerSubstituteRequestViewModel.ts | 71 +++++++-- .../lib/managerSubstituteListFilters.ts | 23 +++ .../manager/substitute-request/index.tsx | 144 ++++++++++-------- src/shared/lib/queryKeys.ts | 1 + 7 files changed, 178 insertions(+), 85 deletions(-) create mode 100644 src/features/manager/substitute/lib/managerSubstituteListFilters.ts diff --git a/src/features/manager/api/substitute.ts b/src/features/manager/api/substitute.ts index d0813026..6dc3d23b 100644 --- a/src/features/manager/api/substitute.ts +++ b/src/features/manager/api/substitute.ts @@ -31,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', { @@ -39,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 f1f38e2e..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,19 +74,41 @@ 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({ @@ -97,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 @@ -120,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/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/shared/lib/queryKeys.ts b/src/shared/lib/queryKeys.ts index 1b75a07c..09c41ea0 100644 --- a/src/shared/lib/queryKeys.ts +++ b/src/shared/lib/queryKeys.ts @@ -56,6 +56,7 @@ export const queryKeys = { list: (params?: { workspaceId?: number status?: string + statusFilter?: string pageSize?: number }) => ['substitute', 'list', params] as const, }, From e616be519ce776331f9259effe0c2e2a2bb575eb Mon Sep 17 00:00:00 2001 From: kimtaewoo <70637743+kim3360@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:35:16 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix=20:=20G7=20enum=20=EB=9E=98=ED=8D=BC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/adaptUserSubstituteRequest.test.ts | 23 +++++++++++++++++++ .../lib/adaptUserSubstituteRequest.ts | 6 ++--- src/features/user/substitute/types.ts | 9 ++++++-- 3 files changed, 33 insertions(+), 5 deletions(-) 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/types.ts b/src/features/user/substitute/types.ts index dda365ca..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