diff --git a/app/(business)/business/academy-detail/page.tsx b/app/(business)/business/academy-detail/page.tsx new file mode 100644 index 00000000..a9a87f15 --- /dev/null +++ b/app/(business)/business/academy-detail/page.tsx @@ -0,0 +1,10 @@ +import { getAcademyMe } from 'entities/academy' +import { AcademyDetailPage } from 'pages/academy-detail' + +const Page = async () => { + const academyDetail = await getAcademyMe() + + return +} + +export default Page diff --git a/app/(business)/business/job-posting/[jobPostId]/applicant-management/[jobPostResumeRelationId]/page.ts b/app/(business)/business/job-posting/[jobPostId]/applicant-management/[jobPostResumeRelationId]/page.ts new file mode 100644 index 00000000..0d5dadc3 --- /dev/null +++ b/app/(business)/business/job-posting/[jobPostId]/applicant-management/[jobPostResumeRelationId]/page.ts @@ -0,0 +1 @@ +export { JobPostApplicantManagementDetailPage as default } from 'pages/business-job-posting' diff --git a/app/(business)/business/job-posting/[jobPostId]/applicant-management/page.tsx b/app/(business)/business/job-posting/[jobPostId]/applicant-management/page.tsx new file mode 100644 index 00000000..6a78c8c0 --- /dev/null +++ b/app/(business)/business/job-posting/[jobPostId]/applicant-management/page.tsx @@ -0,0 +1,28 @@ +import { isAfter, parseISO } from 'date-fns' + +import { getJobPost } from 'entities/job-post' +import { JobPostApplicantManagementListPage } from 'pages/business-job-posting' + +type Params = { + jobPostId: string +} + +const Page = async ({ params }: { params: Promise }) => { + const { jobPostId } = await params + + const jobPost = await getJobPost({ jobPostId: Number(jobPostId) }) + + const isExpired = jobPost.dueDate + ? isAfter(new Date(), parseISO(jobPost.dueDate)) + : false + + return ( + + ) +} + +export default Page diff --git a/app/(business)/business/job-posting/[jobPostId]/update/draft/page.tsx b/app/(business)/business/job-posting/[jobPostId]/update/draft/page.tsx new file mode 100644 index 00000000..f281d372 --- /dev/null +++ b/app/(business)/business/job-posting/[jobPostId]/update/draft/page.tsx @@ -0,0 +1,21 @@ +import { getJobPost } from 'entities/job-post' +import { UpdateJobPostingDraftPage } from 'pages/business-job-posting' + +type Params = { + jobPostId: string +} + +const Page = async ({ params }: { params: Promise }) => { + const { jobPostId } = await params + + const jobPost = await getJobPost({ jobPostId: Number(jobPostId) }) + + return ( + + ) +} + +export default Page diff --git a/app/(business)/business/job-posting/[jobPostId]/update/page.ts b/app/(business)/business/job-posting/[jobPostId]/update/page.ts deleted file mode 100644 index c62aed02..00000000 --- a/app/(business)/business/job-posting/[jobPostId]/update/page.ts +++ /dev/null @@ -1 +0,0 @@ -export { UpdateJobPostingPage as default } from 'pages/business-job-posting' diff --git a/app/(business)/business/job-posting/[jobPostId]/update/page.tsx b/app/(business)/business/job-posting/[jobPostId]/update/page.tsx new file mode 100644 index 00000000..8840d84f --- /dev/null +++ b/app/(business)/business/job-posting/[jobPostId]/update/page.tsx @@ -0,0 +1,21 @@ +import { getJobPost } from 'entities/job-post' +import { UpdateJobPostingPage } from 'pages/business-job-posting' + +type Params = { + jobPostId: string +} + +const Page = async ({ params }: { params: Promise }) => { + const { jobPostId } = await params + + const jobPost = await getJobPost({ jobPostId: Number(jobPostId) }) + + return ( + + ) +} + +export default Page diff --git a/app/(business)/business/page.tsx b/app/(business)/business/page.tsx index 31ecb8b6..7ad8008a 100644 --- a/app/(business)/business/page.tsx +++ b/app/(business)/business/page.tsx @@ -1,36 +1,51 @@ -import { colors } from 'shared/config' -import { Icon } from 'shared/ui' +import { getNullableBusinessSession } from 'entities/auth' +import { Button, Image } from 'shared/ui' import { Layout } from 'shared/ui' -export default function BusinessPage() { +const BusinessPage = async () => { + const session = await getNullableBusinessSession() + + const link = session ? '/business/job-posting/create' : '/business/sign-up' + return ( -
- -

- 현재{' '} - - 페이지 준비중 - - 입니다. -

-
-

- 이용에 불편을 드려 죄송합니다. -

-

- 보다 나은 서비스 제공을 위하여 페이지 준비중에 있습니다. -

-

- 빠른 시일 내에 준비하여 찾아뵙겠습니다. -

+
+
+

+ 강사를 손쉽게 관리하고, +
+ 빠르게 채용하세요 +

+
+

+ 학원에 맞는 원어민 강사를 찾고 계신가요? +

+

+ 지금, Plus82와 시작하세요. +

+
+
+ + business-page-image
) } + +export default BusinessPage diff --git a/app/(business)/business/setting/layout.tsx b/app/(business)/business/setting/layout.tsx new file mode 100644 index 00000000..60bf88ff --- /dev/null +++ b/app/(business)/business/setting/layout.tsx @@ -0,0 +1,19 @@ +import { Layout } from 'shared/ui' +import { + SettingSidebar, + SettingSidebarProvider, + businessItems, +} from 'widgets/sidebar' + +const PageLayout = ({ children }: { children: React.ReactNode }) => { + return ( + + + + {children} + + + ) +} + +export default PageLayout diff --git a/app/(business)/business/setting/my-academy/page.ts b/app/(business)/business/setting/my-academy/page.ts new file mode 100644 index 00000000..9246e3ca --- /dev/null +++ b/app/(business)/business/setting/my-academy/page.ts @@ -0,0 +1 @@ +export { MyAcademyPage as default } from 'pages/my-academy' diff --git a/app/(business)/business/setting/my-account/change-password/page.ts b/app/(business)/business/setting/my-account/change-password/page.ts new file mode 100644 index 00000000..fb81fcea --- /dev/null +++ b/app/(business)/business/setting/my-account/change-password/page.ts @@ -0,0 +1 @@ +export { ChangePasswordPage as default } from 'pages/my-academy' diff --git a/app/(business)/business/setting/my-account/personal-information/page.tsx b/app/(business)/business/setting/my-account/personal-information/page.tsx new file mode 100644 index 00000000..a32aa852 --- /dev/null +++ b/app/(business)/business/setting/my-account/personal-information/page.tsx @@ -0,0 +1,10 @@ +import { getBusinessUserMe } from 'entities/user' +import { PersonalInformationPage } from 'pages/my-academy' + +const Page = async () => { + const user = await getBusinessUserMe() + + return +} + +export default Page diff --git a/app/(business)/business/setting/page.tsx b/app/(business)/business/setting/page.tsx new file mode 100644 index 00000000..0c5a5b16 --- /dev/null +++ b/app/(business)/business/setting/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export default function SettingPage() { + redirect('/business/setting/my-academy') +} diff --git a/app/(user)/job-board/[jobPostId]/page.tsx b/app/(user)/job-board/[jobPostId]/page.tsx index bce1eb6c..207922e2 100644 --- a/app/(user)/job-board/[jobPostId]/page.tsx +++ b/app/(user)/job-board/[jobPostId]/page.tsx @@ -7,7 +7,7 @@ type Params = { jobPostId: string } -export const JobPostingDetailPage = async ({ +const JobPostingDetailPage = async ({ params, }: { params: Promise @@ -37,3 +37,5 @@ export const JobPostingDetailPage = async ({ /> ) } + +export default JobPostingDetailPage diff --git a/app/(user)/setting/(with-layout)/layout.tsx b/app/(user)/setting/(with-layout)/layout.tsx index 020129d7..a16fbb61 100644 --- a/app/(user)/setting/(with-layout)/layout.tsx +++ b/app/(user)/setting/(with-layout)/layout.tsx @@ -1,11 +1,11 @@ import { Layout } from 'shared/ui' -import { SettingSidebar, SettingSidebarProvider } from 'widgets/sidebar' +import { SettingSidebar, SettingSidebarProvider, items } from 'widgets/sidebar' const PageLayout = ({ children }: { children: React.ReactNode }) => { return ( - + {children} diff --git a/public/images/business-banner.png b/public/images/business-banner.png new file mode 100644 index 00000000..c233c2e8 Binary files /dev/null and b/public/images/business-banner.png differ diff --git a/src/entities/academy/api/get-academy-me.ts b/src/entities/academy/api/get-academy-me.ts new file mode 100644 index 00000000..b6738741 --- /dev/null +++ b/src/entities/academy/api/get-academy-me.ts @@ -0,0 +1,22 @@ +'use server' + +import { getBusinessSession } from 'entities/auth' +import { apiClient } from 'shared/api' + +import { AcademyDetail } from '../model/academy-detail' + +type GetAcademyMeResponse = AcademyDetail + +export const getAcademyMe = async () => { + const { accessToken } = await getBusinessSession() + + const response = await apiClient.get({ + endpoint: `/academies/me`, + option: { + authorization: `Bearer ${accessToken}`, + tags: ['academy-me'], + }, + }) + + return response +} diff --git a/src/entities/academy/api/query.ts b/src/entities/academy/api/query.ts new file mode 100644 index 00000000..8bb49b91 --- /dev/null +++ b/src/entities/academy/api/query.ts @@ -0,0 +1,12 @@ +import { queryOptions } from '@tanstack/react-query' + +import { getAcademyMe } from './get-academy-me' + +export const academyQueries = { + all: () => ['academy'], + me: () => + queryOptions({ + queryKey: [...academyQueries.all(), 'me'], + queryFn: () => getAcademyMe(), + }), +} diff --git a/src/entities/academy/api/update-academy-me.ts b/src/entities/academy/api/update-academy-me.ts new file mode 100644 index 00000000..69904e15 --- /dev/null +++ b/src/entities/academy/api/update-academy-me.ts @@ -0,0 +1,48 @@ +'use server' + +import { revalidateTag } from 'next/cache' + +import { getBusinessSession } from 'entities/auth' +import { + apiClient, + HttpError, + errorHandler, + ServerError, + ContentType, +} from 'shared/api' + +import { UpdateAcademyDetail } from '../model/academy-detail' + +const handleSuccess = () => { + revalidateTag('academy-me') +} + +const handleError = (error: Error): ServerError => { + const isHttpError = error instanceof HttpError + if (!isHttpError) throw error + + return errorHandler.toast('업데이트에 실패했어요', { + error, + }) +} + +export const updateAcademyMe = async ( + updateAcademyDetail: UpdateAcademyDetail, +) => { + const { accessToken } = await getBusinessSession() + + try { + await apiClient.put({ + endpoint: `/academies/me`, + option: { + contentType: ContentType.MULTIPART, + authorization: `Bearer ${accessToken}`, + }, + body: updateAcademyDetail, + }) + + handleSuccess() + } catch (error) { + return handleError(error as Error) + } +} diff --git a/src/entities/academy/index.ts b/src/entities/academy/index.ts index 85edae0b..2d4a5725 100644 --- a/src/entities/academy/index.ts +++ b/src/entities/academy/index.ts @@ -1 +1,7 @@ -export { type AcademyDetail } from './model/academy-detail' +export { + type AcademyDetail, + type UpdateAcademyDetail, +} from './model/academy-detail' +export { academyQueries } from './api/query' +export { updateAcademyMe } from './api/update-academy-me' +export { getAcademyMe } from './api/get-academy-me' diff --git a/src/entities/academy/model/academy-detail.ts b/src/entities/academy/model/academy-detail.ts index 20f21bc9..fd9e564e 100644 --- a/src/entities/academy/model/academy-detail.ts +++ b/src/entities/academy/model/academy-detail.ts @@ -18,3 +18,10 @@ export type AcademyDetail = { forAdult: boolean imageUrls: string[] } + +export type UpdateAcademyDetail = Omit< + AcademyDetail, + 'id' | 'imageUrls' | 'businessRegistrationNumber' +> & { + images: File[] +} diff --git a/src/entities/job-post-resume-relation/api/get-job-post-resumes.ts b/src/entities/job-post-resume-relation/api/get-job-post-resumes.ts index 35dcc170..3c6c2960 100644 --- a/src/entities/job-post-resume-relation/api/get-job-post-resumes.ts +++ b/src/entities/job-post-resume-relation/api/get-job-post-resumes.ts @@ -8,6 +8,7 @@ import { ApplicationStatus } from '../model/status' export type GetJobPostResumeRequest = PaginationParams<{ status?: ApplicationStatus + jobPostId?: number }> type GetJobPostResumeResponse = Pagination diff --git a/src/entities/job-post/api/create-job-post-draft.ts b/src/entities/job-post/api/create-job-post-draft.ts new file mode 100644 index 00000000..bd502427 --- /dev/null +++ b/src/entities/job-post/api/create-job-post-draft.ts @@ -0,0 +1,39 @@ +'use server' + +import { revalidateTag } from 'next/cache' + +import { getBusinessSession } from 'entities/auth' +import { apiClient, errorHandler, HttpError } from 'shared/api' + +import { CreateJobPost } from '../model/create-job-post' + +const handleSuccess = () => { + revalidateTag('business-job-posts') +} + +const handleError = (error: Error) => { + const isHttpError = error instanceof HttpError + if (!isHttpError) throw error + + return errorHandler.toast('An error occurred while creating job post draft', { + error, + }) +} + +export const createJobPostDraft = async (jobPost: CreateJobPost) => { + const { accessToken } = await getBusinessSession() + + try { + await apiClient.post({ + endpoint: '/job-posts/draft', + option: { + authorization: `Bearer ${accessToken}`, + }, + body: jobPost, + }) + + handleSuccess() + } catch (error) { + return handleError(error as Error) + } +} diff --git a/src/entities/job-post/api/create-job-post.ts b/src/entities/job-post/api/create-job-post.ts new file mode 100644 index 00000000..82756ff4 --- /dev/null +++ b/src/entities/job-post/api/create-job-post.ts @@ -0,0 +1,39 @@ +'use server' + +import { revalidateTag } from 'next/cache' + +import { getBusinessSession } from 'entities/auth' +import { apiClient, errorHandler, HttpError } from 'shared/api' + +import { CreateJobPost } from '../model/create-job-post' + +const handleSuccess = () => { + revalidateTag('business-job-posts') +} + +const handleError = (error: Error) => { + const isHttpError = error instanceof HttpError + if (!isHttpError) throw error + + return errorHandler.toast('An error occurred while creating job post', { + error, + }) +} + +export const createJobPost = async (jobPost: CreateJobPost) => { + const { accessToken } = await getBusinessSession() + + try { + await apiClient.post({ + endpoint: '/job-posts', + option: { + authorization: `Bearer ${accessToken}`, + }, + body: jobPost, + }) + + handleSuccess() + } catch (error) { + return handleError(error as Error) + } +} diff --git a/src/entities/job-post/api/update-job-post-draft.ts b/src/entities/job-post/api/update-job-post-draft.ts new file mode 100644 index 00000000..4bf635f3 --- /dev/null +++ b/src/entities/job-post/api/update-job-post-draft.ts @@ -0,0 +1,47 @@ +'use server' + +import { revalidateTag } from 'next/cache' + +import { getBusinessSession } from 'entities/auth' +import { apiClient, errorHandler, HttpError } from 'shared/api' + +import { CreateJobPost } from '../model/create-job-post' + +type UpdateJobPostDraftRequest = { + jobPostId: number + jobPost: CreateJobPost +} + +const handleSuccess = () => { + revalidateTag('business-job-posts') +} + +const handleError = (error: Error) => { + const isHttpError = error instanceof HttpError + if (!isHttpError) throw error + + return errorHandler.toast('An error occurred while updating job post draft', { + error, + }) +} + +export const updateJobPostDraft = async ({ + jobPostId, + jobPost, +}: UpdateJobPostDraftRequest) => { + const { accessToken } = await getBusinessSession() + + try { + await apiClient.put({ + endpoint: `/job-posts/${jobPostId}/draft`, + option: { + authorization: `Bearer ${accessToken}`, + }, + body: jobPost, + }) + + handleSuccess() + } catch (error) { + return handleError(error as Error) + } +} diff --git a/src/entities/job-post/api/update-job-post.ts b/src/entities/job-post/api/update-job-post.ts new file mode 100644 index 00000000..e166da9e --- /dev/null +++ b/src/entities/job-post/api/update-job-post.ts @@ -0,0 +1,47 @@ +'use server' + +import { revalidateTag } from 'next/cache' + +import { getBusinessSession } from 'entities/auth' +import { apiClient, errorHandler, HttpError } from 'shared/api' + +import { CreateJobPost } from '../model/create-job-post' + +type UpdateJobPostRequest = { + jobPostId: number + jobPost: CreateJobPost +} + +const handleSuccess = () => { + revalidateTag('business-job-posts') +} + +const handleError = (error: Error) => { + const isHttpError = error instanceof HttpError + if (!isHttpError) throw error + + return errorHandler.toast('An error occurred while updating job post', { + error, + }) +} + +export const updateJobPost = async ({ + jobPostId, + jobPost, +}: UpdateJobPostRequest) => { + const { accessToken } = await getBusinessSession() + + try { + await apiClient.put({ + endpoint: `/job-posts/${jobPostId}`, + option: { + authorization: `Bearer ${accessToken}`, + }, + body: jobPost, + }) + + handleSuccess() + } catch (error) { + return handleError(error as Error) + } +} diff --git a/src/entities/job-post/index.ts b/src/entities/job-post/index.ts index 1c0427f9..fbedb5b0 100644 --- a/src/entities/job-post/index.ts +++ b/src/entities/job-post/index.ts @@ -20,3 +20,7 @@ export { PostingImageSwiper } from './ui/posting-image-swiper' export { PostingTitle } from './ui/posting-title' export { copyJobPost } from './api/copy-job-post' export type { CreateJobPost } from './model/create-job-post' +export { createJobPost } from './api/create-job-post' +export { createJobPostDraft } from './api/create-job-post-draft' +export { updateJobPost } from './api/update-job-post' +export { updateJobPostDraft } from './api/update-job-post-draft' diff --git a/src/entities/job-post/model/job-post.ts b/src/entities/job-post/model/job-post.ts index e050193a..b46532a3 100644 --- a/src/entities/job-post/model/job-post.ts +++ b/src/entities/job-post/model/job-post.ts @@ -19,6 +19,7 @@ export type BusinessJobPost = { id: number title: string dueDate: string + openDate: string createdAt: string salary: number resumeCount: number diff --git a/src/entities/job-post/ui/posting-detail/index.tsx b/src/entities/job-post/ui/posting-detail/index.tsx index dbcd142a..38a3cfc0 100644 --- a/src/entities/job-post/ui/posting-detail/index.tsx +++ b/src/entities/job-post/ui/posting-detail/index.tsx @@ -1,3 +1,5 @@ +import { useLocale } from 'next-intl' + import { formatDate, formatCurrency, toDisplayValue } from 'shared/lib' import type { JobPostDetail } from '../../model/job-post-detail' @@ -8,6 +10,10 @@ type Props = { } export const PostingDetail = ({ jobPost }: Props) => { + const local = useLocale() + + const code = local === 'ko' ? '만원' : 'in 10,000 KRW' + return (
  • @@ -51,9 +57,7 @@ export const PostingDetail = ({ jobPost }: Props) => {
  • Salary

    - {toDisplayValue( - formatCurrency({ number: jobPost.salary, code: 'KRW' }), - )} + {toDisplayValue(formatCurrency({ number: jobPost.salary, code }))}

  • diff --git a/src/entities/user/api/change-password.ts b/src/entities/user/api/change-password.ts index c4707c00..f36067b0 100644 --- a/src/entities/user/api/change-password.ts +++ b/src/entities/user/api/change-password.ts @@ -1,6 +1,6 @@ 'use server' -import { getTeacherSession } from 'entities/auth' +import { getBusinessSession, getTeacherSession } from 'entities/auth' import { apiClient, AuthExceptionCode, @@ -50,3 +50,21 @@ export const changePassword = async (data: ChangePasswordRequest) => { return handleError(error as HttpError) } } + +export const changeBusinessUserPassword = async ( + data: ChangePasswordRequest, +) => { + const { accessToken } = await getBusinessSession() + + try { + await apiClient.put({ + endpoint: '/users/me/password', + option: { + authorization: `Bearer ${accessToken}`, + }, + body: data, + }) + } catch (error) { + return handleError(error as HttpError) + } +} diff --git a/src/entities/user/api/get-user-me.ts b/src/entities/user/api/get-user-me.ts index 1fc414c8..95f4ca36 100644 --- a/src/entities/user/api/get-user-me.ts +++ b/src/entities/user/api/get-user-me.ts @@ -1,6 +1,6 @@ 'use server' -import { getTeacherSession } from 'entities/auth' +import { getBusinessSession, getTeacherSession } from 'entities/auth' import { apiClient } from 'shared/api' import { User } from '../model/user' @@ -20,3 +20,17 @@ export const getUserMe = async () => { return response } + +export const getBusinessUserMe = async () => { + const { accessToken } = await getBusinessSession() + + const response = await apiClient.get({ + endpoint: '/users/me', + option: { + authorization: `Bearer ${accessToken}`, + tags: ['user-me'], + }, + }) + + return response +} diff --git a/src/entities/user/index.ts b/src/entities/user/index.ts index 4dc27eba..9535df45 100644 --- a/src/entities/user/index.ts +++ b/src/entities/user/index.ts @@ -1,7 +1,10 @@ -export { changePassword } from './api/change-password' +export { + changePassword, + changeBusinessUserPassword, +} from './api/change-password' export { deleteProfileImage } from './api/delete-profile-image' export { deleteUserMe } from './api/delete-user-me' -export { getUserMe } from './api/get-user-me' +export { getUserMe, getBusinessUserMe } from './api/get-user-me' export { updateProfileImage } from './api/update-profile-image' export { updateUserMe } from './api/update-user-me' export type { UpdateUserMeRequest } from './api/update-user-me' diff --git a/src/features/academy-detail-form/index.ts b/src/features/academy-detail-form/index.ts new file mode 100644 index 00000000..05020920 --- /dev/null +++ b/src/features/academy-detail-form/index.ts @@ -0,0 +1,3 @@ +export { AcademyDetailForm } from './ui/academy-detail-form' +export { SidePanel } from './ui/side-panel' +export { convertToFormValues } from './model/form-values' diff --git a/src/features/academy-detail-form/lib/use-geocoding.ts b/src/features/academy-detail-form/lib/use-geocoding.ts new file mode 100644 index 00000000..e768d9ca --- /dev/null +++ b/src/features/academy-detail-form/lib/use-geocoding.ts @@ -0,0 +1,33 @@ +import { useMapsLibrary } from '@vis.gl/react-google-maps' +import { useEffect, useRef } from 'react' + +export const useGeocoding = () => { + const lib = useMapsLibrary('geocoding') + const geocoder = useRef(null) + + useEffect(() => { + if (lib) { + geocoder.current = new lib.Geocoder() + } + }, [lib]) + + const geocode = async ( + address: string, + callback: (result: { lat: number; lng: number }) => void, + ) => { + if (!geocoder.current) return null + + await geocoder.current.geocode({ address }, (results, status) => { + if (status === 'OK' && results?.[0]) { + const { location } = results[0].geometry + + callback({ + lat: location.lat(), + lng: location.lng(), + }) + } + }) + } + + return { geocode } +} diff --git a/src/features/academy-detail-form/lib/util.ts b/src/features/academy-detail-form/lib/util.ts new file mode 100644 index 00000000..2581f70a --- /dev/null +++ b/src/features/academy-detail-form/lib/util.ts @@ -0,0 +1,42 @@ +import { Location } from 'entities/job-post' + +export const convertToLocationType = (sido: string) => { + switch (sido) { + case '서울': + return Location.SEOUL + case '부산': + return Location.BUSAN + case '대구': + return Location.DAEGU + case '인천': + return Location.INCHEON + case '광주': + return Location.GWANGJU + case '대전': + return Location.DAEJEON + case '울산': + return Location.ULSAN + case '세종특별자치시': + return Location.SEJONG + case '경기': + return Location.GYEONGGI + case '강원특별자치도': + return Location.GANGWON + case '충북': + return Location.CHUNGBUK + case '충남': + return Location.CHUNGNAM + case '전북특별자치도': + return Location.JEONBUK + case '전남': + return Location.JEONNAM + case '경북': + return Location.GYEONGBUK + case '경남': + return Location.GYEONGNAM + case '제주특별자치도': + return Location.JEJU + default: + return null + } +} diff --git a/src/features/academy-detail-form/model/form-values.ts b/src/features/academy-detail-form/model/form-values.ts new file mode 100644 index 00000000..f9dce0d2 --- /dev/null +++ b/src/features/academy-detail-form/model/form-values.ts @@ -0,0 +1,105 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import { isNil } from 'lodash-es' + +import type { AcademyDetail, UpdateAcademyDetail } from 'entities/academy' +import { Location } from 'entities/auth' +import { convertStudentType } from 'entities/job-post' + +export type FormValues = { + name: string + nameEn: string + representativeName: string + description: string + studentType: string[] | null + images: { + image: File | null + url: string | null + }[] + locationType: Location | null + address: string + detailedAddress: string + lat: number | null + lng: number | null + businessRegistrationNumber: string +} + +export const convertToFormValues = (academyDetail: AcademyDetail) => { + return { + name: academyDetail.name, + nameEn: academyDetail.nameEn, + representativeName: academyDetail.representativeName, + description: academyDetail.description ?? '', + studentType: convertStudentType({ + forKindergarten: academyDetail.forKindergarten, + forElementary: academyDetail.forElementary, + forMiddleSchool: academyDetail.forMiddleSchool, + forHighSchool: academyDetail.forHighSchool, + forAdult: academyDetail.forAdult, + }), + images: [ + { + image: null, + url: null, + }, + ], + locationType: academyDetail.locationType, + address: academyDetail.detailedAddress, + detailedAddress: academyDetail.detailedAddress, + lat: academyDetail.lat, + lng: academyDetail.lng, + businessRegistrationNumber: + academyDetail.businessRegistrationNumber.replace( + /(\d{3})(\d{2})(\d{5})/, + '$1-$2-$3', + ), + } +} + +export const convertToUpdateAcademyDetail = ({ + studentType, + lat, + lng, + locationType, + address, + detailedAddress, + businessRegistrationNumber, + ...restFormValues +}: FormValues): UpdateAcademyDetail => { + return { + ...restFormValues, + detailedAddress, + lat: lat!, + lng: lng!, + locationType: locationType!, + images: restFormValues.images + .filter(({ image }) => image) + .map(({ image }) => image!), + forKindergarten: studentType?.includes('Kindergarten') ?? false, + forElementary: studentType?.includes('Elementary') ?? false, + forMiddleSchool: studentType?.includes('MiddleSchool') ?? false, + forHighSchool: studentType?.includes('HighSchool') ?? false, + forAdult: studentType?.includes('Adult') ?? false, + } +} + +export const canRegisterForm = ( + formValues: Pick< + FormValues, + | 'representativeName' + | 'name' + | 'nameEn' + | 'address' + | 'detailedAddress' + | 'description' + | 'studentType' + | 'images' + >, +) => { + return ( + formValues.description && + !isNil(formValues.studentType) && + formValues.studentType.length > 0 && + formValues.images.filter(({ image }) => image).length > 0 + ) +} diff --git a/src/features/academy-detail-form/model/rules.ts b/src/features/academy-detail-form/model/rules.ts new file mode 100644 index 00000000..b9635924 --- /dev/null +++ b/src/features/academy-detail-form/model/rules.ts @@ -0,0 +1,77 @@ +export const representativeName = { + required: 'validation.representativeName.required', + maxLength: { + value: 10, + message: 'validation.representativeName.maxLength', + }, + pattern: { + value: /^[가-힣]+$/, + message: 'validation.representativeName.pattern', + }, +} + +export const academyName = { + required: 'validation.academyName.required', + maxLength: { + value: 30, + message: 'validation.academyName.maxLength', + }, +} + +export const academyNameEn = { + required: 'validation.academyNameEn.required', + maxLength: { + value: 30, + message: 'validation.academyNameEn.maxLength', + }, +} + +export const address = { + required: 'validation.address.required', +} + +export const detailedAddress = { + required: 'validation.detailedAddress.required', + maxLength: { + value: 100, + message: 'validation.detailedAddress.maxLength', + }, +} + +export const businessRegistrationNumber = { + required: 'validation.businessRegistrationNumber.required', + minLength: { + value: 10, + message: 'validation.businessRegistrationNumber.length', + }, + maxLength: { + value: 10, + message: 'validation.businessRegistrationNumber.length', + }, + pattern: { + value: /^\d+$/, + message: 'validation.businessRegistrationNumber.pattern', + }, +} + +export const description = { + required: 'validation.description.required', + maxLength: { + value: 1000, + message: 'validation.description.maxLength', + }, +} + +export const studentType = { + required: true, +} + +export const images = { + validate: (value: { image: File | null; url: string | null }[]) => { + if (value.length === 0 || value.every(({ image }) => image === null)) { + return 'validation.images.required' + } + + return true + }, +} diff --git a/src/features/academy-detail-form/ui/academy-detail-form.tsx b/src/features/academy-detail-form/ui/academy-detail-form.tsx new file mode 100644 index 00000000..186cc3a3 --- /dev/null +++ b/src/features/academy-detail-form/ui/academy-detail-form.tsx @@ -0,0 +1,31 @@ +'use client' + +import { cn } from 'shared/lib' + +import { AcademyName } from './field/academy-name' +import { AcademyNameEn } from './field/academy-name-en' +import { Address } from './field/address' +import { BusinessRegistrationNumber } from './field/business-registration-number' +import { Description } from './field/description' +import { Images } from './field/images' +import { RepresentativeName } from './field/representative-name' +import { StudentType } from './field/student-type' + +type Props = { + className?: string +} + +export const AcademyDetailForm = ({ className }: Props) => { + return ( +
    + + + + +
    + + + +
    + ) +} diff --git a/src/features/academy-detail-form/ui/field/academy-name-en.tsx b/src/features/academy-detail-form/ui/field/academy-name-en.tsx new file mode 100644 index 00000000..53c28a13 --- /dev/null +++ b/src/features/academy-detail-form/ui/field/academy-name-en.tsx @@ -0,0 +1,23 @@ +import { useTranslations } from 'next-intl' + +import { fieldCss, Form } from 'shared/form' +import { Label } from 'shared/ui' + +import { academyNameEn as academyNameEnRule } from '../../model/rules' + +export const AcademyNameEn = () => { + const t = useTranslations() + + return ( +
    + + + + + +
    + ) +} diff --git a/src/features/academy-detail-form/ui/field/academy-name.tsx b/src/features/academy-detail-form/ui/field/academy-name.tsx new file mode 100644 index 00000000..330e2d70 --- /dev/null +++ b/src/features/academy-detail-form/ui/field/academy-name.tsx @@ -0,0 +1,23 @@ +import { useTranslations } from 'next-intl' + +import { fieldCss, Form } from 'shared/form' +import { Label } from 'shared/ui' + +import { academyName as academyNameRule } from '../../model/rules' + +export const AcademyName = () => { + const t = useTranslations() + + return ( +
    + + + + + +
    + ) +} diff --git a/src/features/academy-detail-form/ui/field/address.tsx b/src/features/academy-detail-form/ui/field/address.tsx new file mode 100644 index 00000000..af12ccee --- /dev/null +++ b/src/features/academy-detail-form/ui/field/address.tsx @@ -0,0 +1,75 @@ +import { useTranslations } from 'next-intl' +import { useState } from 'react' +import DaumPostcode, { type Address as AddressType } from 'react-daum-postcode' +import { useFormContext } from 'react-hook-form' + +import { fieldCss, Form } from 'shared/form' +import { Button, Label, Modal } from 'shared/ui' + +import { useGeocoding } from '../../lib/use-geocoding' +import { convertToLocationType } from '../../lib/util' +import { + address as addressRule, + detailedAddress as detailedAddressRule, +} from '../../model/rules' + +export const Address = () => { + const t = useTranslations() + + const { geocode } = useGeocoding() + + const [isOpen, setIsOpen] = useState(false) + + const form = useFormContext() + + const handleButtonClick = () => { + setIsOpen(true) + } + + const handleCompleteSearchingCode = (data: AddressType) => { + form.setValue('address', data.address) + form.setValue('detailedAddress', '') + form.setValue('locationType', convertToLocationType(data.sido)) + + geocode(data.address, ({ lat, lng }) => { + form.setValue('lat', lat) + form.setValue('lng', lng) + }) + + setIsOpen(false) + } + + return ( +
    + +
    + + + + + + + + + +
    + + + + +
    + ) +} diff --git a/src/features/academy-detail-form/ui/field/business-registration-number.tsx b/src/features/academy-detail-form/ui/field/business-registration-number.tsx new file mode 100644 index 00000000..cee14305 --- /dev/null +++ b/src/features/academy-detail-form/ui/field/business-registration-number.tsx @@ -0,0 +1,23 @@ +import { useTranslations } from 'next-intl' + +import { fieldCss, Form } from 'shared/form' +import { Label } from 'shared/ui' + +export const BusinessRegistrationNumber = () => { + const t = useTranslations() + + return ( +
    + + + + + +
    + ) +} diff --git a/src/features/academy-detail-form/ui/field/description.tsx b/src/features/academy-detail-form/ui/field/description.tsx new file mode 100644 index 00000000..376ed804 --- /dev/null +++ b/src/features/academy-detail-form/ui/field/description.tsx @@ -0,0 +1,18 @@ +import { fieldCss, Form } from 'shared/form' +import { Label } from 'shared/ui' + +export const Description = () => { + return ( +
    + + + + + +
    + ) +} diff --git a/src/features/academy-detail-form/ui/field/images.tsx b/src/features/academy-detail-form/ui/field/images.tsx new file mode 100644 index 00000000..83d9752f --- /dev/null +++ b/src/features/academy-detail-form/ui/field/images.tsx @@ -0,0 +1,60 @@ +import { useFieldArray, useFormContext } from 'react-hook-form' + +import { Label } from 'shared/ui' + +import { FormValues } from '../../model/form-values' +import { ImageUploader } from '../image-uploader' + +export const Images = () => { + const { + control, + formState: { errors }, + } = useFormContext() + + const { fields, append, remove, update } = useFieldArray({ + control, + name: 'images', + }) + + const handleImageChange = (index: number) => (image: File, url: string) => { + update(index, { image, url }) + + const canAddImage = index < 5 + + if (canAddImage) { + append({ image: null, url: null }) + } + } + + const handleImageDelete = (id: number) => () => { + const lastImage = fields.at(-1)?.image + const maxLength = fields.length === 6 + + if (lastImage && maxLength) { + append({ image: null, url: null }) + } + + remove(id) + } + + return ( +
    + +

    + 소개 이미지는 최소 1장 이상 등록해 주세요. +

    +
      + {fields.map((field, index) => ( +
    • + +
    • + ))} +
    +
    + ) +} diff --git a/src/features/academy-detail-form/ui/field/representative-name.tsx b/src/features/academy-detail-form/ui/field/representative-name.tsx new file mode 100644 index 00000000..d99215e2 --- /dev/null +++ b/src/features/academy-detail-form/ui/field/representative-name.tsx @@ -0,0 +1,23 @@ +import { useTranslations } from 'next-intl' + +import { fieldCss, Form } from 'shared/form' +import { Label } from 'shared/ui' + +import { representativeName as representativeNameRule } from '../../model/rules' + +export const RepresentativeName = () => { + const t = useTranslations() + + return ( +
    + + + + + +
    + ) +} diff --git a/src/features/academy-detail-form/ui/field/student-type.tsx b/src/features/academy-detail-form/ui/field/student-type.tsx new file mode 100644 index 00000000..bf53b6b5 --- /dev/null +++ b/src/features/academy-detail-form/ui/field/student-type.tsx @@ -0,0 +1,68 @@ +import { isEqual } from 'lodash-es' +import { useFormContext, useWatch } from 'react-hook-form' + +import { fieldCss, Form } from 'shared/form' +import { Checkbox, Label } from 'shared/ui' + +import { FormValues } from '../../model/form-values' +import * as rules from '../../model/rules' + +export const StudentType = () => { + const { + control, + setValue, + formState: { errors }, + clearErrors, + } = useFormContext() + + const [studentType] = useWatch({ + control, + name: ['studentType'], + }) + + const studentTypeOptions = [ + 'Kindergarten', + 'Elementary', + 'MiddleSchool', + 'HighSchool', + 'Adult', + ] + + const isAllChecked = isEqual(studentType, studentTypeOptions) + + const handleAllCheckboxClick = () => { + if (isAllChecked) { + setValue('studentType', null) + } else { + setValue('studentType', studentTypeOptions) + } + + clearErrors('studentType') + } + + return ( +
    + +
    + + + + + + + + +
    +
    + ) +} diff --git a/src/features/academy-detail-form/ui/image-uploader.tsx b/src/features/academy-detail-form/ui/image-uploader.tsx new file mode 100644 index 00000000..01d4a6e4 --- /dev/null +++ b/src/features/academy-detail-form/ui/image-uploader.tsx @@ -0,0 +1,113 @@ +'use client' + +import { useRef, useState } from 'react' + +import { ImageUploadInput } from 'features/upload-image' +import { colors } from 'shared/config' +import { cn, isNilOrEmptyString } from 'shared/lib' +import { Icon, Image } from 'shared/ui' + +type Props = { + src: string | null + onChange: (file: File, url: string) => void + onDelete?: () => void + className?: string +} + +export const ImageUploader = ({ + src, + onChange, + onDelete, + className, +}: Props) => { + const fileInputRef = useRef(null) + + const [isHovering, setIsHovering] = useState(false) + + const hasImage = !isNilOrEmptyString(src) + + const handleUploadButtonClick = () => { + fileInputRef.current?.click() + } + + const handleFileChange = async (file: File) => { + const reader = new FileReader() + reader.readAsDataURL(file) + + reader.onload = event => { + if (reader.readyState === 2) { + const url = event.target?.result + + if (url) { + onChange(file, url as string) + } + } + } + } + + const handleMouseEnter = () => { + setIsHovering(true) + } + + const handleMouseLeave = () => { + setIsHovering(false) + } + + const handleDeleteButtonClick = async () => { + onDelete?.() + } + + return ( +
    + + + {(() => { + if (hasImage) { + return ( +
    + academy image + {isHovering && ( +
    + +
    + )} +
    + ) + } + + return ( + + ) + })()} +
    + ) +} diff --git a/src/features/academy-detail-form/ui/side-panel.tsx b/src/features/academy-detail-form/ui/side-panel.tsx new file mode 100644 index 00000000..d7aa91db --- /dev/null +++ b/src/features/academy-detail-form/ui/side-panel.tsx @@ -0,0 +1,91 @@ +import { useFormContext, useWatch } from 'react-hook-form' +import { toast } from 'react-toastify' + +import { updateAcademyMe } from 'entities/academy' +import { isServerError, useServerErrorHandler } from 'shared/api' +import { Button } from 'shared/ui' + +import { + canRegisterForm, + convertToUpdateAcademyDetail, + FormValues, +} from '../model/form-values' + +export const SidePanel = () => { + const { handleSubmit, control } = useFormContext() + + const { handleServerError } = useServerErrorHandler() + + const [ + representativeName, + name, + nameEn, + address, + detailedAddress, + description, + studentType, + images, + ] = useWatch({ + control, + name: [ + 'representativeName', + 'name', + 'nameEn', + 'address', + 'detailedAddress', + 'description', + 'studentType', + 'images', + ], + }) + + const handleRegisterSuccess = () => { + toast.success('정보를 저장했어요') + } + + const submitForm = async (data: FormValues) => { + const response = await updateAcademyMe(convertToUpdateAcademyDetail(data)) + + if (isServerError(response)) { + handleServerError(response) + } else { + handleRegisterSuccess() + } + } + + return ( +
    +

    작성에 유의해 주세요

    +
    +

    + 입력한 정보는 검색에 반영돼요. +

    +

    + 중요한 정보를 빠뜨리지 않았는지 확인해 주세요. +

    +
    +
    + +
    +
    + ) +} diff --git a/src/features/job-posting-form/index.ts b/src/features/job-posting-form/index.ts index de87618b..5a7d8aec 100644 --- a/src/features/job-posting-form/index.ts +++ b/src/features/job-posting-form/index.ts @@ -1,2 +1,9 @@ export { JobPostingForm } from './ui/job-posting-form' export { SidePanel } from './ui/side-panel' +export { + convertToJobDetail, + convertToCreateJobPostDTO, + type FormValues, + defaultValues, + convertToFormValues, +} from './model/form-values' diff --git a/src/features/job-posting-form/model/form-values.ts b/src/features/job-posting-form/model/form-values.ts new file mode 100644 index 00000000..e01e7cee --- /dev/null +++ b/src/features/job-posting-form/model/form-values.ts @@ -0,0 +1,138 @@ +import { isValid, parse } from 'date-fns' +import { isArray, isNil } from 'lodash-es' + +import { AcademyDetail } from 'entities/academy' +import { + convertStudentTypeToArray, + CreateJobPost, + JobPostDetail, +} from 'entities/job-post' +import { isNilOrEmptyString } from 'shared/lib' + +export type FormValues = { + title: string + jobDescription: string + requiredQualification: string + preferredQualification: string + benefits: string + salary: number | null + salaryNegotiable: string[] + jobStartDate?: string + dueDate?: string | null + noExpirationDate: string[] + studentType: string[] | null +} + +export const defaultValues: FormValues = { + title: '', + jobDescription: '', + requiredQualification: '', + preferredQualification: '', + benefits: '', + salary: null, + salaryNegotiable: [], + jobStartDate: undefined, + dueDate: undefined, + studentType: null, + noExpirationDate: [], +} + +export const convertToFormValues = (jobPost?: JobPostDetail): FormValues => { + if (isNil(jobPost)) return defaultValues + + return { + title: jobPost.title, + jobDescription: jobPost.jobDescription, + requiredQualification: jobPost.requiredQualification, + preferredQualification: jobPost.preferredQualification, + benefits: jobPost.benefits, + salary: jobPost.salary, + salaryNegotiable: jobPost.salaryNegotiable ? ['true'] : [], + jobStartDate: isNilOrEmptyString(jobPost.jobStartDate) + ? undefined + : jobPost.jobStartDate, + dueDate: isNilOrEmptyString(jobPost.dueDate) ? undefined : jobPost.dueDate, + noExpirationDate: isNil(jobPost.dueDate) ? ['true'] : [], + studentType: convertStudentTypeToArray({ + forKindergarten: jobPost.forKindergarten, + forElementary: jobPost.forElementary, + forMiddleSchool: jobPost.forMiddleSchool, + forHighSchool: jobPost.forHighSchool, + forAdult: jobPost.forAdult, + }), + } +} + +export const convertToCreateJobPostDTO = ({ + studentType, + noExpirationDate, + ...formValues +}: FormValues): CreateJobPost => { + return { + ...formValues, + salary: Number(formValues.salary), + forKindergarten: studentType?.includes('Kindergarten') ?? false, + forElementary: studentType?.includes('Elementary') ?? false, + forMiddleSchool: studentType?.includes('MiddleSchool') ?? false, + forHighSchool: studentType?.includes('HighSchool') ?? false, + forAdult: studentType?.includes('Adult') ?? false, + dueDate: noExpirationDate.includes('true') + ? null + : formValues.dueDate || null, + jobStartDate: formValues.jobStartDate || '', + salaryNegotiable: + isArray(formValues.salaryNegotiable) && + formValues.salaryNegotiable.includes('true'), + } +} + +export const convertToJobDetail = ( + jobPost: CreateJobPost, + academy: AcademyDetail, +): JobPostDetail => { + return { + ...jobPost, + academyId: academy.id, + academyName: academy.name, + academyNameEn: academy.nameEn, + academyRepresentativeName: academy.representativeName, + academyDescription: academy.description, + academyLocationType: academy.locationType, + academyDetailedAddress: academy.detailedAddress, + lat: academy.lat, + lng: academy.lng, + academyImageUrls: academy.imageUrls, + id: academy.id, + } +} + +export const canRegisterForm = ( + formValues: Pick< + FormValues, + | 'title' + | 'jobDescription' + | 'salary' + | 'studentType' + | 'dueDate' + | 'noExpirationDate' + >, +) => { + const isDateString = (str?: string | null) => { + if (isNil(str)) return false + + const parsedDate = parse(str, 'yyyy-MM-dd', new Date()) + + return isValid(parsedDate) + } + + return ( + formValues.title && + formValues.jobDescription && + formValues.salary && + !isNil(formValues.studentType) && + formValues.studentType.length > 0 && + ((formValues.noExpirationDate.includes('true') && + isNil(formValues.dueDate)) || + isDateString(formValues.dueDate)) + ) +} diff --git a/src/features/job-posting-form/model/rules.ts b/src/features/job-posting-form/model/rules.ts new file mode 100644 index 00000000..1784bf58 --- /dev/null +++ b/src/features/job-posting-form/model/rules.ts @@ -0,0 +1,72 @@ +import { isNull } from 'lodash-es' + +export const title = { + required: 'validation.jobPostingTitle.required', + maxLength: { + value: 100, + message: 'validation.jobPostingTitle.maxLength', + }, +} + +export const jobDescription = { + required: 'validation.jobPostingDescription.required', + maxLength: { + value: 1000, + message: 'validation.jobPostingDescription.maxLength', + }, +} + +export const requiredQualification = { + maxLength: { + value: 1000, + message: 'validation.jobPostingRequiredQualification.maxLength', + }, +} + +export const preferredQualification = { + maxLength: { + value: 1000, + message: 'validation.jobPostingPreferredQualification.maxLength', + }, +} + +export const benefits = { + maxLength: { + value: 1000, + message: 'validation.jobPostingBenefits.maxLength', + }, +} + +export const salary = { + required: 'validation.jobPostingSalary.required', + validate: (value: string) => { + const isNumber = /^\d+$/.test(value) + + if (!isNumber) { + return 'validation.jobPostingSalary.invalid' + } + + const salary = value.toString().replace(/,/g, '') + + if (salary.length > 10) { + return 'validation.jobPostingSalary.maxLength' + } + + return true + }, +} + +export const studentType = { + required: true, +} + +export const dueDate = { + validate: (value: string) => { + if (isNull(value)) return true + + if (value === '' || value === undefined) + return 'validation.jobPostingDueDate.required' + + return true + }, +} diff --git a/src/features/job-posting-form/ui/job-posting-form.tsx b/src/features/job-posting-form/ui/job-posting-form.tsx index 75836e73..5fcd9e8b 100644 --- a/src/features/job-posting-form/ui/job-posting-form.tsx +++ b/src/features/job-posting-form/ui/job-posting-form.tsx @@ -1,14 +1,31 @@ +import { isEqual } from 'lodash-es' +import { useFormContext, useWatch } from 'react-hook-form' + import { fieldCss, Form } from 'shared/form' import { cn, Slot } from 'shared/lib' -import { Checkbox, Label } from 'shared/ui' +import { Checkbox, CheckboxValue, Label } from 'shared/ui' + +import { FormValues } from '../model/form-values' +import * as rules from '../model/rules' type Props = { className?: string } export const JobPostingForm = ({ className }: Props) => { + const { + control, + setValue, + formState: { errors }, + clearErrors, + } = useFormContext() + + const [studentType, noExpirationDate] = useWatch({ + control, + name: ['studentType', 'noExpirationDate'], + }) + const studentTypeOptions = [ - 'All', 'Kindergarten', 'Elementary', 'MiddleSchool', @@ -16,11 +33,33 @@ export const JobPostingForm = ({ className }: Props) => { 'Adult', ] + const isAllChecked = isEqual(studentType, studentTypeOptions) + + const handleAllCheckboxClick = () => { + if (isAllChecked) { + setValue('studentType', null) + } else { + setValue('studentType', studentTypeOptions) + } + + clearErrors('studentType') + } + + const handleExpirationDateChange = (value: CheckboxValue[]) => { + if (value.includes('true')) { + setValue('dueDate', null) + } else { + setValue('dueDate', undefined) + } + + clearErrors('dueDate') + } + return (
    - + {
    - + {
    - + {
    - + {
    - + {
    - + - KRW + 만원 @@ -93,8 +138,18 @@ export const JobPostingForm = ({ className }: Props) => {
    - - + + @@ -110,6 +165,7 @@ export const JobPostingForm = ({ className }: Props) => { @@ -117,14 +173,27 @@ export const JobPostingForm = ({ className }: Props) => {
    - + - + + + + + + -
    ) diff --git a/src/features/job-posting-form/ui/side-panel.tsx b/src/features/job-posting-form/ui/side-panel.tsx index f7379e69..94c01374 100644 --- a/src/features/job-posting-form/ui/side-panel.tsx +++ b/src/features/job-posting-form/ui/side-panel.tsx @@ -1,12 +1,48 @@ +import { isEqual } from 'lodash-es' +import { useFormContext, useWatch } from 'react-hook-form' + import { Button } from 'shared/ui' +import { + canRegisterForm, + defaultValues, + FormValues, +} from '../model/form-values' + type Props = { type: 'register' | 'update' onRegister?: () => void onSave?: () => void } -export const SidePanel = ({ type }: Props) => { +export const SidePanel = ({ type, onRegister, onSave }: Props) => { + const { handleSubmit, control, getValues } = useFormContext() + + const [ + title, + jobDescription, + salary, + studentType, + dueDate, + noExpirationDate, + ] = useWatch({ + control, + name: [ + 'title', + 'jobDescription', + 'salary', + 'studentType', + 'dueDate', + 'noExpirationDate', + ], + }) + + const canSave = isEqual(getValues(), defaultValues) + + const submitForm = handleSubmit(() => { + onRegister?.() + }) + return (

    @@ -21,11 +57,32 @@ export const SidePanel = ({ type }: Props) => {

    - {type === 'register' && ( - )} diff --git a/src/features/sign-up/ui/field/birth-date.tsx b/src/features/sign-up/ui/field/birth-date.tsx index 5dc1d650..26944a5d 100644 --- a/src/features/sign-up/ui/field/birth-date.tsx +++ b/src/features/sign-up/ui/field/birth-date.tsx @@ -12,7 +12,10 @@ export const BirthDate = () => {
    - +
    diff --git a/src/pages/academy-detail/index.ts b/src/pages/academy-detail/index.ts new file mode 100644 index 00000000..d2f7cb8a --- /dev/null +++ b/src/pages/academy-detail/index.ts @@ -0,0 +1 @@ +export { AcademyDetailPage } from './ui/academy-detail-page' diff --git a/src/pages/academy-detail/ui/academy-detail-page.tsx b/src/pages/academy-detail/ui/academy-detail-page.tsx new file mode 100644 index 00000000..1488eae1 --- /dev/null +++ b/src/pages/academy-detail/ui/academy-detail-page.tsx @@ -0,0 +1,36 @@ +'use client' + +import { useForm } from 'react-hook-form' + +import { AcademyDetail } from 'entities/academy' +import { + AcademyDetailForm, + SidePanel, + convertToFormValues, +} from 'features/academy-detail-form' +import { Form } from 'shared/form' +import { Layout } from 'shared/ui' + +type Props = { + academyDetail: AcademyDetail +} + +export const AcademyDetailPage = ({ academyDetail }: Props) => { + const form = useForm({ + defaultValues: convertToFormValues(academyDetail), + }) + + return ( + +

    + 학원 상세 정보 +

    +
    + +
    + +
    + +
    + ) +} diff --git a/src/pages/business-job-posting/api/use-job-post-relations.ts b/src/pages/business-job-posting/api/use-job-post-relations.ts new file mode 100644 index 00000000..64a36503 --- /dev/null +++ b/src/pages/business-job-posting/api/use-job-post-relations.ts @@ -0,0 +1,31 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query' + +import { + ApplicationStatus, + jobPostResumeRelationQueries, +} from 'entities/job-post-resume-relation' + +export const useJobPostRelations = ({ + status, + pageNumber, + jobPostId, +}: { + status: ApplicationStatus + pageNumber: number + jobPostId?: number +}) => { + const { data } = useQuery({ + ...jobPostResumeRelationQueries.businessList({ + pageNumber, + rowCount: 10, + status, + jobPostId, + }), + placeholderData: keepPreviousData, + }) + + return { + applications: data?.content ?? [], + totalPages: data?.totalPages === 0 ? 1 : data?.totalPages, + } +} diff --git a/src/pages/business-job-posting/index.ts b/src/pages/business-job-posting/index.ts index 148caffa..e541d430 100644 --- a/src/pages/business-job-posting/index.ts +++ b/src/pages/business-job-posting/index.ts @@ -1,3 +1,6 @@ export { CreateJobPostingPage } from './ui/create-job-posting-page' +export { UpdateJobPostingDraftPage } from './ui/update-job-posting-draft-page' export { BusinessJobPostingListPage } from './ui/job-posting-list-page' export { UpdateJobPostingPage } from './ui/update-job-posting-page' +export { JobPostApplicantManagementListPage } from './ui/job-post-applicant-management-list-page' +export { JobPostApplicantManagementDetailPage } from './ui/job-post-applicant-management-detail-page' diff --git a/src/pages/business-job-posting/ui/create-job-posting-page.tsx b/src/pages/business-job-posting/ui/create-job-posting-page.tsx index 42b69019..227b51c0 100644 --- a/src/pages/business-job-posting/ui/create-job-posting-page.tsx +++ b/src/pages/business-job-posting/ui/create-job-posting-page.tsx @@ -1,14 +1,79 @@ 'use client' +import { useQuery } from '@tanstack/react-query' +import { useRouter } from 'next/navigation' import { useForm } from 'react-hook-form' +import { toast } from 'react-toastify' -import { JobPostingForm, SidePanel } from 'features/job-posting-form' +import { academyQueries } from 'entities/academy' +import { createJobPost, createJobPostDraft } from 'entities/job-post' +import { + defaultValues, + JobPostingForm, + SidePanel, +} from 'features/job-posting-form' +import { + convertToCreateJobPostDTO, + convertToJobDetail, + FormValues, +} from 'features/job-posting-form' import { PreviewJobPostingButton } from 'features/preview-job-posting' +import { isServerError, useServerErrorHandler } from 'shared/api' import { Form } from 'shared/form' import { Layout } from 'shared/ui' export const CreateJobPostingPage = () => { - const form = useForm() + const router = useRouter() + + const { data: academyMe } = useQuery(academyQueries.me()) + + const { handleServerError } = useServerErrorHandler() + + const form = useForm({ + defaultValues, + reValidateMode: 'onSubmit', + }) + + const getJobPosting = async () => { + const values = form.getValues() + const createJobPost = convertToCreateJobPostDTO(values) + + return convertToJobDetail(createJobPost, academyMe!) + } + + const handleRegisterJobPostingSuccess = () => { + router.push('/business/job-posting') + toast.success('공고를 등록했어요') + } + + const registerJobPosting = async () => { + const values = form.getValues() + + const response = await createJobPost(convertToCreateJobPostDTO(values)) + + if (isServerError(response)) { + handleServerError(response) + } else { + handleRegisterJobPostingSuccess() + } + } + + const handleSaveJobPostingDraftSuccess = () => { + router.push('/business/job-posting') + toast.success('공고를 임시 저장했어요') + } + + const saveJobPostingDraft = async () => { + const values = form.getValues() + + const response = await createJobPostDraft(convertToCreateJobPostDTO(values)) + + if (isServerError(response)) { + handleServerError(response) + } else { + handleSaveJobPostingDraftSuccess() + } + } return ( @@ -18,10 +83,15 @@ export const CreateJobPostingPage = () => {
    - +
    diff --git a/src/pages/business-job-posting/ui/job-post-applicant-management-detail-page.tsx b/src/pages/business-job-posting/ui/job-post-applicant-management-detail-page.tsx new file mode 100644 index 00000000..fdeae252 --- /dev/null +++ b/src/pages/business-job-posting/ui/job-post-applicant-management-detail-page.tsx @@ -0,0 +1,31 @@ +import { getBusinessJobPostResumeRelation } from 'entities/job-post-resume-relation' +import { Layout } from 'shared/ui' +import { FileResume, FormResume } from 'widgets/application-resume' + +type Params = { + jobPostResumeRelationId: string +} + +export const JobPostApplicantManagementDetailPage = async ({ + params, +}: { + params: Promise +}) => { + const { jobPostResumeRelationId } = await params + + const jobPostResumeRelation = await getBusinessJobPostResumeRelation({ + jobPostResumeRelationId: Number(jobPostResumeRelationId), + }) + + const isFileResume = jobPostResumeRelation.filePath !== null + + return ( + + {isFileResume ? ( + + ) : ( + + )} + + ) +} diff --git a/src/pages/business-job-posting/ui/job-post-applicant-management-list-page.tsx b/src/pages/business-job-posting/ui/job-post-applicant-management-list-page.tsx new file mode 100644 index 00000000..0e15d1a9 --- /dev/null +++ b/src/pages/business-job-posting/ui/job-post-applicant-management-list-page.tsx @@ -0,0 +1,163 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { useTranslations } from 'next-intl' +import { useState } from 'react' + +import { ApplicationStatus } from 'entities/job-post-resume-relation' +import { CopyJobPostingButton } from 'features/copy-job-posting' +import { PreviewJobPostingButton } from 'features/preview-job-posting' +import { cn } from 'shared/lib' +import { Layout, Tabs, Table, Pagination, Button } from 'shared/ui' + +import { useJobPostRelations } from '../api/use-job-post-relations' + +type Props = { + title: string + jobPostId: number + isExpired: boolean +} + +export const JobPostApplicantManagementListPage = ({ + title, + jobPostId, + isExpired, +}: Props) => { + const t = useTranslations('applicant-management-list') + + const router = useRouter() + + const [currentPage, setCurrentPage] = useState(0) + const [status, setStatus] = useState( + ApplicationStatus.SUBMITTED, + ) + + const { applications, totalPages } = useJobPostRelations({ + status, + pageNumber: currentPage, + jobPostId: Number(jobPostId), + }) + + const hasNoApplications = applications.length === 0 + + const handlePageChange = ({ selected }: { selected: number }) => { + setCurrentPage(selected) + } + + const handleStatusChange = (value: string) => { + setStatus(value as ApplicationStatus) + } + + const handleItemClick = (id: number) => () => { + router.push(`/business/job-posting/${jobPostId}/applicant-management/${id}`) + } + + const handleEditButtonClick = () => { + router.push(`/business/job-posting/${jobPostId}/update`) + } + + const handleCopySuccess = () => { + router.push(`/business/job-posting`) + } + + return ( + +

    {title}

    + +
    + + {t('tabs.submitted')} + {t('tabs.reviewed')} + {t('tabs.accepted')} + {t('tabs.rejected')} + +
    + + + +
    +
    + +
    + + + + + {t('table.applicant')} + + + {t('table.job-title')} + + + {t('table.memo')} + + + {t('table.application-date')} + + + + {(() => { + if (hasNoApplications) { + return null + } + + return ( + + {applications.map(application => ( + + + {application.resumeFirstName}{' '} + {application.resumeLastName} + + {application.jobPostTitle} + + {application?.academyMemo ?? ''} + + {application.submittedDate} + + ))} + + ) + })()} + + {hasNoApplications && ( +

    + {t('table.no-data')} +

    + )} +
    +
    +
    + +
    + ) +} diff --git a/src/pages/business-job-posting/ui/job-posting-list-page.tsx b/src/pages/business-job-posting/ui/job-posting-list-page.tsx index f70015c7..92e2d2a6 100644 --- a/src/pages/business-job-posting/ui/job-posting-list-page.tsx +++ b/src/pages/business-job-posting/ui/job-posting-list-page.tsx @@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query' import { format } from 'date-fns' import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' +import { type MouseEvent } from 'react' import { useState } from 'react' import { jobPostQueries } from 'entities/job-post' @@ -42,7 +43,20 @@ export const BusinessJobPostingListPage = () => { } const handleItemClick = (id: number) => () => { - router.push(`/business/job-posting/${id}`) + if (status === JobFilter.SAVED) return + + router.push(`/business/job-posting/${id}/applicant-management`) + } + + const handleEditButtonClick = (id: number) => (event: MouseEvent) => { + event.stopPropagation() + event.preventDefault() + + if (status === JobFilter.SAVED) { + router.push(`/business/job-posting/${id}/update/draft`) + } else { + router.push(`/business/job-posting/${id}/update`) + } } const handleCopySuccess = () => { @@ -122,7 +136,10 @@ export const BusinessJobPostingListPage = () => { {jobPost.title} {jobPost.resumeCount}명 @@ -132,9 +149,11 @@ export const BusinessJobPostingListPage = () => { code: '만원', })} - - {jobPost.createdAt - ? format(jobPost.createdAt, 'yyyy.MM.dd') + + {jobPost.openDate + ? format(jobPost.openDate, 'yyyy.MM.dd') : t('table.draft')} { + +
    +
    + ) +} diff --git a/src/pages/my-academy/ui/my-academy.tsx b/src/pages/my-academy/ui/my-academy.tsx new file mode 100644 index 00000000..03d9c41c --- /dev/null +++ b/src/pages/my-academy/ui/my-academy.tsx @@ -0,0 +1,40 @@ +import { getAcademyMe } from 'entities/academy' +import { getBusinessUserMe } from 'entities/user' +import { Button } from 'shared/ui' + +export const MyAcademyPage = async () => { + const academy = await getAcademyMe() + const user = await getBusinessUserMe() + + return ( +
    +
    +

    {academy.name}

    +
    +

    담당자

    +

    {user.firstName}

    +
    +
    +
    + + +
    +
    + ) +} diff --git a/src/pages/my-academy/ui/personal-information-page.tsx b/src/pages/my-academy/ui/personal-information-page.tsx new file mode 100644 index 00000000..629388c2 --- /dev/null +++ b/src/pages/my-academy/ui/personal-information-page.tsx @@ -0,0 +1,69 @@ +'use client' + +import { useForm } from 'react-hook-form' +import { toast } from 'react-toastify' + +import { updateUserMe, User } from 'entities/user' +import { DeleteUserButton } from 'features/delete-account' +import { BirthDate, FullName, Gender } from 'features/sign-up' +import { isServerError, useServerErrorHandler } from 'shared/api' +import { Form } from 'shared/form' +import { Button } from 'shared/ui' + +import { + convertToUpdateUserMeDTO, + convertToUpdateUserMeFormValues, + UpdateUserMeFormValues, +} from '../model/form-values' + +type Props = { + user: User +} + +export const PersonalInformationPage = ({ user }: Props) => { + const form = useForm({ + defaultValues: convertToUpdateUserMeFormValues(user), + reValidateMode: 'onSubmit', + }) + + const { handleServerError } = useServerErrorHandler() + + const handleSuccess = () => { + toast.success('Your personal information has been updated') + } + + const submitForm = (data: UpdateUserMeFormValues) => { + const response = updateUserMe(convertToUpdateUserMeDTO(data)) + + if (isServerError(response)) { + handleServerError(response) + } else { + handleSuccess() + } + } + + return ( +
    +
    +

    + 개인 정보 +

    +
    +
    + + + +
    + +
    + +
    +
    + ) +} diff --git a/src/shared/api/api-client.ts b/src/shared/api/api-client.ts index e9124722..00d2d473 100644 --- a/src/shared/api/api-client.ts +++ b/src/shared/api/api-client.ts @@ -76,7 +76,13 @@ export class ApiClient { const formData = new FormData() Object.entries(body as Record).forEach(([key, value]) => { - formData.append(key, value) + if (isArray(value) && value.some(item => item instanceof File)) { + value.forEach((file: File) => { + formData.append(key, file) + }) + } else { + formData.append(key, value) + } }) requestBody = formData diff --git a/src/shared/config/internationalization/locales/en/validation.json b/src/shared/config/internationalization/locales/en/validation.json index bdf90028..0ec19420 100644 --- a/src/shared/config/internationalization/locales/en/validation.json +++ b/src/shared/config/internationalization/locales/en/validation.json @@ -58,6 +58,31 @@ "required": "Please enter the business registration number", "length": "Please enter 10 digits", "pattern": "Please enter numbers only" + }, + "jobPostingTitle": { + "required": "Please enter the job posting title", + "maxLength": "Please enter less than 100 characters" + }, + "jobPostingDescription": { + "required": "Please enter the job posting description", + "maxLength": "Please enter less than 1000 characters" + }, + "jobPostingRequiredQualification": { + "maxLength": "Please enter less than 1000 characters" + }, + "jobPostingPreferredQualification": { + "maxLength": "Please enter less than 1000 characters" + }, + "jobPostingBenefits": { + "maxLength": "Please enter less than 1000 characters" + }, + "jobPostingSalary": { + "required": "Please enter the salary", + "maxLength": "Please enter less than 10 characters", + "invalid": "Please enter a number only" + }, + "jobPostingDueDate": { + "required": "Please enter the due date" } } } diff --git a/src/shared/config/internationalization/locales/ko/validation.json b/src/shared/config/internationalization/locales/ko/validation.json index 2bec0b5f..6ff93d5e 100644 --- a/src/shared/config/internationalization/locales/ko/validation.json +++ b/src/shared/config/internationalization/locales/ko/validation.json @@ -58,6 +58,31 @@ "required": "사업자 등록번호를 입력해 주세요", "length": "10자를 입력해주세요", "pattern": "숫자만 입력해주세요" + }, + "jobPostingTitle": { + "required": "공고 제목을 입력해 주세요", + "maxLength": "공고 제목은 최대 100자를 초과할 수 없습니다" + }, + "jobPostingDescription": { + "required": "주요 업무를 입력해 주세요", + "maxLength": "주요 업무는 최대 1000자를 초과할 수 없습니다" + }, + "jobPostingRequiredQualification": { + "maxLength": "자격 요건은 최대 1000자를 초과할 수 없습니다" + }, + "jobPostingPreferredQualification": { + "maxLength": "우대 사항은 최대 1000자를 초과할 수 없습니다" + }, + "jobPostingBenefits": { + "maxLength": "혜택/복지는 최대 1000자를 초과할 수 없습니다" + }, + "jobPostingSalary": { + "required": "월급을 입력해 주세요", + "maxLength": "월급은 최대 10자를 초과할 수 없습니다", + "invalid": "숫자만 입력해 주세요" + }, + "jobPostingDueDate": { + "required": "공고 마감일을 입력해 주세요" } } } diff --git a/src/shared/config/middleware/with-business-auth.ts b/src/shared/config/middleware/with-business-auth.ts index cd0de031..983b46e7 100644 --- a/src/shared/config/middleware/with-business-auth.ts +++ b/src/shared/config/middleware/with-business-auth.ts @@ -7,8 +7,12 @@ import { MiddlewareFactory } from './type' const matchersForAuth = [ '/business/setting/*path', + '/business/applicant-management', '/business/applicant-management/*path', + '/business/job-posting', '/business/job-posting/*path', + '/business/academy-detail', + '/business/preview/*path', ] const matchersForSignIn = ['/business/sign-up', '/business/sign-in'] diff --git a/src/shared/form/ui/checkbox/checkbox.tsx b/src/shared/form/ui/checkbox/checkbox.tsx index 62f31898..aad6b1e4 100644 --- a/src/shared/form/ui/checkbox/checkbox.tsx +++ b/src/shared/form/ui/checkbox/checkbox.tsx @@ -14,6 +14,7 @@ import { commonRules } from '../../lib' type FormCheckboxGroupProps = UseCheckboxProps & { rules?: RegisterOptions required?: boolean + onChange?: (value: CheckboxValue[]) => void } const FormCheckboxGroup = ({ @@ -22,6 +23,7 @@ const FormCheckboxGroup = ({ rules, children, required, + onChange, }: PropsWithChildren) => { const checkboxState = useCheckbox({ name, options }) @@ -37,8 +39,9 @@ const FormCheckboxGroup = ({ () => ({ ...checkboxState, controller, + onChange, }), - [checkboxState, controller], + [checkboxState, controller, onChange], ) return ( @@ -67,6 +70,7 @@ const FormCheckboxItem = ({ updateCheckedValue, getCheckboxProps, getIndeterminateCheckboxProps, + onChange, } = useCheckboxContext() const { @@ -88,6 +92,7 @@ const FormCheckboxItem = ({ checkboxProps.onChange(updatedCheckedValues => { field.onChange(updatedCheckedValues) clearErrors(field.name) + onChange?.(updatedCheckedValues) }) } diff --git a/src/shared/form/ui/checkbox/context.tsx b/src/shared/form/ui/checkbox/context.tsx index 27862e39..2a386363 100644 --- a/src/shared/form/ui/checkbox/context.tsx +++ b/src/shared/form/ui/checkbox/context.tsx @@ -5,6 +5,7 @@ import { useCheckbox } from 'shared/lib' type CheckboxState = ReturnType & { controller: UseControllerReturn + onChange?: (value: CheckboxValue[]) => void } export const CheckboxContext = createContext(null) diff --git a/src/shared/form/ui/message/message.tsx b/src/shared/form/ui/message/message.tsx index 2ad9819c..2e1e2bd8 100644 --- a/src/shared/form/ui/message/message.tsx +++ b/src/shared/form/ui/message/message.tsx @@ -9,7 +9,10 @@ type FormErrorMessageProps = HelperTextProps & { name?: string } -export const FormErrorMessage = ({ name = '' }: FormErrorMessageProps) => { +export const FormErrorMessage = ({ + name = '', + className, +}: FormErrorMessageProps) => { const { formState: { errors }, } = useFormContext() @@ -21,8 +24,16 @@ export const FormErrorMessage = ({ name = '' }: FormErrorMessageProps) => { if (!error) return null if (error.message.includes('validation')) { - return {t(error.message)} + return ( + + {t(error.message)} + + ) } - return {error.message} + return ( + + {error.message} + + ) } diff --git a/src/shared/ui/gnb/business-button.tsx b/src/shared/ui/gnb/business-button.tsx index 1cd81c45..f7ba62b7 100644 --- a/src/shared/ui/gnb/business-button.tsx +++ b/src/shared/ui/gnb/business-button.tsx @@ -31,17 +31,17 @@ export const BusinessButton = () => { } const handleMyPageClick = () => { - router.push('/business/setting/my-page') + router.push('/business/setting/my-academy') close() } const handleSignOutClick = async () => { - queryClient.removeQueries() - await businessSignOut() await signOut({ redirect: false }) router.push('/business') + + queryClient.removeQueries() close() } diff --git a/src/shared/ui/gnb/navigation.tsx b/src/shared/ui/gnb/navigation.tsx index 58ec1002..5d3e111c 100644 --- a/src/shared/ui/gnb/navigation.tsx +++ b/src/shared/ui/gnb/navigation.tsx @@ -15,7 +15,7 @@ type ItemProps = { export const Item = ({ value, children }: PropsWithChildren) => { const pathname = usePathname() - const isActive = (pathname ?? '/') === value + const isActive = pathname?.includes(value) return (
  • { } const handleSignOutClick = async () => { - queryClient.removeQueries() - await teacherSignOut() await signOut({ redirect: false }) router.push('/') + + queryClient.removeQueries() close() } diff --git a/src/shared/ui/text-field/text-field.tsx b/src/shared/ui/text-field/text-field.tsx index 0bd81787..5975c349 100644 --- a/src/shared/ui/text-field/text-field.tsx +++ b/src/shared/ui/text-field/text-field.tsx @@ -96,7 +96,9 @@ export const TextField = forwardRef( )} onClick={handleClick} > - {slots?.left &&
    {slots.left}
    } + {slots?.left && ( +
    {slots.left}
    + )} ( onFocus={handleFocus} onBlur={handleBlur} /> - {slots?.right &&
    {slots.right}
    } + {slots?.right && ( +
    {slots.right}
    + )}
) }, diff --git a/src/widgets/sidebar/index.ts b/src/widgets/sidebar/index.ts index 14a7302f..700ca7e2 100644 --- a/src/widgets/sidebar/index.ts +++ b/src/widgets/sidebar/index.ts @@ -1 +1,2 @@ export * from './ui/sidebar' +export * from './model/constant' diff --git a/src/widgets/sidebar/model/constant.ts b/src/widgets/sidebar/model/constant.ts index c9b0ce79..a822fdfe 100644 --- a/src/widgets/sidebar/model/constant.ts +++ b/src/widgets/sidebar/model/constant.ts @@ -27,3 +27,23 @@ export const items: MenuItemData[] = [ ], }, ] + +export const businessItems: MenuItemData[] = [ + { + title: '학원 페이지', + url: '/business/setting/my-academy', + }, + { + title: '내 계정', + subItems: [ + { + title: '개인 정보', + url: '/business/setting/my-account/personal-information', + }, + { + title: '비밀번호 변경', + url: '/business/setting/my-account/change-password', + }, + ], + }, +] diff --git a/src/widgets/sidebar/ui/sidebar.tsx b/src/widgets/sidebar/ui/sidebar.tsx index aa77d931..87f73056 100644 --- a/src/widgets/sidebar/ui/sidebar.tsx +++ b/src/widgets/sidebar/ui/sidebar.tsx @@ -7,9 +7,13 @@ import { Sidebar } from 'shared/ui' import { CollapsibleMenuItem, SingleMenuItem } from './sidebar-item' import { hasSubItems } from '../lib/type-guard' -import { items } from '../model/constant' +import { MenuItemData } from '../model/type' -export const SettingSidebar = () => { +type Props = { + items: MenuItemData[] +} + +export const SettingSidebar = ({ items }: Props) => { const pathname = usePathname() const isActive = useCallback(