diff --git a/.gitignore b/.gitignore index 58a544ec..2ffdb14b 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,5 @@ next-env.d.ts !.yarn/versions # omc -.omc \ No newline at end of file +.omc +.gstack/ diff --git a/src/__generated__/api.d.ts b/src/__generated__/api.d.ts index cc2ab30b..136a5589 100644 --- a/src/__generated__/api.d.ts +++ b/src/__generated__/api.d.ts @@ -1119,7 +1119,7 @@ export interface components { /** * Format: int32 * @description 기수 - * @example 36 + * @example 39 */ generation: number; /** @@ -1130,7 +1130,25 @@ export interface components { /** @description 리뷰 목록 */ review?: components["schemas"]["AddAdminReviewRequestDto"][]; /** @description 최신소식 목록 */ - news?: components["schemas"]["AddAdminNewsRequestDto"][]; + news?: components["schemas"]["AddAdminNewsPresignedRequestDto"][]; + }; + /** @description 최신소식 목록 */ + AddAdminNewsPresignedRequestDto: { + /** + * @description 이미지 파일명 + * @example news.png + */ + imageFileName: string; + /** + * @description 제목 + * @example MIND 23 + */ + title: string; + /** + * @description 링크 + * @example https://disquiet.io/product/mind-23-%EC%98%A4%EB%8A%98%EB%8F%84-%EB%A9%88%EC%B6%94%EC%A7%80-%EC%95%8A%EB%8A%94-it%EC%9D%B8%EB%93%A4 + */ + link: string; }; /** @description 어드민 홈 탭 배포 응답 (S3 PresignedUrl 포함) */ AddAdminHomeResponseDto: { @@ -1155,25 +1173,25 @@ export interface components { /** @description 브랜딩 컬러 */ AddAdminBrandingColorRequestDto: { /** - * @description 메인 컬러 - * @example FF0000 + * @description 다크모드 키 컬러 + * @example #FF0000 */ - main: string; + darkModeKeyColor: string; /** - * @description 로우 톤 컬러 - * @example CC0000 + * @description 다크모드 텍스트 컬러 + * @example white */ - low: string; + darkModeTextColor: string; /** - * @description 하이 톤 컬러 - * @example FF3333 + * @description 라이트모드 키 컬러 + * @example #FF0000 */ - high: string; + lightModeKeyColor: string; /** - * @description 포인트 컬러 - * @example FF9999 + * @description 라이트모드 텍스트 컬러 + * @example black */ - point: string; + lightModeTextColor: string; }; /** @description 어드민 공통 탭 배포 */ AddAdminCommonRequestDto: { @@ -1191,25 +1209,6 @@ export interface components { /** @description 모집 일정 */ recruitSchedule?: components["schemas"]["AddAdminRecruitScheduleRequestDto"][]; brandingColor: components["schemas"]["AddAdminBrandingColorRequestDto"]; - mainButton: components["schemas"]["AddAdminMainButtonRequestDto"]; - }; - /** @description 메인 버튼 스타일 */ - AddAdminMainButtonRequestDto: { - /** - * @description 버튼 텍스트 - * @example 지원하기 - */ - text: string; - /** - * @description 주요 컬러 - * @example #FF0000 - */ - keyColor: string; - /** - * @description 보조 컬러 - * @example #CC0000 - */ - subColor: string; }; /** @description 모집 일정 */ AddAdminRecruitScheduleRequestDto: { @@ -1589,10 +1588,10 @@ export interface components { }; /** @description 기수 브랜딩 컬러 */ BrandingColor: { - main?: string; - high?: string; - low?: string; - point?: string; + darkModeKeyColor?: string; + darkModeTextColor?: string; + lightModeKeyColor?: string; + lightModeTextColor?: string; }; /** @description 핵심 가치 */ CoreValue: { @@ -1757,11 +1756,6 @@ export interface components { image?: string; link?: string; }; - MainButton: { - text?: string; - keyColor?: string; - subColor?: string; - }; /** @description 메인 페이지 데이터 */ MainPageResponse: { /** @@ -1776,16 +1770,39 @@ export interface components { */ name?: string; brandingColor?: components["schemas"]["BrandingColor"]; - mainButton?: components["schemas"]["MainButton"]; partIntroduction?: components["schemas"]["PartIntroduction"][]; latestNews?: components["schemas"]["LatestNews"][]; recruitSchedule?: components["schemas"]["RecruitSchedule"][]; activitiesRecords?: components["schemas"]["ActivitiesRecords"]; + reviews?: components["schemas"]["Review"][]; }; RecruitSchedule: { type?: string; schedule?: components["schemas"]["Schedule"]; }; + Review: { + /** + * Format: int32 + * @description ID + * @example 1 + */ + id?: number; + /** + * @description 제목 + * @example 후회없는 활동 + */ + title?: string; + /** + * @description 내용 + * @example 후회없는 활동이었어요 + */ + content?: string; + /** + * @description 작성자 정보 + * @example 김솝트 | 36, 37기 활동 | 서버 + */ + authorInfo?: string; + }; Schedule: { applicationStartTime?: string; applicationEndTime?: string; @@ -1882,25 +1899,25 @@ export interface components { /** @description 브랜딩 컬러 정보 */ GetAdminBrandingColorResponseRecordDto: { /** - * @description 메인 컬러 + * @description 다크모드 키 컬러 * @example #FF0000 */ - main: string; + darkModeKeyColor: string; /** - * @description 로우 톤 컬러 - * @example #CC0000 + * @description 다크모드 텍스트 컬러 + * @example white */ - low: string; + darkModeTextColor: string; /** - * @description 하이 톤 컬러 - * @example #FF3333 + * @description 라이트모드 키 컬러 + * @example #FF0000 */ - high: string; + lightModeKeyColor: string; /** - * @description 포인트 컬러 - * @example #FF9999 + * @description 라이트모드 텍스트 컬러 + * @example black */ - point: string; + lightModeTextColor: string; }; /** @description 핵심 가치 정보 */ GetAdminCoreValueResponseRecordDto: { @@ -1952,24 +1969,6 @@ export interface components { */ title: string; }; - /** @description 메인 버튼 스타일 */ - GetAdminMainButtonResponseRecordDto: { - /** - * @description 버튼 텍스트 - * @example 지원하기 - */ - text: string; - /** - * @description 주요 컬러 - * @example #FF0000 - */ - keyColor: string; - /** - * @description 보조 컬러 - * @example #CC0000 - */ - subColor: string; - }; /** @description 멤버 정보 */ GetAdminMemberResponseRecordDto: { /** @@ -2079,7 +2078,6 @@ export interface components { name: string; recruitSchedule?: components["schemas"]["GetAdminRecruitScheduleResponseRecordDto"][]; brandingColor?: components["schemas"]["GetAdminBrandingColorResponseRecordDto"]; - mainButton?: components["schemas"]["GetAdminMainButtonResponseRecordDto"]; partIntroduction?: components["schemas"]["GetAdminPartIntroductionResponseRecordDto"][]; latestNews?: components["schemas"]["GetAdminLatestNewsResponseRecordDto"][]; /** diff --git a/src/__generated__/org-types/data-contracts.ts b/src/__generated__/org-types/data-contracts.ts index a8f8f98e..9da7c314 100644 --- a/src/__generated__/org-types/data-contracts.ts +++ b/src/__generated__/org-types/data-contracts.ts @@ -397,7 +397,7 @@ export interface AddAdminHomeRequestDto { /** * 기수 * @format int32 - * @example 36 + * @example 39 */ generation: number; /** @@ -408,7 +408,26 @@ export interface AddAdminHomeRequestDto { /** 리뷰 목록 */ review?: AddAdminReviewRequestDto[]; /** 최신소식 목록 */ - news?: AddAdminNewsRequestDto[]; + news?: AddAdminNewsPresignedRequestDto[]; +} + +/** 최신소식 목록 */ +export interface AddAdminNewsPresignedRequestDto { + /** + * 이미지 파일명 + * @example "news.png" + */ + imageFileName: string; + /** + * 제목 + * @example "MIND 23" + */ + title: string; + /** + * 링크 + * @example "https://disquiet.io/product/mind-23-%EC%98%A4%EB%8A%98%EB%8F%84-%EB%A9%88%EC%B6%94%EC%A7%80-%EC%95%8A%EB%8A%94-it%EC%9D%B8%EB%93%A4" + */ + link: string; } /** 어드민 홈 탭 배포 응답 (S3 PresignedUrl 포함) */ @@ -436,29 +455,29 @@ export interface AddAdminNewsResponseRecordDto { /** 브랜딩 컬러 */ export interface AddAdminBrandingColorRequestDto { /** - * 메인 컬러 - * @pattern ^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ - * @example "FF0000" + * 다크모드 키 컬러 + * @pattern ^#?[A-Fa-f0-9]{6}$ + * @example "#FF0000" */ - main: string; + darkModeKeyColor: string; /** - * 로우 톤 컬러 - * @pattern ^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ - * @example "CC0000" + * 다크모드 텍스트 컬러 + * @pattern ^(white|black)$ + * @example "white" */ - low: string; + darkModeTextColor: string; /** - * 하이 톤 컬러 - * @pattern ^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ - * @example "FF3333" + * 라이트모드 키 컬러 + * @pattern ^#?[A-Fa-f0-9]{6}$ + * @example "#FF0000" */ - high: string; + lightModeKeyColor: string; /** - * 포인트 컬러 - * @pattern ^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ - * @example "FF9999" + * 라이트모드 텍스트 컬러 + * @pattern ^(white|black)$ + * @example "black" */ - point: string; + lightModeTextColor: string; } /** 어드민 공통 탭 배포 */ @@ -478,29 +497,6 @@ export interface AddAdminCommonRequestDto { recruitSchedule?: AddAdminRecruitScheduleRequestDto[]; /** 브랜딩 컬러 */ brandingColor: AddAdminBrandingColorRequestDto; - /** 메인 버튼 스타일 */ - mainButton: AddAdminMainButtonRequestDto; -} - -/** 메인 버튼 스타일 */ -export interface AddAdminMainButtonRequestDto { - /** - * 버튼 텍스트 - * @example "지원하기" - */ - text: string; - /** - * 주요 컬러 - * @pattern ^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ - * @example "#FF0000" - */ - keyColor: string; - /** - * 보조 컬러 - * @pattern ^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ - * @example "#CC0000" - */ - subColor: string; } /** 모집 일정 */ @@ -909,10 +905,10 @@ export interface ReviewsByAuthorRes { /** 기수 브랜딩 컬러 */ export interface BrandingColor { - main?: string; - high?: string; - low?: string; - point?: string; + darkModeKeyColor?: string; + darkModeTextColor?: string; + lightModeKeyColor?: string; + lightModeTextColor?: string; } /** 핵심 가치 */ @@ -1109,12 +1105,6 @@ export interface LatestNews { link?: string; } -export interface MainButton { - text?: string; - keyColor?: string; - subColor?: string; -} - /** 메인 페이지 데이터 */ export interface MainPageResponse { /** @@ -1130,11 +1120,11 @@ export interface MainPageResponse { name?: string; /** 기수 브랜딩 컬러 */ brandingColor?: BrandingColor; - mainButton?: MainButton; partIntroduction?: PartIntroduction[]; latestNews?: LatestNews[]; recruitSchedule?: RecruitSchedule[]; activitiesRecords?: ActivitiesRecords; + reviews?: Review[]; } export interface RecruitSchedule { @@ -1142,6 +1132,30 @@ export interface RecruitSchedule { schedule?: Schedule; } +export interface Review { + /** + * ID + * @format int32 + * @example 1 + */ + id?: number; + /** + * 제목 + * @example "후회없는 활동" + */ + title?: string; + /** + * 내용 + * @example "후회없는 활동이었어요" + */ + content?: string; + /** + * 작성자 정보 + * @example "김솝트 | 36, 37기 활동 | 서버" + */ + authorInfo?: string; +} + export interface Schedule { applicationStartTime?: string; applicationEndTime?: string; @@ -1248,25 +1262,25 @@ export interface GetAdminActivityScheduleResponseRecordDto { /** 브랜딩 컬러 정보 */ export interface GetAdminBrandingColorResponseRecordDto { /** - * 메인 컬러 + * 다크모드 키 컬러 * @example "#FF0000" */ - main: string; + darkModeKeyColor: string; /** - * 로우 톤 컬러 - * @example "#CC0000" + * 다크모드 텍스트 컬러 + * @example "white" */ - low: string; + darkModeTextColor: string; /** - * 하이 톤 컬러 - * @example "#FF3333" + * 라이트모드 키 컬러 + * @example "#FF0000" */ - high: string; + lightModeKeyColor: string; /** - * 포인트 컬러 - * @example "#FF9999" + * 라이트모드 텍스트 컬러 + * @example "black" */ - point: string; + lightModeTextColor: string; } /** 핵심 가치 정보 */ @@ -1322,25 +1336,6 @@ export interface GetAdminLatestNewsResponseRecordDto { title: string; } -/** 메인 버튼 스타일 */ -export interface GetAdminMainButtonResponseRecordDto { - /** - * 버튼 텍스트 - * @example "지원하기" - */ - text: string; - /** - * 주요 컬러 - * @example "#FF0000" - */ - keyColor: string; - /** - * 보조 컬러 - * @example "#CC0000" - */ - subColor: string; -} - /** 멤버 정보 */ export interface GetAdminMemberResponseRecordDto { /** @@ -1460,8 +1455,6 @@ export interface GetAdminResponseDto { recruitSchedule?: GetAdminRecruitScheduleResponseRecordDto[]; /** 브랜딩 컬러 정보 */ brandingColor?: GetAdminBrandingColorResponseRecordDto; - /** 메인 버튼 스타일 */ - mainButton?: GetAdminMainButtonResponseRecordDto; partIntroduction?: GetAdminPartIntroductionResponseRecordDto[]; latestNews?: GetAdminLatestNewsResponseRecordDto[]; /** diff --git a/src/components/org/OrgAdmin/CommonSection/BrandingColor.tsx b/src/components/org/OrgAdmin/CommonSection/BrandingColor.tsx index e418fea8..3e855d37 100644 --- a/src/components/org/OrgAdmin/CommonSection/BrandingColor.tsx +++ b/src/components/org/OrgAdmin/CommonSection/BrandingColor.tsx @@ -11,14 +11,18 @@ import ColorInputField from './ColorInputField'; import ColorRadioField from './ColorRadioField'; import { StInputFieldWrapper, StStretchContainer } from './style'; -const BRANDING_COLOR_FIELD_IDS = { +export const BRANDING_COLOR_FIELD_IDS = { darkKeyColor: 'brandingColor.darkKeyColor', darkTextOnColor: 'brandingColor.darkTextOnColor', lightKeyColor: 'brandingColor.lightKeyColor', lightTextOnColor: 'brandingColor.lightTextOnColor', } as const; -const BrandingColor = () => { +interface BrandingColorProps { + isEditable?: boolean; +} + +const BrandingColor = ({ isEditable = true }: BrandingColorProps) => { return ( @@ -37,6 +41,7 @@ const BrandingColor = () => { id={BRANDING_COLOR_FIELD_IDS.darkKeyColor} description="다크 모드 텍스트와 컴포넌트에 강조색으로 적용돼요." previewDefaultColor={colors.white} + disabled={!isEditable} /> { id={BRANDING_COLOR_FIELD_IDS.darkTextOnColor} keyColorId={BRANDING_COLOR_FIELD_IDS.darkKeyColor} previewDefaultColor={colors.white} - defaultValue="dark" + defaultValue="black" + disabled={!isEditable} /> @@ -54,6 +60,7 @@ const BrandingColor = () => { id={BRANDING_COLOR_FIELD_IDS.lightKeyColor} description="라이트 모드 키컬러와 컴포넌트에 강조색으로 적용돼요." previewDefaultColor={colors.black} + disabled={!isEditable} /> { id={BRANDING_COLOR_FIELD_IDS.lightTextOnColor} keyColorId={BRANDING_COLOR_FIELD_IDS.lightKeyColor} previewDefaultColor={colors.black} - defaultValue="light" + defaultValue="white" lightRecommended + disabled={!isEditable} /> diff --git a/src/components/org/OrgAdmin/CommonSection/BrandingSubColor.tsx b/src/components/org/OrgAdmin/CommonSection/BrandingSubColor.tsx deleted file mode 100644 index bcb7975c..00000000 --- a/src/components/org/OrgAdmin/CommonSection/BrandingSubColor.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { IconInfoCircle } from '@sopt-makers/icons'; -import { type MouseEvent } from 'react'; -import { useFormContext } from 'react-hook-form'; - -import { VALIDATION_CHECK } from '@/utils/org'; - -import { StInputLabel } from '../AboutSection/style'; -import RequiredIcon from '../assets/RequiredIcon'; -import { StInput, StInputBox } from '../style'; -import { - StColorWrapper, - StInfoButton, - StSubColorDescription, - StSubColorPreview, -} from './style'; -import { expandHexColor } from './utils'; - -const BrandingSubColor = ({ - onInfoToggle, -}: { - onInfoToggle: (e: MouseEvent) => void; -}) => { - const { - register, - formState: { errors }, - watch, - setValue, - } = useFormContext(); - - return ( - - - - 서브 컬러 (강조 그레이 컬러) - - - - 강조하고 싶은 박스의 그레이 컬러를 지정해주세요. - - - - - - - setValue('brandingColor.point', e.target.value.replace('#', '')) - } - /> - - - ); -}; - -export default BrandingSubColor; diff --git a/src/components/org/OrgAdmin/CommonSection/ColorInputField.tsx b/src/components/org/OrgAdmin/CommonSection/ColorInputField.tsx index 47055ca4..4d88e1dd 100644 --- a/src/components/org/OrgAdmin/CommonSection/ColorInputField.tsx +++ b/src/components/org/OrgAdmin/CommonSection/ColorInputField.tsx @@ -17,6 +17,7 @@ interface ColorInputFieldProps { id: string; description?: string; previewDefaultColor: string; + disabled?: boolean; } const ColorInputField = ({ @@ -24,6 +25,7 @@ const ColorInputField = ({ id, description, previewDefaultColor, + disabled = false, }: ColorInputFieldProps) => { const { register, @@ -56,6 +58,7 @@ const ColorInputField = ({ type="text" maxLength={8} placeholder="ex. ffffff" + disabled={disabled} isError={!!(errors as any)[brandingColor]?.[color]?.message} errorMessage={ (errors as any)[brandingColor]?.[color]?.message as string @@ -64,6 +67,7 @@ const ColorInputField = ({ setValue(id, e.target.value.replace('#', ''), { shouldDirty: true, diff --git a/src/components/org/OrgAdmin/CommonSection/ColorRadioField.tsx b/src/components/org/OrgAdmin/CommonSection/ColorRadioField.tsx index 2f559360..d9168c3d 100644 --- a/src/components/org/OrgAdmin/CommonSection/ColorRadioField.tsx +++ b/src/components/org/OrgAdmin/CommonSection/ColorRadioField.tsx @@ -24,9 +24,10 @@ interface ColorRadioFieldProps { /** 키컬러가 비어 있을 때 미리보기 배경색 */ previewDefaultColor: string; /** 초기 선택값 */ - defaultValue?: 'dark' | 'light'; + defaultValue?: 'black' | 'white'; /** true이면 밝은 텍스트 옵션에 "(권장)" 표시 */ lightRecommended?: boolean; + disabled?: boolean; } const ColorRadioField = ({ @@ -37,13 +38,14 @@ const ColorRadioField = ({ previewDefaultColor, defaultValue, lightRecommended, + disabled = false, }: ColorRadioFieldProps) => { const { control } = useFormContext(); const selectedValue = useWatch({ control, name: id, defaultValue: defaultValue ?? '', - }) as 'dark' | 'light' | ''; + }) as 'black' | 'white' | ''; const currentKeyColor = useWatch({ control, @@ -57,7 +59,7 @@ const ColorRadioField = ({ const keyColor = currentKeyColor ? expandHexColor(currentKeyColor) : previewDefaultColor; - const previewTextColor = selectedValue === 'light' ? '#ffffff' : '#000000'; + const previewTextColor = selectedValue === 'white' ? '#ffffff' : '#000000'; return ( @@ -79,19 +81,21 @@ const ColorRadioField = ({ name={field.name} ref={field.ref} onBlur={field.onBlur} - onChange={() => field.onChange('dark')} - checked={field.value === 'dark'} + onChange={() => field.onChange('black')} + checked={field.value === 'black'} label="어두운 텍스트" - value="dark" + value="black" + disabled={disabled} /> field.onChange('light')} - checked={field.value === 'light'} + onChange={() => field.onChange('white')} + checked={field.value === 'white'} label={lightTextLabel} - value="light" + value="white" + disabled={disabled} /> )} diff --git a/src/components/org/OrgAdmin/CommonSection/GenerationInformation.tsx b/src/components/org/OrgAdmin/CommonSection/GenerationInformation.tsx index e00bc063..32deb7b3 100644 --- a/src/components/org/OrgAdmin/CommonSection/GenerationInformation.tsx +++ b/src/components/org/OrgAdmin/CommonSection/GenerationInformation.tsx @@ -11,7 +11,13 @@ import { StWrapper, } from '../style'; -const GenerationInformation = () => { +interface GenerationInformationProps { + isEditable?: boolean; +} + +const GenerationInformation = ({ + isEditable = true, +}: GenerationInformationProps) => { const { register, formState: { errors }, @@ -47,6 +53,7 @@ const GenerationInformation = () => { id="generation" type="text" placeholder="ex. 35" + disabled={!isEditable} isError={errors.generation?.message != undefined} errorMessage={errors.generation?.message as string} /> @@ -63,6 +70,7 @@ const GenerationInformation = () => { id="sopt-name" type="text" placeholder="ex. 00 SOPT" + disabled={!isEditable} isError={errors.name?.message != undefined} errorMessage={errors.name?.message as string} /> diff --git a/src/components/org/OrgAdmin/CommonSection/RecruitSchedule.tsx b/src/components/org/OrgAdmin/CommonSection/RecruitSchedule.tsx index 4e634545..14083dc2 100644 --- a/src/components/org/OrgAdmin/CommonSection/RecruitSchedule.tsx +++ b/src/components/org/OrgAdmin/CommonSection/RecruitSchedule.tsx @@ -16,9 +16,14 @@ import { StDateWrapper, StRadioWrapper } from './style'; interface ScheduleInputProps { id: string; label: string; + isEditable?: boolean; } -const ScheduleInput = ({ id, label }: ScheduleInputProps) => { +const ScheduleInput = ({ + id, + label, + isEditable = true, +}: ScheduleInputProps) => { const { register, formState: { errors }, @@ -35,6 +40,7 @@ const ScheduleInput = ({ id, label }: ScheduleInputProps) => { labelText={label} id={id} type="datetime-local" + disabled={!isEditable} hasValue={!!watch(`${recruitSchedule}.${group}.${time}`)} isError={!!(errors as any)[recruitSchedule]?.[group]?.[time]?.message} errorMessage={ @@ -47,9 +53,14 @@ const ScheduleInput = ({ id, label }: ScheduleInputProps) => { interface RecruitScheduleProps { group: Group; onChangeGroup: (group: Group) => void; + isEditable?: boolean; } -const RecruitSchedule = ({ group, onChangeGroup }: RecruitScheduleProps) => { +const RecruitSchedule = ({ + group, + onChangeGroup, + isEditable = true, +}: RecruitScheduleProps) => { const currentFields = SCHEDULE_FIELDS[group]; return ( diff --git a/src/components/org/OrgAdmin/CommonSection/api.ts b/src/components/org/OrgAdmin/CommonSection/api.ts new file mode 100644 index 00000000..ce063032 --- /dev/null +++ b/src/components/org/OrgAdmin/CommonSection/api.ts @@ -0,0 +1,66 @@ +import type { FieldValues } from 'react-hook-form'; + +import type { + AddAdminBrandingColorRequestDto, + AddAdminCommonRequestDto, + AddAdminRecruitScheduleRequestDto, +} from '@/__generated__/org-types/data-contracts'; + +import { soptFetcher } from '../api'; +import { + buildBrandingColorFromForm, + buildRecruitScheduleFromForm, +} from './formMapper'; + +export const postCommonTab = async (body: AddAdminCommonRequestDto) => { + const { data, error } = await soptFetcher.POST('/admin/common', { body }); + + if (error || !data) { + throw new Error('공통 탭 배포 요청에 실패했습니다.'); + } + + return data; +}; + +export const postCommonTabConfirm = async (generation: number) => { + const { data, error } = await soptFetcher.POST('/admin/common/confirm', { + body: { generation }, + }); + + if (error || !data) { + throw new Error('공통 탭 배포 확정에 실패했습니다.'); + } + + return data; +}; + +export type DeployCommonInput = { + generation: number; + name: string; + recruitSchedule: AddAdminRecruitScheduleRequestDto[]; + brandingColor: AddAdminBrandingColorRequestDto; +}; + +export const deployCommonTab = async ({ + generation, + name, + recruitSchedule, + brandingColor, +}: DeployCommonInput) => { + await postCommonTab({ + generation, + name, + recruitSchedule, + brandingColor, + }); + + return postCommonTabConfirm(generation); +}; + +export const deployCommonTabFromForm = async (values: FieldValues) => + deployCommonTab({ + generation: Number(values.generation), + name: values.name, + recruitSchedule: buildRecruitScheduleFromForm(values), + brandingColor: buildBrandingColorFromForm(values), + }); diff --git a/src/components/org/OrgAdmin/CommonSection/formMapper.ts b/src/components/org/OrgAdmin/CommonSection/formMapper.ts new file mode 100644 index 00000000..2da5f8e6 --- /dev/null +++ b/src/components/org/OrgAdmin/CommonSection/formMapper.ts @@ -0,0 +1,111 @@ +import type { FieldValues, UseFormSetValue } from 'react-hook-form'; + +import type { + AddAdminBrandingColorRequestDto, + AddAdminRecruitScheduleRequestDto, +} from '@/__generated__/org-types/data-contracts'; + +import { + fromApiDateTime, + SCHEDULE_TIME_FIELDS, + stripHexHash, + toApiHexColor, +} from './utils'; + +type AdminCommonData = { + generation?: number; + name?: string; + recruitSchedule?: { + type?: 'OB' | 'YB'; + schedule?: Partial>; + }[]; + brandingColor?: { + darkModeKeyColor?: string; + darkModeTextColor?: string; + lightModeKeyColor?: string; + lightModeTextColor?: string; + }; +}; + +const setValueOptions = { shouldDirty: false, shouldValidate: false }; + +export const syncCommonFormFromAdminData = ( + data: AdminCommonData | undefined, + setValue: UseFormSetValue, +) => { + if (!data) return; + + setValue( + 'generation', + data.generation ? String(data.generation) : '', + setValueOptions, + ); + setValue('name', data.name ?? '', setValueOptions); + + data.recruitSchedule?.forEach(({ type, schedule }) => { + if (!type || !schedule) return; + + SCHEDULE_TIME_FIELDS.forEach((field) => { + const value = schedule[field]; + + setValue( + `recruitSchedule.${type}.${field}`, + value ? fromApiDateTime(value) : '', + setValueOptions, + ); + }); + }); + + if (data.brandingColor) { + setValue( + 'brandingColor.darkKeyColor', + stripHexHash(data.brandingColor.darkModeKeyColor), + setValueOptions, + ); + setValue( + 'brandingColor.darkTextOnColor', + data.brandingColor.darkModeTextColor, + setValueOptions, + ); + setValue( + 'brandingColor.lightKeyColor', + stripHexHash(data.brandingColor.lightModeKeyColor), + setValueOptions, + ); + setValue( + 'brandingColor.lightTextOnColor', + data.brandingColor.lightModeTextColor, + setValueOptions, + ); + } +}; + +export const buildRecruitScheduleFromForm = ( + values: FieldValues, +): AddAdminRecruitScheduleRequestDto[] => + (['OB', 'YB'] as const).map((type) => { + const schedule = values.recruitSchedule?.[type] ?? {}; + + return { + type, + // API는 'yyyy-MM-ddTHH:mm' 같은 타임존 없는 로컬 문자열만 허용한다. + // datetime-local 입력값이 이미 이 형식이라 그대로 보낸다. + // (UTC로 변환해서 보내면 백엔드가 형식 검증에서 reject한다.) + schedule: SCHEDULE_TIME_FIELDS.reduce( + (acc, field) => ({ + ...acc, + [field]: schedule[field] ?? '', + }), + {} as AddAdminRecruitScheduleRequestDto['schedule'], + ), + }; + }); + +export const buildBrandingColorFromForm = ( + values: FieldValues, +): AddAdminBrandingColorRequestDto => ({ + darkModeKeyColor: toApiHexColor(values.brandingColor?.darkKeyColor ?? ''), + darkModeTextColor: values.brandingColor?.darkTextOnColor, + lightModeKeyColor: toApiHexColor(values.brandingColor?.lightKeyColor ?? ''), + lightModeTextColor: values.brandingColor?.lightTextOnColor, +}); diff --git a/src/components/org/OrgAdmin/CommonSection/index.tsx b/src/components/org/OrgAdmin/CommonSection/index.tsx index 4db076f7..c426a56e 100644 --- a/src/components/org/OrgAdmin/CommonSection/index.tsx +++ b/src/components/org/OrgAdmin/CommonSection/index.tsx @@ -1,7 +1,22 @@ +import { ToastProvider } from '@sopt-makers/ui'; +import { useEffect, useState } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; + +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 { validationCommonInputs } from '@/components/org/OrgAdmin/utils'; + import { StContainer } from '../style'; import type { Group } from '../types'; import BrandingColor from './BrandingColor'; +import { syncCommonFormFromAdminData } from './formMapper'; import GenerationInformation from './GenerationInformation'; +import { useDeployCommonMutation } from './queries'; import RecruitSchedule from './RecruitSchedule'; interface CommonSectionProps { @@ -9,14 +24,108 @@ interface CommonSectionProps { onChangeGroup: (group: Group) => void; } -const CommonSection = ({ group, onChangeGroup }: CommonSectionProps) => { +const COMMON_FIELD_NAMES = [ + 'generation', + 'name', + 'recruitSchedule', + 'brandingColor', +]; + +const CommonSectionContent = ({ group, onChangeGroup }: CommonSectionProps) => { + const [editStep, setEditStep] = useState(EDIT_STEP.VIEW); + const [snapshot, setSnapshot] = useState(null); + + const { data } = useAdminInfoQuery(); + const { mutate: deployCommon, isLoading: isDeploying } = + useDeployCommonMutation(); + + const { control, getValues, setValue, setError, clearErrors } = + useFormContext(); + + const watchedValues = useWatch({ control, name: COMMON_FIELD_NAMES }); + + const isEditMode = editStep !== EDIT_STEP.VIEW; + const isDeployModalOpen = editStep === EDIT_STEP.DEPLOY; + const hasUnsavedChanges = + isEditMode && + snapshot !== null && + JSON.stringify(watchedValues) !== JSON.stringify(snapshot); + + useEffect(() => { + syncCommonFormFromAdminData(data, setValue); + }, [data, setValue]); + + const startEditMode = () => { + // getValues()는 recruitSchedule/brandingColor 같은 중첩 객체에 대해 + // RHF 내부 상태의 참조를 그대로 돌려준다. 깊은 복사 없이 들고 있으면 + // 이후 setValue로 인해 snapshot도 같이 바뀌어버려 비교/복원이 무력화된다. + setSnapshot(JSON.parse(JSON.stringify(getValues(COMMON_FIELD_NAMES)))); + setEditStep(EDIT_STEP.EDITING); + }; + + const cancelEditMode = () => { + if (snapshot) { + COMMON_FIELD_NAMES.forEach((name, index) => { + setValue(name, snapshot[index], { + shouldDirty: false, + shouldValidate: false, + }); + }); + clearErrors(COMMON_FIELD_NAMES); + } + setSnapshot(null); + setEditStep(EDIT_STEP.VIEW); + }; + + const handleDeploy = () => { + deployCommon(getValues(), { + onSuccess: () => { + setSnapshot(null); + setEditStep(EDIT_STEP.VIEW); + }, + }); + }; + return ( - - - + { + if (validationCommonInputs(getValues, setError, onChangeGroup)) { + setEditStep(EDIT_STEP.DEPLOY); + } + }} + /> + + + + + setEditStep(EDIT_STEP.EDITING)} + onAction={handleDeploy} + alertText="배포하시겠습니까?" + description="입력한 공통 설정 내용은 공홈에 즉시 반영돼요." + /> ); }; +const CommonSection = (props: CommonSectionProps) => { + return ( + + + + ); +}; + export default CommonSection; diff --git a/src/components/org/OrgAdmin/CommonSection/queries.ts b/src/components/org/OrgAdmin/CommonSection/queries.ts new file mode 100644 index 00000000..321aad0b --- /dev/null +++ b/src/components/org/OrgAdmin/CommonSection/queries.ts @@ -0,0 +1,23 @@ +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 { deployCommonTabFromForm } from './api'; + +export const useDeployCommonMutation = () => { + const queryClient = useQueryClient(); + const { open } = useToast(); + + return useMutation({ + mutationFn: (values: FieldValues) => deployCommonTabFromForm(values), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin'] }); + open(DEPLOY_TOAST_OPTION.success); + }, + onError: () => { + open(DEPLOY_TOAST_OPTION.error); + }, + }); +}; diff --git a/src/components/org/OrgAdmin/CommonSection/utils.ts b/src/components/org/OrgAdmin/CommonSection/utils.ts index 96d4643f..a45df833 100644 --- a/src/components/org/OrgAdmin/CommonSection/utils.ts +++ b/src/components/org/OrgAdmin/CommonSection/utils.ts @@ -12,3 +12,28 @@ export const expandHexColor = (hex: string) => { return `#${hex}`; }; + +export const stripHexHash = (hex: string | undefined) => + (hex ?? '').replace(/^#/, ''); + +/** API는 `#RRGGBB` 6자리만 허용해서, 폼이 허용하는 8자리(알파) 입력은 잘라낸다. */ +export const toApiHexColor = (hex: string) => { + const expanded = expandHexColor(hex); + return expanded.length > 7 ? expanded.slice(0, 7) : expanded; +}; + +export const SCHEDULE_TIME_FIELDS = [ + 'applicationStartTime', + 'applicationEndTime', + 'applicationResultTime', + 'interviewStartTime', + 'interviewEndTime', + 'finalResultTime', +] as const; + +/** API가 내려주는 'yyyy-MM-dd HH:mm:ss' 또는 'yyyy-MM-ddTHH:mm[:ss]' 문자열을 datetime-local 입력값으로 정규화한다. */ +export const fromApiDateTime = (apiValue: string) => { + if (!apiValue) return ''; + + return apiValue.replace(' ', 'T').slice(0, 16); +}; diff --git a/src/components/org/OrgAdmin/HomeSection/ButtonSection.tsx b/src/components/org/OrgAdmin/HomeSection/ButtonSection.tsx deleted file mode 100644 index 85c8f015..00000000 --- a/src/components/org/OrgAdmin/HomeSection/ButtonSection.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { TextField } from '@sopt-makers/ui'; -import { useFormContext } from 'react-hook-form'; - -import LiveAppliedButton from '@/components/org/OrgAdmin/HomeSection/LiveAppliedButton'; -import { - StButtonFormContainer, - StContentContainer, - StFirstSectionContainer, - StTitle, -} from '@/components/org/OrgAdmin/HomeSection/style'; - -type ButtonSectionProps = { - mainButton?: { - text: string; - keyColor: string; - subColor: string; - }; -}; - -const ButtonSection = ({ mainButton }: ButtonSectionProps) => { - const { register, watch } = useFormContext(); - - const textString = watch('text'); - const keyColorString = watch('keyColor'); - const subColorString = watch('subColor'); - - return ( - - 메인 버튼 - - - - - - - - - - - - ); -}; - -export default ButtonSection; diff --git a/src/components/org/OrgAdmin/HomeSection/api.ts b/src/components/org/OrgAdmin/HomeSection/api.ts index 7d4fa0c9..3e1b44cf 100644 --- a/src/components/org/OrgAdmin/HomeSection/api.ts +++ b/src/components/org/OrgAdmin/HomeSection/api.ts @@ -194,7 +194,7 @@ export const extractFileNameFromUrl = (url: string) => { }; type PostHomeTabBody = Omit & { - news?: { image: string; title: string; link: string }[]; + news?: { imageFileName: string; title: string; link: string }[]; }; export const postHomeTab = async (body: PostHomeTabBody) => { @@ -249,7 +249,7 @@ export const deployHomeTab = async ({ news: newsDetails .filter((news): news is NonNullable => Boolean(news)) .map((news) => ({ - image: extractFileNameFromUrl(news.image), + imageFileName: extractFileNameFromUrl(news.image), title: news.title, link: news.link, })), diff --git a/src/components/org/OrgAdmin/api.ts b/src/components/org/OrgAdmin/api.ts index bfa62144..229b7afd 100644 --- a/src/components/org/OrgAdmin/api.ts +++ b/src/components/org/OrgAdmin/api.ts @@ -23,10 +23,7 @@ const soptAuthMiddleware: Middleware = { const token = getToken('ACCESS'); if (token) { - request.headers.set( - 'Authorization', - token.startsWith('Bearer ') ? token : `Bearer ${token}`, - ); + request.headers.set('Authorization', token); } else { request.headers.delete('Authorization'); } diff --git a/src/components/org/OrgAdmin/common/EditActionBar/style.ts b/src/components/org/OrgAdmin/common/EditActionBar/style.ts index ed276274..3ef25f53 100644 --- a/src/components/org/OrgAdmin/common/EditActionBar/style.ts +++ b/src/components/org/OrgAdmin/common/EditActionBar/style.ts @@ -19,6 +19,7 @@ export const StEditActionWrapper = styled.div` export const StEditActionButtonWrapper = styled.div` display: flex; gap: 14px; + align-self: flex-end; `; export const StUnsavedChangeText = styled.p` diff --git a/src/components/org/OrgAdmin/index.tsx b/src/components/org/OrgAdmin/index.tsx index 79dc281a..faa259b8 100644 --- a/src/components/org/OrgAdmin/index.tsx +++ b/src/components/org/OrgAdmin/index.tsx @@ -384,18 +384,20 @@ function OrgAdmin() { onChangeFnaPart={(part: PART_KO) => setFnaPart(part)} /> )} - {selectedPart !== '홈' && selectedPart !== '모집안내' && ( - { - if (handleCheckNonFilledInputs()) { - setIsActionModalOpen(true); - } - }}> - - 배포 - - )} + {selectedPart !== '홈' && + selectedPart !== '모집안내' && + selectedPart !== '공통' && ( + { + if (handleCheckNonFilledInputs()) { + setIsActionModalOpen(true); + } + }}> + + 배포 + + )} ({ + ...Object.keys(BRANDING_COLOR_FIELD_IDS).map((color) => ({ name: `brandingColor.${color}`, value: brandingColor?.[color], })), diff --git a/src/configs/config.ts b/src/configs/config.ts index d5091a9e..ff381c2b 100644 --- a/src/configs/config.ts +++ b/src/configs/config.ts @@ -7,7 +7,7 @@ const CLIENT_URL = 'https://operation.sopt.org'; const ORG_API_URL = process.env.NEXT_PUBLIC_API_URL === 'PRODUCTION' ? 'https://api.sopt.org' - : 'https://org-api-dev.sopt.org'; + : 'https://api-dev.sopt.org'; const SOPT_API_URL = process.env.NEXT_PUBLIC_API_URL === 'PRODUCTION' ? 'https://api.sopt.org'