diff --git a/src/entities/workspace/api/getMyWorkspaces.ts b/src/entities/workspace/api/getMyWorkspaces.ts new file mode 100644 index 00000000..5ffbf1da --- /dev/null +++ b/src/entities/workspace/api/getMyWorkspaces.ts @@ -0,0 +1,20 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { + WorkspaceListApiResponse, + WorkspaceListQueryParams, +} from '../model/workspace' + +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 +} diff --git a/src/entities/workspace/index.ts b/src/entities/workspace/index.ts new file mode 100644 index 00000000..c10e5f68 --- /dev/null +++ b/src/entities/workspace/index.ts @@ -0,0 +1,8 @@ +export { getMyWorkspaces } from './api/getMyWorkspaces' +export type { + WorkspaceItemDto, + WorkspaceListApiResponse, + WorkspaceListDto, + WorkspaceListQueryParams, + WorkspacePageDto, +} from './model/workspace' diff --git a/src/entities/workspace/model/workspace.ts b/src/entities/workspace/model/workspace.ts new file mode 100644 index 00000000..93cd0698 --- /dev/null +++ b/src/entities/workspace/model/workspace.ts @@ -0,0 +1,26 @@ +import type { CommonApiResponse } from '@/shared/types/common' + +export interface WorkspaceItemDto { + workspaceId: number + businessName: string + employedAt: string + nextShiftDateTime?: string | null +} + +export interface WorkspacePageDto { + cursor: string + pageSize: number + totalCount: number +} + +export interface WorkspaceListDto { + page: WorkspacePageDto + data: WorkspaceItemDto[] +} + +export type WorkspaceListApiResponse = CommonApiResponse + +export interface WorkspaceListQueryParams { + cursor?: string + pageSize: number +} diff --git a/src/features/job-lookup-map/common/PostingWorkspaceEligibilityNotice.tsx b/src/features/job-lookup-map/common/PostingWorkspaceEligibilityNotice.tsx new file mode 100644 index 00000000..3a4c805c --- /dev/null +++ b/src/features/job-lookup-map/common/PostingWorkspaceEligibilityNotice.tsx @@ -0,0 +1,44 @@ +import type { PostingWorkspaceEligibilityStatus } from '@/features/job-lookup-map/hooks/usePostingWorkspaceEligibility' + +interface PostingWorkspaceEligibilityNoticeProps { + status: PostingWorkspaceEligibilityStatus + onRetry: () => void +} + +export function PostingWorkspaceEligibilityNotice({ + status, + onRetry, +}: PostingWorkspaceEligibilityNoticeProps) { + if (status === 'eligible') return null + + if (status === 'error') { + return ( +
+

+ 근무 중인 업장인지 확인하지 못했습니다. +

+ +
+ ) + } + + return ( +

+ {status === 'employed' + ? '이미 근무 중인 업장입니다.' + : '근무 중인 업장인지 확인하는 중…'} +

+ ) +} diff --git a/src/features/job-lookup-map/hooks/usePostingWorkspaceEligibility.ts b/src/features/job-lookup-map/hooks/usePostingWorkspaceEligibility.ts new file mode 100644 index 00000000..4fb1263b --- /dev/null +++ b/src/features/job-lookup-map/hooks/usePostingWorkspaceEligibility.ts @@ -0,0 +1,31 @@ +import { useQuery } from '@tanstack/react-query' +import { isEmployedAtWorkspace } from '@/features/job-lookup-map/lib/postingWorkspaceEligibility' + +export type PostingWorkspaceEligibilityStatus = + | 'checking' + | 'employed' + | 'eligible' + | 'error' + +export function usePostingWorkspaceEligibility( + workspaceId: number | undefined +) { + const { data, isPending, isFetching, isError, refetch } = useQuery({ + queryKey: ['postingWorkspaceEligibility', workspaceId], + queryFn: () => isEmployedAtWorkspace(workspaceId!), + enabled: workspaceId != null && workspaceId > 0, + refetchOnMount: 'always', + retry: false, + }) + + const status: PostingWorkspaceEligibilityStatus = + isPending || isFetching + ? 'checking' + : isError + ? 'error' + : data + ? 'employed' + : 'eligible' + + return { status, retry: refetch } +} diff --git a/src/features/job-lookup-map/index.ts b/src/features/job-lookup-map/index.ts new file mode 100644 index 00000000..91989ba0 --- /dev/null +++ b/src/features/job-lookup-map/index.ts @@ -0,0 +1,12 @@ +export { PostingWorkspaceEligibilityNotice } from './common/PostingWorkspaceEligibilityNotice' +export { useApplyPosting } from './hooks/useApplyPosting' +export { usePostingDetail } from './hooks/usePostingDetail' +export { usePostingWorkspaceEligibility } from './hooks/usePostingWorkspaceEligibility' +export { useToggleFavoritePosting } from './hooks/useToggleFavoritePosting' +export { resolveApplyPostingError } from './lib/applyPostingError' +export { isPostingIntroductionValid } from './lib/postingApplicationValidation' +export { + formatPostedAgo, + formatWorkDaysForDisplay, +} from './lib/postingToAlbaboxProps' +export type { Schedule } from './types/posting' diff --git a/src/features/job-lookup-map/lib/applyPostingError.ts b/src/features/job-lookup-map/lib/applyPostingError.ts index 0943f676..306ee32e 100644 --- a/src/features/job-lookup-map/lib/applyPostingError.ts +++ b/src/features/job-lookup-map/lib/applyPostingError.ts @@ -1,16 +1,36 @@ import axios from 'axios' import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage' +import { parseErrorResponse } from '@/shared/lib/utils/errorUtils' import type { ErrorResponse } from '@/shared/types/common' export interface ApplyPostingError { - message: string + message: string | null + descriptionError?: string retryable: boolean + blocked?: boolean } export function resolveApplyPostingError(error: unknown): ApplyPostingError { if (axios.isAxiosError(error)) { const response = error.response?.data as ErrorResponse | undefined + if (response?.code === 'B018') { + return { + message: '이미 근무 중인 업장에는 지원할 수 없어요.', + retryable: false, + blocked: true, + } + } + + const { fieldErrors } = parseErrorResponse(response) + if (fieldErrors.description) { + return { + message: null, + descriptionError: fieldErrors.description, + retryable: false, + } + } + const retryable = error.response?.status === 429 || response?.code === 'E001' diff --git a/src/features/job-lookup-map/lib/postingApplicationValidation.ts b/src/features/job-lookup-map/lib/postingApplicationValidation.ts new file mode 100644 index 00000000..9a37082d --- /dev/null +++ b/src/features/job-lookup-map/lib/postingApplicationValidation.ts @@ -0,0 +1,3 @@ +export function isPostingIntroductionValid(introduction: string) { + return introduction.trim().length > 0 +} diff --git a/src/features/job-lookup-map/lib/postingWorkspaceEligibility.ts b/src/features/job-lookup-map/lib/postingWorkspaceEligibility.ts new file mode 100644 index 00000000..a6976fe5 --- /dev/null +++ b/src/features/job-lookup-map/lib/postingWorkspaceEligibility.ts @@ -0,0 +1,29 @@ +import { getMyWorkspaces } from '@/entities/workspace' + +const PAGE_SIZE = 10 + +export async function isEmployedAtWorkspace( + workspaceId: number +): Promise { + const seenCursors = new Set() + let cursor: string | undefined + + do { + const response = await getMyWorkspaces({ pageSize: PAGE_SIZE, cursor }) + if ( + response.data.data.some( + workspace => workspace.workspaceId === workspaceId + ) + ) { + return true + } + + cursor = response.data.page.cursor || undefined + if (cursor && seenCursors.has(cursor)) { + throw new Error('근무 업장 목록을 끝까지 확인하지 못했습니다.') + } + if (cursor) seenCursors.add(cursor) + } while (cursor) + + return 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 index d0214fca..f6a86f47 100644 --- a/src/features/job-lookup-map/test/lib/applyPostingError.test.ts +++ b/src/features/job-lookup-map/test/lib/applyPostingError.test.ts @@ -21,6 +21,66 @@ describe('공고 지원 오류', () => { ).toEqual({ message, retryable: false }) }) + it('근무 중인 업장 지원 거부를 명확히 안내한다', () => { + expect( + resolveApplyPostingError( + axiosError(400, { code: 'B018', message: '잘못된 요청입니다.' }) + ) + ).toEqual({ + message: '이미 근무 중인 업장에는 지원할 수 없어요.', + retryable: false, + blocked: true, + }) + }) + + it.each([ + { + name: 'data 배열', + data: { + code: 'B001', + message: '잘못된 요청입니다.', + data: [{ field: 'description', message: '자기소개를 입력해 주세요.' }], + }, + }, + { + name: 'data 객체', + data: { + code: 'B001', + message: '잘못된 요청입니다.', + data: { description: '자기소개를 입력해 주세요.' }, + }, + }, + { + name: 'fieldErrors 객체', + data: { + code: 'B001', + message: '잘못된 요청입니다.', + fieldErrors: { description: '자기소개를 입력해 주세요.' }, + }, + }, + ])('$name의 자기소개 오류를 필드 오류로 분류한다', ({ data }) => { + expect(resolveApplyPostingError(axiosError(400, data))).toEqual({ + message: null, + descriptionError: '자기소개를 입력해 주세요.', + retryable: false, + }) + }) + + it('알 수 없는 필드 오류는 일반 메시지로 표시한다', () => { + expect( + resolveApplyPostingError( + axiosError(400, { + code: 'B001', + message: '잘못된 요청입니다.', + data: { unknown: '잘못된 값입니다.' }, + }) + ) + ).toEqual({ + message: '잘못된 요청입니다.', + retryable: false, + }) + }) + it('HTTP 429를 재시도 가능한 오류로 분류한다', () => { expect( resolveApplyPostingError( diff --git a/src/features/job-lookup-map/test/lib/postingApplicationValidation.test.ts b/src/features/job-lookup-map/test/lib/postingApplicationValidation.test.ts new file mode 100644 index 00000000..068d301d --- /dev/null +++ b/src/features/job-lookup-map/test/lib/postingApplicationValidation.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { isPostingIntroductionValid } from '../../lib/postingApplicationValidation' + +describe('공고 지원 자기소개 검증', () => { + it.each(['', ' ', ' \n\t'])( + '빈 값과 공백만 있는 값은 거부한다', + introduction => { + expect(isPostingIntroductionValid(introduction)).toBe(false) + } + ) + + it('공백을 제외한 내용이 있으면 허용한다', () => { + expect(isPostingIntroductionValid(' 성실하게 일하겠습니다. ')).toBe(true) + }) +}) diff --git a/src/features/job-lookup-map/test/lib/postingWorkspaceEligibility.test.ts b/src/features/job-lookup-map/test/lib/postingWorkspaceEligibility.test.ts new file mode 100644 index 00000000..149ee32e --- /dev/null +++ b/src/features/job-lookup-map/test/lib/postingWorkspaceEligibility.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getMyWorkspaces } from '@/entities/workspace' +import { isEmployedAtWorkspace } from '../../lib/postingWorkspaceEligibility' + +vi.mock('@/entities/workspace', () => ({ + getMyWorkspaces: vi.fn(), +})) + +const getWorkspaces = vi.mocked(getMyWorkspaces) + +function page(workspaceIds: number[], cursor: string) { + return { + timestamp: '2026-09-16T00:00:00Z', + data: { + page: { cursor, pageSize: 10, totalCount: workspaceIds.length }, + data: workspaceIds.map(workspaceId => ({ + workspaceId, + businessName: '업장', + employedAt: '2026-09-16', + })), + }, + } +} + +describe('공고 업장 근무 여부 확인', () => { + beforeEach(() => { + getWorkspaces.mockReset() + }) + + it('첫 페이지에서 근무 업장을 찾으면 추가 요청 없이 차단한다', async () => { + getWorkspaces.mockResolvedValue(page([10, 20], 'next')) + + await expect(isEmployedAtWorkspace(20)).resolves.toBe(true) + expect(getWorkspaces).toHaveBeenCalledTimes(1) + expect(getWorkspaces).toHaveBeenCalledWith({ + pageSize: 10, + cursor: undefined, + }) + }) + + it('첫 페이지에 없으면 다음 커서까지 조회해 근무 업장을 찾는다', async () => { + getWorkspaces + .mockResolvedValueOnce(page([10], 'next')) + .mockResolvedValueOnce(page([20], '')) + + await expect(isEmployedAtWorkspace(20)).resolves.toBe(true) + expect(getWorkspaces).toHaveBeenNthCalledWith(2, { + pageSize: 10, + cursor: 'next', + }) + }) + + it('마지막 페이지까지 없을 때만 지원 가능으로 판단한다', async () => { + getWorkspaces + .mockResolvedValueOnce(page([10], 'next')) + .mockResolvedValueOnce(page([30], '')) + + await expect(isEmployedAtWorkspace(20)).resolves.toBe(false) + expect(getWorkspaces).toHaveBeenCalledTimes(2) + }) + + it('다음 페이지 조회에 실패하면 지원 가능으로 판단하지 않는다', async () => { + getWorkspaces + .mockResolvedValueOnce(page([10], 'next')) + .mockRejectedValueOnce(new Error('network')) + + await expect(isEmployedAtWorkspace(20)).rejects.toThrow('network') + }) + + it('반복되는 커서는 종료 실패로 처리한다', async () => { + getWorkspaces + .mockResolvedValueOnce(page([10], 'next')) + .mockResolvedValueOnce(page([30], 'next')) + + await expect(isEmployedAtWorkspace(20)).rejects.toThrow( + '근무 업장 목록을 끝까지 확인하지 못했습니다.' + ) + expect(getWorkspaces).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/features/manager/posting/hooks/usePostingForm.ts b/src/features/manager/posting/hooks/usePostingForm.ts index ce5d6196..2739204c 100644 --- a/src/features/manager/posting/hooks/usePostingForm.ts +++ b/src/features/manager/posting/hooks/usePostingForm.ts @@ -53,6 +53,11 @@ function createInitialValues(posting?: Posting | null): PostingFormValues { } } +function toMinuteOfDay(time: string): number { + const [hour, minute] = time.split(':') + return Number(hour) * 60 + Number(minute) +} + export function validatePostingForm( values: PostingFormValues, isEditMode: boolean @@ -75,6 +80,14 @@ export function validatePostingForm( errors.schedules = '근무일정을 1개 이상 추가해 주세요' } else if (hasIncompleteSchedule) { errors.schedules = '근무요일과 시작·종료 시간을 모두 입력해 주세요' + } else { + const zeroDurationScheduleIndex = values.schedules.findIndex( + schedule => + toMinuteOfDay(schedule.startTime) === toMinuteOfDay(schedule.endTime) + ) + if (zeroDurationScheduleIndex !== -1) { + errors.schedules = `근무 시작 시간과 종료 시간은 같을 수 없어요` + } } const payAmount = Number(values.payAmount.replace(/[^0-9]/g, '')) diff --git a/src/features/manager/posting/test/lib/buildPostingRequest.test.ts b/src/features/manager/posting/test/lib/buildPostingRequest.test.ts index 99ed9342..e26e4599 100644 --- a/src/features/manager/posting/test/lib/buildPostingRequest.test.ts +++ b/src/features/manager/posting/test/lib/buildPostingRequest.test.ts @@ -45,6 +45,75 @@ describe('공고 폼 검증과 요청 생성', () => { ) }) + it('미입력 시간은 00:00을 명시 선택한 경우와 구분한다', () => { + for (const [startTime, endTime] of [ + ['', ''], + ['00:00', ''], + ['', '00:00'], + ]) { + const formValues = values() + formValues.schedules[0] = { + ...formValues.schedules[0], + startTime, + endTime, + } + expect(validatePostingForm(formValues, false).schedules).toBe( + '근무요일과 시작·종료 시간을 모두 입력해 주세요' + ) + } + }) + + it('등록과 수정에서 0분 일정을 차단한다', () => { + for (const isEditMode of [false, true]) { + for (const [startTime, endTime] of [ + ['00:00', '00:00'], + ['09:30', '09:30'], + ['9:30', '09:30'], + ]) { + const formValues = values() + formValues.schedules[0] = { + ...formValues.schedules[0], + startTime, + endTime, + } + expect(validatePostingForm(formValues, isEditMode).schedules).toBe( + '근무 시작 시간과 종료 시간은 같을 수 없어요' + ) + } + } + }) + + it('두 번째 일정이 0분이어도 동일한 오류를 표시한다', () => { + const formValues = values() + formValues.schedules.push({ + ...formValues.schedules[0], + key: 'new-2', + id: null, + startTime: '08:00', + endTime: '08:00', + }) + expect(validatePostingForm(formValues, false).schedules).toBe( + '근무 시작 시간과 종료 시간은 같을 수 없어요' + ) + }) + + it('등록과 수정에서 당일 및 익일 근무를 허용한다', () => { + for (const isEditMode of [false, true]) { + for (const [startTime, endTime] of [ + ['09:00', '18:00'], + ['22:00', '02:00'], + ]) { + const formValues = values() + formValues.schedules[0] = { + ...formValues.schedules[0], + startTime, + endTime, + } + expect(validatePostingForm(formValues, isEditMode)).toEqual({}) + } + } + }) + it('모든 기존 일정을 삭제 목록에 담는다', () => { expect( toUpdatePostingRequest({ ...values(), schedules: [] }, [1, 2]) diff --git a/src/features/manager/posting/ui/ScheduleEditor.tsx b/src/features/manager/posting/ui/ScheduleEditor.tsx index 3b2423b1..86ed734f 100644 --- a/src/features/manager/posting/ui/ScheduleEditor.tsx +++ b/src/features/manager/posting/ui/ScheduleEditor.tsx @@ -77,18 +77,6 @@ function ScheduleCard({ const isExisting = schedule.id !== null const [pickerTarget, setPickerTarget] = useState(null) - // 픽커는 휠 조작 시에만 onChange가 발생하므로, 열기 전에 기본값을 먼저 커밋해야 - // 보이는 값 그대로 닫아도 기록된다 - const openPicker = (target: TimeTarget) => { - if (target === 'start' && schedule.startTime === '') { - form.updateSchedule(schedule.key, { startTime: '00:00' }) - } - if (target === 'end' && schedule.endTime === '') { - form.updateSchedule(schedule.key, { endTime: '00:00' }) - } - setPickerTarget(target) - } - const start = splitTime(schedule.startTime) const end = splitTime(schedule.endTime) @@ -97,21 +85,13 @@ function ScheduleCard({ startMinute: start.minute, endHour: end.hour, endMinute: end.minute, - setStartHour: hour => - form.updateSchedule(schedule.key, { - startTime: `${hour}:${start.minute || '00'}`, - }), - setStartMinute: minute => - form.updateSchedule(schedule.key, { - startTime: `${start.hour || '00'}:${minute}`, - }), - setEndHour: hour => + setStartTime: (hour, minute) => form.updateSchedule(schedule.key, { - endTime: `${hour}:${end.minute || '00'}`, + startTime: hour && minute ? `${hour}:${minute}` : '', }), - setEndMinute: minute => + setEndTime: (hour, minute) => form.updateSchedule(schedule.key, { - endTime: `${end.hour || '00'}:${minute}`, + endTime: hour && minute ? `${hour}:${minute}` : '', }), } @@ -178,12 +158,12 @@ function ScheduleCard({ openPicker('start')} + onOpen={() => setPickerTarget('start')} /> openPicker('end')} + onOpen={() => setPickerTarget('end')} /> diff --git a/src/features/manager/worker-schedule/hooks/useWorkerScheduleManageViewModel.ts b/src/features/manager/worker-schedule/hooks/useWorkerScheduleManageViewModel.ts index 36dc901c..cae04090 100644 --- a/src/features/manager/worker-schedule/hooks/useWorkerScheduleManageViewModel.ts +++ b/src/features/manager/worker-schedule/hooks/useWorkerScheduleManageViewModel.ts @@ -451,11 +451,10 @@ export function useWorkerScheduleManageViewModel(args: { startMinute, endHour, endMinute, - setStartHour: (hour: string) => patchActiveForm({ startHour: hour }), - setStartMinute: (minute: string) => - patchActiveForm({ startMinute: minute }), - setEndHour: (hour: string) => patchActiveForm({ endHour: hour }), - setEndMinute: (minute: string) => patchActiveForm({ endMinute: minute }), + setStartTime: (hour: string, minute: string) => + patchActiveForm({ startHour: hour, startMinute: minute }), + setEndTime: (hour: string, minute: string) => + patchActiveForm({ endHour: hour, endMinute: minute }), }, handleSave, isSaving: saveMutation.isPending, diff --git a/src/features/user/home/workspace/api/workspace.ts b/src/features/user/home/workspace/api/workspace.ts index 0dba6701..f05c2599 100644 --- a/src/features/user/home/workspace/api/workspace.ts +++ b/src/features/user/home/workspace/api/workspace.ts @@ -1,10 +1,7 @@ import axiosInstance from '@/shared/lib/axiosInstance' +import type { WorkspaceListApiResponse } from '@/entities/workspace' import type { ResignWorkspaceResponse } from '@/features/user/home/workspace/types/resign' -import type { - WorkspaceItem, - WorkspaceListApiResponse, - WorkspaceListQueryParams, -} from '@/features/user/home/workspace/types/workspace' +import type { WorkspaceItem } from '@/features/user/home/workspace/types/workspace' function mapToWorkspaceItem( dto: WorkspaceListApiResponse['data']['data'][number] @@ -17,21 +14,6 @@ function mapToWorkspaceItem( } } -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 async function resignWorkspace( workspaceId: number ): Promise { diff --git a/src/features/user/home/workspace/hooks/useWorkspacesViewModel.ts b/src/features/user/home/workspace/hooks/useWorkspacesViewModel.ts index b66800ff..d2f34f44 100644 --- a/src/features/user/home/workspace/hooks/useWorkspacesViewModel.ts +++ b/src/features/user/home/workspace/hooks/useWorkspacesViewModel.ts @@ -1,8 +1,6 @@ import { useInfiniteQuery } from '@tanstack/react-query' -import { - adaptWorkspaceListResponse, - getMyWorkspaces, -} from '@/features/user/home/workspace/api/workspace' +import { getMyWorkspaces } from '@/entities/workspace' +import { adaptWorkspaceListResponse } from '@/features/user/home/workspace/api/workspace' import { queryKeys } from '@/shared/lib/queryKeys' const PAGE_SIZE = 10 diff --git a/src/features/user/home/workspace/types/workspace.ts b/src/features/user/home/workspace/types/workspace.ts index 6b1d8b94..da8d7ecf 100644 --- a/src/features/user/home/workspace/types/workspace.ts +++ b/src/features/user/home/workspace/types/workspace.ts @@ -1,25 +1,10 @@ -import type { CommonApiResponse } from '@/shared/types/common' - -// DTO -export interface WorkspaceItemDto { - workspaceId: number - businessName: string - employedAt: string - nextShiftDateTime?: string | null -} - -export interface WorkspacePageDto { - cursor: string - pageSize: number - totalCount: number -} - -export interface WorkspaceListDto { - page: WorkspacePageDto - data: WorkspaceItemDto[] -} - -export type WorkspaceListApiResponse = CommonApiResponse +export type { + WorkspaceItemDto, + WorkspaceListApiResponse, + WorkspaceListDto, + WorkspaceListQueryParams, + WorkspacePageDto, +} from '@/entities/workspace' // UI Model export interface WorkspaceItem { @@ -28,8 +13,3 @@ export interface WorkspaceItem { employedAt: string nextShiftDateTime: string | null } - -export interface WorkspaceListQueryParams { - cursor?: string - pageSize: number -} diff --git a/src/pages/user/job-lookup-map-apply/index.tsx b/src/pages/user/job-lookup-map-apply/index.tsx index 667cf698..7549376e 100644 --- a/src/pages/user/job-lookup-map-apply/index.tsx +++ b/src/pages/user/job-lookup-map-apply/index.tsx @@ -1,14 +1,17 @@ import { useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import ChevronLeftIcon from '@/assets/icons/nav/chevron-left.svg?react' -import { useApplyPosting } from '@/features/job-lookup-map/hooks/useApplyPosting' -import { usePostingDetail } from '@/features/job-lookup-map/hooks/usePostingDetail' -import { resolveApplyPostingError } from '@/features/job-lookup-map/lib/applyPostingError' -import type { Schedule } from '@/features/job-lookup-map/types/posting' import { formatPostedAgo, formatWorkDaysForDisplay, -} from '@/features/job-lookup-map/lib/postingToAlbaboxProps' + isPostingIntroductionValid, + PostingWorkspaceEligibilityNotice, + resolveApplyPostingError, + type Schedule, + useApplyPosting, + usePostingDetail, + usePostingWorkspaceEligibility, +} from '@/features/job-lookup-map' function parseSelectedWorkDaysFromSchedule(schedule: Schedule): string[] { if (!schedule.workingDays?.length) return [] @@ -114,6 +117,8 @@ export function JobLookupMapApplyPage() { const { data, isLoading, isError } = usePostingDetail( idOk ? postingId : undefined ) + const { status: eligibilityStatus, retry: retryEligibility } = + usePostingWorkspaceEligibility(data?.workspace.id) const [introduction, setIntroduction] = useState('') const [selectedScheduleId, setSelectedScheduleId] = useState( null @@ -124,11 +129,19 @@ export function JobLookupMapApplyPage() { isPending: isSubmitting, isError: isSubmitError, error: submitError, + reset: resetApply, } = useApplyPosting() const applyError = isSubmitError ? resolveApplyPostingError(submitError) : null + const introductionError = applyError?.descriptionError + const isIntroductionEmpty = !isPostingIntroductionValid(introduction) + const isSubmitDisabled = + isSubmitting || + isIntroductionEmpty || + eligibilityStatus !== 'eligible' || + Boolean(applyError?.blocked) const showLoading = idOk && isLoading && !data const showError = idOk && isError && !data @@ -258,28 +271,54 @@ export function JobLookupMapApplyPage() {
-

+

자기소개

setIntroduction(e.target.value)} - placeholder="자신을 장점을 마음껏 작성해 주세요!" + onChange={e => { + setIntroduction(e.target.value) + if (introductionError) resetApply() + }} + aria-labelledby="posting-introduction-label" + aria-invalid={Boolean(introductionError)} + aria-describedby={ + introductionError ? 'posting-introduction-error' : undefined + } + placeholder="자신의 장점을 마음껏 작성해 주세요!" className="mt-3 h-12 w-full rounded-2xl bg-bg-light px-4 typography-body03-regular text-text-100 placeholder:text-text-50 outline-none" /> + {introductionError ? ( + + ) : null}
- {applyError ? ( + void retryEligibility()} + /> + {applyError?.message ? (

{applyError.message}

) : null}
diff --git a/src/pages/user/job-lookup-map-detail/index.tsx b/src/pages/user/job-lookup-map-detail/index.tsx index b35c8a88..465e17fb 100644 --- a/src/pages/user/job-lookup-map-detail/index.tsx +++ b/src/pages/user/job-lookup-map-detail/index.tsx @@ -3,12 +3,14 @@ import { generatePath, useNavigate, useParams } from 'react-router-dom' import { ROUTES } from '@/shared/constants/routes' import ChevronLeftIcon from '@/assets/icons/nav/chevron-left.svg?react' import BookmarkIcon from '@/assets/icons/job-lookup-map/Bookmark.svg?react' -import { usePostingDetail } from '@/features/job-lookup-map/hooks/usePostingDetail' -import { useToggleFavoritePosting } from '@/features/job-lookup-map/hooks/useToggleFavoritePosting' import { formatPostedAgo, formatWorkDaysForDisplay, -} from '@/features/job-lookup-map/lib/postingToAlbaboxProps' + PostingWorkspaceEligibilityNotice, + usePostingDetail, + usePostingWorkspaceEligibility, + useToggleFavoritePosting, +} from '@/features/job-lookup-map' const WEEK_DAYS = ['월', '화', '수', '목', '금', '토', '일'] as const @@ -39,6 +41,8 @@ export function JobLookupMapDetailPage() { const { data, isLoading, isError } = usePostingDetail( idOk ? postingId : undefined ) + const { status: eligibilityStatus, retry: retryEligibility } = + usePostingWorkspaceEligibility(data?.workspace.id) const { toggleFavorite, isPending: isFavoritePending } = useToggleFavoritePosting() const [savedById, setSavedById] = useState>({}) @@ -250,18 +254,26 @@ export function JobLookupMapDetailPage() {
+ void retryEligibility()} + />
diff --git a/src/shared/lib/utils/errorUtils.ts b/src/shared/lib/utils/errorUtils.ts index b2eccb3c..54d3e1ef 100644 --- a/src/shared/lib/utils/errorUtils.ts +++ b/src/shared/lib/utils/errorUtils.ts @@ -16,10 +16,15 @@ export function parseErrorResponse(data: unknown): { fieldErrors[item.field] = item.message } } - } else if ( - typedData.fieldErrors && - typeof typedData.fieldErrors === 'object' - ) { + } else if (typedData.data && typeof typedData.data === 'object') { + for (const [field, message] of Object.entries(typedData.data)) { + if (typeof message === 'string') { + fieldErrors[field] = message + } + } + } + + if (typedData.fieldErrors && typeof typedData.fieldErrors === 'object') { Object.assign(fieldErrors, typedData.fieldErrors) } diff --git a/src/shared/types/workTime.ts b/src/shared/types/workTime.ts index 0e2e4de0..53db8dcf 100644 --- a/src/shared/types/workTime.ts +++ b/src/shared/types/workTime.ts @@ -3,8 +3,6 @@ export interface WorkTimeEditorState { startMinute: string endHour: string endMinute: string - setStartHour: (value: string) => void - setStartMinute: (value: string) => void - setEndHour: (value: string) => void - setEndMinute: (value: string) => void + setStartTime: (hour: string, minute: string) => void + setEndTime: (hour: string, minute: string) => void } diff --git a/src/shared/ui/MobileLayout.tsx b/src/shared/ui/MobileLayout.tsx index eb62680b..f9fa7c13 100644 --- a/src/shared/ui/MobileLayout.tsx +++ b/src/shared/ui/MobileLayout.tsx @@ -1,4 +1,8 @@ import type { ReactNode } from 'react' +import { + DEFAULT_MOBILE_LAYOUT_MAX_WIDTH, + MobileLayoutMaxWidthContext, +} from '@/shared/ui/mobileLayoutWidth' interface MobileLayoutProps { children: ReactNode @@ -9,16 +13,18 @@ interface MobileLayoutProps { export function MobileLayout({ children, className = '', - maxWidth = '428px', + maxWidth = DEFAULT_MOBILE_LAYOUT_MAX_WIDTH, }: MobileLayoutProps) { return ( -
-
- {children} + +
+
+ {children} +
-
+ ) } diff --git a/src/shared/ui/MobileLayoutWithDocbar.tsx b/src/shared/ui/MobileLayoutWithDocbar.tsx index 1dd61e05..38f378f6 100644 --- a/src/shared/ui/MobileLayoutWithDocbar.tsx +++ b/src/shared/ui/MobileLayoutWithDocbar.tsx @@ -1,5 +1,9 @@ import type { ReactNode } from 'react' import { Docbar } from './common/Docbar' +import { + DEFAULT_MOBILE_LAYOUT_MAX_WIDTH, + MobileLayoutMaxWidthContext, +} from '@/shared/ui/mobileLayoutWidth' interface MobileLayoutWithDocbarProps { children: ReactNode @@ -10,23 +14,25 @@ interface MobileLayoutWithDocbarProps { export function MobileLayoutWithDocbar({ children, className = '', - maxWidth = '428px', + maxWidth = DEFAULT_MOBILE_LAYOUT_MAX_WIDTH, }: MobileLayoutWithDocbarProps) { return ( -
-
- {children} + +
+
+ {children} +
+
+ +
-
- -
-
+ ) } diff --git a/src/shared/ui/common/WorkTimePickerDrawer.tsx b/src/shared/ui/common/WorkTimePickerDrawer.tsx index f2b77da0..ce60e486 100644 --- a/src/shared/ui/common/WorkTimePickerDrawer.tsx +++ b/src/shared/ui/common/WorkTimePickerDrawer.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { Drawer } from 'vaul' import { WheelPicker } from '@/shared/ui/common/WheelPicker' import { @@ -9,12 +10,22 @@ import { type TimePeriod, } from '@/shared/lib/formatKoreanWorkTime' import type { WorkTimeEditorState } from '@/shared/types/workTime' +import { useMobileLayoutMaxWidth } from '@/shared/ui/mobileLayoutWidth' const PERIOD_ITEMS = ['오전', '오후'] as const -const HOUR_ITEMS = Array.from({ length: 12 }, (_, i) => `${i + 1}시`) -const MINUTE_ITEMS = WORK_TIME_MINUTE_OPTIONS.map(m => `${m}분`) +const HOUR_ITEMS = [ + '시', + '12시', + ...Array.from({ length: 11 }, (_, i) => `${i + 1}시`), +] +const MINUTE_ITEMS = ['분', ...WORK_TIME_MINUTE_OPTIONS.map(m => `${m}분`)] type TimeTarget = 'start' | 'end' +type TimeSelection = { + period: TimePeriod + hour12: number | null + minute: string +} interface WorkTimePickerDrawerProps { open: boolean @@ -31,42 +42,90 @@ export function WorkTimePickerDrawer({ }: WorkTimePickerDrawerProps) { if (!open || !target) return null + return ( + + ) +} + +function OpenWorkTimePickerDrawer({ + target, + workTime, + onOpenChange, +}: { + target: TimeTarget + workTime: WorkTimeEditorState + onOpenChange: (open: boolean) => void +}) { + const maxWidth = useMobileLayoutMaxWidth() const hour = target === 'start' ? workTime.startHour : workTime.endHour const minute = target === 'start' ? workTime.startMinute : workTime.endMinute - const setHour = - target === 'start' ? workTime.setStartHour : workTime.setEndHour - const setMinute = - target === 'start' ? workTime.setStartMinute : workTime.setEndMinute + const setTime = + target === 'start' ? workTime.setStartTime : workTime.setEndTime + + const [selection, setSelection] = useState(() => { + const { period, hour12 } = hour24To12Parts(hour) + return { period, hour12: hour ? hour12 : null, minute } + }) + + const select = (next: TimeSelection) => { + setSelection(next) + if (next.hour12 !== null && next.minute) { + setTime(partsToHour24(next.period, next.hour12), next.minute) + } + } - const { period, hour12 } = hour24To12Parts(hour) - const periodIndex = period === '오후' ? 1 : 0 - const hourIndex = Math.min(11, Math.max(0, hour12 - 1)) - const minuteIndex = Math.max(0, minuteToTenMinuteIndex(minute)) + const periodIndex = selection.period === '오후' ? 1 : 0 + const hourIndex = + selection.hour12 === null + ? 0 + : selection.hour12 === 12 + ? 1 + : selection.hour12 + 1 + const minuteIndex = selection.minute + ? minuteToTenMinuteIndex(selection.minute) + 1 + : 0 const applyPeriod = (index: number) => { - const nextPeriod: TimePeriod = index === 1 ? '오후' : '오전' - setHour(partsToHour24(nextPeriod, hour12)) + select({ ...selection, period: index === 1 ? '오후' : '오전' }) } const applyHour = (index: number) => { - setHour(partsToHour24(period, index + 1)) + select({ + ...selection, + hour12: index === 0 ? null : index === 1 ? 12 : index - 1, + }) } const applyMinute = (index: number) => { - setMinute(snapMinuteToTen(WORK_TIME_MINUTE_OPTIONS[index] ?? '00')) + select({ + ...selection, + minute: + index === 0 + ? '' + : snapMinuteToTen(WORK_TIME_MINUTE_OPTIONS[index - 1] ?? '00'), + }) } return ( - + - + -

+ 근무 시간 선택 -

+
( + Story => (
@@ -21,7 +25,7 @@ export default meta type Story = StoryObj export const Default: Story = { - render: (args) => ( + render: args => (

@@ -30,7 +34,83 @@ export const Default: Story = {

), - args: { - maxWidth: '428px', +} + +export const TimePickerUsesLayoutWidth: Story = { + render: () => ( + + {}, + setEndTime: () => {}, + }} + /> + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const body = within(canvasElement.ownerDocument.body) + const frame = canvasElement.querySelector('.mobile-layout-container') + const buttons = within( + canvas.getByRole('group', { name: '근무 시간 범위' }) + ).getAllByRole('button') + + await userEvent.click(buttons[0]) + + const dialog = body.getByRole('dialog', { name: '근무 시간 선택' }) + const overlay = body.getByTestId('work-time-picker-overlay') + const expectedWidth = Math.min(window.innerWidth, 390) + + for (const element of [frame, dialog, overlay]) { + const bounds = element!.getBoundingClientRect() + await expect(bounds.width).toBe(expectedWidth) + await expect(bounds.left).toBeCloseTo( + (window.innerWidth - expectedWidth) / 2 + ) + } + }, +} + +export const TimePickerUsesDocbarLayoutWidth: Story = { + render: () => ( + + + {}, + setEndTime: () => {}, + }} + /> + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const body = within(canvasElement.ownerDocument.body) + const buttons = within( + canvas.getByRole('group', { name: '근무 시간 범위' }) + ).getAllByRole('button') + + await userEvent.click(buttons[0]) + + const dialog = body.getByRole('dialog', { name: '근무 시간 선택' }) + const overlay = body.getByTestId('work-time-picker-overlay') + const expectedWidth = Math.min(window.innerWidth, 400) + + for (const element of [dialog, overlay]) { + const bounds = element.getBoundingClientRect() + await expect(bounds.width).toBe(expectedWidth) + await expect(bounds.left).toBeCloseTo( + (window.innerWidth - expectedWidth) / 2 + ) + } }, } diff --git a/storybook/stories/PostingWorkspaceEligibilityNotice.stories.tsx b/storybook/stories/PostingWorkspaceEligibilityNotice.stories.tsx new file mode 100644 index 00000000..9ef1ad5c --- /dev/null +++ b/storybook/stories/PostingWorkspaceEligibilityNotice.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, fn, userEvent, within } from 'storybook/test' +import { PostingWorkspaceEligibilityNotice } from '../../src/features/job-lookup-map/common/PostingWorkspaceEligibilityNotice' + +const meta = { + title: 'features/job-lookup-map/PostingWorkspaceEligibilityNotice', + component: PostingWorkspaceEligibilityNotice, + args: { onRetry: fn() }, + parameters: { layout: 'centered' }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Checking: Story = { + args: { status: 'checking' }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect( + canvas.getByText('근무 중인 업장인지 확인하는 중…') + ).toBeVisible() + await expect(canvas.queryByRole('button')).not.toBeInTheDocument() + }, +} + +export const Employed: Story = { + args: { status: 'employed' }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect(canvas.getByText('이미 근무 중인 업장입니다.')).toBeVisible() + }, +} + +export const ErrorWithRetry: Story = { + args: { status: 'error' }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement) + await expect( + canvas.getByText('근무 중인 업장인지 확인하지 못했습니다.') + ).toBeVisible() + await userEvent.click(canvas.getByRole('button', { name: '다시 시도' })) + await expect(args.onRetry).toHaveBeenCalledOnce() + }, +} diff --git a/storybook/stories/ScheduleEditor.stories.tsx b/storybook/stories/ScheduleEditor.stories.tsx index 6016108c..b1e036ce 100644 --- a/storybook/stories/ScheduleEditor.stories.tsx +++ b/storybook/stories/ScheduleEditor.stories.tsx @@ -4,6 +4,7 @@ import { expect, userEvent, within } from 'storybook/test' import { usePostingForm } from '../../src/features/manager/posting/hooks/usePostingForm' import type { Posting } from '../../src/features/manager/posting/types/posting' import { ScheduleEditor } from '../../src/features/manager/posting/ui/ScheduleEditor' +import { DEFAULT_MOBILE_LAYOUT_MAX_WIDTH } from '../../src/shared/ui/mobileLayoutWidth' const posting: Posting = { id: 1, @@ -122,3 +123,92 @@ export const MultipleSchedules: Story = { } }, } + +export const AccessibleTimePicker: Story = { + args: { initialPosting: posting }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const body = within(canvasElement.ownerDocument.body) + + await userEvent.click( + canvas.getByRole('button', { name: '시작 시간 선택' }) + ) + const dialog = body.getByRole('dialog', { name: '근무 시간 선택' }) + const overlay = body.getByTestId('work-time-picker-overlay') + await expect(dialog).toBeVisible() + + const expectedWidth = Math.min( + window.innerWidth, + Number.parseInt(DEFAULT_MOBILE_LAYOUT_MAX_WIDTH, 10) + ) + for (const element of [dialog, overlay]) { + const bounds = element.getBoundingClientRect() + await expect(bounds.width).toBe(expectedWidth) + await expect(bounds.left).toBeCloseTo( + (window.innerWidth - expectedWidth) / 2 + ) + } + + await userEvent.keyboard('{Escape}') + await userEvent.click( + canvas.getByRole('button', { name: '종료 시간 선택' }) + ) + await expect( + body.getByRole('dialog', { name: '근무 시간 선택' }) + ).toBeVisible() + }, +} + +export const EmptyTimeRemainsUnselected: Story = { + args: { + initialPosting: { + ...posting, + schedules: [{ ...posting.schedules[0], startTime: '', endTime: '' }], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const body = within(canvasElement.ownerDocument.body) + const start = canvas.getByRole('button', { name: '시작 시간 선택' }) + const end = canvas.getByRole('button', { name: '종료 시간 선택' }) + + await userEvent.click(start) + await expect( + within(body.getByRole('listbox', { name: '시' })).getByText('시') + ).toHaveClass('text-text-100') + await expect( + within(body.getByRole('listbox', { name: '분' })).getByText('분') + ).toHaveClass('text-text-100') + await userEvent.keyboard('{Escape}') + await expect(start).toHaveTextContent('시간 선택') + + await userEvent.click(end) + await userEvent.keyboard('{Escape}') + await expect(end).toHaveTextContent('시간 선택') + + await userEvent.click(start) + body.getByRole('listbox', { name: '시' }).focus() + await userEvent.keyboard('{ArrowDown}') + await expect(start).toHaveTextContent('시간 선택') + await userEvent.keyboard('{Escape}') + await userEvent.click(start) + await expect( + within(body.getByRole('listbox', { name: '시' })).getByText('시') + ).toHaveClass('text-text-100') + body.getByRole('listbox', { name: '시' }).focus() + await userEvent.keyboard('{ArrowDown}') + body.getByRole('listbox', { name: '분' }).focus() + await userEvent.keyboard('{ArrowDown}') + await expect(start).toHaveTextContent('00:00') + await userEvent.keyboard('{Escape}') + await expect(start).toHaveTextContent('00:00') + + await userEvent.click(end) + body.getByRole('listbox', { name: '분' }).focus() + await userEvent.keyboard('{ArrowDown}') + await expect(end).toHaveTextContent('시간 선택') + body.getByRole('listbox', { name: '시' }).focus() + await userEvent.keyboard('{ArrowDown}') + await expect(end).toHaveTextContent('00:00') + }, +} diff --git a/storybook/stories/WorkTimeRangeField.stories.tsx b/storybook/stories/WorkTimeRangeField.stories.tsx new file mode 100644 index 00000000..268ca629 --- /dev/null +++ b/storybook/stories/WorkTimeRangeField.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, fn, userEvent, within } from 'storybook/test' + +import { WorkTimeRangeField } from '../../src/pages/manager/worker-schedule/components/WorkTimeRangeField' + +const meta = { + title: 'pages/manager/worker-schedule/WorkTimeRangeField', + component: WorkTimeRangeField, + parameters: { layout: 'centered' }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const AccessibleTimePicker: Story = { + args: { + workTime: { + startHour: '09', + startMinute: '00', + endHour: '18', + endMinute: '00', + setStartTime: fn(), + setEndTime: fn(), + }, + }, + play: async ({ args, canvasElement }) => { + const buttons = within( + within(canvasElement).getByRole('group', { name: '근무 시간 범위' }) + ).getAllByRole('button') + const body = within(canvasElement.ownerDocument.body) + + await userEvent.click(buttons[0]) + await expect( + body.getByRole('dialog', { name: '근무 시간 선택' }) + ).toBeVisible() + body.getByRole('listbox', { name: '시' }).focus() + await userEvent.keyboard('{ArrowUp}') + await expect(args.workTime.setStartTime).toHaveBeenCalledWith('08', '00') + + await userEvent.keyboard('{Escape}') + await userEvent.click(buttons[1]) + await expect( + body.getByRole('dialog', { name: '근무 시간 선택' }) + ).toBeVisible() + }, +}