diff --git a/src/app/App.tsx b/src/app/App.tsx index 20665f50..989a8a39 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -30,6 +30,12 @@ import { StoreRegisterRequestsPage } from '@/pages/store-register/requests' import { StoreRegisterRequestDetailPage } from '@/pages/store-register/request-detail' import { ManagerWorkerInvitePage } from '@/pages/manager/worker-invite' import { WorkspaceImageEditPage } from '@/pages/manager/workspace-image-edit' +import { ManagerPostingListPage } from '@/pages/manager/posting-list' +import { ManagerPostingCreatePage } from '@/pages/manager/posting-create' +import { ManagerPostingDetailPage } from '@/pages/manager/posting-detail' +import { ManagerPostingEditPage } from '@/pages/manager/posting-edit' +import { ManagerApplicationListPage } from '@/pages/manager/application-list' +import { ManagerApplicationDetailPage } from '@/pages/manager/application-detail' import { WorkspaceJoinPage } from '@/pages/user/workspace-join' import { NotificationPage } from '@/pages/notification' import { NotificationSettingsPage } from '@/pages/notification/settings' @@ -44,6 +50,7 @@ import { ErrorPageRoute } from '@/pages/error' import { MobileLayout } from '@/shared/ui/MobileLayout' import { MobileLayoutWithDocbar } from '@/shared/ui/MobileLayoutWithDocbar' import { HomeRouteGuard } from '@/shared/ui/common/HomeRouteGuard' +import { ToastViewport } from '@/shared/ui/common/ToastViewport' import { ROUTES } from '@/shared/constants/routes' const SignupPage = lazy(async () => { @@ -161,6 +168,47 @@ export function App() { path={ROUTES.MANAGER.WORKSPACE_IMAGES_EDIT_PATTERN} element={} /> + {/* 사장님 구인구직 — 정적 세그먼트가 :postingId보다 우선 매칭됩니다 */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> }> @@ -205,6 +253,22 @@ export function App() { path={ROUTES.MANAGER.SOCIAL_CHAT} element={} /> + + + + } + /> + + + + } + /> } /> } /> } /> + ) } diff --git a/src/assets/icons/doc/Applicant.svg b/src/assets/icons/doc/Applicant.svg new file mode 100644 index 00000000..a2241ee1 --- /dev/null +++ b/src/assets/icons/doc/Applicant.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/posting/Alert.svg b/src/assets/icons/posting/Alert.svg new file mode 100644 index 00000000..c221a140 --- /dev/null +++ b/src/assets/icons/posting/Alert.svg @@ -0,0 +1,4 @@ + diff --git a/src/assets/icons/posting/Calendar.svg b/src/assets/icons/posting/Calendar.svg new file mode 100644 index 00000000..17c0f11b --- /dev/null +++ b/src/assets/icons/posting/Calendar.svg @@ -0,0 +1,4 @@ + diff --git a/src/assets/icons/posting/CheckCircle.svg b/src/assets/icons/posting/CheckCircle.svg new file mode 100644 index 00000000..b75691d9 --- /dev/null +++ b/src/assets/icons/posting/CheckCircle.svg @@ -0,0 +1,4 @@ + diff --git a/src/assets/icons/posting/ChevronRight.svg b/src/assets/icons/posting/ChevronRight.svg new file mode 100644 index 00000000..55696990 --- /dev/null +++ b/src/assets/icons/posting/ChevronRight.svg @@ -0,0 +1,3 @@ + diff --git a/src/assets/icons/posting/Clock.svg b/src/assets/icons/posting/Clock.svg new file mode 100644 index 00000000..6bad8fda --- /dev/null +++ b/src/assets/icons/posting/Clock.svg @@ -0,0 +1,4 @@ + diff --git a/src/assets/icons/posting/Close.svg b/src/assets/icons/posting/Close.svg new file mode 100644 index 00000000..fa406d71 --- /dev/null +++ b/src/assets/icons/posting/Close.svg @@ -0,0 +1,3 @@ + diff --git a/src/assets/icons/posting/Lock.svg b/src/assets/icons/posting/Lock.svg new file mode 100644 index 00000000..c929a9c8 --- /dev/null +++ b/src/assets/icons/posting/Lock.svg @@ -0,0 +1,4 @@ + diff --git a/src/assets/icons/posting/Person.svg b/src/assets/icons/posting/Person.svg new file mode 100644 index 00000000..057cddce --- /dev/null +++ b/src/assets/icons/posting/Person.svg @@ -0,0 +1,4 @@ + diff --git a/src/assets/icons/posting/Plus.svg b/src/assets/icons/posting/Plus.svg new file mode 100644 index 00000000..fca5177b --- /dev/null +++ b/src/assets/icons/posting/Plus.svg @@ -0,0 +1,3 @@ + diff --git a/src/assets/icons/posting/Write.svg b/src/assets/icons/posting/Write.svg new file mode 100644 index 00000000..88461cd8 --- /dev/null +++ b/src/assets/icons/posting/Write.svg @@ -0,0 +1,4 @@ + diff --git a/src/features/job-lookup-map/hooks/useApplyPosting.ts b/src/features/job-lookup-map/hooks/useApplyPosting.ts index ce57999f..4e7af003 100644 --- a/src/features/job-lookup-map/hooks/useApplyPosting.ts +++ b/src/features/job-lookup-map/hooks/useApplyPosting.ts @@ -11,6 +11,7 @@ export function useApplyPosting() { const queryClient = useQueryClient() return useMutation({ + retry: false, mutationFn: ({ postingId, body }: ApplyPostingVariables) => applyPosting(postingId, body), onSuccess: (_, { postingId }) => { diff --git a/src/features/job-lookup-map/lib/applyPostingError.ts b/src/features/job-lookup-map/lib/applyPostingError.ts new file mode 100644 index 00000000..0943f676 --- /dev/null +++ b/src/features/job-lookup-map/lib/applyPostingError.ts @@ -0,0 +1,31 @@ +import axios from 'axios' + +import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage' +import type { ErrorResponse } from '@/shared/types/common' + +export interface ApplyPostingError { + message: string + retryable: boolean +} + +export function resolveApplyPostingError(error: unknown): ApplyPostingError { + if (axios.isAxiosError(error)) { + const response = error.response?.data as ErrorResponse | undefined + const retryable = + error.response?.status === 429 || response?.code === 'E001' + + if (retryable) { + return { + message: + response?.message ?? + '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.', + retryable: true, + } + } + } + + return { + message: getAxiosErrorMessage(error, '지원에 실패했습니다.'), + retryable: false, + } +} diff --git a/src/features/job-lookup-map/test/lib/applyPostingError.test.ts b/src/features/job-lookup-map/test/lib/applyPostingError.test.ts new file mode 100644 index 00000000..d0214fca --- /dev/null +++ b/src/features/job-lookup-map/test/lib/applyPostingError.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' + +import { resolveApplyPostingError } from '../../lib/applyPostingError' + +function axiosError(status: number, data: unknown) { + return { + isAxiosError: true, + message: `Request failed with status code ${status}`, + response: { status, data }, + } +} + +describe('공고 지원 오류', () => { + it.each([ + '이미 지원한 공고입니다.', + '모집이 종료된 공고입니다.', + '삭제된 근무일정입니다.', + ])('서버 메시지 %s를 표시한다', message => { + expect( + resolveApplyPostingError(axiosError(400, { code: 'B001', message })) + ).toEqual({ message, retryable: false }) + }) + + it('HTTP 429를 재시도 가능한 오류로 분류한다', () => { + expect( + resolveApplyPostingError( + axiosError(429, { + code: 'E001', + message: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.', + }) + ) + ).toEqual({ + message: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.', + retryable: true, + }) + }) + + it('E001 코드를 재시도 가능한 오류로 분류한다', () => { + expect( + resolveApplyPostingError( + axiosError(400, { + code: 'E001', + message: '잠시 후 다시 시도해 주세요.', + }) + ).retryable + ).toBe(true) + }) +}) diff --git a/src/features/manager/api/posting.ts b/src/features/manager/api/posting.ts index 5b814f77..eb749046 100644 --- a/src/features/manager/api/posting.ts +++ b/src/features/manager/api/posting.ts @@ -1,12 +1,14 @@ import axiosInstance from '@/shared/lib/axiosInstance' +import { unwrapCursorPage, type CursorPage } from '@/shared/lib/cursorPage' import type { + PostingDto, PostingListApiResponse, ManagedPostingsQueryParams, } from '@/features/manager/home/types/posting' export async function fetchManagedPostings( params: ManagedPostingsQueryParams -): Promise { +): Promise> { const response = await axiosInstance.get( '/manager/postings', { @@ -20,5 +22,5 @@ export async function fetchManagedPostings( }, } ) - return response.data + return unwrapCursorPage(response.data) } diff --git a/src/features/manager/home/hooks/useManagedPostingsViewModel.ts b/src/features/manager/home/hooks/useManagedPostingsViewModel.ts index 3b1a5871..c87696fe 100644 --- a/src/features/manager/home/hooks/useManagedPostingsViewModel.ts +++ b/src/features/manager/home/hooks/useManagedPostingsViewModel.ts @@ -30,19 +30,17 @@ export function useManagedPostingsViewModel( cursor: pageParam as string | undefined, }), initialPageParam: undefined as string | undefined, - getNextPageParam: lastPage => lastPage.data?.page?.cursor ?? undefined, + getNextPageParam: lastPage => lastPage.page?.cursor ?? undefined, enabled: workspaceId !== null, }) const postings = useMemo(() => { const all = - data?.pages.flatMap( - page => page.data?.data?.map(adaptPostingDto) ?? [] - ) ?? [] + data?.pages.flatMap(page => page.data?.map(adaptPostingDto) ?? []) ?? [] return [...new Map(all.map(p => [p.id, p])).values()] }, [data]) - const totalCount = data?.pages[0]?.data?.page?.totalCount ?? 0 + const totalCount = data?.pages[0]?.page?.totalCount ?? 0 return { postings, diff --git a/src/features/manager/home/types/posting.ts b/src/features/manager/home/types/posting.ts index b627d1fa..ad9b2667 100644 --- a/src/features/manager/home/types/posting.ts +++ b/src/features/manager/home/types/posting.ts @@ -1,23 +1,24 @@ +import { PAYMENT_TYPE_LABEL as SHARED_PAYMENT_TYPE_LABEL } from '@/shared/constants/payment' +import { WORKING_DAYS, WORKING_DAY_LABEL } from '@/shared/constants/workingDays' +import { toTimeOfDay } from '@/shared/lib/toTimeOfDay' +import type { CursorPage } from '@/shared/lib/cursorPage' 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 + position?: string } export interface PostingWorkspaceDto { id: number businessName: string + businessType?: string + businessTypeDetail?: string } export interface PostingDto { @@ -26,21 +27,13 @@ export interface PostingDto { 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[] -}> +export type PostingListApiResponse = + | CursorPage + | CommonApiResponse> // ---- Query Params ---- export interface ManagedPostingsQueryParams { @@ -51,22 +44,9 @@ export interface ManagedPostingsQueryParams { } // ---- Mappers ---- -const PAYMENT_TYPE_LABEL: Record = { - HOURLY: '시급', - DAILY: '일급', - MONTHLY: '월급', - WEEKLY: '주급', -} +const PAYMENT_TYPE_LABEL: Record = SHARED_PAYMENT_TYPE_LABEL -const WORKING_DAY_KO: Record = { - MONDAY: '월', - TUESDAY: '화', - WEDNESDAY: '수', - THURSDAY: '목', - FRIDAY: '금', - SATURDAY: '토', - SUNDAY: '일', -} +const WORKING_DAY_KO: Record = WORKING_DAY_LABEL function formatWage(payAmount: number, paymentType: string): string { const label = PAYMENT_TYPE_LABEL[paymentType] ?? paymentType @@ -77,24 +57,14 @@ function formatWage(payAmount: number, paymentType: string): string { function formatWorkHours(schedules: PostingScheduleDto[]): string { if (schedules.length === 0) return '-' const first = schedules[0] - const base = `${first.startTime} ~ ${first.endTime}` + const base = `${toTimeOfDay(first.startTime)} ~ ${toTimeOfDay(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)) + return WORKING_DAYS.filter(d => daySet.has(d)) .map(d => WORKING_DAY_KO[d] ?? d) .join(', ') } diff --git a/src/features/manager/posting/api/application.ts b/src/features/manager/posting/api/application.ts new file mode 100644 index 00000000..03788598 --- /dev/null +++ b/src/features/manager/posting/api/application.ts @@ -0,0 +1,61 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import { unwrapCursorPage, type CursorPage } from '@/shared/lib/cursorPage' +import type { ApplicationApiStatus } from '@/shared/types/applicationStatus' +import type { CommonApiResponse } from '@/shared/types/common' +import type { + PostingApplicationDetailDto, + PostingApplicationListItemDto, +} from '@/features/manager/posting/types/dto' + +export interface PostingApplicationsQueryParams { + pageSize: number + postingId?: number + workspaceId?: number + status?: ApplicationApiStatus[] + cursor?: string +} + +type ApplicationListBody = + | CursorPage + | CommonApiResponse> + +export async function fetchPostingApplications( + params: PostingApplicationsQueryParams +): Promise> { + const response = await axiosInstance.get( + '/manager/postings/applications', + { + params: { + pageSize: params.pageSize, + ...(params.postingId !== undefined && { + postingId: params.postingId, + }), + ...(params.workspaceId !== undefined && { + workspaceId: params.workspaceId, + }), + ...(params.status?.length && { status: params.status }), + ...(params.cursor !== undefined && { cursor: params.cursor }), + }, + } + ) + return unwrapCursorPage(response.data) +} + +export async function fetchPostingApplicationDetail( + postingApplicationId: number +): Promise { + const response = await axiosInstance.get< + CommonApiResponse + >(`/manager/postings/applications/${postingApplicationId}`) + return response.data.data +} + +export async function patchPostingApplicationStatus( + postingApplicationId: number, + status: ApplicationApiStatus +): Promise { + await axiosInstance.patch( + `/manager/postings/applications/${postingApplicationId}/status`, + { status } + ) +} diff --git a/src/features/manager/posting/api/posting.ts b/src/features/manager/posting/api/posting.ts new file mode 100644 index 00000000..a07e9b34 --- /dev/null +++ b/src/features/manager/posting/api/posting.ts @@ -0,0 +1,69 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import { unwrapCursorPage, type CursorPage } from '@/shared/lib/cursorPage' +import type { CommonApiResponse } from '@/shared/types/common' +import type { PostingStatus } from '@/features/manager/posting/types/posting' +import type { + CreatePostingRequestDto, + ManagerPostingDetailDto, + ManagerPostingListItemDto, + UpdatePostingRequestDto, +} from '@/features/manager/posting/types/dto' + +export interface ManagerPostingsQueryParams { + pageSize: number + workspaceId?: number + status?: PostingStatus + cursor?: string +} + +type PostingListBody = + | CursorPage + | CommonApiResponse> + +export async function fetchManagerPostings( + params: ManagerPostingsQueryParams +): 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 unwrapCursorPage(response.data) +} + +export async function fetchManagerPostingDetail( + postingId: number +): Promise { + const response = await axiosInstance.get< + CommonApiResponse + >(`/manager/postings/${postingId}`) + return response.data.data +} + +export async function postManagerPosting( + body: CreatePostingRequestDto +): Promise { + await axiosInstance.post('/manager/postings', body) +} + +export async function putManagerPosting( + postingId: number, + body: UpdatePostingRequestDto +): Promise { + await axiosInstance.put(`/manager/postings/${postingId}`, body) +} + +export async function patchManagerPostingStatus( + postingId: number, + status: PostingStatus +): Promise { + await axiosInstance.patch(`/manager/postings/${postingId}/status`, { status }) +} diff --git a/src/features/manager/posting/hooks/mutation/useClosePostingMutation.ts b/src/features/manager/posting/hooks/mutation/useClosePostingMutation.ts new file mode 100644 index 00000000..ed4a5e77 --- /dev/null +++ b/src/features/manager/posting/hooks/mutation/useClosePostingMutation.ts @@ -0,0 +1,23 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { patchManagerPostingStatus } from '@/features/manager/posting/api/posting' +import { resolvePostingErrorMessage } from '@/features/manager/posting/lib/postingErrorMessage' +import { queryKeys } from '@/shared/lib/queryKeys' +import { showToast } from '@/shared/stores/useToastStore' + +export function useClosePostingMutation(postingId: number) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: () => patchManagerPostingStatus(postingId, 'CLOSED'), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.posting.all }) + }, + onError: (error: unknown) => { + showToast( + resolvePostingErrorMessage(error, '모집을 마감하지 못했어요.'), + 'error' + ) + }, + }) +} diff --git a/src/features/manager/posting/hooks/mutation/useCreatePostingMutation.ts b/src/features/manager/posting/hooks/mutation/useCreatePostingMutation.ts new file mode 100644 index 00000000..f3e3fcdf --- /dev/null +++ b/src/features/manager/posting/hooks/mutation/useCreatePostingMutation.ts @@ -0,0 +1,23 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { postManagerPosting } from '@/features/manager/posting/api/posting' +import { toCreatePostingRequest } from '@/features/manager/posting/lib/buildPostingRequest' +import type { PostingFormValues } from '@/features/manager/posting/types/posting' +import { queryKeys } from '@/shared/lib/queryKeys' + +interface CreatePostingVariables { + values: PostingFormValues + workspaceId: number +} + +export function useCreatePostingMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ values, workspaceId }: CreatePostingVariables) => + postManagerPosting(toCreatePostingRequest(values, workspaceId)), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.posting.all }) + }, + }) +} diff --git a/src/features/manager/posting/hooks/mutation/useUpdateApplicationStatusMutation.ts b/src/features/manager/posting/hooks/mutation/useUpdateApplicationStatusMutation.ts new file mode 100644 index 00000000..6db5faa7 --- /dev/null +++ b/src/features/manager/posting/hooks/mutation/useUpdateApplicationStatusMutation.ts @@ -0,0 +1,27 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { patchPostingApplicationStatus } from '@/features/manager/posting/api/application' +import type { HiringDecision } from '@/features/manager/posting/lib/applicationStatus' +import { resolvePostingErrorMessage } from '@/features/manager/posting/lib/postingErrorMessage' +import { queryKeys } from '@/shared/lib/queryKeys' +import { showToast } from '@/shared/stores/useToastStore' + +export function useUpdateApplicationStatusMutation( + postingApplicationId: number +) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (status: HiringDecision) => + patchPostingApplicationStatus(postingApplicationId, status), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.posting.all }) + }, + onError: (error: unknown) => { + showToast( + resolvePostingErrorMessage(error, '상태를 변경하지 못했어요.'), + 'error' + ) + }, + }) +} diff --git a/src/features/manager/posting/hooks/mutation/useUpdatePostingMutation.ts b/src/features/manager/posting/hooks/mutation/useUpdatePostingMutation.ts new file mode 100644 index 00000000..e8f328b8 --- /dev/null +++ b/src/features/manager/posting/hooks/mutation/useUpdatePostingMutation.ts @@ -0,0 +1,26 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { putManagerPosting } from '@/features/manager/posting/api/posting' +import { toUpdatePostingRequest } from '@/features/manager/posting/lib/buildPostingRequest' +import type { PostingFormValues } from '@/features/manager/posting/types/posting' +import { queryKeys } from '@/shared/lib/queryKeys' + +interface UpdatePostingVariables { + values: PostingFormValues + originalScheduleIds: number[] +} + +export function useUpdatePostingMutation(postingId: number) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ values, originalScheduleIds }: UpdatePostingVariables) => + putManagerPosting( + postingId, + toUpdatePostingRequest(values, originalScheduleIds) + ), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.posting.all }) + }, + }) +} diff --git a/src/features/manager/posting/hooks/query/useManagerPostingDetailQuery.ts b/src/features/manager/posting/hooks/query/useManagerPostingDetailQuery.ts new file mode 100644 index 00000000..721b1ca7 --- /dev/null +++ b/src/features/manager/posting/hooks/query/useManagerPostingDetailQuery.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query' + +import { fetchManagerPostingDetail } from '@/features/manager/posting/api/posting' +import { adaptPostingDetail } from '@/features/manager/posting/types/dto' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useManagerPostingDetailQuery(postingId: number) { + const isValidId = Number.isInteger(postingId) && postingId > 0 + + const { data, isPending, isError } = useQuery({ + queryKey: queryKeys.posting.detail(postingId), + queryFn: () => fetchManagerPostingDetail(postingId), + enabled: isValidId, + select: adaptPostingDetail, + }) + + return { + posting: data ?? null, + isLoading: isValidId && isPending, + isError: !isValidId || isError, + } +} diff --git a/src/features/manager/posting/hooks/query/useManagerPostingsQuery.ts b/src/features/manager/posting/hooks/query/useManagerPostingsQuery.ts new file mode 100644 index 00000000..407583dc --- /dev/null +++ b/src/features/manager/posting/hooks/query/useManagerPostingsQuery.ts @@ -0,0 +1,65 @@ +import { useMemo } from 'react' +import { useInfiniteQuery } from '@tanstack/react-query' + +import { fetchManagerPostings } from '@/features/manager/posting/api/posting' +import { adaptPostingListItem } from '@/features/manager/posting/types/dto' +import type { + PostingListItem, + PostingStatus, +} from '@/features/manager/posting/types/posting' +import { queryKeys } from '@/shared/lib/queryKeys' + +const PAGE_SIZE = 10 + +interface UseManagerPostingsQueryOptions { + workspaceId?: number + status?: PostingStatus +} + +export function useManagerPostingsQuery({ + workspaceId, + status, +}: UseManagerPostingsQueryOptions) { + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isError, + } = useInfiniteQuery({ + queryKey: queryKeys.posting.list({ + workspaceId, + status, + pageSize: PAGE_SIZE, + }), + queryFn: ({ pageParam }) => + fetchManagerPostings({ + pageSize: PAGE_SIZE, + workspaceId, + status, + cursor: pageParam, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.page?.cursor ?? undefined, + }) + + const postings = useMemo(() => { + const byId = new Map() + for (const dto of data?.pages.flatMap(page => page.data ?? []) ?? []) { + const posting = adaptPostingListItem(dto) + byId.set(posting.id, posting) + } + return [...byId.values()] + }, [data]) + + return { + postings, + totalCount: data?.pages[0]?.page?.totalCount ?? postings.length, + isLoading: isPending, + isError, + hasNextPage: Boolean(hasNextPage), + isFetchingNextPage, + fetchNextPage, + } +} diff --git a/src/features/manager/posting/hooks/query/usePostingApplicationDetailQuery.ts b/src/features/manager/posting/hooks/query/usePostingApplicationDetailQuery.ts new file mode 100644 index 00000000..827fa8a8 --- /dev/null +++ b/src/features/manager/posting/hooks/query/usePostingApplicationDetailQuery.ts @@ -0,0 +1,23 @@ +import { useQuery } from '@tanstack/react-query' + +import { fetchPostingApplicationDetail } from '@/features/manager/posting/api/application' +import { adaptApplicationDetail } from '@/features/manager/posting/types/dto' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function usePostingApplicationDetailQuery(postingApplicationId: number) { + const isValidId = + Number.isInteger(postingApplicationId) && postingApplicationId > 0 + + const { data, isPending, isError } = useQuery({ + queryKey: queryKeys.posting.applicationDetail(postingApplicationId), + queryFn: () => fetchPostingApplicationDetail(postingApplicationId), + enabled: isValidId, + select: adaptApplicationDetail, + }) + + return { + application: data ?? null, + isLoading: isValidId && isPending, + isError: !isValidId || isError, + } +} diff --git a/src/features/manager/posting/hooks/query/usePostingApplicationsQuery.ts b/src/features/manager/posting/hooks/query/usePostingApplicationsQuery.ts new file mode 100644 index 00000000..eecd5a83 --- /dev/null +++ b/src/features/manager/posting/hooks/query/usePostingApplicationsQuery.ts @@ -0,0 +1,72 @@ +import { useMemo } from 'react' +import { useInfiniteQuery } from '@tanstack/react-query' + +import { fetchPostingApplications } from '@/features/manager/posting/api/application' +import { adaptApplicationListItem } from '@/features/manager/posting/types/dto' +import type { ApplicationListItem } from '@/features/manager/posting/types/posting' +import type { ApplicationApiStatus } from '@/shared/types/applicationStatus' +import { queryKeys } from '@/shared/lib/queryKeys' + +const PAGE_SIZE = 10 + +interface UsePostingApplicationsQueryOptions { + enabled?: boolean + postingId?: number + workspaceId?: number + status?: ApplicationApiStatus +} + +export function usePostingApplicationsQuery({ + enabled = true, + postingId, + workspaceId, + status, +}: UsePostingApplicationsQueryOptions) { + const statusFilter = useMemo(() => (status ? [status] : undefined), [status]) + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isError, + } = useInfiniteQuery({ + enabled, + queryKey: queryKeys.posting.applicationList({ + postingId, + workspaceId, + status: statusFilter, + pageSize: PAGE_SIZE, + }), + queryFn: ({ pageParam }) => + fetchPostingApplications({ + pageSize: PAGE_SIZE, + postingId, + workspaceId, + status: statusFilter, + cursor: pageParam, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.page?.cursor ?? undefined, + }) + + const applications = useMemo(() => { + const byId = new Map() + for (const dto of data?.pages.flatMap(page => page.data ?? []) ?? []) { + const application = adaptApplicationListItem(dto) + byId.set(application.id, application) + } + return [...byId.values()] + }, [data]) + + return { + applications, + totalCount: data?.pages[0]?.page?.totalCount ?? applications.length, + isLoading: enabled && isPending, + isError, + hasNextPage: Boolean(hasNextPage), + isFetchingNextPage, + fetchNextPage, + } +} diff --git a/src/features/manager/posting/hooks/query/useWorkspaceFilterOptions.ts b/src/features/manager/posting/hooks/query/useWorkspaceFilterOptions.ts new file mode 100644 index 00000000..1c2fcb76 --- /dev/null +++ b/src/features/manager/posting/hooks/query/useWorkspaceFilterOptions.ts @@ -0,0 +1,58 @@ +import { useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' + +import { fetchManagedWorkspaces } from '@/features/manager/api/workspace' +import { queryKeys } from '@/shared/lib/queryKeys' +import type { SelectOption } from '@/shared/ui/common/SelectDropdown' + +export const ALL_WORKSPACES = 'ALL' as const +export type WorkspaceFilter = number | typeof ALL_WORKSPACES + +function useActivatedWorkspaces() { + const { data, isPending, isError } = useQuery({ + queryKey: queryKeys.managerWorkspace.list(), + queryFn: fetchManagedWorkspaces, + }) + + const workspaces = useMemo( + () => + (data?.data ?? []).filter( + workspace => workspace.status?.value === 'ACTIVATED' + ), + [data] + ) + + return { workspaces, isLoading: isPending, isError } +} + +export function useWorkspaceFilterOptions() { + const { workspaces, isLoading, isError } = useActivatedWorkspaces() + + const workspaceOptions = useMemo[]>( + () => [ + { value: ALL_WORKSPACES, label: '전체 업장' }, + ...workspaces.map(workspace => ({ + value: workspace.id as WorkspaceFilter, + label: workspace.businessName, + })), + ], + [workspaces] + ) + + return { workspaceOptions, isLoading, isError } +} + +export function useWorkspaceSelectOptions() { + const { workspaces, isLoading, isError } = useActivatedWorkspaces() + + const workspaceOptions = useMemo[]>( + () => + workspaces.map(workspace => ({ + value: workspace.id, + label: workspace.businessName, + })), + [workspaces] + ) + + return { workspaceOptions, isLoading, isError } +} diff --git a/src/features/manager/posting/hooks/useApplicationDetailViewModel.ts b/src/features/manager/posting/hooks/useApplicationDetailViewModel.ts new file mode 100644 index 00000000..5c878268 --- /dev/null +++ b/src/features/manager/posting/hooks/useApplicationDetailViewModel.ts @@ -0,0 +1,49 @@ +import { useState } from 'react' + +import { + DECISION_COPY, + isTerminalApplicationStatus, + type HiringDecision, +} from '@/features/manager/posting/lib/applicationStatus' +import { useUpdateApplicationStatusMutation } from '@/features/manager/posting/hooks/mutation/useUpdateApplicationStatusMutation' +import { usePostingApplicationDetailQuery } from '@/features/manager/posting/hooks/query/usePostingApplicationDetailQuery' +import { showToast } from '@/shared/stores/useToastStore' + +export function useApplicationDetailViewModel(applicationId: number) { + const { application, isLoading, isError } = + usePostingApplicationDetailQuery(applicationId) + const updateStatus = useUpdateApplicationStatusMutation(applicationId) + + const [pendingDecision, setPendingDecision] = useState( + null + ) + + const isTerminal = application + ? isTerminalApplicationStatus(application.status) + : false + + const confirmDecision = () => { + if (!pendingDecision) return + const decision = pendingDecision + setPendingDecision(null) + updateStatus.mutate(decision, { + onSuccess: () => showToast(DECISION_COPY[decision].toast), + }) + } + + return { + application, + isLoading, + isNotFound: !isLoading && (isError || application === null), + canDecide: application !== null && !isTerminal, + isDeciding: updateStatus.isPending, + pendingDecision, + decisionCopy: pendingDecision ? DECISION_COPY[pendingDecision] : null, + requestDecision: (decision: HiringDecision) => { + if (updateStatus.isPending) return + setPendingDecision(decision) + }, + cancelDecision: () => setPendingDecision(null), + confirmDecision, + } +} diff --git a/src/features/manager/posting/hooks/useApplicationListViewModel.ts b/src/features/manager/posting/hooks/useApplicationListViewModel.ts new file mode 100644 index 00000000..54e0c6c6 --- /dev/null +++ b/src/features/manager/posting/hooks/useApplicationListViewModel.ts @@ -0,0 +1,53 @@ +import { useState } from 'react' + +import { usePostingApplicationsQuery } from '@/features/manager/posting/hooks/query/usePostingApplicationsQuery' +import { + ALL_WORKSPACES, + useWorkspaceFilterOptions, + type WorkspaceFilter, +} from '@/features/manager/posting/hooks/query/useWorkspaceFilterOptions' +import type { ApplicationStatusFilter } from '@/features/manager/posting/lib/applicationStatus' + +export function useApplicationListViewModel( + postingId?: number, + enabled: boolean = true +) { + const [workspaceFilter, setWorkspaceFilter] = + useState(ALL_WORKSPACES) + const [statusFilter, setStatusFilter] = + useState('ALL') + + const { workspaceOptions } = useWorkspaceFilterOptions() + + const { + applications, + totalCount, + isLoading, + isError, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = usePostingApplicationsQuery({ + enabled, + postingId, + workspaceId: + workspaceFilter === ALL_WORKSPACES ? undefined : workspaceFilter, + status: statusFilter === 'ALL' ? undefined : statusFilter, + }) + + return { + applications, + totalCount, + isLoading, + isError, + isEmpty: !isLoading && !isError && applications.length === 0, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + workspaceFilter, + setWorkspaceFilter, + statusFilter, + setStatusFilter, + workspaceOptions, + } +} diff --git a/src/features/manager/posting/hooks/usePostingDetailViewModel.ts b/src/features/manager/posting/hooks/usePostingDetailViewModel.ts new file mode 100644 index 00000000..9a3d30a5 --- /dev/null +++ b/src/features/manager/posting/hooks/usePostingDetailViewModel.ts @@ -0,0 +1,31 @@ +import { useState } from 'react' + +import { useClosePostingMutation } from '@/features/manager/posting/hooks/mutation/useClosePostingMutation' +import { useManagerPostingDetailQuery } from '@/features/manager/posting/hooks/query/useManagerPostingDetailQuery' +import { showToast } from '@/shared/stores/useToastStore' + +export function usePostingDetailViewModel(postingId: number) { + const { posting, isLoading, isError } = + useManagerPostingDetailQuery(postingId) + const closePosting = useClosePostingMutation(postingId) + + const [isCloseModalOpen, setIsCloseModalOpen] = useState(false) + + const confirmClose = () => { + setIsCloseModalOpen(false) + closePosting.mutate(undefined, { + onSuccess: () => showToast('모집을 마감했어요'), + }) + } + + return { + posting, + isLoading, + isNotFound: !isLoading && (isError || posting === null), + isClosing: closePosting.isPending, + isCloseModalOpen, + openCloseModal: () => setIsCloseModalOpen(true), + closeCloseModal: () => setIsCloseModalOpen(false), + confirmClose, + } +} diff --git a/src/features/manager/posting/hooks/usePostingForm.ts b/src/features/manager/posting/hooks/usePostingForm.ts new file mode 100644 index 00000000..ce5d6196 --- /dev/null +++ b/src/features/manager/posting/hooks/usePostingForm.ts @@ -0,0 +1,248 @@ +import { useCallback, useMemo, useState } from 'react' + +import { + type PaymentType, + type Posting, + type PostingFormErrors, + type PostingFormSchedule, + type PostingFormValues, + type WorkingDay, +} from '@/features/manager/posting/types/posting' + +let scheduleKeySeq = 0 + +function createEmptySchedule(): PostingFormSchedule { + scheduleKeySeq += 1 + return { + key: `new-${scheduleKeySeq}`, + id: null, + workingDays: [], + startTime: '', + endTime: '', + position: '', + positionsNeeded: 1, + } +} + +function createInitialValues(posting?: Posting | null): PostingFormValues { + if (!posting) { + return { + workspaceId: null, + title: '', + schedules: [createEmptySchedule()], + paymentType: 'HOURLY', + payAmount: '', + description: '', + } + } + + return { + workspaceId: posting.workspaceId, + title: posting.title, + schedules: posting.schedules.map(schedule => { + scheduleKeySeq += 1 + return { + ...schedule, + workingDays: [...schedule.workingDays], + key: `existing-${schedule.id ?? scheduleKeySeq}`, + } + }), + paymentType: posting.paymentType, + payAmount: String(posting.payAmount), + description: posting.description, + } +} + +export function validatePostingForm( + values: PostingFormValues, + isEditMode: boolean +): PostingFormErrors { + const errors: PostingFormErrors = {} + + if (values.workspaceId === null) { + errors.workspaceId = '업장을 선택해 주세요' + } + if (values.title.trim() === '') { + errors.title = '공고 제목을 입력해 주세요' + } + const hasIncompleteSchedule = values.schedules.some( + schedule => + schedule.workingDays.length === 0 || + schedule.startTime === '' || + schedule.endTime === '' + ) + if (!isEditMode && values.schedules.length === 0) { + errors.schedules = '근무일정을 1개 이상 추가해 주세요' + } else if (hasIncompleteSchedule) { + errors.schedules = '근무요일과 시작·종료 시간을 모두 입력해 주세요' + } + + const payAmount = Number(values.payAmount.replace(/[^0-9]/g, '')) + if (values.payAmount.trim() === '' || payAmount <= 0) { + errors.payAmount = '급여를 입력해 주세요' + } + + // 서버가 description을 필수(minLength 1)로 받습니다 + if (values.description.trim() === '') { + errors.description = '상세내용을 입력해 주세요' + } + + return errors +} + +interface UsePostingFormOptions { + posting?: Posting | null +} + +export function usePostingForm({ posting }: UsePostingFormOptions = {}) { + const isEditMode = Boolean(posting) + const [values, setValues] = useState(() => + createInitialValues(posting) + ) + const [isSubmitAttempted, setIsSubmitAttempted] = useState(false) + const [serverErrors, setServerErrors] = useState({}) + + const clientErrors = useMemo( + () => validatePostingForm(values, isEditMode), + [isEditMode, values] + ) + const hasServerErrors = Object.keys(serverErrors).length > 0 + const isValid = Object.keys(clientErrors).length === 0 && !hasServerErrors + const errors = { + ...serverErrors, + ...(isSubmitAttempted ? clientErrors : {}), + } + const isSubmitDisabled = + hasServerErrors || + (isSubmitAttempted && Object.keys(clientErrors).length > 0) + + const clearServerError = useCallback((field: keyof PostingFormErrors) => { + setServerErrors(prev => { + if (!prev[field]) return prev + const next = { ...prev } + delete next[field] + return next + }) + }, []) + + const setWorkspaceId = useCallback( + (workspaceId: number) => { + clearServerError('workspaceId') + setValues(prev => ({ ...prev, workspaceId })) + }, + [clearServerError] + ) + + const setTitle = useCallback( + (title: string) => { + clearServerError('title') + setValues(prev => ({ ...prev, title })) + }, + [clearServerError] + ) + + const setPaymentType = useCallback( + (paymentType: PaymentType) => { + clearServerError('paymentType') + setValues(prev => ({ ...prev, paymentType })) + }, + [clearServerError] + ) + + const setPayAmount = useCallback( + (payAmount: string) => { + clearServerError('payAmount') + setValues(prev => ({ + ...prev, + payAmount: payAmount.replace(/[^0-9]/g, ''), + })) + }, + [clearServerError] + ) + + const setDescription = useCallback( + (description: string) => { + clearServerError('description') + setValues(prev => ({ ...prev, description })) + }, + [clearServerError] + ) + + const addSchedule = useCallback(() => { + clearServerError('schedules') + setValues(prev => ({ + ...prev, + schedules: [...prev.schedules, createEmptySchedule()], + })) + }, [clearServerError]) + + const removeSchedule = useCallback( + (key: string) => { + clearServerError('schedules') + setValues(prev => ({ + ...prev, + schedules: prev.schedules.filter(schedule => schedule.key !== key), + })) + }, + [clearServerError] + ) + + const updateSchedule = useCallback( + (key: string, patch: Partial>) => { + clearServerError('schedules') + setValues(prev => ({ + ...prev, + schedules: prev.schedules.map(schedule => + schedule.key === key ? { ...schedule, ...patch } : schedule + ), + })) + }, + [clearServerError] + ) + + const toggleScheduleDay = useCallback( + (key: string, day: WorkingDay) => { + clearServerError('schedules') + setValues(prev => ({ + ...prev, + schedules: prev.schedules.map(schedule => { + if (schedule.key !== key) return schedule + const workingDays = schedule.workingDays.includes(day) + ? schedule.workingDays.filter(item => item !== day) + : [...schedule.workingDays, day] + return { ...schedule, workingDays } + }), + })) + }, + [clearServerError] + ) + + const attemptSubmit = useCallback(() => { + setIsSubmitAttempted(true) + return ( + Object.keys(validatePostingForm(values, isEditMode)).length === 0 && + Object.keys(serverErrors).length === 0 + ) + }, [isEditMode, serverErrors, values]) + + return { + values, + errors, + isValid, + isSubmitDisabled, + isEditMode, + setWorkspaceId, + setTitle, + setPaymentType, + setPayAmount, + setDescription, + addSchedule, + removeSchedule, + updateSchedule, + toggleScheduleDay, + attemptSubmit, + setServerErrors, + } +} + +export type PostingFormViewModel = ReturnType diff --git a/src/features/manager/posting/hooks/usePostingListViewModel.ts b/src/features/manager/posting/hooks/usePostingListViewModel.ts new file mode 100644 index 00000000..77e37992 --- /dev/null +++ b/src/features/manager/posting/hooks/usePostingListViewModel.ts @@ -0,0 +1,47 @@ +import { useState } from 'react' + +import { useManagerPostingsQuery } from '@/features/manager/posting/hooks/query/useManagerPostingsQuery' +import { + ALL_WORKSPACES, + useWorkspaceFilterOptions, + type WorkspaceFilter, +} from '@/features/manager/posting/hooks/query/useWorkspaceFilterOptions' +import type { PostingStatusFilter } from '@/features/manager/posting/lib/postingStatus' + +export function usePostingListViewModel() { + const [workspaceFilter, setWorkspaceFilter] = + useState(ALL_WORKSPACES) + const [statusFilter, setStatusFilter] = useState('ALL') + + const { workspaceOptions } = useWorkspaceFilterOptions() + + const { + postings, + totalCount, + isLoading, + isError, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useManagerPostingsQuery({ + workspaceId: + workspaceFilter === ALL_WORKSPACES ? undefined : workspaceFilter, + status: statusFilter === 'ALL' ? undefined : statusFilter, + }) + + return { + postings, + totalCount, + isLoading, + isError, + isEmpty: !isLoading && !isError && postings.length === 0, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + workspaceFilter, + setWorkspaceFilter, + statusFilter, + setStatusFilter, + workspaceOptions, + } +} diff --git a/src/features/manager/posting/lib/applicationDetailFormat.ts b/src/features/manager/posting/lib/applicationDetailFormat.ts new file mode 100644 index 00000000..c6c5fb03 --- /dev/null +++ b/src/features/manager/posting/lib/applicationDetailFormat.ts @@ -0,0 +1,26 @@ +export function formatApplicantBirthDate(value: string): string { + const digits = value.replace(/\D/g, '') + if (digits.length < 8) return value + + return `${digits.slice(0, 4)}.${digits.slice(4, 6)}.${digits.slice(6, 8)}` +} + +export function formatCertificateAcquiredMonth(value: string): string { + const digits = value.replace(/\D/g, '') + if (digits.length < 6) return value + + return `${digits.slice(0, 4)}.${digits.slice(4, 6)}` +} + +export function formatApplicantPhoneNumber(value: string): string { + const digits = value.replace(/\D/g, '') + + if (digits.length === 11) { + return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}` + } + if (digits.length === 10) { + return `${digits.slice(0, 2)}-${digits.slice(2, 6)}-${digits.slice(6)}` + } + + return value +} diff --git a/src/features/manager/posting/lib/applicationStatus.ts b/src/features/manager/posting/lib/applicationStatus.ts new file mode 100644 index 00000000..b31d852c --- /dev/null +++ b/src/features/manager/posting/lib/applicationStatus.ts @@ -0,0 +1,109 @@ +import type { ApplicationStatus } from '@/features/manager/posting/types/posting' + +interface ApplicationStatusBadgeStyle { + label: string + containerClassName: string + textClassName: string +} + +const APPLICATION_STATUS_BADGE: Record< + ApplicationStatus, + ApplicationStatusBadgeStyle +> = { + SUBMITTED: { + label: '지원완료', + containerClassName: 'bg-white border border-line-1', + textClassName: 'text-text-70', + }, + SHORTLISTED: { + label: '서류합격', + containerClassName: 'bg-main-100 border border-main-100', + textClassName: 'text-sub', + }, + ACCEPTED: { + label: '최종합격', + containerClassName: 'bg-main border border-main', + textClassName: 'text-white', + }, + REJECTED: { + label: '불합격', + containerClassName: 'bg-white border border-error', + textClassName: 'text-error', + }, + CANCELLED: { + label: '취소', + containerClassName: 'bg-bg-dark border border-bg-dark', + textClassName: 'text-text-50', + }, + EXPIRED: { + label: '만료', + containerClassName: 'bg-bg-dark border border-bg-dark', + textClassName: 'text-text-50', + }, + DELETED: { + label: '삭제됨', + containerClassName: 'bg-bg-dark border border-bg-dark', + textClassName: 'text-text-50', + }, +} + +export function resolveApplicationStatusBadge(status: ApplicationStatus) { + return APPLICATION_STATUS_BADGE[status] +} + +const TERMINAL_STATUSES: ApplicationStatus[] = [ + 'ACCEPTED', + 'REJECTED', + 'CANCELLED', + 'EXPIRED', + 'DELETED', +] + +export function isTerminalApplicationStatus(status: ApplicationStatus) { + return TERMINAL_STATUSES.includes(status) +} + +export const HIRING_DECISIONS = [ + { status: 'SHORTLISTED', label: '서류합격' }, + { status: 'ACCEPTED', label: '최종합격' }, + { status: 'REJECTED', label: '불합격' }, +] as const + +export type HiringDecision = (typeof HIRING_DECISIONS)[number]['status'] + +interface DecisionCopy { + title: string + description?: string + toast: string +} + +export const DECISION_COPY: Record = { + SHORTLISTED: { + title: '서류합격 처리할까요?', + description: '지원자에게 서류합격 결과가 전달됩니다.', + toast: '서류합격 처리됐어요', + }, + ACCEPTED: { + title: '최종합격 처리할까요?', + description: '최종합격 시 해당 지원자가 업장 근무자로 자동 등록됩니다.', + toast: '최종합격 처리됐어요', + }, + REJECTED: { + title: '불합격 처리할까요?', + description: '불합격 처리 후에는 상태를 변경할 수 없어요.', + toast: '불합격 처리됐어요', + }, +} + +export const APPLICATION_STATUS_FILTER_OPTIONS = [ + { value: 'ALL', label: '전체 상태' }, + { value: 'SUBMITTED', label: '지원완료' }, + { value: 'SHORTLISTED', label: '서류합격' }, + { value: 'ACCEPTED', label: '최종합격' }, + { value: 'REJECTED', label: '불합격' }, + { value: 'CANCELLED', label: '취소' }, + { value: 'EXPIRED', label: '만료' }, +] as const + +export type ApplicationStatusFilter = + (typeof APPLICATION_STATUS_FILTER_OPTIONS)[number]['value'] diff --git a/src/features/manager/posting/lib/buildPostingRequest.ts b/src/features/manager/posting/lib/buildPostingRequest.ts new file mode 100644 index 00000000..94f76e5c --- /dev/null +++ b/src/features/manager/posting/lib/buildPostingRequest.ts @@ -0,0 +1,75 @@ +import type { + CreatePostingRequestDto, + CreatePostingScheduleRequestDto, + UpdatePostingRequestDto, + UpdatePostingScheduleRequestDto, +} from '@/features/manager/posting/types/dto' +import type { + PostingFormSchedule, + PostingFormValues, +} from '@/features/manager/posting/types/posting' + +export function toPayAmount(payAmount: string): number { + return Number(payAmount.replace(/[^0-9]/g, '')) || 0 +} + +function toScheduleBody( + schedule: PostingFormSchedule +): CreatePostingScheduleRequestDto { + const position = schedule.position.trim() + return { + workingDays: schedule.workingDays, + startTime: schedule.startTime, + endTime: schedule.endTime, + positionsNeeded: Math.max(1, schedule.positionsNeeded), + // 서버가 빈 문자열을 거부함 + ...(position && { position }), + } +} + +export function toCreatePostingRequest( + values: PostingFormValues, + workspaceId: number +): CreatePostingRequestDto { + return { + workspaceId, + title: values.title.trim(), + description: values.description.trim(), + payAmount: toPayAmount(values.payAmount), + paymentType: values.paymentType, + schedules: values.schedules.map(toScheduleBody), + } +} + +export function toUpdatePostingRequest( + values: PostingFormValues, + originalScheduleIds: number[] +): UpdatePostingRequestDto { + const createSchedules: CreatePostingScheduleRequestDto[] = [] + const updateSchedules: UpdatePostingScheduleRequestDto[] = [] + + for (const schedule of values.schedules) { + const body = toScheduleBody(schedule) + if (schedule.id === null) { + createSchedules.push(body) + } else { + updateSchedules.push({ ...body, id: schedule.id }) + } + } + + const remainingIds = new Set( + values.schedules + .map(schedule => schedule.id) + .filter((id): id is number => id !== null) + ) + + return { + title: values.title.trim(), + description: values.description.trim(), + payAmount: toPayAmount(values.payAmount), + paymentType: values.paymentType, + createSchedules, + updateSchedules, + deleteScheduleIds: originalScheduleIds.filter(id => !remainingIds.has(id)), + } +} diff --git a/src/features/manager/posting/lib/postingErrorMessage.ts b/src/features/manager/posting/lib/postingErrorMessage.ts new file mode 100644 index 00000000..d7fa8557 --- /dev/null +++ b/src/features/manager/posting/lib/postingErrorMessage.ts @@ -0,0 +1,73 @@ +import axios from 'axios' + +import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage' +import { parseErrorResponse } from '@/shared/lib/utils/errorUtils' +import type { ErrorResponse } from '@/shared/types/common' +import type { PostingFormErrors } from '@/features/manager/posting/types/posting' + +const POSTING_ERROR_MESSAGES: Record = { + B007: '존재하지 않는 공고예요.', + B012: '지원 정보를 찾을 수 없어요.', + B017: '이미 처리된 지원서예요.', + B018: '이미 근무 중인 사용자예요.', + B019: '수정할 근무일정을 찾을 수 없어요.', + B020: '상태를 변경할 수 없는 공고예요.', +} + +export function resolvePostingErrorMessage( + error: unknown, + fallback: string +): string { + if (axios.isAxiosError(error)) { + const code = (error.response?.data as ErrorResponse | undefined)?.code + if (code && POSTING_ERROR_MESSAGES[code]) { + return POSTING_ERROR_MESSAGES[code] + } + } + return getAxiosErrorMessage(error, fallback) +} + +const POSTING_FORM_FIELDS = new Set([ + 'workspaceId', + 'title', + 'schedules', + 'paymentType', + 'payAmount', + 'description', +]) + +export function resolvePostingFormError( + error: unknown, + fallback: string +): { fieldErrors: PostingFormErrors; message: string | null } { + if (!axios.isAxiosError(error)) { + return { + fieldErrors: {}, + message: resolvePostingErrorMessage(error, fallback), + } + } + + const { fieldErrors: rawFieldErrors } = parseErrorResponse( + error.response?.data + ) + const fieldErrors: PostingFormErrors = {} + let hasUnknownField = false + + for (const [field, message] of Object.entries(rawFieldErrors)) { + const rootField = field.split(/[.[\]]/, 1)[0] + if (POSTING_FORM_FIELDS.has(rootField as keyof PostingFormErrors)) { + fieldErrors[rootField as keyof PostingFormErrors] = message + } else { + hasUnknownField = true + } + } + + const hasKnownField = Object.keys(fieldErrors).length > 0 + return { + fieldErrors, + message: + hasKnownField && !hasUnknownField + ? null + : resolvePostingErrorMessage(error, fallback), + } +} diff --git a/src/features/manager/posting/lib/postingStatus.ts b/src/features/manager/posting/lib/postingStatus.ts new file mode 100644 index 00000000..44dd425d --- /dev/null +++ b/src/features/manager/posting/lib/postingStatus.ts @@ -0,0 +1,43 @@ +import type { PostingStatus } from '@/features/manager/posting/types/posting' + +interface PostingStatusBadgeStyle { + label: string + containerClassName: string + textClassName: string +} + +const POSTING_STATUS_BADGE: Record = { + OPEN: { + label: '모집중', + containerClassName: 'bg-main-100', + textClassName: 'text-sub', + }, + CLOSED: { + label: '모집완료', + containerClassName: 'bg-bg-dark', + textClassName: 'text-text-70', + }, + CANCELLED: { + label: '취소됨', + containerClassName: 'bg-bg-dark', + textClassName: 'text-text-50', + }, + DELETED: { + label: '삭제됨', + containerClassName: 'bg-bg-dark', + textClassName: 'text-text-50', + }, +} + +export function resolvePostingStatusBadge(status: PostingStatus) { + return POSTING_STATUS_BADGE[status] +} + +export const POSTING_STATUS_FILTER_OPTIONS = [ + { value: 'ALL', label: '전체 상태' }, + { value: 'OPEN', label: '모집중' }, + { value: 'CLOSED', label: '모집완료' }, +] as const + +export type PostingStatusFilter = + (typeof POSTING_STATUS_FILTER_OPTIONS)[number]['value'] diff --git a/src/features/manager/posting/test/api/application.test.ts b/src/features/manager/posting/test/api/application.test.ts new file mode 100644 index 00000000..2bc03484 --- /dev/null +++ b/src/features/manager/posting/test/api/application.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import axiosInstance from '@/shared/lib/axiosInstance' +import { queryKeys } from '@/shared/lib/queryKeys' + +import { fetchPostingApplications } from '../../api/application' + +vi.mock('@/shared/lib/axiosInstance', () => ({ + default: { get: vi.fn() }, +})) + +const get = vi.mocked(axiosInstance.get) +const emptyPage = { + page: { cursor: null, pageSize: 10, totalCount: 0 }, + data: [], +} + +describe('공고 지원자 목록 API', () => { + beforeEach(() => { + get.mockReset() + get.mockResolvedValue({ data: emptyPage }) + }) + + it('공고별 목록 요청과 쿼리 키에 postingId를 포함한다', async () => { + await expect( + fetchPostingApplications({ pageSize: 10, postingId: 7 }) + ).resolves.toEqual(emptyPage) + + expect(get).toHaveBeenCalledWith('/manager/postings/applications', { + params: { pageSize: 10, postingId: 7 }, + }) + expect( + queryKeys.posting.applicationList({ postingId: 7, pageSize: 10 }) + ).toEqual([ + 'posting', + 'application', + 'list', + { postingId: 7, pageSize: 10 }, + ]) + }) + + it('전체 목록 요청에는 postingId를 포함하지 않는다', async () => { + await fetchPostingApplications({ pageSize: 10 }) + + expect(get).toHaveBeenCalledWith('/manager/postings/applications', { + params: { pageSize: 10 }, + }) + }) +}) diff --git a/src/features/manager/posting/test/lib/applicationDetailFormat.test.ts b/src/features/manager/posting/test/lib/applicationDetailFormat.test.ts new file mode 100644 index 00000000..a6a9c899 --- /dev/null +++ b/src/features/manager/posting/test/lib/applicationDetailFormat.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { + formatApplicantBirthDate, + formatApplicantPhoneNumber, + formatCertificateAcquiredMonth, +} from '../../lib/applicationDetailFormat' + +describe('지원자 상세 정보 포맷', () => { + it.each([ + ['1997-03-21', '1997.03.21'], + ['19970321', '1997.03.21'], + ])('생년월일 %s를 %s로 표시한다', (value, expected) => { + expect(formatApplicantBirthDate(value)).toBe(expected) + }) + + it.each([ + ['01012345678', '010-1234-5678'], + ['010-1234-5678', '010-1234-5678'], + ['0212345678', '02-1234-5678'], + ['02-1234-5678', '02-1234-5678'], + ])('전화번호 %s를 %s로 표시한다', (value, expected) => { + expect(formatApplicantPhoneNumber(value)).toBe(expected) + }) + + it.each([ + ['2024-07-15', '2024.07'], + ['202407', '2024.07'], + ])('자격증 취득일 %s를 %s로 표시한다', (value, expected) => { + expect(formatCertificateAcquiredMonth(value)).toBe(expected) + }) + + it('지원하지 않는 값은 원본을 유지한다', () => { + expect(formatApplicantBirthDate('-')).toBe('-') + expect(formatApplicantPhoneNumber('-')).toBe('-') + expect(formatCertificateAcquiredMonth('-')).toBe('-') + }) +}) diff --git a/src/features/manager/posting/test/lib/buildPostingRequest.test.ts b/src/features/manager/posting/test/lib/buildPostingRequest.test.ts new file mode 100644 index 00000000..99ed9342 --- /dev/null +++ b/src/features/manager/posting/test/lib/buildPostingRequest.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' + +import { validatePostingForm } from '@/features/manager/posting/hooks/usePostingForm' +import type { + PostingFormSchedule, + PostingFormValues, +} from '@/features/manager/posting/types/posting' + +import { + toCreatePostingRequest, + toUpdatePostingRequest, +} from '../../lib/buildPostingRequest' + +function values(): PostingFormValues { + return { + workspaceId: 10, + title: '주말 홀서빙 모집', + schedules: [ + { + key: 'existing-1', + id: 1, + workingDays: ['SATURDAY', 'SUNDAY'], + startTime: '17:00', + endTime: '22:00', + position: '홀서빙', + positionsNeeded: 2, + }, + ], + paymentType: 'HOURLY', + payAmount: '12000', + description: '상세내용', + } +} + +describe('공고 폼 검증과 요청 생성', () => { + it('등록에서는 근무일정이 최소 1개 필요하다', () => { + expect( + validatePostingForm({ ...values(), schedules: [] }, false).schedules + ).toBe('근무일정을 1개 이상 추가해 주세요') + }) + + it('수정에서는 근무일정 0개를 허용한다', () => { + expect(validatePostingForm({ ...values(), schedules: [] }, true)).toEqual( + {} + ) + }) + + it('모든 기존 일정을 삭제 목록에 담는다', () => { + expect( + toUpdatePostingRequest({ ...values(), schedules: [] }, [1, 2]) + ).toMatchObject({ + createSchedules: [], + updateSchedules: [], + deleteScheduleIds: [1, 2], + }) + }) + + it('기존 일정 삭제와 신규 일정 추가를 함께 직렬화한다', () => { + const newSchedule: PostingFormSchedule = { + ...values().schedules[0], + key: 'new-1', + id: null, + workingDays: ['MONDAY'], + startTime: '09:00', + endTime: '18:00', + } + const request = toUpdatePostingRequest( + { ...values(), schedules: [newSchedule] }, + [1] + ) + + expect(request.deleteScheduleIds).toEqual([1]) + expect(request.updateSchedules).toEqual([]) + expect(request.createSchedules[0]).toMatchObject({ + workingDays: ['MONDAY'], + startTime: '09:00', + endTime: '18:00', + }) + }) + + it('등록 요청은 대문자 요일, HH:mm, 양수 급여를 유지한다', () => { + expect(toCreatePostingRequest(values(), 10)).toMatchObject({ + workspaceId: 10, + payAmount: 12000, + schedules: [ + { + workingDays: ['SATURDAY', 'SUNDAY'], + startTime: '17:00', + endTime: '22:00', + }, + ], + }) + }) +}) diff --git a/src/features/manager/posting/test/lib/postingErrorMessage.test.ts b/src/features/manager/posting/test/lib/postingErrorMessage.test.ts new file mode 100644 index 00000000..200c3de0 --- /dev/null +++ b/src/features/manager/posting/test/lib/postingErrorMessage.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' + +import { resolvePostingFormError } from '../../lib/postingErrorMessage' + +function axiosError(data: unknown) { + return { + isAxiosError: true, + message: 'Request failed with status code 400', + response: { status: 400, data }, + } +} + +describe('공고 폼 서버 오류', () => { + it('필드 오류 배열을 폼 오류로 매핑한다', () => { + expect( + resolvePostingFormError( + axiosError({ + code: 'B001', + message: '잘못된 요청입니다.', + data: [ + { field: 'workspaceId', message: '널이어서는 안됩니다' }, + { field: 'schedules[0].workingDays', message: '필수입니다' }, + ], + }), + '공고를 등록하지 못했어요.' + ) + ).toEqual({ + fieldErrors: { + workspaceId: '널이어서는 안됩니다', + schedules: '필수입니다', + }, + message: null, + }) + }) + + it('알 수 없는 필드가 있으면 일반 메시지도 반환한다', () => { + expect( + resolvePostingFormError( + axiosError({ + code: 'B001', + message: '잘못된 요청입니다.', + data: [{ field: 'unknown', message: '잘못된 값입니다' }], + }), + '공고를 등록하지 못했어요.' + ) + ).toEqual({ + fieldErrors: {}, + message: '잘못된 요청입니다.', + }) + }) +}) diff --git a/src/features/manager/posting/test/types/dto.test.ts b/src/features/manager/posting/test/types/dto.test.ts new file mode 100644 index 00000000..53d3e8db --- /dev/null +++ b/src/features/manager/posting/test/types/dto.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' + +import { + adaptPostingDetail, + adaptPostingListItem, + type ManagerPostingDetailDto, + type ManagerPostingListItemDto, +} from '../../types/dto' +import type { PostingStatus } from '../../types/posting' + +function listItem(status: PostingStatus): ManagerPostingListItemDto { + return { + id: 1, + title: '주말 홀서빙 모집', + payAmount: 12000, + paymentType: 'HOURLY', + status: { value: status, description: status }, + createdAt: '2026-08-04T00:00:00', + schedules: [], + workspace: { + id: 10, + businessName: '알터 강남점', + businessType: '카페', + }, + } +} + +function detail(applicantCount: number): ManagerPostingDetailDto { + return { + id: 1, + workspace: { id: 10, name: '알터 강남점', businessType: '카페' }, + title: '주말 홀서빙 모집', + description: '상세내용', + payAmount: 12000, + paymentType: 'HOURLY', + status: { value: 'OPEN', description: '모집중' }, + applicantCount, + createdAt: '2026-08-04T00:00:00', + updatedAt: '2026-08-04T00:00:00', + schedules: [], + } +} + +describe('매니저 공고 DTO 어댑터', () => { + it.each(['OPEN', 'CLOSED'] as const)( + '목록의 %s 상태를 화면 모델로 전달한다', + status => { + expect(adaptPostingListItem(listItem(status))).toMatchObject({ + status, + schedules: [], + }) + } + ) + + it('상세의 지원자 수와 빈 근무일정을 전달한다', () => { + expect(adaptPostingDetail(detail(6))).toMatchObject({ + applicantCount: 6, + schedules: [], + }) + }) +}) diff --git a/src/features/manager/posting/test/ui/PostingDescriptionTextarea.stories.tsx b/src/features/manager/posting/test/ui/PostingDescriptionTextarea.stories.tsx new file mode 100644 index 00000000..e7679e21 --- /dev/null +++ b/src/features/manager/posting/test/ui/PostingDescriptionTextarea.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { useState } from 'react' +import { expect, userEvent, within } from 'storybook/test' + +import { PostingDescriptionTextarea } from '@/features/manager/posting/ui/PostingDescriptionTextarea' + +function PostingDescriptionTextareaStory() { + const [value, setValue] = useState('주말 근무 가능자를 우대합니다.') + + return ( + + ) +} + +const meta = { + title: 'features/manager/posting/PostingDescriptionTextarea', + component: PostingDescriptionTextareaStory, + parameters: { layout: 'centered' }, + decorators: [ + Story => ( +
+ +
+ ), + ], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const AutoResize: Story = { + play: async ({ canvasElement }) => { + const textarea = within(canvasElement).getByRole('textbox') + const initialHeight = textarea.clientHeight + + await userEvent.type( + textarea, + '\n첫 번째 추가 조건\n두 번째 추가 조건\n세 번째 추가 조건\n네 번째 추가 조건\n다섯 번째 추가 조건\n여섯 번째 추가 조건' + ) + + await expect(textarea.clientHeight).toBeGreaterThan(initialHeight) + await expect(textarea.scrollHeight).toBeLessThanOrEqual( + textarea.clientHeight + ) + await expect(window.getComputedStyle(textarea).overflowY).toBe('hidden') + + await userEvent.clear(textarea) + await expect(textarea.clientHeight).toBe(initialHeight) + }, +} diff --git a/src/features/manager/posting/test/ui/PostingDetailActionBar.stories.tsx b/src/features/manager/posting/test/ui/PostingDetailActionBar.stories.tsx new file mode 100644 index 00000000..2f5cc4c2 --- /dev/null +++ b/src/features/manager/posting/test/ui/PostingDetailActionBar.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, within } from 'storybook/test' + +import { PostingDetailActionBar } from '@/features/manager/posting/ui/PostingDetailActionBar' + +const meta = { + title: 'features/manager/posting/PostingDetailActionBar', + component: PostingDetailActionBar, + parameters: { layout: 'fullscreen' }, + args: { + status: 'OPEN', + isClosing: false, + onEdit: () => {}, + onClosePosting: () => {}, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Open: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect(canvas.getByRole('button', { name: '수정' })).toBeVisible() + await expect( + canvas.getByRole('button', { name: '모집 마감' }) + ).toBeEnabled() + }, +} + +export const Closed: Story = { + args: { status: 'CLOSED' }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect( + canvas.queryByRole('button', { name: '수정' }) + ).not.toBeInTheDocument() + await expect(canvas.getAllByRole('button')).toHaveLength(1) + await expect(canvas.getByRole('button', { name: '마감됨' })).toBeDisabled() + }, +} + +export const Cancelled: Story = { + args: { status: 'CANCELLED' }, + play: async ({ canvasElement }) => { + await expect( + within(canvasElement).getByRole('button', { name: '수정' }) + ).toBeVisible() + }, +} diff --git a/src/features/manager/posting/test/ui/PostingDetailHeader.stories.tsx b/src/features/manager/posting/test/ui/PostingDetailHeader.stories.tsx new file mode 100644 index 00000000..3c0a59ba --- /dev/null +++ b/src/features/manager/posting/test/ui/PostingDetailHeader.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, within } from 'storybook/test' + +import { PostingDetailHeader } from '@/features/manager/posting/ui/PostingDetailHeader' + +const meta = { + title: 'features/manager/posting/PostingDetailHeader', + component: PostingDetailHeader, + parameters: { layout: 'centered' }, + decorators: [ + Story => ( +
+ +
+ ), + ], + args: { + title: '주말 홀서빙 · 오후 마감조 구합니다', + workspaceName: '알터 강남점', + businessType: '카페', + status: 'OPEN', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Open: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const title = canvas.getByRole('heading', { + name: '주말 홀서빙 · 오후 마감조 구합니다', + }) + const badge = canvas.getByText('모집중') + await expect(title).toBeVisible() + await expect(badge).toBeVisible() + + const titleStyle = window.getComputedStyle(title) + await expect(titleStyle.fontSize).toBe('20px') + + const badgeStyle = window.getComputedStyle(badge) + await expect(badgeStyle.fontSize).toBe('13px') + await expect(badgeStyle.paddingLeft).toBe('10px') + await expect(badgeStyle.paddingRight).toBe('10px') + await expect(badgeStyle.paddingTop).toBe('6px') + await expect(badgeStyle.paddingBottom).toBe('6px') + + const section = canvasElement.querySelector('section') + await expect(section).not.toBeNull() + + const style = window.getComputedStyle(section!) + await expect(style.paddingLeft).toBe('0px') + await expect(style.paddingRight).toBe('0px') + await expect(style.backgroundColor).toBe('rgba(0, 0, 0, 0)') + await expect(style.boxShadow).toBe('none') + }, +} diff --git a/src/features/manager/posting/test/ui/PostingListStickyHeader.stories.tsx b/src/features/manager/posting/test/ui/PostingListStickyHeader.stories.tsx new file mode 100644 index 00000000..1d4a580c --- /dev/null +++ b/src/features/manager/posting/test/ui/PostingListStickyHeader.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { MemoryRouter } from 'react-router-dom' +import { expect, within } from 'storybook/test' + +import { PostingListStickyHeader } from '@/features/manager/posting/ui/PostingListStickyHeader' + +const meta = { + title: 'features/manager/posting/PostingListStickyHeader', + component: PostingListStickyHeader, + parameters: { layout: 'fullscreen' }, + decorators: [ + Story => ( + +
+ +
+
+ ), + ], + args: { + rightAction: , + workspaceOptions: [ + { value: 'ALL', label: '전체 업장' }, + { value: 1, label: '알터 강남점' }, + ], + workspaceValue: 'ALL', + onWorkspaceChange: () => {}, + statusOptions: [ + { value: 'ALL', label: '전체 상태' }, + { value: 'OPEN', label: '모집중' }, + { value: 'CLOSED', label: '모집완료' }, + ], + statusValue: 'ALL', + onStatusChange: () => {}, + totalCount: 12, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect(canvas.getByText('내 공고')).toBeVisible() + await expect( + canvas.getByRole('button', { name: '업장 필터' }) + ).toBeVisible() + await expect( + canvas.getByRole('button', { name: '상태 필터' }) + ).toBeVisible() + + const header = canvas.getByText('내 공고').closest('header')?.parentElement + await expect(header).not.toBeNull() + await expect(window.getComputedStyle(header!).position).toBe('sticky') + await expect(window.getComputedStyle(header!).top).toBe('0px') + + window.scrollTo(0, 500) + await new Promise(resolve => { + window.requestAnimationFrame(() => resolve()) + }) + await expect(Math.round(header!.getBoundingClientRect().top)).toBe(0) + window.scrollTo(0, 0) + }, +} diff --git a/src/features/manager/posting/types/dto.ts b/src/features/manager/posting/types/dto.ts new file mode 100644 index 00000000..f4aff297 --- /dev/null +++ b/src/features/manager/posting/types/dto.ts @@ -0,0 +1,267 @@ +import type { PaymentType } from '@/shared/constants/payment' +import type { WorkingDay } from '@/shared/constants/workingDays' +import { toTimeOfDay } from '@/shared/lib/toTimeOfDay' +import type { ApplicationApiStatus } from '@/shared/types/applicationStatus' +import type { + Applicant, + Application, + ApplicationListItem, + Certificate, + Posting, + PostingListItem, + PostingSchedule, + PostingStatus, +} from '@/features/manager/posting/types/posting' + +export interface DescribedEnumDto { + value: T + description: string +} + +export interface PostingScheduleDto { + id: number + workingDays: WorkingDay[] + startTime: string + endTime: string + positionsNeeded: number + positionsAvailable: number + position?: string +} + +export interface ManagerPostingListWorkspaceDto { + id: number + businessName?: string + businessType?: string + businessTypeDetail?: string +} + +export interface ManagerPostingListItemDto { + id: number + title: string + payAmount: number + paymentType: PaymentType + status: DescribedEnumDto + createdAt: string + schedules: PostingScheduleDto[] + workspace: ManagerPostingListWorkspaceDto +} + +export interface ManagerPostingDetailWorkspaceDto { + id: number + name?: string + businessType?: string + businessTypeDetail?: string + fullAddress?: string +} + +export interface ManagerPostingDetailDto { + id: number + workspace: ManagerPostingDetailWorkspaceDto + title: string + description?: string + payAmount: number + paymentType: PaymentType + status: DescribedEnumDto + applicantCount: number + createdAt: string + updatedAt: string + schedules: PostingScheduleDto[] +} + +export interface PostingApplicationWorkspaceDto { + id: number + name?: string +} + +export interface PostingApplicationScheduleDto { + id: number + workingDays: WorkingDay[] + startTime: string + endTime: string + position?: string +} + +export interface PostingApplicationListItemDto { + id: number + workspace: PostingApplicationWorkspaceDto + schedule: PostingApplicationScheduleDto + status: DescribedEnumDto + applicant: { name?: string } + createdAt: string +} + +export interface PostingApplicantCertificateDto { + id: number + certificateName?: string + publisherName?: string + issuedAt?: string + expiresAt?: string +} + +export interface PostingApplicantDetailDto { + id: number + name?: string + email?: string + contact?: string + birthday?: string + gender?: 'GENDER_MALE' | 'GENDER_FEMALE' + userCertificates?: PostingApplicantCertificateDto[] +} + +export interface PostingApplicationDetailDto { + id: number + workspace: PostingApplicationWorkspaceDto + schedule: PostingApplicationScheduleDto + description?: string + status: DescribedEnumDto + applicant: PostingApplicantDetailDto + createdAt: string +} + +export interface CreatePostingScheduleRequestDto { + workingDays: WorkingDay[] + startTime: string + endTime: string + positionsNeeded: number + position?: string +} + +export interface CreatePostingRequestDto { + workspaceId: number + title: string + description: string + payAmount: number + paymentType: PaymentType + schedules: CreatePostingScheduleRequestDto[] +} + +export interface UpdatePostingScheduleRequestDto extends CreatePostingScheduleRequestDto { + id: number +} + +export interface UpdatePostingRequestDto { + title: string + description: string + payAmount: number + paymentType: PaymentType + createSchedules: CreatePostingScheduleRequestDto[] + updateSchedules: UpdatePostingScheduleRequestDto[] + deleteScheduleIds: number[] +} + +function resolveBusinessType(workspace: { + businessType?: string + businessTypeDetail?: string +}): string { + return workspace.businessTypeDetail?.trim() || workspace.businessType || '' +} + +function toPostingSchedule(dto: PostingScheduleDto): PostingSchedule { + return { + id: dto.id ?? null, + workingDays: dto.workingDays ?? [], + startTime: toTimeOfDay(dto.startTime), + endTime: toTimeOfDay(dto.endTime), + position: dto.position ?? '', + positionsNeeded: dto.positionsNeeded ?? 1, + } +} + +export function adaptPostingListItem( + dto: ManagerPostingListItemDto +): PostingListItem { + return { + id: dto.id, + workspaceId: dto.workspace?.id ?? 0, + workspaceName: dto.workspace?.businessName ?? '', + businessType: resolveBusinessType(dto.workspace ?? {}), + title: dto.title, + paymentType: dto.paymentType, + payAmount: dto.payAmount, + status: dto.status.value, + schedules: (dto.schedules ?? []).map(toPostingSchedule), + createdAt: dto.createdAt, + } +} + +export function adaptPostingDetail(dto: ManagerPostingDetailDto): Posting { + return { + id: dto.id, + workspaceId: dto.workspace?.id ?? 0, + workspaceName: dto.workspace?.name ?? '', + businessType: resolveBusinessType(dto.workspace ?? {}), + title: dto.title, + description: dto.description ?? '', + paymentType: dto.paymentType, + payAmount: dto.payAmount, + status: dto.status?.value ?? 'OPEN', + applicantCount: dto.applicantCount, + schedules: (dto.schedules ?? []).map(toPostingSchedule), + createdAt: dto.createdAt, + } +} + +function toApplicationSchedule(dto: PostingApplicationScheduleDto) { + return { + workingDays: dto?.workingDays ?? [], + startTime: toTimeOfDay(dto?.startTime), + endTime: toTimeOfDay(dto?.endTime), + position: dto?.position ?? '', + } +} + +export function adaptApplicationListItem( + dto: PostingApplicationListItemDto +): ApplicationListItem { + return { + id: dto.id, + workspaceId: dto.workspace?.id ?? 0, + workspaceName: dto.workspace?.name ?? '', + status: dto.status?.value ?? 'SUBMITTED', + appliedAt: dto.createdAt, + applicantName: dto.applicant?.name ?? '', + schedule: toApplicationSchedule(dto.schedule), + } +} + +function toCertificate(dto: PostingApplicantCertificateDto): Certificate { + return { + name: dto.certificateName ?? '', + issuer: dto.publisherName ?? '', + acquiredAt: dto.issuedAt ?? '', + } +} + +function toGender( + gender: PostingApplicantDetailDto['gender'] +): Applicant['gender'] { + if (gender === 'GENDER_MALE') return '남성' + if (gender === 'GENDER_FEMALE') return '여성' + return '-' +} + +function adaptApplicant(dto: PostingApplicantDetailDto): Applicant { + return { + name: dto?.name ?? '', + phoneNumber: dto?.contact ?? '-', + birthDate: dto?.birthday ?? '-', + gender: toGender(dto?.gender), + email: dto?.email ?? '-', + certificates: (dto?.userCertificates ?? []).map(toCertificate), + } +} + +export function adaptApplicationDetail( + dto: PostingApplicationDetailDto +): Application { + return { + id: dto.id, + workspaceId: dto.workspace?.id ?? 0, + workspaceName: dto.workspace?.name ?? '', + status: dto.status?.value ?? 'SUBMITTED', + appliedAt: dto.createdAt, + applicant: adaptApplicant(dto.applicant), + schedule: toApplicationSchedule(dto.schedule), + description: dto.description ?? '', + } +} diff --git a/src/features/manager/posting/types/posting.ts b/src/features/manager/posting/types/posting.ts new file mode 100644 index 00000000..2444e1dc --- /dev/null +++ b/src/features/manager/posting/types/posting.ts @@ -0,0 +1,126 @@ +import type { ApplicationApiStatus } from '@/shared/types/applicationStatus' +import type { PaymentType } from '@/shared/constants/payment' +import type { WorkingDay } from '@/shared/constants/workingDays' +import { WORKING_DAYS, WORKING_DAY_LABEL } from '@/shared/constants/workingDays' + +export type { PaymentType, WorkingDay } +export { PAYMENT_TYPES, PAYMENT_TYPE_LABEL } from '@/shared/constants/payment' +export { WORKING_DAYS, WORKING_DAY_LABEL } + +export type PostingStatus = 'OPEN' | 'CLOSED' | 'CANCELLED' | 'DELETED' + +export type ApplicationStatus = ApplicationApiStatus + +export interface PostingSchedule { + // 신규 추가분은 null + id: number | null + workingDays: WorkingDay[] + startTime: string + endTime: string + position: string + positionsNeeded: number +} + +export interface PostingListItem { + id: number + workspaceId: number + workspaceName: string + businessType: string + title: string + paymentType: PaymentType + payAmount: number + status: PostingStatus + schedules: PostingSchedule[] + createdAt: string +} + +export interface Posting { + id: number + workspaceId: number + workspaceName: string + businessType: string + title: string + description: string + paymentType: PaymentType + payAmount: number + status: PostingStatus + applicantCount: number + schedules: PostingSchedule[] + createdAt: string +} + +export interface Certificate { + name: string + issuer: string + acquiredAt: string +} + +export interface Applicant { + name: string + phoneNumber: string + birthDate: string + gender: '남성' | '여성' | '-' + email: string + certificates: Certificate[] +} + +export type ApplicationSchedule = Pick< + PostingSchedule, + 'workingDays' | 'startTime' | 'endTime' | 'position' +> + +export interface ApplicationListItem { + id: number + workspaceId: number + workspaceName: string + status: ApplicationStatus + appliedAt: string + applicantName: string + schedule: ApplicationSchedule +} + +export interface Application { + id: number + workspaceId: number + workspaceName: string + status: ApplicationStatus + appliedAt: string + applicant: Applicant + schedule: ApplicationSchedule + description: string +} + +export interface PostingFormSchedule extends PostingSchedule { + key: string +} + +export interface PostingFormValues { + workspaceId: number | null + title: string + schedules: PostingFormSchedule[] + paymentType: PaymentType + payAmount: string + description: string +} + +export interface PostingFormErrors { + workspaceId?: string + title?: string + schedules?: string + paymentType?: string + payAmount?: string + description?: string +} + +export function formatWorkingDays(days: WorkingDay[]): string { + if (days.length === 0) return '-' + return WORKING_DAYS.filter(day => days.includes(day)) + .map(day => WORKING_DAY_LABEL[day]) + .join('·') +} + +export function formatTimeRange(startTime: string, endTime: string): string { + return `${startTime}~${endTime}` +} + +export { formatRelativeTime } from '@/shared/lib/formatRelativeTime' diff --git a/src/features/manager/posting/ui/ApplicantCard.tsx b/src/features/manager/posting/ui/ApplicantCard.tsx new file mode 100644 index 00000000..6a1c6dc1 --- /dev/null +++ b/src/features/manager/posting/ui/ApplicantCard.tsx @@ -0,0 +1,64 @@ +import CalendarIcon from '@/assets/icons/posting/Calendar.svg?react' +import ClockIcon from '@/assets/icons/posting/Clock.svg?react' +import { ManagerApplicationStatusBadge } from '@/features/manager/posting/ui/ManagerApplicationStatusBadge' +import { + formatRelativeTime, + formatTimeRange, + formatWorkingDays, + type ApplicationListItem, +} from '@/features/manager/posting/types/posting' + +interface ApplicantCardProps { + application: ApplicationListItem + onClick: () => void +} + +export function ApplicantCard({ application, onClick }: ApplicantCardProps) { + const { applicantName, schedule } = application + + return ( + + ) +} diff --git a/src/features/manager/posting/ui/FilterBar.tsx b/src/features/manager/posting/ui/FilterBar.tsx new file mode 100644 index 00000000..f24abaac --- /dev/null +++ b/src/features/manager/posting/ui/FilterBar.tsx @@ -0,0 +1,55 @@ +import type { WorkspaceFilter } from '@/features/manager/posting/hooks/query/useWorkspaceFilterOptions' +import { SelectDropdown } from '@/shared/ui/common/SelectDropdown' +import type { SelectOption } from '@/shared/ui/common/SelectDropdown' + +interface FilterBarProps { + workspaceOptions: SelectOption[] + workspaceValue: WorkspaceFilter + onWorkspaceChange: (value: WorkspaceFilter) => void + statusOptions: readonly SelectOption[] + statusValue: TStatus + onStatusChange: (value: TStatus) => void + totalCount: number + showWorkspaceFilter?: boolean +} + +export function FilterBar({ + workspaceOptions, + workspaceValue, + onWorkspaceChange, + statusOptions, + statusValue, + onStatusChange, + totalCount, + showWorkspaceFilter = true, +}: FilterBarProps) { + return ( +
+
+ {showWorkspaceFilter ? ( + + ) : null} + +
+

+ 총{' '} + + {totalCount} + + 건 +

+
+ ) +} diff --git a/src/features/manager/posting/ui/HiringActionBar.tsx b/src/features/manager/posting/ui/HiringActionBar.tsx new file mode 100644 index 00000000..ee057282 --- /dev/null +++ b/src/features/manager/posting/ui/HiringActionBar.tsx @@ -0,0 +1,49 @@ +import { + HIRING_DECISIONS, + type HiringDecision, +} from '@/features/manager/posting/lib/applicationStatus' +import LockIcon from '@/assets/icons/posting/Lock.svg?react' +import { cn } from '@/shared/lib/utils' + +interface HiringActionBarProps { + canDecide: boolean + onDecide: (decision: HiringDecision) => void +} + +const DECISION_CLASS: Record = { + SHORTLISTED: 'border border-main bg-white text-main', + ACCEPTED: 'border border-main bg-main text-white', + REJECTED: 'border border-error bg-white text-error', +} + +export function HiringActionBar({ canDecide, onDecide }: HiringActionBarProps) { + return ( +
+ {canDecide ? ( +
+ {HIRING_DECISIONS.map(decision => ( + + ))} +
+ ) : ( +
+ + + 상태 변경 불가 (종료된 지원서) + +
+ )} +
+ ) +} diff --git a/src/features/manager/posting/ui/ManagerApplicationStatusBadge.tsx b/src/features/manager/posting/ui/ManagerApplicationStatusBadge.tsx new file mode 100644 index 00000000..5158c2d5 --- /dev/null +++ b/src/features/manager/posting/ui/ManagerApplicationStatusBadge.tsx @@ -0,0 +1,28 @@ +import { resolveApplicationStatusBadge } from '@/features/manager/posting/lib/applicationStatus' +import type { ApplicationStatus } from '@/features/manager/posting/types/posting' +import { cn } from '@/shared/lib/utils' + +interface ManagerApplicationStatusBadgeProps { + status: ApplicationStatus + className?: string +} + +export function ManagerApplicationStatusBadge({ + status, + className, +}: ManagerApplicationStatusBadgeProps) { + const style = resolveApplicationStatusBadge(status) + + return ( + + {style.label} + + ) +} diff --git a/src/features/manager/posting/ui/ManagerPostingStatusBadge.tsx b/src/features/manager/posting/ui/ManagerPostingStatusBadge.tsx new file mode 100644 index 00000000..dd1c88a9 --- /dev/null +++ b/src/features/manager/posting/ui/ManagerPostingStatusBadge.tsx @@ -0,0 +1,33 @@ +import { resolvePostingStatusBadge } from '@/features/manager/posting/lib/postingStatus' +import type { PostingStatus } from '@/features/manager/posting/types/posting' +import { cn } from '@/shared/lib/utils' + +interface ManagerPostingStatusBadgeProps { + status: PostingStatus + className?: string + size?: 'sm' | 'md' +} + +export function ManagerPostingStatusBadge({ + status, + className, + size = 'sm', +}: ManagerPostingStatusBadgeProps) { + const style = resolvePostingStatusBadge(status) + + return ( + + {style.label} + + ) +} diff --git a/src/features/manager/posting/ui/PostingDescriptionTextarea.tsx b/src/features/manager/posting/ui/PostingDescriptionTextarea.tsx new file mode 100644 index 00000000..a41771fa --- /dev/null +++ b/src/features/manager/posting/ui/PostingDescriptionTextarea.tsx @@ -0,0 +1,40 @@ +import { useLayoutEffect, useRef } from 'react' + +import { cn } from '@/shared/lib/utils' + +interface PostingDescriptionTextareaProps { + value: string + hasError: boolean + onChange: (value: string) => void +} + +export function PostingDescriptionTextarea({ + value, + hasError, + onChange, +}: PostingDescriptionTextareaProps) { + const textareaRef = useRef(null) + + useLayoutEffect(() => { + const textarea = textareaRef.current + if (!textarea) return + + const borderHeight = textarea.offsetHeight - textarea.clientHeight + textarea.style.height = 'auto' + textarea.style.height = `${textarea.scrollHeight + borderHeight}px` + }, [value]) + + return ( +