From dc273ef3d4f448cd43ae2ed9b389d01468225f1f Mon Sep 17 00:00:00 2001 From: constantly-dev Date: Wed, 12 Aug 2026 22:11:43 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=EB=AA=A8=EC=A7=91=EC=95=88=EB=82=B4?= =?UTF-8?q?=20FAQ=20=EC=9E=AC=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=EB=90=9C=20=EC=A7=88=EB=AC=B8=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EA=B0=80=20=ED=8F=BC=EC=97=90=20=EB=82=A8=EC=95=84?= =?UTF-8?q?=EC=9E=88=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버 데이터 개수만큼만 setValue를 호출해 그 이상 인덱스를 비워주지 않았음. 배포 직후 stale한 캐시로 폼을 다시 채우는 타이밍과 겹치면 방금 삭제한 질문이 화면에 부활하는 현상으로 이어져, 모든 인덱스를 순회하며 서버에 없는 값은 명시적으로 빈 문자열로 덮어쓰도록 수정. --- .../_utils/syncRecruitFormFromAdminData.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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, ); - }); + } }); }; From fbab1d27ebbe27772627d250a73288494601bacb Mon Sep 17 00:00:00 2001 From: constantly-dev Date: Wed, 12 Aug 2026 22:11:52 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EB=AA=A8=EC=A7=91=EC=95=88=EB=82=B4?= =?UTF-8?q?=20FAQ=20=EC=A7=88=EB=AC=B8=20=EC=B9=B8=EC=9D=84=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B0=9C=EC=88=98?= =?UTF-8?q?=EB=A7=8C=ED=81=BC=EB=A7=8C=20=ED=91=9C=EC=8B=9C=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EB=B9=88=20=EC=B9=B8=EC=9D=80=20=ED=95=84=EC=88=98?= =?UTF-8?q?=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 파트별 질문 칸 수를 항상 최소 3개로 고정해서 실제 저장된 개수보다 많은 빈 칸이 표시되던 문제를 제거하고, 신규 파트는 최소 1개만 기본 노출되도록 변경. 화면에 보이는 칸(질문/답변)은 모두 필수로 간주해 하나라도 비어있으면 배포 시 에러를 띄우고 막도록 검증 로직을 추가. 화면에 보이는 칸 수를 폼(recruitQuestionCount)에 반영해 배포 유효성 검사에서 참조하도록 함. --- .../RecruitSection/_components/Faq/index.tsx | 15 ++++++++-- src/components/org/OrgAdmin/utils.ts | 28 ++++++++----------- src/utils/org.ts | 2 +- 3 files changed, 26 insertions(+), 19 deletions(-) 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/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] From ab982de0a2738a2e76e867ceed0e9fd992e70b97 Mon Sep 17 00:00:00 2001 From: constantly-dev Date: Wed, 12 Aug 2026 22:12:00 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=ED=99=9C=EB=8F=99=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EC=B2=AB=20=EB=B2=88=EC=A7=B8=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=EB=AA=85=EC=9D=84=20OT=EB=A1=9C=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지원서/공홈이 전체 일정의 첫 번째 항목을 이름으로 식별하는데, 관리자가 직접 입력한 세션명이 저장되면서 식별이 깨지는 문제가 있었음. FIRST_SCHEDULE_SESSION_NAME 상수를 도입해 0번째 행은 입력 UI를 비활성화하고, 서버 전송 시와 서버 데이터 동기화 시 모두 강제로 'OT'로 고정하도록 수정. 과거에 순서가 뒤섞여 저장된 데이터에 대비해 이름 기준으로 재정렬하는 폴백도 추가. --- .../OrgAdmin/AboutSection/Schedule/index.tsx | 13 +++++++- .../org/OrgAdmin/AboutSection/api.ts | 16 +++++---- .../org/OrgAdmin/AboutSection/formMapper.ts | 33 +++++++++++++++++-- .../AboutSection/scheduleConstants.ts | 3 ++ 4 files changed, 55 insertions(+), 10 deletions(-) create mode 100644 src/components/org/OrgAdmin/AboutSection/scheduleConstants.ts 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'; From aa4d09f157ded0fa97214247a090a6834c5e1ddf Mon Sep 17 00:00:00 2001 From: constantly-dev Date: Wed, 12 Aug 2026 22:12:06 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=EC=86=8C=EC=8B=9D=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=ED=99=95=EC=9D=B8=20=EB=8B=A4=EC=9D=B4=EC=96=BC?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=98=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=95=88=EB=82=B4=20=EB=AC=B8=EA=B5=AC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '최신 소식은 배포 버튼을 거치지 않고 즉시 배포가 돼요' 설명이 삭제 확인 맥락과 맞지 않아 혼란을 줘서 제거. --- .../org/OrgAdmin/HomeSection/_components/News/NewsSection.tsx | 1 - 1 file changed, 1 deletion(-) 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={() => {