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`,