diff --git a/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/components/mobile/InfoSection/InfoSection.stories.tsx b/frontend/src/pages/AdminPage/components/InfoSection/InfoSection.stories.tsx
similarity index 97%
rename from frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/components/mobile/InfoSection/InfoSection.stories.tsx
rename to frontend/src/pages/AdminPage/components/InfoSection/InfoSection.stories.tsx
index a4e96babc..beb3af5a7 100644
--- a/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/components/mobile/InfoSection/InfoSection.stories.tsx
+++ b/frontend/src/pages/AdminPage/components/InfoSection/InfoSection.stories.tsx
@@ -53,7 +53,7 @@ const MobileTextarea = ({
};
const meta = {
- title: 'Pages/AdminPage/tabs/ClubIntroEditTab/components/mobile/InfoSection',
+ title: 'Pages/AdminPage/components/InfoSection',
component: InfoSection,
parameters: { layout: 'centered' },
tags: ['autodocs'],
diff --git a/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/components/mobile/InfoSection/InfoSection.styles.ts b/frontend/src/pages/AdminPage/components/InfoSection/InfoSection.styles.ts
similarity index 100%
rename from frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/components/mobile/InfoSection/InfoSection.styles.ts
rename to frontend/src/pages/AdminPage/components/InfoSection/InfoSection.styles.ts
diff --git a/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/components/mobile/InfoSection/InfoSection.tsx b/frontend/src/pages/AdminPage/components/InfoSection/InfoSection.tsx
similarity index 100%
rename from frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/components/mobile/InfoSection/InfoSection.tsx
rename to frontend/src/pages/AdminPage/components/InfoSection/InfoSection.tsx
diff --git a/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/ClubIntroEditTabMobile.tsx b/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/ClubIntroEditTabMobile.tsx
index 5d59ee004..c50ab01a7 100644
--- a/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/ClubIntroEditTabMobile.tsx
+++ b/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/ClubIntroEditTabMobile.tsx
@@ -15,12 +15,12 @@ import {
INTRO_DESCRIPTION_PLACEHOLDER,
} from '@/constants/adminFieldPlaceholders';
import ClearableTextArea from '@/pages/AdminPage/components/ClearableTextArea/ClearableTextArea';
+import InfoSection from '@/pages/AdminPage/components/InfoSection/InfoSection';
import { Award, FAQ, IdealCandidate } from '@/types/club';
import * as Styled from './ClubIntroEditTabMobile.styles';
import AwardEditPage from './components/mobile/AwardEditPage/AwardEditPage';
import AwardSection from './components/mobile/AwardSection/AwardSection';
import FAQSection from './components/mobile/FAQSection/FAQSection';
-import InfoSection from './components/mobile/InfoSection/InfoSection';
interface ClubIntroEditTabMobileProps {
introDescription: string;
diff --git a/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab.tsx b/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab.tsx
index 1e68c7146..7196d7ca8 100644
--- a/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab.tsx
+++ b/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab.tsx
@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useOutletContext } from 'react-router-dom';
-import { useQueryClient } from '@tanstack/react-query';
import { setYear } from 'date-fns';
import Button from '@/components/common/Button/Button';
import InputField from '@/components/common/InputField/InputField';
@@ -9,15 +8,18 @@ import { ADMIN_EVENT, PAGE_VIEW } from '@/constants/eventName';
import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack';
import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView';
import { useUpdateClubDescription } from '@/hooks/Queries/useClub';
+import useDevice from '@/hooks/useDevice';
import { ContentSection } from '@/pages/AdminPage/components/ContentSection/ContentSection';
import { ClubDetail } from '@/types/club';
import { recruitmentDateParser } from '@/utils/recruitmentDateParser';
import DateTimeRangePicker from './components/DateTimeRangePicker/DateTimeRangePicker';
import * as Styled from './RecruitEditTab.styles';
+import RecruitEditTabMobile from './RecruitEditTabMobile';
const FAR_FUTURE_YEAR = 2999;
const RecruitEditTab = () => {
+ const { isMobile, isTablet } = useDevice();
const trackEvent = useMixpanelTrack();
useTrackPageView(PAGE_VIEW.RECRUITMENT_INFO_EDIT_PAGE);
@@ -30,6 +32,18 @@ const RecruitEditTab = () => {
const [recruitmentTarget, setRecruitmentTarget] = useState('');
const [isAlwaysRecruiting, setIsAlwaysRecruiting] = useState(false);
+ const [initialValues, setInitialValues] = useState<{
+ recruitmentStart: string | null;
+ recruitmentEnd: string | null;
+ recruitmentTarget: string;
+ } | null>(null);
+
+ const isDirty =
+ initialValues !== null &&
+ (recruitmentStart?.toISOString() !== initialValues.recruitmentStart ||
+ recruitmentEnd?.toISOString() !== initialValues.recruitmentEnd ||
+ recruitmentTarget !== initialValues.recruitmentTarget);
+
const backupRangeRef = useRef<{ start: Date | null; end: Date | null }>({
start: null,
end: null,
@@ -59,7 +73,7 @@ const RecruitEditTab = () => {
};
useEffect(() => {
- if (!clubDetail) return;
+ if (!clubDetail || isDirty) return;
const parsedStart = clubDetail.recruitmentStart
? recruitmentDateParser(clubDetail.recruitmentStart)
@@ -76,6 +90,14 @@ const RecruitEditTab = () => {
if (isAlways)
backupRangeRef.current = { start: parsedStart, end: parsedEnd };
+
+ setInitialValues({
+ recruitmentStart: parsedStart?.toISOString() ?? null,
+ recruitmentEnd: parsedEnd?.toISOString() ?? null,
+ recruitmentTarget: clubDetail.recruitmentTarget || '',
+ });
+ // isDirty는 의도적으로 deps에서 제외 — clubDetail refetch 시점의 편집 상태만 확인하면 된다
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [clubDetail]);
useEffect(() => {
@@ -113,7 +135,7 @@ const RecruitEditTab = () => {
});
};
- const handleUpdateClub = async () => {
+ const handleUpdateClub = () => {
trackEvent(ADMIN_EVENT.UPDATE_RECRUIT_BUTTON_CLICKED);
if (!clubDetail) return;
@@ -125,12 +147,36 @@ const RecruitEditTab = () => {
};
updateClubDescription(updatedData, {
- onSuccess: () => alert('모집 정보가 성공적으로 수정되었습니다.'),
+ onSuccess: () => {
+ alert('모집 정보가 성공적으로 수정되었습니다.');
+ setInitialValues({
+ recruitmentStart: recruitmentStart?.toISOString() ?? null,
+ recruitmentEnd: recruitmentEnd?.toISOString() ?? null,
+ recruitmentTarget,
+ });
+ },
onError: (error) =>
alert(`모집 정보 수정에 실패했습니다: ${error.message}`),
});
};
+ if (isMobile || isTablet) {
+ return (
+
+ );
+ }
+
return (
diff --git a/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTabMobile.styles.ts b/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTabMobile.styles.ts
new file mode 100644
index 000000000..a43b8eaac
--- /dev/null
+++ b/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTabMobile.styles.ts
@@ -0,0 +1,111 @@
+import styled from 'styled-components';
+import { media } from '@/styles/mediaQuery';
+import { colors } from '@/styles/theme/colors';
+import { setTypography, typography } from '@/styles/theme/typography';
+
+export const MobileContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ padding-bottom: calc(80px + env(safe-area-inset-bottom) + 40px);
+ width: 100%;
+ max-width: 500px;
+ min-height: 100vh;
+ margin: 0 auto;
+ box-shadow: 0px 2px 12px rgba(0, 0, 0, 0.04);
+
+ ${media.mobile} {
+ max-width: 100%;
+ margin: 0;
+ box-shadow: none;
+ }
+`;
+
+export const FormSection = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 30px;
+ padding: 32px 20px 0;
+`;
+
+export const PageTitle = styled.h2`
+ ${setTypography(typography.title.title5)}
+ color: ${colors.base.black};
+ margin: 0;
+`;
+
+export const PageSubtitle = styled.p`
+ ${setTypography(typography.button.button1)}
+ color: ${colors.gray[700]};
+ margin: -22px 0 0;
+`;
+
+export const FieldList = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+`;
+
+export const PeriodSection = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+`;
+
+export const SectionLabel = styled.span`
+ ${setTypography(typography.button.button1)}
+ color: ${colors.gray[900]};
+ padding: 0 2px;
+`;
+
+export const DateTimeRow = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+`;
+
+export const DateTimeInput = styled.input<{ $isDisabled?: boolean }>`
+ width: 100%;
+ height: 45px;
+ padding: 0 14px;
+ border: none;
+ border-radius: 12px;
+ background-color: ${({ $isDisabled }) =>
+ $isDisabled ? colors.gray[400] : colors.gray[200]};
+ color: ${({ $isDisabled }) =>
+ $isDisabled ? colors.gray[500] : colors.gray[700]};
+ font-size: 1rem;
+ pointer-events: ${({ $isDisabled }) => ($isDisabled ? 'none' : 'auto')};
+
+ &::-webkit-calendar-picker-indicator {
+ ${({ $isDisabled }) => $isDisabled && `filter: invert(77%);`}
+ }
+`;
+
+export const AlwaysRecruitButton = styled.button<{ $isAlwaysActive: boolean }>`
+ align-self: flex-start;
+ border-radius: 10px;
+ padding: 0 18px;
+ height: 40px;
+ ${setTypography(typography.button.button1)}
+ cursor: pointer;
+
+ color: ${colors.gray[700]};
+ background-color: ${colors.gray[300]};
+ border: 1px solid ${colors.gray[400]};
+
+ ${({ $isAlwaysActive }) =>
+ $isAlwaysActive &&
+ `
+ color: ${colors.base.white};
+ background-color: ${colors.primary[800]};
+ border: none;
+ `}
+
+ transition:
+ background-color 0.12s ease,
+ transform 0.06s ease;
+
+ &:active {
+ transform: translateY(1px);
+ }
+`;
diff --git a/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTabMobile.tsx b/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTabMobile.tsx
new file mode 100644
index 000000000..ee50bf3c7
--- /dev/null
+++ b/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTabMobile.tsx
@@ -0,0 +1,121 @@
+import { useNavigate } from 'react-router-dom';
+import FixedBottomButtonArea from '@/components/common/FixedBottomButtonArea/FixedBottomButtonArea';
+import WebviewTopBar from '@/components/common/WebviewTopBar/WebviewTopBar';
+import { RECRUIT_TARGET_MAX } from '@/constants/adminFieldLimits';
+import ClearableTextArea from '@/pages/AdminPage/components/ClearableTextArea/ClearableTextArea';
+import InfoSection from '@/pages/AdminPage/components/InfoSection/InfoSection';
+import * as Styled from './RecruitEditTabMobile.styles';
+
+const toDateTimeLocalValue = (date: Date | null): string => {
+ if (!date) return '';
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
+};
+
+const fromDateTimeLocalValue = (value: string): Date | null => {
+ if (!value) return null;
+ return new Date(value);
+};
+
+interface RecruitEditTabMobileProps {
+ recruitmentStart: Date | null;
+ recruitmentEnd: Date | null;
+ recruitmentTarget: string;
+ isAlwaysRecruiting: boolean;
+ isDirty: boolean;
+ onStartChange: (date: Date | null) => void;
+ onEndChange: (date: Date | null) => void;
+ onTargetChange: (value: string) => void;
+ onToggleAlwaysRecruiting: () => void;
+ onSave: () => void;
+}
+
+const RecruitEditTabMobile = ({
+ recruitmentStart,
+ recruitmentEnd,
+ recruitmentTarget,
+ isAlwaysRecruiting,
+ isDirty,
+ onStartChange,
+ onEndChange,
+ onTargetChange,
+ onToggleAlwaysRecruiting,
+ onSave,
+}: RecruitEditTabMobileProps) => {
+ const navigate = useNavigate();
+
+ return (
+ <>
+
+ navigate('/admin')}
+ />
+
+
+ 모집 정보를 입력해주세요
+
+ 해당 기간 동안 모집중 상태가 돼요
+
+
+
+
+ 모집 기간
+
+
+ onStartChange(fromDateTimeLocalValue(e.target.value))
+ }
+ />
+
+ onEndChange(fromDateTimeLocalValue(e.target.value))
+ }
+ $isDisabled={isAlwaysRecruiting}
+ />
+
+
+ 상시모집
+
+
+
+
+ onTargetChange('')}
+ placeholder='모집대상을 입력해주세요'
+ maxLength={RECRUIT_TARGET_MAX}
+ />
+
+
+
+
+
+
+ 저장하기
+
+ >
+ );
+};
+
+export default RecruitEditTabMobile;