diff --git a/.eslintrc.js b/.eslintrc.js index c8df6075..03ee7431 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,4 +1,4 @@ module.exports = { root: true, - extends: ["custom"], + extends: ['custom'], }; diff --git "a/.github/ISSUE_TEMPLATE/\342\231\273\357\270\217-\353\246\254\355\214\251\355\206\240\353\247\201-\354\232\224\354\262\255.md" "b/.github/ISSUE_TEMPLATE/\342\231\273\357\270\217-\353\246\254\355\214\251\355\206\240\353\247\201-\354\232\224\354\262\255.md" index 49fc6fd4..936d4a86 100644 --- "a/.github/ISSUE_TEMPLATE/\342\231\273\357\270\217-\353\246\254\355\214\251\355\206\240\353\247\201-\354\232\224\354\262\255.md" +++ "b/.github/ISSUE_TEMPLATE/\342\231\273\357\270\217-\353\246\254\355\214\251\355\206\240\353\247\201-\354\232\224\354\262\255.md" @@ -1,23 +1,26 @@ --- -name: '♻️ 리팩토링 요청' +name: "♻️ 리팩토링 요청" about: 변경 혹은 개선해야 되는 문제를 작성해 주세요 title: 'refactor: ' labels: '' assignees: '' + --- -## 개선해야 되는 코드 혹은 기능에 대해서 적어주세요 +## 개선해야 되는 코드 혹은 기능에 대해서 적어주세요 + + -개선해야 될 코드에 대한 명확하고 간단한 설명 ## 원하는 개선 방향 + + -개선해야 되는 간단한 이유 혹은 개선 후 장점에 대해 적어주세요 ## 생각 중인 기능 추가 방안 + -해결책으로 간단하게 생각한 개선 방법에 대해 적어주세요 -## ETC -스크린샷이나 기능 등 추가 자료를 기술해 주세요 +## ETC + diff --git "a/.github/ISSUE_TEMPLATE/\342\234\250-\352\270\260\353\212\245-\354\266\224\352\260\200-\354\232\224\354\262\255.md" "b/.github/ISSUE_TEMPLATE/\342\234\250-\352\270\260\353\212\245-\354\266\224\352\260\200-\354\232\224\354\262\255.md" index d6783961..3e6aee3d 100644 --- "a/.github/ISSUE_TEMPLATE/\342\234\250-\352\270\260\353\212\245-\354\266\224\352\260\200-\354\232\224\354\262\255.md" +++ "b/.github/ISSUE_TEMPLATE/\342\234\250-\352\270\260\353\212\245-\354\266\224\352\260\200-\354\232\224\354\262\255.md" @@ -1,26 +1,27 @@ --- -name: '✨ 기능 추가 요청' +name: "✨ 기능 추가 요청" about: 구현하려는 새로운 기능을 요청 -title: 'feat: ' +title: 'feat: ' labels: '' assignees: '' + --- -## 추가하려는 기능이 어떠한 문제 혹은 기능과 연관되어 있나요? +## 추가하려는 기능이 어떠한 문제 혹은 기능과 연관되어 있나요? + -문제가 무엇인지에 대한 명확하고 간결한 설명을 적어주세요 -## 원하는 기능 추가 -추가하려는 기능을 명확하고 간결하게 설명해주세요 +## 원하는 기능 추가 + - [ ] todo - [ ] todo ## 생각 중인 기능 추가 방안 + -해결책으로 간단하게 생각한 기능의 방향 혹은 컴포넌트를 설명해주세요 -## ETC -스크린샷이나 기능 등 추가 자료를 기술해 주세요 +## ETC + diff --git "a/.github/ISSUE_TEMPLATE/\360\237\220\233-\353\262\204\352\267\270-\353\246\254\355\217\254\355\212\270.md" "b/.github/ISSUE_TEMPLATE/\360\237\220\233-\353\262\204\352\267\270-\353\246\254\355\217\254\355\212\270.md" index 561796c4..7a361c09 100644 --- "a/.github/ISSUE_TEMPLATE/\360\237\220\233-\353\262\204\352\267\270-\353\246\254\355\217\254\355\212\270.md" +++ "b/.github/ISSUE_TEMPLATE/\360\237\220\233-\353\262\204\352\267\270-\353\246\254\355\217\254\355\212\270.md" @@ -4,17 +4,20 @@ about: 버그를 고쳐주세요 title: 'bug: ' labels: '' assignees: '' + --- ## 버그 설명 + -발생되는 문제에 대해 간단하게 설명해 주세요 -## 버그 발생 단계 +## 버그 발생 단계 + diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index a4cd76ab..2fdc2c20 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -2,9 +2,9 @@ name: Build Test on: push: - branches: [main] + branches: [main, develop] pull_request: - branches: [main] + branches: [main, develop] workflow_dispatch: jobs: diff --git a/apps/shelter/index.html b/apps/shelter/index.html index 15baae6e..dbcdb704 100644 --- a/apps/shelter/index.html +++ b/apps/shelter/index.html @@ -3,6 +3,13 @@ + 보호소 어플리케이션 diff --git a/apps/shelter/package.json b/apps/shelter/package.json index d75db056..059c5543 100644 --- a/apps/shelter/package.json +++ b/apps/shelter/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "tsc && vite build", "dev": "vite", - "lint": "eslint \"src/**/*.ts\"", + "lint": "eslint \"src/**/*.{ts,tsx}\"", "preview": "vite preview" }, "dependencies": { @@ -24,7 +24,7 @@ "react-error-boundary": "^4.0.11", "react-hook-form": "^7.47.0", "react-router-dom": "^6.17.0", - "ui": "workspace:*", + "shared": "workspace:*", "zod": "^3.22.4", "zustand": "^4.4.4" }, diff --git a/apps/shelter/src/App.tsx b/apps/shelter/src/App.tsx index 88653cfd..e222a5e1 100644 --- a/apps/shelter/src/App.tsx +++ b/apps/shelter/src/App.tsx @@ -1,12 +1,22 @@ import { ChakraProvider } from '@chakra-ui/react'; -import { CustomButton, Header } from 'ui'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { RouterProvider } from 'react-router-dom'; +import Fonts from 'shared/fonts'; +import theme from 'shared/theme'; + +import { router } from '@/routes'; + +const queryClient = new QueryClient(); export default function App() { return ( - -
- 보호소 어플리케이션 - - + + + + + + + ); } diff --git a/apps/shelter/src/apis/auth.ts b/apps/shelter/src/apis/auth.ts new file mode 100644 index 00000000..66df5a2c --- /dev/null +++ b/apps/shelter/src/apis/auth.ts @@ -0,0 +1,33 @@ +import axiosInstance from 'shared/apis/axiosInstance'; +import type { + ChangePasswordRequestData, + CheckDuplicatedEmailRequestData, + CheckDuplicatedEmailResponseData, + SigninRequestData, + SigninResponseData, +} from 'shared/types/apis/auth'; + +import { SignupRequestData } from '@/types/apis/auth'; + +export const signinShelter = (data: SigninRequestData) => + axiosInstance.post( + '/auth/shelters/login', + data, + ); + +export const signupShelter = (data: SignupRequestData) => + axiosInstance.post('/shelters', data); + +export const checkDuplicatedShelterEmail = ( + data: CheckDuplicatedEmailRequestData, +) => + axiosInstance.post< + CheckDuplicatedEmailResponseData, + CheckDuplicatedEmailRequestData + >('/shelters/email', data); + +export const changeShelterPassword = (data: ChangePasswordRequestData) => + axiosInstance.patch( + '/shelters/me/passwords', + data, + ); diff --git a/apps/shelter/src/apis/recruitment.ts b/apps/shelter/src/apis/recruitment.ts new file mode 100644 index 00000000..3e3b47aa --- /dev/null +++ b/apps/shelter/src/apis/recruitment.ts @@ -0,0 +1,74 @@ +import axiosInstance from 'shared/apis/axiosInstance'; + +import { + ApplicantsApprovalRequest, + ApprovedRecruitmentApplicantsResponse, + RecruitementsResponse, + RecruitmentApplicantsResponse, + RecruitmentApplicantUpdateRequest, + RecruitmentCreateRequest, + RecruitmentsRequest, + RecruitmentUpdateRequest, +} from '@/types/apis/recruitment'; + +export const getShelterRecruitments = async ( + request: Partial, +) => + axiosInstance.get( + '/shelters/recruitments', + { params: request }, + ); + +export const createShelterRecruitment = (request: RecruitmentCreateRequest) => + axiosInstance.post< + { + recruitmentId: number; + }, + RecruitmentCreateRequest + >(`/shelters/recruitments`, request); + +export const updateShelterRecruitment = ( + recruitmentId: number, + request: RecruitmentUpdateRequest, +) => + axiosInstance.patch( + `/shelters/recruitments/${recruitmentId}`, + request, + ); + +export const deleteShelterRecruitment = (recruitmentId: number) => + axiosInstance.delete(`/shelters/recruitments/${recruitmentId}`); + +export const closeShelterRecruitment = (recruitmentId: number) => + axiosInstance.patch(`/shelters/recruitments/${recruitmentId}/close`); + +export const getShelterRecruitmentApplicants = (recruitmentId: number) => + axiosInstance.get( + `/shelters/recruitments/${recruitmentId}/applicants`, + ); + +export const updateShelterRecruitmentApplicant = ( + recruitmentId: number, + applicantId: number, + request: RecruitmentApplicantUpdateRequest, +) => + axiosInstance.patch( + `/shelters/recruitments/${recruitmentId}/applicants/${applicantId}`, + request, + ); + +export const getShelterApprovedRecruitmentApplicants = ( + recruitmentId: number, +) => + axiosInstance.get( + `/shelters/recruitments/${recruitmentId}/approval`, + ); + +export const updateAttendanceAPI = ( + recruitmentId: number, + request: ApplicantsApprovalRequest, +) => + axiosInstance.patch( + `/shelters/recruitments/${recruitmentId}/approval`, + request, + ); diff --git a/apps/shelter/src/apis/shelter.ts b/apps/shelter/src/apis/shelter.ts new file mode 100644 index 00000000..87faad9f --- /dev/null +++ b/apps/shelter/src/apis/shelter.ts @@ -0,0 +1,53 @@ +import axiosInstance from 'shared/apis/axiosInstance'; + +import { ShelterInfo } from '@/types/apis/shetler'; + +type PasswordUpdateParams = { + newPassword: string; + oldPassword: string; +}; + +type PageParams = { + pageSize: number; + pageNumber: number; +}; + +export const getShelterInfoAPI = () => + axiosInstance.get('/shelters/me'); + +export const updateShelterInfo = (shelterInfo: ShelterInfo) => + axiosInstance.patch('/shelters/me', shelterInfo); + +export const updatePassword = (passwordUpdateParams: PasswordUpdateParams) => + axiosInstance.patch( + '/shelters/me/password', + passwordUpdateParams, + ); + +export const updateAddressStatusAPI = (isOpenedAddress: boolean) => + axiosInstance.patch< + unknown, + { + isOpenedAddress: boolean; + } + >('/shelters/me/address/status', { isOpenedAddress }); + +export const getShelterReviewList = (pageParams: PageParams) => + axiosInstance.get<{ + pageInfo: { + totalElements: number; + hasNext: boolean; + }; + reviews: { + reviewId: number; + reviewCreatedAt: string; + reviewContent: string; + reviewImageUrls: string[]; + volunteerName: string; + volunteerTemperature: number; + volunteerReviewCount: number; + volunteerImageUrl: string; + }[]; + }>(`/shelters/me/reviews`, { + params: pageParams, + }); diff --git a/apps/shelter/src/assets/CkCheck.tsx b/apps/shelter/src/assets/CkCheck.tsx new file mode 100644 index 00000000..9a143457 --- /dev/null +++ b/apps/shelter/src/assets/CkCheck.tsx @@ -0,0 +1,19 @@ +import { ComponentProps } from 'react'; + +export default function CkCheck({ ...props }: ComponentProps<'svg'>) { + return ( + + + + ); +} diff --git a/apps/shelter/src/constants/path.ts b/apps/shelter/src/constants/path.ts new file mode 100644 index 00000000..d6396874 --- /dev/null +++ b/apps/shelter/src/constants/path.ts @@ -0,0 +1,40 @@ +const PATH = { + VOLUNTEERS: { + INDEX: 'volunteers', + DETAIL: ':id', + PROFILE: 'profile', + SEARCH: 'search', + WRITE: 'write', + UPDATE: 'write/:id', + }, + ANIMALS: { + INDEX: 'animals', + DETAIL: ':id', + SEARCH: 'search', + WRITE: 'write', + UPDATE: 'write/:id', + }, + CHATTINGS: { + INDEX: 'chattings', + ROOM: 'chattings/:id', + }, + MYPAGE: { + INDEX: 'mypage', + REVIEWS: 'reviews', + }, + SETTINGS: { + INDEX: 'settings', + ACCOUNT: 'account', + PASSWORD: 'password', + }, + MANAGE: { + INDEX: 'manage', + ATTENDANCE: 'attendance/:id', + APPLY: 'apply/:id', + }, + NOTIFICATIONS: 'notifications', + SIGNUP: 'signup', + SIGNIN: 'signin', +}; + +export default PATH; diff --git a/apps/shelter/src/constants/recruitment.ts b/apps/shelter/src/constants/recruitment.ts new file mode 100644 index 00000000..52a96528 --- /dev/null +++ b/apps/shelter/src/constants/recruitment.ts @@ -0,0 +1,11 @@ +export const APPLICANT_STATUS_ENG = { + PENDING: 'PENDING', + APPROVED: 'APPROVED', + REFUSED: 'REFUSED', +} as const; + +export const APPLICANT_STATUS_KOR = { + PENDING: '대기중', + APPROVED: '승인됨', + REFUSED: '거절됨', +} as const; diff --git a/apps/shelter/src/main.tsx b/apps/shelter/src/main.tsx index 23ceb8cf..f6444594 100644 --- a/apps/shelter/src/main.tsx +++ b/apps/shelter/src/main.tsx @@ -1,5 +1,3 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import React from 'react'; import ReactDOM from 'react-dom/client'; @@ -17,15 +15,10 @@ async function deferRender() { return worker.start(); } -const queryClient = new QueryClient(); - deferRender().then(() => { ReactDOM.createRoot(document.getElementById('root')!).render( - - - - + , ); }); diff --git a/apps/shelter/src/mocks/browser.ts b/apps/shelter/src/mocks/browser.ts index c95883bc..f6d52450 100644 --- a/apps/shelter/src/mocks/browser.ts +++ b/apps/shelter/src/mocks/browser.ts @@ -1,5 +1,15 @@ import { setupWorker } from 'msw/browser'; -import { handlers } from './handlers'; +import { handlers as authHandlers } from './handlers/auth'; +import { handlers as manageHandlers } from './handlers/manage'; +import { handlers as recruitmentHandler } from './handlers/recruitment'; +import { handlers as recruitmentDetailHandler } from './handlers/recruitmentDetail'; +import { handlers as shelterHandlers } from './handlers/shelter'; -export const worker = setupWorker(...handlers); +export const worker = setupWorker( + ...authHandlers, + ...shelterHandlers, + ...recruitmentHandler, + ...recruitmentDetailHandler, + ...manageHandlers, +); diff --git a/apps/shelter/src/mocks/handlers.ts b/apps/shelter/src/mocks/handlers.ts deleted file mode 100644 index b091270d..00000000 --- a/apps/shelter/src/mocks/handlers.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { delay, http, HttpResponse } from 'msw'; - -const ALL_POST = [ - { - userId: 1, - id: 1, - title: '모킹 포스트 1', - body: '모킹된 포스트에요. MSW를 이용했어요.', - }, - { - userId: 1, - id: 2, - title: '모킹 포스트 2', - body: '모킹된 포스트에요. MSW를 이용했어요.', - }, - { - userId: 1, - id: 3, - title: '모킹 포스트 3', - body: '모킹된 포스트에요. MSW를 이용했어요.', - }, - { - userId: 1, - id: 4, - title: '모킹 포스트 4', - body: '모킹된 포스트에요. MSW를 이용했어요.', - }, - { - userId: 1, - id: 5, - title: '모킹 포스트 5', - body: '모킹된 포스트에요. MSW를 이용했어요.', - }, -]; - -export const handlers = [ - http.get('/example/posts', async () => { - await delay(200); - return HttpResponse.json(ALL_POST); - }), -]; diff --git a/apps/shelter/src/mocks/handlers/auth.ts b/apps/shelter/src/mocks/handlers/auth.ts new file mode 100644 index 00000000..a8580232 --- /dev/null +++ b/apps/shelter/src/mocks/handlers/auth.ts @@ -0,0 +1,93 @@ +import { delay, http, HttpResponse } from 'msw'; + +export const handlers = [ + http.post('/auth/shelters/login', async () => { + await delay(200); + return HttpResponse.json( + { + accessToken: 'accessToken', + userId: 1, + role: 'ROLE_SEHLTER', + }, + { status: 200 }, + ); + }), + http.post('/auth/shelters/login', async () => { + await delay(200); + return HttpResponse.json( + { + errorCode: 'AF002', + message: '이메일/비밀번호가 올바르지 않습니다', + }, + { status: 400 }, + ); + }), + http.post('/auth/shelters/login', async () => { + await delay(200); + return HttpResponse.json( + { + errorCode: 'AF001', + message: '잘못된 입력값입니다', + }, + { status: 400 }, + ); + }), + http.post('/shelters', async () => { + await delay(200); + return HttpResponse.json({}, { status: 400 }); + }), + http.post('/shelters', async () => { + await delay(200); + return HttpResponse.json( + { + errorCode: 'AF002', + message: '{입력값}은 1자 이상, 20자 이하여야 합니다.', + }, + { status: 400 }, + ); + }), + http.post('/shelters', async () => { + await delay(200); + return HttpResponse.json( + { + errorCode: 'AF001', + message: '요청값이 입력되지 않았습니다.. {}', + }, + { status: 400 }, + ); + }), + http.post('/shelters/email', async () => { + await delay(200); + return HttpResponse.json( + { + isDuplicated: false, + }, + { status: 200 }, + ); + }), + http.post('/shelters/email', async () => { + await delay(200); + return HttpResponse.json( + { + errorCode: 'AF001', + message: '잘못된 입력값입니다', + }, + { status: 400 }, + ); + }), + http.patch('/shelters/me/passwords', async () => { + await delay(200); + return HttpResponse.json({}, { status: 200 }); + }), + http.post('/auth/refresh', async () => { + await delay(500); + return HttpResponse.json( + { + accessToken: 'access token', + userId: 1, + role: 'role', + }, + { status: 200 }, + ); + }), +]; diff --git a/apps/shelter/src/mocks/handlers/manage.ts b/apps/shelter/src/mocks/handlers/manage.ts new file mode 100644 index 00000000..9de57959 --- /dev/null +++ b/apps/shelter/src/mocks/handlers/manage.ts @@ -0,0 +1,35 @@ +import { delay, http, HttpResponse } from 'msw'; + +const DUMMY_USER = { + volunteerId: 1, + applicantId: 2, + volunteerName: '김영희', + volunteerBirthDate: '2021-11-08', + volunteerGender: 'FEMALE', + volunteerPhoneNumber: '010-1234-5678', + volunteerAttendance: false, +}; + +const DUMMY_USER_LIST = Array.from({ length: 8 }, () => { + return { + ...DUMMY_USER, + volunteerId: Math.random(), + applicantId: Math.random(), + }; +}); + +export const handlers = [ + http.get('/shelters/recruitments/:recruitmentId/approval', async () => { + await delay(200); + return HttpResponse.json( + { + applicants: DUMMY_USER_LIST, + }, + { status: 200 }, + ); + }), + http.patch('/shelters/recruitments/:recruitmentId/approval', async () => { + await delay(1000); + return HttpResponse.json({ status: 200 }); + }), +]; diff --git a/apps/shelter/src/mocks/handlers/recruitment.ts b/apps/shelter/src/mocks/handlers/recruitment.ts new file mode 100644 index 00000000..d87da1b6 --- /dev/null +++ b/apps/shelter/src/mocks/handlers/recruitment.ts @@ -0,0 +1,41 @@ +import { delay, http, HttpResponse } from 'msw'; + +const DUMMY_RECRUITMENT = { + recruitmentId: 1, + recruitmentTitle: '봉사자를 모집합니다', + recruitmentStartTime: '2021-11-08T11:44:30.327959', + recruitmentEndTime: '2021-11-08T11:44:30.327959', + recruitmentDeadline: '2023-11-20T11:44:30.327959', + recruitmentIsClosed: false, + recruitmentApplicantCount: 15, + recruitmentCapacity: 15, +}; + +export const handlers = [ + http.get('/shelters/recruitments', async () => { + await delay(1000); + return HttpResponse.json( + { + pageInfo: { + totalElements: 100, + hasNext: true, + }, + recruitments: Array.from({ length: 4 }, () => ({ + ...DUMMY_RECRUITMENT, + recruitmentId: Math.random(), + shelterId: Math.random(), + })), + }, + { status: 200 }, + ); + }), + http.post('/shelters/recruitments', async () => { + await delay(1000); + return HttpResponse.json({}, { status: 201 }); + }), + http.patch('/shelters/recruitments/:recruitmentId', async ({ request }) => { + console.log(request); + await delay(1000); + return HttpResponse.json({ status: 204 }); + }), +]; diff --git a/apps/shelter/src/mocks/handlers/recruitmentDetail.ts b/apps/shelter/src/mocks/handlers/recruitmentDetail.ts new file mode 100644 index 00000000..b020a7f0 --- /dev/null +++ b/apps/shelter/src/mocks/handlers/recruitmentDetail.ts @@ -0,0 +1,26 @@ +import { delay, http, HttpResponse } from 'msw'; + +export const handlers = [ + http.get('/recruitments/:id', async () => { + await delay(200); + return HttpResponse.json( + { + recruitmentTitle: '가짜 데이터 제목입니다.', + recruitmentApplicantCount: 5, + recruitmentCapacity: 10, + recruitmentContent: '가짜 데이터 내용입니다!!! '.repeat(50), + recruitmentStartTime: '2023-12-17T14:00:00', + recruitmentEndTime: '2023-12-17T16:00:00', + recruitmentIsClosed: false, + recruitmentDeadline: '2023-12-18T18:00:00', + recruitmentCreatedAt: '2023-12-15T14:00:00', + recruitmentUpdatedAt: '2023-12-15T14:00:00', + recruitmentImageUrls: [ + 'https://source.unsplash.com/random/?animal', + 'https://source.unsplash.com/random/300X500', + ], + }, + { status: 200 }, + ); + }), +]; diff --git a/apps/shelter/src/mocks/handlers/shelter.ts b/apps/shelter/src/mocks/handlers/shelter.ts new file mode 100644 index 00000000..d7ee40cf --- /dev/null +++ b/apps/shelter/src/mocks/handlers/shelter.ts @@ -0,0 +1,52 @@ +import { delay, http, HttpResponse } from 'msw'; + +const DUMMY_IMAGE = 'https://source.unsplash.com/random'; +const DUMMY_IMAGE_LIST = Array.from({ length: 4 }, () => DUMMY_IMAGE); +const DUMMY_REVIEW = { + reviewId: 32, + reviewCreatedAt: '2023-03-16T18:00', + reviewContent: '시설이 너무 깨끗하고 강아지도...', + reviewImageUrls: DUMMY_IMAGE_LIST, + volunteerName: '강혜린', + volunteerTemperature: 44, + volunteerReviewCount: 4, + volunteerImageUrl: DUMMY_IMAGE, +}; +const DUMMY_REVIEW_LIST = Array.from({ length: 4 }, () => DUMMY_REVIEW); + +export const handlers = [ + http.get('/shelters/me', async () => { + await delay(200); + return HttpResponse.json( + { + shelterId: 1, + shelterEmail: 'Shelter1234@gmail.com', + shelterName: '양천구 보호소', + imageUrl: null, + shelterAddress: '서울특별시 양천구', + shelterAddressDetail: '서울특별시 양천구 신월동 동자빌딩', + shelterPhoneNumber: '010-1234-5678', + shelterSparePhoneNumber: '02-345-6780', + shelterIsOpenedAddress: true, + }, + { status: 200 }, + ); + }), + http.patch('/shelters/me/address/status', async () => { + await delay(200); + return HttpResponse.json({ status: 200 }); + }), + http.get('/shelters/me/reviews', async () => { + await delay(200); + return HttpResponse.json( + { + pageInfo: { + totalElements: 100, + hasNext: true, + }, + reviews: DUMMY_REVIEW_LIST, + }, + { status: 200 }, + ); + }), +]; diff --git a/apps/shelter/src/pages/animals/detail/index.tsx b/apps/shelter/src/pages/animals/detail/index.tsx new file mode 100644 index 00000000..e36382be --- /dev/null +++ b/apps/shelter/src/pages/animals/detail/index.tsx @@ -0,0 +1,21 @@ +import { useEffect } from 'react'; +import useDetailHeaderStore from 'shared/store/detailHeaderStore'; + +const handleDeletePost = (postId: number) => { + // TODO: AnimalPost delete API 호출 + console.log('[Delete Animal] postId:', postId); +}; + +export default function AnimalsDetailPage() { + const setOnDelete = useDetailHeaderStore((state) => state.setOnDelete); + + useEffect(() => { + setOnDelete(handleDeletePost); + + return () => { + setOnDelete(() => {}); + }; + }, [setOnDelete]); + + return

AnimalsDetailPage

; +} diff --git a/apps/shelter/src/pages/animals/index.tsx b/apps/shelter/src/pages/animals/index.tsx new file mode 100644 index 00000000..c11ddc43 --- /dev/null +++ b/apps/shelter/src/pages/animals/index.tsx @@ -0,0 +1,3 @@ +export default function AnimalsPage() { + return

AnimalsPage

; +} diff --git a/apps/shelter/src/pages/animals/search/index.tsx b/apps/shelter/src/pages/animals/search/index.tsx new file mode 100644 index 00000000..5bbff8b6 --- /dev/null +++ b/apps/shelter/src/pages/animals/search/index.tsx @@ -0,0 +1,21 @@ +import { useEffect } from 'react'; +import useSearchHeaderStore from 'shared/store/searchHeaderStore'; + +const handleSearchkeyword = (keyword: string) => { + // TODO: AnimalList 검색 API 호출 + console.log('[Search Animal] - keyword:', keyword); +}; + +export default function AnimalsSearchPage() { + const setOnSearch = useSearchHeaderStore((state) => state.setOnSearch); + + useEffect(() => { + setOnSearch(handleSearchkeyword); + + return () => { + setOnSearch(() => {}); + }; + }, [setOnSearch]); + + return

AnimalsSearchPage

; +} diff --git a/apps/shelter/src/pages/animals/update/index.tsx b/apps/shelter/src/pages/animals/update/index.tsx new file mode 100644 index 00000000..3a9b93fc --- /dev/null +++ b/apps/shelter/src/pages/animals/update/index.tsx @@ -0,0 +1,3 @@ +export default function AnimalsUpdatePage() { + return

AnimalsUpdatePage

; +} diff --git a/apps/shelter/src/pages/animals/write/index.tsx b/apps/shelter/src/pages/animals/write/index.tsx new file mode 100644 index 00000000..a081d6a5 --- /dev/null +++ b/apps/shelter/src/pages/animals/write/index.tsx @@ -0,0 +1,3 @@ +export default function AnimalsWritePage() { + return

AnimalsWritePage

; +} diff --git a/apps/shelter/src/pages/chattings/index.tsx b/apps/shelter/src/pages/chattings/index.tsx new file mode 100644 index 00000000..a48ead0b --- /dev/null +++ b/apps/shelter/src/pages/chattings/index.tsx @@ -0,0 +1,3 @@ +export default function ChattingsPage() { + return

ChattingsPage

; +} diff --git a/apps/shelter/src/pages/chattings/room/index.tsx b/apps/shelter/src/pages/chattings/room/index.tsx new file mode 100644 index 00000000..55fc00e1 --- /dev/null +++ b/apps/shelter/src/pages/chattings/room/index.tsx @@ -0,0 +1,3 @@ +export default function ChattingsRoomPage() { + return

ChattingsRoomPage

; +} diff --git a/apps/shelter/src/pages/manage/apply/_components/ApplyInfoItem.tsx b/apps/shelter/src/pages/manage/apply/_components/ApplyInfoItem.tsx new file mode 100644 index 00000000..11fcf57c --- /dev/null +++ b/apps/shelter/src/pages/manage/apply/_components/ApplyInfoItem.tsx @@ -0,0 +1,30 @@ +import { Flex, Text } from '@chakra-ui/react'; +import ApplicantStatus from 'shared/components/ApplicantStatus'; + +type ApplyInfoItemProps = { + currentRecuritmentCount: number; + recruitmentCapacity: number; +}; + +export default function ApplyInfoBox({ + currentRecuritmentCount, + recruitmentCapacity, +}: ApplyInfoItemProps) { + return ( + + + 총 {`${5}명`}이 봉사를 + 신청했습니다 + + + + ); +} diff --git a/apps/shelter/src/pages/manage/apply/_components/ApprovedCountBox.tsx b/apps/shelter/src/pages/manage/apply/_components/ApprovedCountBox.tsx new file mode 100644 index 00000000..a2dcb328 --- /dev/null +++ b/apps/shelter/src/pages/manage/apply/_components/ApprovedCountBox.tsx @@ -0,0 +1,30 @@ +import { Box, Button } from '@chakra-ui/react'; + +import CkCheck from '@/assets/CkCheck'; + +type ApprovedCountBoxProps = { + approvedCount: number; +}; + +export default function ApprovedCountBox({ + approvedCount, +}: ApprovedCountBoxProps) { + return ( + + ); +} diff --git a/apps/shelter/src/pages/manage/apply/_components/ManageApplyItem.tsx b/apps/shelter/src/pages/manage/apply/_components/ManageApplyItem.tsx new file mode 100644 index 00000000..0c6922a1 --- /dev/null +++ b/apps/shelter/src/pages/manage/apply/_components/ManageApplyItem.tsx @@ -0,0 +1,67 @@ +import { Button, Flex, HStack, Text, VStack } from '@chakra-ui/react'; +import Label from 'shared/components/Label'; +import { PERSON_GENDER_KOR } from 'shared/constants/gender'; +import { getAge } from 'shared/utils/date'; + +import { ShelterRecruitmentApplicant } from '@/types/apis/recruitment'; + +type ManageApplyItemProps = { + applicant: ShelterRecruitmentApplicant; +}; + +export default function ManageApplyItem({ + applicant: { + volunteerBirthDate, + volunteerGender, + volunteerName, + volunteerTemperature, + completedVolunteerCount, + }, +}: ManageApplyItemProps) { + const age = getAge(volunteerBirthDate); + + return ( + + + + {volunteerName} + + {`${age < 0 ? '00' : age}살 · ${ + PERSON_GENDER_KOR[volunteerGender] + } · 봉사횟수 ${completedVolunteerCount}회`} + + + + + + + ); +} diff --git a/apps/shelter/src/pages/manage/apply/index.tsx b/apps/shelter/src/pages/manage/apply/index.tsx new file mode 100644 index 00000000..39009da0 --- /dev/null +++ b/apps/shelter/src/pages/manage/apply/index.tsx @@ -0,0 +1,33 @@ +import { ShelterRecruitmentApplicant } from '@/types/apis/recruitment'; + +import ApplyInfoItem from './_components/ApplyInfoItem'; +import ApprovedCountBox from './_components/ApprovedCountBox'; +import ManageApplyItem from './_components/ManageApplyItem'; + +const applicant: ShelterRecruitmentApplicant = { + applicantId: 10, + volunteerId: 1, + volunteerName: '김영희', + volunteerBirthDate: '1997-11-21', + volunteerGender: 'FEMALE', + completedVolunteerCount: 3, + volunteerTemperature: 33, + applicantStatus: 'APPROVED', +}; + +export default function ManageApplyPage() { + return ( + <> + + + + + + + + + + + + ); +} diff --git a/apps/shelter/src/pages/manage/attendance/index.tsx b/apps/shelter/src/pages/manage/attendance/index.tsx new file mode 100644 index 00000000..b2062f5e --- /dev/null +++ b/apps/shelter/src/pages/manage/attendance/index.tsx @@ -0,0 +1,223 @@ +import { + Button, + Checkbox, + Flex, + Table, + TableContainer, + Tbody, + Td, + Th, + Thead, + Tr, +} from '@chakra-ui/react'; +import { + queryOptions, + useMutation, + useQueryClient, + useSuspenseQuery, +} from '@tanstack/react-query'; +import { ChangeEvent, Suspense, useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; + +import { + getShelterApprovedRecruitmentApplicants, + updateAttendanceAPI, +} from '@/apis/recruitment'; +import { AttendanceStatus } from '@/types/apis/recruitment'; + +const attendanceQueryOptions = (recruitmentId: number) => + queryOptions({ + queryKey: ['attendance', recruitmentId], + queryFn: () => getShelterApprovedRecruitmentApplicants(recruitmentId), + select: ({ data }) => data, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + refetchInterval: false, + }); + +type Gender = 'MALE' | 'FEMALE'; + +type Applicant = { + volunteerId: number; + applicantId: number; + volunteerName: string; + volunteerBirthDate: string; + volunteerGender: Gender; + volunteerPhoneNumber: string; + volunteerAttendance: boolean; +}; + +function AttendanceForm() { + const { id } = useParams<{ id: string }>(); + + const [userList, setUserList] = useState([]); + const queryClient = useQueryClient(); + + const { mutate, isPending } = useMutation({ + mutationFn: ({ + recruitmentId, + applicants, + }: { + recruitmentId: number; + applicants: AttendanceStatus[]; + }) => updateAttendanceAPI(recruitmentId, { applicants }), + onError: (error) => { + console.warn('error', error); + }, + onSettled: (_, __, { recruitmentId }) => { + queryClient.invalidateQueries({ + queryKey: ['attendance', recruitmentId], + }); + }, + }); + + const updateAttendance = () => { + if (isPending) { + return; + } + const updatedUserList = userList.map( + ({ applicantId, volunteerAttendance }) => ({ + applicantId, + isAttended: volunteerAttendance, + }), + ); + mutate({ + recruitmentId: Number(id), + applicants: updatedUserList, + }); + }; + + const toggleCheck = ({ target: { id } }: ChangeEvent) => { + if (isPending) { + return; + } + const updatedUserList = userList.map((user) => + user.applicantId.toString() === id + ? { ...user, volunteerAttendance: !user.volunteerAttendance } + : user, + ); + + setUserList(updatedUserList); + }; + + const toggleAllCheck = ({ + target: { checked }, + }: ChangeEvent) => { + if (isPending) { + return; + } + setUserList( + userList.map((user) => ({ ...user, volunteerAttendance: checked })), + ); + }; + + const { + data: { applicants }, + } = useSuspenseQuery(attendanceQueryOptions(Number(id))); + + const allChecked = userList.every(({ volunteerAttendance }) => + Boolean(volunteerAttendance), + ); + + useEffect(() => { + setUserList(applicants); + }, [applicants]); + + return ( + + + + + + + + + + + + + + {userList.map( + ({ + volunteerId, + applicantId, + volunteerName, + volunteerBirthDate, + volunteerGender, + volunteerPhoneNumber, + volunteerAttendance, + }) => ( + + + + + + + + ), + )} + +
+ + + 이름 + + 성별 + + 생년월일 + + 전화번호 +
+ + + {volunteerName} + + {volunteerGender === 'FEMALE' ? '여성' : '남성'} + + {volunteerBirthDate.split('-').join('.')} + + {volunteerPhoneNumber.split('-').join('')} +
+
+ +
+ ); +} + +export default function ManageAttendancePage() { + return ( + 로딩 중...

}> + +
+ ); +} diff --git a/apps/shelter/src/pages/my/_hooks/useMyPage.ts b/apps/shelter/src/pages/my/_hooks/useMyPage.ts new file mode 100644 index 00000000..2d781c66 --- /dev/null +++ b/apps/shelter/src/pages/my/_hooks/useMyPage.ts @@ -0,0 +1,66 @@ +import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; + +import { getShelterInfoAPI, updateAddressStatusAPI } from '@/apis/shelter'; +import { ShelterInfo } from '@/types/apis/shetler'; + +type ShelterProfile = { + shelterName: string; + email: string; + phoneNumber: string; + sparePhoneNumber: string; + shelterAddress: string; + isAddressPublic: boolean; +}; + +const createProfile = (response: ShelterInfo): ShelterProfile => { + const { + shelterName, + shelterEmail, + shelterPhoneNumber, + shelterSparePhoneNumber, + shelterAddressDetail, + shelterIsOpenedAddress, + } = response; + return { + shelterName: shelterName, + email: shelterEmail, + phoneNumber: shelterPhoneNumber, + sparePhoneNumber: shelterSparePhoneNumber, + shelterAddress: shelterAddressDetail, + isAddressPublic: shelterIsOpenedAddress, + }; +}; + +export const useMyPage = () => { + const [isAddressPublic, setIsAddressPublic] = useState(false); + + const updateAddressStatus = async () => { + try { + await updateAddressStatusAPI(!isAddressPublic); + setIsAddressPublic(!isAddressPublic); + } catch (error) { + console.error(error); + } + }; + + const { data } = useQuery({ + queryKey: ['shelterProfile'], + queryFn: async () => { + const response = (await getShelterInfoAPI()).data; + setIsAddressPublic(response.shelterIsOpenedAddress); + + return createProfile(response); + }, + initialData: { + shelterName: '', + email: '', + phoneNumber: '', + sparePhoneNumber: '', + shelterAddress: '', + isAddressPublic: false, + }, + }); + + return { shelterProfile: data, isAddressPublic, updateAddressStatus }; +}; diff --git a/apps/shelter/src/pages/my/index.tsx b/apps/shelter/src/pages/my/index.tsx new file mode 100644 index 00000000..7648b4d1 --- /dev/null +++ b/apps/shelter/src/pages/my/index.tsx @@ -0,0 +1,62 @@ +import { Box, Divider, Switch, VStack } from '@chakra-ui/react'; +import { useNavigate } from 'react-router-dom'; +import InfoItem from 'shared/components/InfoItem'; +import InfoList from 'shared/components/InfoList'; +import InfoTextItem from 'shared/components/InfoTextItem'; +import ProfileInfo from 'shared/components/ProfileInfo'; +import SettingGroup from 'shared/components/SettingGroup'; + +import { useMyPage } from '@/pages/my/_hooks/useMyPage'; + +export default function MyPage() { + const navigate = useNavigate(); + const { shelterProfile, isAddressPublic, updateAddressStatus } = useMyPage(); + + const { shelterName, email, phoneNumber, sparePhoneNumber, shelterAddress } = + shelterProfile; + + const goShelterReview = () => navigate('/mypage/reviews'); + const goSettingsAccount = () => navigate('/settings/account'); + const goSettingsPassword = () => navigate('/settings/password'); + const logout = () => { + // TODO: 로그아웃 + }; + + return ( + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/shelter/src/pages/my/reviews/VolunteerProfile.tsx b/apps/shelter/src/pages/my/reviews/VolunteerProfile.tsx new file mode 100644 index 00000000..9701bbcd --- /dev/null +++ b/apps/shelter/src/pages/my/reviews/VolunteerProfile.tsx @@ -0,0 +1,41 @@ +import { Avatar, Box, HStack, Image, Text } from '@chakra-ui/react'; +import NextIcon from 'shared/assets/icon_review_next.svg'; +import InfoSubtext from 'shared/components/InfoSubtext'; +import Label from 'shared/components/Label'; + +type VolunteerProfileprops = { + volunteerName: string; + volunteerTempature: number; + volunteerReviewCount: number; + volunteerImageUrl: string; + reviewCreatedAt: string; +}; + +export default function VolunteerProfile({ + volunteerName, + volunteerTempature, + volunteerReviewCount, + volunteerImageUrl, + reviewCreatedAt, +}: VolunteerProfileprops) { + return ( + + + + + + {volunteerName} + + + + + + + + + + + ); +} diff --git a/apps/shelter/src/pages/my/reviews/hooks/useFetchShelterReviews.tsx b/apps/shelter/src/pages/my/reviews/hooks/useFetchShelterReviews.tsx new file mode 100644 index 00000000..48bb56dd --- /dev/null +++ b/apps/shelter/src/pages/my/reviews/hooks/useFetchShelterReviews.tsx @@ -0,0 +1,14 @@ +import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; + +import { getShelterReviewList } from '@/apis/shelter'; + +export default function useFetchShelterReviews(pageSize: number) { + return useSuspenseInfiniteQuery({ + queryKey: ['reviews'], + queryFn: ({ pageParam }) => + getShelterReviewList({ pageNumber: pageParam, pageSize }), + initialPageParam: 0, + getNextPageParam: ({ data: { pageInfo } }, _, lastPageParam) => + pageInfo.hasNext ? lastPageParam + 1 : null, + }); +} diff --git a/apps/shelter/src/pages/my/reviews/index.tsx b/apps/shelter/src/pages/my/reviews/index.tsx new file mode 100644 index 00000000..cb62093e --- /dev/null +++ b/apps/shelter/src/pages/my/reviews/index.tsx @@ -0,0 +1,76 @@ +import { Box, Heading, VStack } from '@chakra-ui/react'; +import { Suspense } from 'react'; +import ReviewItem from 'shared/components/ReviewItem'; +import useIntersect from 'shared/hooks/useIntersection'; +import { createFormattedTime } from 'shared/utils/date'; + +import useFetchShelterReviews from './hooks/useFetchShelterReviews'; +import VolunteerProfile from './VolunteerProfile'; + +const PAGE_SIZE = 10; + +function Reviews() { + //TODO 봉사자 옆에 화살표 버튼 클릭시 봉사자 프로필 페이지로 가는 기능추가 + + const { + data: { pages }, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useFetchShelterReviews(PAGE_SIZE); + + const totalReviews = pages[0].data.pageInfo.totalElements; + const reviews = pages.flatMap(({ data }) => data.reviews); + const ref = useIntersect(async (entry, observer) => { + observer.unobserve(entry.target); + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }); + + return ( + + + 봉사자들이 작성한 봉사후기{` ${totalReviews}개`} + + + + {reviews.map((review) => ( + + + + ))} + + + + ); +} + +export default function MyReviewsPage() { + return ( + 글목록 로딩중...

}> + +
+ ); +} diff --git a/apps/shelter/src/pages/notfound/index.tsx b/apps/shelter/src/pages/notfound/index.tsx new file mode 100644 index 00000000..5ba55266 --- /dev/null +++ b/apps/shelter/src/pages/notfound/index.tsx @@ -0,0 +1,3 @@ +export default function NotFoundPage() { + return

NotFoundPage

; +} diff --git a/apps/shelter/src/pages/notifications/index.tsx b/apps/shelter/src/pages/notifications/index.tsx new file mode 100644 index 00000000..b7e24e5d --- /dev/null +++ b/apps/shelter/src/pages/notifications/index.tsx @@ -0,0 +1,3 @@ +export default function NotificationsPage() { + return

NotificationsPage

; +} diff --git a/apps/shelter/src/pages/settings/account/index.tsx b/apps/shelter/src/pages/settings/account/index.tsx new file mode 100644 index 00000000..378b2756 --- /dev/null +++ b/apps/shelter/src/pages/settings/account/index.tsx @@ -0,0 +1,112 @@ +import { + Avatar, + Box, + Button, + Center, + FormControl, + FormLabel, + HStack, + Input, + Switch, +} from '@chakra-ui/react'; +import { useState } from 'react'; + +export default function SettingsAccountPage() { + const [imgFile, setImgFile] = useState(''); + + const uploadImgFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + const newImgFile = URL.createObjectURL(file); + setImgFile(newImgFile); + } + }; + + return ( + +
+
+ + + +
+ + + 이메일 + + + + + 보호소 이름 + + + + + 보호소 주소 + + + + + + + 보호소 상세 주소 + + + + 상세주소 공개 + + + + + + + + + 보호소 전화번호 + + + + + 보호소 임시 전화번호 + + +
+ +
+
+
+ ); +} diff --git a/apps/shelter/src/pages/settings/password/index.tsx b/apps/shelter/src/pages/settings/password/index.tsx new file mode 100644 index 00000000..ef1ed7b3 --- /dev/null +++ b/apps/shelter/src/pages/settings/password/index.tsx @@ -0,0 +1,170 @@ +import { + Box, + Button, + FormControl, + FormErrorMessage, + FormLabel, + Icon, + Input, + InputGroup, + InputRightElement, + useToast, + VStack, +} from '@chakra-ui/react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation } from '@tanstack/react-query'; +import { useForm } from 'react-hook-form'; +import IoEyeOff from 'shared/assets/IoEyeOff'; +import IoEyeSharp from 'shared/assets/IoEyeSharp'; +import LogoImageBox from 'shared/components/LogoImageBox'; +import useToggle from 'shared/hooks/useToggle'; +import { ChangePasswordRequestData } from 'shared/types/apis/auth'; +import * as z from 'zod'; + +import { changeShelterPassword } from '@/apis/auth'; + +type Schema = z.infer; + +const schema = z + .object({ + oldPassword: z.string().min(1, '기본 비밀번호 정보는 필수입니다'), + newPassword: z.string().min(1, '변경 비밀번호 정보는 필수입니다'), + // TODO + // + // .regex( + // /^(?=.*[!@#$%^&*()\-_=+[\]\\|{};:'",<.>/?]+)(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}$/, + // '비밀번호는 필수 정보입니다(8자 이상)', + // ), + newPasswordConfirm: z + .string() + .min(1, '변경 비밀번호 확인 정보는 필수입니다'), + }) + .refine( + ({ newPassword, newPasswordConfirm }) => newPassword === newPasswordConfirm, + { + message: '변경 비밀번호가 일치하지 않습니다', + path: ['newPasswordConfirm'], + }, + ); + +export default function SettingsPasswordPage() { + const toast = useToast(); + const [isOldPasswordShow, toggleOldPasswordShow] = useToggle(); + const [isNewPasswordShow, toggleNewPasswordShow] = useToggle(); + const [isNewPasswordConfirmShow, toggleNewPasswordConfirmShow] = useToggle(); + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + }); + const { mutate } = useMutation({ + mutationFn: (data: ChangePasswordRequestData) => + changeShelterPassword(data), + onSuccess: () => { + toast({ + position: 'top', + description: '비밀번호 변경이 완료되었습니다', + status: 'success', + duration: 2500, + }); + }, + }); + + const onSubmit = ({ oldPassword, newPassword }: Schema) => { + mutate({ oldPassword, newPassword }); + }; + + return ( + + +
+ + 기존 비밀번호 + + + + + + + + {errors.oldPassword && errors.oldPassword.message} + + + + 변경 비밀번호 + + + + + + + + {errors.newPassword && errors.newPassword.message} + + + + 변경 비밀번호 확인 + + + + + + + + {errors.newPasswordConfirm && errors.newPasswordConfirm.message} + + + + + +
+
+ ); +} diff --git a/apps/shelter/src/pages/signin/index.tsx b/apps/shelter/src/pages/signin/index.tsx new file mode 100644 index 00000000..be57d2f1 --- /dev/null +++ b/apps/shelter/src/pages/signin/index.tsx @@ -0,0 +1,169 @@ +import { + Box, + Button, + Center, + FormControl, + FormErrorMessage, + FormLabel, + Icon, + Image, + Input, + InputGroup, + InputRightElement, + useToast, + VStack, +} from '@chakra-ui/react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation } from '@tanstack/react-query'; +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom'; +import AnimalfriendsLogo from 'shared/assets/image-anifriends-logo.png'; +import IoEyeOff from 'shared/assets/IoEyeOff'; +import IoEyeSharp from 'shared/assets/IoEyeSharp'; +import useToggle from 'shared/hooks/useToggle'; +import useAuthStore from 'shared/store/authStore'; +import { SigninRequestData } from 'shared/types/apis/auth'; +import * as z from 'zod'; + +import { signinShelter } from '@/apis/auth'; +import PATH from '@/constants/path'; + +type Schema = z.infer; + +const schema = z.object({ + email: z + .string() + .min(1, '이메일이 입려되지 않았습니다') + .email('유효하지 않은 이메일입니다'), + password: z.string().min(1, '비밀번호가 입력되지 않았습니다'), +}); + +export default function SigninPage() { + const navigate = useNavigate(); + const toast = useToast(); + const [isShow, toggleInputShow] = useToggle(); + const { setUser } = useAuthStore(); + const { + register, + handleSubmit, + formState: { errors }, + setFocus, + } = useForm({ + resolver: zodResolver(schema), + }); + const { mutate } = useMutation({ + mutationFn: (data: SigninRequestData) => signinShelter(data), + onSuccess: ({ data: { userId, accessToken } }) => { + setUser({ userId, accessToken }); + navigate(`/${PATH.VOLUNTEERS.INDEX}`); + }, + onError: (error) => { + setUser(null); + toast({ + position: 'top', + description: error.response?.data.message, + status: 'error', + duration: 1500, + }); + + setFocus('email'); + }, + }); + + const goSignupPage = () => { + navigate(`/${PATH.SIGNUP}`); + }; + + const onSubmit = async (data: Schema) => { + mutate(data); + }; + + useEffect(() => setFocus('email'), [setFocus]); + + return ( + +
+ +
+
+ + 이메일 + + + {errors.email && errors.email.message} + + + + 비밀번호 + + + + + + + + {errors.password && errors.password.message} + + + + + + +
+
+ ); +} diff --git a/apps/shelter/src/pages/signup/index.tsx b/apps/shelter/src/pages/signup/index.tsx new file mode 100644 index 00000000..694cbc4a --- /dev/null +++ b/apps/shelter/src/pages/signup/index.tsx @@ -0,0 +1,448 @@ +import { + Box, + Button, + Center, + FormControl, + FormErrorMessage, + FormHelperText, + FormLabel, + HStack, + Icon, + Image, + Input, + InputGroup, + InputRightAddon, + InputRightElement, + Switch, + useToast, + VStack, +} from '@chakra-ui/react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation } from '@tanstack/react-query'; +import { Controller, useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom'; +import AnimalfriendsLogo from 'shared/assets/image-anifriends-logo.png'; +import IoEyeOff from 'shared/assets/IoEyeOff'; +import IoEyeSharp from 'shared/assets/IoEyeSharp'; +import useToggle from 'shared/hooks/useToggle'; +import { CheckDuplicatedEmailRequestData } from 'shared/types/apis/auth'; +import * as z from 'zod'; + +import { checkDuplicatedShelterEmail, signupShelter } from '@/apis/auth'; +import PATH from '@/constants/path'; +import { SignupRequestData } from '@/types/apis/auth'; + +type Schema = z.infer; + +const schema = z + .object({ + email: z + .string() + .min(1, '이메일은 필수 정보입니다') + .regex( + /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, + '이메일 형식에 맞게 적어주세요', + ), + isEmailDuplicated: z.boolean(), + password: z.string().min(1, '비밀번호는 필수 정보입니다'), + // TODO 나중에 추가 예정 + // + // .regex( + // /^(?=.*[!@#$%^&*()\-_=+[\]\\|{};:'",<.>/?]+)(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}$/, + // '비밀번호는 필수 정보입니다(8자 이상)', + // ), + passwordConfirm: z.string().min(1, '비밀번호 확인 정보는 필수입니다'), + name: z.string().min(1, '보호소 이름 정보는 필수입니다'), + address: z.string().min(1, '보호소 주소 정보는 필수입니다'), + addressDetail: z.string().min(1, '보호소 상세주소 정보는 필수입니다'), + isOpenedAddress: z.boolean(), + phoneNumber: z + .string() + .min(1, '보호소 전화번호 정보는 필수입니다') + .refine( + (val) => !Number.isNaN(Number(val)), + '전화번호 형식은 숫자입니다', + ), + sparePhoneNumber: z + .string() + .refine( + (val) => !Number.isNaN(Number(val)) || val === '', + '전화번호 형식은 숫자입니다', + ), + }) + .refine(({ password, passwordConfirm }) => password === passwordConfirm, { + message: '비밀번호가 일치하지 않습니다', + path: ['passwordConfirm'], + }); + +export default function SignupPage() { + const navigate = useNavigate(); + const toast = useToast(); + const [isPasswordShow, togglePasswordShow] = useToggle(); + const [isPasswordConfirmShow, togglePasswordConfirmShow] = useToggle(); + const { + register, + handleSubmit, + formState: { errors }, + control, + setValue, + getValues, + watch, + setFocus, + } = useForm({ + defaultValues: { + isOpenedAddress: false, + isEmailDuplicated: true, + }, + resolver: zodResolver(schema), + }); + const watchIsEmailDuplicated = watch('isEmailDuplicated'); + const watchEmail = watch('email'); + const { mutate: signupShelterMutate } = useMutation({ + mutationFn: (data: SignupRequestData) => signupShelter(data), + onSuccess: () => { + toast({ + position: 'top', + description: '회원가입이 완료되었습니다', + status: 'success', + duration: 1500, + }); + + navigate(`/${PATH.SIGNIN}`); + }, + onError: (error) => { + toast({ + position: 'top', + description: error.response?.data.message, + status: 'error', + duration: 1500, + }); + }, + }); + const { mutate: checkDuplicatedEmailMutate } = useMutation({ + mutationFn: (data: CheckDuplicatedEmailRequestData) => + checkDuplicatedShelterEmail(data), + onSuccess: ({ data: { isDuplicated } }) => { + if (isDuplicated) { + setValue('isEmailDuplicated', true); + + toast({ + position: 'top', + description: '이메일이 중복됩니다', + status: 'error', + duration: 2500, + }); + + setFocus('email'); + } else { + setValue('isEmailDuplicated', false); + + toast({ + position: 'top', + description: '이메일이 확인되었습니다', + status: 'success', + duration: 2500, + }); + } + }, + onError: (error) => { + toast({ + position: 'top', + description: error.response?.data.message, + status: 'error', + duration: 2500, + }); + + setFocus('email'); + }, + }); + + const checkDuplicatedEmail = () => { + if (!watchIsEmailDuplicated) { + setValue('email', ''); + setValue('isEmailDuplicated', true); + return; + } + + const isValid = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test( + watchEmail, + ); + + if (!isValid) { + toast({ + position: 'top', + description: '이메일이 유효하지 않습니다', + status: 'error', + duration: 1500, + }); + + setValue('isEmailDuplicated', true); + setFocus('email'); + + return; + } + + checkDuplicatedEmailMutate({ email: getValues('email') }); + }; + + const goSigninPage = () => navigate(`/${PATH.SIGNIN}`); + + const onSubmit = async ({ + email, + password, + name, + address, + addressDetail, + phoneNumber, + sparePhoneNumber, + isOpenedAddress, + isEmailDuplicated, + }: Schema) => { + if (isEmailDuplicated) { + toast({ + position: 'top', + description: '이메일 중복 확인을 해주세요', + status: 'error', + duration: 1500, + }); + + return; + } + + signupShelterMutate({ + email, + password, + name, + address, + addressDetail, + phoneNumber, + sparePhoneNumber, + isOpenedAddress, + }); + }; + + return ( + +
+ +
+
+ + 이메일 + + + + {watchIsEmailDuplicated ? '확인' : '초기화'} + + + + {errors.email && errors.email.message} + + + + 비밀번호 + + + + + + + + 영대문자, 영소문자, 숫자, 특수문자 조합 8자리 이상 +
+ 특수문자: {`!@#$%^&*()-_=+[\\]{};:'",<.>/?`} +
+ + {errors.password && errors.password.message} + +
+ + 비밀번호 확인 + + + + + + + + {errors.passwordConfirm && errors.passwordConfirm.message} + + + + 보호소 이름 + + + {errors.name && errors.name.message} + + + + 보호소 주소 + + + {errors.address && errors.address.message} + + + + + + 보호소 상세주소 + + + + 상세주소 공개 + + ( + + )} + /> + + + + + {errors.isOpenedAddress && errors.isOpenedAddress.message} + + + {errors.addressDetail && errors.addressDetail.message} + + + + 보호소 전화번호 + + 형식: 01012345678 + + {errors.phoneNumber && errors.phoneNumber.message} + + + + 보호소 임시 전화번호 + + + {errors.sparePhoneNumber && errors.sparePhoneNumber.message} + + + + + + +
+
+ ); +} diff --git a/apps/shelter/src/pages/volunteers/_components/PlusIcon.tsx b/apps/shelter/src/pages/volunteers/_components/PlusIcon.tsx new file mode 100644 index 00000000..5706c585 --- /dev/null +++ b/apps/shelter/src/pages/volunteers/_components/PlusIcon.tsx @@ -0,0 +1,13 @@ +export default function PlusIcon() { + return ( + + + + ); +} diff --git a/apps/shelter/src/pages/volunteers/_components/RecruitDateText.tsx b/apps/shelter/src/pages/volunteers/_components/RecruitDateText.tsx new file mode 100644 index 00000000..2a2f686a --- /dev/null +++ b/apps/shelter/src/pages/volunteers/_components/RecruitDateText.tsx @@ -0,0 +1,15 @@ +import { Text } from '@chakra-ui/react'; + +type RecruitDateTextProps = { + title: string; + date: string; + time: string; +}; + +export default function RecruitDateText({ + title, + date, + time, +}: RecruitDateTextProps) { + return {`${title} | ${date} • ${time}`}; +} diff --git a/apps/shelter/src/pages/volunteers/_components/RecruitItem.tsx b/apps/shelter/src/pages/volunteers/_components/RecruitItem.tsx new file mode 100644 index 00000000..e13fe287 --- /dev/null +++ b/apps/shelter/src/pages/volunteers/_components/RecruitItem.tsx @@ -0,0 +1,233 @@ +import { + Box, + Button, + Flex, + HStack, + Image, + Menu, + MenuButton, + MenuItem, + MenuList, + Spacer, + Text, + VStack, +} from '@chakra-ui/react'; +import MenuIcon from 'shared/assets/icon_menu.svg'; +import ApplicantStatus from 'shared/components/ApplicantStatus'; +import Label from 'shared/components/Label'; +import LabelText from 'shared/components/LabelText'; +import { createFormattedTime, getDDay } from 'shared/utils/date'; + +import RecruitDateText from './RecruitDateText'; + +type Recruitment = { + recruitmentId: number; + recruitmentTitle: string; + recruitmentStartTime: string; + recruitmentEndTime: string; + recruitmentDeadline: string; + recruitmentIsClosed: boolean; + recruitmentApplicantCount: number; + recruitmentCapacity: number; +}; + +type RecruitItemProps = { + showMenuButton?: boolean; + onUpdate?: VoidFunction; + onDelete?: VoidFunction; + onClickManageApplyButton: VoidFunction; + onClickManageAttendanceButton: VoidFunction; + onClickCloseRecruitButton: VoidFunction; +} & Recruitment; + +export default function RecruitItem({ + showMenuButton = true, + onUpdate = () => {}, + onDelete = () => {}, + recruitmentId, + recruitmentTitle, + recruitmentStartTime, + recruitmentEndTime, + recruitmentDeadline, + recruitmentIsClosed, + recruitmentApplicantCount, + recruitmentCapacity, + onClickCloseRecruitButton, + onClickManageApplyButton, + onClickManageAttendanceButton, +}: RecruitItemProps) { + return ( + + + + {recruitmentIsClosed ? ( + + {recruitmentIsClosed ? ( + + ) : ( + + )} + + {showMenuButton && } + + ); +} + +function RecruitingButtons({ + onClickManageApplyButton, + onClickCloseRecruitButton, +}: Pick< + RecruitItemProps, + 'onClickCloseRecruitButton' | 'onClickManageApplyButton' +>) { + return ( + + + + + ); +} + +function AttendanceManagementButton({ + onClickManageAttendanceButton, +}: Pick) { + return ( + + ); +} + +function CustomMenu({ + onUpdate, + onDelete, +}: { + onUpdate: VoidFunction; + onDelete: VoidFunction; +}) { + return ( + + + menu icon + + + 수정하기 + 삭제하기 + 닫기 + + + ); +} diff --git a/apps/shelter/src/pages/volunteers/_queryOptions/recruitment.ts b/apps/shelter/src/pages/volunteers/_queryOptions/recruitment.ts new file mode 100644 index 00000000..c21e82f8 --- /dev/null +++ b/apps/shelter/src/pages/volunteers/_queryOptions/recruitment.ts @@ -0,0 +1,69 @@ +import { + InfiniteData, + infiniteQueryOptions, + UseInfiniteQueryOptions, +} from '@tanstack/react-query'; +import { AxiosError, AxiosResponse } from 'axios'; +import { ErrorResponseData } from 'shared/types/apis/error'; + +import { getShelterRecruitments } from '@/apis/recruitment'; +import { + RecruitementsResponse, + RecruitmentSearchFilter, +} from '@/types/apis/recruitment'; + +type RecruitmentQueryOptions = { + all: () => UseInfiniteQueryOptions< + AxiosResponse, + AxiosError, + InfiniteData, unknown>, + AxiosResponse, + string[], + number + >; + search: ( + searchFilter: Partial, + isKeywordSearched: boolean, + ) => UseInfiniteQueryOptions< + AxiosResponse, + AxiosError, + InfiniteData, unknown>, + AxiosResponse, + (string | Partial)[], + number + >; +}; + +const recruitmentQueryOptions: RecruitmentQueryOptions = { + all: () => + infiniteQueryOptions({ + queryKey: ['recruitments'], + queryFn: ({ pageParam }) => + getShelterRecruitments({ + pageNumber: pageParam, + pageSize: 10, + }), + initialPageParam: 0, + getNextPageParam: ({ data: { pageInfo } }, _, lastPageParam) => + pageInfo.hasNext ? lastPageParam + 1 : null, + }), + search: ( + searchFilter: Partial, + isKeywordSearched: boolean, + ) => + infiniteQueryOptions({ + enabled: isKeywordSearched, + queryKey: ['recruitments', searchFilter], + queryFn: ({ pageParam }) => + getShelterRecruitments({ + ...searchFilter, + pageNumber: pageParam, + pageSize: 10, + }), + initialPageParam: 0, + getNextPageParam: ({ data: { pageInfo } }, _, lastPageParam) => + pageInfo.hasNext ? lastPageParam + 1 : null, + }), +}; + +export default recruitmentQueryOptions; diff --git a/apps/shelter/src/pages/volunteers/detail/_hooks/useGetVolunteerDetail.ts b/apps/shelter/src/pages/volunteers/detail/_hooks/useGetVolunteerDetail.ts new file mode 100644 index 00000000..e938138e --- /dev/null +++ b/apps/shelter/src/pages/volunteers/detail/_hooks/useGetVolunteerDetail.ts @@ -0,0 +1,56 @@ +import { useQuery } from '@tanstack/react-query'; +import { getRecruitmentDetail } from 'shared/apis/common/Recruitments'; +import { + createFormattedTime, + createWeekDayLocalString, +} from 'shared/utils/date'; + +const useGetVolunteerDetail = (recruitmentId: number) => { + return useQuery({ + queryKey: ['recruitment', recruitmentId], + queryFn: async () => (await getRecruitmentDetail(recruitmentId)).data, + select: (data) => { + const startDate = new Date(data.recruitmentStartTime); + const endDate = new Date(data.recruitmentEndTime); + const deadLine = new Date(data.recruitmentDeadline); + + return { + imageUrls: data.recruitmentImageUrls, + title: data.recruitmentTitle, + content: data.recruitmentContent, + applicant: data.recruitmentApplicantCount, + capacity: data.recruitmentCapacity, + volunteerDay: `${createFormattedTime( + startDate, + )}(${createWeekDayLocalString(startDate)})`, + recruitmentDeadline: `${createFormattedTime( + deadLine, + )}(${createWeekDayLocalString(deadLine)}) ${createFormattedTime( + deadLine, + 'hh:mm', + )}`, + volunteerStartTime: createFormattedTime(startDate, 'hh:mm'), + volunteerEndTime: createFormattedTime(endDate, 'hh:mm'), + recruitmentCreatedAt: createFormattedTime( + new Date(data.recruitmentCreatedAt), + ), + recruitmentIsClosed: data.recruitmentIsClosed, + }; + }, + initialData: { + recruitmentTitle: '', + recruitmentApplicantCount: 0, + recruitmentCapacity: 0, + recruitmentContent: '', + recruitmentStartTime: '', + recruitmentEndTime: '', + recruitmentIsClosed: false, + recruitmentDeadline: '', + recruitmentCreatedAt: '', + recruitmentUpdatedAt: '', + recruitmentImageUrls: [], + }, + }); +}; + +export default useGetVolunteerDetail; diff --git a/apps/shelter/src/pages/volunteers/detail/index.tsx b/apps/shelter/src/pages/volunteers/detail/index.tsx new file mode 100644 index 00000000..f97d9f8f --- /dev/null +++ b/apps/shelter/src/pages/volunteers/detail/index.tsx @@ -0,0 +1,164 @@ +import { + Box, + Button, + Divider, + HStack, + Text, + useDisclosure, + VStack, +} from '@chakra-ui/react'; +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import AlertModal from 'shared/components/AlertModal'; +import ImageCarousel from 'shared/components/ImageCarousel'; +import InfoTextList from 'shared/components/InfoTextList'; +import { LabelProps } from 'shared/components/Label'; +import LabelText from 'shared/components/LabelText'; +import useDetailHeaderStore from 'shared/store/detailHeaderStore'; +import { getDDay } from 'shared/utils/date'; + +import useGetVolunteerDetail from './_hooks/useGetVolunteerDetail'; + +const handleDeletePost = (postId: number) => { + // TODO: VolunteerPost delete API 호출 + console.log('[Delete Volunteer] postId:', postId); +}; + +export default function VolunteersDetailPage() { + const setOnDelete = useDetailHeaderStore((state) => state.setOnDelete); + + useEffect(() => { + setOnDelete(handleDeletePost); + + return () => { + setOnDelete(() => {}); + }; + }, [setOnDelete]); + + const navigate = useNavigate(); + const { id: recruitmentId } = useParams(); + + const { isOpen, onOpen, onClose } = useDisclosure(); + const { + imageUrls, + title, + content, + applicant, + capacity, + volunteerDay, + recruitmentDeadline, + volunteerStartTime, + volunteerEndTime, + recruitmentCreatedAt, + recruitmentIsClosed, + } = useGetVolunteerDetail(100).data; + + const [label, setLabel] = useState({ + labelTitle: '모집중', + type: 'GREEN', + }); + const [isClosed, setIsClosed] = useState(false); + + useEffect(() => { + if (recruitmentIsClosed) { + setIsClosed(true); + setLabel({ labelTitle: '마감완료', type: 'GRAY' }); + } + }, [recruitmentIsClosed]); + + const goManageApply = () => navigate(`/manage/apply/${recruitmentId}`); + const goManageAttendance = () => + navigate(`/manage/attendance/${recruitmentId}`); + const onCloseRecruitment = () => { + onClose(); + setIsClosed(true); + setLabel({ labelTitle: '마감완료', type: 'GRAY' }); + }; + + return ( + + + + + + {title} + + + 작성일 | {recruitmentCreatedAt}(수정됨) + + + + + + + + {content} + + + + {isClosed ? ( + + ) : ( + <> + + + + )} + + + + ); +} diff --git a/apps/shelter/src/pages/volunteers/index.tsx b/apps/shelter/src/pages/volunteers/index.tsx new file mode 100644 index 00000000..c4e4bdf9 --- /dev/null +++ b/apps/shelter/src/pages/volunteers/index.tsx @@ -0,0 +1,88 @@ +import { IconButton } from '@chakra-ui/react'; +import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; +import { Suspense } from 'react'; +import { useNavigate } from 'react-router-dom'; +import useIntersect from 'shared/hooks/useIntersection'; + +import recruitmentQueryOptions from '@/pages/volunteers/_queryOptions/recruitment'; + +import PlusIcon from './_components/PlusIcon'; +import RecruitItem from './_components/RecruitItem'; + +function Recruitments() { + const navigate = useNavigate(); + + const goToManageApplyPage = (postId: number) => { + navigate(`/manage/apply/${postId}`); + }; + const goToManageAttendancePage = (postId: number) => { + navigate(`/manage/attendance/${postId}`); + }; + const goToUpdatePage = (postId: number) => { + navigate(`/volunteers/write/${postId}`); + }; + + //TODO 삭제 버튼 눌렀을 때 기능 추가 + + //TODO recruit id 받아서 마감 + const closeRecruit = () => {}; + + const goToWritePage = () => navigate('/volunteers/write'); + + const { + data: { pages }, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useSuspenseInfiniteQuery(recruitmentQueryOptions.all()); + + const recruitments = pages.flatMap(({ data }) => data.recruitments); + + const ref = useIntersect(async (entry, observer) => { + observer.unobserve(entry.target); + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }); + + return ( + <> + {recruitments.map((recruitment) => ( + + goToManageApplyPage(recruitment.recruitmentId) + } + onClickManageAttendanceButton={() => + goToManageAttendancePage(recruitment.recruitmentId) + } + onClickCloseRecruitButton={closeRecruit} + onUpdate={() => goToUpdatePage(recruitment.recruitmentId)} + /> + ))} +
+ } + pos="fixed" + bottom="4.125rem" + right={4} + borderRadius="full" + bgColor="orange.400" + color="white" + onClick={goToWritePage} + boxShadow="lg" + /> + + ); +} + +export default function VolunteersPage() { + return ( + 글목록 로딩중...

}> + +
+ ); +} diff --git a/apps/shelter/src/pages/volunteers/profile/index.tsx b/apps/shelter/src/pages/volunteers/profile/index.tsx new file mode 100644 index 00000000..dd8105c0 --- /dev/null +++ b/apps/shelter/src/pages/volunteers/profile/index.tsx @@ -0,0 +1,3 @@ +export default function VolunteersProfilePage() { + return

VolunteersProfilePage

; +} diff --git a/apps/shelter/src/pages/volunteers/search/_components/RecruitmentsSearchFilter.tsx b/apps/shelter/src/pages/volunteers/search/_components/RecruitmentsSearchFilter.tsx new file mode 100644 index 00000000..65415aa6 --- /dev/null +++ b/apps/shelter/src/pages/volunteers/search/_components/RecruitmentsSearchFilter.tsx @@ -0,0 +1,49 @@ +import { ChangeEvent } from 'react'; +import SearchFilters, { + SearchFilterSelectData, +} from 'shared/components/SearchFilters'; +import { PERIOD } from 'shared/constants/period'; + +import { + RECRUITMENT_STATUS, + SEARCH_TYPE, +} from '@/pages/volunteers/search/_constants/filter'; +import { SearchFilter } from '@/pages/volunteers/search/_types/filter'; + +type RecruitmentsSearchFilterProps = { + searchFilter: Partial; + onChangeFilter: (event: ChangeEvent) => void; +}; + +export default function RecruitmentsSearchFilter({ + searchFilter, + onChangeFilter, +}: RecruitmentsSearchFilterProps) { + const searchFilters: SearchFilterSelectData[] = [ + { + selectOption: PERIOD, + name: 'period', + placeholder: '봉사일', + value: searchFilter.period, + }, + { + selectOption: RECRUITMENT_STATUS, + name: 'recruitmentStatus', + placeholder: '모집', + value: searchFilter.recruitmentStatus, + }, + { + selectOption: SEARCH_TYPE, + name: 'searchType', + placeholder: '전체', + value: searchFilter.searchType, + }, + ]; + + return ( + + ); +} diff --git a/apps/shelter/src/pages/volunteers/search/_constants/filter.ts b/apps/shelter/src/pages/volunteers/search/_constants/filter.ts new file mode 100644 index 00000000..9356e5f0 --- /dev/null +++ b/apps/shelter/src/pages/volunteers/search/_constants/filter.ts @@ -0,0 +1,9 @@ +export const RECRUITMENT_STATUS = { + IS_OPENED: '모집 중', + IS_CLOSED: '모집 완료', +} as const; + +export const SEARCH_TYPE = { + IS_TITLE: '제목 포함', + IS_CONTENT: '내용 포함', +} as const; diff --git a/apps/shelter/src/pages/volunteers/search/_hooks/useRecruitmentSearch.ts b/apps/shelter/src/pages/volunteers/search/_hooks/useRecruitmentSearch.ts new file mode 100644 index 00000000..0ee4ed46 --- /dev/null +++ b/apps/shelter/src/pages/volunteers/search/_hooks/useRecruitmentSearch.ts @@ -0,0 +1,25 @@ +import { ChangeEvent } from 'react'; +import { useSearchFilter } from 'shared/hooks/useSearchFilter'; +import { useSearchKeyword } from 'shared/hooks/useSearchKeyword'; + +import { SearchFilter } from '@/pages/volunteers/search/_types/filter'; + +export const useRecruitmentSearch = () => { + const [searchFilter, setSearchFilter] = useSearchFilter(); + + const setKeywordFilter = (keyword: string) => setSearchFilter({ keyword }); + + useSearchKeyword(setKeywordFilter); + + const handleChangeSearchFilter = (event: ChangeEvent) => { + const { name, value } = event.target; + + setSearchFilter({ [name]: value }); + }; + + return { + searchFilter, + isKeywordSearched: Boolean(searchFilter.keyword), + handleChangeSearchFilter, + }; +}; diff --git a/apps/shelter/src/pages/volunteers/search/_types/filter.ts b/apps/shelter/src/pages/volunteers/search/_types/filter.ts new file mode 100644 index 00000000..b1712e71 --- /dev/null +++ b/apps/shelter/src/pages/volunteers/search/_types/filter.ts @@ -0,0 +1,16 @@ +import { Period } from 'shared/types/period'; + +import { + RECRUITMENT_STATUS, + SEARCH_TYPE, +} from '@/pages/volunteers/search/_constants/filter'; + +export type RecruitmentStatus = keyof typeof RECRUITMENT_STATUS; +export type SearchType = keyof typeof SEARCH_TYPE; + +export type SearchFilter = { + keyword: string; + period: Period; + recruitmentStatus: RecruitmentStatus; + searchType: SearchType; +}; diff --git a/apps/shelter/src/pages/volunteers/search/index.tsx b/apps/shelter/src/pages/volunteers/search/index.tsx new file mode 100644 index 00000000..4dbc5f0d --- /dev/null +++ b/apps/shelter/src/pages/volunteers/search/index.tsx @@ -0,0 +1,98 @@ +import { Box } from '@chakra-ui/react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import useIntersect from 'shared/hooks/useIntersection'; +import { getDatesFromPeriod } from 'shared/utils/period'; + +import RecruitItem from '@/pages/volunteers/_components/RecruitItem'; +import recruitmentQueryOptions from '@/pages/volunteers/_queryOptions/recruitment'; +import RecruitmentsSearchFilter from '@/pages/volunteers/search/_components/RecruitmentsSearchFilter'; +import { useRecruitmentSearch } from '@/pages/volunteers/search/_hooks/useRecruitmentSearch'; +import { SearchFilter } from '@/pages/volunteers/search/_types/filter'; +import { RecruitmentSearchFilter } from '@/types/apis/recruitment'; + +const getVolunteerSearchRequestFilter = ( + searchFilter: Partial, +): Partial => { + const { keyword, period, recruitmentStatus, searchType } = searchFilter; + const { startDate, endDate } = getDatesFromPeriod(period); + + return { + keyword, + startDate, + endDate, + closedFilter: recruitmentStatus, + keywordFilter: searchType, + }; +}; + +export default function VolunteersSearchPage() { + const { isKeywordSearched, searchFilter, handleChangeSearchFilter } = + useRecruitmentSearch(); + + const navigate = useNavigate(); + + const goToManageApplyPage = (postId: number) => { + navigate(`/manage/apply/${postId}`); + }; + const goToManageAttendancePage = (postId: number) => { + navigate(`/manage/attendance/${postId}`); + }; + const goToUpdatePage = (postId: number) => { + navigate(`/volunteers/write/${postId}`); + }; + + //TODO 삭제 버튼 눌렀을 때 기능 추가 + + //TODO recruit id 받아서 마감 + const closeRecruit = () => {}; + + const { data, hasNextPage, isFetchingNextPage, fetchNextPage, isLoading } = + useInfiniteQuery( + recruitmentQueryOptions.search( + getVolunteerSearchRequestFilter(searchFilter), + isKeywordSearched, + ), + ); + + const recruitments = data?.pages.flatMap(({ data }) => data.recruitments); + + const ref = useIntersect(async (entry, observer) => { + observer.unobserve(entry.target); + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }); + + if (!isKeywordSearched) { + return null; + } + + if (isLoading) { + return

로딩중

; + } + + return ( + + + {recruitments?.map((recruitment) => ( + + goToManageApplyPage(recruitment.recruitmentId) + } + onClickManageAttendanceButton={() => + goToManageAttendancePage(recruitment.recruitmentId) + } + onClickCloseRecruitButton={closeRecruit} + onUpdate={() => goToUpdatePage(recruitment.recruitmentId)} + /> + ))} +
+ + ); +} diff --git a/apps/shelter/src/pages/volunteers/update/index.tsx b/apps/shelter/src/pages/volunteers/update/index.tsx new file mode 100644 index 00000000..871ee98c --- /dev/null +++ b/apps/shelter/src/pages/volunteers/update/index.tsx @@ -0,0 +1,217 @@ +import { + Box, + Button, + Flex, + FormControl, + FormErrorMessage, + FormHelperText, + FormLabel, + Input, + InputGroup, + InputRightAddon, + Textarea, +} from '@chakra-ui/react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { useNavigate, useParams } from 'react-router-dom'; +import EditPhotoList from 'shared/components/EditPhotoList'; +import * as z from 'zod'; + +import { updateShelterRecruitment } from '@/apis/recruitment'; +import type { RecruitmentUpdateRequest } from '@/types/apis/recruitment'; + +import useGetVolunteerDetail from '../detail/_hooks/useGetVolunteerDetail'; + +const recruitmentSchema = z + .object({ + title: z.string().min(1, '제목은 필수로 입력해주세요'), + startTime: z.coerce.date(), + endTime: z.coerce.date(), + deadline: z.coerce.date(), + capacity: z.coerce.number(), + content: z + .string() + .optional() + .refine((val) => val?.length && val.length < 500, '에러입니다'), + }) + .refine(({ startTime, endTime }) => startTime.getTime() < endTime.getTime(), { + message: '봉사 시작 일시 이후로 입력해주세요 ', + path: ['endTime'], + }) + .refine( + ({ startTime, deadline }) => deadline.getTime() <= startTime.getTime(), + { + message: '봉사 시작 일시 전으로 입력해주세요', + path: ['deadLine'], + }, + ); + +type RecruitmentSchema = z.infer; + +const DUMMY_IMAGE = 'https://source.unsplash.com/random'; +const DUMMY_IMAGE_URLS = Array.from({ length: 2 }, () => DUMMY_IMAGE); + +export default function VolunteersUpdatePage() { + const { id: recruitmentId } = useParams<{ id: string }>() as { id: string }; + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + // TODO 이 훅에서 startDate, endDate와 같은 날짜데이터를 가공하기 때문에 + // 다른 훅을 만들어서 사용해야 할 것 같습니다. + // 혹은 훅 내의 select 옵션을 수정해야 할 것 같습니다. + const { data: recruitment, isPending: isRecruitFetchLoading } = + useGetVolunteerDetail(Number(recruitmentId)); + + const { + register, + handleSubmit, + watch, + setValue, + setFocus, + formState: { errors }, + } = useForm({ + resolver: zodResolver(recruitmentSchema), + }); + const [imageUrls, setImageUrls] = useState(DUMMY_IMAGE_URLS); + + const contentLength = watch('content')?.length ?? 0; + + const { mutate, isPending } = useMutation({ + mutationFn: ({ + recruitmentId, + request, + }: { + recruitmentId: number; + request: RecruitmentUpdateRequest; + }) => updateShelterRecruitment(recruitmentId, request), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ['recruitment', recruitmentId], + }); + navigate(`/volunteers/${recruitmentId}`); + }, + onError: (error) => { + console.warn(error); + }, + }); + + const onSubmit: SubmitHandler = ( + data: RecruitmentSchema, + ) => { + // alert(JSON.stringify(data)); + const { startTime, endTime, deadline } = data; + + mutate({ + recruitmentId: Number(recruitmentId), + request: { + ...data, + startTime: String(startTime), + endTime: String(endTime), + deadline: String(deadline), + }, + }); + }; + + useEffect(() => { + setValue('title', recruitment.title); + setValue('startTime', new Date(recruitment.volunteerStartTime)); + setValue('endTime', new Date(recruitment.volunteerEndTime)); + setValue('deadline', new Date(recruitment.recruitmentDeadline)); + setValue('capacity', recruitment.capacity); + setValue('content', recruitment?.content ?? ''); + setImageUrls(recruitment.imageUrls); + setFocus('title'); + }, [recruitment, setFocus, setValue]); + + if (isRecruitFetchLoading) { + return

...로딩중

; + } + + return ( + +
+ + 제목 + + + + 봉사시작 일시 + + {errors.startTime?.message} + + + 봉사종료 일시 + + {errors.endTime?.message} + + + 모집 마감 일시 + + {errors.deadline?.message} + + + 모집 인원 + + + + + + + 모집글 상세 +