diff --git a/src/mocks/repositories/applicant.ts b/src/mocks/repositories/applicant.ts index 64b24b57..48c6d032 100644 --- a/src/mocks/repositories/applicant.ts +++ b/src/mocks/repositories/applicant.ts @@ -9,7 +9,7 @@ const applicants: ApplicantData[] = [ department: '소프트웨어공학과', phoneNumber: '010-1010-1010', email: 'ddd@naver.com', - status: '미정', + status: 'PENDING', }, { id: 2, @@ -18,7 +18,7 @@ const applicants: ApplicantData[] = [ department: '소프트웨어공학과', phoneNumber: '010-1010-1010', email: 'ddd@naver.com', - status: '합격', + status: 'APPROVED', }, { id: 3, @@ -27,7 +27,7 @@ const applicants: ApplicantData[] = [ department: '소프트웨어공학과', phoneNumber: '010-1010-1010', email: 'ddd@naver.com', - status: '불합격', + status: 'REJECTED', }, { id: 4, @@ -36,7 +36,7 @@ const applicants: ApplicantData[] = [ department: '소프트웨어공학과', phoneNumber: '010-1010-1010', email: 'ddd@naver.com', - status: '미정', + status: 'PENDING', }, ]; diff --git a/src/pages/admin/ApplicationDetail/components/ApplicantProfileSection/ApplicantStatusButton.tsx b/src/pages/admin/ApplicationDetail/components/ApplicantProfileSection/ApplicantStatusButton.tsx index c4c2da22..b7574ada 100644 --- a/src/pages/admin/ApplicationDetail/components/ApplicantProfileSection/ApplicantStatusButton.tsx +++ b/src/pages/admin/ApplicationDetail/components/ApplicantProfileSection/ApplicantStatusButton.tsx @@ -1,11 +1,11 @@ import styled from '@emotion/styled'; -import type { ApplicantData, ApplicantStatus } from '@/pages/admin/Dashboard/types/dashboard'; +import type { StatusLabel, ApplicationStatus } from '@/pages/admin/Dashboard/types/dashboard'; type Props = { - label: ApplicantData['status']; - value: ApplicantStatus; + label: StatusLabel; + value: ApplicationStatus; selected: boolean; - onClick: (status: ApplicantStatus) => void; + onClick: (status: ApplicationStatus) => void; }; export const ApplicantStatusButton = ({ label, value, selected, onClick }: Props) => { diff --git a/src/pages/admin/ApplicationDetail/components/ApplicantProfileSection/ApplicantStatusToggle.tsx b/src/pages/admin/ApplicationDetail/components/ApplicantProfileSection/ApplicantStatusToggle.tsx index 8e55df78..caef8288 100644 --- a/src/pages/admin/ApplicationDetail/components/ApplicantProfileSection/ApplicantStatusToggle.tsx +++ b/src/pages/admin/ApplicationDetail/components/ApplicantProfileSection/ApplicantStatusToggle.tsx @@ -1,17 +1,17 @@ import styled from '@emotion/styled'; import { useState } from 'react'; import { ApplicantStatusButton } from './ApplicantStatusButton'; -import type { ApplicantStatus } from '@/pages/admin/Dashboard/types/dashboard'; +import type { ApplicationStatus } from '@/pages/admin/Dashboard/types/dashboard'; type Props = { - status?: ApplicantStatus; - updateStatus: (status: ApplicantStatus) => void; + status?: ApplicationStatus; + updateStatus: (status: ApplicationStatus) => void; }; export const ApplicantStatusToggle = ({ status, updateStatus }: Props) => { const [statusOption, setStatusOption] = useState(status); - const handleClick = (newStatus: ApplicantStatus) => { + const handleClick = (newStatus: ApplicationStatus) => { setStatusOption(newStatus); updateStatus(newStatus); }; @@ -20,8 +20,8 @@ export const ApplicantStatusToggle = ({ status, updateStatus }: Props) => { void; + updateStatus: (status: ApplicationStatus) => void; }; export const ApplicantProfileSection = ({ diff --git a/src/pages/admin/ApplicationDetail/types/detailApplication.ts b/src/pages/admin/ApplicationDetail/types/detailApplication.ts index 50ff7c06..eb539c43 100644 --- a/src/pages/admin/ApplicationDetail/types/detailApplication.ts +++ b/src/pages/admin/ApplicationDetail/types/detailApplication.ts @@ -1,8 +1,8 @@ -import type { ApplicantStatus } from '@/pages/admin/Dashboard/types/dashboard'; +import type { ApplicationStatus } from '@/pages/admin/Dashboard/types/dashboard'; export type DetailApplication = { applicationId: number; - status: ApplicantStatus; + status: ApplicationStatus; rating: number; applicantInfo: { applicantId: number; diff --git a/src/pages/admin/Dashboard/api/dashboard.ts b/src/pages/admin/Dashboard/api/dashboard.ts new file mode 100644 index 00000000..8c18bf3a --- /dev/null +++ b/src/pages/admin/Dashboard/api/dashboard.ts @@ -0,0 +1,11 @@ +import type { DashboardSummary } from '@/pages/admin/Dashboard/types/dashboard'; + +export const fetchDashboardSummary = async (clubId: number): Promise => { + const response = await fetch(`/api/clubs/${clubId}/dashboard`); + + if (!response.ok) { + throw new Error('대시보드 요약 정보를 불러오는 데 실패했습니다.'); + } + + return response.json(); +}; diff --git a/src/pages/admin/Dashboard/components/ApplicantListSection/ApplicantList.tsx b/src/pages/admin/Dashboard/components/ApplicantListSection/ApplicantList.tsx index 7752aad2..8b99ea46 100644 --- a/src/pages/admin/Dashboard/components/ApplicantListSection/ApplicantList.tsx +++ b/src/pages/admin/Dashboard/components/ApplicantListSection/ApplicantList.tsx @@ -31,23 +31,33 @@ export const ApplicantList = ({ filterOption }: Props) => { ))} - {applicants.map((applicant) => ( - - ))} + {applicants.length > 0 ? ( + applicants.map((applicant) => ( + + )) + ) : ( + + {filterOption === 'ALL' + ? '아직 지원자가 없습니다.' + : `${filterOption === 'PENDING' ? '심사중' : filterOption === 'APPROVED' ? '합격' : '불합격'} 지원자가 없습니다.`} + + )} - - - + {applicants.length > 0 && ( + + + + )} ); }; @@ -82,6 +92,13 @@ const ButtonWrapper = styled.div({ width: '100%', }); +const EmptyMessage = styled.div(({ theme }) => ({ + padding: '4rem', + textAlign: 'center', + color: theme.colors.gray500, + fontSize: '1.4rem', +})); + type ApplicateInfoCategory = '이름' | '학번' | '학과' | '전화번호' | '이메일' | '결과'; const INFO_CATEGORY: ApplicateInfoCategory[] = [ '이름', diff --git a/src/pages/admin/Dashboard/components/ApplicantListSection/ApplicantListItem.tsx b/src/pages/admin/Dashboard/components/ApplicantListSection/ApplicantListItem.tsx index fc11b814..8d9129d3 100644 --- a/src/pages/admin/Dashboard/components/ApplicantListSection/ApplicantListItem.tsx +++ b/src/pages/admin/Dashboard/components/ApplicantListSection/ApplicantListItem.tsx @@ -5,6 +5,12 @@ type Props = ApplicantData & { onClick: (id: number) => void; }; +const STATUS_LABEL: Record = { + PENDING: '미정', + REJECTED: '불합격', + APPROVED: '합격', +}; + export const ApplicantListItem = ({ id, name, @@ -22,7 +28,7 @@ export const ApplicantListItem = ({ {department || '-'} {phoneNumber || '-'} {email || '-'} - {status || '-'} + {STATUS_LABEL[status] || '-'} ); }; @@ -47,15 +53,15 @@ const InfoText = styled.p(({ theme }) => ({ const StatusBadge = styled.p>(({ theme, status }) => { const styles = { - 합격: { + APPROVED: { backgroundColor: theme.colors.primary100, color: theme.colors.primary800, }, - 불합격: { + REJECTED: { backgroundColor: theme.colors.red100, color: theme.colors.red600, }, - 미정: { + PENDING: { backgroundColor: theme.colors.gray100, color: theme.colors.gray600, }, diff --git a/src/pages/admin/Dashboard/components/ApplicantListSection/ApplicationFilter.tsx b/src/pages/admin/Dashboard/components/ApplicantListSection/ApplicationFilter.tsx index d6010df2..c1980b84 100644 --- a/src/pages/admin/Dashboard/components/ApplicantListSection/ApplicationFilter.tsx +++ b/src/pages/admin/Dashboard/components/ApplicantListSection/ApplicationFilter.tsx @@ -1,4 +1,5 @@ import styled from '@emotion/styled'; +import { useApplicants } from '@/pages/admin/Dashboard/hooks/useApplicants'; import { ApplicantFilterButton } from './ApplicationFilterButton'; import type { ApplicationFilterOption } from '@/pages/admin/Dashboard/types/dashboard'; @@ -8,29 +9,31 @@ export type Props = { }; export const ApplicationStatusFilter = ({ option, onOptionChange }: Props) => { + const { counts } = useApplicants(1); + return ( diff --git a/src/pages/admin/Dashboard/components/DashboardSummarySection/SummaryCard.tsx b/src/pages/admin/Dashboard/components/DashboardSummarySection/SummaryCard.tsx index 8011e00c..fa402a4d 100644 --- a/src/pages/admin/Dashboard/components/DashboardSummarySection/SummaryCard.tsx +++ b/src/pages/admin/Dashboard/components/DashboardSummarySection/SummaryCard.tsx @@ -1,15 +1,18 @@ import styled from '@emotion/styled'; import type { DashboardCard } from '@/pages/admin/Dashboard/types/dashboard'; -type Props = Omit; +type Props = Omit & { + isEmpty?: boolean; +}; -export const SummaryCard = ({ label, value, image }: Props) => { +export const SummaryCard = ({ label, value, image, isEmpty = false }: Props) => { return ( - {image} + {image} - {value} + {value} + {isEmpty && 예정된 모집이 없습니다} ); @@ -26,8 +29,9 @@ const Wrapper = styled.div(({ theme }) => ({ borderRadius: theme.radius.lg, })); -const IconWrapper = styled.div(({ theme }) => ({ - color: theme.colors.gray900, +const IconWrapper = styled.div<{ isEmpty?: boolean }>(({ theme, isEmpty }) => ({ + color: isEmpty ? theme.colors.gray400 : theme.colors.gray900, + transition: 'color 0.2s', })); const TextWrapper = styled.div({ @@ -41,8 +45,15 @@ const Label = styled.p(({ theme }) => ({ color: theme.colors.gray900, })); -const Value = styled.p(({ theme }) => ({ - fontSize: '2.2rem', +const Value = styled.p<{ isEmpty?: boolean }>(({ theme, isEmpty }) => ({ + fontSize: '2rem', fontWeight: theme.font.weight.bold, - color: theme.colors.gray900, + color: isEmpty ? theme.colors.gray400 : theme.colors.gray900, + transition: 'color 0.2s', +})); + +const EmptyText = styled.span(({ theme }) => ({ + fontSize: '1.1rem', + color: theme.colors.gray500, + marginTop: '-0.3rem', })); diff --git a/src/pages/admin/Dashboard/components/DashboardSummarySection/index.tsx b/src/pages/admin/Dashboard/components/DashboardSummarySection/index.tsx index ce71a2b9..ec2ce3a9 100644 --- a/src/pages/admin/Dashboard/components/DashboardSummarySection/index.tsx +++ b/src/pages/admin/Dashboard/components/DashboardSummarySection/index.tsx @@ -1,13 +1,49 @@ import styled from '@emotion/styled'; +import { GoPeople, GoCalendar } from 'react-icons/go'; +import { IoDocumentTextOutline } from 'react-icons/io5'; +import { useDashboardSummary } from '@/pages/admin/Dashboard/hooks/useDashboardSummary'; +import { LoadingSpinner } from '@/shared/components/LoadingSpinner'; +import { formatDate } from '@/utils/dateUtils'; import { SummaryCard } from './SummaryCard'; -import type { DashboardCard } from '@/pages/admin/Dashboard/types/dashboard'; export const DashboardSummarySection = () => { + const clubId = 1; + + const { data: summary, isLoading, error } = useDashboardSummary(clubId); + + if (isLoading) return ; + + if (error) { + return Error: {error.message}; + } + + if (!summary) { + return null; + } + + const recruitmentPeriod = + summary.startDay && summary.endDay + ? `${formatDate(summary.startDay)} ~ ${formatDate(summary.endDay)}` + : '-'; + return ( - {DASHBOARD_CARDS.map((card) => ( - - ))} + } + /> + } + /> + } + isEmpty={!summary.startDay || !summary.endDay} + /> ); }; @@ -32,64 +68,3 @@ const Wrapper = styled.section(({ theme }) => ({ backgroundColor: theme.colors.gray300, }, })); - -// 더미 데이터 -const DASHBOARD_CARDS: DashboardCard[] = [ - { - id: 1, - label: '총 지원자', - value: 127, - image: ( - - - - ), - }, - { - id: 2, - label: '대기중인 지원서', - value: 23, - image: ( - - - - ), - }, - { - id: 3, - label: '이번 모집 일정', - value: '8/16 ~ 8/26', - image: ( - - - - ), - }, -]; diff --git a/src/pages/admin/Dashboard/hooks/useApplicants.ts b/src/pages/admin/Dashboard/hooks/useApplicants.ts index 9772308c..9fe755b3 100644 --- a/src/pages/admin/Dashboard/hooks/useApplicants.ts +++ b/src/pages/admin/Dashboard/hooks/useApplicants.ts @@ -1,24 +1,56 @@ import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; import { fetchApplicants } from '@/pages/admin/Dashboard/api/applicant'; import type { ApplicantData, ApplicationFilterOption, + ApplicantCounts, } from '@/pages/admin/Dashboard/types/dashboard'; import type { UseApiQueryResult } from '@/types/useApiQueryResult'; +export interface ExtendedUseApiQueryResult extends UseApiQueryResult { + counts: ApplicantCounts; +} + export const useApplicants = ( clubId: number, status?: ApplicationFilterOption, -): UseApiQueryResult => { +): ExtendedUseApiQueryResult => { const { data, isLoading, error } = useQuery({ - queryKey: ['applicants', clubId, status], + queryKey: ['applicants', clubId], queryFn: () => fetchApplicants(clubId), - staleTime: 1000 * 60 * 2, + staleTime: 1000 * 60 * 5, + refetchInterval: 30000, }); + const filteredData = useMemo(() => { + if (!data) return []; + if (!status || status === 'ALL') return data; + + return data.filter((applicant) => applicant.status === status); + }, [data, status]); + + const counts = useMemo(() => { + if (!data) return { ALL: 0, PENDING: 0, APPROVED: 0, REJECTED: 0 }; + + return data.reduce( + (acc, applicant) => { + acc[applicant.status] += 1; + return acc; + }, + { + ALL: data.length, + PENDING: 0, + APPROVED: 0, + REJECTED: 0, + }, + ); + }, [data]); + return { - data: data || [], + data: filteredData, isLoading, error, + counts, }; }; diff --git a/src/pages/admin/Dashboard/hooks/useDashboardSummary.ts b/src/pages/admin/Dashboard/hooks/useDashboardSummary.ts new file mode 100644 index 00000000..ac0dcf97 --- /dev/null +++ b/src/pages/admin/Dashboard/hooks/useDashboardSummary.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchDashboardSummary } from '@/pages/admin/Dashboard/api/dashboard'; +import type { DashboardSummary } from '@/pages/admin/Dashboard/types/dashboard'; +import type { UseApiQueryResult } from '@/types/useApiQueryResult'; + +export const useDashboardSummary = (clubId: number): UseApiQueryResult => { + const { data, isLoading, error } = useQuery({ + queryKey: ['dashboardSummary', clubId], + queryFn: () => fetchDashboardSummary(clubId), + staleTime: 1000 * 60 * 5, + refetchInterval: 30000, + }); + + return { data: data || null, isLoading, error }; +}; diff --git a/src/pages/admin/Dashboard/types/dashboard.ts b/src/pages/admin/Dashboard/types/dashboard.ts index 93343863..4694d0b9 100644 --- a/src/pages/admin/Dashboard/types/dashboard.ts +++ b/src/pages/admin/Dashboard/types/dashboard.ts @@ -7,9 +7,9 @@ export type DashboardCard = { image: ReactNode; }; -export type ApplicationFilterOption = 'ALL' | 'ACCEPTED' | 'REJECTED' | 'PENDING'; - -export type ApplicantStatus = 'ACCEPTED' | 'REJECTED' | 'PENDING'; +export type StatusLabel = '합격' | '불합격' | '미정'; +export type ApplicationStatus = 'APPROVED' | 'REJECTED' | 'PENDING'; +export type ApplicationFilterOption = 'ALL' | ApplicationStatus; export type ApplicantData = { id: number; @@ -18,5 +18,19 @@ export type ApplicantData = { department: string; phoneNumber: string; email: string; - status: '합격' | '불합격' | '미정'; + status: ApplicationStatus; +}; + +export type DashboardSummary = { + totalApplicantCount: number; + pendingApplicationCount: number; + startDay: string; + endDay: string; +}; + +export type ApplicantCounts = { + ALL: number; + PENDING: number; + APPROVED: number; + REJECTED: number; }; diff --git a/src/pages/user/ClubDetail/api/clubDetail.ts b/src/pages/user/ClubDetail/api/clubDetail.ts index 753dbf16..dfdd8381 100644 --- a/src/pages/user/ClubDetail/api/clubDetail.ts +++ b/src/pages/user/ClubDetail/api/clubDetail.ts @@ -1,9 +1,12 @@ import type { ClubDetail } from '../types/clubDetail'; -const BASE_URL = import.meta.env.VITE_API_BASE_URL; - export const fetchClubDetail = async (clubId: number): Promise => { - const res = await fetch(`${BASE_URL}/clubs/${clubId}`); - if (!res.ok) throw new Error('동아리 상세 정보를 가져오는데 실패했습니다.'); + const url = `/api/clubs/${clubId}`; + const res = await fetch(url); + + if (!res.ok) { + throw new Error('동아리 상세 정보를 가져오는데 실패했습니다.'); + } + return res.json() as Promise; };