diff --git a/src/components/org/OrgAdmin/AboutSection/Schedule/index.tsx b/src/components/org/OrgAdmin/AboutSection/Schedule/index.tsx index 03e698b0..26013430 100644 --- a/src/components/org/OrgAdmin/AboutSection/Schedule/index.tsx +++ b/src/components/org/OrgAdmin/AboutSection/Schedule/index.tsx @@ -1,10 +1,12 @@ import { IconInfoCircle } from '@sopt-makers/icons'; import { Button, DialogOptionType, useDialog } from '@sopt-makers/ui'; +import { useEffect } from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; import RequiredIcon from '../../assets/RequiredIcon'; // import Modal from '../../common/Modal'; import useModal from '../../common/Modal/useModal'; +import { FIRST_SCHEDULE_SESSION_NAME } from '../scheduleConstants'; import { StWrapper } from '../style'; import { StScheduleBody, @@ -46,6 +48,15 @@ const Schedule = ({ isEditable = true }: ScheduleProps) => { (index) => activitySchedule?.[index]?.date, ); + useEffect(() => { + if (activitySchedule?.[0]?.session !== FIRST_SCHEDULE_SESSION_NAME) { + setValue('activitySchedule.0.session', FIRST_SCHEDULE_SESSION_NAME, { + shouldDirty: false, + shouldValidate: false, + }); + } + }, [activitySchedule, setValue]); + const handleResetDates = () => { SCHEDULE_ROW_INDICES.forEach((index) => { setValue(`activitySchedule.${index}.date`, '', { @@ -121,7 +132,7 @@ const Schedule = ({ isEditable = true }: ScheduleProps) => { {...register(`activitySchedule.${index}.session`)} id={`schedule-session-${index}`} placeholder="세션명을 입력해 주세요." - disabled={!isEditable} + disabled={!isEditable || index === 0} /> ))} diff --git a/src/components/org/OrgAdmin/AboutSection/api.ts b/src/components/org/OrgAdmin/AboutSection/api.ts index 16e82966..6c4e933a 100644 --- a/src/components/org/OrgAdmin/AboutSection/api.ts +++ b/src/components/org/OrgAdmin/AboutSection/api.ts @@ -13,6 +13,7 @@ import { ACTIVITY_GENERATION } from '@/utils/generation'; import { soptFetcher } from '../api'; import { EXEC_ROLE_LIST, toMemberRole } from './memberRole'; +import { FIRST_SCHEDULE_SESSION_NAME } from './scheduleConstants'; type ImageField = { fileName?: string; file?: File } | undefined; @@ -110,13 +111,14 @@ const SCHEDULE_ROW_COUNT = 16; export const buildActivityScheduleFromForm = ( values: FieldValues, ): AddAdminActivityScheduleRequestDto[] => - Array.from( - { length: SCHEDULE_ROW_COUNT }, - (_, index) => values.activitySchedule?.[index], - ) - .filter((row) => row?.date && row?.session) - .map((row) => ({ - name: row.session, + Array.from({ length: SCHEDULE_ROW_COUNT }, (_, index) => ({ + index, + row: values.activitySchedule?.[index], + })) + .filter(({ row }) => row?.date && row?.session) + .map(({ index, row }) => ({ + // 첫 일정은 지원서/공홈에서 이 이름으로 식별하므로 폼 값과 무관하게 고정한다. + name: index === 0 ? FIRST_SCHEDULE_SESSION_NAME : row.session, startDate: row.date, })); diff --git a/src/components/org/OrgAdmin/AboutSection/formMapper.ts b/src/components/org/OrgAdmin/AboutSection/formMapper.ts index b192f791..be3151d2 100644 --- a/src/components/org/OrgAdmin/AboutSection/formMapper.ts +++ b/src/components/org/OrgAdmin/AboutSection/formMapper.ts @@ -1,6 +1,7 @@ import type { FieldValues, UseFormSetValue } from 'react-hook-form'; import { fromMemberRole } from './memberRole'; +import { FIRST_SCHEDULE_SESSION_NAME } from './scheduleConstants'; type AdminAboutMember = { role: string; @@ -34,6 +35,25 @@ type AdminAboutData = { const setValueOptions = { shouldDirty: false, shouldValidate: false }; +const normalizeSessionName = (name?: string) => name?.trim().toUpperCase(); + +// (과거 데이터가 순서와 무관하게 저장됐을 가능성에 대비한 안전장치) +// 이름으로 못 찾으면 원래 순서를 그대로 두고, 이후 로직에서 0번째 행을 OT로 강제하는 걸로 폴백한다. +const reorderScheduleWithOtFirst = ( + schedule: AdminAboutSchedule[], +): AdminAboutSchedule[] => { + const otIndex = schedule.findIndex( + (item) => normalizeSessionName(item.name) === FIRST_SCHEDULE_SESSION_NAME, + ); + + if (otIndex <= 0) return schedule; + + const otEntry = schedule[otIndex]; + const rest = schedule.filter((_, index) => index !== otIndex); + + return [otEntry, ...rest]; +}; + export const syncAboutFormFromAdminData = ( data: AdminAboutData | undefined, setValue: UseFormSetValue, @@ -89,12 +109,21 @@ export const syncAboutFormFromAdminData = ( ); }); - data.activitySchedule?.forEach(({ name, startDate }, index) => { + const orderedSchedule = data.activitySchedule + ? reorderScheduleWithOtFirst(data.activitySchedule) + : undefined; + + orderedSchedule?.forEach(({ name, startDate }, index) => { setValue( `activitySchedule.${index}.date`, startDate ?? '', setValueOptions, ); - setValue(`activitySchedule.${index}.session`, name ?? '', setValueOptions); + // 이름으로 OT를 못 찾아 재정렬되지 않았더라도, 0번째 행은 항상 OT로 고정한다(폴백). + setValue( + `activitySchedule.${index}.session`, + index === 0 ? FIRST_SCHEDULE_SESSION_NAME : (name ?? ''), + setValueOptions, + ); }); }; diff --git a/src/components/org/OrgAdmin/AboutSection/scheduleConstants.ts b/src/components/org/OrgAdmin/AboutSection/scheduleConstants.ts new file mode 100644 index 00000000..7633e19c --- /dev/null +++ b/src/components/org/OrgAdmin/AboutSection/scheduleConstants.ts @@ -0,0 +1,3 @@ +// 다른 서비스(지원서, 공홈)가 전체 일정의 첫 번째 항목을 이 이름으로 식별해 사용하므로, +// 0번째 일정의 이름은 항상 이 값으로 고정되어야 한다. +export const FIRST_SCHEDULE_SESSION_NAME = 'OT'; diff --git a/src/components/org/OrgAdmin/HomeSection/_components/News/NewsSection.tsx b/src/components/org/OrgAdmin/HomeSection/_components/News/NewsSection.tsx index 0532686e..0c99b683 100644 --- a/src/components/org/OrgAdmin/HomeSection/_components/News/NewsSection.tsx +++ b/src/components/org/OrgAdmin/HomeSection/_components/News/NewsSection.tsx @@ -123,7 +123,6 @@ const NewsSection = ({ key={deleteId ?? undefined} variant="danger" title="삭제하시겠습니까?" - description="최신 소식은 ‘배포’버튼을 거치지 않고 즉시 배포가 돼요." isOpen={deleteId != null} onCancel={() => setDeleteId(null)} onAction={() => { diff --git a/src/components/org/OrgAdmin/RecruitSection/_components/Faq/index.tsx b/src/components/org/OrgAdmin/RecruitSection/_components/Faq/index.tsx index 4f07d9a4..991d7f59 100644 --- a/src/components/org/OrgAdmin/RecruitSection/_components/Faq/index.tsx +++ b/src/components/org/OrgAdmin/RecruitSection/_components/Faq/index.tsx @@ -87,13 +87,24 @@ const FaqSection = ({ 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); + next[part as PART_KO] = Math.min( + questions.length, + FAQ_MAX_QUESTION_COUNT, + ); }); setQuestionCounts(next); }, [data, restoreSignal]); + useEffect(() => { + PART_LIST.forEach((part) => { + setValue(`recruitQuestionCount.${part}`, questionCounts[part], { + shouldDirty: false, + shouldValidate: false, + }); + }); + }, [questionCounts, setValue]); + const currentCount = questionCounts[fnaPart]; const handleSetSelectedPart = (value: PART_KO) => { diff --git a/src/components/org/OrgAdmin/RecruitSection/_utils/syncRecruitFormFromAdminData.ts b/src/components/org/OrgAdmin/RecruitSection/_utils/syncRecruitFormFromAdminData.ts index 549a9325..117ee197 100644 --- a/src/components/org/OrgAdmin/RecruitSection/_utils/syncRecruitFormFromAdminData.ts +++ b/src/components/org/OrgAdmin/RecruitSection/_utils/syncRecruitFormFromAdminData.ts @@ -59,17 +59,20 @@ export const syncRecruitFormFromAdminData = ( data.recruitQuestion?.forEach(({ part, questions }) => { if (!part) return; - questions?.slice(0, FAQ_MAX_QUESTION_COUNT).forEach((qa, index) => { + // 서버에 없는 인덱스도 명시적으로 비워야 삭제된 질문이 폼에 남아있지 않음 + for (let index = 0; index < FAQ_MAX_QUESTION_COUNT; index += 1) { + const qa = questions?.[index]; + setValue( `recruitQuestion.${part}.question${index}`, - qa.question ?? '', + qa?.question ?? '', setValueOptions, ); setValue( `recruitQuestion.${part}.answer${index}`, - qa.answer ?? '', + qa?.answer ?? '', setValueOptions, ); - }); + } }); }; diff --git a/src/components/org/OrgAdmin/utils.ts b/src/components/org/OrgAdmin/utils.ts index a94d968d..6b05784a 100644 --- a/src/components/org/OrgAdmin/utils.ts +++ b/src/components/org/OrgAdmin/utils.ts @@ -238,7 +238,12 @@ export const validationRecruitInputs = ( onInvalidFaqPart: (faqPart: PART_KO) => void, ) => { const values = getValues(); - const { partCurriculum, recruitPartCurriculum, recruitQuestion } = values; + const { + partCurriculum, + recruitPartCurriculum, + recruitQuestion, + recruitQuestionCount, + } = values; const setRequiredError = (name: string) => { setError(name, { type: 'required', @@ -301,17 +306,19 @@ export const validationRecruitInputs = ( } for (const part of PART_LIST) { - let hasCompletePair = false; const partQuestions = recruitQuestion?.[part]; + const count = Math.min( + recruitQuestionCount?.[part] ?? 1, + FAQ_MAX_QUESTION_COUNT, + ); - for (let index = 0; index < FAQ_MAX_QUESTION_COUNT; index += 1) { + // 화면에 보이는 칸은 전부 필수. 안 쓸 칸은 채우거나 삭제 버튼으로 지워야 한다. + for (let index = 0; index < count; index += 1) { const questionName = `recruitQuestion.${part}.question${index}`; const answerName = `recruitQuestion.${part}.answer${index}`; const question = (partQuestions?.[`question${index}`] ?? '').trim(); const answer = (partQuestions?.[`answer${index}`] ?? '').trim(); - if (!question && !answer) continue; - if (!question) { focusInvalidField(questionName, onInvalidFaqPart, part); return false; @@ -321,17 +328,6 @@ export const validationRecruitInputs = ( focusInvalidField(answerName, onInvalidFaqPart, part); return false; } - - hasCompletePair = true; - } - - if (!hasCompletePair) { - focusInvalidField( - `recruitQuestion.${part}.question0`, - onInvalidFaqPart, - part, - ); - return false; } } diff --git a/src/utils/org.ts b/src/utils/org.ts index 886dafa0..032f65cf 100644 --- a/src/utils/org.ts +++ b/src/utils/org.ts @@ -45,7 +45,7 @@ export const PART_LIST: PART_KO[] = [ // 모집안내 탭 FAQ 파트별 질문 최대 개수 export const FAQ_MAX_QUESTION_COUNT = 10; -export const FAQ_DEFAULT_QUESTION_COUNT = 3; +export const FAQ_DEFAULT_QUESTION_COUNT = 1; export type EXEC_TYPE = | (typeof 임원진_LIST)[number]