diff --git a/src/components/Reports/JobAnalytics/JobAnalyticsCompetitiveRolesPage.jsx b/src/components/Reports/JobAnalytics/JobAnalyticsCompetitiveRolesPage.jsx
new file mode 100644
index 0000000000..f4b905499a
--- /dev/null
+++ b/src/components/Reports/JobAnalytics/JobAnalyticsCompetitiveRolesPage.jsx
@@ -0,0 +1,77 @@
+import React, { useState, useEffect, useMemo } from "react";
+import axios from "axios";
+import { useSelector } from "react-redux";
+import { ENDPOINTS } from "../../../utils/URL";
+import JobAnalyticsFilters from "./JobAnalyticsFilters";
+import JobAnalyticsGraph from "./JobAnalyticsGraph";
+import styles from "./JobAnalyticsPage.module.css";
+
+const JobAnalyticsCompetitiveRolesPage = () => {
+ const darkMode = useSelector((state) => state.theme.darkMode);
+
+ const [filters, setFilters] = useState({
+ dateMode: "All",
+ startDate: "",
+ endDate: "",
+ roles: "All",
+ granularity: "totals",
+ });
+
+ const [data, setData] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ const requestUrl = useMemo(() => {
+ const start = filters.dateMode === "Custom" ? filters.startDate : "";
+ const end = filters.dateMode === "Custom" ? filters.endDate : "";
+ const gran =
+ filters.dateMode === "Custom" && filters.granularity !== "totals"
+ ? filters.granularity
+ : undefined;
+
+ return ENDPOINTS.JOB_ANALYTICS_QUERY(start, end, filters.roles, gran);
+ }, [filters]);
+
+ useEffect(() => {
+ let alive = true;
+
+ (async () => {
+ setLoading(true);
+ try {
+ const resp = await axios.get(requestUrl);
+ if (alive) {
+ setData(Array.isArray(resp.data) ? resp.data : []);
+ }
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error("Error fetching job analytics:", e);
+ if (alive) setData([]);
+ } finally {
+ if (alive) setLoading(false);
+ }
+ })();
+
+ return () => {
+ alive = false;
+ };
+ }, [requestUrl]);
+
+ return (
+
+
+
+
+
+ {loading ? (
+
Loading…
+ ) : (
+
+ )}
+
+ );
+};
+
+export default JobAnalyticsCompetitiveRolesPage;
diff --git a/src/components/Reports/JobAnalytics/JobAnalyticsFilters.jsx b/src/components/Reports/JobAnalytics/JobAnalyticsFilters.jsx
new file mode 100644
index 0000000000..e040bb1ea2
--- /dev/null
+++ b/src/components/Reports/JobAnalytics/JobAnalyticsFilters.jsx
@@ -0,0 +1,184 @@
+import React, { useEffect, useState } from "react";
+import PropTypes from "prop-types";
+import axios from "axios";
+import { ENDPOINTS } from "../../../utils/URL";
+import styles from "./JobAnalyticsPage.module.css";
+
+const GRANULARITY_OPTS = [
+ { value: "totals", label: "Totals" },
+ { value: "weekly", label: "Weekly" },
+ { value: "monthly", label: "Monthly" },
+ { value: "annually", label: "Annually" },
+];
+
+function FilterField({ label, children }) {
+ return (
+
+ );
+}
+
+FilterField.propTypes = {
+ label: PropTypes.string.isRequired,
+ children: PropTypes.node.isRequired,
+};
+
+function JobAnalyticsFilters({ filters, setFilters }) {
+ const [roleOptions, setRoleOptions] = useState(["All"]);
+ const [loadingRoles, setLoadingRoles] = useState(false);
+
+ useEffect(() => {
+ let alive = true;
+
+ async function loadRoles() {
+ setLoadingRoles(true);
+ try {
+ const resp = await axios.get(ENDPOINTS.JOB_ANALYTICS_ROLES);
+ const roles = Array.isArray(resp.data) ? resp.data : [];
+
+ const sorted = [
+ "All",
+ ...Array.from(new Set(roles)).sort((a, b) =>
+ a.localeCompare(b)
+ ),
+ ];
+
+ if (alive) {
+ setRoleOptions(sorted);
+ if (!sorted.includes(filters.roles)) {
+ setFilters((prev) => ({ ...prev, roles: "All" }));
+ }
+ }
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.error("Failed to load roles:", err);
+ if (alive) setRoleOptions(["All"]);
+ } finally {
+ if (alive) setLoadingRoles(false);
+ }
+ }
+
+ loadRoles();
+ return () => {
+ alive = false;
+ };
+ }, [filters.roles, setFilters]);
+
+ const onChange = (e) => {
+ const { name, value } = e.target;
+
+ if (name === "dateMode") {
+ if (value === "All") {
+ setFilters((prev) => ({
+ ...prev,
+ dateMode: "All",
+ startDate: "",
+ endDate: "",
+ granularity: "totals",
+ }));
+ return;
+ }
+ setFilters((prev) => ({ ...prev, dateMode: "Custom" }));
+ return;
+ }
+
+ if (name === "granularity" && filters.dateMode === "All") {
+ setFilters((prev) => ({ ...prev, granularity: "totals" }));
+ return;
+ }
+
+ setFilters((prev) => ({ ...prev, [name]: value }));
+ };
+
+ const nonTotalsDisabled = filters.dateMode !== "Custom";
+
+ return (
+
+
+
+
+
+ {filters.dateMode === "Custom" && (
+ <>
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+JobAnalyticsFilters.propTypes = {
+ filters: PropTypes.shape({
+ dateMode: PropTypes.string.isRequired,
+ startDate: PropTypes.string,
+ endDate: PropTypes.string,
+ roles: PropTypes.string,
+ granularity: PropTypes.string,
+ }).isRequired,
+ setFilters: PropTypes.func.isRequired,
+};
+
+export default JobAnalyticsFilters;
diff --git a/src/components/Reports/JobAnalytics/JobAnalyticsGraph.jsx b/src/components/Reports/JobAnalytics/JobAnalyticsGraph.jsx
new file mode 100644
index 0000000000..b3c5a797cb
--- /dev/null
+++ b/src/components/Reports/JobAnalytics/JobAnalyticsGraph.jsx
@@ -0,0 +1,104 @@
+import React from "react";
+import PropTypes from "prop-types";
+import { Bar } from "react-chartjs-2";
+import {
+ Chart as ChartJS,
+ CategoryScale,
+ LinearScale,
+ BarElement,
+ Title,
+ Tooltip,
+ Legend,
+} from "chart.js";
+import ChartDataLabels from "chartjs-plugin-datalabels";
+import styles from "./JobAnalyticsPage.module.css";
+
+ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
+
+export default function JobAnalyticsGraph({ data, darkMode }) {
+ if (!Array.isArray(data) || data.length === 0) {
+ return No data available
;
+ }
+
+ const chartData = {
+ labels: data.map((d) => d.role),
+ datasets: [
+ {
+ label: "Applications",
+ data: data.map((d) => d.applications ?? d.count ?? 0),
+ backgroundColor: darkMode ? "#3A506B" : "rgba(54, 162, 235, 0.7)",
+ },
+ ],
+ };
+
+ const chartOptions = {
+ indexAxis: "y",
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: { display: false },
+ title: {
+ display: true,
+ text: "Most Competitive Roles",
+ color: darkMode ? "#E0E0E0" : "#111111",
+ font: { size: 18, weight: "bold" },
+ },
+ datalabels: {
+ color: darkMode ? "#E0E0E0" : "#111111",
+ anchor: "end",
+ align: "left",
+ offset: -5,
+ formatter: (value) => value.toLocaleString(),
+ font: { weight: "bold" },
+ },
+ },
+ scales: {
+ x: {
+ title: {
+ display: true,
+ text: "Number of Applications",
+ color: darkMode ? "#E0E0E0" : "#111111",
+ font: { weight: "bold", size: 14 },
+ },
+ ticks: {
+ color: darkMode ? "#E0E0E0" : "#111111",
+ },
+ grid: { color: darkMode ? "#333" : "#ddd" },
+ },
+ y: {
+ title: {
+ display: true,
+ text: "Role",
+ color: darkMode ? "#E0E0E0" : "#111111",
+ font: { weight: "bold", size: 14 },
+ },
+ ticks: {
+ color: darkMode ? "#E0E0E0" : "#111111",
+ },
+ grid: { color: darkMode ? "#333" : "#ddd" },
+ },
+ },
+ };
+
+ return (
+
+
+
+ );
+}
+
+JobAnalyticsGraph.propTypes = {
+ data: PropTypes.arrayOf(
+ PropTypes.shape({
+ role: PropTypes.string.isRequired,
+ applications: PropTypes.number,
+ count: PropTypes.number,
+ })
+ ),
+ darkMode: PropTypes.bool,
+};
+
+JobAnalyticsGraph.defaultProps = {
+ data: [],
+ darkMode: false,
+};
diff --git a/src/components/Reports/JobAnalytics/JobAnalyticsPage.module.css b/src/components/Reports/JobAnalytics/JobAnalyticsPage.module.css
new file mode 100644
index 0000000000..dec9ca2308
--- /dev/null
+++ b/src/components/Reports/JobAnalytics/JobAnalyticsPage.module.css
@@ -0,0 +1,78 @@
+.jobAnalyticsPage {
+ width: 100%;
+ max-width: 100%;
+ margin: 0 auto;
+ padding: 1rem;
+ min-width: 0;
+ border-radius: 8px;
+ transition: background-color 0.3s ease, color 0.3s ease;
+}
+
+.light {
+ background-color: #ffffff;
+ color: #111111;
+}
+
+.dark {
+ background-color: #1b2a41;
+ color: #e0e0e0;
+}
+
+/* Filters wrapper */
+.jobAnalyticsFilters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ margin-bottom: 1.25rem;
+}
+
+/* Label layout */
+.filterLabel {
+ display: flex;
+ flex-direction: column;
+ font-size: 0.92rem;
+ min-width: 180px;
+}
+
+/* Inputs & selects */
+.filterInput {
+ margin-top: 4px;
+ padding: 0.25rem;
+ border-radius: 4px;
+ border: 1px solid #ccc;
+ background-color: #ffffff;
+ color: #111111;
+}
+
+/* Dark mode overrides */
+.dark .filterLabel {
+ color: #e0e0e0;
+}
+
+.dark .filterInput {
+ background-color: #1a1a1a;
+ color: #e0e0e0;
+ border-color: #666;
+}
+
+/* Graph container */
+.graphContainer {
+ width: 100%;
+ max-width: 100%;
+ height: 70vh;
+ display: flex;
+ flex-direction: column;
+}
+
+@media (max-width: 1024px) {
+ .graphContainer {
+ height: 60vh;
+ }
+}
+
+@media (max-width: 600px) {
+ .graphContainer {
+ height: 50vh;
+ }
+}
+
diff --git a/src/components/Reports/JobAnalytics/index.js b/src/components/Reports/JobAnalytics/index.js
new file mode 100644
index 0000000000..2bebb4e51f
--- /dev/null
+++ b/src/components/Reports/JobAnalytics/index.js
@@ -0,0 +1,4 @@
+export { default as JobAnalyticsCompetitiveRolesPage } from './JobAnalyticsCompetitiveRolesPage';
+export { default as JobAnalyticsGraph } from './JobAnalyticsGraph';
+export { default as JobAnalyticsFilters } from './JobAnalyticsFilters';
+//what iexport { default as sampleData } from './dataSample';
diff --git a/src/routes.jsx b/src/routes.jsx
index 73a489e9ec..2e6b0f2bca 100644
--- a/src/routes.jsx
+++ b/src/routes.jsx
@@ -120,6 +120,8 @@ import TestEventRegistration from './components/EventRegistration/TestEventRegis
import MemberList from './components/QuestionnaireDashboard/MemberList';
import EventPopularity from './components/EventPopularity/EventPopularity';
import ApplicantVolunteerRatio from './components/ApplicantVolunteerRatio/ApplicantVolunteerRatio';
+import { JobAnalyticsCompetitiveRolesPage } from './components/Reports/JobAnalytics';
+// LB Dashboard
import LBProtectedRoute from './components/common/LBDashboard/LBProtectedRoute/LBProtectedRoute';
import LBHome from './components/LBDashboard/Home/Home';
import LBDashboard from './components/LBDashboard';
@@ -344,6 +346,12 @@ export default (
+
`${APIEndpoint}/task/updateAllParents/${wbsId}`,
MOVE_TASKS: wbsId => `${APIEndpoint}/tasks/moveTasks/${wbsId}`,
WEEKLY_SUMMARIES_REPORT: () => `${APIEndpoint}/reports/weeklysummaries`,
- WEEKLY_SUMMARIES_FILTERS:`${APIEndpoint}/weeklySummariesFilters`,
+ WEEKLY_SUMMARIES_FILTERS: `${APIEndpoint}/weeklySummariesFilters`,
WEEKLY_SUMMARIES_FILTER_BY_ID: filterId => `${APIEndpoint}/weeklySummariesFilters/${filterId}`,
WEEKLY_SUMMARIES_FILTER_REPLACE_CODES: `${APIEndpoint}/weeklySummariesFilters/replaceTeamcodes`,
WEEKLY_SUMMARIES_FILTER_REPLACE_INDIVIDUAL_CODES: `${APIEndpoint}/weeklySummariesFilters/replaceIndividualTeamcodes`,
@@ -230,7 +230,7 @@ export const ENDPOINTS = {
NON_HGN_EMAIL_SUBSCRIPTION: `${APIEndpoint}/add-non-hgn-email-subscription`,
CONFIRM_EMAIL_SUBSCRIPTION: `${APIEndpoint}/confirm-non-hgn-email-subscription`,
REMOVE_EMAIL_SUBSCRIPTION: `${APIEndpoint}/remove-non-hgn-email-subscription`,
-
+
PERMISSION_MANAGEMENT_UPDATE: () => `${APIEndpoint}/permission-management`,
// reasons endpoints
@@ -463,6 +463,29 @@ export const ENDPOINTS = {
UPDATE_SAVED_FILTERS_INDIVIDUAL_TEAM_CODE: () =>
`${APIEndpoint}/savedFilters/updateIndividualTeamCode`,
+ // Job Analytics endpoint
+ JOB_ANALYTICS_QUERY: (startDate, endDate, roles, granularity) => {
+ const params = [
+ startDate && endDate
+ ? `startDate=${encodeURIComponent(startDate)}&endDate=${encodeURIComponent(endDate)}`
+ : null,
+
+ roles && roles !== "All"
+ ? `roles=${encodeURIComponent(roles)}`
+ : null,
+
+ granularity
+ ? `granularity=${encodeURIComponent(granularity)}`
+ : null,
+ ].filter(Boolean);
+
+ const qs = params.length ? `?${params.join("&")}` : "";
+
+ return `${APIEndpoint.replace("/api", "")}/job-analytics${qs}`;
+ },
+
+ JOB_ANALYTICS_ROLES: `${APIEndpoint.replace('/api', '')}/job-analytics/roles`,
+
// pr dashboard endpoints
PROMOTION_ELIGIBILITY: `${APIEndpoint}/promotion-eligibility`,
PROMOTE_MEMBERS: `${APIEndpoint}/promote-members`,