diff --git a/src/app/App.tsx b/src/app/App.tsx index df2e7c3..54ca404 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -7,6 +7,7 @@ import { Outlet, } from 'react-router-dom' import { ManagerHomePage } from '@/pages/manager/home' +import { ManagerWorkerSchedulePage } from '@/pages/manager/worker-schedule' import { SocialPage } from '@/pages/manager/social' import { SocialChatPage } from '@/pages/manager/social-chat' import { LoginPage } from '@/pages/login' @@ -14,6 +15,9 @@ import { JobLookupMapPage } from '@/pages/user/job-lookup-map' import { SchedulePage } from '@/pages/user/schedule' import { UserHomePage } from '@/pages/user/home' import { WorkspaceMembersPage } from '@/pages/user/workspace-members' +import { WorkspacePage } from '@/pages/user/workspace' +import { WorkspaceDetailPage } from '@/pages/user/workspace-detail' +import { AppliedStoresPage } from '@/pages/user/applied-stores' import { MobileLayout } from '@/shared/ui/MobileLayout' import { MobileLayoutWithDocbar } from '@/shared/ui/MobileLayoutWithDocbar' @@ -58,16 +62,26 @@ export function App() { } /> - } /> + } /> } /> + } /> + } + /> + } /> + } + /> }> - } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/src/assets/icons/home/crown-solid.svg b/src/assets/icons/home/crown-solid.svg new file mode 100644 index 0000000..b731c8a --- /dev/null +++ b/src/assets/icons/home/crown-solid.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/home/manager-home-banner-plus.svg b/src/assets/icons/home/manager-home-banner-plus.svg new file mode 100644 index 0000000..fbc00fb --- /dev/null +++ b/src/assets/icons/home/manager-home-banner-plus.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/icons/home/manager-workspace-modal-plus.svg b/src/assets/icons/home/manager-workspace-modal-plus.svg new file mode 100644 index 0000000..98ce654 --- /dev/null +++ b/src/assets/icons/home/manager-workspace-modal-plus.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/icons/home/users.svg b/src/assets/icons/home/users.svg new file mode 100644 index 0000000..476d9e7 --- /dev/null +++ b/src/assets/icons/home/users.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/manager-home-banner.jpg b/src/assets/manager-home-banner.jpg new file mode 100644 index 0000000..9da6f65 Binary files /dev/null and b/src/assets/manager-home-banner.jpg differ diff --git a/src/features/home/user/constants/calendar.ts b/src/features/home/common/schedule/constants/calendar.ts similarity index 100% rename from src/features/home/user/constants/calendar.ts rename to src/features/home/common/schedule/constants/calendar.ts diff --git a/src/features/home/user/hooks/useMonthlyCalendarViewModel.ts b/src/features/home/common/schedule/hooks/useMonthlyCalendarViewModel.ts similarity index 92% rename from src/features/home/user/hooks/useMonthlyCalendarViewModel.ts rename to src/features/home/common/schedule/hooks/useMonthlyCalendarViewModel.ts index 6619cac..5c25968 100644 --- a/src/features/home/user/hooks/useMonthlyCalendarViewModel.ts +++ b/src/features/home/common/schedule/hooks/useMonthlyCalendarViewModel.ts @@ -14,15 +14,15 @@ import { DATE_KEY_FORMAT, MONTH_LABEL_FORMAT, WEEKDAY_LABELS_MONDAY_FIRST, -} from '@/features/home/user/constants/calendar' -import { useMonthlyDateCellsState } from '@/features/home/user/hooks/useMonthlyDateCellsState' +} from '@/features/home/common/schedule/constants/calendar' +import { useMonthlyDateCellsState } from '@/features/home/common/schedule/hooks/useMonthlyDateCellsState' import type { MonthlyCalendarViewModel, MonthlyCellInput, MonthlyDayMetrics, MonthlyCalendarPropsBase, -} from '@/features/home/user/types/monthlyCalendar' -import type { CalendarViewData } from '@/features/home/user/types/schedule' +} from '@/features/home/common/schedule/types/monthlyCalendar' +import type { CalendarViewData } from '@/features/home/common/schedule/types/calendarView' function getMonthlyCells(baseDate: Date): MonthlyCellInput[] { const monthStart = startOfMonth(baseDate) diff --git a/src/features/home/user/hooks/useMonthlyDateCellsState.ts b/src/features/home/common/schedule/hooks/useMonthlyDateCellsState.ts similarity index 95% rename from src/features/home/user/hooks/useMonthlyDateCellsState.ts rename to src/features/home/common/schedule/hooks/useMonthlyDateCellsState.ts index 25dde6f..9cf16b9 100644 --- a/src/features/home/user/hooks/useMonthlyDateCellsState.ts +++ b/src/features/home/common/schedule/hooks/useMonthlyDateCellsState.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import type { UseMonthlyDateCellsStateParams } from '@/features/home/user/types/monthlyCalendar' +import type { UseMonthlyDateCellsStateParams } from '@/features/home/common/schedule/types/monthlyCalendar' export function useMonthlyDateCellsState({ cells, diff --git a/src/features/home/common/schedule/lib/date.ts b/src/features/home/common/schedule/lib/date.ts new file mode 100644 index 0000000..464737e --- /dev/null +++ b/src/features/home/common/schedule/lib/date.ts @@ -0,0 +1,18 @@ +const ISO_DATE_LENGTH = 10 +const ISO_TIME_START = 11 +const ISO_TIME_END = 16 + +export function toDateKey(iso: string) { + return iso.slice(0, ISO_DATE_LENGTH) +} + +export function toTimeLabel(iso: string) { + return iso.slice(ISO_TIME_START, ISO_TIME_END) +} + +export function getDurationHours(startIso: string, endIso: string) { + const start = new Date(startIso).getTime() + const end = new Date(endIso).getTime() + const diffHours = Math.max((end - start) / (1000 * 60 * 60), 0) + return Number(diffHours.toFixed(1)) +} diff --git a/src/features/home/user/types/calendar.ts b/src/features/home/common/schedule/types/calendarBase.ts similarity index 55% rename from src/features/home/user/types/calendar.ts rename to src/features/home/common/schedule/types/calendarBase.ts index 05af996..53f0779 100644 --- a/src/features/home/user/types/calendar.ts +++ b/src/features/home/common/schedule/types/calendarBase.ts @@ -1,4 +1,4 @@ -import type { CalendarViewData } from '@/features/home/user/types/schedule' +import type { CalendarViewData } from '@/features/home/common/schedule/types/calendarView' export interface BaseCalendarProps { baseDate: Date diff --git a/src/features/home/common/schedule/types/calendarView.ts b/src/features/home/common/schedule/types/calendarView.ts new file mode 100644 index 0000000..54fd831 --- /dev/null +++ b/src/features/home/common/schedule/types/calendarView.ts @@ -0,0 +1,24 @@ +import type { StatusEnum } from '@/shared/types/enums' + +export interface CalendarEvent { + shiftId: number + workspaceName: string + position: string + status: StatusEnum + startDateTime: string + endDateTime: string + dateKey: string + startTimeLabel: string + endTimeLabel: string + durationHours: number +} + +export interface CalendarSummary { + totalWorkHours: number + eventCount: number +} + +export interface CalendarViewData { + summary: CalendarSummary + events: CalendarEvent[] +} diff --git a/src/features/home/user/types/monthlyCalendar.ts b/src/features/home/common/schedule/types/monthlyCalendar.ts similarity index 87% rename from src/features/home/user/types/monthlyCalendar.ts rename to src/features/home/common/schedule/types/monthlyCalendar.ts index bed9761..f721a4a 100644 --- a/src/features/home/user/types/monthlyCalendar.ts +++ b/src/features/home/common/schedule/types/monthlyCalendar.ts @@ -1,5 +1,5 @@ -import type { WEEKDAY_LABELS_MONDAY_FIRST } from '@/features/home/user/constants/calendar' -import type { BaseCalendarProps } from '@/features/home/user/types/calendar' +import type { WEEKDAY_LABELS_MONDAY_FIRST } from '@/features/home/common/schedule/constants/calendar' +import type { BaseCalendarProps } from '@/features/home/common/schedule/types/calendarBase' export interface MonthlyCellInput { dateKey: string diff --git a/src/features/home/common/schedule/ui/MonthlyCalendar.tsx b/src/features/home/common/schedule/ui/MonthlyCalendar.tsx new file mode 100644 index 0000000..34e99f9 --- /dev/null +++ b/src/features/home/common/schedule/ui/MonthlyCalendar.tsx @@ -0,0 +1,108 @@ +import type { ReactNode } from 'react' +import DownIcon from '@/assets/icons/home/chevron-down.svg?react' +import { useMonthlyCalendarViewModel } from '@/features/home/common/schedule/hooks/useMonthlyCalendarViewModel' +import type { MonthlyCalendarPropsBase } from '@/features/home/common/schedule/types/monthlyCalendar' +import { MonthlyDateCell } from '@/features/home/common/schedule/ui/MonthlyDateCell' + +interface MonthlyCalendarProps extends MonthlyCalendarPropsBase { + isLoading?: boolean + hideTitle?: boolean + rightAction?: ReactNode + estimatedEarningsText?: string + layout?: 'default' | 'manager' +} + +export function MonthlyCalendar({ + baseDate, + data, + workspaceName, + isLoading = false, + selectedDateKey, + hideTitle = false, + rightAction, + estimatedEarningsText, + layout = 'default', +}: MonthlyCalendarProps) { + const { + title, + monthLabel, + totalWorkHoursText, + weekdayLabels, + monthlyDateCellsState, + } = useMonthlyCalendarViewModel({ + baseDate, + data, + workspaceName, + selectedDateKey, + }) + + if (isLoading) { + return ( +
+

월간 일정을 불러오는 중...

+
+ ) + } + + return ( +
+
+ {!hideTitle &&

{title}

} + +
+ + {rightAction} +
+ +
+
+ {totalWorkHoursText} + 시간 근무해요 +
+ {estimatedEarningsText ? ( + + {estimatedEarningsText} + + ) : null} +
+
+ +
+
+ {weekdayLabels.map((label, index) => ( + + {label} + + ))} +
+ +
+ {monthlyDateCellsState.map(cell => { + return ( + + ) + })} +
+
+
+ ) +} diff --git a/src/features/home/user/ui/MonthlyDateCell.tsx b/src/features/home/common/schedule/ui/MonthlyDateCell.tsx similarity index 89% rename from src/features/home/user/ui/MonthlyDateCell.tsx rename to src/features/home/common/schedule/ui/MonthlyDateCell.tsx index d2f038b..42e1f28 100644 --- a/src/features/home/user/ui/MonthlyDateCell.tsx +++ b/src/features/home/common/schedule/ui/MonthlyDateCell.tsx @@ -1,4 +1,4 @@ -import { MonthlyDateGauge } from '@/features/home/user/ui/MonthlyDateGauge' +import { MonthlyDateGauge } from '@/features/home/common/schedule/ui/MonthlyDateGauge' interface MonthlyDateCellProps { dayText: string @@ -20,7 +20,7 @@ export function MonthlyDateCell({ const dayTextColor = !isCurrentMonth ? 'text-text-50' : isWeekend - ? 'text-[#DC0000]' + ? 'text-error' : 'text-text-50' return ( diff --git a/src/features/home/user/ui/MonthlyDateGauge.tsx b/src/features/home/common/schedule/ui/MonthlyDateGauge.tsx similarity index 100% rename from src/features/home/user/ui/MonthlyDateGauge.tsx rename to src/features/home/common/schedule/ui/MonthlyDateGauge.tsx diff --git a/src/features/home/index.ts b/src/features/home/index.ts index ebdc3b0..8caf520 100644 --- a/src/features/home/index.ts +++ b/src/features/home/index.ts @@ -1,18 +1,16 @@ -export { HomeScheduleCalendar } from '@/features/home/user/ui/HomeScheduleCalendar' +export { HomeScheduleCalendar } from '@/features/home/user/schedule/ui/HomeScheduleCalendar' export { TodayWorkerList } from '@/features/home/manager/ui/TodayWorkerList' export { StoreWorkerListItem } from '@/features/home/manager/ui/StoreWorkerListItem' export { WorkspaceChangeCard } from '@/features/home/manager/ui/WorkspaceChangeCard' export { WorkspaceChangeList } from '@/features/home/manager/ui/WorkspaceChangeList' -export { AppliedStoreCard } from '@/features/home/user/ui/AppliedStoreCard' -export { AppliedStoreList } from '@/features/home/user/ui/AppliedStoreList' -export { WorkingStoresList } from '@/features/home/user/ui/WorkingStoresList' -export { WorkingStoreCard } from '@/features/home/user/ui/WorkingStoreCard' -export type { - HomeCalendarMode, - CalendarViewData, -} from '@/features/home/user/types/schedule' +export { AppliedStoreCard } from '@/features/home/user/applied-stores/ui/AppliedStoreCard' +export { AppliedStoreList } from '@/features/home/user/applied-stores/ui/AppliedStoreList' +export { AppliedStoreDetailModal } from '@/features/home/user/applied-stores/ui/AppliedStoreDetailModal' +export { WorkingStoresList } from '@/features/home/user/workspace/ui/WorkingStoresList' +export { WorkingStoreCard } from '@/features/home/user/workspace/ui/WorkingStoreCard' +export type { HomeCalendarMode } from '@/features/home/user/schedule/types/schedule' +export type { CalendarViewData } from '@/features/home/common/schedule/types/calendarView' export { - getMonthlySchedules, - getWeeklySchedules, - getDailySchedules, -} from '@/features/home/user/api/schedule' + getSelfSchedule, + adaptScheduleResponse, +} from '@/features/home/user/schedule/api/schedule' diff --git a/src/features/home/manager/api/posting.ts b/src/features/home/manager/api/posting.ts new file mode 100644 index 0000000..b8271af --- /dev/null +++ b/src/features/home/manager/api/posting.ts @@ -0,0 +1,24 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { + PostingListApiResponse, + ManagedPostingsQueryParams, +} from '@/features/home/manager/types/posting' + +export async function fetchManagedPostings( + params: ManagedPostingsQueryParams +): Promise { + const response = await axiosInstance.get( + '/manager/postings', + { + params: { + pageSize: params.pageSize, + ...(params.workspaceId !== undefined && { + workspaceId: params.workspaceId, + }), + ...(params.status && { status: params.status }), + ...(params.cursor !== undefined && { cursor: params.cursor }), + }, + } + ) + return response.data +} diff --git a/src/features/home/manager/api/schedule.ts b/src/features/home/manager/api/schedule.ts new file mode 100644 index 0000000..185d4f1 --- /dev/null +++ b/src/features/home/manager/api/schedule.ts @@ -0,0 +1,21 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { + ManagerScheduleApiResponse, + ManagerScheduleQueryParams, +} from '@/features/home/manager/types/schedule' + +export async function fetchMonthlySchedules( + params: ManagerScheduleQueryParams +): Promise { + const response = await axiosInstance.get( + '/manager/schedules', + { + params: { + workspaceId: params.workspaceId, + year: params.year, + month: params.month, + }, + } + ) + return response.data +} diff --git a/src/features/home/manager/api/substitute.ts b/src/features/home/manager/api/substitute.ts new file mode 100644 index 0000000..ae01a83 --- /dev/null +++ b/src/features/home/manager/api/substitute.ts @@ -0,0 +1,24 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { + SubstituteListApiResponse, + SubstituteRequestsQueryParams, +} from '@/features/home/manager/types/substitute' + +export async function fetchSubstituteRequests( + params: SubstituteRequestsQueryParams +): Promise { + const response = await axiosInstance.get( + '/manager/substitute-requests', + { + params: { + pageSize: params.pageSize, + ...(params.workspaceId !== undefined && { + workspaceId: params.workspaceId, + }), + ...(params.status && { status: params.status }), + ...(params.cursor !== undefined && { cursor: params.cursor }), + }, + } + ) + return response.data +} diff --git a/src/features/home/manager/api/worker.ts b/src/features/home/manager/api/worker.ts new file mode 100644 index 0000000..f318480 --- /dev/null +++ b/src/features/home/manager/api/worker.ts @@ -0,0 +1,23 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { + WorkersApiResponse, + WorkspaceWorkersQueryParams, +} from '@/features/home/manager/types/worker' + +export async function fetchWorkspaceWorkers( + params: WorkspaceWorkersQueryParams +): Promise { + const { workspaceId, cursor, pageSize, status, name } = params + const response = await axiosInstance.get( + `/manager/workspaces/${workspaceId}/workers`, + { + params: { + pageSize, + ...(cursor !== undefined && { cursor }), + ...(status && { status }), + ...(name && { name }), + }, + } + ) + return response.data +} diff --git a/src/features/home/manager/api/workspace.ts b/src/features/home/manager/api/workspace.ts new file mode 100644 index 0000000..ee0d624 --- /dev/null +++ b/src/features/home/manager/api/workspace.ts @@ -0,0 +1,21 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { + ManagedWorkspacesApiResponse, + WorkspaceDetailApiResponse, +} from '@/features/home/manager/types/workspace' + +export async function fetchManagedWorkspaces(): Promise { + const response = await axiosInstance.get( + '/manager/workspaces' + ) + return response.data +} + +export async function fetchWorkspaceDetail( + workspaceId: number +): Promise { + const response = await axiosInstance.get( + `/manager/workspaces/${workspaceId}` + ) + return response.data +} diff --git a/src/features/home/manager/hooks/useManagedPostingsViewModel.ts b/src/features/home/manager/hooks/useManagedPostingsViewModel.ts new file mode 100644 index 0000000..03db2e6 --- /dev/null +++ b/src/features/home/manager/hooks/useManagedPostingsViewModel.ts @@ -0,0 +1,55 @@ +import { useMemo } from 'react' +import { useInfiniteQuery } from '@tanstack/react-query' +import { fetchManagedPostings } from '@/features/home/manager/api/posting' +import { adaptPostingDto } from '@/features/home/manager/types/posting' +import { queryKeys } from '@/shared/lib/queryKeys' + +const PAGE_SIZE = 10 + +export function useManagedPostingsViewModel( + workspaceId: number | null, + params?: { status?: string } +) { + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isError, + } = useInfiniteQuery({ + queryKey: queryKeys.posting.list({ + workspaceId: workspaceId ?? undefined, + status: params?.status, + pageSize: PAGE_SIZE, + }), + queryFn: ({ pageParam }) => + fetchManagedPostings({ + pageSize: PAGE_SIZE, + workspaceId: workspaceId ?? undefined, + status: params?.status, + cursor: pageParam as string | undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.data.page.cursor ?? undefined, + enabled: workspaceId !== null, + }) + + const postings = useMemo( + () => + data?.pages.flatMap(page => page.data.data.map(adaptPostingDto)) ?? [], + [data] + ) + + const totalCount = data?.pages[0]?.data.page.totalCount ?? 0 + + return { + postings, + totalCount, + fetchNextPage, + hasNextPage: !!hasNextPage, + isFetchingNextPage, + isLoading: isPending && workspaceId !== null, + isError, + } +} diff --git a/src/features/home/manager/hooks/useManagedWorkspacesQuery.ts b/src/features/home/manager/hooks/useManagedWorkspacesQuery.ts new file mode 100644 index 0000000..21a83d0 --- /dev/null +++ b/src/features/home/manager/hooks/useManagedWorkspacesQuery.ts @@ -0,0 +1,39 @@ +import { useEffect, useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { fetchManagedWorkspaces } from '@/features/home/manager/api/workspace' +import { useWorkspaceStore } from '@/shared/stores/useWorkspaceStore' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useManagedWorkspacesQuery() { + const { activeWorkspaceId, setActiveWorkspaceId } = useWorkspaceStore() + + const { data, isPending, isError } = useQuery({ + queryKey: queryKeys.managerWorkspace.list(), + queryFn: fetchManagedWorkspaces, + }) + + const workspaces = useMemo(() => data?.data ?? [], [data]) + + // ID가 가장 작은 업장을 기본값으로 설정 + useEffect(() => { + if (workspaces.length === 0) return + const hasValidActiveWorkspace = + activeWorkspaceId !== null && + workspaces.some(workspace => workspace.id === activeWorkspaceId) + + if (hasValidActiveWorkspace) return + + const defaultWorkspace = workspaces.reduce((prev, curr) => + curr.id < prev.id ? curr : prev + ) + setActiveWorkspaceId(defaultWorkspace.id) + }, [workspaces, activeWorkspaceId, setActiveWorkspaceId]) + + return { + workspaces, + activeWorkspaceId, + setActiveWorkspaceId, + isLoading: isPending, + isError, + } +} diff --git a/src/features/home/manager/hooks/useManagerHomeViewModel.ts b/src/features/home/manager/hooks/useManagerHomeViewModel.ts new file mode 100644 index 0000000..578e08f --- /dev/null +++ b/src/features/home/manager/hooks/useManagerHomeViewModel.ts @@ -0,0 +1,116 @@ +import { useEffect, useState } from 'react' +import type { TodayWorkerItem } from '@/features/home/manager/ui/TodayWorkerList' +import { useManagedWorkspacesQuery } from '@/features/home/manager/hooks/useManagedWorkspacesQuery' +import { useWorkspaceDetailQuery } from '@/features/home/manager/hooks/useWorkspaceDetailQuery' +import { useWorkspaceWorkersViewModel } from '@/features/home/manager/hooks/useWorkspaceWorkersViewModel' +import { useManagedPostingsViewModel } from '@/features/home/manager/hooks/useManagedPostingsViewModel' +import { useSubstituteRequestsViewModel } from '@/features/home/manager/hooks/useSubstituteRequestsViewModel' +import { useMonthlySchedulesViewModel } from '@/features/home/manager/hooks/useMonthlySchedulesViewModel' + +const TODAY_WORKERS: TodayWorkerItem[] = [ + { id: '1', name: '알바생1', workTime: '00:00 ~ 00:00' }, + { id: '2', name: '알바생2', workTime: '00:00 ~ 00:00' }, +] + +export function useManagerHomeViewModel() { + const [isWorkspaceChangeModalOpen, setIsWorkspaceChangeModalOpen] = + useState(false) + + const { workspaces, activeWorkspaceId, setActiveWorkspaceId } = + useManagedWorkspacesQuery() + + const { detail: workspaceDetail } = useWorkspaceDetailQuery(activeWorkspaceId) + + const { + workers: storeWorkers, + fetchNextPage: fetchMoreWorkers, + hasNextPage: hasMoreWorkers, + isFetchingNextPage: isFetchingMoreWorkers, + } = useWorkspaceWorkersViewModel(activeWorkspaceId) + + const { + postings: ongoingPostings, + totalCount: postingsTotalCount, + fetchNextPage: fetchMorePostings, + hasNextPage: hasMorePostings, + } = useManagedPostingsViewModel(activeWorkspaceId, { status: 'OPEN' }) + + const { + requests: substituteRequests, + totalCount: substituteTotalCount, + fetchNextPage: fetchMoreSubstitutes, + hasNextPage: hasMoreSubstitutes, + } = useSubstituteRequestsViewModel(activeWorkspaceId) + + const { + baseDate: scheduleBaseDate, + calendarData, + selectedDateKey, + isLoading: isScheduleLoading, + goToPrevMonth, + goToNextMonth, + } = useMonthlySchedulesViewModel(activeWorkspaceId) + + useEffect(() => { + if (!isWorkspaceChangeModalOpen) return + + const previousOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + + return () => { + document.body.style.overflow = previousOverflow + } + }, [isWorkspaceChangeModalOpen]) + + useEffect(() => { + if (!isWorkspaceChangeModalOpen) return + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsWorkspaceChangeModalOpen(false) + } + } + + window.addEventListener('keydown', handleKeyDown) + + return () => { + window.removeEventListener('keydown', handleKeyDown) + } + }, [isWorkspaceChangeModalOpen]) + + return { + todayWorkers: TODAY_WORKERS, + storeWorkers, + fetchMoreWorkers, + hasMoreWorkers, + isFetchingMoreWorkers, + ongoingPostings, + postingsTotalCount, + fetchMorePostings, + hasMorePostings, + substituteRequests, + substituteTotalCount, + fetchMoreSubstitutes, + hasMoreSubstitutes, + schedule: { + baseDate: scheduleBaseDate, + selectedDateKey, + data: calendarData, + isLoading: isScheduleLoading, + goToPrevMonth, + goToNextMonth, + }, + workspaceDetail, + workspaceChangeModal: { + isOpen: isWorkspaceChangeModalOpen, + items: workspaces, + selectedWorkspaceId: activeWorkspaceId, + }, + openWorkspaceChangeModal: () => setIsWorkspaceChangeModalOpen(true), + closeWorkspaceChangeModal: () => setIsWorkspaceChangeModalOpen(false), + selectWorkspace: (workspaceId: number) => { + setActiveWorkspaceId(workspaceId) + setIsWorkspaceChangeModalOpen(false) + }, + } +} diff --git a/src/features/home/manager/hooks/useMonthlySchedulesViewModel.ts b/src/features/home/manager/hooks/useMonthlySchedulesViewModel.ts new file mode 100644 index 0000000..ccb27ae --- /dev/null +++ b/src/features/home/manager/hooks/useMonthlySchedulesViewModel.ts @@ -0,0 +1,58 @@ +import { useCallback, useMemo, useState } from 'react' +import { addMonths, format } from 'date-fns' +import { useQuery } from '@tanstack/react-query' +import { fetchMonthlySchedules } from '@/features/home/manager/api/schedule' +import { adaptManagerScheduleResponse } from '@/features/home/manager/types/schedule' +import { queryKeys } from '@/shared/lib/queryKeys' + +const DATE_KEY_FORMAT = 'yyyy-MM-dd' + +export function useMonthlySchedulesViewModel(workspaceId: number | null) { + const [baseDate, setBaseDate] = useState(() => new Date()) + + const year = baseDate.getFullYear() + const month = baseDate.getMonth() + 1 + + const { data: rawData, isPending } = useQuery({ + queryKey: queryKeys.manager.schedules(workspaceId ?? 0, year, month), + queryFn: () => + fetchMonthlySchedules({ workspaceId: workspaceId!, year, month }), + enabled: workspaceId !== null, + }) + + const calendarData = useMemo( + () => (rawData ? adaptManagerScheduleResponse(rawData) : null), + [rawData] + ) + + // 선택된 날짜: 오늘이 현재 월이면 오늘, 아니면 해당 월 1일 + const selectedDateKey = useMemo(() => { + const today = new Date() + const todayKey = format(today, DATE_KEY_FORMAT) + const isSameMonth = + today.getFullYear() === year && today.getMonth() + 1 === month + return isSameMonth ? todayKey : format(baseDate, 'yyyy-MM-01') + }, [baseDate, year, month]) + + const onDateChange = useCallback((nextDate: Date) => { + setBaseDate(nextDate) + }, []) + + const goToPrevMonth = useCallback(() => { + setBaseDate(prev => addMonths(prev, -1)) + }, []) + + const goToNextMonth = useCallback(() => { + setBaseDate(prev => addMonths(prev, 1)) + }, []) + + return { + baseDate, + calendarData, + selectedDateKey, + isLoading: isPending && workspaceId !== null, + onDateChange, + goToPrevMonth, + goToNextMonth, + } +} diff --git a/src/features/home/manager/hooks/useSubstituteRequestsViewModel.ts b/src/features/home/manager/hooks/useSubstituteRequestsViewModel.ts new file mode 100644 index 0000000..8bbb15e --- /dev/null +++ b/src/features/home/manager/hooks/useSubstituteRequestsViewModel.ts @@ -0,0 +1,57 @@ +import { useMemo } from 'react' +import { useInfiniteQuery } from '@tanstack/react-query' +import { fetchSubstituteRequests } from '@/features/home/manager/api/substitute' +import { adaptSubstituteRequestDto } from '@/features/home/manager/types/substitute' +import { queryKeys } from '@/shared/lib/queryKeys' + +const PAGE_SIZE = 10 + +export function useSubstituteRequestsViewModel( + workspaceId: number | null, + params?: { status?: string } +) { + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isError, + } = useInfiniteQuery({ + queryKey: queryKeys.substitute.list({ + workspaceId: workspaceId ?? undefined, + status: params?.status, + pageSize: PAGE_SIZE, + }), + queryFn: ({ pageParam }) => + fetchSubstituteRequests({ + pageSize: PAGE_SIZE, + workspaceId: workspaceId ?? undefined, + status: params?.status, + cursor: pageParam as string | undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.data.page.cursor ?? undefined, + enabled: workspaceId !== null, + }) + + const requests = useMemo( + () => + data?.pages.flatMap(page => + page.data.data.map(adaptSubstituteRequestDto) + ) ?? [], + [data] + ) + + const totalCount = data?.pages[0]?.data.page.totalCount ?? 0 + + return { + requests, + totalCount, + fetchNextPage, + hasNextPage: !!hasNextPage, + isFetchingNextPage, + isLoading: isPending && workspaceId !== null, + isError, + } +} diff --git a/src/features/home/manager/hooks/useWorkerScheduleManageViewModel.ts b/src/features/home/manager/hooks/useWorkerScheduleManageViewModel.ts new file mode 100644 index 0000000..35787bc --- /dev/null +++ b/src/features/home/manager/hooks/useWorkerScheduleManageViewModel.ts @@ -0,0 +1,45 @@ +import { useMemo, useState } from 'react' + +const WORKDAY_OPTIONS = ['월', '화', '수', '목', '금', '토', '일'] as const + +const DEFAULT_SELECTED_DAYS = ['수', '금'] + +const DEFAULT_TIME = { + startHour: '00', + startMinute: '00', + endHour: '00', + endMinute: '00', +} + +export function useWorkerScheduleManageViewModel() { + const [selectedDays, setSelectedDays] = useState( + DEFAULT_SELECTED_DAYS + ) + + const workTimeRangeLabel = useMemo( + () => + `${DEFAULT_TIME.startHour}:${DEFAULT_TIME.startMinute} ~ ${DEFAULT_TIME.endHour}:${DEFAULT_TIME.endMinute}`, + [] + ) + + function toggleDay(day: string) { + setSelectedDays(prev => + prev.includes(day) ? prev.filter(item => item !== day) : [...prev, day] + ) + } + + return { + worker: { + name: '이름임', + role: 'manager' as const, + }, + workdayOptions: WORKDAY_OPTIONS, + selectedDays, + workTimeRangeLabel, + startHour: DEFAULT_TIME.startHour, + startMinute: DEFAULT_TIME.startMinute, + endHour: DEFAULT_TIME.endHour, + endMinute: DEFAULT_TIME.endMinute, + toggleDay, + } +} diff --git a/src/features/home/manager/hooks/useWorkspaceDetailQuery.ts b/src/features/home/manager/hooks/useWorkspaceDetailQuery.ts new file mode 100644 index 0000000..7d0beb6 --- /dev/null +++ b/src/features/home/manager/hooks/useWorkspaceDetailQuery.ts @@ -0,0 +1,20 @@ +import { useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { fetchWorkspaceDetail } from '@/features/home/manager/api/workspace' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useWorkspaceDetailQuery(workspaceId: number | null) { + const { data, isPending, isError } = useQuery({ + queryKey: queryKeys.managerWorkspace.detail(workspaceId!), + queryFn: () => fetchWorkspaceDetail(workspaceId!), + enabled: workspaceId !== null, + }) + + const detail = useMemo(() => data?.data ?? null, [data]) + + return { + detail, + isLoading: isPending && workspaceId !== null, + isError, + } +} diff --git a/src/features/home/manager/hooks/useWorkspaceWorkersViewModel.ts b/src/features/home/manager/hooks/useWorkspaceWorkersViewModel.ts new file mode 100644 index 0000000..95e3339 --- /dev/null +++ b/src/features/home/manager/hooks/useWorkspaceWorkersViewModel.ts @@ -0,0 +1,52 @@ +import { useMemo } from 'react' +import { useInfiniteQuery } from '@tanstack/react-query' +import { fetchWorkspaceWorkers } from '@/features/home/manager/api/worker' +import { adaptWorkerDto } from '@/features/home/manager/types/worker' +import { queryKeys } from '@/shared/lib/queryKeys' + +const PAGE_SIZE = 20 + +export function useWorkspaceWorkersViewModel( + workspaceId: number | null, + params?: { status?: string; name?: string } +) { + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isError, + } = useInfiniteQuery({ + queryKey: queryKeys.managerWorkspace.workers(workspaceId ?? 0, { + status: params?.status, + name: params?.name, + pageSize: PAGE_SIZE, + }), + queryFn: ({ pageParam }) => + fetchWorkspaceWorkers({ + workspaceId: workspaceId!, + pageSize: PAGE_SIZE, + cursor: pageParam as string | undefined, + status: params?.status, + name: params?.name, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.data.page.cursor ?? undefined, + enabled: workspaceId !== null, + }) + + const workers = useMemo( + () => data?.pages.flatMap(page => page.data.data.map(adaptWorkerDto)) ?? [], + [data] + ) + + return { + workers, + fetchNextPage, + hasNextPage: !!hasNextPage, + isFetchingNextPage, + isLoading: isPending && workspaceId !== null, + isError, + } +} diff --git a/src/features/home/manager/types/posting.ts b/src/features/home/manager/types/posting.ts new file mode 100644 index 0000000..b627d1f --- /dev/null +++ b/src/features/home/manager/types/posting.ts @@ -0,0 +1,111 @@ +import type { CommonApiResponse } from '@/shared/types/common' +import type { JobPostingItem } from '@/shared/ui/manager/OngoingPostingCard' + +// ---- API DTOs ---- +export interface PostingKeywordDto { + id: number + name: string +} + +export interface PostingScheduleDto { + workingDays: string[] + startTime: string + endTime: string + positionsNeeded: number + position: number +} + +export interface PostingWorkspaceDto { + id: number + businessName: string +} + +export interface PostingDto { + id: number + title: string + payAmount: number + paymentType: string + createdAt: string + keywords: PostingKeywordDto[] + schedules: PostingScheduleDto[] + workspace: PostingWorkspaceDto +} + +export interface PostingPageDto { + cursor: string | null + pageSize: number + totalCount: number +} + +export type PostingListApiResponse = CommonApiResponse<{ + page: PostingPageDto + data: PostingDto[] +}> + +// ---- Query Params ---- +export interface ManagedPostingsQueryParams { + workspaceId?: number + status?: string + cursor?: string + pageSize: number +} + +// ---- Mappers ---- +const PAYMENT_TYPE_LABEL: Record = { + HOURLY: '시급', + DAILY: '일급', + MONTHLY: '월급', + WEEKLY: '주급', +} + +const WORKING_DAY_KO: Record = { + MONDAY: '월', + TUESDAY: '화', + WEDNESDAY: '수', + THURSDAY: '목', + FRIDAY: '금', + SATURDAY: '토', + SUNDAY: '일', +} + +function formatWage(payAmount: number, paymentType: string): string { + const label = PAYMENT_TYPE_LABEL[paymentType] ?? paymentType + const amount = payAmount.toLocaleString('ko-KR') + return `${label} ${amount}원` +} + +function formatWorkHours(schedules: PostingScheduleDto[]): string { + if (schedules.length === 0) return '-' + const first = schedules[0] + const base = `${first.startTime} ~ ${first.endTime}` + return schedules.length > 1 ? `${base} 외 ${schedules.length - 1}개` : base +} + +function formatWorkDays(schedules: PostingScheduleDto[]): string { + if (schedules.length === 0) return '-' + // 모든 스케줄의 요일을 합산 후 중복 제거 + 요일 순서 정렬 + const DAY_ORDER = [ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ] + const daySet = new Set(schedules.flatMap(s => s.workingDays)) + return DAY_ORDER.filter(d => daySet.has(d)) + .map(d => WORKING_DAY_KO[d] ?? d) + .join(', ') +} + +export function adaptPostingDto(dto: PostingDto): JobPostingItem { + return { + id: String(dto.id), + dDay: '', + title: dto.title, + wage: formatWage(dto.payAmount, dto.paymentType), + workHours: formatWorkHours(dto.schedules), + workDays: formatWorkDays(dto.schedules), + } +} diff --git a/src/features/home/manager/types/schedule.ts b/src/features/home/manager/types/schedule.ts new file mode 100644 index 0000000..1b47c88 --- /dev/null +++ b/src/features/home/manager/types/schedule.ts @@ -0,0 +1,76 @@ +import type { CommonApiResponse } from '@/shared/types/common' +import type { StatusEnum } from '@/shared/types/enums' +import type { + CalendarEvent, + CalendarViewData, +} from '@/features/home/common/schedule/types/calendarView' +import { + toDateKey, + toTimeLabel, + getDurationHours, +} from '@/features/home/common/schedule/lib/date' + +// ---- API DTOs ---- +export interface ManagerScheduleWorkspaceDto { + workspaceId: number + workspaceName: string +} + +export interface ManagerScheduleWorkerDto { + workerId: number + workerName: string +} + +export interface ManagerScheduleStatusDto { + value: string + description: string +} + +export interface ManagerScheduleDto { + shiftId: number + workspace: ManagerScheduleWorkspaceDto + assignedWorker: ManagerScheduleWorkerDto + startDateTime: string + endDateTime: string + position: string + status: ManagerScheduleStatusDto +} + +export type ManagerScheduleApiResponse = CommonApiResponse + +// ---- Query Params ---- +export interface ManagerScheduleQueryParams { + workspaceId: number + year: number + month: number +} + +// ---- Adapter ---- +function adaptManagerScheduleDto(dto: ManagerScheduleDto): CalendarEvent { + return { + shiftId: dto.shiftId, + workspaceName: dto.workspace.workspaceName, + position: dto.position, + status: dto.status.value as StatusEnum, + startDateTime: dto.startDateTime, + endDateTime: dto.endDateTime, + dateKey: toDateKey(dto.startDateTime), + startTimeLabel: toTimeLabel(dto.startDateTime), + endTimeLabel: toTimeLabel(dto.endDateTime), + durationHours: getDurationHours(dto.startDateTime, dto.endDateTime), + } +} + +export function adaptManagerScheduleResponse( + response: ManagerScheduleApiResponse +): CalendarViewData { + const events = response.data.map(adaptManagerScheduleDto) + const totalWorkHours = events.reduce((sum, e) => sum + e.durationHours, 0) + return { + summary: { + totalWorkHours, + eventCount: events.length, + }, + events, + } +} diff --git a/src/features/home/manager/types/storeWorkerRole.ts b/src/features/home/manager/types/storeWorkerRole.ts new file mode 100644 index 0000000..5fba466 --- /dev/null +++ b/src/features/home/manager/types/storeWorkerRole.ts @@ -0,0 +1,3 @@ +import type { WorkerRole } from '@/shared/ui/home/WorkerRoleBadge' + +export type StoreWorkerRole = WorkerRole diff --git a/src/features/home/manager/types/substitute.ts b/src/features/home/manager/types/substitute.ts new file mode 100644 index 0000000..6aca83d --- /dev/null +++ b/src/features/home/manager/types/substitute.ts @@ -0,0 +1,83 @@ +import type { CommonApiResponse } from '@/shared/types/common' +import type { SubstituteRequestItem } from '@/shared/ui/manager/SubstituteApprovalCard' + +// ---- API DTOs ---- +export interface SubstituteScheduleDto { + scheduleId: number + startDateTime: string + endDateTime: string + position: string +} + +export interface SubstituteRequesterDto { + workerId: number + workerName: string +} + +export interface SubstituteStatusDto { + value: string + description: string +} + +export interface SubstituteRequestTypeDto { + value: string + description: string +} + +export interface SubstituteRequestDto { + id: number + schedule: SubstituteScheduleDto + requester: SubstituteRequesterDto + requestType: SubstituteRequestTypeDto + status: SubstituteStatusDto + createdAt: string +} + +export interface SubstitutePageDto { + cursor: string | null + pageSize: number + totalCount: number +} + +export type SubstituteListApiResponse = CommonApiResponse<{ + page: SubstitutePageDto + data: SubstituteRequestDto[] +}> + +// ---- Query Params ---- +export interface SubstituteRequestsQueryParams { + workspaceId?: number + status?: string + cursor?: string + pageSize: number +} + +// ---- Mappers ---- +function mapApiStatusToUiStatus( + apiStatus: string +): SubstituteRequestItem['status'] { + if (apiStatus === 'ACCEPTED' || apiStatus === 'APPROVED') return 'accepted' + return 'pending' +} + +function formatDateRange(startDateTime: string, endDateTime: string): string { + const start = new Date(startDateTime) + const end = new Date(endDateTime) + const fmt = (d: Date) => `${d.getMonth() + 1}월 ${d.getDate()}일` + return `${fmt(start)} ↔ ${fmt(end)}` +} + +export function adaptSubstituteRequestDto( + dto: SubstituteRequestDto +): SubstituteRequestItem { + return { + id: String(dto.id), + name: dto.requester.workerName, + role: dto.schedule.position, + dateRange: formatDateRange( + dto.schedule.startDateTime, + dto.schedule.endDateTime + ), + status: mapApiStatusToUiStatus(dto.status.value), + } +} diff --git a/src/features/home/manager/types/worker.ts b/src/features/home/manager/types/worker.ts new file mode 100644 index 0000000..634909d --- /dev/null +++ b/src/features/home/manager/types/worker.ts @@ -0,0 +1,86 @@ +import type { CommonApiResponse } from '@/shared/types/common' +import type { StoreWorkerRole } from '@/features/home/manager/types/storeWorkerRole' + +// ---- API DTOs ---- +export interface WorkerUserDto { + id: number + name: string + contact: string + gender: string +} + +export interface WorkerStatusDto { + value: string + description: string +} + +export interface WorkerPositionDto { + type: string + description: string + emoji: string +} + +export interface WorkerDto { + id: number + user: WorkerUserDto + status: WorkerStatusDto + position: WorkerPositionDto + employedAt: string + resignedAt: string | null + nextShiftDateTime: string | null +} + +export interface WorkerPageDto { + cursor: string | null + pageSize: number + totalCount: number +} + +export type WorkersApiResponse = CommonApiResponse<{ + page: WorkerPageDto + data: WorkerDto[] +}> + +// ---- Query Params ---- +export interface WorkspaceWorkersQueryParams { + workspaceId: number + cursor?: string + pageSize: number + status?: string + name?: string +} + +// ---- UI Model ---- +export interface ManagerWorkerItem { + id: number + name: string + role: StoreWorkerRole + nextWorkDate: string + profileImageUrl?: string +} + +// ---- Adapter ---- +function mapPositionTypeToRole(positionType: string): StoreWorkerRole { + const lower = positionType.toLowerCase() + if (lower === 'manager') return 'manager' + if (lower === 'owner') return 'owner' + return 'staff' +} + +function formatNextShiftDate(isoDateTime: string | null): string { + if (!isoDateTime) return '-' + const date = new Date(isoDateTime) + const y = date.getFullYear() + const m = date.getMonth() + 1 + const d = date.getDate() + return `${y}. ${m}. ${d}.` +} + +export function adaptWorkerDto(dto: WorkerDto): ManagerWorkerItem { + return { + id: dto.id, + name: dto.user.name, + role: mapPositionTypeToRole(dto.position.type), + nextWorkDate: formatNextShiftDate(dto.nextShiftDateTime), + } +} diff --git a/src/features/home/manager/types/workspace.ts b/src/features/home/manager/types/workspace.ts new file mode 100644 index 0000000..c568f0a --- /dev/null +++ b/src/features/home/manager/types/workspace.ts @@ -0,0 +1,30 @@ +import type { CommonApiResponse } from '@/shared/types/common' + +// ---- API DTOs ---- +export interface WorkspaceStatusDto { + value: string + description: string +} + +export interface WorkspaceItemDto { + id: number + businessName: string + businessType: string + fullAddress: string + createdAt: string + status: WorkspaceStatusDto +} + +export interface WorkspaceDetailDto { + id: number + businessName: string + businessType: string + contact: string + description: string + fullAddress: string + reputationSummary: string +} + +// ---- API Response Types ---- +export type ManagedWorkspacesApiResponse = CommonApiResponse +export type WorkspaceDetailApiResponse = CommonApiResponse diff --git a/src/features/home/manager/ui/StoreWorkerListItem.tsx b/src/features/home/manager/ui/StoreWorkerListItem.tsx index 21ee23f..dedba76 100644 --- a/src/features/home/manager/ui/StoreWorkerListItem.tsx +++ b/src/features/home/manager/ui/StoreWorkerListItem.tsx @@ -1,10 +1,6 @@ import MoreVerticalIcon from '@/assets/icons/home/more-vertical.svg' -import { - WorkerRoleBadge, - type WorkerRoleBadgeProps, -} from '@/shared/ui/home/WorkerRoleBadge' - -type StoreWorkerRole = WorkerRoleBadgeProps['role'] +import { WorkerRoleBadge } from '@/shared/ui/home/WorkerRoleBadge' +import type { StoreWorkerRole } from '@/features/home/manager/types/storeWorkerRole' interface StoreWorkerListItemProps { name: string @@ -62,4 +58,4 @@ export function StoreWorkerListItem({ ) } -export type { StoreWorkerListItemProps, StoreWorkerRole } +export type { StoreWorkerListItemProps } diff --git a/src/features/home/manager/ui/WorkspaceChangeCard.tsx b/src/features/home/manager/ui/WorkspaceChangeCard.tsx index 2005040..6bd58a7 100644 --- a/src/features/home/manager/ui/WorkspaceChangeCard.tsx +++ b/src/features/home/manager/ui/WorkspaceChangeCard.tsx @@ -1,23 +1,10 @@ import EditIcon from '@/assets/icons/home/edit.svg' import { WorkCategoryBadge } from '@/shared/ui/home/WorkCategoryBadge' - -interface WorkspaceStatus { - value: string - description: string -} - -interface WorkspaceChangeItem { - id: number - businessName: string - fullAddress: string - createdAt: string - status: WorkspaceStatus -} +import type { WorkspaceItemDto } from '@/features/home/manager/types/workspace' interface WorkspaceChangeCardProps { - workspace: WorkspaceChangeItem + workspace: WorkspaceItemDto isSelected?: boolean - categoryLabel?: string className?: string onEdit?: (workspaceId: number) => void onClick?: (workspaceId: number) => void @@ -26,7 +13,6 @@ interface WorkspaceChangeCardProps { export function WorkspaceChangeCard({ workspace, isSelected = false, - categoryLabel = '', className = '', onEdit, onClick, @@ -43,7 +29,7 @@ export function WorkspaceChangeCard({

{workspace.businessName}

- +
@@ -79,4 +65,4 @@ export function WorkspaceChangeCard({ ) } -export type { WorkspaceChangeCardProps, WorkspaceChangeItem, WorkspaceStatus } +export type { WorkspaceChangeCardProps } diff --git a/src/features/home/manager/ui/WorkspaceChangeList.tsx b/src/features/home/manager/ui/WorkspaceChangeList.tsx index ae01c2c..ff4412e 100644 --- a/src/features/home/manager/ui/WorkspaceChangeList.tsx +++ b/src/features/home/manager/ui/WorkspaceChangeList.tsx @@ -1,12 +1,9 @@ -import { - WorkspaceChangeCard, - type WorkspaceChangeItem, -} from './WorkspaceChangeCard' +import { WorkspaceChangeCard } from './WorkspaceChangeCard' +import type { WorkspaceItemDto } from '@/features/home/manager/types/workspace' interface WorkspaceChangeListProps { - workspaces: WorkspaceChangeItem[] + workspaces: WorkspaceItemDto[] selectedWorkspaceId?: number - categoryLabel?: string className?: string onSelectWorkspace?: (workspaceId: number) => void onEditWorkspace?: (workspaceId: number) => void @@ -15,7 +12,6 @@ interface WorkspaceChangeListProps { export function WorkspaceChangeList({ workspaces, selectedWorkspaceId, - categoryLabel = '', className = '', onSelectWorkspace, onEditWorkspace, @@ -26,7 +22,6 @@ export function WorkspaceChangeList({ { + const response = await axiosInstance.get( + '/app/users/me/postings/applications', + { + params: { + pageSize: params.pageSize, + ...(params.cursor !== undefined && { cursor: params.cursor }), + ...(params.status?.length && { status: params.status }), + }, + } + ) + return response.data +} + +export async function cancelJobApplication( + applicationId: number +): Promise { + await axiosInstance.patch( + `/app/users/me/postings/applications/${applicationId}/status`, + { status: 'CANCELLED' } + ) +} diff --git a/src/features/home/user/applied-stores/hooks/useAppliedStoresViewModel.ts b/src/features/home/user/applied-stores/hooks/useAppliedStoresViewModel.ts new file mode 100644 index 0000000..90931b0 --- /dev/null +++ b/src/features/home/user/applied-stores/hooks/useAppliedStoresViewModel.ts @@ -0,0 +1,127 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useInfiniteQuery } from '@tanstack/react-query' +import type { + ApplicationStatus, + AppliedStoreData, + FilterType, +} from '@/features/home/user/applied-stores/types/appliedStore' +import { + FILTER_TO_API_STATUS, + adaptApplicationDto, +} from '@/features/home/user/applied-stores/types/application' +import { getJobApplications } from '@/features/home/user/applied-stores/api/application' +import { useCancelApplication } from '@/features/home/user/applied-stores/hooks/useCancelApplication' +import { queryKeys } from '@/shared/lib/queryKeys' + +const PAGE_SIZE = 20 + +const FILTER_OPTIONS: { key: FilterType; label: string }[] = [ + { key: 'completed', label: '지원 완료' }, + { key: 'viewed', label: '열람' }, + { key: 'not_viewed', label: '미열람' }, + { key: 'cancelled', label: '지원 취소' }, +] + +const STATUS_SECTIONS: { key: ApplicationStatus; label: string }[] = [ + { key: 'submitted', label: '제출됨' }, + { key: 'accepted', label: '수락됨' }, + { key: 'cancelled', label: '취소됨' }, +] + +function getCardStatus(status: ApplicationStatus): 'applied' | 'rejected' { + return status === 'cancelled' ? 'rejected' : 'applied' +} + +function getFilterLabel(filter: FilterType): string { + if (filter === 'all') return '전체' + return FILTER_OPTIONS.find(o => o.key === filter)?.label ?? '전체' +} + +export function useAppliedStoresViewModel() { + const [selectedFilter, setSelectedFilter] = useState('all') + const [isDropdownOpen, setIsDropdownOpen] = useState(false) + const dropdownRef = useRef(null) + + const { mutate: cancelApplication, isPending: isCancelling } = + useCancelApplication() + + const apiStatus = FILTER_TO_API_STATUS[selectedFilter] + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isError, + } = useInfiniteQuery({ + queryKey: queryKeys.application.list({ + status: apiStatus.length ? apiStatus : undefined, + pageSize: PAGE_SIZE, + }), + queryFn: ({ pageParam }) => + getJobApplications({ + pageSize: PAGE_SIZE, + cursor: pageParam as string | undefined, + status: apiStatus.length ? apiStatus : undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.page.cursor ?? undefined, + }) + + const stores = useMemo( + () => data?.pages.flatMap(page => page.data.map(adaptApplicationDto)) ?? [], + [data] + ) + + const grouped = useMemo( + () => + STATUS_SECTIONS.map(section => ({ + ...section, + stores: stores.filter(s => s.status === section.key), + })).filter(section => section.stores.length > 0), + [stores] + ) + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(e.target as Node) + ) { + setIsDropdownOpen(false) + } + } + if (isDropdownOpen) { + document.addEventListener('mousedown', handleClickOutside) + } + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [isDropdownOpen]) + + function toggleDropdown() { + setIsDropdownOpen(prev => !prev) + } + + function selectFilter(filter: FilterType) { + setSelectedFilter(filter) + setIsDropdownOpen(false) + } + + return { + filterLabel: getFilterLabel(selectedFilter), + isDropdownOpen, + dropdownRef, + filterOptions: FILTER_OPTIONS, + grouped, + toggleDropdown, + selectFilter, + getCardStatus, + fetchNextPage, + hasNextPage: !!hasNextPage, + isFetchingNextPage, + isLoading: isPending, + isError, + cancelApplication, + isCancelling, + } +} diff --git a/src/features/home/user/applied-stores/hooks/useCancelApplication.ts b/src/features/home/user/applied-stores/hooks/useCancelApplication.ts new file mode 100644 index 0000000..a89f92f --- /dev/null +++ b/src/features/home/user/applied-stores/hooks/useCancelApplication.ts @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { cancelJobApplication } from '@/features/home/user/applied-stores/api/application' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useCancelApplication() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (applicationId: number) => cancelJobApplication(applicationId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.application.list() }) + }, + }) +} diff --git a/src/features/home/user/applied-stores/types/application.ts b/src/features/home/user/applied-stores/types/application.ts new file mode 100644 index 0000000..d4dc01e --- /dev/null +++ b/src/features/home/user/applied-stores/types/application.ts @@ -0,0 +1,147 @@ +import type { + AppliedStoreData, + ApplicationStatus, + FilterType, + WeekdayLabel, +} from '@/features/home/user/applied-stores/types/appliedStore' + +// ---- API Status ---- +export type ApplicationApiStatus = + | 'SUBMITTED' + | 'SHORTLISTED' + | 'ACCEPTED' + | 'REJECTED' + | 'CANCELLED' + | 'EXPIRED' + | 'DELETED' + +// ---- DTO ---- +export interface PostingScheduleDto { + id: number + workingDays: string + startTime: string + endTime: string + position: string +} + +export interface PostingDto { + id: number + workspace: number + title: string + payAmount: number + paymentType: string +} + +export interface ApplicationDto { + id: number + postingSchedule: PostingScheduleDto + posting: PostingDto + description: string + status: { value: ApplicationApiStatus; description: string } + createdAt: string +} + +export interface ApplicationPageDto { + cursor: string | null + pageSize: number + totalCount: number +} + +export type ApplicationListApiResponse = { + page: ApplicationPageDto + data: ApplicationDto[] +} + +// ---- Query Params ---- +export interface ApplicationListQueryParams { + cursor?: string + pageSize: number + status?: ApplicationApiStatus[] +} + +// ---- FilterType → API Status 매핑 ---- +export const FILTER_TO_API_STATUS: Record = + { + all: [], + not_viewed: ['SUBMITTED'], + viewed: ['SHORTLISTED', 'ACCEPTED', 'REJECTED'], + completed: ['ACCEPTED'], + cancelled: ['CANCELLED', 'EXPIRED', 'DELETED'], + } + +// ---- 근무 요일 파싱 ---- +const WORKING_DAY_MAP: Record = { + MONDAY: '월', + TUESDAY: '화', + WEDNESDAY: '수', + THURSDAY: '목', + FRIDAY: '금', + SATURDAY: '토', + SUNDAY: '일', +} + +export function parseWorkingDays(workingDaysStr: string): WeekdayLabel[] { + const matches = workingDaysStr.match(/[A-Z]+/g) ?? [] + return matches + .map(day => WORKING_DAY_MAP[day]) + .filter((d): d is WeekdayLabel => d !== undefined) +} + +// ---- 근무 시간 포맷 ---- +function calcDurationHours(startTime: string, endTime: string): number { + const [sh, sm] = startTime.split(':').map(Number) + const [eh, em] = endTime.split(':').map(Number) + const startMins = sh * 60 + sm + const endMins = eh * 60 + em + const diffMins = + endMins >= startMins ? endMins - startMins : 24 * 60 - startMins + endMins + const hours = diffMins / 60 + return Number.isInteger(hours) ? hours : Math.round(hours * 10) / 10 +} + +export function formatTimeRange(startTime: string, endTime: string): string { + const hours = calcDurationHours(startTime, endTime) + return `${startTime}~${endTime} (${hours}시간)` +} + +// ---- API Status → UI 타입 매핑 ---- +function mapApiStatusToUiStatus( + apiStatus: ApplicationApiStatus +): ApplicationStatus { + if (apiStatus === 'ACCEPTED') return 'accepted' + if ( + apiStatus === 'CANCELLED' || + apiStatus === 'REJECTED' || + apiStatus === 'EXPIRED' || + apiStatus === 'DELETED' + ) + return 'cancelled' + return 'submitted' +} + +function mapApiStatusToFilterType(apiStatus: ApplicationApiStatus): FilterType { + if (apiStatus === 'SUBMITTED') return 'not_viewed' + if (apiStatus === 'SHORTLISTED') return 'viewed' + if (apiStatus === 'ACCEPTED') return 'completed' + if (apiStatus === 'REJECTED') return 'viewed' + return 'cancelled' +} + +// ---- DTO → UI Model ---- +export function adaptApplicationDto(dto: ApplicationDto): AppliedStoreData { + const { postingSchedule, posting, description, status } = dto + return { + id: dto.id, + storeName: posting.title, + status: mapApiStatusToUiStatus(status.value), + filterType: mapApiStatusToFilterType(status.value), + applicationDetail: { + selectedWeekdays: parseWorkingDays(postingSchedule.workingDays), + timeRangeLabel: formatTimeRange( + postingSchedule.startTime, + postingSchedule.endTime + ), + selfIntroduction: description, + }, + } +} diff --git a/src/features/home/user/applied-stores/types/appliedStore.ts b/src/features/home/user/applied-stores/types/appliedStore.ts new file mode 100644 index 0000000..6f97964 --- /dev/null +++ b/src/features/home/user/applied-stores/types/appliedStore.ts @@ -0,0 +1,36 @@ +export type ApplicationStatus = 'submitted' | 'accepted' | 'cancelled' + +export type FilterType = + | 'all' + | 'completed' + | 'viewed' + | 'not_viewed' + | 'cancelled' + +export const WEEKDAY_LABELS = [ + '월', + '화', + '수', + '목', + '금', + '토', + '일', +] as const + +export type WeekdayLabel = (typeof WEEKDAY_LABELS)[number] + +/** 지원서에 기재된 내용 (조회 전용) */ +export interface AppliedApplicationDetail { + selectedWeekdays: WeekdayLabel[] + timeRangeLabel: string + selfIntroduction: string +} + +export interface AppliedStoreData { + id: number + storeName: string + status: ApplicationStatus + filterType: FilterType + thumbnailUrl?: string + applicationDetail?: AppliedApplicationDetail +} diff --git a/src/features/home/user/ui/AppliedStoreCard.tsx b/src/features/home/user/applied-stores/ui/AppliedStoreCard.tsx similarity index 100% rename from src/features/home/user/ui/AppliedStoreCard.tsx rename to src/features/home/user/applied-stores/ui/AppliedStoreCard.tsx diff --git a/src/features/home/user/applied-stores/ui/AppliedStoreDetailModal.tsx b/src/features/home/user/applied-stores/ui/AppliedStoreDetailModal.tsx new file mode 100644 index 0000000..ffe972f --- /dev/null +++ b/src/features/home/user/applied-stores/ui/AppliedStoreDetailModal.tsx @@ -0,0 +1,137 @@ +import { useEffect } from 'react' +import { + WEEKDAY_LABELS, + type AppliedApplicationDetail, +} from '@/features/home/user/applied-stores/types/appliedStore' + +interface AppliedStoreDetailModalProps { + isOpen: boolean + onClose: () => void + storeName: string + detail: AppliedApplicationDetail + showCancelButton: boolean + onCancel?: () => void + isCancelling?: boolean +} + +export function AppliedStoreDetailModal({ + isOpen, + onClose, + storeName, + detail, + showCancelButton, + onCancel, + isCancelling = false, +}: AppliedStoreDetailModalProps) { + useEffect(() => { + if (!isOpen) return + const prev = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.body.style.overflow = prev + } + }, [isOpen]) + + useEffect(() => { + if (!isOpen) return + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [isOpen, onClose]) + + if (!isOpen) return null + + const selectedSet = new Set(detail.selectedWeekdays) + + return ( +
+ +
+ )} + + {!showCancelButton &&
} +
+
+ ) +} + +export type { AppliedStoreDetailModalProps } diff --git a/src/features/home/user/ui/AppliedStoreList.tsx b/src/features/home/user/applied-stores/ui/AppliedStoreList.tsx similarity index 89% rename from src/features/home/user/ui/AppliedStoreList.tsx rename to src/features/home/user/applied-stores/ui/AppliedStoreList.tsx index 41fd20d..5fdc248 100644 --- a/src/features/home/user/ui/AppliedStoreList.tsx +++ b/src/features/home/user/applied-stores/ui/AppliedStoreList.tsx @@ -1,5 +1,5 @@ import { MoreButton } from '@/shared/ui/common/MoreButton' -import { AppliedStoreCard } from '@/features/home/user/ui/AppliedStoreCard' +import { AppliedStoreCard } from '@/features/home/user/applied-stores/ui/AppliedStoreCard' interface AppliedStoreItem { id: number | string @@ -24,7 +24,7 @@ export function AppliedStoreList({ const rightLabel = recentLabel ?? `최근 지원한 ${visibleStores.length}개` return ( -
+

{title}

diff --git a/src/features/home/user/applied-stores/ui/AppliedStoreListItem.tsx b/src/features/home/user/applied-stores/ui/AppliedStoreListItem.tsx new file mode 100644 index 0000000..133ffe6 --- /dev/null +++ b/src/features/home/user/applied-stores/ui/AppliedStoreListItem.tsx @@ -0,0 +1,52 @@ +import { ApplicationStatusBadge } from '@/shared/ui/home/ApplicationStatusBadge' + +interface AppliedStoreListItemProps { + storeName: string + status: 'applied' | 'rejected' + thumbnailUrl?: string + onClick?: () => void +} + +export function AppliedStoreListItem({ + storeName, + status, + thumbnailUrl, + onClick, +}: AppliedStoreListItemProps) { + return ( +

{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onClick() + } + } + : undefined + } + > +
+ {thumbnailUrl ? ( + {storeName} + ) : null} +
+

+ {storeName} +

+ +
+ ) +} + +export type { AppliedStoreListItemProps } diff --git a/src/features/home/user/api/schedule.ts b/src/features/home/user/schedule/api/schedule.ts similarity index 52% rename from src/features/home/user/api/schedule.ts rename to src/features/home/user/schedule/api/schedule.ts index 82bccd5..ef63892 100644 --- a/src/features/home/user/api/schedule.ts +++ b/src/features/home/user/schedule/api/schedule.ts @@ -6,16 +6,23 @@ import type { CalendarViewData, ScheduleApiResponse, ScheduleDataDto, -} from '@/features/home/user/types/schedule' +} from '@/features/home/user/schedule/types/schedule' import { getDurationHours, toDateKey, toTimeLabel, -} from '@/features/home/user/lib/date' +} from '@/features/home/user/schedule/lib/date' -interface PeriodQueryParams { - startDate: string - endDate: string +export interface SelfScheduleQueryParams { + year?: number + month?: number + day?: number + fromYear?: number + fromMonth?: number + fromDay?: number + toYear?: number + toMonth?: number + toDay?: number } function mapToCalendarEvent( @@ -47,14 +54,22 @@ export function adaptScheduleResponse( } } -async function fetchSchedule( - endpoint: string, - params: PeriodQueryParams +async function fetchScheduleByMonth( + year?: number, + month?: number, + day?: number ): Promise { try { - const response = await axiosInstance.get(endpoint, { - params, - }) + const response = await axiosInstance.get( + '/app/schedules/self', + { + params: { + ...(year !== undefined && { year }), + ...(month !== undefined && { month }), + ...(day !== undefined && { day }), + }, + } + ) return response.data } catch (error) { if (axios.isAxiosError(error)) { @@ -68,17 +83,33 @@ async function fetchSchedule( } } -export async function getMonthlySchedules(params: PeriodQueryParams) { - const response = await fetchSchedule('/app/schedules/self/monthly', params) - return adaptScheduleResponse(response) -} - -export async function getWeeklySchedules(params: PeriodQueryParams) { - const response = await fetchSchedule('/app/schedules/self/weekly', params) - return adaptScheduleResponse(response) +function mergeScheduleResponses( + a: ScheduleApiResponse, + b: ScheduleApiResponse +): ScheduleApiResponse { + return { + ...a, + data: { + totalWorkHours: a.data.totalWorkHours + b.data.totalWorkHours, + schedules: [...a.data.schedules, ...b.data.schedules], + }, + } } -export async function getDailySchedules(params: PeriodQueryParams) { - const response = await fetchSchedule('/app/schedules/self/daily', params) - return adaptScheduleResponse(response) +export async function getSelfSchedule( + params?: SelfScheduleQueryParams +): Promise { + if (params?.fromYear !== undefined) { + const sameMonth = + params.fromYear === params.toYear && params.fromMonth === params.toMonth + if (sameMonth) { + return fetchScheduleByMonth(params.fromYear, params.fromMonth) + } + const [fromData, toData] = await Promise.all([ + fetchScheduleByMonth(params.fromYear, params.fromMonth), + fetchScheduleByMonth(params.toYear, params.toMonth), + ]) + return mergeScheduleResponses(fromData, toData) + } + return fetchScheduleByMonth(params?.year, params?.month, params?.day) } diff --git a/src/features/home/user/schedule/constants/calendar.ts b/src/features/home/user/schedule/constants/calendar.ts new file mode 100644 index 0000000..5678e0b --- /dev/null +++ b/src/features/home/user/schedule/constants/calendar.ts @@ -0,0 +1,11 @@ +// constants는 common으로 이동 — 하위 호환 re-export +export { + WEEKDAY_LABELS, + WEEKDAY_LABELS_MONDAY_FIRST, + DATE_KEY_FORMAT, + MONTH_LABEL_FORMAT, + DAILY_TIMELINE_HEIGHT, + DAILY_TIMELINE_START_HOUR, + DAILY_TIMELINE_END_HOUR, + DAILY_STATUS_STYLE_MAP, +} from '@/features/home/common/schedule/constants/calendar' diff --git a/src/features/home/user/hooks/useDailyCalendarViewModel.ts b/src/features/home/user/schedule/hooks/useDailyCalendarViewModel.ts similarity index 93% rename from src/features/home/user/hooks/useDailyCalendarViewModel.ts rename to src/features/home/user/schedule/hooks/useDailyCalendarViewModel.ts index 0afe30f..508cc25 100644 --- a/src/features/home/user/hooks/useDailyCalendarViewModel.ts +++ b/src/features/home/user/schedule/hooks/useDailyCalendarViewModel.ts @@ -5,12 +5,12 @@ import { DAILY_TIMELINE_END_HOUR, DAILY_TIMELINE_HEIGHT, DAILY_TIMELINE_START_HOUR, -} from '@/features/home/user/constants/calendar' +} from '@/features/home/user/schedule/constants/calendar' import type { DailyCalendarPropsBase, DailyCalendarViewModel, -} from '@/features/home/user/types/dailyCalendar' -import type { CalendarEvent } from '@/features/home/user/types/schedule' +} from '@/features/home/user/schedule/types/dailyCalendar' +import type { CalendarEvent } from '@/features/home/user/schedule/types/schedule' function getStatusStyle(status: string) { return DAILY_STATUS_STYLE_MAP[status] ?? 'bg-bg-dark text-text-90' diff --git a/src/features/home/user/schedule/hooks/useHomeScheduleViewModel.ts b/src/features/home/user/schedule/hooks/useHomeScheduleViewModel.ts new file mode 100644 index 0000000..1b7026c --- /dev/null +++ b/src/features/home/user/schedule/hooks/useHomeScheduleViewModel.ts @@ -0,0 +1,44 @@ +import { useCallback, useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import type { HomeCalendarMode } from '@/features/home/user/schedule/types/schedule' +import { + getSelfSchedule, + adaptScheduleResponse, +} from '@/features/home/user/schedule/api/schedule' +import { getScheduleParamsByMode } from '@/features/home/user/schedule/lib/date' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useHomeScheduleViewModel() { + const [mode, setMode] = useState('monthly') + const [baseDate, setBaseDate] = useState(() => new Date()) + + const params = getScheduleParamsByMode(baseDate, mode) + + const { + data: rawData, + isPending, + error, + } = useQuery({ + queryKey: queryKeys.schedules.self(params), + queryFn: () => getSelfSchedule(params), + }) + + const calendarData = useMemo( + () => (rawData ? adaptScheduleResponse(rawData) : null), + [rawData] + ) + + const onDateChange = useCallback((nextDate: Date) => { + setBaseDate(nextDate) + }, []) + + return { + mode, + setMode, + baseDate, + calendarData, + isLoading: isPending, + error, + onDateChange, + } +} diff --git a/src/features/home/user/schedule/hooks/useMonthlyCalendarViewModel.ts b/src/features/home/user/schedule/hooks/useMonthlyCalendarViewModel.ts new file mode 100644 index 0000000..e41c322 --- /dev/null +++ b/src/features/home/user/schedule/hooks/useMonthlyCalendarViewModel.ts @@ -0,0 +1,2 @@ +// useMonthlyCalendarViewModel는 common으로 이동 — 하위 호환 re-export +export { useMonthlyCalendarViewModel } from '@/features/home/common/schedule/hooks/useMonthlyCalendarViewModel' diff --git a/src/features/home/user/schedule/hooks/useMonthlyDateCellsState.ts b/src/features/home/user/schedule/hooks/useMonthlyDateCellsState.ts new file mode 100644 index 0000000..156204e --- /dev/null +++ b/src/features/home/user/schedule/hooks/useMonthlyDateCellsState.ts @@ -0,0 +1,2 @@ +// useMonthlyDateCellsState는 common으로 이동 — 하위 호환 re-export +export { useMonthlyDateCellsState } from '@/features/home/common/schedule/hooks/useMonthlyDateCellsState' diff --git a/src/features/home/user/schedule/hooks/useScheduleListViewModel.ts b/src/features/home/user/schedule/hooks/useScheduleListViewModel.ts new file mode 100644 index 0000000..bdf79ab --- /dev/null +++ b/src/features/home/user/schedule/hooks/useScheduleListViewModel.ts @@ -0,0 +1,57 @@ +import { useCallback, useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { getSelfSchedule } from '@/features/home/user/schedule/api/schedule' +import { mapToScheduleListItems } from '@/features/home/user/schedule/lib/date' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useScheduleListViewModel() { + const [currentYear, setCurrentYear] = useState(() => new Date().getFullYear()) + const [currentMonth, setCurrentMonth] = useState( + () => new Date().getMonth() + 1 + ) + + const { data: rawData, isPending } = useQuery({ + queryKey: queryKeys.schedules.self({ + year: currentYear, + month: currentMonth, + }), + queryFn: () => getSelfSchedule({ year: currentYear, month: currentMonth }), + }) + + const schedules = useMemo( + () => mapToScheduleListItems(rawData?.data), + [rawData] + ) + + const handlePreviousMonth = useCallback(() => { + if (currentMonth === 1) { + setCurrentYear(y => y - 1) + setCurrentMonth(12) + } else { + setCurrentMonth(m => m - 1) + } + }, [currentMonth]) + + const handleNextMonth = useCallback(() => { + if (currentMonth === 12) { + setCurrentYear(y => y + 1) + setCurrentMonth(1) + } else { + setCurrentMonth(m => m + 1) + } + }, [currentMonth]) + + const handleScheduleClick = useCallback((id: string) => { + console.log('스케줄 클릭:', id) + }, []) + + return { + currentYear, + currentMonth, + schedules, + isLoading: isPending, + handlePreviousMonth, + handleNextMonth, + handleScheduleClick, + } +} diff --git a/src/features/home/user/hooks/useWeeklyCalendarViewModel.ts b/src/features/home/user/schedule/hooks/useWeeklyCalendarViewModel.ts similarity index 92% rename from src/features/home/user/hooks/useWeeklyCalendarViewModel.ts rename to src/features/home/user/schedule/hooks/useWeeklyCalendarViewModel.ts index d95a4c2..e07511c 100644 --- a/src/features/home/user/hooks/useWeeklyCalendarViewModel.ts +++ b/src/features/home/user/schedule/hooks/useWeeklyCalendarViewModel.ts @@ -1,10 +1,10 @@ import { format } from 'date-fns' import { useMemo } from 'react' -import { getWeeklyDateCells } from '@/features/home/user/lib/date' +import { getWeeklyDateCells } from '@/features/home/user/schedule/lib/date' import type { WeeklyCalendarPropsBase, WeeklyCalendarViewModel, -} from '@/features/home/user/types/weeklyCalendar' +} from '@/features/home/user/schedule/types/weeklyCalendar' function getSelectedDayIndex(baseDate: Date) { const day = baseDate.getDay() diff --git a/src/features/home/user/lib/date.test.ts b/src/features/home/user/schedule/lib/date.test.ts similarity index 78% rename from src/features/home/user/lib/date.test.ts rename to src/features/home/user/schedule/lib/date.test.ts index f05b8b2..dc5b564 100644 --- a/src/features/home/user/lib/date.test.ts +++ b/src/features/home/user/schedule/lib/date.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ScheduleItemDto } from '@/features/home/user/types/schedule' +import type { ScheduleItemDto } from '@/features/home/user/schedule/types/schedule' import { StatusEnum } from '@/shared/types/enums' import { @@ -8,7 +8,7 @@ import { getDayHours, getDurationHours, getMonthlyDateCells, - getRangeParamsByMode, + getScheduleParamsByMode, getWeekRangeLabel, getWeeklyDateCells, moveDateByMode, @@ -131,26 +131,46 @@ describe('getDailyHourTicks', () => { }) }) -describe('getRangeParamsByMode', () => { +describe('getScheduleParamsByMode', () => { const base = new Date(2026, 3, 15) - it('monthly이면 해당 월의 첫날·마지막날이다', () => { - expect(getRangeParamsByMode(base, 'monthly')).toEqual({ - startDate: '2026-04-01', - endDate: '2026-04-30', + it('monthly이면 year·month만 반환한다', () => { + expect(getScheduleParamsByMode(base, 'monthly')).toEqual({ + year: 2026, + month: 4, }) }) - it('weekly이면 해당 주 월요일~일요일이다', () => { - const { startDate, endDate } = getRangeParamsByMode(base, 'weekly') - expect(startDate <= endDate).toBe(true) - expect(startDate).toMatch(/^\d{4}-\d{2}-\d{2}$/) + it('weekly이면 주의 시작·끝 날짜 범위를 반환한다', () => { + // 2026-04-15 (수요일) 기준 주: 2026-04-13(월) ~ 2026-04-19(일) + expect(getScheduleParamsByMode(base, 'weekly')).toEqual({ + fromYear: 2026, + fromMonth: 4, + fromDay: 13, + toYear: 2026, + toMonth: 4, + toDay: 19, + }) + }) + + it('weekly에서 주가 월 경계를 넘으면 두 달을 커버하는 범위를 반환한다', () => { + // 2026-04-28 (화요일) 기준 주: 2026-04-27(월) ~ 2026-05-03(일) + const crossMonth = new Date(2026, 3, 28) + expect(getScheduleParamsByMode(crossMonth, 'weekly')).toEqual({ + fromYear: 2026, + fromMonth: 4, + fromDay: 27, + toYear: 2026, + toMonth: 5, + toDay: 3, + }) }) - it('daily이면 하루 범위로 동일한 날짜다', () => { - expect(getRangeParamsByMode(base, 'daily')).toEqual({ - startDate: '2026-04-15', - endDate: '2026-04-15', + it('daily이면 year·month·day를 반환한다', () => { + expect(getScheduleParamsByMode(base, 'daily')).toEqual({ + year: 2026, + month: 4, + day: 15, }) }) }) diff --git a/src/features/home/user/lib/date.ts b/src/features/home/user/schedule/lib/date.ts similarity index 51% rename from src/features/home/user/lib/date.ts rename to src/features/home/user/schedule/lib/date.ts index 188ceed..cf016eb 100644 --- a/src/features/home/user/lib/date.ts +++ b/src/features/home/user/schedule/lib/date.ts @@ -10,27 +10,19 @@ import { startOfWeek, } from 'date-fns' import { ko } from 'date-fns/locale' -import type { ScheduleDataDto } from '@/features/home/user/types/schedule' -import type { HomeCalendarMode } from '@/features/home/user/types/schedule' - -const ISO_DATE_LENGTH = 10 -const ISO_TIME_START = 11 -const ISO_TIME_END = 16 - -export function toDateKey(iso: string) { - return iso.slice(0, ISO_DATE_LENGTH) -} - -export function toTimeLabel(iso: string) { - return iso.slice(ISO_TIME_START, ISO_TIME_END) -} +import type { ScheduleDataDto } from '@/features/home/user/schedule/types/schedule' +import type { HomeCalendarMode } from '@/features/home/user/schedule/types/schedule' +import type { SelfScheduleQueryParams } from '@/features/home/user/schedule/api/schedule' +import type { ScheduleListItem } from '@/features/home/user/schedule/types/scheduleList' +import { WEEKDAY_LABELS } from '@/features/home/user/schedule/constants/calendar' +import { + toDateKey, + toTimeLabel, + getDurationHours, +} from '@/features/home/common/schedule/lib/date' -export function getDurationHours(startIso: string, endIso: string) { - const start = new Date(startIso).getTime() - const end = new Date(endIso).getTime() - const diffHours = Math.max((end - start) / (1000 * 60 * 60), 0) - return Number(diffHours.toFixed(1)) -} +// 순수 날짜 유틸은 common으로 이동 — 하위 호환 re-export +export { toDateKey, toTimeLabel, getDurationHours } export function getMonthlyDateCells(baseDate: Date) { const monthStart = startOfMonth(baseDate) @@ -87,31 +79,65 @@ export function getDailyHourTicks() { ) } -export function getRangeParamsByMode(baseDate: Date, mode: HomeCalendarMode) { - if (mode === 'monthly') { - return { - startDate: format(startOfMonth(baseDate), 'yyyy-MM-dd'), - endDate: format(endOfMonth(baseDate), 'yyyy-MM-dd'), - } +export function getScheduleParamsByMode( + baseDate: Date, + mode: HomeCalendarMode +): SelfScheduleQueryParams { + const year = baseDate.getFullYear() + const month = baseDate.getMonth() + 1 + if (mode === 'daily') { + return { year, month, day: baseDate.getDate() } } - if (mode === 'weekly') { + const weekStart = startOfWeek(baseDate, { weekStartsOn: 1 }) + const weekEnd = endOfWeek(baseDate, { weekStartsOn: 1 }) return { - startDate: format( - startOfWeek(baseDate, { weekStartsOn: 1 }), - 'yyyy-MM-dd' - ), - endDate: format(endOfWeek(baseDate, { weekStartsOn: 1 }), 'yyyy-MM-dd'), + fromYear: weekStart.getFullYear(), + fromMonth: weekStart.getMonth() + 1, + fromDay: weekStart.getDate(), + toYear: weekEnd.getFullYear(), + toMonth: weekEnd.getMonth() + 1, + toDay: weekEnd.getDate(), } } + return { year, month } +} - const day = format(baseDate, 'yyyy-MM-dd') +export function formatScheduleTimeRange( + startIso: string, + endIso: string +): { time: string; hours: string } { + const durationHours = getDurationHours(startIso, endIso) + const hoursLabel = Number.isInteger(durationHours) + ? `${durationHours}시간` + : `${durationHours.toFixed(1)}시간` return { - startDate: day, - endDate: day, + time: `${toTimeLabel(startIso)} ~ ${toTimeLabel(endIso)}`, + hours: hoursLabel, } } +export function mapToScheduleListItems( + data: ScheduleDataDto | undefined +): ScheduleListItem[] { + if (!data) return [] + return data.schedules.map(schedule => { + const start = new Date(schedule.startDateTime) + const { time, hours } = formatScheduleTimeRange( + schedule.startDateTime, + schedule.endDateTime + ) + return { + id: String(schedule.shiftId), + day: WEEKDAY_LABELS[start.getDay()], + date: String(start.getDate()), + workplace: schedule.workspace.workspaceName, + time, + hours, + } + }) +} + export function moveDateByMode( baseDate: Date, direction: 'prev' | 'next', diff --git a/src/features/home/user/schedule/types/calendar.ts b/src/features/home/user/schedule/types/calendar.ts new file mode 100644 index 0000000..c7485a5 --- /dev/null +++ b/src/features/home/user/schedule/types/calendar.ts @@ -0,0 +1,2 @@ +// BaseCalendarProps는 common으로 이동 — 하위 호환 re-export +export type { BaseCalendarProps } from '@/features/home/common/schedule/types/calendarBase' diff --git a/src/features/home/user/types/dailyCalendar.ts b/src/features/home/user/schedule/types/dailyCalendar.ts similarity index 85% rename from src/features/home/user/types/dailyCalendar.ts rename to src/features/home/user/schedule/types/dailyCalendar.ts index 99fa035..75fcfe7 100644 --- a/src/features/home/user/types/dailyCalendar.ts +++ b/src/features/home/user/schedule/types/dailyCalendar.ts @@ -1,4 +1,4 @@ -import type { BaseCalendarProps } from '@/features/home/user/types/calendar' +import type { BaseCalendarProps } from '@/features/home/user/schedule/types/calendar' export type DailyCalendarPropsBase = BaseCalendarProps diff --git a/src/features/home/user/schedule/types/monthlyCalendar.ts b/src/features/home/user/schedule/types/monthlyCalendar.ts new file mode 100644 index 0000000..c01c15b --- /dev/null +++ b/src/features/home/user/schedule/types/monthlyCalendar.ts @@ -0,0 +1,9 @@ +// monthlyCalendar 타입들은 common으로 이동 — 하위 호환 re-export +export type { + MonthlyCellInput, + MonthlyDateCellState, + MonthlyDayMetrics, + UseMonthlyDateCellsStateParams, + MonthlyCalendarViewModel, + MonthlyCalendarPropsBase, +} from '@/features/home/common/schedule/types/monthlyCalendar' diff --git a/src/features/home/user/types/schedule.ts b/src/features/home/user/schedule/types/schedule.ts similarity index 58% rename from src/features/home/user/types/schedule.ts rename to src/features/home/user/schedule/types/schedule.ts index 8baa8d4..e79cbc2 100644 --- a/src/features/home/user/types/schedule.ts +++ b/src/features/home/user/schedule/types/schedule.ts @@ -1,6 +1,13 @@ import type { CommonApiResponse } from '@/shared/types/common' import type { StatusEnum } from '@/shared/types/enums' +// CalendarEvent, CalendarSummary, CalendarViewData는 common으로 이동 — 하위 호환 re-export +export type { + CalendarEvent, + CalendarSummary, + CalendarViewData, +} from '@/features/home/common/schedule/types/calendarView' + export type HomeCalendarMode = 'monthly' | 'weekly' | 'daily' export interface WorkspaceInfo { @@ -23,26 +30,3 @@ export interface ScheduleDataDto { } export type ScheduleApiResponse = CommonApiResponse - -export interface CalendarEvent { - shiftId: number - workspaceName: string - position: string - status: StatusEnum - startDateTime: string - endDateTime: string - dateKey: string - startTimeLabel: string - endTimeLabel: string - durationHours: number -} - -export interface CalendarSummary { - totalWorkHours: number - eventCount: number -} - -export interface CalendarViewData { - summary: CalendarSummary - events: CalendarEvent[] -} diff --git a/src/features/home/user/schedule/types/scheduleList.ts b/src/features/home/user/schedule/types/scheduleList.ts new file mode 100644 index 0000000..9c3c1c7 --- /dev/null +++ b/src/features/home/user/schedule/types/scheduleList.ts @@ -0,0 +1,8 @@ +export interface ScheduleListItem { + id: string + day: string + date: string + workplace: string + time: string + hours: string +} diff --git a/src/features/home/user/types/weeklyCalendar.ts b/src/features/home/user/schedule/types/weeklyCalendar.ts similarity index 82% rename from src/features/home/user/types/weeklyCalendar.ts rename to src/features/home/user/schedule/types/weeklyCalendar.ts index fd97d7f..55e9a64 100644 --- a/src/features/home/user/types/weeklyCalendar.ts +++ b/src/features/home/user/schedule/types/weeklyCalendar.ts @@ -1,4 +1,4 @@ -import type { BaseCalendarProps } from '@/features/home/user/types/calendar' +import type { BaseCalendarProps } from '@/features/home/user/schedule/types/calendar' export type WeeklyCalendarPropsBase = BaseCalendarProps diff --git a/src/features/home/user/ui/DailyCalendar.tsx b/src/features/home/user/schedule/ui/DailyCalendar.tsx similarity index 97% rename from src/features/home/user/ui/DailyCalendar.tsx rename to src/features/home/user/schedule/ui/DailyCalendar.tsx index e0188e1..3197ce6 100644 --- a/src/features/home/user/ui/DailyCalendar.tsx +++ b/src/features/home/user/schedule/ui/DailyCalendar.tsx @@ -1,6 +1,6 @@ import DownIcon from '@/assets/icons/home/chevron-down.svg?react' -import { useDailyCalendarViewModel } from '@/features/home/user/hooks/useDailyCalendarViewModel' -import type { DailyCalendarPropsBase } from '@/features/home/user/types/dailyCalendar' +import { useDailyCalendarViewModel } from '@/features/home/user/schedule/hooks/useDailyCalendarViewModel' +import type { DailyCalendarPropsBase } from '@/features/home/user/schedule/types/dailyCalendar' interface DailyCalendarProps extends DailyCalendarPropsBase { isLoading?: boolean diff --git a/src/features/home/user/ui/HomeScheduleCalendar.tsx b/src/features/home/user/schedule/ui/HomeScheduleCalendar.tsx similarity index 73% rename from src/features/home/user/ui/HomeScheduleCalendar.tsx rename to src/features/home/user/schedule/ui/HomeScheduleCalendar.tsx index a2af202..dd2836c 100644 --- a/src/features/home/user/ui/HomeScheduleCalendar.tsx +++ b/src/features/home/user/schedule/ui/HomeScheduleCalendar.tsx @@ -1,10 +1,10 @@ import type { CalendarViewData, HomeCalendarMode, -} from '@/features/home/user/types/schedule' -import { DailyCalendar } from '@/features/home/user/ui/DailyCalendar' -import { MonthlyCalendar } from '@/features/home/user/ui/MonthlyCalendar' -import { WeeklyCalendar } from '@/features/home/user/ui/WeeklyCalendar' +} from '@/features/home/user/schedule/types/schedule' +import { DailyCalendar } from '@/features/home/user/schedule/ui/DailyCalendar' +import { MonthlyCalendar } from '@/features/home/user/schedule/ui/MonthlyCalendar' +import { WeeklyCalendar } from '@/features/home/user/schedule/ui/WeeklyCalendar' interface HomeScheduleCalendarProps { mode: HomeCalendarMode @@ -23,7 +23,7 @@ export function HomeScheduleCalendar({ isLoading = false, }: HomeScheduleCalendarProps) { return ( -
+
{mode === 'monthly' && ( -

월간 일정을 불러오는 중...

-
- ) - } - - return ( -
-
-

{title}

- -
- {totalWorkHoursText} - 시간 근무해요 -
-
- -
-
- {weekdayLabels.map((label, index) => ( - - {label} - - ))} -
- -
- {monthlyDateCellsState.map(cell => { - return ( - - ) - })} -
-
-
- ) -} diff --git a/src/features/home/user/workspace/api/workspace.ts b/src/features/home/user/workspace/api/workspace.ts new file mode 100644 index 0000000..7e208b4 --- /dev/null +++ b/src/features/home/user/workspace/api/workspace.ts @@ -0,0 +1,38 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { + WorkspaceItem, + WorkspaceListApiResponse, + WorkspaceListQueryParams, +} from '@/features/home/user/workspace/types/workspace' + +function mapToWorkspaceItem( + dto: WorkspaceListApiResponse['data']['data'][number] +): WorkspaceItem { + return { + workspaceId: dto.workspaceId, + businessName: dto.businessName, + employedAt: dto.employedAt, + nextShiftDateTime: dto.nextShiftDateTime, + } +} + +export async function getMyWorkspaces( + params: WorkspaceListQueryParams +): Promise { + const response = await axiosInstance.get( + '/app/users/me/workspaces', + { + params: { + pageSize: params.pageSize, + ...(params.cursor !== undefined && { cursor: params.cursor }), + }, + } + ) + return response.data +} + +export function adaptWorkspaceListResponse( + response: WorkspaceListApiResponse +): WorkspaceItem[] { + return response.data.data.map(mapToWorkspaceItem) +} diff --git a/src/features/home/user/workspace/api/workspaceMembers.ts b/src/features/home/user/workspace/api/workspaceMembers.ts new file mode 100644 index 0000000..57a10d4 --- /dev/null +++ b/src/features/home/user/workspace/api/workspaceMembers.ts @@ -0,0 +1,68 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { + WorkspaceMembersQueryParams, + WorkspaceManagerItem, + WorkspaceManagersApiResponse, + WorkspaceWorkerItem, + WorkspaceWorkersApiResponse, + WorkspaceWorkerDto, + WorkspaceManagerDto, +} from '@/features/home/user/workspace/types/workspaceMembers' + +export async function getWorkspaceWorkers( + workspaceId: number, + params: WorkspaceMembersQueryParams +): Promise { + const response = await axiosInstance.get( + `/app/users/me/workspaces/${workspaceId}/workers`, + { + params: { + pageSize: params.pageSize, + ...(params.cursor !== undefined && { cursor: params.cursor }), + }, + } + ) + return response.data +} + +export async function getWorkspaceManagers( + workspaceId: number, + params: WorkspaceMembersQueryParams +): Promise { + const response = await axiosInstance.get( + `/app/users/me/workspaces/${workspaceId}/managers`, + { + params: { + pageSize: params.pageSize, + ...(params.cursor !== undefined && { cursor: params.cursor }), + }, + } + ) + return response.data +} + +export function adaptWorkerDto(dto: WorkspaceWorkerDto): WorkspaceWorkerItem { + return { + id: dto.id, + userId: dto.user.id, + name: dto.user.name, + positionType: dto.position.type, + positionDescription: dto.position.description, + positionEmoji: dto.position.emoji, + employedAt: dto.employedAt, + nextShiftDateTime: dto.nextShiftDateTime, + } +} + +export function adaptManagerDto( + dto: WorkspaceManagerDto +): WorkspaceManagerItem { + return { + id: dto.id, + managerId: dto.manager.id, + name: dto.manager.name, + positionType: dto.position.type, + positionDescription: dto.position.description, + positionEmoji: dto.position.emoji, + } +} diff --git a/src/features/home/user/workspace/api/workspaceSchedule.ts b/src/features/home/user/workspace/api/workspaceSchedule.ts new file mode 100644 index 0000000..e9fbd94 --- /dev/null +++ b/src/features/home/user/workspace/api/workspaceSchedule.ts @@ -0,0 +1,149 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { + CalendarEvent, + CalendarViewData, +} from '@/features/home/user/schedule/types/schedule' +import { + toDateKey, + toTimeLabel, + getDurationHours, + formatScheduleTimeRange, +} from '@/features/home/user/schedule/lib/date' +import type { + WorkspaceScheduleApiResponse, + WorkspaceScheduleQueryParams, + WorkspaceShiftItem, + WorkspaceWorkerItem, +} from '@/features/home/user/workspace/types/workspaceSchedule' + +async function fetchWorkspaceScheduleByMonth( + workspaceId: number, + year?: number, + month?: number, + day?: number +): Promise { + const response = await axiosInstance.get( + `/app/schedules/workspaces/${workspaceId}`, + { + params: { + ...(year !== undefined && { year }), + ...(month !== undefined && { month }), + ...(day !== undefined && { day }), + }, + } + ) + return response.data +} + +export async function getWorkspaceSchedule( + workspaceId: number, + params?: WorkspaceScheduleQueryParams +): Promise { + if (params?.fromYear !== undefined) { + const sameMonth = + params.fromYear === params.toYear && params.fromMonth === params.toMonth + if (sameMonth) { + return fetchWorkspaceScheduleByMonth( + workspaceId, + params.fromYear, + params.fromMonth + ) + } + const [fromData, toData] = await Promise.all([ + fetchWorkspaceScheduleByMonth( + workspaceId, + params.fromYear, + params.fromMonth + ), + fetchWorkspaceScheduleByMonth(workspaceId, params.toYear, params.toMonth), + ]) + return { ...fromData, data: [...fromData.data, ...toData.data] } + } + return fetchWorkspaceScheduleByMonth( + workspaceId, + params?.year, + params?.month, + params?.day + ) +} + +export function adaptWorkspaceScheduleToCalendar( + response: WorkspaceScheduleApiResponse +): CalendarViewData { + const shifts = response.data + + const events: CalendarEvent[] = shifts.map(shift => ({ + shiftId: shift.shiftId, + workspaceName: shift.assignedWorker.workerName, + position: shift.position, + status: shift.status, + startDateTime: shift.startDateTime, + endDateTime: shift.endDateTime, + dateKey: toDateKey(shift.startDateTime), + startTimeLabel: toTimeLabel(shift.startDateTime), + endTimeLabel: toTimeLabel(shift.endDateTime), + durationHours: getDurationHours(shift.startDateTime, shift.endDateTime), + })) + + const totalWorkHours = events.reduce((acc, e) => acc + e.durationHours, 0) + + return { + summary: { totalWorkHours, eventCount: events.length }, + events, + } +} + +export function adaptWorkspaceScheduleToShifts( + response: WorkspaceScheduleApiResponse +): WorkspaceShiftItem[] { + return response.data.map(shift => { + const { time } = formatScheduleTimeRange( + shift.startDateTime, + shift.endDateTime + ) + return { + shiftId: shift.shiftId, + workerId: shift.assignedWorker.workerId, + workerName: shift.assignedWorker.workerName, + position: shift.position, + status: shift.status, + startDateTime: shift.startDateTime, + endDateTime: shift.endDateTime, + timeRange: time, + durationHours: getDurationHours(shift.startDateTime, shift.endDateTime), + } + }) +} + +export function deriveWorkerList( + response: WorkspaceScheduleApiResponse +): WorkspaceWorkerItem[] { + const workerMap = new Map() + const now = Date.now() + + const sorted = response.data + .filter(shift => new Date(shift.startDateTime).getTime() >= now) + .sort( + (a, b) => + new Date(a.startDateTime).getTime() - + new Date(b.startDateTime).getTime() + ) + + for (const shift of sorted) { + const { workerId, workerName } = shift.assignedWorker + if (!workerMap.has(workerId)) { + const { time } = formatScheduleTimeRange( + shift.startDateTime, + shift.endDateTime + ) + workerMap.set(workerId, { + workerId, + workerName, + nextShiftDateTime: shift.startDateTime, + nextShiftTimeRange: time, + }) + } + } + + return Array.from(workerMap.values()) +} diff --git a/src/features/home/user/hooks/useWorkingStoreCardViewModel.ts b/src/features/home/user/workspace/hooks/useWorkingStoreCardViewModel.ts similarity index 91% rename from src/features/home/user/hooks/useWorkingStoreCardViewModel.ts rename to src/features/home/user/workspace/hooks/useWorkingStoreCardViewModel.ts index 1022534..ca91c2f 100644 --- a/src/features/home/user/hooks/useWorkingStoreCardViewModel.ts +++ b/src/features/home/user/workspace/hooks/useWorkingStoreCardViewModel.ts @@ -1,6 +1,6 @@ import { differenceInCalendarDays, format, parseISO } from 'date-fns' import { useMemo } from 'react' -import type { WorkingStoreItem } from '@/features/home/user/ui/WorkingStoreCard' +import type { WorkingStoreItem } from '@/features/home/user/workspace/ui/WorkingStoreCard' function formatNextShiftDate(nextShiftDateTime: string) { const date = parseISO(nextShiftDateTime) diff --git a/src/features/home/user/hooks/useWorkingStoresListViewModel.ts b/src/features/home/user/workspace/hooks/useWorkingStoresListViewModel.ts similarity index 69% rename from src/features/home/user/hooks/useWorkingStoresListViewModel.ts rename to src/features/home/user/workspace/hooks/useWorkingStoresListViewModel.ts index 4d95ea3..9f3f521 100644 --- a/src/features/home/user/hooks/useWorkingStoresListViewModel.ts +++ b/src/features/home/user/workspace/hooks/useWorkingStoresListViewModel.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import type { WorkingStoreItem } from '@/features/home/user/ui/WorkingStoreCard' +import type { WorkingStoreItem } from '@/features/home/user/workspace/ui/WorkingStoreCard' export function useWorkingStoresListViewModel(stores: WorkingStoreItem[]) { return useMemo( diff --git a/src/features/home/user/workspace/hooks/useWorkspaceManagersViewModel.ts b/src/features/home/user/workspace/hooks/useWorkspaceManagersViewModel.ts new file mode 100644 index 0000000..65a04a8 --- /dev/null +++ b/src/features/home/user/workspace/hooks/useWorkspaceManagersViewModel.ts @@ -0,0 +1,42 @@ +import { useInfiniteQuery } from '@tanstack/react-query' +import { + getWorkspaceManagers, + adaptManagerDto, +} from '@/features/home/user/workspace/api/workspaceMembers' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useWorkspaceManagersViewModel( + workspaceId: number, + pageSize = 10 +) { + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isError, + } = useInfiniteQuery({ + queryKey: [...queryKeys.workspace.managers(workspaceId), { pageSize }], + queryFn: ({ pageParam }) => + getWorkspaceManagers(workspaceId, { + pageSize, + cursor: pageParam as string | undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.data.page.cursor ?? undefined, + enabled: workspaceId > 0, + }) + + const managers = + data?.pages.flatMap(page => page.data.data.map(adaptManagerDto)) ?? [] + + return { + managers, + fetchNextPage, + hasNextPage: !!hasNextPage, + isFetchingNextPage, + isLoading: isPending, + isError, + } +} diff --git a/src/features/home/user/workspace/hooks/useWorkspaceScheduleViewModel.ts b/src/features/home/user/workspace/hooks/useWorkspaceScheduleViewModel.ts new file mode 100644 index 0000000..1cbee67 --- /dev/null +++ b/src/features/home/user/workspace/hooks/useWorkspaceScheduleViewModel.ts @@ -0,0 +1,52 @@ +import { useCallback, useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import type { HomeCalendarMode } from '@/features/home/user/schedule/types/schedule' +import { getScheduleParamsByMode } from '@/features/home/user/schedule/lib/date' +import { queryKeys } from '@/shared/lib/queryKeys' +import { + getWorkspaceSchedule, + adaptWorkspaceScheduleToCalendar, + deriveWorkerList, +} from '@/features/home/user/workspace/api/workspaceSchedule' + +export function useWorkspaceScheduleViewModel(workspaceId: number) { + const [mode, setMode] = useState('monthly') + const [baseDate, setBaseDate] = useState(() => new Date()) + + const params = getScheduleParamsByMode(baseDate, mode) + + const { + data: rawData, + isPending, + isError, + } = useQuery({ + queryKey: queryKeys.workspace.schedules(workspaceId, params), + queryFn: () => getWorkspaceSchedule(workspaceId, params), + enabled: workspaceId > 0, + }) + + const calendarData = useMemo( + () => (rawData ? adaptWorkspaceScheduleToCalendar(rawData) : null), + [rawData] + ) + + const workers = useMemo( + () => (rawData ? deriveWorkerList(rawData) : []), + [rawData] + ) + + const onDateChange = useCallback((nextDate: Date) => { + setBaseDate(nextDate) + }, []) + + return { + mode, + setMode, + baseDate, + calendarData, + workers, + isLoading: isPending, + isError, + onDateChange, + } +} diff --git a/src/features/home/user/workspace/hooks/useWorkspaceWorkersViewModel.ts b/src/features/home/user/workspace/hooks/useWorkspaceWorkersViewModel.ts new file mode 100644 index 0000000..91efcc7 --- /dev/null +++ b/src/features/home/user/workspace/hooks/useWorkspaceWorkersViewModel.ts @@ -0,0 +1,42 @@ +import { useInfiniteQuery } from '@tanstack/react-query' +import { + getWorkspaceWorkers, + adaptWorkerDto, +} from '@/features/home/user/workspace/api/workspaceMembers' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useWorkspaceWorkersViewModel( + workspaceId: number, + pageSize = 10 +) { + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isError, + } = useInfiniteQuery({ + queryKey: [...queryKeys.workspace.workers(workspaceId), { pageSize }], + queryFn: ({ pageParam }) => + getWorkspaceWorkers(workspaceId, { + pageSize, + cursor: pageParam as string | undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.data.page.cursor ?? undefined, + enabled: workspaceId > 0, + }) + + const workers = + data?.pages.flatMap(page => page.data.data.map(adaptWorkerDto)) ?? [] + + return { + workers, + fetchNextPage, + hasNextPage: !!hasNextPage, + isFetchingNextPage, + isLoading: isPending, + isError, + } +} diff --git a/src/features/home/user/workspace/hooks/useWorkspacesViewModel.ts b/src/features/home/user/workspace/hooks/useWorkspacesViewModel.ts new file mode 100644 index 0000000..d845255 --- /dev/null +++ b/src/features/home/user/workspace/hooks/useWorkspacesViewModel.ts @@ -0,0 +1,45 @@ +import { useInfiniteQuery } from '@tanstack/react-query' +import { + adaptWorkspaceListResponse, + getMyWorkspaces, +} from '@/features/home/user/workspace/api/workspace' +import { queryKeys } from '@/shared/lib/queryKeys' + +const PAGE_SIZE = 10 + +export function useWorkspacesViewModel() { + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } = useInfiniteQuery({ + queryKey: queryKeys.workspace.list({ pageSize: PAGE_SIZE }), + queryFn: async ({ pageParam }) => { + const response = await getMyWorkspaces({ + pageSize: PAGE_SIZE, + cursor: pageParam as string | undefined, + }) + return response + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => { + const cursor = lastPage.data.page.cursor + return cursor || undefined + }, + }) + + const workspaces = + data?.pages.flatMap(page => adaptWorkspaceListResponse(page)) ?? [] + + return { + workspaces, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } +} diff --git a/src/features/home/user/workspace/types/workspace.ts b/src/features/home/user/workspace/types/workspace.ts new file mode 100644 index 0000000..2679b58 --- /dev/null +++ b/src/features/home/user/workspace/types/workspace.ts @@ -0,0 +1,35 @@ +import type { CommonApiResponse } from '@/shared/types/common' + +// DTO +export interface WorkspaceItemDto { + workspaceId: number + businessName: string + employedAt: string + nextShiftDateTime: string +} + +export interface WorkspacePageDto { + cursor: string + pageSize: number + totalCount: number +} + +export interface WorkspaceListDto { + page: WorkspacePageDto + data: WorkspaceItemDto[] +} + +export type WorkspaceListApiResponse = CommonApiResponse + +// UI Model +export interface WorkspaceItem { + workspaceId: number + businessName: string + employedAt: string + nextShiftDateTime: string +} + +export interface WorkspaceListQueryParams { + cursor?: string + pageSize: number +} diff --git a/src/features/home/user/workspace/types/workspaceMembers.ts b/src/features/home/user/workspace/types/workspaceMembers.ts new file mode 100644 index 0000000..ec5f04c --- /dev/null +++ b/src/features/home/user/workspace/types/workspaceMembers.ts @@ -0,0 +1,65 @@ +import type { CommonApiResponse } from '@/shared/types/common' + +// ---- DTO ---- +export interface WorkspacePositionDto { + type: string + description: string + emoji: string +} + +export interface WorkspaceWorkerDto { + id: number + user: { id: number; name: string } + position: WorkspacePositionDto + employedAt: string + nextShiftDateTime: string +} + +export interface WorkspaceManagerDto { + id: number + manager: { id: number; name: string } + position: WorkspacePositionDto +} + +export interface WorkspaceMembersPageDto { + cursor: string | null + pageSize: number + totalCount: number +} + +export type WorkspaceWorkersApiResponse = CommonApiResponse<{ + page: WorkspaceMembersPageDto + data: WorkspaceWorkerDto[] +}> + +export type WorkspaceManagersApiResponse = CommonApiResponse<{ + page: WorkspaceMembersPageDto + data: WorkspaceManagerDto[] +}> + +// ---- Params ---- +export interface WorkspaceMembersQueryParams { + cursor?: string + pageSize: number +} + +// ---- UI Model ---- +export interface WorkspaceWorkerItem { + id: number + userId: number + name: string + positionType: string + positionDescription: string + positionEmoji: string + employedAt: string + nextShiftDateTime: string +} + +export interface WorkspaceManagerItem { + id: number + managerId: number + name: string + positionType: string + positionDescription: string + positionEmoji: string +} diff --git a/src/features/home/user/workspace/types/workspaceSchedule.ts b/src/features/home/user/workspace/types/workspaceSchedule.ts new file mode 100644 index 0000000..c680762 --- /dev/null +++ b/src/features/home/user/workspace/types/workspaceSchedule.ts @@ -0,0 +1,54 @@ +import type { CommonApiResponse } from '@/shared/types/common' +import type { StatusEnum } from '@/shared/types/enums' + +// ---- DTO ---- +export interface WorkspaceShiftWorkerDto { + workerId: number + workerName: string +} + +export interface WorkspaceShiftDto { + shiftId: number + assignedWorker: WorkspaceShiftWorkerDto + startDateTime: string + endDateTime: string + position: string + status: StatusEnum +} + +export type WorkspaceScheduleApiResponse = CommonApiResponse< + WorkspaceShiftDto[] +> + +// ---- Query Params ---- +export interface WorkspaceScheduleQueryParams { + year?: number + month?: number + day?: number + fromYear?: number + fromMonth?: number + fromDay?: number + toYear?: number + toMonth?: number + toDay?: number +} + +// ---- UI Model ---- +export interface WorkspaceShiftItem { + shiftId: number + workerId: number + workerName: string + position: string + status: StatusEnum + startDateTime: string + endDateTime: string + timeRange: string + durationHours: number +} + +export interface WorkspaceWorkerItem { + workerId: number + workerName: string + nextShiftDateTime: string + nextShiftTimeRange: string +} diff --git a/src/features/home/user/ui/WorkingStoreCard.tsx b/src/features/home/user/workspace/ui/WorkingStoreCard.tsx similarity index 96% rename from src/features/home/user/ui/WorkingStoreCard.tsx rename to src/features/home/user/workspace/ui/WorkingStoreCard.tsx index 72e225b..6b1b2a3 100644 --- a/src/features/home/user/ui/WorkingStoreCard.tsx +++ b/src/features/home/user/workspace/ui/WorkingStoreCard.tsx @@ -1,4 +1,4 @@ -import { useWorkingStoreCardViewModel } from '@/features/home/user/hooks/useWorkingStoreCardViewModel' +import { useWorkingStoreCardViewModel } from '@/features/home/user/workspace/hooks/useWorkingStoreCardViewModel' export interface WorkingStoreItem { workspaceId: number diff --git a/src/features/home/user/ui/WorkingStoresList.tsx b/src/features/home/user/workspace/ui/WorkingStoresList.tsx similarity index 91% rename from src/features/home/user/ui/WorkingStoresList.tsx rename to src/features/home/user/workspace/ui/WorkingStoresList.tsx index 20458aa..9894b96 100644 --- a/src/features/home/user/ui/WorkingStoresList.tsx +++ b/src/features/home/user/workspace/ui/WorkingStoresList.tsx @@ -1,9 +1,9 @@ import { MoreButton } from '@/shared/ui/common/MoreButton' -import { useWorkingStoresListViewModel } from '@/features/home/user/hooks/useWorkingStoresListViewModel' +import { useWorkingStoresListViewModel } from '@/features/home/user/workspace/hooks/useWorkingStoresListViewModel' import { WorkingStoreCard, type WorkingStoreItem, -} from '@/features/home/user/ui/WorkingStoreCard' +} from '@/features/home/user/workspace/ui/WorkingStoreCard' interface WorkingStoresListProps { title?: string @@ -25,7 +25,7 @@ export function WorkingStoresList({ const { visibleStores } = useWorkingStoresListViewModel(stores) return ( -
+

{title}

diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index f6bd1b9..87fa593 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -20,9 +20,9 @@ export function LoginPage() { useEffect(() => { if (isLoggedIn && token) { if (scope === 'MANAGER') { - navigate('/main', { replace: true }) + navigate('/manager/home', { replace: true }) } else { - navigate('/job-lookup-map', { replace: true }) + navigate('/user/home', { replace: true }) } } }, [isLoggedIn, scope, token, navigate]) @@ -90,7 +90,7 @@ export function LoginPage() { setPhoneError('') setErrorMessage('') }} - borderColor={phoneError ? '1px solid #DC0000' : undefined} + borderColor={phoneError ? '1px solid error' : undefined} />
diff --git a/src/pages/manager/home/index.tsx b/src/pages/manager/home/index.tsx index 0de94f5..5fe4917 100644 --- a/src/pages/manager/home/index.tsx +++ b/src/pages/manager/home/index.tsx @@ -1,130 +1,185 @@ +import { format } from 'date-fns' +import { ko } from 'date-fns/locale' import { Navbar } from '@/shared/ui/common/Navbar' -import { WorkerImageCard } from '@/shared/ui/manager/WorkerImageCard' -import { - StoreWorkerListItem, - type StoreWorkerRole, -} from '@/features/home/manager/ui/StoreWorkerListItem' -import { - OngoingPostingCard, - type JobPostingItem, -} from '@/shared/ui/manager/OngoingPostingCard' -import { - SubstituteApprovalCard, - type SubstituteRequestItem, -} from '@/shared/ui/manager/SubstituteApprovalCard' - -import homeBanner from '@/assets/home.png' - -// 더미 데이터 -const TODAY_WORKERS = [ - { name: '알바생1', timeRange: '00:00 ~ 00:00' }, - { name: '알바생2', timeRange: '00:00 ~ 00:00' }, -] as const - -interface StoreWorkerData { - id: string - name: string - role: StoreWorkerRole - nextWorkDate: string - profileImageUrl?: string -} - -const STORE_WORKERS: StoreWorkerData[] = [ - { id: '1', name: '이름임', role: 'manager', nextWorkDate: '2025. 1. 1.' }, - { id: '2', name: '이름임', role: 'staff', nextWorkDate: '2025. 1. 1.' }, - { id: '3', name: '이름임', role: 'staff', nextWorkDate: '2025. 1. 1.' }, -] - -const ONGOING_POSTINGS: JobPostingItem[] = [ - { - id: '1', - dDay: 'D-3', - title: '[가게이름] 평일 저녁 마감 근무자 모집', - wage: '시급 10,030원', - workHours: '17:00 ~ 21:00', - workDays: '수, 목, 금', - }, - { - id: '2', - dDay: 'D-7', - title: '[가게이름] 평일 저녁 마감 근무자 모집', - wage: '시급 10,030원', - workHours: '07:00 ~ 13:00', - workDays: '월, 화, 수', - }, - { - id: '3', - dDay: 'D-27', - title: '[가게이름] 평일 저녁 마감 근무자 모집', - wage: '시급 10,030원', - workHours: '07:00 ~ 13:00', - workDays: '월, 화, 수', - }, -] - -const SUBSTITUTE_REQUESTS: SubstituteRequestItem[] = [ - { - id: '1', - name: '나영채', - role: '알바', - dateRange: '1월 1일 ↔ 1월 10일', - status: 'accepted', - }, - { - id: '2', - name: '나영채', - role: '알바', - dateRange: '1월 1일 ↔ 1월 10일', - status: 'pending', - }, - { - id: '3', - name: '나영채', - role: '알바', - dateRange: '1월 1일 ↔ 1월 10일', - status: 'pending', - }, -] +import { useNavigate } from 'react-router-dom' +import { TodayWorkerList } from '@/features/home/manager/ui/TodayWorkerList' +import { StoreWorkerListItem } from '@/features/home/manager/ui/StoreWorkerListItem' +import { useManagerHomeViewModel } from '@/features/home/manager/hooks/useManagerHomeViewModel' +import { WorkspaceChangeList } from '@/features/home/manager/ui/WorkspaceChangeList' +import { MonthlyCalendar } from '@/features/home/common/schedule/ui/MonthlyCalendar' +import { OngoingPostingCard } from '@/shared/ui/manager/OngoingPostingCard' +import { SubstituteApprovalCard } from '@/shared/ui/manager/SubstituteApprovalCard' +import { MoreButton } from '@/shared/ui/common/MoreButton' +import { WorkCategoryBadge } from '@/shared/ui/home/WorkCategoryBadge' +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' export function ManagerHomePage() { + const navigate = useNavigate() + const { + todayWorkers, + storeWorkers, + fetchMoreWorkers, + hasMoreWorkers, + isFetchingMoreWorkers, + ongoingPostings, + postingsTotalCount, + fetchMorePostings, + hasMorePostings, + substituteRequests, + substituteTotalCount, + fetchMoreSubstitutes, + hasMoreSubstitutes, + schedule, + workspaceDetail, + workspaceChangeModal, + openWorkspaceChangeModal, + closeWorkspaceChangeModal, + selectWorkspace, + } = useManagerHomeViewModel() + return ( -
+
-
+
logo +
+ +
+
+

+ {workspaceDetail?.businessName ?? ''} +

+ +
+

+ {workspaceDetail?.fullAddress ?? ''} +

+

+ {workspaceDetail?.businessName ?? ''} +

+
+ +
+ {workspaceChangeModal.isOpen && ( +
+ +
+
+ )} +
-
MM월 dd일
+
+ {format(new Date(), 'M월 d일', { locale: ko })} +
전체 보기
-
- 오늘 근무자는 6명이에요 -
- -
- {TODAY_WORKERS.map(worker => ( - - ))} +
+
+
+

+ 우리 매장 시간표 +

+ navigate('/manager/worker-schedule')} + > + + + } + /> +
+

우리 매장 근무자

-
- {STORE_WORKERS.map(worker => ( +
+ {storeWorkers.map(worker => ( {}} /> ))} -
-
- + {hasMoreWorkers && ( + fetchMoreWorkers()} + disabled={isFetchingMoreWorkers} + /> + )}

- 진행 중인 공고 10건 + 진행 중인 공고 {postingsTotalCount} + 건

{}} + postings={ongoingPostings} + onViewMore={hasMorePostings ? () => fetchMorePostings() : undefined} onPostingClick={() => {}} />

- 대타 승인 요청 10건 + 대타 승인 요청{' '} + {substituteTotalCount}

{}} + requests={substituteRequests} + onViewMore={ + hasMoreSubstitutes ? () => fetchMoreSubstitutes() : undefined + } onRequestClick={() => {}} />
diff --git a/src/pages/manager/worker-schedule/index.tsx b/src/pages/manager/worker-schedule/index.tsx new file mode 100644 index 0000000..2ef8154 --- /dev/null +++ b/src/pages/manager/worker-schedule/index.tsx @@ -0,0 +1,173 @@ +import { useNavigate } from 'react-router-dom' +import { WorkerRoleBadge } from '@/shared/ui/home/WorkerRoleBadge' +import { useWorkerScheduleManageViewModel } from '@/features/home/manager/hooks/useWorkerScheduleManageViewModel' +import chevronLeftIcon from '@/assets/icons/chevron-left.svg' +import chevronDownIcon from '@/assets/icons/home/chevron-down.svg' + +interface TimeSelectBoxProps { + value: string + unit: string +} + +function TimeSelectBox({ value, unit }: TimeSelectBoxProps) { + return ( +
+ {value} + + {unit} + +
+ ) +} + +export function ManagerWorkerSchedulePage() { + const navigate = useNavigate() + const { + worker, + workdayOptions, + selectedDays, + workTimeRangeLabel, + startHour, + startMinute, + endHour, + endMinute, + toggleDay, + } = useWorkerScheduleManageViewModel() + + return ( +
+
+ +

+ 근무자 스케줄 관리 +

+
+ +
+
+

근무자 선택

+
+
+ + +
+
+ +
+

근무일 선택

+
+ {workdayOptions.map(day => { + const selected = selectedDays.includes(day) + return ( + + ) + })} +
+
+ +
+

+ 근무 시간 선택 +

+

+ {workTimeRangeLabel} +

+ +
+ + 출근 시간 + +
+ +
+
+ +
+
+ +
+ + 퇴근 시간 + +
+ +
+
+ +
+
+
+
+ +
+ +
+
+ ) +} diff --git a/src/pages/signup/components/EmailVerification.tsx b/src/pages/signup/components/EmailVerification.tsx index 0d6d3f8..d3d2f28 100644 --- a/src/pages/signup/components/EmailVerification.tsx +++ b/src/pages/signup/components/EmailVerification.tsx @@ -42,9 +42,9 @@ export function EmailVerification({ onChange={e => handleEmailChange(e.target.value)} borderColor={ verified - ? '1px solid #2DE283' + ? '1px solid main' : message && !codeSent - ? '1px solid #DC0000' + ? '1px solid error' : undefined } /> @@ -92,7 +92,7 @@ export function EmailVerification({ {message && (

{message}

diff --git a/src/pages/signup/components/PhoneVerification.tsx b/src/pages/signup/components/PhoneVerification.tsx index b8403d7..6652faa 100644 --- a/src/pages/signup/components/PhoneVerification.tsx +++ b/src/pages/signup/components/PhoneVerification.tsx @@ -38,9 +38,9 @@ export function PhoneVerification({ onChange={e => handlePhoneChange(e.target.value)} borderColor={ verified - ? '1px solid #2DE283' + ? '1px solid main' : message && !smsSent - ? '1px solid #DC0000' + ? '1px solid error' : undefined } /> @@ -88,7 +88,7 @@ export function PhoneVerification({ {message && (

{message}

diff --git a/src/pages/signup/components/SignupTerms.tsx b/src/pages/signup/components/SignupTerms.tsx index f7fe5fd..aa49cb6 100644 --- a/src/pages/signup/components/SignupTerms.tsx +++ b/src/pages/signup/components/SignupTerms.tsx @@ -36,7 +36,7 @@ export function SignupTerms({ className={checkboxCls} /> - (필수) + (필수) 이용약관과{' '} 개인정보 보호정책 에 동의합니다. diff --git a/src/pages/signup/components/Step2AccountInfo.tsx b/src/pages/signup/components/Step2AccountInfo.tsx index 094cad6..0f5c3ce 100644 --- a/src/pages/signup/components/Step2AccountInfo.tsx +++ b/src/pages/signup/components/Step2AccountInfo.tsx @@ -111,9 +111,9 @@ export function Step2AccountInfo({ onChange={e => handleNicknameChange(e.target.value)} borderColor={ nicknameChecked - ? '1px solid #2DE283' + ? '1px solid main' : nicknameCheckMessage - ? '1px solid #DC0000' + ? '1px solid error' : undefined } /> @@ -128,7 +128,7 @@ export function Step2AccountInfo({ {nicknameCheckMessage && (

{nicknameCheckMessage}

diff --git a/src/pages/user/applied-stores/index.tsx b/src/pages/user/applied-stores/index.tsx new file mode 100644 index 0000000..3e26607 --- /dev/null +++ b/src/pages/user/applied-stores/index.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react' +import { Navbar } from '@/shared/ui/common/Navbar' +import { AppliedStoreListItem } from '@/features/home/user/applied-stores/ui/AppliedStoreListItem' +import { AppliedStoreDetailModal } from '@/features/home/user/applied-stores/ui/AppliedStoreDetailModal' +import { useAppliedStoresViewModel } from '@/features/home/user/applied-stores/hooks/useAppliedStoresViewModel' +import type { AppliedStoreData } from '@/features/home/user/applied-stores/types/appliedStore' +import DownIcon from '@/assets/icons/home/chevron-down.svg?react' + +export function AppliedStoresPage() { + const [selectedStore, setSelectedStore] = useState( + null + ) + + const { + filterLabel, + isDropdownOpen, + dropdownRef, + filterOptions, + grouped, + toggleDropdown, + selectFilter, + getCardStatus, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + cancelApplication, + isCancelling, + } = useAppliedStoresViewModel() + + const closeDetail = () => setSelectedStore(null) + + const handleCancel = () => { + if (!selectedStore) return + cancelApplication(selectedStore.id, { onSuccess: closeDetail }) + } + + return ( +
+ +
+
+ + + {isDropdownOpen && ( +
+ {filterOptions.map((option, index) => ( + + ))} +
+ )} +
+ + {isLoading ? ( +
+

로딩 중...

+
+ ) : isError ? ( +
+

+ 데이터를 불러오는 데 실패했습니다. +

+
+ ) : grouped.length === 0 ? ( +
+

+ 지원 내역이 없습니다. +

+
+ ) : ( + <> +
+ {grouped.map(section => ( +
+

+ {section.label} +

+
+ {section.stores.map(store => ( + setSelectedStore(store)} + /> + ))} +
+
+ ))} +
+ + {hasNextPage && ( + + )} + + )} +
+ + {selectedStore?.applicationDetail && ( + + )} +
+ ) +} diff --git a/src/pages/user/home/index.tsx b/src/pages/user/home/index.tsx index f6b1cfb..44a41a4 100644 --- a/src/pages/user/home/index.tsx +++ b/src/pages/user/home/index.tsx @@ -1,94 +1,63 @@ -import { useEffect, useState } from 'react' +import { useMemo } from 'react' +import { useNavigate } from 'react-router-dom' import { - getDailySchedules, - getMonthlySchedules, - getWeeklySchedules, HomeScheduleCalendar, - type CalendarViewData, - type HomeCalendarMode, + WorkingStoresList, + AppliedStoreList, } from '@/features/home' -import { getRangeParamsByMode } from '@/features/home/user/lib/date' +import type { AppliedStoreItem } from '@/features/home/user/applied-stores/ui/AppliedStoreList' +import { useHomeScheduleViewModel } from '@/features/home/user/schedule/hooks/useHomeScheduleViewModel' +import { useWorkspacesViewModel } from '@/features/home/user/workspace/hooks/useWorkspacesViewModel' +import { useAppliedStoresViewModel } from '@/features/home/user/applied-stores/hooks/useAppliedStoresViewModel' +import { Navbar } from '@/shared/ui/common/Navbar' export function UserHomePage() { - const [mode, setMode] = useState('monthly') - const [baseDate, setBaseDate] = useState(new Date()) - const [data, setData] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [errorMessage, setErrorMessage] = useState('') - - useEffect(() => { - let mounted = true - - const fetchData = async () => { - setIsLoading(true) - setErrorMessage('') - try { - const range = getRangeParamsByMode(baseDate, mode) - const result = - mode === 'monthly' - ? await getMonthlySchedules(range) - : mode === 'weekly' - ? await getWeeklySchedules(range) - : await getDailySchedules(range) - - if (!mounted) return - setData(result) - } catch (error) { - if (!mounted) return - setData(null) - setErrorMessage( - error instanceof Error - ? error.message - : '홈 스케줄을 불러오는 중 오류가 발생했습니다.' - ) - } finally { - if (mounted) { - setIsLoading(false) - } - } - } - - fetchData() - - return () => { - mounted = false - } - }, [baseDate, mode]) + const navigate = useNavigate() + + const { mode, baseDate, calendarData, isLoading, onDateChange } = + useHomeScheduleViewModel() + + const { workspaces } = useWorkspacesViewModel() + + const { grouped } = useAppliedStoresViewModel() + + const appliedStores = useMemo( + () => + grouped + .flatMap(g => g.stores) + .slice(0, 5) + .map(s => ({ + id: s.id, + storeName: s.storeName, + status: s.status === 'cancelled' ? 'rejected' : 'applied', + })), + [grouped] + ) return ( -
-
- {(['monthly', 'weekly', 'daily'] as const).map(item => ( - - ))} +
+
+ +
+
+ + + navigate('/user/workspace')} + /> + + navigate('/user/applied-stores')} + />
- - {errorMessage && ( -
- {errorMessage} -
- )} - -
) } diff --git a/src/pages/user/schedule/components/ScheduleItem.tsx b/src/pages/user/schedule/components/ScheduleItem.tsx index ce813f0..5161273 100644 --- a/src/pages/user/schedule/components/ScheduleItem.tsx +++ b/src/pages/user/schedule/components/ScheduleItem.tsx @@ -1,6 +1,6 @@ -import type { ScheduleItem as ScheduleItemType } from '@/shared/stores/useScheduleStore' +import type { ScheduleListItem } from '@/features/home/user/schedule/types/scheduleList' -interface ScheduleItemProps extends ScheduleItemType { +interface ScheduleItemProps extends ScheduleListItem { onClick?: (id: string) => void } diff --git a/src/pages/user/schedule/hooks/useSchedule.ts b/src/pages/user/schedule/hooks/useSchedule.ts deleted file mode 100644 index 54e40d6..0000000 --- a/src/pages/user/schedule/hooks/useSchedule.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useScheduleStore } from '@/shared/stores/useScheduleStore' - -/** - * 스케줄 페이지의 UI 상태(연·월)와 핸들러만 제공. - * 데이터 조회(useSelfScheduleQuery)는 데이터를 소비하는 페이지에서 호출. - */ -export function useSchedule() { - const currentYear = useScheduleStore(state => state.currentYear) - const currentMonth = useScheduleStore(state => state.currentMonth) - const goPrevMonth = useScheduleStore(state => state.goPrevMonth) - const goNextMonth = useScheduleStore(state => state.goNextMonth) - - const handlePreviousMonth = () => { - goPrevMonth() - } - - const handleNextMonth = () => { - goNextMonth() - } - - const handleScheduleClick = (id: string) => { - console.log('스케줄 클릭:', id) - // 스케줄 상세 페이지 이동 (필요시 구현) - } - - return { - currentYear, - currentMonth, - handlePreviousMonth, - handleNextMonth, - handleScheduleClick, - } -} diff --git a/src/pages/user/schedule/index.tsx b/src/pages/user/schedule/index.tsx index 290bbd4..4079577 100644 --- a/src/pages/user/schedule/index.tsx +++ b/src/pages/user/schedule/index.tsx @@ -1,7 +1,6 @@ -import type { ScheduleItem as ScheduleItemType } from '@/shared/stores/useScheduleStore' -import { useSelfScheduleQuery } from '@/shared/hooks/useSelfScheduleQuery' +import type { ScheduleListItem } from '@/features/home/user/schedule/types/scheduleList' +import { useScheduleListViewModel } from '@/features/home/user/schedule/hooks/useScheduleListViewModel' import { ScheduleItem } from './components/ScheduleItem' -import { useSchedule } from './hooks/useSchedule' import { ChevronLeftIcon } from '@/assets/icons/ChevronLeftIcon' import { ChevronRightIcon } from '@/assets/icons/ChevronRightIcon' import { CalendarEmptyIcon } from '@/assets/icons/CalendarEmptyIcon' @@ -11,15 +10,12 @@ export function SchedulePage() { const { currentYear, currentMonth, + schedules, + isLoading, handlePreviousMonth, handleNextMonth, handleScheduleClick, - } = useSchedule() - - const { schedules, isLoading } = useSelfScheduleQuery({ - year: currentYear, - month: currentMonth, - }) + } = useScheduleListViewModel() return (
@@ -61,7 +57,7 @@ export function SchedulePage() { {schedules.length > 0 ? (
- {schedules.map((schedule: ScheduleItemType) => ( + {schedules.map((schedule: ScheduleListItem) => ( () + const { state } = useLocation() + const id = Number(workspaceId) + const businessName = (state as { businessName?: string } | null)?.businessName + + const { + mode, + baseDate, + calendarData, + isLoading: scheduleLoading, + onDateChange, + } = useWorkspaceScheduleViewModel(id) + + const { + managers, + fetchNextPage: fetchNextManagers, + hasNextPage: hasMoreManagers, + isFetchingNextPage: fetchingManagers, + isLoading: managersLoading, + } = useWorkspaceManagersViewModel(id, 5) + + const { + workers, + fetchNextPage: fetchNextWorkers, + hasNextPage: hasMoreWorkers, + isFetchingNextPage: fetchingWorkers, + isLoading: workersLoading, + } = useWorkspaceWorkersViewModel(id, 5) + + return ( +
+ +
+ + + {/* 관리자 섹션 */} +
+
+ + + 관리자 ({managers.length}명) + +
+ + {managersLoading ? ( +
+

+ 로딩 중... +

+
+ ) : managers.length === 0 ? ( +
+

+ 등록된 관리자가 없습니다. +

+
+ ) : ( + <> +
+ {managers.map(manager => ( + {}} + /> + ))} +
+ {hasMoreManagers && ( + + )} + + )} +
+ + {/* 근무자 섹션 */} +
+
+ + + 근무자 ({workers.length}명) + +
+ + {workersLoading ? ( +
+

+ 로딩 중... +

+
+ ) : workers.length === 0 ? ( +
+

+ 등록된 근무자가 없습니다. +

+
+ ) : ( + <> +
+ {workers.map(worker => ( + {}} + /> + ))} +
+ {hasMoreWorkers && ( + + )} + + )} +
+
+
+ ) +} diff --git a/src/pages/user/workspace-members/components/ManagersSection.tsx b/src/pages/user/workspace-members/components/ManagersSection.tsx index 3de3f1e..df6fb5f 100644 --- a/src/pages/user/workspace-members/components/ManagersSection.tsx +++ b/src/pages/user/workspace-members/components/ManagersSection.tsx @@ -1,15 +1,13 @@ -import type { WorkspaceManagerDto } from '@/shared/api/workspaceMembers' +import type { WorkspaceManagerItem } from '@/features/home/user/workspace/types/workspaceMembers' import { LoadMoreButton } from './LoadMoreButton' type Props = { - managers: WorkspaceManagerDto[] + managers: WorkspaceManagerItem[] hasMore: boolean onLoadMore: () => void } -export function ManagersSection(props: Props) { - const { managers, hasMore, onLoadMore } = props - +export function ManagersSection({ managers, hasMore, onLoadMore }: Props) { if (managers.length === 0) return null return ( @@ -25,11 +23,11 @@ export function ManagersSection(props: Props) { >
- {manager.manager.name} + {manager.name} - {manager.position.emoji}{' '} - {manager.position.description || manager.position.type} + {manager.positionEmoji}{' '} + {manager.positionDescription || manager.positionType}
diff --git a/src/pages/user/workspace-members/components/WorkersSection.tsx b/src/pages/user/workspace-members/components/WorkersSection.tsx index 99c4462..5b615cd 100644 --- a/src/pages/user/workspace-members/components/WorkersSection.tsx +++ b/src/pages/user/workspace-members/components/WorkersSection.tsx @@ -1,15 +1,13 @@ -import type { WorkspaceWorkerDto } from '@/shared/api/workspaceMembers' +import type { WorkspaceWorkerItem } from '@/features/home/user/workspace/types/workspaceMembers' import { LoadMoreButton } from './LoadMoreButton' type Props = { - workers: WorkspaceWorkerDto[] + workers: WorkspaceWorkerItem[] hasMore: boolean onLoadMore: () => void } -export function WorkersSection(props: Props) { - const { workers, hasMore, onLoadMore } = props - +export function WorkersSection({ workers, hasMore, onLoadMore }: Props) { if (workers.length === 0) return null return ( @@ -25,11 +23,11 @@ export function WorkersSection(props: Props) { >
- {worker.user.name} + {worker.name} - {worker.position.emoji}{' '} - {worker.position.description || worker.position.type} + {worker.positionEmoji}{' '} + {worker.positionDescription || worker.positionType}
diff --git a/src/pages/user/workspace-members/hooks/useWorkspaceMembers.ts b/src/pages/user/workspace-members/hooks/useWorkspaceMembers.ts index 67af613..559d72d 100644 --- a/src/pages/user/workspace-members/hooks/useWorkspaceMembers.ts +++ b/src/pages/user/workspace-members/hooks/useWorkspaceMembers.ts @@ -1,6 +1,6 @@ -import { useEffect, useMemo, useState } from 'react' -import { useWorkspaceManagersQuery } from '@/shared/hooks/useWorkspaceManagersQuery' -import { useWorkspaceWorkersQuery } from '@/shared/hooks/useWorkspaceWorkersQuery' +import { useMemo } from 'react' +import { useWorkspaceWorkersViewModel } from '@/features/home/user/workspace/hooks/useWorkspaceWorkersViewModel' +import { useWorkspaceManagersViewModel } from '@/features/home/user/workspace/hooks/useWorkspaceManagersViewModel' type Params = { workspaceId?: string @@ -9,203 +9,40 @@ type Params = { } export function useWorkspaceMembers(params: Params) { - const { workspaceId, initialPageSize = 3, loadMorePageSize = 10 } = params + const { workspaceId, initialPageSize = 3 } = params - const numericWorkspaceId = useMemo(() => { + const numericId = useMemo(() => { const parsed = Number(workspaceId) - return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0 }, [workspaceId]) - const isWorkspaceIdInvalid = !numericWorkspaceId - type WorkersState = { - workspaceId?: number - cursor?: string - items: ReturnType['workers'] - } - - type ManagersState = { - workspaceId?: number - cursor?: string - items: ReturnType['managers'] - } - - const [workersState, setWorkersState] = useState({ - workspaceId: numericWorkspaceId, - cursor: undefined, - items: [], - }) - - const [managersState, setManagersState] = useState({ - workspaceId: numericWorkspaceId, - cursor: undefined, - items: [], - }) - - const workersCursor = - workersState.workspaceId === numericWorkspaceId - ? workersState.cursor - : undefined - const managersCursor = - managersState.workspaceId === numericWorkspaceId - ? managersState.cursor - : undefined + const isWorkspaceIdInvalid = numericId === 0 const { workers, + fetchNextPage: fetchNextWorkers, + hasNextPage: hasMoreWorkers, isLoading: workersLoading, - error: workersError, - page: workersPage, - } = useWorkspaceWorkersQuery({ - workspaceId: numericWorkspaceId, - cursor: workersCursor, - pageSize: workersCursor ? loadMorePageSize : initialPageSize, - }) + isError: workersError, + } = useWorkspaceWorkersViewModel(numericId, initialPageSize) const { managers, + fetchNextPage: fetchNextManagers, + hasNextPage: hasMoreManagers, isLoading: managersLoading, - error: managersError, - page: managersPage, - } = useWorkspaceManagersQuery({ - workspaceId: numericWorkspaceId, - cursor: managersCursor, - pageSize: managersCursor ? loadMorePageSize : initialPageSize, - }) - const allWorkers = - workersState.workspaceId === numericWorkspaceId ? workersState.items : [] - const allManagers = - managersState.workspaceId === numericWorkspaceId ? managersState.items : [] - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setWorkersState(prev => { - if (prev.workspaceId === numericWorkspaceId) return prev - - return { - workspaceId: numericWorkspaceId, - cursor: undefined, - items: [], - } - }) - - setManagersState(prev => { - if (prev.workspaceId === numericWorkspaceId) return prev - - return { - workspaceId: numericWorkspaceId, - cursor: undefined, - items: [], - } - }) - }, [numericWorkspaceId]) - - useEffect(() => { - if (!numericWorkspaceId || workersLoading || workersError) return - if (!workers || workers.length === 0) return - - // eslint-disable-next-line react-hooks/set-state-in-effect - setWorkersState(prev => { - if (prev.workspaceId !== numericWorkspaceId) { - const existingIds = new Set(workers.map(worker => worker.id)) - const uniqueWorkers = workers.filter( - worker => !existingIds.has(worker.id) - ) - - return { - workspaceId: numericWorkspaceId, - cursor: undefined, - items: uniqueWorkers, - } - } - - const existingIds = new Set(prev.items.map(worker => worker.id)) - const appended = workers.filter(worker => !existingIds.has(worker.id)) - - return { - ...prev, - items: [...prev.items, ...appended], - } - }) - }, [numericWorkspaceId, workers, workersLoading, workersError]) - - useEffect(() => { - if (!numericWorkspaceId || managersLoading || managersError) return - if (!managers || managers.length === 0) return - - // eslint-disable-next-line react-hooks/set-state-in-effect - setManagersState(prev => { - if (prev.workspaceId !== numericWorkspaceId) { - const existingIds = new Set(managers.map(manager => manager.id)) - const uniqueManagers = managers.filter( - manager => !existingIds.has(manager.id) - ) - - return { - workspaceId: numericWorkspaceId, - cursor: undefined, - items: uniqueManagers, - } - } - - const existingIds = new Set(prev.items.map(manager => manager.id)) - const appended = managers.filter(manager => !existingIds.has(manager.id)) - - return { - ...prev, - items: [...prev.items, ...appended], - } - }) - }, [numericWorkspaceId, managers, managersLoading, managersError]) - - const workersTotalCount = workersPage?.totalCount ?? allWorkers.length - const managersTotalCount = managersPage?.totalCount ?? allManagers.length - - const hasMoreWorkers = workersTotalCount > allWorkers.length - const hasMoreManagers = managersTotalCount > allManagers.length - - const loadMoreWorkers = () => { - if (!numericWorkspaceId) return - - setWorkersState(prev => { - if (prev.workspaceId !== numericWorkspaceId) return prev - - return { - ...prev, - cursor: workersPage?.cursor ?? undefined, - } - }) - } - - const loadMoreManagers = () => { - if (!numericWorkspaceId) return - - setManagersState(prev => { - if (prev.workspaceId !== numericWorkspaceId) return prev - - return { - ...prev, - cursor: managersPage?.cursor ?? undefined, - } - }) - } - - const isLoading = workersLoading || managersLoading - const hasError = !!workersError || !!managersError + isError: managersError, + } = useWorkspaceManagersViewModel(numericId, initialPageSize) return { - numericWorkspaceId, isWorkspaceIdInvalid, - - isLoading, - hasError, - - workers: allWorkers, - managers: allManagers, - + isLoading: workersLoading || managersLoading, + hasError: workersError || managersError, + workers, + managers, hasMoreWorkers, hasMoreManagers, - - loadMoreWorkers, - loadMoreManagers, + loadMoreWorkers: fetchNextWorkers, + loadMoreManagers: fetchNextManagers, } } diff --git a/src/pages/user/workspace/index.tsx b/src/pages/user/workspace/index.tsx new file mode 100644 index 0000000..2fc820b --- /dev/null +++ b/src/pages/user/workspace/index.tsx @@ -0,0 +1,76 @@ +import { useNavigate } from 'react-router-dom' +import { Navbar } from '@/shared/ui/common/Navbar' +import { WorkingStoreCard } from '@/features/home/user/workspace/ui/WorkingStoreCard' +import { useWorkspacesViewModel } from '@/features/home/user/workspace/hooks/useWorkspacesViewModel' + +export function WorkspacePage() { + const navigate = useNavigate() + const { + workspaces, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } = useWorkspacesViewModel() + + if (isError) { + return ( +
+ +
+

+ 데이터를 불러오는 데 실패했습니다. +

+
+
+ ) + } + + return ( +
+ +
+ {isLoading ? ( +
+

로딩 중...

+
+ ) : ( + <> + {workspaces.map(store => ( + + ))} + {workspaces.length === 0 && ( +
+

+ 근무중인 가게가 없습니다. +

+
+ )} + {hasNextPage && ( + + )} + + )} +
+
+ ) +} diff --git a/src/shared/api/auth.ts b/src/shared/api/auth.ts index c2ee514..a7babe4 100644 --- a/src/shared/api/auth.ts +++ b/src/shared/api/auth.ts @@ -107,7 +107,7 @@ export async function loginIDPW( if (scope === 'MANAGER') { navigate('/main', { replace: true }) } else { - navigate('/job-lookup-map', { replace: true }) + navigate('/user/job-lookup-map', { replace: true }) } return loginResponse @@ -158,7 +158,7 @@ export async function loginSocial( if (scope === 'MANAGER') { navigate('/main', { replace: true }) } else { - navigate('/job-lookup-map', { replace: true }) + navigate('/user/job-lookup-map', { replace: true }) } return result @@ -327,7 +327,7 @@ export async function signup( if (scope === 'MANAGER') { navigate('/main', { replace: true }) } else { - navigate('/job-lookup-map', { replace: true }) + navigate('/user/job-lookup-map', { replace: true }) } return signupResponse diff --git a/src/shared/api/schedule.ts b/src/shared/api/schedule.ts deleted file mode 100644 index 7dfbcfe..0000000 --- a/src/shared/api/schedule.ts +++ /dev/null @@ -1,59 +0,0 @@ -import axios from 'axios' -import axiosInstance from '@/shared/lib/axiosInstance' -import type { - ApiError, - CommonApiResponse, - ErrorResponse, -} from '@/shared/types/common' -import type { StatusEnum } from '@/shared/types/enums' - -type SelfScheduleResponse = CommonApiResponse<{ - totalWorkHours: number - schedules: { - shiftId: number - workspace: { - workspaceId: number - workspaceName: string - } - startDateTime: string - endDateTime: string - position: string - status: StatusEnum - }[] -}> - -type GetSelfScheduleParams = { - year?: number - month?: number - day?: number -} - -export async function getSelfSchedule( - params?: GetSelfScheduleParams -): Promise { - const { year, month, day } = params ?? {} - - try { - const response = await axiosInstance.get( - '/app/schedules/self', - { - params: { - ...(year !== undefined && { year }), - ...(month !== undefined && { month }), - ...(day !== undefined && { day }), - }, - } - ) - return response.data - } catch (error) { - if (axios.isAxiosError(error)) { - const errorData: ErrorResponse = error.response?.data ?? {} - const message = - errorData.message ?? '나의 근무 스케줄 조회 중 오류가 발생했습니다.' - const apiError = new Error(message) as ApiError & Error - apiError.data = errorData - throw apiError - } - throw new Error('나의 근무 스케줄 조회 중 오류가 발생했습니다.') - } -} diff --git a/src/shared/api/workspaceMembers.ts b/src/shared/api/workspaceMembers.ts deleted file mode 100644 index da49b73..0000000 --- a/src/shared/api/workspaceMembers.ts +++ /dev/null @@ -1,115 +0,0 @@ -import axios from 'axios' -import axiosInstance from '@/shared/lib/axiosInstance' -import type { - ApiError, - CommonApiResponse, - ErrorResponse, -} from '@/shared/types/common' - -type PageInfo = { - cursor: string | null - pageSize: number - totalCount: number -} - -type PositionDto = { - type: string - description: string - emoji: string -} - -export type WorkspaceWorkerDto = { - id: number - user: { - id: number - name: string - } - position: PositionDto - employedAt: string - nextShiftDateTime: string -} - -export type WorkspaceManagerDto = { - id: number - manager: { - id: number - name: string - } - position: PositionDto -} - -export type WorkspaceWorkersResponse = CommonApiResponse<{ - page: PageInfo - data: WorkspaceWorkerDto[] -}> - -export type WorkspaceManagersResponse = CommonApiResponse<{ - page: PageInfo - data: WorkspaceManagerDto[] -}> - -export type WorkspaceMembersParams = { - workspaceId: number - cursor?: string - pageSize?: number -} - -export async function getWorkspaceWorkers( - params: WorkspaceMembersParams -): Promise { - const { workspaceId, cursor, pageSize } = params - - try { - const response = await axiosInstance.get( - `/app/users/me/workspaces/${workspaceId}/workers`, - { - params: { - ...(cursor !== undefined && { cursor }), - ...(pageSize !== undefined && { pageSize }), - }, - } - ) - return response.data - } catch (error) { - if (axios.isAxiosError(error)) { - const errorData: ErrorResponse = error.response?.data ?? {} - const message = - errorData.message ?? '근무자 목록 조회 중 오류가 발생했습니다.' - const apiError = new Error(message) as ApiError & Error - apiError.data = errorData - throw apiError - } - - throw new Error('근무자 목록 조회 중 오류가 발생했습니다.') - } -} - -export async function getWorkspaceManagers( - params: WorkspaceMembersParams -): Promise { - const { workspaceId, cursor, pageSize } = params - - try { - const response = await axiosInstance.get( - `/app/users/me/workspaces/${workspaceId}/managers`, - { - params: { - ...(cursor !== undefined && { cursor }), - ...(pageSize !== undefined && { pageSize }), - }, - } - ) - return response.data - } catch (error) { - if (axios.isAxiosError(error)) { - const errorData: ErrorResponse = error.response?.data ?? {} - const message = - errorData.message ?? '점주/매니저 목록 조회 중 오류가 발생했습니다.' - const apiError = new Error(message) as ApiError & Error - apiError.data = errorData - throw apiError - } - - throw new Error('점주/매니저 목록 조회 중 오류가 발생했습니다.') - } -} diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index 3ca9b48..67ef6b3 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -1,12 +1,2 @@ -export { - useSelfScheduleQuery, - SELF_SCHEDULE_QUERY_KEY, -} from './useSelfScheduleQuery' -export { - useWorkspaceWorkersQuery, - WORKSPACE_WORKERS_QUERY_KEY, -} from './useWorkspaceWorkersQuery' -export { - useWorkspaceManagersQuery, - WORKSPACE_MANAGERS_QUERY_KEY, -} from './useWorkspaceManagersQuery' +export { useWorkspaceWorkersViewModel as useWorkspaceWorkersQuery } from '@/features/home/user/workspace/hooks/useWorkspaceWorkersViewModel' +export { useWorkspaceManagersViewModel as useWorkspaceManagersQuery } from '@/features/home/user/workspace/hooks/useWorkspaceManagersViewModel' diff --git a/src/shared/hooks/useSelfScheduleQuery.ts b/src/shared/hooks/useSelfScheduleQuery.ts deleted file mode 100644 index d9c283c..0000000 --- a/src/shared/hooks/useSelfScheduleQuery.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { useMemo } from 'react' -import { useQuery } from '@tanstack/react-query' -import { getSelfSchedule } from '@/shared/api/schedule' -import type { ScheduleItem } from '@/shared/stores/useScheduleStore' - -const DAY_LABELS = ['일', '월', '화', '수', '목', '금', '토'] as const - -function formatTimeRange( - startIso: string, - endIso: string -): { time: string; hours: string } { - const start = new Date(startIso) - const end = new Date(endIso) - - const pad = (value: number) => value.toString().padStart(2, '0') - - const startTime = `${pad(start.getHours())}:${pad(start.getMinutes())}` - const endTime = `${pad(end.getHours())}:${pad(end.getMinutes())}` - - const diffMs = end.getTime() - start.getTime() - const diffHours = Math.max(diffMs / (1000 * 60 * 60), 0) - - const hoursLabel = Number.isInteger(diffHours) - ? `${diffHours}시간` - : `${diffHours.toFixed(1)}시간` - - return { - time: `${startTime} ~ ${endTime}`, - hours: hoursLabel, - } -} - -function mapSchedulesToItems( - data: Awaited>['data'] | undefined -): ScheduleItem[] { - if (!data) return [] - return data.schedules.map(schedule => { - const start = new Date(schedule.startDateTime) - const dayIndex = start.getDay() - const { time, hours } = formatTimeRange( - schedule.startDateTime, - schedule.endDateTime - ) - return { - id: String(schedule.shiftId), - day: DAY_LABELS[dayIndex], - date: String(start.getDate()), - workplace: schedule.workspace.workspaceName, - time, - hours, - } - }) -} - -export const SELF_SCHEDULE_QUERY_KEY = ['schedule', 'self'] as const - -type SelfScheduleQueryParams = { - year: number - month: number - day?: number -} - -/** - * 나의 근무 스케줄 조회 - * 파라미터 조합에 따라 조회가 달라집니다. -- 인자 없음: 이번 주 스케줄 조회 -- year, month: 해당 월 스케줄 조회 -- year, month, day: 해당 일 스케줄 조회 - * - * @param params.year 조회할 연도 - * @param params.month 조회할 월 - * @param params.day 조회할 일 (일별 조회 시 사용) - * @returns - */ -export function useSelfScheduleQuery(params: SelfScheduleQueryParams) { - const { year, month, day } = params - - const { data, isPending, error } = useQuery({ - queryKey: [ - ...SELF_SCHEDULE_QUERY_KEY, - year, - month, - ...(day !== undefined ? [day] : []), - ], - queryFn: async () => { - const res = await getSelfSchedule({ - year, - month, - ...(day !== undefined && { day }), - }) - return res.data - }, - }) - - const schedules = useMemo(() => mapSchedulesToItems(data), [data]) - - return { - schedules, - isLoading: isPending, - error, - rawData: data, - } -} diff --git a/src/shared/hooks/useWorkspaceManagersQuery.ts b/src/shared/hooks/useWorkspaceManagersQuery.ts deleted file mode 100644 index 1f11fc5..0000000 --- a/src/shared/hooks/useWorkspaceManagersQuery.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useMemo } from 'react' -import { useQuery } from '@tanstack/react-query' -import { - getWorkspaceManagers, - type WorkspaceManagersResponse, -} from '@/shared/api/workspaceMembers' - -export const WORKSPACE_MANAGERS_QUERY_KEY = ['workspace', 'managers'] as const - -type WorkspaceManagersQueryParams = { - workspaceId?: number - cursor?: string - pageSize?: number -} - -type WorkspaceManagersData = WorkspaceManagersResponse['data'] - -export function useWorkspaceManagersQuery( - params: WorkspaceManagersQueryParams -) { - const { workspaceId, cursor, pageSize } = params - - const { data, isPending, error } = useQuery({ - queryKey: [...WORKSPACE_MANAGERS_QUERY_KEY, workspaceId, cursor, pageSize], - queryFn: async () => { - if (!workspaceId) { - throw new Error('workspaceId는 필수입니다.') - } - - const res = await getWorkspaceManagers({ - workspaceId, - cursor, - pageSize, - }) - return res.data - }, - enabled: !!workspaceId, - }) - - const managers = useMemo( - () => (data as WorkspaceManagersData | undefined)?.data ?? [], - [data] - ) - - const pageInfo = useMemo( - () => (data as WorkspaceManagersData | undefined)?.page, - [data] - ) - - return { - managers, - page: pageInfo, - isLoading: isPending, - error, - rawData: data as WorkspaceManagersData | undefined, - } -} diff --git a/src/shared/hooks/useWorkspaceWorkersQuery.ts b/src/shared/hooks/useWorkspaceWorkersQuery.ts deleted file mode 100644 index a45aaaf..0000000 --- a/src/shared/hooks/useWorkspaceWorkersQuery.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { useMemo } from 'react' -import { useQuery } from '@tanstack/react-query' -import { - getWorkspaceWorkers, - type WorkspaceWorkersResponse, -} from '@/shared/api/workspaceMembers' - -export const WORKSPACE_WORKERS_QUERY_KEY = ['workspace', 'workers'] as const - -type WorkspaceWorkersQueryParams = { - workspaceId?: number - cursor?: string - pageSize?: number -} - -type WorkspaceWorkersData = WorkspaceWorkersResponse['data'] - -export function useWorkspaceWorkersQuery(params: WorkspaceWorkersQueryParams) { - const { workspaceId, cursor, pageSize } = params - - const { data, isPending, error } = useQuery({ - queryKey: [...WORKSPACE_WORKERS_QUERY_KEY, workspaceId, cursor, pageSize], - queryFn: async () => { - if (!workspaceId) { - throw new Error('workspaceId는 필수입니다.') - } - - const res = await getWorkspaceWorkers({ - workspaceId, - cursor, - pageSize, - }) - return res.data - }, - enabled: !!workspaceId, - }) - - const workers = useMemo( - () => (data as WorkspaceWorkersData | undefined)?.data ?? [], - [data] - ) - - const pageInfo = useMemo( - () => (data as WorkspaceWorkersData | undefined)?.page, - [data] - ) - - return { - workers, - page: pageInfo, - isLoading: isPending, - error, - rawData: data as WorkspaceWorkersData | undefined, - } -} diff --git a/src/shared/lib/queryKeys.ts b/src/shared/lib/queryKeys.ts new file mode 100644 index 0000000..227b291 --- /dev/null +++ b/src/shared/lib/queryKeys.ts @@ -0,0 +1,52 @@ +import type { SelfScheduleQueryParams } from '@/features/home/user/schedule/api/schedule' + +export const queryKeys = { + schedules: { + all: ['schedules'] as const, + self: (params: SelfScheduleQueryParams) => + ['schedules', 'self', params] as const, + }, + workspace: { + workers: (workspaceId?: number) => + ['workspace', 'workers', workspaceId] as const, + managers: (workspaceId?: number) => + ['workspace', 'managers', workspaceId] as const, + list: (params?: { pageSize: number }) => + ['workspace', 'list', params] as const, + schedules: ( + workspaceId: number, + params?: { year?: number; month?: number; day?: number } + ) => ['workspace', 'schedules', workspaceId, params] as const, + }, + application: { + list: (params?: { status?: string[]; pageSize?: number }) => + ['application', 'list', params] as const, + }, + managerWorkspace: { + list: () => ['managerWorkspace', 'list'] as const, + detail: (workspaceId: number) => + ['managerWorkspace', 'detail', workspaceId] as const, + workers: ( + workspaceId: number, + params?: { status?: string; name?: string; pageSize?: number } + ) => ['managerWorkspace', 'workers', workspaceId, params] as const, + }, + posting: { + list: (params?: { + workspaceId?: number + status?: string + pageSize?: number + }) => ['posting', 'list', params] as const, + }, + substitute: { + list: (params?: { + workspaceId?: number + status?: string + pageSize?: number + }) => ['substitute', 'list', params] as const, + }, + manager: { + schedules: (workspaceId: number, year: number, month: number) => + ['manager', 'schedules', workspaceId, year, month] as const, + }, +} as const diff --git a/src/shared/stores/useDocStore.ts b/src/shared/stores/useDocStore.ts index 8bf6ff7..c6a1138 100644 --- a/src/shared/stores/useDocStore.ts +++ b/src/shared/stores/useDocStore.ts @@ -8,7 +8,7 @@ const PATHNAME_TAB_MAP: Array<{ matcher: RegExp; tab: TabKey }> = [ { matcher: /(^|\/)repute(\/|$)/, tab: 'repute' }, { matcher: /(^|\/)search(\/|$)/, tab: 'search' }, { matcher: /^\/manager\/home/, tab: 'home' }, - { matcher: /^\/job-lookup-map/, tab: 'search' }, + { matcher: /^\/user\/job-lookup-map/, tab: 'search' }, ] const createSelectedTab = (activeTab?: TabKey) => ({ diff --git a/src/shared/stores/useScheduleStore.ts b/src/shared/stores/useScheduleStore.ts deleted file mode 100644 index 7ab06ab..0000000 --- a/src/shared/stores/useScheduleStore.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { create } from 'zustand' - -export interface ScheduleItem { - id: string - day: string - date: string - workplace: string - time: string - hours: string -} - -export interface ScheduleState { - schedules: ScheduleItem[] - isLoading: boolean - hasMore: boolean - nextCursor: string | null - isLoadingMore: boolean - currentYear: number - currentMonth: number - setSchedules: (schedules: ScheduleItem[]) => void - setLoading: (loading: boolean) => void - setHasMore: (hasMore: boolean) => void - setNextCursor: (cursor: string | null) => void - setIsLoadingMore: (loading: boolean) => void - setCurrentYear: (year: number) => void - setCurrentMonth: (month: number) => void - goPrevMonth: () => { year: number; month: number } - goNextMonth: () => { year: number; month: number } - setYearMonth: (year: number, month: number) => void -} - -export const useScheduleStore = create((set, get) => { - return { - schedules: [], - isLoading: true, - hasMore: true, - nextCursor: null, - isLoadingMore: false, - currentYear: new Date().getFullYear(), - currentMonth: new Date().getMonth() + 1, - - setSchedules: schedules => set({ schedules }), - setLoading: loading => set({ isLoading: loading }), - setHasMore: hasMore => set({ hasMore }), - setNextCursor: nextCursor => set({ nextCursor }), - setIsLoadingMore: isLoadingMore => set({ isLoadingMore }), - setCurrentYear: currentYear => set({ currentYear }), - setCurrentMonth: currentMonth => { - if (currentMonth < 1 || currentMonth > 12) { - throw new RangeError('month must be between 1 and 12') - } - set({ currentMonth }) - }, - - goPrevMonth: () => { - const { currentYear, currentMonth } = get() - const month = currentMonth === 1 ? 12 : currentMonth - 1 - const year = currentMonth === 1 ? currentYear - 1 : currentYear - set({ currentYear: year, currentMonth: month }) - return { year, month } - }, - - goNextMonth: () => { - const { currentYear, currentMonth } = get() - const month = currentMonth === 12 ? 1 : currentMonth + 1 - const year = currentMonth === 12 ? currentYear + 1 : currentYear - set({ currentYear: year, currentMonth: month }) - return { year, month } - }, - - setYearMonth: (year, month) => { - if (month < 1 || month > 12) { - throw new RangeError('month must be between 1 and 12') - } - set({ currentYear: year, currentMonth: month }) - }, - } -}) diff --git a/src/shared/stores/useWorkspaceStore.ts b/src/shared/stores/useWorkspaceStore.ts new file mode 100644 index 0000000..0312965 --- /dev/null +++ b/src/shared/stores/useWorkspaceStore.ts @@ -0,0 +1,19 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +interface WorkspaceState { + activeWorkspaceId: number | null + setActiveWorkspaceId: (id: number) => void +} + +export const useWorkspaceStore = create()( + persist( + set => ({ + activeWorkspaceId: null, + setActiveWorkspaceId: (id: number) => set({ activeWorkspaceId: id }), + }), + { + name: 'workspace-storage', + } + ) +) diff --git a/src/shared/ui/common/Docbar.tsx b/src/shared/ui/common/Docbar.tsx index 9627b29..9003889 100644 --- a/src/shared/ui/common/Docbar.tsx +++ b/src/shared/ui/common/Docbar.tsx @@ -9,6 +9,7 @@ import { useLocation, useNavigate } from 'react-router-dom' import { useDocStore } from '@/shared/stores/useDocStore' import { typography } from '@/shared/lib/tokens' import { TAB_TITLE_MAP, type TabKey } from '@/shared/types/tab' +import useAuthStore from '@/shared/stores/useAuthStore' function DocContent({ icon, @@ -108,14 +109,15 @@ export function Docbar() { const setSelectedTabByPathname = useDocStore( state => state.setSelectedTabByPathname ) + const { scope } = useAuthStore() useEffect(() => { setSelectedTabByPathname(pathname) }, [pathname, setSelectedTabByPathname]) const pathByTab: Record = { - home: '/manager/home', - search: '/job-lookup-map', + home: scope === 'MANAGER' ? '/manager/home' : '/user/home', + search: '/user/job-lookup-map', message: '/message', repute: '/repute', my: '/my', diff --git a/src/shared/ui/common/Navbar.tsx b/src/shared/ui/common/Navbar.tsx index 28f49fc..2df8625 100644 --- a/src/shared/ui/common/Navbar.tsx +++ b/src/shared/ui/common/Navbar.tsx @@ -2,7 +2,6 @@ import AlterLogo from '@/assets/Alter-logo.png' import BellIcon from '@/assets/icons/nav/bell.svg' import MenuIcon from '@/assets/icons/nav/menu.svg' import ChevronLeftIcon from '@/assets/icons/nav/chevron-left.svg' -import { typography } from '@/shared/lib/tokens' import { useNavigate } from 'react-router-dom' type NavbarVariant = 'main' | 'detail' @@ -30,7 +29,7 @@ export function Navbar({ } return ( -
+
{isMain ? (
@@ -51,16 +50,7 @@ export function Navbar({
{!isMain && ( - + {title} )} diff --git a/src/shared/ui/home/WorkerListItem.tsx b/src/shared/ui/home/WorkerListItem.tsx new file mode 100644 index 0000000..136c541 --- /dev/null +++ b/src/shared/ui/home/WorkerListItem.tsx @@ -0,0 +1,91 @@ +export type WorkerVariant = 'manager' | 'worker' + +export interface WorkerListItemProps { + name: string + role: string + variant?: WorkerVariant + nextWorkDate?: string + imageUrl?: string | null + onOptions?: () => void +} + +export interface WorkerListItemData extends WorkerListItemProps { + id: string +} + +function EllipsisIcon() { + return ( + + + + + + ) +} + +export function WorkerListItem({ + name, + role, + variant = 'worker', + nextWorkDate, + imageUrl, + onOptions, +}: WorkerListItemProps) { + const badgeBg = variant === 'manager' ? 'bg-main-700' : 'bg-main-300' + + return ( +
+
+
+ {imageUrl ? ( + {name} + ) : null} +
+ +
+
+ + {name} + + + {role} + +
+ + {nextWorkDate && ( +
+ + 다음 근무 예정일 + + + {nextWorkDate} + +
+ )} +
+
+ + +
+ ) +} diff --git a/src/shared/ui/home/WorkerRoleBadge.tsx b/src/shared/ui/home/WorkerRoleBadge.tsx index 351bbbd..4976344 100644 --- a/src/shared/ui/home/WorkerRoleBadge.tsx +++ b/src/shared/ui/home/WorkerRoleBadge.tsx @@ -1,4 +1,4 @@ -type WorkerRole = 'staff' | 'manager' +type WorkerRole = 'staff' | 'manager' | 'owner' interface WorkerRoleBadgeProps { role: WorkerRole @@ -14,6 +14,10 @@ const ROLE_STYLE_MAP = { label: '매니저', containerClassName: 'bg-main-700', }, + owner: { + label: '사장님', + containerClassName: 'bg-main-700', + }, } as const export function WorkerRoleBadge({ diff --git a/src/shared/ui/manager/OngoingPostingCard.tsx b/src/shared/ui/manager/OngoingPostingCard.tsx index a8a9987..6b1eda2 100644 --- a/src/shared/ui/manager/OngoingPostingCard.tsx +++ b/src/shared/ui/manager/OngoingPostingCard.tsx @@ -1,4 +1,7 @@ // 진행 중인 공고 카드 +import { MoreButton } from '@/shared/ui/common/MoreButton' +import clockIcon from '@/assets/icons/alba/Clock.svg' +import calendarIcon from '@/assets/icons/alba/Calendar.svg' export interface JobPostingItem { id: string @@ -15,6 +18,33 @@ interface OngoingPostingCardProps { onPostingClick?: (posting: JobPostingItem) => void } +function parseWage(wage: string) { + const match = wage.match(/(\d[\d,]*)/) + + if (!match || !match[1]) { + return { + prefix: '시급', + amount: wage, + suffix: '', + } + } + + const amount = match[1] + const parts = wage.split(amount) + const [rawPrefix = '', rawSuffix = ''] = parts + + return { + prefix: rawPrefix.trim() || '시급', + amount, + suffix: rawSuffix.trim(), + } +} + +function parseDdayValue(dDay: string) { + const value = Number(dDay.replace(/[^0-9]/g, '')) + return Number.isNaN(value) ? null : value +} + function PostingRow({ posting, onClick, @@ -24,26 +54,49 @@ function PostingRow({ onClick?: () => void isLast: boolean }) { + const dDayValue = parseDdayValue(posting.dDay) + const isUrgent = dDayValue !== null && dDayValue <= 3 + const { prefix, amount, suffix } = parseWage(posting.wage) + return ( ) @@ -55,8 +108,8 @@ export function OngoingPostingCard({ onPostingClick, }: OngoingPostingCardProps) { return ( -
-
+
+
{postings.map((posting, index) => ( ))}
-
- +
+
) diff --git a/src/shared/ui/manager/SubstituteApprovalCard.tsx b/src/shared/ui/manager/SubstituteApprovalCard.tsx index ec79b1c..a423caa 100644 --- a/src/shared/ui/manager/SubstituteApprovalCard.tsx +++ b/src/shared/ui/manager/SubstituteApprovalCard.tsx @@ -1,4 +1,5 @@ // 대타 승인 요청 카드 +import { MoreButton } from '@/shared/ui/common/MoreButton' export type SubstituteRequestStatus = 'accepted' | 'pending' @@ -24,12 +25,12 @@ const statusConfig: Record< accepted: { label: '수락됨', className: - 'bg-[#E8F5F1] border-[#3A9982]/30 text-[#3A9982] typography-body03-semibold', + 'border-[#2CE283] bg-[#EAFDF3] text-[#2CE283] typography-body02-semibold', }, pending: { label: '대기중', className: - 'bg-amber-50 border-amber-300/50 text-amber-700 typography-body03-semibold', + 'border-[#E28D2C] bg-[#FDF8EA] text-[#E28D2C] typography-body02-semibold', }, } @@ -48,37 +49,39 @@ function RequestRow({ +
+
) diff --git a/src/shared/ui/manager/WorkerListItem.tsx b/src/shared/ui/manager/WorkerListItem.tsx deleted file mode 100644 index fe83b4a..0000000 --- a/src/shared/ui/manager/WorkerListItem.tsx +++ /dev/null @@ -1,81 +0,0 @@ -// 근무자 목록 - -export type WorkerRole = '매니저' | '알바' - -export interface WorkerListItemProps { - name: string - role: WorkerRole - nextWorkDate: string - imageUrl?: string | null - onOptions?: () => void -} - -export interface WorkerListItemData extends WorkerListItemProps { - id: string -} - -function EllipsisIcon() { - return ( - - - - - - ) -} - -export function WorkerListItem({ - name, - role, - nextWorkDate, - imageUrl, - onOptions, -}: WorkerListItemProps) { - return ( -
-
- {imageUrl ? ( - {name} - ) : ( -
- )} -
-
-
- - {name} - - - {role} - -
-

- 다음 근무 예정일 {nextWorkDate} -

-
- -
- ) -} diff --git a/storybook/stories/AppliedStoreCard.stories.tsx b/storybook/stories/AppliedStoreCard.stories.tsx index 595b2d0..b755e25 100644 --- a/storybook/stories/AppliedStoreCard.stories.tsx +++ b/storybook/stories/AppliedStoreCard.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import React from 'react' -import { AppliedStoreCard } from '../../src/features/home/user/ui/AppliedStoreCard' +import { AppliedStoreCard } from '../../src/features/home/user/applied-stores/ui/AppliedStoreCard' const meta = { title: 'features/home/user/AppliedStoreCard', diff --git a/storybook/stories/AppliedStoreList.stories.tsx b/storybook/stories/AppliedStoreList.stories.tsx index ffb2de2..6490515 100644 --- a/storybook/stories/AppliedStoreList.stories.tsx +++ b/storybook/stories/AppliedStoreList.stories.tsx @@ -3,7 +3,7 @@ import React from 'react' import { AppliedStoreList, type AppliedStoreItem, -} from '../../src/features/home/user/ui/AppliedStoreList' +} from '../../src/features/home/user/applied-stores/ui/AppliedStoreList' const sampleStores: AppliedStoreItem[] = [ { id: 1, storeName: '지원한 매장 이름입니다.', status: 'applied' }, diff --git a/storybook/stories/HomeScheduleCalendar.stories.tsx b/storybook/stories/HomeScheduleCalendar.stories.tsx index c2d5912..dc90ca8 100644 --- a/storybook/stories/HomeScheduleCalendar.stories.tsx +++ b/storybook/stories/HomeScheduleCalendar.stories.tsx @@ -1,10 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import React, { useMemo, useState } from 'react' -import { HomeScheduleCalendar } from '../../src/features/home/user/ui/HomeScheduleCalendar' +import { HomeScheduleCalendar } from '../../src/features/home/user/schedule/ui/HomeScheduleCalendar' import type { CalendarViewData, HomeCalendarMode, -} from '../../src/features/home/user/types/schedule' +} from '../../src/features/home/user/schedule/types/schedule' const baseDate = new Date('2026-01-19T09:00:00+09:00') diff --git a/storybook/stories/MonthlyDateCell.stories.tsx b/storybook/stories/MonthlyDateCell.stories.tsx index 5783290..7b7fb39 100644 --- a/storybook/stories/MonthlyDateCell.stories.tsx +++ b/storybook/stories/MonthlyDateCell.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import React from 'react' -import { MonthlyDateCell } from '../../src/features/home/user/ui/MonthlyDateCell' +import { MonthlyDateCell } from '../../src/features/home/user/schedule/ui/MonthlyDateCell' const meta = { title: 'features/home/user/MonthlyDateCell', diff --git a/storybook/stories/WorkerListItem.stories.tsx b/storybook/stories/WorkerListItem.stories.tsx index 1385e1b..b38bb38 100644 --- a/storybook/stories/WorkerListItem.stories.tsx +++ b/storybook/stories/WorkerListItem.stories.tsx @@ -1,10 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import React from 'react' -import { WorkerListItem } from '../../src/shared/ui/manager/WorkerListItem' +import { WorkerListItem } from '../../src/shared/ui/home/WorkerListItem' const meta = { - title: 'shared/ui/manager/WorkerListItem', + title: 'shared/ui/home/WorkerListItem', component: WorkerListItem, parameters: { layout: 'centered' }, tags: ['autodocs'], @@ -20,19 +20,21 @@ const meta = { export default meta type Story = StoryObj -export const Manager: Story = { +export const ManagerVariant: Story = { args: { name: '이름임', - role: '매니저', + role: '사장님', + variant: 'manager', nextWorkDate: '2025. 1. 1.', onOptions: () => {}, }, } -export const PartTimer: Story = { +export const WorkerVariant: Story = { args: { name: '이름임', role: '알바', + variant: 'worker', nextWorkDate: '2025. 1. 1.', onOptions: () => {}, }, diff --git a/storybook/stories/WorkingStoresCard.stories.tsx b/storybook/stories/WorkingStoresCard.stories.tsx index 2a1dcdd..c7e5825 100644 --- a/storybook/stories/WorkingStoresCard.stories.tsx +++ b/storybook/stories/WorkingStoresCard.stories.tsx @@ -3,7 +3,7 @@ import React from 'react' import { WorkingStoresList, type WorkingStoreItem, -} from '../../src/features/home/user/ui/WorkingStoresList' +} from '../../src/features/home/user/workspace/ui/WorkingStoresList' const sampleStores: WorkingStoreItem[] = [ { diff --git a/tailwind.config.js b/tailwind.config.js index 4853b50..bf7381c 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -34,6 +34,7 @@ export default { // Main 색상 main: { DEFAULT: '#2ce283', + 900: '#42e590', 700: '#6ceba9', 500: '#96f1c1', 300: '#c0f7da',