diff --git a/src/assets/image/3d-card.png b/src/assets/image/3d-card.png new file mode 100644 index 0000000..947957b Binary files /dev/null and b/src/assets/image/3d-card.png differ diff --git a/src/assets/image/3d-file.png b/src/assets/image/3d-file.png new file mode 100644 index 0000000..d16d2d5 Binary files /dev/null and b/src/assets/image/3d-file.png differ diff --git a/src/assets/image/3d-flag.png b/src/assets/image/3d-flag.png new file mode 100644 index 0000000..e010919 Binary files /dev/null and b/src/assets/image/3d-flag.png differ diff --git a/src/assets/image/boy.png b/src/assets/image/boy.png new file mode 100644 index 0000000..22e33ac Binary files /dev/null and b/src/assets/image/boy.png differ diff --git a/src/assets/image/chat.png b/src/assets/image/chat.png new file mode 100644 index 0000000..ed1e258 Binary files /dev/null and b/src/assets/image/chat.png differ diff --git a/src/assets/image/folder.png b/src/assets/image/folder.png new file mode 100644 index 0000000..ff941dd Binary files /dev/null and b/src/assets/image/folder.png differ diff --git a/src/assets/svg/Chevron-left-dark.svg b/src/assets/svg/Chevron-left-dark.svg new file mode 100644 index 0000000..0247e94 --- /dev/null +++ b/src/assets/svg/Chevron-left-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svg/add-photo-alternate.svg b/src/assets/svg/add-photo-alternate.svg new file mode 100644 index 0000000..130f897 --- /dev/null +++ b/src/assets/svg/add-photo-alternate.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/svg/close.svg b/src/assets/svg/close.svg new file mode 100644 index 0000000..61d2d7e --- /dev/null +++ b/src/assets/svg/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svg/more-horizontal.svg b/src/assets/svg/more-horizontal.svg new file mode 100644 index 0000000..4340b74 --- /dev/null +++ b/src/assets/svg/more-horizontal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/svg/role-selector-arrow-down.svg b/src/assets/svg/role-selector-arrow-down.svg new file mode 100644 index 0000000..1cf72f8 --- /dev/null +++ b/src/assets/svg/role-selector-arrow-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/common/BottomModal.tsx b/src/components/common/BottomModal.tsx index efb19f8..c5a6e15 100644 --- a/src/components/common/BottomModal.tsx +++ b/src/components/common/BottomModal.tsx @@ -2,15 +2,17 @@ import { useRef, type HTMLAttributes, type ReactNode } from 'react'; import { twMerge } from 'tailwind-merge'; import useClickTouchOutside from '@/utils/hooks/useClickTouchOutside'; import useScrollLock from '@/utils/hooks/useScrollLock'; +import { cn } from '@/utils/ts/cn'; import Portal from './Portal'; interface BottomModalProps extends HTMLAttributes { isOpen: boolean; onClose: () => void; children: ReactNode; + overlayClassName?: string; } -function BottomModal({ isOpen, onClose, children, className }: BottomModalProps) { +function BottomModal({ isOpen, onClose, children, className, overlayClassName }: BottomModalProps) { const modalRef = useRef(null); useClickTouchOutside(modalRef, onClose); @@ -21,7 +23,7 @@ function BottomModal({ isOpen, onClose, children, className }: BottomModalProps) return (
{ e.stopPropagation(); onClose(); diff --git a/src/components/common/ToggleSwitch.tsx b/src/components/common/ToggleSwitch.tsx index 3d4a8ac..7fbf008 100644 --- a/src/components/common/ToggleSwitch.tsx +++ b/src/components/common/ToggleSwitch.tsx @@ -6,8 +6,11 @@ interface ToggleSwitchProps { enabled: boolean; onChange: (enabled: boolean) => void; disabled?: boolean; + ariaLabel?: string; layout?: 'vertical' | 'horizontal'; className?: string; + labelClassName?: string; + variant?: 'default' | 'manager'; } function ToggleSwitch({ @@ -16,19 +19,35 @@ function ToggleSwitch({ enabled, onChange, disabled = false, + ariaLabel, layout = 'vertical', className, + labelClassName, + variant = 'default', }: ToggleSwitchProps) { + const isManager = variant === 'manager'; const isHorizontal = layout === 'horizontal'; return (
-
+
{Icon && (
@@ -36,8 +55,13 @@ function ToggleSwitch({ )} {label} @@ -45,18 +69,31 @@ function ToggleSwitch({
+ {title} +
+ + + ); +} + +function ManagerHeaderWithClub({ clubId }: { clubId: number }) { + const { managedClub } = useManagedClub(clubId); + return ; +} + +function ManagerHeader({ fallbackTitle }: { fallbackTitle: string }) { + const { pathname } = useLocation(); + const match = pathname.match(/^\/mypage\/manager\/(\d+)$/); + + if (match) { + return ; + } + + return ; +} + +export default ManagerHeader; diff --git a/src/components/layout/Header/constants.ts b/src/components/layout/Header/constants.ts new file mode 100644 index 0000000..2944c3f --- /dev/null +++ b/src/components/layout/Header/constants.ts @@ -0,0 +1 @@ +export const MANAGER_HEADER_HEIGHT = '63px'; diff --git a/src/components/layout/Header/headerConfig.ts b/src/components/layout/Header/headerConfig.ts index cfc8d0a..a0b8983 100644 --- a/src/components/layout/Header/headerConfig.ts +++ b/src/components/layout/Header/headerConfig.ts @@ -26,8 +26,8 @@ export const HEADER_CONFIGS: HeaderConfig[] = [ match: (pathname) => pathname === '/signup/finish' || /^\/clubs\/\d+\/complete$/.test(pathname), }, { - type: 'full', - match: (pathname) => /^\/mypage\/manager(?:\/[^/]+)?$/.test(pathname), + type: 'manager', + match: (pathname) => pathname.startsWith('/mypage/manager'), }, { type: 'signup', diff --git a/src/components/layout/Header/index.tsx b/src/components/layout/Header/index.tsx index 52f1de1..39b888a 100644 --- a/src/components/layout/Header/index.tsx +++ b/src/components/layout/Header/index.tsx @@ -2,6 +2,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import ChatHeader from './components/ChatHeader'; import DefaultHeader from './components/DefaultHeader'; import InfoHeader from './components/InfoHeader'; +import ManagerHeader from './components/ManagerHeader'; import ProfileHeader from './components/ProfileHeader'; import ScheduleHeader from './components/ScheduleHeader'; import { HEADER_CONFIGS, DEFAULT_HEADER_TYPE } from './headerConfig'; @@ -26,6 +27,7 @@ function Header() { signup: ({ title, onBack }) => , council: ({ title }) => , default: ({ title }) => , + manager: ({ title }) => , }; const onBack = headerType === 'signup' ? () => navigate('/') : undefined; diff --git a/src/components/layout/Header/routeTitles.ts b/src/components/layout/Header/routeTitles.ts index a86523e..2fc300b 100644 --- a/src/components/layout/Header/routeTitles.ts +++ b/src/components/layout/Header/routeTitles.ts @@ -4,6 +4,46 @@ export interface RouteTitle { } export const ROUTE_TITLES: RouteTitle[] = [ + { + match: (pathname) => pathname === '/mypage/manager', + title: '동아리 관리', + }, + { + match: (pathname) => /^\/mypage\/manager\/\d+\/members$/.test(pathname), + title: '부원관리', + }, + { + match: (pathname) => /^\/mypage\/manager\/\d+\/members\/\d+\/application$/.test(pathname), + title: '지원서 보기', + }, + { + match: (pathname) => /^\/mypage\/manager\/\d+\/info$/.test(pathname), + title: '정보 수정하기', + }, + { + match: (pathname) => /^\/mypage\/manager\/\d+\/applications$/.test(pathname), + title: '지원자 관리', + }, + { + match: (pathname) => /^\/mypage\/manager\/\d+\/applications\/\d+$/.test(pathname), + title: '지원서 보기', + }, + { + match: (pathname) => /^\/mypage\/manager\/\d+\/recruitment$/.test(pathname), + title: '모집 공고 및 지원서 관리', + }, + { + match: (pathname) => /^\/mypage\/manager\/\d+\/recruitment\/write$/.test(pathname), + title: '모집 공고', + }, + { + match: (pathname) => /^\/mypage\/manager\/\d+\/recruitment\/form$/.test(pathname), + title: '지원서', + }, + { + match: (pathname) => /^\/mypage\/manager\/\d+\/recruitment\/account$/.test(pathname), + title: '가입비', + }, { match: (pathname) => pathname.startsWith('/clubs/search'), title: '동아리 검색', diff --git a/src/components/layout/Header/types.ts b/src/components/layout/Header/types.ts index 6bed593..e9dbf0d 100644 --- a/src/components/layout/Header/types.ts +++ b/src/components/layout/Header/types.ts @@ -10,7 +10,8 @@ export type HeaderType = | 'full' | 'signup' | 'schedule' - | 'council'; + | 'council' + | 'manager'; export interface HeaderConfig { type: HeaderType; diff --git a/src/components/layout/index.tsx b/src/components/layout/index.tsx index 398b312..6243275 100644 --- a/src/components/layout/index.tsx +++ b/src/components/layout/index.tsx @@ -1,8 +1,9 @@ -import { Suspense } from 'react'; +import { Suspense, type CSSProperties } from 'react'; import { Outlet, useLocation } from 'react-router-dom'; import { cn } from '@/utils/ts/cn'; import BottomNav from './BottomNav'; import Header from './Header'; +import { MANAGER_HEADER_HEIGHT } from './Header/constants'; import { HEADER_CONFIGS } from './Header/headerConfig'; interface LayoutProps { @@ -15,19 +16,22 @@ export default function Layout({ showBottomNav = false, contentClassName }: Layo const headerConfig = HEADER_CONFIGS.find((config) => config.match(pathname)); const headerType = headerConfig?.type; const isInfoHeader = headerType === 'info'; + const isManagerHeader = headerType === 'manager'; const hasHeader = headerType !== 'none'; + const layoutStyle = { + height: 'var(--viewport-height)', + transform: 'translateY(var(--viewport-offset))', + '--manager-header-height': MANAGER_HEADER_HEIGHT, + } as CSSProperties; return ( -
+
{hasHeader &&
}
(); const navigate = useNavigate(); const location = useLocation(); const clubIdNumber = Number(clubId); - const { managedClubFee } = useManagedClubFee(clubIdNumber); + const { banks } = useGetBanks(); + const { managedClubFee } = useManagedClubFee(clubIdNumber); + const { data: clubSettings } = useGetClubSettings(clubIdNumber); const { mutate, isPending, error } = useManagedClubFeeMutation(clubIdNumber); - const { mutate: patchSettings } = usePatchClubSettings(clubIdNumber); + const { mutate: patchSettings, isPending: isPatchPending } = usePatchClubSettings(clubIdNumber); + const initialAmount = managedClubFee.amount?.toString() ?? ''; const initialBankId = banks.find((bank) => bank.name === managedClubFee.bankName)?.id ?? null; + const initialBankName = managedClubFee.bankName ?? ''; + const initialAccountHolder = managedClubFee.accountHolder ?? ''; + const initialAccountNumber = managedClubFee.accountNumber ?? ''; - const [amount, setAmount] = useState(managedClubFee.amount?.toString() ?? ''); + const [amount, setAmount] = useState(initialAmount); const [selectedBankId, setSelectedBankId] = useState(initialBankId); - const [selectedBank, setSelectedBank] = useState(managedClubFee.bankName ?? ''); - const [accountHolder, setAccountHolder] = useState(managedClubFee.accountHolder ?? ''); - const [accountNumber, setAccountNumber] = useState(managedClubFee.accountNumber ?? ''); + const [selectedBankName, setSelectedBankName] = useState(initialBankName); + const [accountHolder, setAccountHolder] = useState(initialAccountHolder); + const [accountNumber, setAccountNumber] = useState(initialAccountNumber); const [isBankModalOpen, setIsBankModalOpen] = useState(false); + const hasChanges = + amount !== initialAmount || + selectedBankId !== initialBankId || + accountHolder !== initialAccountHolder || + accountNumber !== initialAccountNumber; + const isFormValid = amount.trim() !== '' && selectedBankId !== null && accountHolder.trim() !== '' && accountNumber.trim() !== ''; + const isFeeEnabled = clubSettings?.isFeeEnabled ?? false; + const feeStatusLabel = isFeeEnabled ? '활성화' : '비활성화'; + const handleSubmit = () => { - if (isPending || !isFormValid || selectedBankId === null) return; + if (isPending || isPatchPending || !isFormValid || !hasChanges || selectedBankId === null) return; const payload: ClubFeeRequest = { - amount: amount, + amount: amount.trim(), bankId: selectedBankId, accountNumber: accountNumber.trim(), accountHolder: accountHolder.trim(), }; + mutate(payload, { onSuccess: () => { if (location.state?.enableAfterSave) { @@ -46,101 +71,134 @@ function ManagedAccount() { }); }; - const hasChanges = () => - amount !== (managedClubFee.amount?.toString() ?? '') || - selectedBank !== (managedClubFee.bankName ?? '') || - accountHolder !== (managedClubFee.accountHolder ?? '') || - accountNumber !== (managedClubFee.accountNumber ?? ''); + const handleFeeEnabledChange = (enabled: boolean) => { + patchSettings({ isFeeEnabled: enabled }); + }; + + const errorMessage = + (isApiError(error) ? error.apiError?.fieldErrors?.[0]?.message : undefined) ?? + error?.message ?? + '회비 정보 저장에 실패했습니다.'; return ( -
-
-

회비 정보

- -
- - setAmount(e.target.value)} - placeholder="가입비를 입력해주세요" - className="bg-indigo-5 w-full rounded-lg p-2 text-[15px] leading-6 font-semibold" +
+
+
+
-
- +
+
+
+

회비정보

+ + +
+ + setAmount(e.target.value)} + placeholder="가입비를 입력해주세요" + className={fieldInputClassName} + /> +
+ +
+ + +
+ +
+ + setAccountHolder(e.target.value)} + placeholder="예금주를 입력해주세요" + className={fieldInputClassName} + /> +
+ +
+ + setAccountNumber(e.target.value)} + placeholder="계좌번호를 입력해주세요" + className={fieldInputClassName} + /> +
+
+
+ +
+ {error &&

{errorMessage}

}
- -
- - setAccountHolder(e.target.value)} - placeholder="예금주를 입력해주세요" - className="bg-indigo-5 w-full rounded-lg p-2 text-[15px] leading-6 font-semibold" - /> -
- -
- - setAccountNumber(e.target.value)} - placeholder="계좌번호를 입력해주세요" - className="bg-indigo-5 w-full rounded-lg p-2 text-[15px] leading-6 font-semibold" - /> -
-
- {error && ( -

- {(error as ApiError).apiError?.fieldErrors?.[0]?.message ?? - error.message ?? - '회비 정보 저장에 실패했습니다.'} -

- )} - -
+ setIsBankModalOpen(false)} + overlayClassName="bg-black/30" + className="max-h-[75vh]" + > +
+

은행 선택

+
+ {banks.map((bank) => { + const isSelected = bank.id === selectedBankId; - setIsBankModalOpen(false)}> -
-

은행 선택

-
- {banks?.map((bank) => ( - - ))} + return ( + + ); + })}
diff --git a/src/pages/Manager/ManagedApplicationDetail/index.tsx b/src/pages/Manager/ManagedApplicationDetail/index.tsx index dfad280..ca83099 100644 --- a/src/pages/Manager/ManagedApplicationDetail/index.tsx +++ b/src/pages/Manager/ManagedApplicationDetail/index.tsx @@ -1,16 +1,21 @@ import { useParams } from 'react-router-dom'; -import CheckIcon from '@/assets/svg/check.svg'; -import WarningIcon from '@/assets/svg/warning.svg'; import BottomModal from '@/components/common/BottomModal'; -import Portal from '@/components/common/Portal'; import { useToastContext } from '@/contexts/useToastContext'; -import useBooleanState from '@/utils/hooks/useBooleanState'; -import { formatIsoDateToYYYYMMDDHHMM } from '@/utils/ts/date'; +import ApplicationDetailContent from '@/pages/Manager/components/ApplicationDetailContent'; import { useApproveApplication, - useRejectApplication, useGetManagedApplicationDetail, -} from '../hooks/useManagedApplications'; + useRejectApplication, +} from '@/pages/Manager/hooks/useManagedApplications'; +import useBooleanState from '@/utils/hooks/useBooleanState'; +import { cn } from '@/utils/ts/cn'; + +const BUTTON_BASE_CLASS = + 'flex h-[55px] flex-1 items-center justify-center rounded-2xl border border-[#69BFDF] text-center text-[16px] leading-[22px] font-bold tracking-[-0.408px]'; +const BUTTON_SECONDARY_CLASS = 'text-[#69BFDF]'; +const BUTTON_PRIMARY_CLASS = 'bg-[#69BFDF] text-white'; +const BUTTON_DISABLED_CLASS = 'disabled:opacity-50'; +const BUTTON_DISABLED_WITH_CURSOR_CLASS = 'disabled:cursor-not-allowed disabled:opacity-50'; function ManagedApplicationDetail() { const params = useParams(); @@ -25,8 +30,6 @@ function ManagedApplicationDetail() { const { mutate: reject, isPending: isRejecting } = useRejectApplication(clubId, { navigateBack: true, }); - - const { value: isImageOpen, setTrue: openImage, setFalse: closeImage } = useBooleanState(); const { value: isApproveOpen, setTrue: openApprove, setFalse: closeApprove } = useBooleanState(); const { value: isRejectOpen, setTrue: openReject, setFalse: closeReject } = useBooleanState(); @@ -47,106 +50,44 @@ function ManagedApplicationDetail() { }; return ( -
-
-
-
- {`${application.name} -
-
- {application.name} - ({application.studentNumber}) -
-
- 지원일: {formatIsoDateToYYYYMMDDHHMM(application.appliedAt)} -
-
-
-
- - {application.feePaymentImageUrl && ( -
- 회비 납부 인증 -
- -
-
- )} - -
-
- 지원서 내용 - {application.answers.length}개의 문항 -
- -
- {application.answers.map((answer, index) => ( -
-
- 문항 {index + 1} - {answer.isRequired && ( - 필수 - )} -
- -

{answer.question}

- -
-

{answer.answer || '(응답 없음)'}

-
-
- ))} + <> + + +
-
-
- - -
-
+ } + /> - {/* Approve Confirm Modal */}
지원 승인
{application.name}님의 지원을 승인하시겠어요?
-
- @@ -154,44 +95,26 @@ function ManagedApplicationDetail() {
- {/* Reject Confirm Modal */}
지원 거절
{application.name}님의 지원을 거절하시겠어요?
-
-
- - {isImageOpen && application.feePaymentImageUrl && ( - -
- 회비 납부 인증 e.stopPropagation()} - /> -
-
- )} -
+ ); } diff --git a/src/pages/Manager/ManagedApplicationList/index.tsx b/src/pages/Manager/ManagedApplicationList/index.tsx index f45daa9..0cfe504 100644 --- a/src/pages/Manager/ManagedApplicationList/index.tsx +++ b/src/pages/Manager/ManagedApplicationList/index.tsx @@ -1,36 +1,107 @@ -import { useState } from 'react'; +import type { MouseEvent } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import Card from '@/components/common/Card'; +import type { ClubApplicationsResponse } from '@/apis/club/entity'; +import CheckIcon from '@/assets/svg/check.svg'; +import CloseIcon from '@/assets/svg/close.svg'; +import PersonIcon from '@/assets/svg/person.svg'; import UserInfoCard from '@/pages/User/MyPage/components/UserInfoCard'; +import { useInfiniteScroll } from '@/utils/hooks/useInfiniteScroll'; import { formatIsoDateToYYYYMMDDHHMM } from '@/utils/ts/date'; import { useApproveApplication, - useRejectApplication, useGetManagedApplications, + useRejectApplication, } from '../hooks/useManagedApplications'; +type ManagedApplication = ClubApplicationsResponse['applications'][number]; + +function ApplicationAvatar({ imageUrl, name }: Pick) { + if (imageUrl) { + return {`${name}; + } + + return ( +
+ +
+ ); +} + +interface ApplicationCardProps { + application: ManagedApplication; + disabled: boolean; + onApprove: (e: MouseEvent, applicationId: number) => void; + onReject: (e: MouseEvent, applicationId: number) => void; + onDetail: (applicationId: number) => void; +} + +function ApplicationCard({ application, disabled, onApprove, onReject, onDetail }: ApplicationCardProps) { + return ( +
onDetail(application.id)} + > +
+ +
+
+ {application.name} ({application.studentNumber}) +
+
+ 지원일 : {formatIsoDateToYYYYMMDDHHMM(application.appliedAt)} +
+
+
+ +
+ + + +
+
+ ); +} + function ManagedApplicationList() { const params = useParams(); const navigate = useNavigate(); const clubId = Number(params.clubId); - const [page, setPage] = useState(1); const limit = 10; - const { managedClubApplicationList, hasNoRecruitment } = useGetManagedApplications(clubId, page, limit); + const { managedClubApplicationList, applications, fetchNextPage, hasNextPage, isFetchingNextPage, hasNoRecruitment } = + useGetManagedApplications(clubId, { limit }); const { mutate: approve, isPending: isApproving } = useApproveApplication(clubId); const { mutate: reject, isPending: isRejecting } = useRejectApplication(clubId); + const observerRef = useInfiniteScroll(fetchNextPage, hasNextPage, isFetchingNextPage, { + enabled: !hasNoRecruitment, + }); const isPending = isApproving || isRejecting; - const totalPages = managedClubApplicationList?.totalPage ?? 1; - const handleApprove = (e: React.MouseEvent, applicationId: number) => { + const handleApprove = (e: MouseEvent, applicationId: number) => { e.stopPropagation(); approve(applicationId); }; - const handleReject = (e: React.MouseEvent, applicationId: number) => { + const handleReject = (e: MouseEvent, applicationId: number) => { e.stopPropagation(); reject(applicationId); }; @@ -41,89 +112,49 @@ function ManagedApplicationList() { if (hasNoRecruitment) { return ( -
+
-
-

현재 진행 중인 모집 공고가 없습니다.

+
+

현재 진행 중인 모집 공고가 없습니다.

); } return ( -
+
-
- 대기 중 - {managedClubApplicationList?.totalCount ?? 0}명 -
-
- {managedClubApplicationList?.applications.map((application) => ( - handleDetail(application.id)} - > -
- Member Avatar -
-
- {application.name} ({application.studentNumber}) -
-
- 지원일 : {formatIsoDateToYYYYMMDDHHMM(application.appliedAt)} -
-
-
- -
- - - -
-
- ))} -
- - {totalPages > 1 && ( -
- - - - {page} / {totalPages} +
+ + 대기중 {managedClubApplicationList?.totalCount ?? 0}명 - -
- )} + + {applications.length > 0 ? ( + applications.map((application) => ( + + )) + ) : ( +
+

현재 대기 중인 지원자가 없습니다.

+
+ )} + + {hasNextPage && ( +
+ {isFetchingNextPage ? '지원자를 불러오는 중입니다.' : ''} +
+ )} +
); } diff --git a/src/pages/Manager/ManagedClubDetail/index.tsx b/src/pages/Manager/ManagedClubDetail/index.tsx index 4874c2a..93af48e 100644 --- a/src/pages/Manager/ManagedClubDetail/index.tsx +++ b/src/pages/Manager/ManagedClubDetail/index.tsx @@ -1,30 +1,28 @@ import { Link } from 'react-router-dom'; -import RightArrowIcon from '@/assets/svg/chevron-right.svg'; -import ClipboardListIcon from '@/assets/svg/clipboard-list.svg'; -import PeopleGroupIcon from '@/assets/svg/people-group.svg'; -import UserSquareIcon from '@/assets/svg/user-square.svg'; +import BoyIcon from '@/assets/image/boy.png'; +import ChatIcon from '@/assets/image/chat.png'; +import FloderIcon from '@/assets/image/folder.png'; +import ChevronRightDarkIcon from '@/assets/svg/Chevron-left-dark.svg'; import UserInfoCard from '@/pages/User/MyPage/components/UserInfoCard'; const menuItems = [ - { to: 'recruitment', icon: ClipboardListIcon, label: '모집 공고 및 지원서 관리' }, - { to: 'applications', icon: UserSquareIcon, label: '지원자 관리' }, - { to: 'members', icon: PeopleGroupIcon, label: '부원 관리' }, + { to: 'recruitment', icon: FloderIcon, size: 24, label: '모집 공고 및 지원서 관리' }, + { to: 'applications', icon: ChatIcon, size: 28, label: '지원자 관리' }, + { to: 'members', icon: BoyIcon, size: 28, label: '부원 관리' }, ]; function ManagedClubDetail() { return ( -
+
-
- {menuItems.map(({ to, icon: Icon, label }) => ( - -
-
- -
{label}
-
- +
+ {menuItems.map(({ to, icon, size, label }) => ( + +
+ + {label}
+ ))}
diff --git a/src/pages/Manager/ManagedClubList/index.tsx b/src/pages/Manager/ManagedClubList/index.tsx index a848e25..235751a 100644 --- a/src/pages/Manager/ManagedClubList/index.tsx +++ b/src/pages/Manager/ManagedClubList/index.tsx @@ -1,5 +1,5 @@ import { Link } from 'react-router-dom'; -import RightArrowIcon from '@/assets/svg/chevron-right.svg'; +import RightArrowIcon from '@/assets/svg/Chevron-left-dark.svg'; import UserInfoCard from '@/pages/User/MyPage/components/UserInfoCard'; import { useGetManagedClubs } from '../hooks/useManagedClubs'; @@ -7,20 +7,32 @@ function ManagedClubList() { const { managedClubList } = useGetManagedClubs(); return ( -
+
-
- {managedClubList.joinedClubs.map((club) => ( - -
-
- Club Avatar -
{club.name}
+
+

동아리 목록

+
+ {managedClubList.joinedClubs.map((club) => ( + +
+ Club Avatar +
+ {club.name} + {club.categoryName} +
-
- - ))} + + ))} +
); diff --git a/src/pages/Manager/ManagedClubProfile/index.tsx b/src/pages/Manager/ManagedClubProfile/index.tsx index 15bb7ec..d1e1466 100644 --- a/src/pages/Manager/ManagedClubProfile/index.tsx +++ b/src/pages/Manager/ManagedClubProfile/index.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { type ChangeEvent, type MutableRefObject, useEffect, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; import ImageIcon from '@/assets/svg/image.svg'; import BottomModal from '@/components/common/BottomModal'; @@ -10,49 +10,94 @@ import useUploadImage from '@/utils/hooks/useUploadImage'; const DESCRIPTION_MAX_LENGTH = 25; +const cardClassName = 'rounded-2xl bg-white shadow-[0_0_3px_rgba(0,0,0,0.15)]'; +const fieldLabelClassName = 'text-body3-strong text-text-700'; +const fieldControlClassName = + 'w-full rounded-lg border border-text-200 bg-white px-3 text-[13px] leading-[20.8px] font-medium text-black outline-none placeholder:text-text-300 focus:border-primary-500'; +const fieldInputClassName = `${fieldControlClassName} h-[31px]`; +const disabledFieldInputClassName = + 'h-[31px] w-full rounded-lg border border-transparent bg-background px-3 text-[13px] leading-[20.8px] font-medium text-text-500 outline-none disabled:cursor-not-allowed disabled:opacity-100 disabled:[-webkit-text-fill-color:#5A6B7F]'; +const fieldTextAreaClassName = `${fieldControlClassName} min-h-[512px] resize-none py-2.5`; +const imageActionButtonClassName = + 'absolute flex size-[25px] items-center justify-center rounded-full bg-[#9f9f9f] text-[18px] leading-none text-white shadow-[0_1px_2px_rgba(0,0,0,0.16)]'; +const clubNameFieldId = 'managed-club-name'; +const categoryFieldId = 'managed-club-category'; +const descriptionFieldId = 'managed-club-description'; +const locationFieldId = 'managed-club-location'; +const introduceFieldId = 'managed-club-introduce'; + +function clearLocalPreviewUrl(localPreviewUrlRef: MutableRefObject) { + if (!localPreviewUrlRef.current) return; + + URL.revokeObjectURL(localPreviewUrlRef.current); + localPreviewUrlRef.current = null; +} + function ManagedClubInfo() { const { clubId } = useParams<{ clubId: string }>(); - const { data: clubDetail } = useGetClubDetail(Number(clubId)); + const numericClubId = Number(clubId); + const { data: clubDetail } = useGetClubDetail(numericClubId); + + const initialDescription = clubDetail.description ?? ''; + const initialLocation = clubDetail.location ?? ''; + const initialIntroduce = clubDetail.introduce ?? ''; + const initialImageUrl = clubDetail.imageUrl ?? ''; - const [description, setDescription] = useState(clubDetail.description ?? ''); - const [location, setLocation] = useState(clubDetail.location ?? ''); - const [introduce, setIntroduce] = useState(clubDetail.introduce ?? ''); + const [description, setDescription] = useState(initialDescription); + const [location, setLocation] = useState(initialLocation); + const [introduce, setIntroduce] = useState(initialIntroduce); const [imageFile, setImageFile] = useState(null); - const [imagePreview, setImagePreview] = useState(clubDetail.imageUrl ?? ''); + const [imagePreview, setImagePreview] = useState(initialImageUrl); const [isUploading, setIsUploading] = useState(false); + const fileInputRef = useRef(null); + const localPreviewUrlRef = useRef(null); const { mutateAsync: uploadImage, error: uploadError } = useUploadImage('CLUB'); - const { mutateAsync: updateClubInfo, isPending, error } = useUpdateClubInfo(Number(clubId)); - + const { mutateAsync: updateClubInfo, isPending, error } = useUpdateClubInfo(numericClubId); const { value: isSubmitModalOpen, setTrue: openSubmitModal, setFalse: closeSubmitModal } = useBooleanState(false); - const initialDescription = clubDetail.description ?? ''; - const initialLocation = clubDetail.location ?? ''; - const initialIntroduce = clubDetail.introduce ?? ''; - const initialImageUrl = clubDetail.imageUrl ?? ''; + useEffect(() => { + clearLocalPreviewUrl(localPreviewUrlRef); + setDescription(initialDescription); + setLocation(initialLocation); + setIntroduce(initialIntroduce); + setImageFile(null); + setImagePreview(initialImageUrl); + }, [initialDescription, initialImageUrl, initialIntroduce, initialLocation]); + + useEffect(() => { + return () => { + clearLocalPreviewUrl(localPreviewUrlRef); + }; + }, []); + const hasChanges = description !== initialDescription || location !== initialLocation || introduce !== initialIntroduce || imagePreview !== initialImageUrl; - const handleDescriptionChange = (e: React.ChangeEvent) => { + const handleDescriptionChange = (e: ChangeEvent) => { const value = e.target.value; + if (value.length <= DESCRIPTION_MAX_LENGTH) { setDescription(value); } }; - const handleImageSelect = (e: React.ChangeEvent) => { + const handleImageSelect = (e: ChangeEvent) => { const file = e.target.files?.[0]; + if (!file) return; - if (imageFile) { - URL.revokeObjectURL(imagePreview); - } + clearLocalPreviewUrl(localPreviewUrlRef); + + const previewUrl = URL.createObjectURL(file); + localPreviewUrlRef.current = previewUrl; + setImageFile(file); - setImagePreview(URL.createObjectURL(file)); + setImagePreview(previewUrl); e.target.value = ''; }; @@ -61,9 +106,7 @@ function ManagedClubInfo() { }; const handleDeleteImage = () => { - if (imageFile) { - URL.revokeObjectURL(imagePreview); - } + clearLocalPreviewUrl(localPreviewUrlRef); setImageFile(null); setImagePreview(''); }; @@ -92,113 +135,135 @@ function ManagedClubInfo() { }; const readOnlyFields = [ - { label: '동아리명', value: clubDetail.name }, - { label: '분과', value: clubDetail.categoryName }, + { id: clubNameFieldId, label: '동아리명', value: clubDetail.name }, + { id: categoryFieldId, label: '분과', value: clubDetail.categoryName }, ]; return ( -
-
-
+
+
+
- {!imagePreview ? ( - - ) : ( -
- 동아리 로고 - +
+ {!imagePreview ? ( -
- )} + ) : ( + <> +
+ 동아리 이미지 미리보기 +
+ + + + )} +
- {readOnlyFields.map(({ label, value }) => ( -
- - -
- ))} - -
-
- - - {description.length}/{DESCRIPTION_MAX_LENGTH} - -
- -
+
+
+
+

정보 수정

+ -
- - setLocation(e.target.value)} - placeholder="동방 위치를 입력해주세요" - className="bg-indigo-5 rounded-lg p-2 text-[15px] leading-6 font-semibold" - /> -
+ {readOnlyFields.map(({ id, label, value }) => ( +
+ + +
+ ))} -
- -