From 946d15bc69e3b956ba5ebb3a1c5d55794b3d650d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 22 Aug 2024 20:48:39 +0900 Subject: [PATCH] =?UTF-8?q?feat-fe:=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=20=EB=82=B4=20=EC=A7=80=EC=9B=90=EC=84=9C=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B0=8F=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=20(#365)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Seongjin Hong --- frontend/src/api/domain/question.ts | 19 ++ frontend/src/api/endPoint.ts | 2 + .../ApplyManagement.stories.tsx | 49 +++++ .../src/components/applyManagement/index.tsx | 120 ++++++++++++ .../src/components/applyManagement/style.ts | 148 ++++++++++++++ frontend/src/components/common/Tab/style.ts | 2 +- .../Apply/QuestionBuilder/index.tsx | 2 +- .../dashboard/DashboardCreate/Apply/index.tsx | 18 +- frontend/src/constants/constants.ts | 23 +++ .../src/hooks/useApplyManagement/index.tsx | 181 ++++++++++++++++++ .../hooks/useDashboardCreateForm/index.tsx | 20 +- frontend/src/mocks/applyForm.json | 8 +- frontend/src/mocks/handlers/index.ts | 2 + .../src/mocks/handlers/questionHandlers.ts | 28 +++ frontend/src/pages/Dashboard/index.tsx | 7 +- frontend/src/types/apply.ts | 2 + frontend/src/types/question.ts | 16 ++ frontend/src/utils/createSimpleKey.ts | 9 + 18 files changed, 627 insertions(+), 29 deletions(-) create mode 100644 frontend/src/api/domain/question.ts create mode 100644 frontend/src/components/applyManagement/ApplyManagement.stories.tsx create mode 100644 frontend/src/components/applyManagement/index.tsx create mode 100644 frontend/src/components/applyManagement/style.ts create mode 100644 frontend/src/hooks/useApplyManagement/index.tsx create mode 100644 frontend/src/mocks/handlers/questionHandlers.ts create mode 100644 frontend/src/types/question.ts create mode 100644 frontend/src/utils/createSimpleKey.ts diff --git a/frontend/src/api/domain/question.ts b/frontend/src/api/domain/question.ts new file mode 100644 index 000000000..52642415a --- /dev/null +++ b/frontend/src/api/domain/question.ts @@ -0,0 +1,19 @@ +import { QUESTIONS } from '@api/endPoint'; +import { convertParamsToQueryString } from '@api/utils'; +import { ModifyQuestionData } from '@customTypes/question'; + +import APIClient from '@api/APIClient'; + +const apiClient = new APIClient(QUESTIONS); + +const questionApis = { + patch: async ({ applyformId, questions }: { applyformId: string; questions: ModifyQuestionData[] }) => + apiClient.patch({ + path: `?${convertParamsToQueryString({ + applyformId, + })}`, + body: { questions }, + }), +}; + +export default questionApis; diff --git a/frontend/src/api/endPoint.ts b/frontend/src/api/endPoint.ts index 69094751f..c06a1ebaa 100644 --- a/frontend/src/api/endPoint.ts +++ b/frontend/src/api/endPoint.ts @@ -13,3 +13,5 @@ export const DASHBOARDS = `${BASE_URL}/dashboards`; export const AUTH = `${BASE_URL}/auth`; export const MEMBERS = `${BASE_URL}/members`; + +export const QUESTIONS = `${BASE_URL}/questions`; diff --git a/frontend/src/components/applyManagement/ApplyManagement.stories.tsx b/frontend/src/components/applyManagement/ApplyManagement.stories.tsx new file mode 100644 index 000000000..82171421d --- /dev/null +++ b/frontend/src/components/applyManagement/ApplyManagement.stories.tsx @@ -0,0 +1,49 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import type { Meta, StoryObj } from '@storybook/react'; +import { reactRouterParameters } from 'storybook-addon-remix-react-router'; +import ApplyManagement from './index'; + +const meta: Meta = { + title: 'Components/Dashboard/ApplyManagement', + component: ApplyManagement, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'ApplyManagement 컴포넌트는 해당 공고의 지원서 사전 질문들을 수정하는 컴포넌트입니다.', + }, + }, + reactRouter: reactRouterParameters({ + location: { + pathParams: { postId: '1' }, + }, + routing: { path: '/postId/:postId' }, + }), + }, + tags: ['autodocs'], + decorators: [ + (Child) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( +
+ +
+ ), +}; diff --git a/frontend/src/components/applyManagement/index.tsx b/frontend/src/components/applyManagement/index.tsx new file mode 100644 index 000000000..a7a4110b4 --- /dev/null +++ b/frontend/src/components/applyManagement/index.tsx @@ -0,0 +1,120 @@ +import { useEffect, useRef } from 'react'; +import { useParams } from 'react-router-dom'; + +import useApplyManagement from '@hooks/useApplyManagement'; +import Button from '@components/common/Button'; +import QuestionBuilder from '@components/dashboard/DashboardCreate/Apply/QuestionBuilder'; +import { APPLY_QUESTION_HEADER, DEFAULT_QUESTIONS, MAX_QUESTION_LENGTH } from '@constants/constants'; + +import { HiOutlinePlusCircle } from 'react-icons/hi'; +import S from './style'; + +export default function ApplyManagement({ isVisible }: { isVisible: boolean }) { + const wrapperRef = useRef(null); + const { postId } = useParams<{ postId: string }>() as { + postId: string; + }; + + const { + isLoading, + applyState, + modifyApplyQuestionsMutator, + addQuestion, + setQuestionTitle, + setQuestionType, + setQuestionOptions, + setQuestionRequiredToggle, + setQuestionPrev, + setQuestionNext, + deleteQuestion, + } = useApplyManagement({ postId }); + + useEffect(() => { + if (isVisible && wrapperRef.current && !isLoading) { + wrapperRef.current.scrollTop = 0; + } + }, [isVisible, isLoading]); + + if (isLoading) { +
로딩 중입니다...
; + } + + const isNextBtnValid = + applyState.length === DEFAULT_QUESTIONS.length || + applyState + .slice(DEFAULT_QUESTIONS.length) + .every((question) => question.question.trim() && question.choices.length !== 1); + + const handleSubmit = (event: React.MouseEvent) => { + event.preventDefault(); + modifyApplyQuestionsMutator.mutate(); + }; + + return ( + + + +

{APPLY_QUESTION_HEADER.defaultQuestions.title}

+ {APPLY_QUESTION_HEADER.defaultQuestions.description} +
+ + 이름 + 이메일 + 전화번호 + +
+ + + +

{APPLY_QUESTION_HEADER.addQuestion.title}

+ {APPLY_QUESTION_HEADER.addQuestion.description} +
+ + {applyState.map((question, index) => { + if (index >= DEFAULT_QUESTIONS.length) { + return ( + + + + ); + } + return null; + })} + + {applyState.length < MAX_QUESTION_LENGTH && ( + + + 항목 추가 + + )} +
+ + + + + + +
+ ); +} diff --git a/frontend/src/components/applyManagement/style.ts b/frontend/src/components/applyManagement/style.ts new file mode 100644 index 000000000..afa46fc12 --- /dev/null +++ b/frontend/src/components/applyManagement/style.ts @@ -0,0 +1,148 @@ +import styled from '@emotion/styled'; + +const Wrapper = styled.div` + width: 100%; + height: 100%; + padding: 2rem 6rem; + + display: flex; + flex-direction: column; + gap: 4rem; + + overflow-y: auto; +`; + +const Section = styled.div` + display: flex; + flex-direction: column; + gap: 1.6rem; +`; + +const SectionTitleContainer = styled.div` + display: flex; + flex-direction: column; + gap: 0.8rem; + ${({ theme }) => theme.typography.common.default}; + + h2 { + ${({ theme }) => theme.typography.heading[500]}; + } + + span { + color: ${({ theme }) => theme.baseColors.grayscale[800]}; + } +`; + +const DefaultInputItemsContainer = styled.div` + display: flex; + flex-direction: column; + gap: 0.8rem; +`; + +const DefaultInputItem = styled.div` + width: 100%; + display: flex; + background: ${({ theme }) => theme.baseColors.grayscale[50]}; + border: 1px solid ${({ theme }) => theme.baseColors.grayscale[400]}; + border-radius: 0.8rem; + padding: 0.8rem 2.4rem; + + ${({ theme }) => theme.typography.common.default}; + font-weight: 600; + text-align: left; + vertical-align: middle; +`; + +const QuestionsContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: 3.6rem; +`; + +const AddQuestionButton = styled.button` + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 0.3rem; + padding: 0.8rem; + width: calc(100% - 3.6rem); + + background: ${({ theme }) => theme.baseColors.grayscale[50]}; + border: 1px dashed ${({ theme }) => theme.baseColors.grayscale[600]}; + border-radius: 0.8rem; + + ${({ theme }) => theme.typography.heading[500]}; + color: ${({ theme }) => theme.baseColors.grayscale[950]}; + transition: all 0.2s ease-in-out; + + :hover { + color: ${({ theme }) => theme.baseColors.purplescale[500]}; + border-color: ${({ theme }) => theme.baseColors.purplescale[500]}; + } +`; + +const StepButtonsContainer = styled.div` + display: flex; + flex-direction: row; + justify-content: flex-end; + gap: 2.4rem; +`; + +const StepButton = styled.button` + display: flex; + flex-direction: row; + align-items: center; + gap: 0.3rem; + + background: ${({ theme }) => theme.baseColors.grayscale[50]}; + border: 1px solid ${({ theme }) => theme.baseColors.grayscale[400]}; + border-radius: 0.8rem; + padding: 0.5rem 0.8rem; + + ${({ theme }) => theme.typography.common.default}; + font-weight: 700; + transition: all 0.2s ease-in-out; + + :hover { + border-color: ${({ theme }) => theme.baseColors.grayscale[700]}; + box-shadow: ${({ theme }) => `0px 2px 2px ${theme.baseColors.grayscale[400]}`}; + } +`; + +const ButtonContent = styled.div` + display: flex; + justify-content: center; + align-items: center; + + padding: 0 0.4rem; + gap: 0.4rem; +`; + +const ModifyButtonContainer = styled.div` + width: 100%; + max-width: 30rem; + height: 5.2rem; + margin: 0 auto; +`; + +const S = { + Wrapper, + Section, + SectionTitleContainer, + + DefaultInputItemsContainer, + DefaultInputItem, + + QuestionsContainer, + AddQuestionButton, + + StepButtonsContainer, + StepButton, + ButtonContent, + + ModifyButtonContainer, +}; + +export default S; diff --git a/frontend/src/components/common/Tab/style.ts b/frontend/src/components/common/Tab/style.ts index d23e03f5d..f552be4a4 100644 --- a/frontend/src/components/common/Tab/style.ts +++ b/frontend/src/components/common/Tab/style.ts @@ -54,7 +54,7 @@ const TabButton = styled.button<{ isActive: boolean }>` const TabPanel = styled.div<{ isVisible: boolean }>` width: 100%; height: 85%; - padding: 2rem 0rem; + padding: 2rem 0; ${hideScrollBar}; diff --git a/frontend/src/components/dashboard/DashboardCreate/Apply/QuestionBuilder/index.tsx b/frontend/src/components/dashboard/DashboardCreate/Apply/QuestionBuilder/index.tsx index a65e5e813..da49f1c92 100644 --- a/frontend/src/components/dashboard/DashboardCreate/Apply/QuestionBuilder/index.tsx +++ b/frontend/src/components/dashboard/DashboardCreate/Apply/QuestionBuilder/index.tsx @@ -38,7 +38,7 @@ export default function QuestionBuilder({ }: QuestionBuilderProps) { const [title, setTitle] = useState(question?.question || ''); const [currentQuestionType, setCurrentQuestionType] = useState(question?.type || 'SHORT_ANSWER'); - const [isRequired, setIsRequired] = useState(question?.required || true); + const [isRequired, setIsRequired] = useState(question ? question.required : true); const handleChangeTitle = (event: React.ChangeEvent) => { setTitle(event.target.value); diff --git a/frontend/src/components/dashboard/DashboardCreate/Apply/index.tsx b/frontend/src/components/dashboard/DashboardCreate/Apply/index.tsx index d319183b3..fe2458ed2 100644 --- a/frontend/src/components/dashboard/DashboardCreate/Apply/index.tsx +++ b/frontend/src/components/dashboard/DashboardCreate/Apply/index.tsx @@ -1,5 +1,7 @@ import { HiOutlinePlusCircle } from 'react-icons/hi'; import { Question, QuestionOptionValue } from '@customTypes/dashboard'; +import { APPLY_QUESTION_HEADER, DEFAULT_QUESTIONS, MAX_QUESTION_LENGTH } from '@constants/constants'; + import Button from '@components/common/Button'; import ChevronButton from '@components/common/ChevronButton'; import QuestionBuilder from './QuestionBuilder'; @@ -20,10 +22,6 @@ interface ApplyProps { nextStep: () => void; } -// 이름, 이메일, 전화번호의 3개 항목은 applyState에 언제나 기본으로 포함되어야 합니다. -const DEFAULT_QUESTION_LENGTH = 3; -const MAX_QUESTION_LENGTH = 20 + DEFAULT_QUESTION_LENGTH; - export default function Apply({ applyState, addQuestion, @@ -38,17 +36,17 @@ export default function Apply({ nextStep, }: ApplyProps) { const isNextBtnValid = - applyState.length === DEFAULT_QUESTION_LENGTH || + applyState.length === DEFAULT_QUESTIONS.length || applyState - .slice(DEFAULT_QUESTION_LENGTH) + .slice(DEFAULT_QUESTIONS.length) .every((question) => question.question.trim() && question.choices.length !== 1); return ( -

지원자 정보

- 아래 항목은 모든 지원자들에게 기본적으로 제출받는 항목입니다. +

{APPLY_QUESTION_HEADER.defaultQuestions.title}

+ {APPLY_QUESTION_HEADER.defaultQuestions.description}
이름 @@ -59,8 +57,8 @@ export default function Apply({ -

사전질문

- 지원자에게 질문하고 싶은 것이 있다면 입력해 주세요. (최대 20개) +

{APPLY_QUESTION_HEADER.addQuestion.title}

+ {APPLY_QUESTION_HEADER.addQuestion.description}
{applyState.map((question, index) => { diff --git a/frontend/src/constants/constants.ts b/frontend/src/constants/constants.ts index dc2d0ee6c..42b129287 100644 --- a/frontend/src/constants/constants.ts +++ b/frontend/src/constants/constants.ts @@ -1,5 +1,6 @@ import { RecruitmentPostTabItems } from '@components/recruitmentPost/RecruitmentPostTab'; import { DashboardTabItems } from '@pages/Dashboard'; +import type { Question } from '@customTypes/dashboard'; export const BASE_URL = `${process.env.API_URL}/${process.env.API_VERSION}`; @@ -7,6 +8,7 @@ export const DASHBOARD_TAB_MENUS: Record = { applicant: '지원자 관리', rejected: '불합격자 관리', process: '모집 과정 관리', + apply: '지원서 관리', } as const; export const RECRUITMENT_POST_MENUS: Record = { @@ -39,3 +41,24 @@ export const QUESTION_INPUT_LENGTH = { SHORT_ANSWER: 50, LONG_ANSWER: 1000, } as const; + +export const DEFAULT_QUESTIONS: Question[] = [ + { type: 'SHORT_ANSWER', question: '이름', choices: [], required: true, id: 0 }, + { type: 'SHORT_ANSWER', question: '이메일', choices: [], required: true, id: 1 }, + { type: 'SHORT_ANSWER', question: '전화번호', choices: [], required: true, id: 2 }, +] as const; + +export const EDITIONAL_QUESTION_LENGTH = 20; + +export const MAX_QUESTION_LENGTH = EDITIONAL_QUESTION_LENGTH + DEFAULT_QUESTIONS.length; + +export const APPLY_QUESTION_HEADER = { + defaultQuestions: { + title: '지원자 정보', + description: '아래 항목은 모든 지원자들에게 기본적으로 제출받는 항목입니다.', + }, + addQuestion: { + title: '사전질문', + description: `지원자에게 질문하고 싶은 것이 있다면 입력해 주세요. (최대 ${EDITIONAL_QUESTION_LENGTH}개)`, + }, +}; diff --git a/frontend/src/hooks/useApplyManagement/index.tsx b/frontend/src/hooks/useApplyManagement/index.tsx new file mode 100644 index 000000000..f64306320 --- /dev/null +++ b/frontend/src/hooks/useApplyManagement/index.tsx @@ -0,0 +1,181 @@ +import { useEffect, useState } from 'react'; +import type { Question, QuestionOptionValue } from '@customTypes/dashboard'; +import { Question as QuestionData } from '@customTypes/apply'; + +import { applyQueries } from '@hooks/apply'; +import { DEFAULT_QUESTIONS } from '@constants/constants'; +import { useMutation, UseMutationResult, useQueryClient } from '@tanstack/react-query'; +import questionApis from '@api/domain/question'; +import QUERY_KEYS from '@hooks/queryKeys'; + +interface UseApplyManagementReturn { + isLoading: boolean; + applyState: Question[]; + modifyApplyQuestionsMutator: UseMutationResult; + addQuestion: () => void; + setQuestionTitle: (index: number) => (title: string) => void; + setQuestionType: (index: number) => (type: Question['type']) => void; + setQuestionOptions: (index: number) => (Options: QuestionOptionValue[]) => void; + setQuestionRequiredToggle: (index: number) => () => void; + setQuestionPrev: (index: number) => () => void; + setQuestionNext: (index: number) => () => void; + deleteQuestion: (index: number) => void; +} + +interface UseApplyManagementProps { + postId: string; +} + +function getQuestions(data: QuestionData[] | undefined): Question[] { + if (!data) return []; + + return data + .sort((a, b) => a.orderIndex - b.orderIndex) + .map((question) => ({ + id: Number(question.questionId), + type: question.type, + question: question.label, + choices: question.choices.map((choice) => ({ + choice: choice.label, + orderIndex: choice.orderIndex, + })), + required: question.required, + })); +} + +export default function useApplyManagement({ postId }: UseApplyManagementProps): UseApplyManagementReturn { + const { data, isLoading } = applyQueries.useGetApplyForm({ postId: postId ?? '' }); + const [applyState, setApplyState] = useState(getQuestions(data)); + const [uniqueId, setUniqueId] = useState(DEFAULT_QUESTIONS.length); + const queryClient = useQueryClient(); + + useEffect(() => { + if (data && data.length > 0) { + const newData = getQuestions(data); + const newApplyState = [...DEFAULT_QUESTIONS, ...newData]; + setApplyState(newApplyState); + setUniqueId(newApplyState.length); + return; + } + + setApplyState([...DEFAULT_QUESTIONS]); + setUniqueId(DEFAULT_QUESTIONS.length); + }, [data]); + + const modifyApplyQuestionsMutator = useMutation({ + mutationFn: () => + questionApis.patch({ + applyformId: postId, + questions: applyState.slice(DEFAULT_QUESTIONS.length).map((value, index) => ({ + orderIndex: index, + type: value.type, + question: value.question, + choices: value.choices.filter(({ choice }) => !!choice), + required: value.required, + })), + }), + onSuccess: async () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.RECRUITMENT_INFO, postId] }); + alert('지원서의 사전 질문 항목 수정에 성공했습니다.'); + }, + onError: () => { + alert('지원서의 사전 질문 항목 수정에 실패했습니다.'); + }, + }); + + const addQuestion = () => { + setApplyState((prev) => [ + ...prev, + { type: 'SHORT_ANSWER', question: '', choices: [], required: true, id: uniqueId }, + ]); + + setUniqueId(uniqueId + 1); + }; + + const setQuestionTitle = (index: number) => (string: string) => { + setApplyState((prevState) => { + const questionsCopy = [...prevState]; + questionsCopy[index].question = string; + return questionsCopy; + }); + }; + + const setQuestionType = (index: number) => (type: Question['type']) => { + setApplyState((prevState) => { + const questionsCopy = [...prevState]; + questionsCopy[index].type = type; + if (type === 'SINGLE_CHOICE' || type === 'MULTIPLE_CHOICE') { + questionsCopy[index].choices = [{ choice: '', orderIndex: 0 }]; + } else { + questionsCopy[index].choices = []; + } + return questionsCopy; + }); + }; + + const setQuestionOptions = (index: number) => (options: QuestionOptionValue[]) => { + setApplyState((prevState) => { + const questionsCopy = [...prevState]; + questionsCopy[index].choices = options.map(({ choice }, i) => ({ choice, orderIndex: i })); + return questionsCopy; + }); + }; + + const setQuestionRequiredToggle = (index: number) => () => { + setApplyState((prevState) => { + const questionsCopy = [...prevState]; + questionsCopy[index].required = !prevState[index].required; + return questionsCopy; + }); + }; + + const setQuestionPrev = (index: number) => () => { + setApplyState((prevState) => { + if (index > DEFAULT_QUESTIONS.length) { + const questionsCopy = [...prevState]; + const temp = questionsCopy[index]; + questionsCopy[index] = questionsCopy[index - 1]; + questionsCopy[index - 1] = temp; + return questionsCopy; + } + return prevState; + }); + }; + + const setQuestionNext = (index: number) => () => { + setApplyState((prevState) => { + if (index >= DEFAULT_QUESTIONS.length && index < prevState.length - 1) { + const questionsCopy = [...prevState]; + const temp = questionsCopy[index]; + questionsCopy[index] = questionsCopy[index + 1]; + questionsCopy[index + 1] = temp; + return questionsCopy; + } + return prevState; + }); + }; + + const deleteQuestion = (index: number) => { + if (index < DEFAULT_QUESTIONS.length) return; + setApplyState((prevState) => { + const newState = prevState.filter((_, i) => i !== index); + return newState; + }); + }; + + return { + isLoading, + applyState, + + modifyApplyQuestionsMutator, + + addQuestion, + setQuestionTitle, + setQuestionType, + setQuestionOptions, + setQuestionRequiredToggle, + setQuestionPrev, + setQuestionNext, + deleteQuestion, + }; +} diff --git a/frontend/src/hooks/useDashboardCreateForm/index.tsx b/frontend/src/hooks/useDashboardCreateForm/index.tsx index 966a5b666..d40b9ca7b 100644 --- a/frontend/src/hooks/useDashboardCreateForm/index.tsx +++ b/frontend/src/hooks/useDashboardCreateForm/index.tsx @@ -1,4 +1,5 @@ import dashboardApis from '@api/domain/dashboard'; +import { DEFAULT_QUESTIONS } from '@constants/constants'; import type { Question, QuestionOptionValue, RecruitmentInfoState, StepState } from '@customTypes/dashboard'; import { useMutation, UseMutationResult } from '@tanstack/react-query'; import { useState } from 'react'; @@ -7,6 +8,7 @@ interface FinishResJson { postUrl: string; postId: string; } + interface UseDashboardCreateFormReturn { stepState: StepState; prevStep: () => void; @@ -43,18 +45,12 @@ const initialRecruitmentInfoState: RecruitmentInfoState = { postingContent: '', }; -const initialApplyState: Question[] = [ - { type: 'SHORT_ANSWER', question: '이름', choices: [], required: true, id: 0 }, - { type: 'SHORT_ANSWER', question: '이메일', choices: [], required: true, id: 1 }, - { type: 'SHORT_ANSWER', question: '전화번호', choices: [], required: true, id: 2 }, -]; - export default function useDashboardCreateForm(): UseDashboardCreateFormReturn { const [stepState, setStepState] = useState('recruitmentForm'); const [recruitmentInfoState, setRecruitmentInfoState] = useState(initialRecruitmentInfoState); - const [applyState, setApplyState] = useState(initialApplyState); + const [applyState, setApplyState] = useState(DEFAULT_QUESTIONS); const [finishResJson, setFinishResJson] = useState(null); - const [uniqueId, setUniqueId] = useState(3); + const [uniqueId, setUniqueId] = useState(DEFAULT_QUESTIONS.length); const submitMutator = useMutation({ mutationFn: ({ clubId }: { clubId: string }) => @@ -62,7 +58,7 @@ export default function useDashboardCreateForm(): UseDashboardCreateFormReturn { clubId, dashboardFormInfo: { ...recruitmentInfoState, - questions: applyState.slice(3).map(({ id, ...value }) => { + questions: applyState.slice(DEFAULT_QUESTIONS.length).map(({ id, ...value }) => { const temp = { ...value, choices: value.choices.filter(({ choice }) => !!choice) }; return { ...temp, orderIndex: id }; }), @@ -135,7 +131,7 @@ export default function useDashboardCreateForm(): UseDashboardCreateFormReturn { const setQuestionPrev = (index: number) => () => { setApplyState((prevState) => { - if (index > initialApplyState.length) { + if (index > DEFAULT_QUESTIONS.length) { const questionsCopy = [...prevState]; const temp = questionsCopy[index]; questionsCopy[index] = questionsCopy[index - 1]; @@ -148,7 +144,7 @@ export default function useDashboardCreateForm(): UseDashboardCreateFormReturn { const setQuestionNext = (index: number) => () => { setApplyState((prevState) => { - if (index >= initialApplyState.length && index < prevState.length - 1) { + if (index >= DEFAULT_QUESTIONS.length && index < prevState.length - 1) { const questionsCopy = [...prevState]; const temp = questionsCopy[index]; questionsCopy[index] = questionsCopy[index + 1]; @@ -160,7 +156,7 @@ export default function useDashboardCreateForm(): UseDashboardCreateFormReturn { }; const deleteQuestion = (index: number) => { - if (index < initialApplyState.length) return; + if (index < DEFAULT_QUESTIONS.length) return; setApplyState((prevState) => { const newState = prevState.filter((_, i) => i !== index); return newState; diff --git a/frontend/src/mocks/applyForm.json b/frontend/src/mocks/applyForm.json index 9ec308a6f..99eecd332 100644 --- a/frontend/src/mocks/applyForm.json +++ b/frontend/src/mocks/applyForm.json @@ -5,7 +5,7 @@ "endDate": "2024-10-31T23:59", "questions": [ { - "id": 1, + "id": 3, "type": "SHORT_ANSWER", "label": "1. 이전에 프로그래밍을 교육 받은 경험이 있다면 어디서 받았는지 간단히 적어주세요.", "description": "", @@ -14,7 +14,7 @@ "required": true }, { - "id": 2, + "id": 4, "type": "LONG_ANSWER", "label": "2. 효과적인 학습 방식과 경험", "description": "프로그래밍 학습을 하고 장기간 개발자로 살아가기 위해, 본인만의 효과적인 학습 방식을 찾는 것은 매우 중요합니다. 프로그래밍이 아니더라도 지금까지의 모든 학습 경험을 되돌아봤을 때, 본인에게 유용했던 학습 방식을 찾아낸 과정과 경험을 공유해 주세요. 그리고 이 경험은 현재 본인의 프로그래밍 학습 과정에 어떻게 적용되고 있나요? (1000자 이내)", @@ -23,7 +23,7 @@ "required": true }, { - "id": 4, + "id": 6, "type": "MULTIPLE_CHOICE", "label": "3. 다음 중 본인에게 해당하는 항목을 모두 선택해주세요.", "description": "", @@ -48,7 +48,7 @@ "required": true }, { - "id": 3, + "id": 5, "type": "SINGLE_CHOICE", "label": "8/6일 오리엔테이션에 참여할 수 있습니다.", "choices": [ diff --git a/frontend/src/mocks/handlers/index.ts b/frontend/src/mocks/handlers/index.ts index b2f7a0e63..f88416c43 100644 --- a/frontend/src/mocks/handlers/index.ts +++ b/frontend/src/mocks/handlers/index.ts @@ -5,6 +5,7 @@ import applyHandlers from './applyHandlers'; import dashboardHandlers from './dashboardHandlers'; import authHandlers from './authHandlers'; import membersHandlers from './memberHandlers'; +import questionHandlers from './questionHandlers'; const handlers = [ ...processHandlers, @@ -14,6 +15,7 @@ const handlers = [ ...applyHandlers, ...authHandlers, ...membersHandlers, + ...questionHandlers, ]; export default handlers; diff --git a/frontend/src/mocks/handlers/questionHandlers.ts b/frontend/src/mocks/handlers/questionHandlers.ts new file mode 100644 index 000000000..781215aab --- /dev/null +++ b/frontend/src/mocks/handlers/questionHandlers.ts @@ -0,0 +1,28 @@ +import { http, HttpResponse } from 'msw'; + +import { QUESTIONS } from '@api/endPoint'; +import { ApplyForm } from '@customTypes/apply'; +import { NotFoundError, Success } from './response'; + +const questionHandlers = [ + http.patch(QUESTIONS, async ({ request }) => { + const url = new URL(request.url); + const applyformId = url.searchParams.get('applyformId'); + const body = (await request.json()) as ApplyForm; + + if (!applyformId) { + return NotFoundError(); + } + + if (!body.questions) { + return new HttpResponse(null, { + status: 400, + statusText: 'questions not found', + }); + } + + return Success(); + }), +]; + +export default questionHandlers; diff --git a/frontend/src/pages/Dashboard/index.tsx b/frontend/src/pages/Dashboard/index.tsx index b1a601d8d..8a155b2ce 100644 --- a/frontend/src/pages/Dashboard/index.tsx +++ b/frontend/src/pages/Dashboard/index.tsx @@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom'; import Tab from '@components/common/Tab'; import ProcessBoard from '@components/dashboard/ProcessBoard'; +import ApplyManagement from '@components/applyManagement'; import ProcessManageBoard from '@components/processManagement/ProcessManageBoard'; import OpenInNewTab from '@components/common/OpenInNewTab'; import CopyToClipboard from '@components/common/CopyToClipboard'; @@ -16,7 +17,7 @@ import { SpecificProcessIdProvider } from '@contexts/SpecificProcessIdContext'; import S from './style'; -export type DashboardTabItems = '지원자 관리' | '모집 과정 관리' | '불합격자 관리'; +export type DashboardTabItems = '지원자 관리' | '모집 과정 관리' | '불합격자 관리' | '지원서 관리'; export default function Dashboard() { const { dashboardId, postId } = useParams() as { dashboardId: string; postId: string }; @@ -88,6 +89,10 @@ export default function Dashboard() { processes={processes} /> + + + + ); } diff --git a/frontend/src/types/apply.ts b/frontend/src/types/apply.ts index 5879ee707..4100f5b1f 100644 --- a/frontend/src/types/apply.ts +++ b/frontend/src/types/apply.ts @@ -13,9 +13,11 @@ export type QuestionType = keyof typeof QUESTION_TYPE_NAME; export interface Choice { id: number; label: string; + orderIndex: number; } export interface Question { + orderIndex: number; questionId: string; type: QuestionType; label: string; diff --git a/frontend/src/types/question.ts b/frontend/src/types/question.ts new file mode 100644 index 000000000..86c719e18 --- /dev/null +++ b/frontend/src/types/question.ts @@ -0,0 +1,16 @@ +import { QUESTION_TYPE_NAME } from '@constants/constants'; + +type QuestionType = keyof typeof QUESTION_TYPE_NAME; + +interface QuestionChoice { + choice: string; + orderIndex: number; +} + +export interface ModifyQuestionData { + orderIndex: number; + type: QuestionType; + question: string; + choices: QuestionChoice[]; + required: boolean; +} diff --git a/frontend/src/utils/createSimpleKey.ts b/frontend/src/utils/createSimpleKey.ts new file mode 100644 index 000000000..e076efb34 --- /dev/null +++ b/frontend/src/utils/createSimpleKey.ts @@ -0,0 +1,9 @@ +export default function createSimpleKey(input: string): string { + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const inputHash = input.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + + return Array.from({ length: 8 }).reduce((id: string, _, index) => { + const randomIndex = (inputHash + index) % characters.length; + return id + randomIndex; + }, ''); +}