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;
};