From b19ba806b28ee588c4a2e27e12860ec22d5cc80a Mon Sep 17 00:00:00 2001 From: Dohyeon Date: Sun, 10 May 2026 19:23:04 +0900 Subject: [PATCH 01/15] =?UTF-8?q?fix:=20=EC=97=85=EC=9E=A5=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=20API=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=BA=98=EB=A6=B0=EB=8D=94=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/useDailyCalendarViewModel.ts | 7 ++ .../hooks/useWeeklyCalendarViewModel.ts | 7 ++ .../user/home/schedule/types/dailyCalendar.ts | 1 + .../home/schedule/types/weeklyCalendar.ts | 1 + .../user/home/schedule/ui/DailyCalendar.tsx | 22 +++-- .../user/home/schedule/ui/WeeklyCalendar.tsx | 34 +++++--- .../home/workspace/api/workspaceSchedule.ts | 86 +++++++++++++++++-- .../home/workspace/types/workspaceSchedule.ts | 23 ++++- src/pages/user/workspace-detail/index.tsx | 30 ++++++- 9 files changed, 178 insertions(+), 33 deletions(-) diff --git a/src/features/user/home/schedule/hooks/useDailyCalendarViewModel.ts b/src/features/user/home/schedule/hooks/useDailyCalendarViewModel.ts index dedf242..a25deab 100644 --- a/src/features/user/home/schedule/hooks/useDailyCalendarViewModel.ts +++ b/src/features/user/home/schedule/hooks/useDailyCalendarViewModel.ts @@ -88,6 +88,12 @@ export function useDailyCalendarViewModel({ totalMinutes, }) + const estimatedLaborCost = data?.summary.estimatedLaborCost + const estimatedEarningsText = + estimatedLaborCost != null + ? `약 ${estimatedLaborCost.toLocaleString()}원` + : undefined + return { title: workspaceName ?? '오늘의 아르바이트', dateLabel: format(baseDate, 'yyyy년 M월 d일'), @@ -95,6 +101,7 @@ export function useDailyCalendarViewModel({ 2, '0' ), + ...(estimatedEarningsText != null ? { estimatedEarningsText } : {}), timelineHeight, timelineLines, eventBlocks, diff --git a/src/features/user/home/schedule/hooks/useWeeklyCalendarViewModel.ts b/src/features/user/home/schedule/hooks/useWeeklyCalendarViewModel.ts index 4fc0f37..fb596f1 100644 --- a/src/features/user/home/schedule/hooks/useWeeklyCalendarViewModel.ts +++ b/src/features/user/home/schedule/hooks/useWeeklyCalendarViewModel.ts @@ -55,9 +55,16 @@ export function useWeeklyCalendarViewModel({ } }) + const estimatedLaborCost = data?.summary.estimatedLaborCost + const estimatedEarningsText = + estimatedLaborCost != null + ? `약 ${estimatedLaborCost.toLocaleString()}원` + : undefined + return { title: workspaceName ?? `${format(baseDate, 'M월')} 주간 아르바이트`, totalWorkHoursText: String(Math.round(data?.summary.totalWorkHours ?? 0)), + ...(estimatedEarningsText != null ? { estimatedEarningsText } : {}), dayCells, } }, [baseDate, data, workspaceName]) diff --git a/src/features/user/home/schedule/types/dailyCalendar.ts b/src/features/user/home/schedule/types/dailyCalendar.ts index 608aada..f317410 100644 --- a/src/features/user/home/schedule/types/dailyCalendar.ts +++ b/src/features/user/home/schedule/types/dailyCalendar.ts @@ -20,6 +20,7 @@ export interface DailyCalendarViewModel { title: string dateLabel: string totalWorkHoursText: string + estimatedEarningsText?: string timelineHeight: number timelineLines: DailyTimelineLineViewModel[] eventBlocks: DailyEventBlockViewModel[] diff --git a/src/features/user/home/schedule/types/weeklyCalendar.ts b/src/features/user/home/schedule/types/weeklyCalendar.ts index f40a3be..70cfa4e 100644 --- a/src/features/user/home/schedule/types/weeklyCalendar.ts +++ b/src/features/user/home/schedule/types/weeklyCalendar.ts @@ -16,5 +16,6 @@ export interface WeeklyDayCellViewModel { export interface WeeklyCalendarViewModel { title: string totalWorkHoursText: string + estimatedEarningsText?: string dayCells: WeeklyDayCellViewModel[] } diff --git a/src/features/user/home/schedule/ui/DailyCalendar.tsx b/src/features/user/home/schedule/ui/DailyCalendar.tsx index 098a8b3..32df0cd 100644 --- a/src/features/user/home/schedule/ui/DailyCalendar.tsx +++ b/src/features/user/home/schedule/ui/DailyCalendar.tsx @@ -16,6 +16,7 @@ export function DailyCalendar({ title, dateLabel, totalWorkHoursText, + estimatedEarningsText, timelineHeight, timelineLines, eventBlocks, @@ -41,13 +42,20 @@ export function DailyCalendar({

{dateLabel}

-
- - {totalWorkHoursText} - - - 시간 근무해요 - +
+
+ + {totalWorkHoursText} + + + 시간 근무해요 + +
+ {estimatedEarningsText ? ( + + {estimatedEarningsText} + + ) : null}
diff --git a/src/features/user/home/schedule/ui/WeeklyCalendar.tsx b/src/features/user/home/schedule/ui/WeeklyCalendar.tsx index 8b84bca..94358cb 100644 --- a/src/features/user/home/schedule/ui/WeeklyCalendar.tsx +++ b/src/features/user/home/schedule/ui/WeeklyCalendar.tsx @@ -11,11 +11,12 @@ export function WeeklyCalendar({ workspaceName, isLoading = false, }: WeeklyCalendarProps) { - const { title, totalWorkHoursText, dayCells } = useWeeklyCalendarViewModel({ - baseDate, - data, - workspaceName, - }) + const { title, totalWorkHoursText, estimatedEarningsText, dayCells } = + useWeeklyCalendarViewModel({ + baseDate, + data, + workspaceName, + }) if (isLoading) { return ( @@ -29,14 +30,21 @@ export function WeeklyCalendar({

{title}

-
- - - {totalWorkHoursText} - - - 시간 근무해요 - +
+
+ + + {totalWorkHoursText} + + + 시간 근무해요 + +
+ {estimatedEarningsText ? ( + + {estimatedEarningsText} + + ) : null}
diff --git a/src/features/user/home/workspace/api/workspaceSchedule.ts b/src/features/user/home/workspace/api/workspaceSchedule.ts index 5aaf1ec..248a8eb 100644 --- a/src/features/user/home/workspace/api/workspaceSchedule.ts +++ b/src/features/user/home/workspace/api/workspaceSchedule.ts @@ -11,23 +11,55 @@ import { } from '@/features/user/home/schedule/lib/date' import type { WorkspaceScheduleApiResponse, + WorkspaceScheduleDataPayload, WorkspaceScheduleQueryParams, + WorkspaceShiftDto, WorkspaceShiftItem, WorkspaceWorkerItem, } from '@/features/user/home/workspace/types/workspaceSchedule' +function normalizeWorkspaceShifts( + payload: WorkspaceScheduleDataPayload | null | undefined +): WorkspaceShiftDto[] { + if (payload == null) return [] + if (Array.isArray(payload)) return payload + if (typeof payload === 'object') { + const { schedules, shifts } = payload + if (Array.isArray(schedules)) return schedules + if (Array.isArray(shifts)) return shifts + } + return [] +} + +function extractWorkspaceScheduleTotals(payload: WorkspaceScheduleDataPayload) { + if (payload == null || Array.isArray(payload)) return {} + if (typeof payload !== 'object') return {} + const totalWorkHours = + typeof payload.totalWorkHours === 'number' + ? payload.totalWorkHours + : undefined + const estimatedSalary = + typeof payload.estimatedSalary === 'number' + ? payload.estimatedSalary + : undefined + return { totalWorkHours, estimatedSalary } +} + async function fetchWorkspaceScheduleByMonth( workspaceId: number, year?: number, month?: number, day?: number ): Promise { + if (year === undefined || month === undefined) { + throw new Error('업장 스케줄 조회에는 year와 month가 필요합니다.') + } const response = await axiosInstance.get( `/app/schedules/workspaces/${workspaceId}`, { params: { - ...(year !== undefined && { year }), - ...(month !== undefined && { month }), + year, + month, ...(day !== undefined && { day }), }, } @@ -57,7 +89,38 @@ export async function getWorkspaceSchedule( ), fetchWorkspaceScheduleByMonth(workspaceId, params.toYear, params.toMonth), ]) - return { ...fromData, data: [...fromData.data, ...toData.data] } + const shiftsFrom = normalizeWorkspaceShifts(fromData.data) + const shiftsTo = normalizeWorkspaceShifts(toData.data) + const fromTotals = extractWorkspaceScheduleTotals(fromData.data) + const toTotals = extractWorkspaceScheduleTotals(toData.data) + + const hasHours = + fromTotals.totalWorkHours !== undefined || + toTotals.totalWorkHours !== undefined + const hasSalary = + fromTotals.estimatedSalary !== undefined || + toTotals.estimatedSalary !== undefined + + return { + ...fromData, + data: { + ...(hasHours + ? { + totalWorkHours: + (fromTotals.totalWorkHours ?? 0) + + (toTotals.totalWorkHours ?? 0), + } + : {}), + ...(hasSalary + ? { + estimatedSalary: + (fromTotals.estimatedSalary ?? 0) + + (toTotals.estimatedSalary ?? 0), + } + : {}), + schedules: [...shiftsFrom, ...shiftsTo], + }, + } } return fetchWorkspaceScheduleByMonth( workspaceId, @@ -70,7 +133,8 @@ export async function getWorkspaceSchedule( export function adaptWorkspaceScheduleToCalendar( response: WorkspaceScheduleApiResponse ): CalendarViewData { - const shifts = response.data + const shifts = normalizeWorkspaceShifts(response.data) + const apiTotals = extractWorkspaceScheduleTotals(response.data) const events: CalendarEvent[] = shifts.map(shift => ({ shiftId: shift.shiftId, @@ -85,10 +149,16 @@ export function adaptWorkspaceScheduleToCalendar( durationHours: getDurationHours(shift.startDateTime, shift.endDateTime), })) - const totalWorkHours = events.reduce((acc, e) => acc + e.durationHours, 0) + const computedHours = events.reduce((acc, e) => acc + e.durationHours, 0) return { - summary: { totalWorkHours, eventCount: events.length }, + summary: { + totalWorkHours: apiTotals.totalWorkHours ?? computedHours, + eventCount: events.length, + ...(apiTotals.estimatedSalary !== undefined + ? { estimatedLaborCost: apiTotals.estimatedSalary } + : {}), + }, events, } } @@ -96,7 +166,7 @@ export function adaptWorkspaceScheduleToCalendar( export function adaptWorkspaceScheduleToShifts( response: WorkspaceScheduleApiResponse ): WorkspaceShiftItem[] { - return response.data.map(shift => { + return normalizeWorkspaceShifts(response.data).map(shift => { const { time } = formatScheduleTimeRange( shift.startDateTime, shift.endDateTime @@ -121,7 +191,7 @@ export function deriveWorkerList( const workerMap = new Map() const now = Date.now() - const sorted = response.data + const sorted = normalizeWorkspaceShifts(response.data) .filter(shift => new Date(shift.startDateTime).getTime() >= now) .sort( (a, b) => diff --git a/src/features/user/home/workspace/types/workspaceSchedule.ts b/src/features/user/home/workspace/types/workspaceSchedule.ts index c680762..5e1a4d9 100644 --- a/src/features/user/home/workspace/types/workspaceSchedule.ts +++ b/src/features/user/home/workspace/types/workspaceSchedule.ts @@ -16,9 +16,26 @@ export interface WorkspaceShiftDto { status: StatusEnum } -export type WorkspaceScheduleApiResponse = CommonApiResponse< - WorkspaceShiftDto[] -> +/** GET /app/schedules/workspaces/{id} 기본 응답의 data 객체 */ +export interface WorkspaceScheduleDataDto { + totalWorkHours: number + estimatedSalary: number + schedules: WorkspaceShiftDto[] +} + +/** 구버전/부분 형태 호환 포함 */ +export type WorkspaceScheduleDataPayload = + | WorkspaceScheduleDataDto + | WorkspaceShiftDto[] + | { + totalWorkHours?: number + estimatedSalary?: number + schedules?: WorkspaceShiftDto[] + shifts?: WorkspaceShiftDto[] + } + +export type WorkspaceScheduleApiResponse = + CommonApiResponse // ---- Query Params ---- export interface WorkspaceScheduleQueryParams { diff --git a/src/pages/user/workspace-detail/index.tsx b/src/pages/user/workspace-detail/index.tsx index 547561b..2e3d225 100644 --- a/src/pages/user/workspace-detail/index.tsx +++ b/src/pages/user/workspace-detail/index.tsx @@ -1,8 +1,10 @@ +import { useEffect, useMemo } from 'react' import { useParams, useLocation } from 'react-router-dom' import { format, parseISO } from 'date-fns' import { Navbar } from '@/shared/ui/common/Navbar' import { HomeScheduleCalendar, + useWorkspacesViewModel, useWorkspaceManagersViewModel, useWorkspaceWorkersViewModel, useWorkspaceScheduleViewModel, @@ -22,7 +24,31 @@ export function WorkspaceDetailPage() { const { workspaceId } = useParams<{ workspaceId: string }>() const { state } = useLocation() const id = Number(workspaceId) - const businessName = (state as { businessName?: string } | null)?.businessName + const businessNameFromNav = (state as { businessName?: string } | null) + ?.businessName + + const { workspaces, fetchNextPage, hasNextPage, isFetchingNextPage } = + useWorkspacesViewModel() + + const businessNameFromList = useMemo( + () => workspaces.find(w => w.workspaceId === id)?.businessName, + [workspaces, id] + ) + + const storeDisplayName = businessNameFromNav?.trim() || businessNameFromList + + useEffect(() => { + if ( + storeDisplayName || + !hasNextPage || + isFetchingNextPage || + !Number.isFinite(id) || + id <= 0 + ) { + return + } + void fetchNextPage() + }, [storeDisplayName, hasNextPage, isFetchingNextPage, fetchNextPage, id]) const { mode, @@ -57,7 +83,7 @@ export function WorkspaceDetailPage() { baseDate={baseDate} data={calendarData} isLoading={scheduleLoading} - workspaceName={businessName} + workspaceName={storeDisplayName} onDateChange={onDateChange} /> From 41d25fa507a46184bfa7fa7d0727f0300f7268d9 Mon Sep 17 00:00:00 2001 From: Dohyeon Date: Sun, 10 May 2026 19:40:13 +0900 Subject: [PATCH 02/15] =?UTF-8?q?fix:=20=EA=B3=B5=ED=86=B5=20=EB=8D=94?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EA=B4=80=EB=A0=A8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/hooks/useManagerHomeViewModel.ts | 2 ++ .../hooks/useWorkspaceWorkersViewModel.ts | 3 +++ .../hooks/useAppliedStoresViewModel.ts | 3 +++ .../hooks/useWorkspaceManagersViewModel.ts | 3 +++ .../hooks/useWorkspaceWorkersViewModel.ts | 3 +++ .../workspace/hooks/useWorkspacesViewModel.ts | 3 +++ src/pages/manager/home/index.tsx | 23 ++++++++++++++++--- src/pages/user/applied-stores/index.tsx | 4 +++- src/pages/user/workspace-detail/index.tsx | 16 +++++++++++-- .../hooks/useWorkspaceMembers.ts | 13 +++++++++-- src/pages/user/workspace/index.tsx | 4 +++- src/shared/lib/listLoadMoreVisibility.ts | 9 ++++++++ 12 files changed, 77 insertions(+), 9 deletions(-) create mode 100644 src/shared/lib/listLoadMoreVisibility.ts diff --git a/src/features/manager/home/hooks/useManagerHomeViewModel.ts b/src/features/manager/home/hooks/useManagerHomeViewModel.ts index 2c351e3..94b267d 100644 --- a/src/features/manager/home/hooks/useManagerHomeViewModel.ts +++ b/src/features/manager/home/hooks/useManagerHomeViewModel.ts @@ -18,6 +18,7 @@ export function useManagerHomeViewModel() { const { workers: storeWorkers, + totalCount: storeWorkersTotalCount, fetchNextPage: fetchMoreWorkers, hasNextPage: hasMoreWorkers, isFetchingNextPage: isFetchingMoreWorkers, @@ -79,6 +80,7 @@ export function useManagerHomeViewModel() { return { todayWorkers, storeWorkers, + storeWorkersTotalCount, fetchMoreWorkers, hasMoreWorkers, isFetchingMoreWorkers, diff --git a/src/features/manager/home/hooks/useWorkspaceWorkersViewModel.ts b/src/features/manager/home/hooks/useWorkspaceWorkersViewModel.ts index 78b6ce3..aa36120 100644 --- a/src/features/manager/home/hooks/useWorkspaceWorkersViewModel.ts +++ b/src/features/manager/home/hooks/useWorkspaceWorkersViewModel.ts @@ -41,8 +41,11 @@ export function useWorkspaceWorkersViewModel( [data] ) + const totalCount = data?.pages[0]?.data.page.totalCount ?? 0 + return { workers, + totalCount, fetchNextPage, hasNextPage: !!hasNextPage, isFetchingNextPage, diff --git a/src/features/user/home/applied-stores/hooks/useAppliedStoresViewModel.ts b/src/features/user/home/applied-stores/hooks/useAppliedStoresViewModel.ts index a342c2c..0825255 100644 --- a/src/features/user/home/applied-stores/hooks/useAppliedStoresViewModel.ts +++ b/src/features/user/home/applied-stores/hooks/useAppliedStoresViewModel.ts @@ -74,6 +74,8 @@ export function useAppliedStoresViewModel() { [data] ) + const totalCount = data?.pages[0]?.page.totalCount ?? 0 + const grouped = useMemo( () => STATUS_SECTIONS.map(section => ({ @@ -116,6 +118,7 @@ export function useAppliedStoresViewModel() { toggleDropdown, selectFilter, getCardStatus, + totalCount, fetchNextPage, hasNextPage: !!hasNextPage, isFetchingNextPage, diff --git a/src/features/user/home/workspace/hooks/useWorkspaceManagersViewModel.ts b/src/features/user/home/workspace/hooks/useWorkspaceManagersViewModel.ts index 8af5e4a..9e07942 100644 --- a/src/features/user/home/workspace/hooks/useWorkspaceManagersViewModel.ts +++ b/src/features/user/home/workspace/hooks/useWorkspaceManagersViewModel.ts @@ -31,8 +31,11 @@ export function useWorkspaceManagersViewModel( const managers = data?.pages.flatMap(page => page.data.data.map(adaptManagerDto)) ?? [] + const totalCount = data?.pages[0]?.data.page.totalCount ?? 0 + return { managers, + totalCount, fetchNextPage, hasNextPage: !!hasNextPage, isFetchingNextPage, diff --git a/src/features/user/home/workspace/hooks/useWorkspaceWorkersViewModel.ts b/src/features/user/home/workspace/hooks/useWorkspaceWorkersViewModel.ts index 6aab3dd..07bac51 100644 --- a/src/features/user/home/workspace/hooks/useWorkspaceWorkersViewModel.ts +++ b/src/features/user/home/workspace/hooks/useWorkspaceWorkersViewModel.ts @@ -31,8 +31,11 @@ export function useWorkspaceWorkersViewModel( const workers = data?.pages.flatMap(page => page.data.data.map(adaptWorkerDto)) ?? [] + const totalCount = data?.pages[0]?.data.page.totalCount ?? 0 + return { workers, + totalCount, fetchNextPage, hasNextPage: !!hasNextPage, isFetchingNextPage, diff --git a/src/features/user/home/workspace/hooks/useWorkspacesViewModel.ts b/src/features/user/home/workspace/hooks/useWorkspacesViewModel.ts index d04a2d9..b66800f 100644 --- a/src/features/user/home/workspace/hooks/useWorkspacesViewModel.ts +++ b/src/features/user/home/workspace/hooks/useWorkspacesViewModel.ts @@ -34,8 +34,11 @@ export function useWorkspacesViewModel() { const workspaces = data?.pages.flatMap(page => adaptWorkspaceListResponse(page)) ?? [] + const totalCount = data?.pages[0]?.data.page.totalCount ?? 0 + return { workspaces, + totalCount, fetchNextPage, hasNextPage, isFetchingNextPage, diff --git a/src/pages/manager/home/index.tsx b/src/pages/manager/home/index.tsx index f9079d3..4a5e6c4 100644 --- a/src/pages/manager/home/index.tsx +++ b/src/pages/manager/home/index.tsx @@ -17,12 +17,14 @@ import managerHomeBannerImage from '@/assets/manager-home-banner.jpg' import managerHomeBannerPlusIcon from '@/assets/icons/home/manager-home-banner-plus.svg' import managerWorkspaceModalPlusIcon from '@/assets/icons/home/manager-workspace-modal-plus.svg' import managerScheduleEditIcon from '@/assets/icons/home/edit.svg' +import { shouldShowInfiniteListLoadMore } from '@/shared/lib/listLoadMoreVisibility' export function ManagerHomePage() { const navigate = useNavigate() const { todayWorkers, storeWorkers, + storeWorkersTotalCount, fetchMoreWorkers, hasMoreWorkers, isFetchingMoreWorkers, @@ -193,7 +195,10 @@ export function ManagerHomePage() { onOptions={() => {}} /> ))} - {hasMoreWorkers && ( + {shouldShowInfiniteListLoadMore( + hasMoreWorkers, + storeWorkersTotalCount + ) && ( fetchMoreWorkers()} disabled={isFetchingMoreWorkers} @@ -210,7 +215,14 @@ export function ManagerHomePage() {
fetchMorePostings() : undefined} + onViewMore={ + shouldShowInfiniteListLoadMore( + hasMorePostings, + postingsTotalCount + ) + ? () => fetchMorePostings() + : undefined + } onPostingClick={() => {}} />
@@ -224,7 +236,12 @@ export function ManagerHomePage() { fetchMoreSubstitutes() : undefined + shouldShowInfiniteListLoadMore( + hasMoreSubstitutes, + substituteTotalCount + ) + ? () => fetchMoreSubstitutes() + : undefined } onRequestClick={() => {}} /> diff --git a/src/pages/user/applied-stores/index.tsx b/src/pages/user/applied-stores/index.tsx index 5cc418c..672bc03 100644 --- a/src/pages/user/applied-stores/index.tsx +++ b/src/pages/user/applied-stores/index.tsx @@ -7,6 +7,7 @@ import { type AppliedStoreData, } from '@/features/user' import DownIcon from '@/assets/icons/home/chevron-down.svg?react' +import { shouldShowInfiniteListLoadMore } from '@/shared/lib/listLoadMoreVisibility' export function AppliedStoresPage() { const [selectedStore, setSelectedStore] = useState( @@ -22,6 +23,7 @@ export function AppliedStoresPage() { toggleDropdown, selectFilter, getCardStatus, + totalCount, fetchNextPage, hasNextPage, isFetchingNextPage, @@ -111,7 +113,7 @@ export function AppliedStoresPage() { ))}
- {hasNextPage && ( + {shouldShowInfiniteListLoadMore(hasNextPage, totalCount) && ( + ) : null + } onDateChange={onDateChange} /> diff --git a/src/shared/ui/common/Navbar.tsx b/src/shared/ui/common/Navbar.tsx index 8c567a9..42f0ea1 100644 --- a/src/shared/ui/common/Navbar.tsx +++ b/src/shared/ui/common/Navbar.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, type ReactNode } from 'react' import AlterLogo from '@/assets/Alter-logo.png' import BellIcon from '@/assets/icons/nav/bell.svg' import MenuIcon from '@/assets/icons/nav/menu.svg' @@ -12,12 +12,15 @@ interface NavbarProps { variant?: NavbarVariant title?: string onBackClick?: () => void + /** 상세 헤더(`variant="detail"`) 우측 영역 — 알림·메뉴 자리에 커스텀 노출 시 사용 */ + rightAction?: ReactNode } export function Navbar({ variant = 'main', title = '', onBackClick, + rightAction, }: NavbarProps) { const navigate = useNavigate() const [menuOpen, setMenuOpen] = useState(false) @@ -61,7 +64,7 @@ export function Navbar({
- {isMain && ( + {isMain ? ( <> + ) : ( + rightAction )}
From ac6cb77c66d0cc962315604abb5ebe3d071beb1b Mon Sep 17 00:00:00 2001 From: Dohyeon Date: Sun, 10 May 2026 20:27:53 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20=EA=B7=BC=EB=AC=B4=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=84=A0=ED=83=9D=20=EB=AA=A8=EB=8B=AC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/ui/MonthYearPickerModal.tsx | 2 +- .../common/schedule/ui/MonthlyCalendar.tsx | 8 + .../common/schedule/ui/MonthlyDateCell.tsx | 47 +- .../SubstituteCalendarPickerPanel.tsx | 181 ++++++ .../components/SubstituteRequestModalFlow.tsx | 547 ++++++++++++++++++ src/pages/user/workspace-detail/index.tsx | 26 +- 6 files changed, 789 insertions(+), 22 deletions(-) create mode 100644 src/pages/user/workspace-detail/components/SubstituteCalendarPickerPanel.tsx create mode 100644 src/pages/user/workspace-detail/components/SubstituteRequestModalFlow.tsx diff --git a/src/features/home/common/schedule/ui/MonthYearPickerModal.tsx b/src/features/home/common/schedule/ui/MonthYearPickerModal.tsx index ba98b2e..65cd5dd 100644 --- a/src/features/home/common/schedule/ui/MonthYearPickerModal.tsx +++ b/src/features/home/common/schedule/ui/MonthYearPickerModal.tsx @@ -122,7 +122,7 @@ export function MonthYearPickerModal({ if (!isOpen) return null return ( -
+
+ ) + } + + return
{body}
} diff --git a/src/pages/user/workspace-detail/components/SubstituteCalendarPickerPanel.tsx b/src/pages/user/workspace-detail/components/SubstituteCalendarPickerPanel.tsx new file mode 100644 index 0000000..b3045cf --- /dev/null +++ b/src/pages/user/workspace-detail/components/SubstituteCalendarPickerPanel.tsx @@ -0,0 +1,181 @@ +import { + addMonths, + eachDayOfInterval, + endOfMonth, + endOfWeek, + format, + isSameMonth, + startOfMonth, + startOfWeek, + subMonths, +} from 'date-fns' +import { ko } from 'date-fns/locale' + +import ChevronLeftIcon from '@/assets/icons/nav/chevron-left.svg' +import ChevronRightIcon from '@/assets/icons/my/chevron-right.svg' + +import { WEEKDAY_LABELS } from '@/features/user/home/applied-stores/types/appliedStore' +import { + DATE_KEY_FORMAT, + MONTH_LABEL_FORMAT, +} from '@/features/home/common/schedule/constants/calendar' +import { cn } from '@/shared/lib/utils' + +const HEADER_NAV_ICON_CLASS = 'size-5 shrink-0 brightness-0 invert' + +export interface SubstituteCalendarPickerPanelProps { + /** 표시 월 안의 아무 날짜 */ + baseDate: Date + /** `yyyy-MM-dd` — 선택 없으면 빈 문자열 */ + selectedDateKey: string + onMonthChange: (monthAnchor: Date) => void + onSelectDateKey: (dateKey: string) => void +} + +/** + * Figma `1:546` — 업장 변경/추가 스타일의 월달력 패널 + * — 초록 헤더, 요일 줄(메인색), 행 높이 50px 그리드, 선택일 `#c0f7da`(main-300). + */ +export function SubstituteCalendarPickerPanel({ + baseDate, + selectedDateKey, + onMonthChange, + onSelectDateKey, +}: SubstituteCalendarPickerPanelProps) { + const monthStart = startOfMonth(baseDate) + const monthEnd = endOfMonth(baseDate) + const gridStart = startOfWeek(monthStart, { weekStartsOn: 1 }) + const gridEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }) + + const flatDays = eachDayOfInterval({ + start: gridStart, + end: gridEnd, + }) + + type DayCell = { + dateKey: string + inMonth: boolean + weekday: number + dayNum: string + } + + const cells: DayCell[] = flatDays.map(d => ({ + dateKey: format(d, DATE_KEY_FORMAT), + inMonth: isSameMonth(d, baseDate), + weekday: d.getDay(), + dayNum: format(d, 'd'), + })) + + const rows: DayCell[][] = [] + for (let i = 0; i < cells.length; i += 7) { + rows.push(cells.slice(i, i + 7)) + } + + const goPrevMonth = () => { + onMonthChange(subMonths(baseDate, 1)) + } + + const goNextMonth = () => { + onMonthChange(addMonths(baseDate, 1)) + } + + const dayNumberClassFor = (cell: DayCell, selected: boolean): string => { + if (selected) return 'typography-body01-semibold text-text-100' + if (!cell.inMonth) return 'typography-body01-regular text-text-50' + if (cell.weekday === 6) return 'typography-body01-regular text-subBlue' + if (cell.weekday === 0) return 'typography-body01-regular text-error' + return 'typography-body01-regular text-text-90' + } + + return ( +
+
+ +

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

+ +
+ +
+ {/* 요일 — 흰 둥근 바 안에 문자만 메인색 (Figma 1:546) */} +
+ {WEEKDAY_LABELS.map(d => ( +
+ {d} +
+ ))} +
+ +
+ {rows.map(row => ( +
c.dateKey).join('-')} + className="flex h-[50px] w-full items-center gap-0.5 overflow-hidden rounded-[20px] bg-white" + > + {row.map(cell => { + const selected = + cell.inMonth && + selectedDateKey !== '' && + cell.dateKey === selectedDateKey + + return ( +
+ {cell.inMonth ? ( + + ) : ( + + {cell.dayNum} + + )} +
+ ) + })} +
+ ))} +
+
+
+ ) +} diff --git a/src/pages/user/workspace-detail/components/SubstituteRequestModalFlow.tsx b/src/pages/user/workspace-detail/components/SubstituteRequestModalFlow.tsx new file mode 100644 index 0000000..38a343e --- /dev/null +++ b/src/pages/user/workspace-detail/components/SubstituteRequestModalFlow.tsx @@ -0,0 +1,547 @@ +import { useEffect, useMemo, useState } from 'react' +import { format, parse } from 'date-fns' + +import ChevronLeftIcon from '@/assets/icons/nav/chevron-left.svg' + +import type { + WorkspaceManagerItem, + WorkspaceWorkerItem, +} from '@/features/user/home/workspace/types/workspaceMembers' +import { WEEKDAY_LABELS } from '@/features/user/home/applied-stores/types/appliedStore' +import { DATE_KEY_FORMAT } from '@/features/home/common/schedule/constants/calendar' +import { SubstituteCalendarPickerPanel } from './SubstituteCalendarPickerPanel' +import { + WorkerRoleBadge, + type WorkerRole, +} from '@/shared/ui/home/WorkerRoleBadge' +import { cn } from '@/shared/lib/utils' + +type StepId = 1 | 2 | 3 | 4 | 5 + +interface SubstituteRequestModalFlowProps { + onClose: () => void + /** 모달 상단에 표시할 업장명 */ + storeName: string + managers: WorkspaceManagerItem[] + workers: WorkspaceWorkerItem[] + /** 캘린더 단계 초기 표시 월(페이지 스케줄 `baseDate`와 동기) */ + initialMonth?: Date + /** 요약(3단계)에서 강조할 요일 라벨 */ + summaryHighlightedWeekdays?: readonly string[] + /** 요약 시간 문구 */ + summaryTimeRangeLabel?: string + /** 요약 자기소개 */ + summarySelfIntroduction?: string +} + +const DEFAULT_SUMMARY_HIGHLIGHTED = ['수', '금'] as const +const DEFAULT_TIME = '18:00~20:00 (4시간)' +const DEFAULT_INTRO = + '저는 카페 근처에 거주 하고 있으며, 카페에서 근무한 경험이 있어서 카페에 지원하였습니다.' + +function timeDigits(raw: string, maxLen: number) { + return raw.replace(/\D/g, '').slice(0, maxLen) +} + +function normalizeHourInput(raw: string) { + const d = timeDigits(raw, 2) + if (d === '') return '00' + const n = parseInt(d, 10) + if (Number.isNaN(n)) return '00' + return String(Math.min(23, Math.max(0, n))).padStart(2, '0') +} + +function normalizeMinuteInput(raw: string) { + const d = timeDigits(raw, 2) + if (d === '') return '00' + const n = parseInt(d, 10) + if (Number.isNaN(n)) return '00' + return String(Math.min(59, Math.max(0, n))).padStart(2, '0') +} + +const timeFieldInputClass = + 'min-w-0 flex-1 bg-transparent text-center tabular-nums typography-body01-semibold text-text-90 outline-none placeholder:text-text-50' + +const timeSegmentLabelClass = + 'flex h-[50px] min-w-0 flex-1 cursor-text items-center justify-center gap-1.5 rounded-2xl bg-bg-dark px-3 outline-none transition focus-within:ring-2 focus-within:ring-main' + +/** 대타 요청 다단계 모달: 일 선택 달력 → 근무 시간 → 요약 → 사유 → 근무자 */ +export function SubstituteRequestModalFlow({ + onClose, + storeName, + managers, + workers, + initialMonth, + summaryHighlightedWeekdays = DEFAULT_SUMMARY_HIGHLIGHTED, + summaryTimeRangeLabel = DEFAULT_TIME, + summarySelfIntroduction = DEFAULT_INTRO, +}: SubstituteRequestModalFlowProps) { + const [step, setStep] = useState(1) + const [substituteReason, setSubstituteReason] = useState('') + const [substituteCalendarBaseDate, setSubstituteCalendarBaseDate] = useState( + () => initialMonth ?? new Date() + ) + const [selectedCalendarDate, setSelectedCalendarDate] = useState( + null + ) + const [startHour, setStartHour] = useState('18') + const [startMin, setStartMin] = useState('00') + const [endHour, setEndHour] = useState('20') + const [endMin, setEndMin] = useState('00') + const [selectedCandidateKeys, setSelectedCandidateKeys] = useState< + Set + >(new Set()) + + const highlightSet = useMemo( + () => new Set(summaryHighlightedWeekdays), + [summaryHighlightedWeekdays] + ) + + const candidates = useMemo(() => { + const managerRows = managers.map(m => ({ + key: `m-${m.id}`, + name: m.name, + badge: 'manager' as const satisfies WorkerRole, + })) + const workerRows = workers.map(w => ({ + key: `w-${w.id}`, + name: w.name, + badge: 'staff' as const satisfies WorkerRole, + })) + return [...managerRows, ...workerRows] + }, [managers, workers]) + + useEffect(() => { + const prev = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.body.style.overflow = prev + } + }, []) + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [onClose]) + + const goNext = () => { + setStep(s => (s < 5 ? ((s + 1) as StepId) : s)) + } + + const goBack = () => { + setStep(s => { + if (s <= 1) return s + return (s - 1) as StepId + }) + } + + const finishFlow = () => { + onClose() + } + + const toggleCandidate = (key: string) => { + setSelectedCandidateKeys(prev => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + } + + const modalMaxWidthClass = + step === 1 ? 'max-w-[min(358px,calc(100vw-32px))]' : 'max-w-[318px]' + + const selectedDateKey = + selectedCalendarDate == null + ? '' + : format(selectedCalendarDate, DATE_KEY_FORMAT) + + const onSubstituteCalendarDaySelect = (dateKey: string) => { + setSelectedCalendarDate(parse(dateKey, DATE_KEY_FORMAT, new Date())) + } + + return ( +
+ +
+ + ) : null} + + {/* 2 — 근무 시간 (Figma 1:815 — 출근·퇴근 시간 시:분 단위 표기) */} + {step === 2 ? ( + <> +
+ +

+ 근무 시간 선택 +

+
+
+ +
+
+

+ 출근 시간 +

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

+ 퇴근 시간 +

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

+ {storeName} +

+
+
+ +
+

+ 요일 +

+
+ {WEEKDAY_LABELS.map(day => { + const selected = highlightSet.has(day) + return ( +
+ {selected ? ( + + {day} + + ) : ( + + {day} + + )} +
+ ) + })} +
+
+ +
+

+ 시간 +

+
+

+ {summaryTimeRangeLabel} +

+
+
+ +
+

+ 자기소개 +

+
+

+ {summarySelfIntroduction} +

+
+
+ +
+ +
+ + ) : null} + + {/* 4 — 대타 사유 */} + {step === 4 ? ( + <> +
+ +

+ 대타 사유 입력 +

+
+
+ +
+ +