diff --git a/public/images/org/imgFaq.png b/public/images/org/imgFaq.png new file mode 100644 index 00000000..76eeac33 Binary files /dev/null and b/public/images/org/imgFaq.png differ diff --git a/public/images/org/imgPartCurriculum.png b/public/images/org/imgPartCurriculum.png new file mode 100644 index 00000000..abc5c2ff Binary files /dev/null and b/public/images/org/imgPartCurriculum.png differ diff --git a/public/images/org/imgPartInfo1.png b/public/images/org/imgPartInfo1.png new file mode 100644 index 00000000..b0b9a474 Binary files /dev/null and b/public/images/org/imgPartInfo1.png differ diff --git a/public/images/org/imgPartInfo2.png b/public/images/org/imgPartInfo2.png new file mode 100644 index 00000000..380d0fce Binary files /dev/null and b/public/images/org/imgPartInfo2.png differ diff --git a/public/images/org/imgRecruitHeaderInfo.png b/public/images/org/imgRecruitHeaderInfo.png index 11e47d88..12b4cb5a 100644 Binary files a/public/images/org/imgRecruitHeaderInfo.png and b/public/images/org/imgRecruitHeaderInfo.png differ diff --git a/src/components/icons/IconXCircleFilled.tsx b/src/components/icons/IconXCircleFilled.tsx new file mode 100644 index 00000000..1e2b1189 --- /dev/null +++ b/src/components/icons/IconXCircleFilled.tsx @@ -0,0 +1,19 @@ +const IconXCircleFilled = () => { + return ( + + + + ); +}; + +export default IconXCircleFilled; diff --git a/src/components/org/OrgAdmin/AboutSection/Curriculum/index.tsx b/src/components/org/OrgAdmin/AboutSection/Curriculum/index.tsx deleted file mode 100644 index 0474ffb7..00000000 --- a/src/components/org/OrgAdmin/AboutSection/Curriculum/index.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useState } from 'react'; -import { useFormContext } from 'react-hook-form'; - -import { PART_KO, PART_LIST, VALIDATION_CHECK } from '@/utils/org'; - -import RequiredIcon from '../../assets/RequiredIcon'; -import PartCategory from '../../PartCategory'; -import { StInput, StInputLabel, StTitle, StWrapper } from '../style'; -import { StContentWrapper, StItem, StList, StWeek } from './style'; - -const CURRICULUM = PART_LIST.reduce( - (acc, part) => { - acc[part] = Array.from({ length: 8 }); - return acc; - }, - {} as Record, -); - -interface CurriculumProps { - selectedPart: PART_KO; - onChangeSelectedPart: (part: PART_KO) => void; -} - -const Curriculum = ({ - selectedPart, - onChangeSelectedPart, -}: CurriculumProps) => { - const { - register, - formState: { errors }, - } = useFormContext(); - - const handleSetSelectedPart = (value: PART_KO) => { - onChangeSelectedPart(value); - }; - - return ( - - 커리큘럼 - - 파트별 커리큘럼 - - - - - - {CURRICULUM[selectedPart].map((_, idx) => ( - - 0{idx + 1} - - - ))} - - - - ); -}; - -export default Curriculum; diff --git a/src/components/org/OrgAdmin/AboutSection/index.tsx b/src/components/org/OrgAdmin/AboutSection/index.tsx index ea99ff8e..066fee33 100644 --- a/src/components/org/OrgAdmin/AboutSection/index.tsx +++ b/src/components/org/OrgAdmin/AboutSection/index.tsx @@ -1,7 +1,7 @@ import { EXEC_TYPE, PART_KO } from '@/utils/org'; +import Curriculum from '../RecruitSection/_components/Curriculum'; import CoreValue from './CoreValue'; -import Curriculum from './Curriculum'; import Executives from './Executives'; import HeaderBanner from './HeaderBanner'; import { StContainer } from './style'; diff --git a/src/components/org/OrgAdmin/AboutSection/style.ts b/src/components/org/OrgAdmin/AboutSection/style.ts index 39f385a0..32f2fc83 100644 --- a/src/components/org/OrgAdmin/AboutSection/style.ts +++ b/src/components/org/OrgAdmin/AboutSection/style.ts @@ -15,6 +15,7 @@ export const StContainer = styled.section` export const StWrapper = styled.div` display: flex; flex-direction: column; + max-width: 582px; gap: 20px; `; @@ -49,7 +50,7 @@ interface StInputProps { } export const StInput = styled(TextField)` - width: 338px; + width: 100%; color: ${({ hasValue = true }) => hasValue ? `${colors.white}` : `${colors.gray300}`}; diff --git a/src/components/org/OrgAdmin/CommonSection/BrandingColor.tsx b/src/components/org/OrgAdmin/CommonSection/BrandingColor.tsx index cac01194..0e0c59c2 100644 --- a/src/components/org/OrgAdmin/CommonSection/BrandingColor.tsx +++ b/src/components/org/OrgAdmin/CommonSection/BrandingColor.tsx @@ -30,7 +30,7 @@ const BrandingColor = () => { {' '} { - const [homeState, setHomeState] = useState(HOME_STATE.VIEW); + const [editStep, setEditStep] = useState(EDIT_STEP.VIEW); const [draft, setDraft] = useState({ reviews: EMPTY_REVIEWS, news: EMPTY_NEWS, @@ -63,8 +55,8 @@ const HomeSectionContent = () => { name: 'homeHeaderImageFileName', }); - const isEditMode = homeState !== HOME_STATE.VIEW; - const isDeployModalOpen = homeState === HOME_STATE.DEPLOY; + const isEditMode = editStep !== EDIT_STEP.VIEW; + const isDeployModalOpen = editStep === EDIT_STEP.DEPLOY; useEffect(() => { setDraft({ @@ -106,7 +98,7 @@ const HomeSectionContent = () => { }); clearErrors('homeHeaderImageFileName'); resetDraft(); - setHomeState(HOME_STATE.VIEW); + setEditStep(EDIT_STEP.VIEW); }; const handleDeploy = () => { @@ -132,48 +124,18 @@ const HomeSectionContent = () => { return ( <> - - {isEditMode ? ( - - - - 취소 - - { - if (validateHomeInputs()) { - setHomeState(HOME_STATE.DEPLOY); - } - }}> - 배포 - - - {hasUnsavedChanges && ( - - 저장되지 않은 변경사항이 있습니다. - - )} - - ) : ( - setHomeState(HOME_STATE.EDITING)}> - 수정하기 - - )} - + setEditStep(EDIT_STEP.EDITING)} + onCancel={exitEditMode} + onDeploy={() => { + if (validateHomeInputs()) { + setEditStep(EDIT_STEP.DEPLOY); + } + }} + /> { setHomeState(HOME_STATE.EDITING)} + onCancel={() => setEditStep(EDIT_STEP.EDITING)} onAction={handleDeploy} alertText="배포하시겠습니까?" description="입력한 홈 탭 내용은 공홈에 즉시 반영돼요." diff --git a/src/components/org/OrgAdmin/HomeSection/PartIntroSection.tsx b/src/components/org/OrgAdmin/HomeSection/PartIntroSection.tsx deleted file mode 100644 index 47e96d88..00000000 --- a/src/components/org/OrgAdmin/HomeSection/PartIntroSection.tsx +++ /dev/null @@ -1,114 +0,0 @@ -'use client'; - -import { IconInfoCircle } from '@sopt-makers/icons'; -import { Chip, TextArea } from '@sopt-makers/ui'; -import { useFormContext } from 'react-hook-form'; - -import { PARTS } from '@/components/org/OrgAdmin/HomeSection/constant'; -import { - StChipsContainer, - StIcon, - StSecondSectionContainer, - StTitle, - StWrapper, -} from '@/components/org/OrgAdmin/HomeSection/style'; -import { PART_KO, VALIDATION_CHECK } from '@/utils/org'; - -import Modal from '../common/Modal'; -import useModal from '../common/Modal/useModal'; -import { StTextAreaWrapper } from '@/components/org/OrgAdmin/RecruitSection/style'; - -type PartIntroSectionProps = { - selectedPart: PART_KO; - onChangePart: (id: PART_KO) => void; -}; - -const PartIntroSection = ({ - selectedPart, - onChangePart, -}: PartIntroSectionProps) => { - const { isInfoVisible, onInfoToggle } = useModal(); - - const { - register, - clearErrors, - setError, - formState: { errors }, - } = useFormContext(); - - const getActiveStatus = (id: PART_KO) => id === selectedPart; - - const handleSelectChip = (id: PART_KO) => { - onChangePart(id); - }; - - const handleValidation = (field: string, value: string) => { - if (value) { - clearErrors(field); - } else { - setError(field, { - type: 'required', - message: '필수 항목이에요.', - }); - } - }; - - return ( - - - - - 파트별 소개 - - - - - - - {PARTS.map((part) => ( - handleSelectChip(part)}> - {part} - - ))} - - - - - handleValidation( - `partIntroduction${selectedPart}`, - e.currentTarget.value, - ) - } - isError={!!errors[`partIntroduction${selectedPart}`]} - errorMessage={ - errors[`partIntroduction${selectedPart}`]?.message as string - } - required - fixedHeight={230} - maxHeight={230} - placeholder={ - '파트별 설명을 작성해주세요. \nex.\n 린스타트업에 기초해 고객 문제정의 - 고객 발굴 - 검증 과정을 거쳐 비즈니스 전략과 핵심지표 설계까지 고객 관점 프로덕트를 만들고 운영하기 위한 모든 과정을 다룹니다.' - } - /> - - - - ); -}; - -export default PartIntroSection; diff --git a/src/components/org/OrgAdmin/HomeSection/style.ts b/src/components/org/OrgAdmin/HomeSection/style.ts index d101c29c..a57a3fd2 100644 --- a/src/components/org/OrgAdmin/HomeSection/style.ts +++ b/src/components/org/OrgAdmin/HomeSection/style.ts @@ -12,35 +12,6 @@ export const StContainer = styled.section` padding: 50px 0 270px 0; `; -export const StHomeEditButtonWrapper = styled.div` - position: absolute; - top: 50px; - right: 0; - z-index: 1; -`; - -export const StHomeActionWrapper = styled.div` - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 10px; -`; - -export const StHomeActionButtonWrapper = styled.div` - display: flex; - gap: 14px; -`; - -export const StUnsavedChangeText = styled.p` - ${fontsObject.LABEL_3_14_SB}; - color: ${colors.error}; - - &::before { - content: '▲'; - margin-right: 6px; - } -`; - export const StSectionWrapper = styled.section` position: relative; display: flex; @@ -167,9 +138,6 @@ export const StChipsContainer = styled.div` align-items: center; gap: 6px; - margin-bottom: 18px; - margin-top: 24px; - & > button { white-space: nowrap; } diff --git a/src/components/org/OrgAdmin/MyDropzone/index.tsx b/src/components/org/OrgAdmin/MyDropzone/index.tsx index ff3656bf..7709048c 100644 --- a/src/components/org/OrgAdmin/MyDropzone/index.tsx +++ b/src/components/org/OrgAdmin/MyDropzone/index.tsx @@ -28,8 +28,8 @@ interface MyDropzoneProps { const MyDropzone = ({ method, label, - width = '547px', - height = '166px', + width = '582px', + height = '176px', shape = 'square', required, disabled = false, diff --git a/src/components/org/OrgAdmin/RecruitSection/Fna.tsx b/src/components/org/OrgAdmin/RecruitSection/Fna.tsx deleted file mode 100644 index 9ee1cbdb..00000000 --- a/src/components/org/OrgAdmin/RecruitSection/Fna.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useFormContext } from 'react-hook-form'; - -import { PART_KO, VALIDATION_CHECK } from '@/utils/org'; - -import PartCategory from '../PartCategory'; -import { StTextArea, StTitle, StTitleWrapper, StWrapper } from '../style'; -import { StFnaWrapper, StTextAreaWrapper } from './style'; - -const QUESTION_NUMBERS = [0, 1, 2]; - -interface FnaProps { - fnaPart: PART_KO; - onChangeFnaPart: (part: PART_KO) => void; -} - -const Fna = ({ fnaPart, onChangeFnaPart }: FnaProps) => { - const { - register, - clearErrors, - setError, - formState: { errors }, - } = useFormContext(); - - const handleSetSelectedPart = (value: PART_KO) => { - onChangeFnaPart(value); - }; - - const handleValidation = (field: string, value: string) => { - if (value) { - clearErrors(field); - } else { - setError(field, { - type: 'required', - message: VALIDATION_CHECK.required.errorText, - }); - } - }; - - return ( - - - 자주 묻는 질문 - - - - {QUESTION_NUMBERS.map((index) => ( - - - handleValidation( - `recruitQuestion.${fnaPart}.question${index}`, - e.currentTarget.value, - ) - } - isError={ - !!(errors as any).recruitQuestion?.[fnaPart]?.[ - `question${index}` - ]?.message - } - errorMessage={ - (errors as any).recruitQuestion?.[fnaPart]?.[`question${index}`] - ?.message as string - } - /> - - handleValidation( - `recruitQuestion.${fnaPart}.answer${index}`, - e.currentTarget.value, - ) - } - isError={ - !!(errors as any).recruitQuestion?.[fnaPart]?.[`answer${index}`] - ?.message - } - errorMessage={ - (errors as any).recruitQuestion?.[fnaPart]?.[`answer${index}`] - ?.message as string - } - /> - - ))} - - - ); -}; - -export default Fna; diff --git a/src/components/org/OrgAdmin/RecruitSection/Header.tsx b/src/components/org/OrgAdmin/RecruitSection/Header.tsx deleted file mode 100644 index b7375e64..00000000 --- a/src/components/org/OrgAdmin/RecruitSection/Header.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { IconInfoCircle } from '@sopt-makers/icons'; -import { useFormContext } from 'react-hook-form'; - -import RequiredIcon from '../assets/RequiredIcon'; -import Modal from '../common/Modal'; -import useModal from '../common/Modal/useModal'; -import MyDropzone from '../MyDropzone'; -import { - StDescription, - StLabel, - StTitle, - StTitleWrapper, - StWrapper, -} from '../style'; -import { StInfoButton, StLabelWrapper, StStretchContainer } from './style'; - -const Header = () => { - const method = useFormContext(); - const { isInfoVisible, onInfoToggle } = useModal(); - - return ( - - - - 지원하기탭 헤더 - - - - - - 이미지 - - - - 이미지는 1920*580 크기로 올려주세요. ‘지원하기’탭 가장 상단에 보여지는 - 이미지예요. - - - - - - ); -}; - -export default Header; diff --git a/src/components/org/OrgAdmin/RecruitSection/PartCurriculum.tsx b/src/components/org/OrgAdmin/RecruitSection/PartCurriculum.tsx deleted file mode 100644 index 6b8ac311..00000000 --- a/src/components/org/OrgAdmin/RecruitSection/PartCurriculum.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useFormContext } from 'react-hook-form'; - -import { PART_KO, VALIDATION_CHECK } from '@/utils/org'; - -import PartCategory from '../PartCategory'; -import { StTextArea, StTitle, StTitleWrapper, StWrapper } from '../style'; -import { StTextAreaWrapper } from './style'; - -interface RecruitSectionProps { - curriculumPart: PART_KO; - onChangeCurriculumPart: (part: PART_KO) => void; -} - -const PartCurriculum = ({ - curriculumPart, - onChangeCurriculumPart, -}: RecruitSectionProps) => { - const { - register, - clearErrors, - setError, - formState: { errors }, - } = useFormContext(); - - const handleSetSelectedPart = (value: PART_KO) => { - onChangeCurriculumPart(value); - }; - - return ( - - - 파트별 소개 - - - - { - if (e.currentTarget.value) { - clearErrors(`recruitPartCurriculum.${curriculumPart}.content`); - } else { - setError(`recruitPartCurriculum.${curriculumPart}.content`, { - type: 'required', - message: VALIDATION_CHECK.required.errorText, - }); - } - }} - isError={ - !!(errors as any).recruitPartCurriculum?.[curriculumPart]?.content - ?.message - } - errorMessage={ - (errors as any).recruitPartCurriculum?.[curriculumPart]?.content - ?.message as string - } - /> - { - if (e.currentTarget.value) { - clearErrors(`recruitPartCurriculum.${curriculumPart}.preference`); - } else { - setError(`recruitPartCurriculum.${curriculumPart}.preference`, { - type: 'required', - message: VALIDATION_CHECK.required.errorText, - }); - } - }} - isError={ - !!(errors as any).recruitPartCurriculum?.[curriculumPart] - ?.preference?.message - } - errorMessage={ - (errors as any).recruitPartCurriculum?.[curriculumPart]?.preference - ?.message as string - } - /> - - - ); -}; - -export default PartCurriculum; diff --git a/src/components/org/OrgAdmin/RecruitSection/_components/Curriculum/index.tsx b/src/components/org/OrgAdmin/RecruitSection/_components/Curriculum/index.tsx new file mode 100644 index 00000000..f048533f --- /dev/null +++ b/src/components/org/OrgAdmin/RecruitSection/_components/Curriculum/index.tsx @@ -0,0 +1,103 @@ +import { IconInfoCircle } from '@sopt-makers/icons'; +import { useFormContext } from 'react-hook-form'; + +import Modal from '@/components/org/OrgAdmin/common/Modal'; +import useModal from '@/components/org/OrgAdmin/common/Modal/useModal'; +import { PART_KO, PART_LIST, VALIDATION_CHECK } from '@/utils/org'; + +import { StInput, StTitle, StWrapper } from '../../../AboutSection/style'; +import PartCategory from '../../../PartCategory'; +import { + StContentWrapper, + StInfoButton, + StModalWrapper, + StSectionWrapper, +} from '../../style'; +import { StItem, StList, StWeek } from './style'; + +const CURRICULUM = PART_LIST.reduce( + (acc, part) => { + acc[part] = Array.from({ length: 8 }); + return acc; + }, + {} as Record, +); + +interface CurriculumProps { + isEditable?: boolean; + selectedPart: PART_KO; + onChangeSelectedPart: (part: PART_KO) => void; +} + +const CurriculumSection = ({ + isEditable = true, + selectedPart, + onChangeSelectedPart, +}: CurriculumProps) => { + const { + register, + formState: { errors }, + } = useFormContext(); + + const { isInfoVisible, onInfoToggle } = useModal(); + + const handleSetSelectedPart = (value: PART_KO) => { + onChangeSelectedPart(value); + }; + + return ( + + + + 파트별 커리큘럼 + + + + + + + + {CURRICULUM[selectedPart].map((_, idx) => ( + + + 0{idx + 1} + + + + ))} + + + + + + + + ); +}; + +export default CurriculumSection; diff --git a/src/components/org/OrgAdmin/AboutSection/Curriculum/style.ts b/src/components/org/OrgAdmin/RecruitSection/_components/Curriculum/style.ts similarity index 77% rename from src/components/org/OrgAdmin/AboutSection/Curriculum/style.ts rename to src/components/org/OrgAdmin/RecruitSection/_components/Curriculum/style.ts index 2ed4f841..1decf9b9 100644 --- a/src/components/org/OrgAdmin/AboutSection/Curriculum/style.ts +++ b/src/components/org/OrgAdmin/RecruitSection/_components/Curriculum/style.ts @@ -2,28 +2,26 @@ import styled from '@emotion/styled'; import { colors } from '@sopt-makers/colors'; import { fontsObject } from '@sopt-makers/fonts'; -export const StContentWrapper = styled.div` - display: flex; - flex-direction: column; - gap: 18px; -`; export const StChipWrapper = styled.div` display: flex; gap: 6px; `; + export const StList = styled.ol` display: flex; + width: 100%; flex-direction: column; justify-content: center; gap: 8px; `; + export const StItem = styled.li` display: flex; gap: 10px; + align-items: center; `; + export const StWeek = styled.label` color: ${colors.gray300}; ${fontsObject.BODY_1_18_M} - line-height: 48px; - width: 24px; `; diff --git a/src/components/org/OrgAdmin/RecruitSection/_components/Faq/index.tsx b/src/components/org/OrgAdmin/RecruitSection/_components/Faq/index.tsx new file mode 100644 index 00000000..f3df3511 --- /dev/null +++ b/src/components/org/OrgAdmin/RecruitSection/_components/Faq/index.tsx @@ -0,0 +1,276 @@ +import { IconInfoCircle, IconPlus } from '@sopt-makers/icons'; +import { Button, useDialog } from '@sopt-makers/ui'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; + +import IconXCircleFilled from '@/components/icons/IconXCircleFilled'; +import { + StTitle, + StWrapper, +} from '@/components/org/OrgAdmin/AboutSection/style'; +import Modal from '@/components/org/OrgAdmin/common/Modal'; +import useModal from '@/components/org/OrgAdmin/common/Modal/useModal'; +import { useAdminInfoQuery } from '@/components/org/OrgAdmin/HomeSection/queries'; +import { ERROR_MESSAGES } from '@/components/org/OrgAdmin/RecruitSection/_constants/constants'; +import { + FAQ_DEFAULT_QUESTION_COUNT, + FAQ_MAX_QUESTION_COUNT, + PART_KO, + PART_LIST, + VALIDATION_CHECK, +} from '@/utils/org'; + +import PartCategory from '../../../PartCategory'; +import { + StContentWrapper, + StInfoButton, + StModalWrapper, + StSectionWrapper, + StTextAreaWrapper, +} from '../../style'; +import { + StAnswerTextArea, + StDeleteButton, + StFnaHeader, + StFnaTitle, + StFnaWrapper, + StQuestionTextArea, +} from './style'; + +type QuestionCounts = Record; + +const createDefaultCounts = (): QuestionCounts => + PART_LIST.reduce( + (acc, part) => ({ ...acc, [part]: FAQ_DEFAULT_QUESTION_COUNT }), + {} as QuestionCounts, + ); + +interface Props { + isEditable: boolean; + resetKey: number; + fnaPart: PART_KO; + onChangeFnaPart: (part: PART_KO) => void; +} + +const FaqSection = ({ + isEditable, + resetKey, + fnaPart, + onChangeFnaPart, +}: Props) => { + const { + register, + getValues, + setValue, + clearErrors, + setError, + formState: { errors }, + } = useFormContext(); + + const { isInfoVisible, onInfoToggle } = useModal(); + const { open: openDialog } = useDialog(); + const { data } = useAdminInfoQuery(); + + const [questionCounts, setQuestionCounts] = + useState(createDefaultCounts); + + const textAreaContainerRef = useRef(null); + + useEffect(() => { + if (!data?.recruitQuestion) { + setQuestionCounts(createDefaultCounts()); + return; + } + + const next = createDefaultCounts(); + + data.recruitQuestion.forEach(({ part, questions }) => { + if (!part || !questions?.length) return; + + const count = Math.min(questions.length, FAQ_MAX_QUESTION_COUNT); + next[part as PART_KO] = Math.max(count, FAQ_DEFAULT_QUESTION_COUNT); + }); + + setQuestionCounts(next); + }, [data, resetKey]); + + const currentCount = questionCounts[fnaPart]; + + const handleSetSelectedPart = (value: PART_KO) => { + onChangeFnaPart(value); + }; + + const handleValidation = (field: string, value: string) => { + if (value) { + clearErrors(field); + } else { + setError(field, { + type: 'required', + message: VALIDATION_CHECK.required.errorText, + }); + } + }; + + const handleAddQuestion = () => { + setQuestionCounts((prev) => ({ + ...prev, + [fnaPart]: Math.min(prev[fnaPart] + 1, FAQ_MAX_QUESTION_COUNT), + })); + }; + + const handleDeleteQuestion = (targetIndex: number) => { + const count = questionCounts[fnaPart]; + + // 삭제 인덱스 뒤의 값들을 한 칸씩 앞으로 당겨 위에서부터 다시 정렬 + for (let index = targetIndex; index < count - 1; index += 1) { + setValue( + `recruitQuestion.${fnaPart}.question${index}`, + getValues(`recruitQuestion.${fnaPart}.question${index + 1}`) ?? '', + ); + setValue( + `recruitQuestion.${fnaPart}.answer${index}`, + getValues(`recruitQuestion.${fnaPart}.answer${index + 1}`) ?? '', + ); + } + + const lastIndex = count - 1; + setValue(`recruitQuestion.${fnaPart}.question${lastIndex}`, ''); + setValue(`recruitQuestion.${fnaPart}.answer${lastIndex}`, ''); + clearErrors(`recruitQuestion.${fnaPart}.question${lastIndex}`); + clearErrors(`recruitQuestion.${fnaPart}.answer${lastIndex}`); + + setQuestionCounts((prev) => ({ + ...prev, + [fnaPart]: prev[fnaPart] - 1, + })); + }; + + const openDeleteDialog = (targetIndex: number) => { + openDialog({ + title: `질문 ${targetIndex + 1}을 삭제하시겠습니까?`, + description: '삭제한 내용은 복구할 수 없습니다.', + type: 'default', + typeOptions: { + cancelButtonText: '취소', + approveButtonText: '삭제', + buttonFunction: () => handleDeleteQuestion(targetIndex), + }, + }); + }; + + // setValue로 채운 값은 onChange를 거치지 않아 자동 높이 계산이 안 되므로 직접 재계산 + useLayoutEffect(() => { + const container = textAreaContainerRef.current; + if (!container) return; + + container.querySelectorAll('textarea').forEach((textarea) => { + textarea.style.height = '1px'; + textarea.style.height = `${textarea.scrollHeight}px`; + }); + }, [data, fnaPart, currentCount]); + + return ( + + + + 자주 묻는 질문 + + + + + + + + {Array.from({ length: currentCount }, (_, index) => ( + + + 질문 {index + 1} + {isEditable && currentCount > 1 && ( + openDeleteDialog(index)}> + + + )} + + + handleValidation( + `recruitQuestion.${fnaPart}.question${index}`, + e.currentTarget.value, + ) + } + isError={ + !!(errors as any).recruitQuestion?.[fnaPart]?.[ + `question${index}` + ]?.message + } + errorMessage={ERROR_MESSAGES.question} + /> + ) => + handleValidation( + `recruitQuestion.${fnaPart}.answer${index}`, + e.currentTarget.value, + ) + } + isError={ + !!(errors as any).recruitQuestion?.[fnaPart]?.[ + `answer${index}` + ]?.message + } + errorMessage={ERROR_MESSAGES.answer} + /> + + ))} + {isEditable && currentCount < FAQ_MAX_QUESTION_COUNT && ( + + 추가 + + )} + + + + + + + + ); +}; + +export default FaqSection; diff --git a/src/components/org/OrgAdmin/RecruitSection/_components/Faq/style.ts b/src/components/org/OrgAdmin/RecruitSection/_components/Faq/style.ts new file mode 100644 index 00000000..5d0560a8 --- /dev/null +++ b/src/components/org/OrgAdmin/RecruitSection/_components/Faq/style.ts @@ -0,0 +1,53 @@ +import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; +import { fontsObject } from '@sopt-makers/fonts'; +import { TextArea } from '@sopt-makers/ui'; + +export const StFnaWrapper = styled.div` + display: flex; + flex-direction: column; + padding-top: 14px; + gap: 8px; + width: 100%; + max-width: 582px; +`; + +export const StQuestionTextArea = styled(TextArea)` + textarea { + min-height: 26px; + } +`; + +export const StAnswerTextArea = styled(TextArea)` + textarea { + min-height: 52px; + } +`; + +export const StFnaHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 0; + margin-bottom: 12px; + border-bottom: 1px solid ${colors.gray700}; +`; + +export const StFnaTitle = styled.h3` + ${fontsObject.TITLE_4_20_SB}; + + color: ${colors.white}; +`; + +export const StDeleteButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + color: ${colors.gray300}; + + &:hover { + color: ${colors.white}; + } +`; diff --git a/src/components/org/OrgAdmin/RecruitSection/_components/Header/index.tsx b/src/components/org/OrgAdmin/RecruitSection/_components/Header/index.tsx new file mode 100644 index 00000000..8a2d19ac --- /dev/null +++ b/src/components/org/OrgAdmin/RecruitSection/_components/Header/index.tsx @@ -0,0 +1,73 @@ +import { IconInfoCircle } from '@sopt-makers/icons'; +import { useFormContext } from 'react-hook-form'; + +import RequiredIcon from '@/components/org/OrgAdmin/assets/RequiredIcon'; +import Modal from '@/components/org/OrgAdmin/common/Modal'; +import useModal from '@/components/org/OrgAdmin/common/Modal/useModal'; +import { useAdminInfoQuery } from '@/components/org/OrgAdmin/HomeSection/queries'; +import MyDropzone from '@/components/org/OrgAdmin/MyDropzone'; +import { + StInfoButton, + StLabelWrapper, + StModalWrapper, + StSectionWrapper, + StStretchContainer, +} from '@/components/org/OrgAdmin/RecruitSection/style'; +import { + StDescription, + StLabel, + StTitle, + StTitleWrapper, +} from '@/components/org/OrgAdmin/style'; + +type HeaderSectionProps = { + isEditable: boolean; +}; + +const HeaderSection = ({ isEditable }: HeaderSectionProps) => { + const method = useFormContext(); + const { data } = useAdminInfoQuery(); + const { isInfoVisible, onInfoToggle } = useModal(); + + return ( + + + + + 모집안내 탭 헤더 + + + + + + 이미지 + + + + 이미지는 1920*580 크기로 올려주세요. ‘모집안내’ 탭 가장 상단에 + 보여지는 이미지예요. + + + + + + + + + ); +}; + +export default HeaderSection; diff --git a/src/components/org/OrgAdmin/RecruitSection/_components/PartIntroduction/index.tsx b/src/components/org/OrgAdmin/RecruitSection/_components/PartIntroduction/index.tsx new file mode 100644 index 00000000..173b9361 --- /dev/null +++ b/src/components/org/OrgAdmin/RecruitSection/_components/PartIntroduction/index.tsx @@ -0,0 +1,322 @@ +'use client'; + +import { IconInfoCircle } from '@sopt-makers/icons'; +import { Chip, TextArea } from '@sopt-makers/ui'; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { useFormContext } from 'react-hook-form'; + +import Modal from '@/components/org/OrgAdmin/common/Modal'; +import useModal from '@/components/org/OrgAdmin/common/Modal/useModal'; +import { PARTS } from '@/components/org/OrgAdmin/HomeSection/constant'; +import { useAdminInfoQuery } from '@/components/org/OrgAdmin/HomeSection/queries'; +import { + StChipsContainer, + StIcon, + StSecondSectionContainer, + StTitle, + StWrapper, +} from '@/components/org/OrgAdmin/HomeSection/style'; +import { + StPartIntroductionModalWrapper, + StPartIntroductionTextArea, +} from '@/components/org/OrgAdmin/RecruitSection/_components/PartIntroduction/style'; +import { + ERROR_MESSAGES, + ONE_LINE_MAX_LENGTH, + PREFERENCE_DEFAULT_COUNT, + PREFERENCE_MAX_COUNT, +} from '@/components/org/OrgAdmin/RecruitSection/_constants/constants'; +import { + StSectionWrapper, + StTextAreaContainer, + StTextAreaWrapper, +} from '@/components/org/OrgAdmin/RecruitSection/style'; +import { PART_KO, PART_LIST } from '@/utils/org'; + +import { StPreferenceItem } from './style'; + +type PreferenceCounts = Record; + +const createDefaultPreferenceCounts = (): PreferenceCounts => + PART_LIST.reduce( + (acc, part) => ({ ...acc, [part]: PREFERENCE_DEFAULT_COUNT }), + {} as PreferenceCounts, + ); + +type PartIntroSectionProps = { + isEditable: boolean; + resetKey: number; + selectedPart: PART_KO; + onChangePart: (id: PART_KO) => void; +}; + +const PartIntroSection = ({ + isEditable, + resetKey, + selectedPart, + onChangePart, +}: PartIntroSectionProps) => { + const { isInfoVisible, onInfoToggle } = useModal(); + const { data } = useAdminInfoQuery(); + + const { + register, + watch, + setValue, + clearErrors, + setError, + formState: { errors }, + } = useFormContext(); + + const oneLineFieldName = `partIntroduction${selectedPart}`; + const contentFieldName = `recruitPartCurriculum.${selectedPart}.content`; + const preferenceFieldName = `recruitPartCurriculum.${selectedPart}.preference`; + + const oneLineValue = watch(oneLineFieldName); + const contentValue = watch(contentFieldName); + const preferenceValue = watch(preferenceFieldName); + + const [preferenceCounts, setPreferenceCounts] = useState( + createDefaultPreferenceCounts, + ); + + useEffect(() => { + if (!data?.recruitPartCurriculum) return; + + const next = createDefaultPreferenceCounts(); + + data.recruitPartCurriculum.forEach(({ part, introduction }) => { + if (!part) return; + + const items = (introduction?.preference ?? '') + .split('\n') + .filter((item) => item.trim()); + const count = items.length + ? Math.min(items.length, PREFERENCE_MAX_COUNT) + : PREFERENCE_DEFAULT_COUNT; + next[part as PART_KO] = Math.max(count, PREFERENCE_DEFAULT_COUNT); + }); + + setPreferenceCounts(next); + }, [data, resetKey]); + + const handleValidation = (field: string, value: string) => { + if (value) { + clearErrors(field); + } else { + setError(field, { + type: 'required', + message: ERROR_MESSAGES.required, + }); + } + }; + + const handleOneLineValidation = useCallback( + (value: string) => { + if (value.length > ONE_LINE_MAX_LENGTH) { + setError(oneLineFieldName, { + type: 'maxLength', + message: ERROR_MESSAGES.oneLine, + }); + return; + } + + clearErrors(oneLineFieldName); + }, + [clearErrors, oneLineFieldName, setError], + ); + + useEffect(() => { + if (typeof oneLineValue !== 'string') return; + + handleOneLineValidation(oneLineValue); + }, [handleOneLineValidation, oneLineValue]); + + const preferenceCount = preferenceCounts[selectedPart]; + + const preferenceItems = Array.from( + { length: preferenceCount }, + (_, index) => (preferenceValue ?? '').split('\n')[index] ?? '', + ); + + const syncPreference = (items: string[]) => { + const joined = items.join('\n'); + setValue(preferenceFieldName, joined); + handleValidation(preferenceFieldName, joined.replace(/\n/g, '').trim()); + }; + + const handleChangePreferenceItem = (index: number, value: string) => { + const nextItems = [...preferenceItems]; + nextItems[index] = value; + syncPreference(nextItems); + }; + + const handleDeletePreferenceItem = (index: number) => { + const nextItems = preferenceItems.filter((_, i) => i !== index); + syncPreference(nextItems); + setPreferenceCounts((prev) => ({ + ...prev, + [selectedPart]: Math.max(prev[selectedPart] - 1, 1), + })); + }; + + const textAreaContainerRef = useRef(null); + + // setValue로 채운 값은 onChange를 거치지 않아 자동 높이 계산이 안 되므로 직접 재계산 + useLayoutEffect(() => { + const container = textAreaContainerRef.current; + if (!container) return; + + container.querySelectorAll('textarea').forEach((textarea) => { + textarea.style.height = '1px'; + textarea.style.height = `${textarea.scrollHeight}px`; + }); + }, [selectedPart, oneLineValue, contentValue, preferenceValue]); + + const getActiveStatus = (id: PART_KO) => id === selectedPart; + + const handleSelectChip = (id: PART_KO) => { + onChangePart(id); + }; + + const oneLineRegister = register(oneLineFieldName, { + maxLength: { + value: ONE_LINE_MAX_LENGTH, + message: ERROR_MESSAGES.oneLine, + }, + }); + + return ( + + + + + + 파트별 소개 + + + + + + + {PARTS.map((part) => ( + handleSelectChip(part)}> + {part} + + ))} + + + + + ) => { + oneLineRegister.onChange(e); + handleOneLineValidation(e.currentTarget.value); + }} + isError={!!(errors as any)[oneLineFieldName]} + errorMessage={ + (errors as any)[oneLineFieldName]?.message as string + } + /> + ) => + handleValidation(contentFieldName, e.currentTarget.value) + } + isError={ + !!(errors as any).recruitPartCurriculum?.[selectedPart]?.content + } + errorMessage={ERROR_MESSAGES.required} + maxHeight={9999} + /> + {preferenceItems.map((item, index) => ( + + ) => + handleChangePreferenceItem(index, e.currentTarget.value) + } + isError={ + index === 0 && + !!(errors as any).recruitPartCurriculum?.[selectedPart] + ?.preference + } + errorMessage={ + index === 0 + ? ((errors as any).recruitPartCurriculum?.[selectedPart] + ?.preference?.message as string) + : undefined + } + maxHeight={9999} + /> + + ))} + + + + + + + + + ); +}; + +export default PartIntroSection; diff --git a/src/components/org/OrgAdmin/RecruitSection/_components/PartIntroduction/style.ts b/src/components/org/OrgAdmin/RecruitSection/_components/PartIntroduction/style.ts new file mode 100644 index 00000000..a777401a --- /dev/null +++ b/src/components/org/OrgAdmin/RecruitSection/_components/PartIntroduction/style.ts @@ -0,0 +1,40 @@ +import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; +import { TextArea } from '@sopt-makers/ui'; + +export const StPartIntroductionTextArea = styled(TextArea)` + textarea { + min-height: 26px; + } +`; + +export const StPreferenceItem = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +export const StPartIntroductionModalWrapper = styled.div` + position: absolute; + top: 80px; + right: 0px; +`; + +export const StPreferenceHeader = styled.div` + display: flex; + align-items: center; + justify-content: flex-end; +`; + +export const StDeleteButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + color: ${colors.gray300}; + + &:hover { + color: ${colors.white}; + } +`; diff --git a/src/components/org/OrgAdmin/RecruitSection/_constants/constants.ts b/src/components/org/OrgAdmin/RecruitSection/_constants/constants.ts new file mode 100644 index 00000000..bae22b94 --- /dev/null +++ b/src/components/org/OrgAdmin/RecruitSection/_constants/constants.ts @@ -0,0 +1,11 @@ +export const ERROR_MESSAGES = { + question: '질문을 입력해주세요.', + answer: '답변을 입력해주세요.', + oneLine: '50자 이내로 입력해 주세요.', + required: '필수 입력값이에요.', +}; + +export const PREFERENCE_DEFAULT_COUNT = 3; +export const PREFERENCE_MAX_COUNT = 10; +export const ONE_LINE_MAX_LENGTH = 50; +export const ONE_LINE_MAX_LENGTH_ERROR_MESSAGE = '50자 이내로 입력해 주세요.'; diff --git a/src/components/org/OrgAdmin/RecruitSection/_utils/syncRecruitFormFromAdminData.ts b/src/components/org/OrgAdmin/RecruitSection/_utils/syncRecruitFormFromAdminData.ts new file mode 100644 index 00000000..549a9325 --- /dev/null +++ b/src/components/org/OrgAdmin/RecruitSection/_utils/syncRecruitFormFromAdminData.ts @@ -0,0 +1,75 @@ +import type { FieldValues, UseFormSetValue } from 'react-hook-form'; + +import { FAQ_MAX_QUESTION_COUNT } from '@/utils/org'; + +type AdminInfoData = { + partIntroduction?: { part?: string; description?: string }[]; + partCurriculum?: { part?: string; curriculums?: string[] }[]; + recruitPartCurriculum?: { + part?: string; + introduction?: { content?: string; preference?: string }; + }[]; + recruitQuestion?: { + part?: string; + questions?: { question?: string; answer?: string }[]; + }[]; +}; + +const setValueOptions = { shouldDirty: false, shouldValidate: false }; + +export const syncRecruitFormFromAdminData = ( + data: AdminInfoData | undefined, + setValue: UseFormSetValue, +) => { + if (!data) return; + + data.partIntroduction?.forEach(({ part, description }) => { + if (!part) return; + + setValue(`partIntroduction${part}`, description ?? '', setValueOptions); + }); + + data.partCurriculum?.forEach(({ part, curriculums }) => { + if (!part) return; + + curriculums?.forEach((curriculum, index) => { + setValue( + `partCurriculum.${part}.${index}`, + curriculum ?? '', + setValueOptions, + ); + }); + }); + + data.recruitPartCurriculum?.forEach(({ part, introduction }) => { + if (!part) return; + + setValue( + `recruitPartCurriculum.${part}.content`, + introduction?.content ?? '', + setValueOptions, + ); + setValue( + `recruitPartCurriculum.${part}.preference`, + introduction?.preference ?? '', + setValueOptions, + ); + }); + + data.recruitQuestion?.forEach(({ part, questions }) => { + if (!part) return; + + questions?.slice(0, FAQ_MAX_QUESTION_COUNT).forEach((qa, index) => { + setValue( + `recruitQuestion.${part}.question${index}`, + qa.question ?? '', + setValueOptions, + ); + setValue( + `recruitQuestion.${part}.answer${index}`, + qa.answer ?? '', + setValueOptions, + ); + }); + }); +}; diff --git a/src/components/org/OrgAdmin/RecruitSection/api.ts b/src/components/org/OrgAdmin/RecruitSection/api.ts new file mode 100644 index 00000000..49c6a424 --- /dev/null +++ b/src/components/org/OrgAdmin/RecruitSection/api.ts @@ -0,0 +1,145 @@ +import type { FieldValues } from 'react-hook-form'; + +import type { AddAdminRecruitRequestDto } from '@/__generated__/org-types/data-contracts'; +import { + extractFileNameFromUrl, + uploadToS3, +} from '@/components/org/OrgAdmin/HomeSection/api'; +import { ACTIVITY_GENERATION } from '@/utils/generation'; +import { FAQ_MAX_QUESTION_COUNT, PART_LIST } from '@/utils/org'; + +import { soptFetcher } from '../api'; + +const CURRICULUM_WEEK_COUNT = 8; + +type RecruitHeaderImageField = { + fileName?: string; + file?: File; +}; + +export const postRecruitTab = async (body: AddAdminRecruitRequestDto) => { + const { data, error } = await soptFetcher.POST('/admin/recruit', { body }); + + if (error || !data) { + throw new Error('모집안내 탭 배포 요청에 실패했습니다.'); + } + + return data; +}; + +export const postRecruitTabConfirm = async () => { + const { data, error } = await soptFetcher.POST('/admin/recruit/confirm', { + body: { + generation: Number(ACTIVITY_GENERATION), + }, + }); + + if (error || !data) { + throw new Error('모집안내 탭 배포 확정에 실패했습니다.'); + } + + return data; +}; + +export type DeployRecruitInput = { + recruitHeaderImageFileName: string; + recruitHeaderImageFile?: File; + partIntroduction: AddAdminRecruitRequestDto['partIntroduction']; + partCurriculum: AddAdminRecruitRequestDto['partCurriculum']; + recruitPartCurriculum: AddAdminRecruitRequestDto['recruitPartCurriculum']; + recruitQuestion: AddAdminRecruitRequestDto['recruitQuestion']; +}; + +const buildRecruitQuestions = (values: FieldValues) => + PART_LIST.map((part) => ({ + part, + questions: Array.from({ length: FAQ_MAX_QUESTION_COUNT }, (_, index) => ({ + question: values.recruitQuestion?.[part]?.[`question${index}`] ?? '', + answer: values.recruitQuestion?.[part]?.[`answer${index}`] ?? '', + })).filter((qa) => qa.question && qa.answer), + })); + +export const buildDeployRecruitInputFromForm = ( + values: FieldValues, +): Omit< + DeployRecruitInput, + 'recruitHeaderImageFileName' | 'recruitHeaderImageFile' +> => ({ + partIntroduction: PART_LIST.map((part) => ({ + part, + description: values[`partIntroduction${part}`] ?? '', + })), + partCurriculum: PART_LIST.map((part) => ({ + part, + curriculums: Array.from( + { length: CURRICULUM_WEEK_COUNT }, + (_, index) => values.partCurriculum?.[part]?.[index] ?? '', + ), + })), + recruitPartCurriculum: PART_LIST.map((part) => ({ + part, + introduction: { + content: values.recruitPartCurriculum?.[part]?.content ?? '', + preference: values.recruitPartCurriculum?.[part]?.preference ?? '', + }, + })), + recruitQuestion: buildRecruitQuestions(values), +}); + +export const resolveRecruitHeaderImageFileName = ( + recruitHeaderImage: RecruitHeaderImageField | undefined, + existingRecruitHeaderImageUrl?: string, +) => + recruitHeaderImage?.fileName ?? + (existingRecruitHeaderImageUrl + ? extractFileNameFromUrl(existingRecruitHeaderImageUrl) + : ''); + +export const deployRecruitTab = async ({ + recruitHeaderImageFileName, + recruitHeaderImageFile, + partIntroduction, + partCurriculum, + recruitPartCurriculum, + recruitQuestion, +}: DeployRecruitInput) => { + const deployResponse = await postRecruitTab({ + generation: Number(ACTIVITY_GENERATION), + recruitHeaderImageFileName, + partIntroduction, + partCurriculum, + recruitPartCurriculum, + recruitQuestion, + }); + + if (recruitHeaderImageFile && deployResponse.recruitHeaderImage) { + const uploadResponse = await uploadToS3( + deployResponse.recruitHeaderImage, + recruitHeaderImageFile, + ); + + if (!uploadResponse.ok) { + throw new Error('모집안내 헤더 이미지 업로드에 실패했습니다.'); + } + } + + return postRecruitTabConfirm(); +}; + +export const deployRecruitTabFromForm = async ( + values: FieldValues, + existingRecruitHeaderImageUrl?: string, +) => { + const recruitHeaderImage = values.recruitHeaderImage as + | RecruitHeaderImageField + | undefined; + + return deployRecruitTab({ + recruitHeaderImageFileName: resolveRecruitHeaderImageFileName( + recruitHeaderImage, + existingRecruitHeaderImageUrl, + ), + recruitHeaderImageFile: recruitHeaderImage?.file, + ...buildDeployRecruitInputFromForm(values), + }); +}; diff --git a/src/components/org/OrgAdmin/RecruitSection/index.tsx b/src/components/org/OrgAdmin/RecruitSection/index.tsx index 66cd506d..5bc24f2a 100644 --- a/src/components/org/OrgAdmin/RecruitSection/index.tsx +++ b/src/components/org/OrgAdmin/RecruitSection/index.tsx @@ -1,32 +1,170 @@ -import { PART_KO } from '@/utils/org'; +import { ToastProvider } from '@sopt-makers/ui'; +import { useEffect, useState } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; -import { StContainer } from '../style'; -import Fna from './Fna'; -import Header from './Header'; -import PartCurriculum from './PartCurriculum'; +import { ActionModal } from '@/components/org/OrgAdmin/common/ActionModal'; +import { + EDIT_STEP, + type EditStep, +} from '@/components/org/OrgAdmin/common/constants/editStep'; +import { EditActionBar } from '@/components/org/OrgAdmin/common/EditActionBar'; +import { useAdminInfoQuery } from '@/components/org/OrgAdmin/HomeSection/queries'; +import CurriculumSection from '@/components/org/OrgAdmin/RecruitSection/_components/Curriculum'; +import FaqSection from '@/components/org/OrgAdmin/RecruitSection/_components/Faq'; +import HeaderSection from '@/components/org/OrgAdmin/RecruitSection/_components/Header'; +import PartIntroSection from '@/components/org/OrgAdmin/RecruitSection/_components/PartIntroduction'; +import { syncRecruitFormFromAdminData } from '@/components/org/OrgAdmin/RecruitSection/_utils/syncRecruitFormFromAdminData'; +import { useDeployRecruitMutation } from '@/components/org/OrgAdmin/RecruitSection/queries'; +import { + StRecruitContainer, + StRecruitSectionWrapper, +} from '@/components/org/OrgAdmin/RecruitSection/style'; +import { validationRecruitInputs } from '@/components/org/OrgAdmin/utils'; +import { PART_KO, VALIDATION_CHECK } from '@/utils/org'; interface RecruitSectionProps { + introPart: PART_KO; + onChangeIntroPart: (part: PART_KO) => void; curriculumPart: PART_KO; onChangeCurriculumPart: (part: PART_KO) => void; fnaPart: PART_KO; onChangeFnaPart: (part: PART_KO) => void; } -const RecruitSection = ({ +const RecruitSectionContent = ({ + introPart, + onChangeIntroPart, curriculumPart, onChangeCurriculumPart, fnaPart, onChangeFnaPart, }: RecruitSectionProps) => { + const [editStep, setEditStep] = useState(EDIT_STEP.VIEW); + const [resetKey, setResetKey] = useState(0); + + const { data } = useAdminInfoQuery(); + const { mutate: deployRecruit, isLoading: isDeploying } = + useDeployRecruitMutation(); + + const { + control, + getValues, + setError, + setValue, + clearErrors, + formState: { isDirty }, + } = useFormContext(); + + const recruitHeaderImage = useWatch({ + control, + name: 'recruitHeaderImage', + }); + + const isEditMode = editStep !== EDIT_STEP.VIEW; + const isDeployModalOpen = editStep === EDIT_STEP.DEPLOY; + const hasUnsavedChanges = + isEditMode && (Boolean(recruitHeaderImage?.file) || isDirty); + + useEffect(() => { + syncRecruitFormFromAdminData(data, setValue); + }, [data, setValue]); + + const validateRecruitTabInputs = () => { + const { recruitHeaderImage: headerImage } = getValues(); + + if (!headerImage?.fileName && !data?.recruitHeaderImage) { + setError('recruitHeaderImage', { + type: 'required', + message: VALIDATION_CHECK.required.errorText, + }); + return false; + } + + return validationRecruitInputs( + getValues, + setError, + onChangeCurriculumPart, + onChangeFnaPart, + ); + }; + + const exitEditMode = () => { + setValue('recruitHeaderImage', undefined, { + shouldDirty: false, + shouldValidate: false, + }); + clearErrors('recruitHeaderImage'); + syncRecruitFormFromAdminData(data, setValue); + setResetKey((prev) => prev + 1); + setEditStep(EDIT_STEP.VIEW); + }; + + const handleDeploy = () => { + deployRecruit( + { + values: getValues(), + existingRecruitHeaderImageUrl: data?.recruitHeaderImage, + }, + { + onSuccess: exitEditMode, + }, + ); + }; + return ( - - - + setEditStep(EDIT_STEP.EDITING)} + onCancel={exitEditMode} + onDeploy={() => { + if (validateRecruitTabInputs()) { + setEditStep(EDIT_STEP.DEPLOY); + } + }} + /> + + + + + + + + setEditStep(EDIT_STEP.EDITING)} + onAction={handleDeploy} + alertText="배포하시겠습니까?" + description="입력한 모집안내 탭 내용은 공홈에 즉시 반영돼요." /> - - + > + ); +}; + +const RecruitSection = (props: RecruitSectionProps) => { + return ( + + + + + ); }; diff --git a/src/components/org/OrgAdmin/RecruitSection/queries.ts b/src/components/org/OrgAdmin/RecruitSection/queries.ts new file mode 100644 index 00000000..0c1d0505 --- /dev/null +++ b/src/components/org/OrgAdmin/RecruitSection/queries.ts @@ -0,0 +1,31 @@ +import { useToast } from '@sopt-makers/ui'; +import type { FieldValues } from 'react-hook-form'; +import { useMutation, useQueryClient } from 'react-query'; + +import { DEPLOY_TOAST_OPTION } from '@/components/org/OrgAdmin/HomeSection/_constants/constants'; +import { deployRecruitTabFromForm } from '@/components/org/OrgAdmin/RecruitSection/api'; + +type DeployRecruitTabFromFormInput = { + values: FieldValues; + existingRecruitHeaderImageUrl?: string; +}; + +export const useDeployRecruitMutation = () => { + const queryClient = useQueryClient(); + const { open } = useToast(); + + return useMutation({ + mutationFn: ({ + values, + existingRecruitHeaderImageUrl, + }: DeployRecruitTabFromFormInput) => + deployRecruitTabFromForm(values, existingRecruitHeaderImageUrl), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin'] }); + open(DEPLOY_TOAST_OPTION.success); + }, + onError: () => { + open(DEPLOY_TOAST_OPTION.error); + }, + }); +}; diff --git a/src/components/org/OrgAdmin/RecruitSection/style.ts b/src/components/org/OrgAdmin/RecruitSection/style.ts index 4cade0a4..e7a4505c 100644 --- a/src/components/org/OrgAdmin/RecruitSection/style.ts +++ b/src/components/org/OrgAdmin/RecruitSection/style.ts @@ -1,11 +1,26 @@ import styled from '@emotion/styled'; import { colors } from '@sopt-makers/colors'; +export const StRecruitContainer = styled.section` + position: relative; + display: flex; + flex-direction: column; + gap: 80px; + padding: 50px 0 270px; +`; + +export const StRecruitSectionWrapper = styled.section` + display: flex; + flex-direction: column; + gap: 80px; +`; + export const StStretchContainer = styled.section` display: flex; justify-content: space-between; align-items: center; `; + export const StLabelWrapper = styled.div` display: flex; gap: 4px; @@ -16,16 +31,33 @@ export const StLabelWrapper = styled.div` export const StTextAreaWrapper = styled.div` display: flex; flex-direction: column; - gap: 8px; + gap: 32px; `; -export const StFnaWrapper = styled.div` +export const StTextAreaContainer = styled.div` display: flex; flex-direction: column; - gap: 8px; + gap: 16px; +`; + +export const StContentWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 18px; +`; + +export const StSectionWrapper = styled.section` + position: relative; +`; + +export const StModalWrapper = styled.div` + position: absolute; + top: 80px; + right: 0px; `; export const StInfoButton = styled.button` color: ${colors.white}; width: 20px; + height: 20px; `; diff --git a/src/components/org/OrgAdmin/common/EditActionBar/index.tsx b/src/components/org/OrgAdmin/common/EditActionBar/index.tsx new file mode 100644 index 00000000..cba53458 --- /dev/null +++ b/src/components/org/OrgAdmin/common/EditActionBar/index.tsx @@ -0,0 +1,63 @@ +import { Button } from '@sopt-makers/ui'; + +import IcSend from '@/components/icons/IcSend'; + +import { + StEditActionBarWrapper, + StEditActionButtonWrapper, + StEditActionWrapper, + StUnsavedChangeText, +} from './style'; + +type EditActionBarProps = { + isEditMode: boolean; + isDeploying?: boolean; + hasUnsavedChanges?: boolean; + onStartEdit: () => void; + onCancel: () => void; + onDeploy: () => void; +}; + +export const EditActionBar = ({ + isEditMode, + isDeploying = false, + hasUnsavedChanges = false, + onStartEdit, + onCancel, + onDeploy, +}: EditActionBarProps) => ( + + {isEditMode ? ( + + + + 취소 + + + 배포 + + + {hasUnsavedChanges && ( + + 저장되지 않은 변경사항이 있습니다. + + )} + + ) : ( + + 수정하기 + + )} + +); diff --git a/src/components/org/OrgAdmin/common/EditActionBar/style.ts b/src/components/org/OrgAdmin/common/EditActionBar/style.ts new file mode 100644 index 00000000..ed276274 --- /dev/null +++ b/src/components/org/OrgAdmin/common/EditActionBar/style.ts @@ -0,0 +1,32 @@ +import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; +import { fontsObject } from '@sopt-makers/fonts'; + +export const StEditActionBarWrapper = styled.div` + position: absolute; + top: 50px; + right: 0; + z-index: 1; +`; + +export const StEditActionWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; +`; + +export const StEditActionButtonWrapper = styled.div` + display: flex; + gap: 14px; +`; + +export const StUnsavedChangeText = styled.p` + ${fontsObject.LABEL_3_14_SB}; + color: ${colors.error}; + + &::before { + content: '▲'; + margin-right: 6px; + } +`; diff --git a/src/components/org/OrgAdmin/common/Modal/index.tsx b/src/components/org/OrgAdmin/common/Modal/index.tsx index e99a9963..49f4c00a 100644 --- a/src/components/org/OrgAdmin/common/Modal/index.tsx +++ b/src/components/org/OrgAdmin/common/Modal/index.tsx @@ -3,18 +3,27 @@ import { MouseEvent } from 'react'; import { StInfoCloseButton, + StInfoContent, StInfoDescription, StInfoImg, + StInfoSection, StInfoSubDescription, StInfoTitle, StInfoWrapper, } from './style'; -interface ModalProps { - title: string; +interface InfoSection { description: string; - subDescription: string; + subDescription?: string; imgSrc: string; +} + +interface ModalProps { + title: string; + description?: string; + subDescription?: string; + imgSrc?: string; + sections?: InfoSection[]; isInfoVisible: boolean; onInfoToggle: (e: MouseEvent) => void; } @@ -24,9 +33,22 @@ const Modal = ({ description, subDescription, imgSrc, + sections, isInfoVisible, onInfoToggle, }: ModalProps) => { + const infoSections = + sections ?? + (description && imgSrc + ? [ + { + description, + subDescription, + imgSrc, + }, + ] + : []); + return ( @@ -36,9 +58,21 @@ const Modal = ({ ✕ - {description} - {subDescription} - + + {infoSections.map((section) => ( + + + {section.description} + {section.subDescription && ( + + {section.subDescription} + + )} + + + + ))} + ); }; diff --git a/src/components/org/OrgAdmin/common/Modal/style.ts b/src/components/org/OrgAdmin/common/Modal/style.ts index baaf315c..cadd5486 100644 --- a/src/components/org/OrgAdmin/common/Modal/style.ts +++ b/src/components/org/OrgAdmin/common/Modal/style.ts @@ -47,17 +47,32 @@ export const StInfoCloseButton = styled.button` export const StInfoDescription = styled.p` ${fontsObject.LABEL_3_14_SB}; - margin-bottom: 8px; color: ${colors.white}; `; export const StInfoSubDescription = styled.p` ${fontsObject.LABEL_4_12_SB}; - margin-bottom: 14px; + margin-top: 8px; color: ${colors.gray300}; `; +export const StInfoContent = styled.div` + display: flex; + flex-direction: column; + gap: 28px; +`; + +export const StInfoSection = styled.div` + display: flex; + flex-direction: column; + gap: 14px; +`; + export const StInfoImg = styled.img` + width: 425px; + height: 213px; + border-radius: 12px; color: ${colors.white}; + object-fit: cover; `; diff --git a/src/components/org/OrgAdmin/common/constants/editStep.ts b/src/components/org/OrgAdmin/common/constants/editStep.ts new file mode 100644 index 00000000..bd32157e --- /dev/null +++ b/src/components/org/OrgAdmin/common/constants/editStep.ts @@ -0,0 +1,7 @@ +export const EDIT_STEP = { + VIEW: 'view', + EDITING: 'editing', + DEPLOY: 'deploy', +} as const; + +export type EditStep = (typeof EDIT_STEP)[keyof typeof EDIT_STEP]; diff --git a/src/components/org/OrgAdmin/index.tsx b/src/components/org/OrgAdmin/index.tsx index 0c374a8a..79dc281a 100644 --- a/src/components/org/OrgAdmin/index.tsx +++ b/src/components/org/OrgAdmin/index.tsx @@ -37,6 +37,7 @@ function OrgAdmin() { const [curriculumPart, setCurriculumPart] = useState('기획'); const [fnaPart, setFnaPart] = useState('기획'); const [introPart, setIntroPart] = useState('기획'); + const [recruitIntroPart, setRecruitIntroPart] = useState('기획'); const methods = useForm({ mode: 'onBlur' }); const { handleSubmit, getValues, setError, reset, setValue } = methods; @@ -115,7 +116,7 @@ function OrgAdmin() { handleValidateHomeInputs, handleValidationRecruitInputs, ], - 지원하기: [ + 모집안내: [ handleValidationRecruitInputs, handleValidateCommonInputs, handleValidateHomeInputs, @@ -128,7 +129,7 @@ function OrgAdmin() { const getPartForValidation = (validateFn: () => boolean) => { if (validateFn === handleValidateCommonInputs) return '공통'; if (validateFn === handleValidationAboutInputs) return '소개'; - if (validateFn === handleValidationRecruitInputs) return '지원하기'; + if (validateFn === handleValidationRecruitInputs) return '모집안내'; if (validateFn === handleValidateHomeInputs) return '홈'; return '공통'; }; @@ -373,6 +374,8 @@ function OrgAdmin() { /> ) : ( setRecruitIntroPart(part)} curriculumPart={curriculumPart} onChangeCurriculumPart={(part: PART_KO) => setCurriculumPart(part) @@ -381,7 +384,7 @@ function OrgAdmin() { onChangeFnaPart={(part: PART_KO) => setFnaPart(part)} /> )} - {selectedPart !== '홈' && ( + {selectedPart !== '홈' && selectedPart !== '모집안내' && ( { diff --git a/src/components/org/OrgAdmin/style.ts b/src/components/org/OrgAdmin/style.ts index 809565a4..356cc4a7 100644 --- a/src/components/org/OrgAdmin/style.ts +++ b/src/components/org/OrgAdmin/style.ts @@ -1,12 +1,15 @@ import styled from '@emotion/styled'; import { colors } from '@sopt-makers/colors'; import { fontsObject } from '@sopt-makers/fonts'; -import { TextArea, TextField } from '@sopt-makers/ui'; +import { TextField } from '@sopt-makers/ui'; import theme from '@/styles/theme'; export const StContainer = styled.section` + display: flex; + flex-direction: column; padding: 50px 0 284px; + gap: 80px; `; export const StWrapper = styled.div` @@ -102,12 +105,3 @@ export const StErrorMessage = styled.p` color: ${colors.error}; `; - -export const StTextArea = styled(TextArea)` - max-width: 547px; - - textarea { - padding: 8px 14px; - margin: 0px; - } -`; diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 9fa38818..890f39cc 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -2,7 +2,7 @@ declare global { type ATTEND_STATUS = 'ATTENDANCE' | 'ABSENT' | 'TARDY'; type ATTEND_STATUS_KR = '출석' | '결석' | '지각'; type PART = 'ALL' | 'PLAN' | 'DESIGN' | 'WEB' | 'ANDROID' | 'IOS' | 'SERVER'; - type ORG_ADMIN = '공통' | '홈' | '소개' | '지원하기'; + type ORG_ADMIN = '공통' | '홈' | '소개' | '모집안내'; type SESSION_TYPE = 'SEMINAR' | 'EVENT' | 'ETC'; type SESSION_STATUS = 'BEFORE' | 'FIRST' | 'SECOND' | 'END'; type AlarmDropdownType = 'part' | 'target' | 'generation' | 'targetSelector'; diff --git a/src/utils/org.ts b/src/utils/org.ts index ec6dd819..a13e35c7 100644 --- a/src/utils/org.ts +++ b/src/utils/org.ts @@ -18,9 +18,9 @@ export const VALIDATION_CHECK = { }, }; -export const ORG_ADMIN_LIST: ORG_ADMIN[] = ['공통', '홈', '소개', '지원하기']; +export const ORG_ADMIN_LIST: ORG_ADMIN[] = ['공통', '홈', '소개', '모집안내']; -export const orgAdminList: ORG_ADMIN[] = ['공통', '홈', '소개', '지원하기']; +export const orgAdminList: ORG_ADMIN[] = ['공통', '홈', '소개', '모집안내']; export const 임원진_LIST = [ '회장', @@ -44,6 +44,10 @@ export const PART_LIST: PART_KO[] = [ '서버', ] as const; +// 모집안내 탭 FAQ 파트별 질문 최대 개수 +export const FAQ_MAX_QUESTION_COUNT = 10; +export const FAQ_DEFAULT_QUESTION_COUNT = 3; + export type EXEC_TYPE = | (typeof 임원진_LIST)[number] | (typeof PART_LIST)[number];