diff --git a/src/actions/bmdashboard/injuryActions.js b/src/actions/bmdashboard/injuryActions.js index f8922c33a2..d2452af218 100644 --- a/src/actions/bmdashboard/injuryActions.js +++ b/src/actions/bmdashboard/injuryActions.js @@ -8,6 +8,8 @@ export const RESET_BM_INJURY_DATA = 'RESET_BM_INJURY_DATA'; export const FETCH_BM_INJURY_SEVERITIES = 'FETCH_BM_INJURY_SEVERITIES'; export const FETCH_BM_INJURY_TYPES = 'FETCH_BM_INJURY_TYPES'; export const FETCH_BM_INJURY_PROJECTS = 'FETCH_BM_INJURY_PROJECTS'; +export const FETCH_BM_INJURY_TREND_SUCCESS = 'FETCH_BM_INJURY_TREND_SUCCESS'; +export const CREATE_BM_INJURY_SUCCESS = 'CREATE_BM_INJURY_SUCCESS'; // Helpers const cleanParams = (obj = {}) => { @@ -38,6 +40,8 @@ const setInjuryDataError = payload => ({ type: FETCH_BM_INJURY_DATA_FAILURE, pay const setInjurySeverities = payload => ({ type: FETCH_BM_INJURY_SEVERITIES, payload }); const setInjuryTypes = payload => ({ type: FETCH_BM_INJURY_TYPES, payload }); const setInjuryProjects = payload => ({ type: FETCH_BM_INJURY_PROJECTS, payload }); +export const setInjuryTrendSuccess = payload => ({ type: FETCH_BM_INJURY_TREND_SUCCESS, payload }); +export const setCreateInjurySuccess = payload => ({ type: CREATE_BM_INJURY_SUCCESS, payload }); // Thunks export const fetchInjuryData = (filters) => async dispatch => { @@ -80,3 +84,30 @@ export const fetchInjuryProjects = (filters) => async dispatch => { } }; +// Trend data: { months:[], serious:[], medium:[], low:[] } +export const fetchInjuryTrend = (filters) => async dispatch => { + dispatch(setInjuryDataLoading()); + try { + const params = cleanParams(filters); + const res = await axios.get(ENDPOINTS.BM_INJURY_TREND, { params, paramsSerializer }); + const data = res?.data && typeof res.data === 'object' ? res.data : { months: [], serious: [], medium: [], low: [] }; + dispatch(setInjuryTrendSuccess(data)); + } catch (error) { + const msg = error?.response?.data?.error || error?.message || 'Failed to fetch injury trend'; + dispatch(setInjuryDataError(msg)); + } +}; + +// Create injuries via API +export const createInjuries = (payload, { useDevSeed = false } = {}) => async dispatch => { + try { + const url = useDevSeed ? ENDPOINTS.BM_INJURY_DEV_SEED : ENDPOINTS.BM_INJURY_CREATE; + const res = await axios.post(url, payload); + dispatch(setCreateInjurySuccess(res?.data)); + return res?.data; + } catch (error) { + const msg = error?.response?.data?.error || error?.message || 'Failed to create injuries'; + throw new Error(msg); + } +}; + diff --git a/src/components/BMDashboard/InjuryTrendChart/InjuryTrendChart.css b/src/components/BMDashboard/InjuryTrendChart/InjuryTrendChart.css new file mode 100644 index 0000000000..0cc771649c --- /dev/null +++ b/src/components/BMDashboard/InjuryTrendChart/InjuryTrendChart.css @@ -0,0 +1,122 @@ +.injury-dark-body { + background-color: #1B2A41 !important; +} + +.injury-trend-container { + width: 100%; + height: 100%; +} + +.injury-trend-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.injury-trend-title { + margin: 10px; + font-size: 20px; +} + +.injury-trend-filters { + display: grid; + grid-template-columns: repeat(2, minmax(240px, 1fr)); + gap: 12px; + align-items: end; +} + +.injury-trend-filters .filter-item { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1 1 0%; +} + +/* Scoped input/select overrides ONLY for injury chart */ +.injury-trend-container .filter-item input, +.injury-trend-container .filter-item select { + padding: 0 12px; + border: 1px solid #ddd; + border-radius: 8px; + width: 100%; + font-size: 1rem; + background: #ffffff; + transition: all 0.3s ease; +} + +.injury-trend-chart-wrapper { + width: 100%; + height: 400px; +} + +/* (Reverted) legend chip styles removed */ + +.injury-trend-container.darkMode .injury-select__control { + background-color: #222; + color: #ddd; + border-color: #2d3a4d; + min-height: 38px; +} + +.injury-trend-container.darkMode .injury-select__single-value { + color: #ddd; +} + +.injury-trend-container.darkMode .injury-select__menu { + background-color: #222; + color: #ddd; +} + +/* dark page background for entire container */ +.injury-trend-container.darkMode { + background-color: #1B2A41; + color: #cfd7e3; +} + +.injury-trend-container.darkMode .injury-trend-title { color: #cfd7e3; } + +/* Ensure project select and datepicker are equal width */ +.injury-select__control, +.injury-datepicker { + width: 100%; + min-height: 38px; +} + +/* Dark mode styling for the native datepicker input */ +.injury-trend-container.darkMode .injury-datepicker { + background-color: #222 !important; + color: #ddd !important; + border: 1px solid #2d3a4d !important; +} + +.injury-trend-container.darkMode .injury-datepicker::placeholder { + color: #bbb !important; +} + +/* Dark mode version of scoped input/select overrides */ +.injury-trend-container.darkMode .filter-item input, +.injury-trend-container.darkMode .filter-item select { + background: #222; + color: #ddd; + border-color: #2d3a4d; +} + +/* react-datepicker popup in dark mode */ +.injury-trend-container.darkMode .react-datepicker { + background-color: #222; + color: #ddd; + border: 1px solid #2d3a4d; +} +.injury-trend-container.darkMode .react-datepicker__header { + background-color: #2a3446; + border-bottom: 1px solid #2d3a4d; +} +.injury-trend-container.darkMode .react-datepicker__day-name, +.injury-trend-container.darkMode .react-datepicker__day, +.injury-trend-container.darkMode .react-datepicker__current-month { + color: #ddd; +} + + diff --git a/src/components/BMDashboard/InjuryTrendChart/InjuryTrendChart.jsx b/src/components/BMDashboard/InjuryTrendChart/InjuryTrendChart.jsx new file mode 100644 index 0000000000..15c18feec6 --- /dev/null +++ b/src/components/BMDashboard/InjuryTrendChart/InjuryTrendChart.jsx @@ -0,0 +1,232 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, + LabelList, +} from 'recharts'; +import Select from 'react-select'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import { fetchInjuryProjects, fetchInjuryTrend } from '../../../actions/bmdashboard/injuryActions'; +import './InjuryTrendChart.css'; + +const toYMD = d => + d instanceof Date && !isNaN(d) + ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String( + d.getDate(), + ).padStart(2, '0')}` + : ''; + +function InjuryTrendChart() { + const dispatch = useDispatch(); + const darkMode = useSelector(state => state.theme?.darkMode); + const { + projects = [], + trend = { months: [], serious: [], medium: [], low: [] }, + loading, + error, + } = useSelector(state => state.bmInjury || {}); + + const [selectedProject, setSelectedProject] = useState(null); + const [dateRange, setDateRange] = useState([null, null]); + const [startDate, endDate] = dateRange; + + useEffect(() => { + dispatch(fetchInjuryProjects({})); + }, [dispatch]); + + useEffect(() => { + const params = { + projectId: selectedProject?.value || '', + startDate: toYMD(startDate), + endDate: toYMD(endDate), + }; + dispatch(fetchInjuryTrend(params)); + }, [dispatch, selectedProject, startDate, endDate]); + + // Make the page background full-width and dark on this page only + useEffect(() => { + if (darkMode) { + document.body.classList.add('injury-dark-body'); + } else { + document.body.classList.remove('injury-dark-body'); + } + return () => { + document.body.classList.remove('injury-dark-body'); + }; + }, [darkMode]); + + const projectOptions = useMemo(() => { + return (projects || []).map(p => ({ value: String(p._id), label: p.name })); + }, [projects]); + + // Transform backend series into recharts consumable array + const chartData = useMemo(() => { + let months = Array.isArray(trend.months) ? trend.months : []; + const serious = Array.isArray(trend.serious) ? trend.serious : []; + const medium = Array.isArray(trend.medium) ? trend.medium : []; + const low = Array.isArray(trend.low) ? trend.low : []; + + // Fallback: if API returns no months, synthesize last 12 months so UI isn't blank + if (months.length === 0) { + const names = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + const now = new Date(); + const list = []; + for (let i = 11; i >= 0; i -= 1) { + const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - i, 1)); + list.push(names[d.getUTCMonth()]); + } + months = list; + } + + return months.map((m, i) => ({ + month: m, + serious: Number(serious[i]) || 0, + medium: Number(medium[i]) || 0, + low: Number(low[i]) || 0, + })); + }, [trend]); + + const tickColor = darkMode ? '#ccc' : '#666'; + const hasAnyData = useMemo(() => { + const s = Array.isArray(trend.serious) ? trend.serious : []; + const m = Array.isArray(trend.medium) ? trend.medium : []; + const l = Array.isArray(trend.low) ? trend.low : []; + const sum = [...s, ...m, ...l].reduce((acc, v) => acc + (Number(v) || 0), 0); + return sum > 0; + }, [trend]); + + return ( +