diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4807b07e3..71c0e5e0d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,6 +17,8 @@ import LoginTab from '@/pages/AdminPage/auth/LoginTab/LoginTab'; import PrivateRoute from '@/pages/AdminPage/auth/PrivateRoute/PrivateRoute'; import PhotoEditTab from '@/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab'; import ApplicationFormPage from './pages/ApplicationFormPage/ApplicationFormPage'; +import ApplicantsTab from './pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab'; +import ApplicantDetailPage from './pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage'; const queryClient = new QueryClient(); @@ -76,6 +78,14 @@ const App = () => { path='application-edit' element={} /> + } + /> + } + /> diff --git a/frontend/src/apis/applicants/getClubApplicants.ts b/frontend/src/apis/applicants/getClubApplicants.ts new file mode 100644 index 000000000..4d71c43ec --- /dev/null +++ b/frontend/src/apis/applicants/getClubApplicants.ts @@ -0,0 +1,20 @@ +import API_BASE_URL from '@/constants/api'; +import { secureFetch } from '../auth/secureFetch'; + +const getClubApplicants = async (clubId: string) => { + try { + const response = await secureFetch(`${API_BASE_URL}/api/club/${clubId}/apply/info`); + if (!response.ok) { + console.error(`Failed to fetch: ${response.statusText}`) + throw new Error((await response.json()).message); + } + + const result = await response.json(); + return result.data; + } catch (error) { + console.error('Error fetching club applicants', error); + throw error; + } +}; + +export default getClubApplicants; diff --git a/frontend/src/components/common/SearchBox/SearchBox.tsx b/frontend/src/components/common/SearchBox/SearchBox.tsx index 5d98a9091..be9c5917f 100644 --- a/frontend/src/components/common/SearchBox/SearchBox.tsx +++ b/frontend/src/components/common/SearchBox/SearchBox.tsx @@ -1,5 +1,6 @@ import { useRef, useState } from 'react'; import { useSearch } from '@/context/SearchContext'; +import { useCategory } from '@/context/CategoryContext'; import useMixpanelTrack from '@/hooks/useMixpanelTrack'; import * as Styled from './SearchBox.styles'; import SearchIcon from '@/assets/images/icons/search_button_icon.svg'; @@ -7,7 +8,8 @@ import { useLocation, useNavigate } from 'react-router-dom'; const SearchBox = () => { const [isSearchBoxClicked, setIsSearchBoxClicked] = useState(false); - const { setKeyword, inputValue, setInputValue } = useSearch(); + const { setKeyword, inputValue, setInputValue, setIsSearching } = useSearch(); + const { setSelectedCategory } = useCategory(); const trackEvent = useMixpanelTrack(); const navigate = useNavigate(); const location = useLocation(); @@ -23,6 +25,8 @@ const SearchBox = () => { const handleSearch = () => { redirectToHome(); setKeyword(inputValue); + setSelectedCategory('all'); + setIsSearching(true); inputRef.current?.blur(); @@ -40,7 +44,8 @@ const SearchBox = () => { return ( + onSubmit={handleSubmit} + > { + aria-label='검색' + > Search Button diff --git a/frontend/src/context/AdminClubContext.tsx b/frontend/src/context/AdminClubContext.tsx index 23c526eb6..aaf87f2cc 100644 --- a/frontend/src/context/AdminClubContext.tsx +++ b/frontend/src/context/AdminClubContext.tsx @@ -1,12 +1,15 @@ import { createContext, useContext, useState } from 'react'; +import { ApplicantsInfo } from '@/types/applicants'; interface AdminClubContextType { clubId: string | null; setClubId: (id: string | null) => void; + applicantsData: ApplicantsInfo | null; + setApplicantsData: (data: ApplicantsInfo | null) => void; } const AdminClubContext = createContext( - undefined, + undefined ); export const AdminClubProvider = ({ @@ -15,9 +18,10 @@ export const AdminClubProvider = ({ children: React.ReactNode; }) => { const [clubId, setClubId] = useState(null); + const [applicantsData, setApplicantsData] = useState(null); return ( - + {children} ); diff --git a/frontend/src/context/SearchContext.tsx b/frontend/src/context/SearchContext.tsx index d20af1f39..24296326f 100644 --- a/frontend/src/context/SearchContext.tsx +++ b/frontend/src/context/SearchContext.tsx @@ -5,6 +5,8 @@ interface SearchContextType { setKeyword: (keyword: string) => void; inputValue: string; setInputValue: (value: string) => void; + isSearching: boolean; + setIsSearching: (isSearching: boolean) => void; } interface SearchProviderProps { @@ -16,6 +18,7 @@ const SearchContext = createContext(undefined); export const SearchProvider = ({ children }: SearchProviderProps) => { const [keyword, setKeyword] = useState(''); const [inputValue, setInputValue] = useState(''); + const [isSearching, setIsSearching] = useState(false); return ( { setKeyword, inputValue, setInputValue, - }}> + isSearching, + setIsSearching, + }} + > {children} ); diff --git a/frontend/src/hooks/queries/applicants/useGetApplicants.ts b/frontend/src/hooks/queries/applicants/useGetApplicants.ts new file mode 100644 index 000000000..38c7da2f6 --- /dev/null +++ b/frontend/src/hooks/queries/applicants/useGetApplicants.ts @@ -0,0 +1,10 @@ +import getClubApplicants from "@/apis/applicants/getClubApplicants" +import { useQuery } from "@tanstack/react-query" + +export const useGetApplicants = (clubId: string) => { + return useQuery({ + queryKey: ['clubApplicants', clubId], + queryFn: () => getClubApplicants(clubId), + retry: false, + }) +} \ No newline at end of file diff --git a/frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx b/frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx index 2c787f3f0..79563ed5e 100644 --- a/frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx +++ b/frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx @@ -3,15 +3,25 @@ import useAuth from '@/hooks/useAuth'; import { Navigate } from 'react-router-dom'; import { useAdminClubContext } from '@/context/AdminClubContext'; import Spinner from '@/components/common/Spinner/Spinner'; +import { useGetApplicants } from '@/hooks/queries/applicants/useGetApplicants'; const PrivateRoute = ({ children }: { children: React.ReactNode }) => { const { isLoading, isAuthenticated, clubId } = useAuth(); - const { setClubId } = useAdminClubContext(); + const { setClubId, setApplicantsData } = useAdminClubContext(); + const { data: applicantsData } = useGetApplicants(clubId ?? ''); useEffect(() => { - if (clubId) setClubId(clubId); + if (clubId) { + setClubId(clubId); + } }, [clubId, setClubId]); + useEffect(() => { + if (clubId && applicantsData) { + setApplicantsData(applicantsData); + } + }, [clubId, applicantsData]); + if (isLoading) return ; if (!isAuthenticated) return ; diff --git a/frontend/src/pages/AdminPage/components/SideBar/SideBar.styles.ts b/frontend/src/pages/AdminPage/components/SideBar/SideBar.styles.ts index 14911debc..26530f5e8 100644 --- a/frontend/src/pages/AdminPage/components/SideBar/SideBar.styles.ts +++ b/frontend/src/pages/AdminPage/components/SideBar/SideBar.styles.ts @@ -7,7 +7,6 @@ export const SidebarWrapper = styled.aside` word-wrap: break-word; overflow-wrap: break-word; white-space: normal; - width: 168px; `; diff --git a/frontend/src/pages/AdminPage/components/SideBar/SideBar.tsx b/frontend/src/pages/AdminPage/components/SideBar/SideBar.tsx index f160cd25a..24f8270e0 100644 --- a/frontend/src/pages/AdminPage/components/SideBar/SideBar.tsx +++ b/frontend/src/pages/AdminPage/components/SideBar/SideBar.tsx @@ -14,7 +14,8 @@ const tabs = [ { label: '기본 정보 수정', path: '/admin/club-info' }, { label: '모집 정보 수정', path: '/admin/recruit-edit' }, { label: '활동 사진 수정', path: '/admin/photo-edit' }, - { label: '지원 관리', path: '/admin/application-edit' }, + { label: '지원서 관리', path: '/admin/application-edit' }, + { label: '지원자 관리', path: '/admin/applicants' }, { label: '계정 관리', path: '/admin/account-edit' }, ]; @@ -40,7 +41,9 @@ const SideBar = ({ clubLogo, clubName }: SideBarProps) => { if (!confirmed) return; try { - await logout(); + if (document.cookie.split(';').some((cookie) => cookie.trim().startsWith('refreshToken='))) { + await logout(); + } localStorage.removeItem('accessToken'); navigate('/admin/login', { replace: true }); } catch (error) { diff --git a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage.tsx b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage.tsx new file mode 100644 index 000000000..32395e9b7 --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useAdminClubContext } from '@/context/AdminClubContext'; +import Header from '@/components/common/Header/Header'; +import { PageContainer } from '@/styles/PageContainer.styles'; +import * as Styled from '@/pages/ApplicationFormPage/ApplicationFormPage.styles'; +import QuestionContainer from '@/pages/ApplicationFormPage/components/QuestionContainer/QuestionContainer'; +import QuestionAnswerer from '@/pages/ApplicationFormPage/components/QuestionAnswerer/QuestionAnswerer'; +import { useGetApplication } from '@/hooks/queries/application/useGetApplication'; +import Spinner from '@/components/common/Spinner/Spinner'; +import backButtonIcon from '@/assets/images/icons/back_button_icon.svg'; + + +const ApplicantDetailPage = () => { + const { questionId } = useParams<{ questionId: string }>(); + const navigate = useNavigate(); + const { applicantsData, clubId } = useAdminClubContext(); + + // 지원서 질문 목록 fetch + const { data: formData, isLoading, isError } = useGetApplication(clubId!); + + if (!applicantsData) { + return
지원자 데이터를 불러올 수 없습니다.
; + } + if (isLoading) return ; + if (isError || !formData) return
지원서 정보를 불러올 수 없습니다.
; + + // questionId로 지원자 찾기 + const applicant = applicantsData.applicants.find( + (a) => a.id === questionId + ); + if (!applicant) { + return
해당 지원자를 찾을 수 없습니다.
; + } + + // 답변 매핑 함수 + const getAnswerByQuestionId = (qId: number) => { + return applicant.answers + .filter((ans) => ans.id === qId) + .map((ans) => ans.value); + }; + + return ( + <> +
+ + {/* FormTitle과 백아이콘을 한 줄에 배치 */} +
+ +
+ {/* 커서 고정 */} + + {formData.questions.map((q: import('@/types/application').Question, i: number) => ( + + {}} + /> + + ))} + +
+ + ); +}; + +export default ApplicantDetailPage; \ No newline at end of file diff --git a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.styles.tsx b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.styles.tsx new file mode 100644 index 000000000..b2bbe8a0e --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.styles.tsx @@ -0,0 +1,136 @@ +import styled from 'styled-components'; + +export const ApplicationHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 32px; +`; + +export const ApplicationTitle = styled.h2` + font-size: 28px; + font-weight: 700; + margin: 0; +`; + +export const SemesterSelect = styled.select` + padding: 8px 16px; + border-radius: 8px; + border: 1px solid #ddd; + background: #fff; + font-size: 16px; +`; + +// 지원현황 +export const SummaryWrapper = styled.div` + display: flex; + gap: 12px; + margin-bottom: 40px; +`; + +export const SummaryCard = styled.div<{ bgColor: string }>` + flex: 1; + background: ${({ bgColor }) => bgColor}; + border-radius: 10px; + padding: 32px 0; + text-align: center; +`; + +export const SummaryLabel = styled.div` + font-size: 18px; + color: #888; +`; + +export const SummaryValue = styled.div` + font-size: 40px; + font-weight: 700; + margin-top: 8px; +`; + +export const SummaryPeople = styled.span` + font-size: 20px; + font-weight: 400; + margin-left: 2px; +`; + +// 지원자 목록 스타일 +export const ApplicantListWrapper = styled.div``; + +export const ApplicantListTitle = styled.h2` + font-size: 28px; + font-weight: 700; + margin-bottom: 24px; +`; + +export const ApplicantListHeader = styled.div` + display: flex; + align-items: center; + margin-bottom: 16px; + gap: 8px; +`; + +export const ApplicantFilterSelect = styled.select` + padding: 8px 16px; + border-radius: 8px; + border: 1px solid #ddd; + background: #fff; + font-size: 16px; +`; + +export const ApplicantSearchBox = styled.input` + margin-left: auto; + padding: 8px 16px; + border-radius: 8px; + border: 1px solid #ddd; + width: 240px; + font-size: 16px; +`; + +export const ApplicantTable = styled.table` + width: 100%; + border-collapse: collapse; + background: #fff; +`; + +export const ApplicantTableHeaderWrapper = styled.thead` + background: #fafafa; +`; + +export const ApplicantTableHeader = styled.th` + background: #fafafa; + padding: 12px 8px; + font-size: 16px; + font-weight: 500; + color: #888; + text-align: center; +`; + +export const ApplicantTableRow = styled.tr` + border-bottom: 1px solid #f0f0f0; + &:hover { + background: #f7faff; + } + text-align: center; +`; + +export const ApplicantTableCol = styled.td` + padding: 12px 8px; + font-size: 16px; +`; + +export const ApplicantStatusBadge = styled.span<{ status: string }>` + display: inline-block; + border-radius: 8px; + padding: 4px 12px; + font-weight: 500; + font-size: 15px; + background: ${({ status }) => + status === '서류검토' + ? '#E6F4FB' + : status === '면접예정' + ? '#E6FBF0' + : status === '합격' + ? '#F5F5F5' + : '#eee'}; + color: ${({ status }) => (status === '합격' ? '#888' : '#222')}; +`; diff --git a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx new file mode 100644 index 000000000..907ba36a7 --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx @@ -0,0 +1,153 @@ +import { useAdminClubContext } from '@/context/AdminClubContext'; +import { Applicant } from '@/types/applicants'; +import React from 'react'; +import * as Styled from './ApplicantsTab.styles'; +import { useNavigate } from 'react-router-dom'; + +function applicationStatusMapping(status: Applicant['status']): string { + switch (status) { + case 'DRAFT': + case 'SUBMITTED': + case 'SCREENING': + return '서류검토'; + case 'SCREENING_PASSED': + case 'INTERVIEW_SCHEDULED': + case 'INTERVIEW_IN_PROGRESS': + return '면접예정'; + case 'INTERVIEW_PASSED': + case 'OFFERED': + case 'ACCEPTED': + return '합격'; + default: + return ''; + } +} + +const ApplicantsTab = () => { + const navigate = useNavigate(); + const { clubId, applicantsData } = useAdminClubContext(); + if (!clubId) return null; + + return ( + <> + + 지원 현황 + {/* + + ...다른 학기 */ + /*{' '} + */} + + + + + 전체 지원자 수 + + {applicantsData?.total} + + + + + 서류 검토 필요 + + {applicantsData?.reviewRequired} + + + + + 면접 예정 + + {applicantsData?.scheduledInterview} + + + + + 합격 + + {applicantsData?.accepted} + + + + + + + 지원자 목록 + + + + + + + + + + + + + + + 현재상태 + + + 이름 + + 메모 + + 제출날짜 + + + + + {applicantsData?.applicants.map( + (item: Applicant, index: number) => ( + + navigate(`/admin/applicants/${item.id}`) + } + > + + ) => + e.stopPropagation() + } + /> + + + {applicationStatusMapping(item.status)} + + + {item.answers[0].value} + + + {item.memo} + + + { + // createdAt을 yyyy-mm-dd 형식으로 변환 + // 임시로.. 나중에 변경해야함 + (() => { + const date = new Date(item.createdAt); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + })() + } + + + ), + )} + + + + + ); +}; + +export default ApplicantsTab; diff --git a/frontend/src/pages/ClubDetailPage/components/InfoBox/InfoBox.tsx b/frontend/src/pages/ClubDetailPage/components/InfoBox/InfoBox.tsx index a922e1194..104ffe404 100644 --- a/frontend/src/pages/ClubDetailPage/components/InfoBox/InfoBox.tsx +++ b/frontend/src/pages/ClubDetailPage/components/InfoBox/InfoBox.tsx @@ -40,7 +40,12 @@ const InfoBox = ({ sectionRefs, clubDetail }: InfoBoxProps) => { { label: '전화번호', value: clubDetail.presidentPhoneNumber }, { label: 'SNS', - render: , + render: ( + + ), }, ], refIndex: INFOTABS_SCROLL_INDEX.CLUB_INFO_TAB, diff --git a/frontend/src/pages/ClubDetailPage/components/ShareButton/ShareButton.tsx b/frontend/src/pages/ClubDetailPage/components/ShareButton/ShareButton.tsx index eea5ea29e..935ab62ba 100644 --- a/frontend/src/pages/ClubDetailPage/components/ShareButton/ShareButton.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ShareButton/ShareButton.tsx @@ -43,7 +43,7 @@ const ShareButton = ({ clubId }: ShareButtonProps) => { }, ], }); - trackEvent(`${clubDetail.name} 공유하기 버튼 클릭`); + trackEvent('공유하기 버튼 클릭', { clubName: clubDetail.name }); }; return ( diff --git a/frontend/src/pages/ClubDetailPage/components/SnsLinkIcons/SnsLinkIcons.tsx b/frontend/src/pages/ClubDetailPage/components/SnsLinkIcons/SnsLinkIcons.tsx index 103cb50f0..4ca845bc1 100644 --- a/frontend/src/pages/ClubDetailPage/components/SnsLinkIcons/SnsLinkIcons.tsx +++ b/frontend/src/pages/ClubDetailPage/components/SnsLinkIcons/SnsLinkIcons.tsx @@ -2,12 +2,16 @@ import React from 'react'; import * as Styled from './SnsLinkIcons.styles'; import { SNS_CONFIG } from '@/constants/snsConfig'; import { SNSPlatform } from '@/types/club'; +import useMixpanelTrack from '@/hooks/useMixpanelTrack'; interface SnsLinkIconsProps { apiSocialLinks: Partial>; + clubName?: string; } -const SnsLinkIcons = ({ apiSocialLinks }: SnsLinkIconsProps) => { +const SnsLinkIcons = ({ apiSocialLinks, clubName }: SnsLinkIconsProps) => { + const trackEvent = useMixpanelTrack(); + if (!apiSocialLinks) return null; return ( @@ -21,6 +25,12 @@ const SnsLinkIcons = ({ apiSocialLinks }: SnsLinkIconsProps) => { href={url} target='_blank' rel='noreferrer' + onClick={() => + trackEvent('sns링크 버튼 클릭', { + platform, + clubName, + }) + } > diff --git a/frontend/src/pages/MainPage/MainPage.tsx b/frontend/src/pages/MainPage/MainPage.tsx index c49dae1be..0f8857380 100644 --- a/frontend/src/pages/MainPage/MainPage.tsx +++ b/frontend/src/pages/MainPage/MainPage.tsx @@ -21,15 +21,16 @@ const MainPage = () => { const [isFilterActive, setIsFilterActive] = useState(false); const { selectedCategory, setSelectedCategory } = useCategory(); - const { keyword } = useSearch(); + const { keyword, isSearching } = useSearch(); const recruitmentStatus = isFilterActive ? 'OPEN' : 'all'; const division = 'all'; + const searchCategory = isSearching ? 'all' : selectedCategory; const { data: clubs, error, isLoading, - } = useGetCardList(keyword, recruitmentStatus, division, selectedCategory); + } = useGetCardList(keyword, recruitmentStatus, division, searchCategory); const isEmpty = !isLoading && (!clubs || clubs.length === 0); const hasData = clubs && clubs.length > 0; diff --git a/frontend/src/pages/MainPage/components/CategoryButtonList/CategoryButtonList.tsx b/frontend/src/pages/MainPage/components/CategoryButtonList/CategoryButtonList.tsx index cdaeafaff..b5a568108 100644 --- a/frontend/src/pages/MainPage/components/CategoryButtonList/CategoryButtonList.tsx +++ b/frontend/src/pages/MainPage/components/CategoryButtonList/CategoryButtonList.tsx @@ -58,7 +58,7 @@ const clubCategories: Category[] = [ ]; const CategoryButtonList = () => { - const { setKeyword, setInputValue } = useSearch(); + const { setKeyword, setInputValue, setIsSearching } = useSearch(); const { setSelectedCategory } = useCategory(); const handleCategoryClick = (category: Category) => { @@ -71,9 +71,9 @@ const CategoryButtonList = () => { setKeyword(''); setInputValue(''); + setIsSearching(false); setSelectedCategory(category.id); - }; return ( diff --git a/frontend/src/types/applicants.ts b/frontend/src/types/applicants.ts new file mode 100644 index 000000000..b91fec312 --- /dev/null +++ b/frontend/src/types/applicants.ts @@ -0,0 +1,33 @@ +import { AnswerItem } from "./application"; + +export enum ApplicationStatus { + DRAFT = 'DRAFT', // 작성 중 + SUBMITTED = 'SUBMITTED', // 제출 완료 + SCREENING = 'SCREENING', // 서류 심사 중 + SCREENING_PASSED = 'SCREENING_PASSED', // 서류 통과 + SCREENING_FAILED = 'SCREENING_FAILED', // 서류 탈락 + INTERVIEW_SCHEDULED = 'INTERVIEW_SCHEDULED', // 면접 일정 확정 + INTERVIEW_IN_PROGRESS = 'INTERVIEW_IN_PROGRESS', // 면접 진행 중 + INTERVIEW_PASSED = 'INTERVIEW_PASSED', // 면접 통과 + INTERVIEW_FAILED = 'INTERVIEW_FAILED', // 면접 탈락 + OFFERED = 'OFFERED', // 최종 합격 제안 + ACCEPTED = 'ACCEPTED', // 제안 수락 + DECLINED = 'DECLINED', // 제안 거절 + CANCELED_BY_APPLICANT = 'CANCELED_BY_APPLICANT', // 지원자 자진 철회 +} + +export interface ApplicantsInfo { + total: number; + reviewRequired: number; + scheduledInterview: number; + accepted: number; + applicants: Applicant[] +} + +export interface Applicant { + id: string; + status: ApplicationStatus; + answers: AnswerItem[] + memo: string; + createdAt: string; +} \ No newline at end of file