diff --git a/apps/api/src/admin/summary_handler.py b/apps/api/src/admin/summary_handler.py
new file mode 100644
index 00000000..b0437217
--- /dev/null
+++ b/apps/api/src/admin/summary_handler.py
@@ -0,0 +1,23 @@
+from collections import Counter
+
+from pydantic import BaseModel, TypeAdapter
+
+from services import mongodb_handler
+from services.mongodb_handler import Collection
+from utils.user_record import ApplicantStatus, Role
+
+
+class ApplicantSummaryRecord(BaseModel):
+ status: ApplicantStatus
+
+
+async def applicant_summary() -> Counter[ApplicantStatus]:
+ """Get summary of applicants by status."""
+ records = await mongodb_handler.retrieve(
+ Collection.USERS,
+ {"role": Role.APPLICANT},
+ ["status"],
+ )
+ applicants = TypeAdapter(list[ApplicantSummaryRecord]).validate_python(records)
+
+ return Counter(applicant.status for applicant in applicants)
diff --git a/apps/api/src/routers/admin.py b/apps/api/src/routers/admin.py
index bbd83be9..2404a74c 100644
--- a/apps/api/src/routers/admin.py
+++ b/apps/api/src/routers/admin.py
@@ -6,6 +6,7 @@
from fastapi import APIRouter, Body, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr, Field, TypeAdapter, ValidationError
+from admin import summary_handler
from auth.authorization import require_role
from auth.user_identity import User, utc_now
from models.ApplicationData import Decision, Review
@@ -15,7 +16,7 @@
from utils import email_handler
from utils.batched import batched
from utils.email_handler import IH_SENDER, REPLY_TO_HACK_AT_UCI
-from utils.user_record import Applicant, Role, Status
+from utils.user_record import Applicant, ApplicantStatus, Role, Status
log = getLogger(__name__)
@@ -86,6 +87,15 @@ async def applicant(
raise RuntimeError("Could not parse applicant data.")
+@router.get(
+ "/summary/applicants",
+ dependencies=[Depends(require_role(ADMIN_ROLES))],
+)
+async def applicant_summary() -> dict[ApplicantStatus, int]:
+ """Provide summary of statuses of applicants."""
+ return await summary_handler.applicant_summary()
+
+
@router.post("/review")
async def submit_review(
applicant: str = Body(),
diff --git a/apps/api/src/utils/user_record.py b/apps/api/src/utils/user_record.py
index 0807e2c8..d91c2cad 100644
--- a/apps/api/src/utils/user_record.py
+++ b/apps/api/src/utils/user_record.py
@@ -2,6 +2,7 @@
from typing import Literal, Union
from pydantic import Field
+from typing_extensions import TypeAlias
from models.ApplicationData import Decision, ProcessedApplicationData
from services.mongodb_handler import BaseRecord
@@ -31,7 +32,10 @@ class UserRecord(BaseRecord):
role: Role
+ApplicantStatus: TypeAlias = Union[Status, Decision]
+
+
class Applicant(UserRecord):
role: Literal[Role.APPLICANT] = Role.APPLICANT
- status: Union[Status, Decision]
+ status: ApplicantStatus
application_data: ProcessedApplicationData
diff --git a/apps/api/tests/test_summary_handler.py b/apps/api/tests/test_summary_handler.py
new file mode 100644
index 00000000..c1066e71
--- /dev/null
+++ b/apps/api/tests/test_summary_handler.py
@@ -0,0 +1,23 @@
+from unittest.mock import AsyncMock, patch
+
+from admin.summary_handler import applicant_summary
+
+
+@patch("services.mongodb_handler.retrieve", autospec=True)
+async def test_applicant_summary(mock_mongodb_handler_retrieve: AsyncMock) -> None:
+ """Test applicant summary counts by status."""
+ mock_mongodb_handler_retrieve.return_value = (
+ [{"status": "ACCEPTED"}, {"status": "REJECTED"}] * 20
+ + [{"status": "CONFIRMED"}] * 24
+ + [{"status": "WAITLISTED"}, {"status": "WAIVER_SIGNED"}] * 3
+ )
+
+ summary = await applicant_summary()
+ mock_mongodb_handler_retrieve.assert_awaited_once()
+ assert dict(summary) == {
+ "REJECTED": 20,
+ "WAITLISTED": 3,
+ "ACCEPTED": 20,
+ "WAIVER_SIGNED": 3,
+ "CONFIRMED": 24,
+ }
diff --git a/apps/site/src/app/admin/dashboard/AdminDashboard.tsx b/apps/site/src/app/admin/dashboard/AdminDashboard.tsx
index 5ae9f9c9..f673ce7c 100644
--- a/apps/site/src/app/admin/dashboard/AdminDashboard.tsx
+++ b/apps/site/src/app/admin/dashboard/AdminDashboard.tsx
@@ -1,12 +1,25 @@
"use client";
+import { useContext } from "react";
+
import Container from "@cloudscape-design/components/container";
import ContentLayout from "@cloudscape-design/components/content-layout";
+import SpaceBetween from "@cloudscape-design/components/space-between";
+
+import { isApplicationManager } from "@/lib/admin/authorization";
+import UserContext from "@/lib/admin/UserContext";
+
+import ApplicantSummary from "./components/ApplicantSummary";
function AdminDashboard() {
+ const { role } = useContext(UserContext);
+
return (
- Admin Dashboard
+
+ Admin Dashboard
+ {isApplicationManager(role) && }
+
);
}
diff --git a/apps/site/src/app/admin/dashboard/components/ApplicantSummary.tsx b/apps/site/src/app/admin/dashboard/components/ApplicantSummary.tsx
new file mode 100644
index 00000000..38dd8bb5
--- /dev/null
+++ b/apps/site/src/app/admin/dashboard/components/ApplicantSummary.tsx
@@ -0,0 +1,57 @@
+import Box from "@cloudscape-design/components/box";
+import Container from "@cloudscape-design/components/container";
+import PieChart from "@cloudscape-design/components/pie-chart";
+
+import { Status } from "@/lib/admin/useApplicant";
+
+import useApplicantSummary from "./useApplicantSummary";
+
+function ApplicantSummary() {
+ const { summary, loading, error } = useApplicantSummary();
+ const totalApplicants = Object.values(summary).reduce((s, v) => s + v, 0);
+
+ const orderedData = [
+ Status.rejected,
+ Status.waitlisted,
+ Status.accepted,
+ Status.signed,
+ Status.confirmed,
+ Status.attending,
+ Status.void,
+ ].map((status) => ({
+ title: status,
+ value: summary[status] ?? 0,
+ }));
+
+ return (
+ Applicant Summary}>
+
+ `${datum.value} applicants (${percentage(datum.value / sum)}%)`
+ }
+ ariaDescription="Donut chart showing summary of applicant statuses."
+ ariaLabel="Donut chart"
+ innerMetricDescription="total applicants"
+ innerMetricValue={`${totalApplicants}`}
+ size="large"
+ variant="donut"
+ empty={
+
+ No data available
+
+ There is no data available
+
+
+ }
+ />
+
+ );
+}
+
+const percentage = (value: number): string => (value * 100).toFixed(0);
+
+export default ApplicantSummary;
diff --git a/apps/site/src/app/admin/dashboard/components/useApplicantSummary.ts b/apps/site/src/app/admin/dashboard/components/useApplicantSummary.ts
new file mode 100644
index 00000000..7c00f6f1
--- /dev/null
+++ b/apps/site/src/app/admin/dashboard/components/useApplicantSummary.ts
@@ -0,0 +1,26 @@
+import axios from "axios";
+import useSWR from "swr";
+
+import { Status } from "@/lib/admin/useApplicant";
+
+type ApplicantSummary = Partial>;
+
+const fetcher = async (url: string) => {
+ const res = await axios.get(url);
+ return res.data;
+};
+
+function useApplicantSummary() {
+ const { data, error, isLoading } = useSWR(
+ "/api/admin/summary/applicants",
+ fetcher,
+ );
+
+ return {
+ summary: data ?? ({} as ApplicantSummary),
+ loading: isLoading,
+ error,
+ };
+}
+
+export default useApplicantSummary;
diff --git a/apps/site/src/app/admin/layout/AdminSidebar.tsx b/apps/site/src/app/admin/layout/AdminSidebar.tsx
index da29c439..e18c1909 100644
--- a/apps/site/src/app/admin/layout/AdminSidebar.tsx
+++ b/apps/site/src/app/admin/layout/AdminSidebar.tsx
@@ -6,10 +6,11 @@ import SideNavigation, {
SideNavigationProps,
} from "@cloudscape-design/components/side-navigation";
-import { BASE_PATH, useFollowWithNextLink } from "./common";
import { isApplicationManager } from "@/lib/admin/authorization";
import UserContext from "@/lib/admin/UserContext";
+import { BASE_PATH, useFollowWithNextLink } from "./common";
+
function AdminSidebar() {
const pathname = usePathname();
const followWithNextLink = useFollowWithNextLink();
@@ -17,20 +18,18 @@ function AdminSidebar() {
const { role } = useContext(UserContext);
const navigationItems: SideNavigationProps.Item[] = [
+ { type: "link", text: "Dashboard", href: "/admin/dashboard" },
{ type: "link", text: "Participants", href: "/admin/participants" },
{ type: "divider" },
{ type: "link", text: "Back to main site", href: "/" },
];
if (isApplicationManager(role)) {
- navigationItems.unshift(
- {
- type: "link",
- text: "Applicants",
- href: "/admin/applicants",
- },
- { type: "divider" },
- );
+ navigationItems.splice(1, 0, {
+ type: "link",
+ text: "Applicants",
+ href: "/admin/applicants",
+ });
}
return (