-
- 이용에 불편을 드려 죄송합니다.
-
-
- 보다 나은 서비스 제공을 위하여 페이지 준비중에 있습니다.
-
-
- 빠른 시일 내에 준비하여 찾아뵙겠습니다.
-
+
+
+
+ 강사를 손쉽게 관리하고,
+
+ 빠르게 채용하세요
+
+
+
+ 학원에 맞는 원어민 강사를 찾고 계신가요?
+
+
+ 지금, Plus82와 시작하세요.
+
+
+
+
+
)
}
+
+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 (
+
+
+ {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) => {
-
)
},
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(