Skip to content

Commit 4b7b9c1

Browse files
added line chart and reducers
1 parent 117f1fd commit 4b7b9c1

5 files changed

Lines changed: 400 additions & 16 deletions

File tree

src/actions/bmdashboard/injuryActions.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import axios from 'axios';
22
import { ENDPOINTS } from '../../utils/URL';
3-
3+
import {setErrors} from './reusableActions.js';
44
export const FETCH_BM_INJURY_DATA_REQUEST = 'FETCH_BM_INJURY_DATA_REQUEST';
55
export const FETCH_BM_INJURY_DATA_SUCCESS = 'FETCH_BM_INJURY_DATA_SUCCESS';
66
export const FETCH_BM_INJURY_DATA_FAILURE = 'FETCH_BM_INJURY_DATA_FAILURE';
77
export const RESET_BM_INJURY_DATA = 'RESET_BM_INJURY_DATA';
88
export const FETCH_BM_INJURY_SEVERITIES = 'FETCH_BM_INJURY_SEVERITIES';
99
export const FETCH_BM_INJURY_TYPES = 'FETCH_BM_INJURY_TYPES';
1010
export const FETCH_BM_INJURY_PROJECTS = 'FETCH_BM_INJURY_PROJECTS';
11+
export const FETCH_BM_INJURY_OVER_TIME = 'FETCH_BM_INJURY_OVER_TIME';
1112

1213
// Helpers
1314
const cleanParams = (obj = {}) => {
@@ -38,6 +39,7 @@ const setInjuryDataError = payload => ({ type: FETCH_BM_INJURY_DATA_FAILURE, pay
3839
const setInjurySeverities = payload => ({ type: FETCH_BM_INJURY_SEVERITIES, payload });
3940
const setInjuryTypes = payload => ({ type: FETCH_BM_INJURY_TYPES, payload });
4041
const setInjuryProjects = payload => ({ type: FETCH_BM_INJURY_PROJECTS, payload });
42+
const setInjuryOverTime = payload => ({ type: FETCH_BM_INJURY_OVER_TIME, payload });
4143

4244
// Thunks
4345
export const fetchInjuryData = (filters) => async dispatch => {
@@ -103,9 +105,9 @@ export const fetchInjuriesOverTime = (filters = {}) => {
103105
}
104106

105107
const res = await axios.get(ENDPOINTS.BM_INJURY_OVER_TIME, { params });
106-
//dispatch(setInjuryOverTime(res.data));
108+
dispatch(setInjuryOverTime(res.data));
107109
} catch (err) {
108-
//dispatch(setErrors(err.response?.data || err.message));
110+
dispatch(setErrors(err.response?.data?.error || err.message));
109111
}
110112
};
111113
}
Lines changed: 285 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import React, { useState, useEffect } from 'react';
1+
import React, { useState, useEffect, useMemo } from 'react';
22
import { connect, useDispatch, useSelector } from 'react-redux';
33
import {
4-
BarChart,
5-
Bar,
4+
LineChart,
5+
Line,
66
XAxis,
77
YAxis,
88
CartesianGrid,
@@ -12,37 +12,309 @@ import {
1212
ResponsiveContainer,
1313
} from 'recharts';
1414
import { Select, DatePicker, Spin } from 'antd';
15+
import dayjs from 'dayjs';
1516

16-
import axios from 'axios';
1717
import { fetchInjuriesOverTime } from '../../../actions/bmdashboard/injuryActions';
18+
import styles from './InjuriesOverTimeChart.module.css';
1819

19-
function InjuriesOverTimeChart(props) {
20+
const { Option } = Select;
21+
const { RangePicker } = DatePicker;
22+
23+
const MONTHS = [
24+
'January',
25+
'February',
26+
'March',
27+
'April',
28+
'May',
29+
'June',
30+
'July',
31+
'August',
32+
'September',
33+
'October',
34+
'November',
35+
'December',
36+
];
37+
38+
const shortId = id => (id ? String(id).slice(-6) : 'unknown');
39+
40+
const generateColors = n =>
41+
Array.from({ length: n }, (_, i) => `hsl(${Math.round((360 / Math.max(n, 1)) * i)},60%,55%)`);
42+
43+
function CustomTooltip({ active, payload, label, darkMode }) {
44+
if (!active || !payload || payload.length === 0) return null;
45+
return (
46+
<div className={`${styles.tooltip} ${darkMode ? styles.tooltipDark : ''}`}>
47+
<div style={{ fontWeight: 600, marginBottom: 6 }}>{label}</div>
48+
{payload
49+
.filter(p => p.value != null && Number(p.value) > 0)
50+
.map(p => (
51+
<div key={p.dataKey} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
52+
<span
53+
style={{
54+
width: 10,
55+
height: 10,
56+
background: p.color,
57+
display: 'inline-block',
58+
borderRadius: 2,
59+
}}
60+
/>
61+
<span>{p.name}</span>
62+
<span style={{ marginLeft: 'auto', fontWeight: 600 }}>{p.value}</span>
63+
</div>
64+
))}
65+
</div>
66+
);
67+
}
68+
69+
function InjuriesOverTimeLine({ darkMode = false }) {
2070
const dispatch = useDispatch();
71+
72+
const rawData = useSelector(state => state.bmInjury?.injuryOverTimeData || []);
73+
2174
const [loading, setLoading] = useState(false);
2275

2376
const [selProjects, setSelProjects] = useState([]);
2477
const [dateRange, setDateRange] = useState([null, null]);
2578
const [selInjTypes, setSelInjTypes] = useState([]);
2679
const [selDepts, setSelDepts] = useState([]);
27-
const [selSeverties, setSelSeverities] = useState([]);
80+
const [selSeverities, setSelSeverities] = useState([]);
2881

2982
useEffect(() => {
83+
setLoading(true);
3084
dispatch(
3185
fetchInjuriesOverTime({
3286
projectId: selProjects,
3387
date: dateRange,
3488
injuryType: selInjTypes,
3589
department: selDepts,
36-
severity: selSeverties,
90+
severity: selSeverities,
3791
}),
38-
);
39-
}, []);
92+
).finally(() => setLoading(false));
93+
}, [dispatch, selProjects, selInjTypes, selDepts, dateRange, selSeverities]);
94+
95+
const allProjects = useMemo(() => {
96+
const ids = Array.from(new Set(rawData.map(r => String(r.projectId)).filter(Boolean)));
97+
return ids.map(id => ({ id, label: `Project …${shortId(id)}` }));
98+
}, [rawData]);
99+
100+
const allDepartments = useMemo(
101+
() => Array.from(new Set(rawData.map(r => r.department).filter(Boolean))),
102+
[rawData],
103+
);
104+
105+
const allInjuryTypes = useMemo(
106+
() => Array.from(new Set(rawData.map(r => r.injuryType).filter(Boolean))),
107+
[rawData],
108+
);
109+
110+
const allSeverities = useMemo(
111+
() => Array.from(new Set(rawData.map(r => r.severity).filter(Boolean))),
112+
[rawData],
113+
);
114+
115+
const filtered = useMemo(() => {
116+
const [start, end] = dateRange || [null, null];
117+
return rawData.filter(r => {
118+
const pid = String(r.projectId);
119+
const keepProject = selProjects.length === 0 || selProjects.includes(pid);
120+
const keepDept = selDepts.length === 0 || selDepts.includes(r.department);
121+
const keepType = selInjTypes.length === 0 || selInjTypes.includes(r.injuryType);
122+
const keepSev = selSeverities.length === 0 || selSeverities.includes(r.severity);
123+
if (!r.date) return false;
124+
125+
let keepDate = true;
126+
const d = dayjs(r.date);
127+
if (start && !end) keepDate = d.isSame(start, 'day') || d.isAfter(start);
128+
if (!start && end) keepDate = d.isSame(end, 'day') || d.isBefore(end);
129+
if (start && end)
130+
keepDate =
131+
(d.isSame(start, 'day') || d.isAfter(start)) && (d.isSame(end, 'day') || d.isBefore(end));
132+
133+
return keepProject && keepDept && keepType && keepSev && keepDate;
134+
});
135+
}, [rawData, selProjects, selDepts, selInjTypes, selSeverities, dateRange]);
136+
137+
const visibleProjectIds = useMemo(
138+
() => Array.from(new Set(filtered.map(r => String(r.projectId)))),
139+
[filtered],
140+
);
141+
const visibleProjects = useMemo(
142+
() => visibleProjectIds.map(id => ({ id, label: `Project …${shortId(id)}` })),
143+
[visibleProjectIds],
144+
);
145+
146+
const chartData = useMemo(() => {
147+
const totals = new Map();
148+
filtered.forEach(r => {
149+
const monthIdx = dayjs(r.date).month();
150+
const pid = String(r.projectId);
151+
const key = `${monthIdx}|${pid}`;
152+
totals.set(key, (totals.get(key) || 0) + (Number(r.count) || 0));
153+
});
154+
155+
const rows = MONTHS.map((month, idx) => {
156+
const row = { month };
157+
visibleProjects.forEach(({ id }) => {
158+
const v = totals.get(`${idx}|${id}`) || 0;
159+
row[id] = v > 0 ? v : null;
160+
});
161+
return row;
162+
});
163+
164+
return rows;
165+
}, [filtered, visibleProjects]);
166+
167+
const lineColors = useMemo(() => generateColors(visibleProjects.length || 1), [
168+
visibleProjects.length,
169+
]);
170+
171+
const maxY = Math.max(...chartData.flatMap(row => visibleProjects.map(p => row[p.id] || 0)), 0);
172+
173+
const step = Math.ceil(maxY / 5);
174+
const ticks = Array.from({ length: 6 }, (_, i) => i * step);
40175

41176
return (
42-
<>
43-
<>InjuriesOverTimeChart</>
44-
</>
177+
<div className={`${styles.wrapper} ${darkMode ? styles.wrapperDark : ''}`}>
178+
<h2 className={styles.title}>Injuries over time</h2>
179+
180+
<div className={styles.filters}>
181+
<Select
182+
className={styles.filterSelect}
183+
mode="multiple"
184+
allowClear
185+
placeholder="Projects"
186+
value={selProjects}
187+
onChange={setSelProjects}
188+
maxTagCount="responsive"
189+
maxTagPlaceholder={o => `+${o.length}`}
190+
>
191+
{allProjects.map(p => (
192+
<Option key={p.id} value={p.id}>
193+
{p.label}
194+
</Option>
195+
))}
196+
</Select>
197+
198+
<RangePicker
199+
className={styles.filterSelect}
200+
value={dateRange}
201+
onChange={dates => setDateRange(dates || [null, null])}
202+
/>
203+
204+
<Select
205+
className={styles.filterSelect}
206+
mode="multiple"
207+
allowClear
208+
placeholder="Injury Types"
209+
value={selInjTypes}
210+
onChange={setSelInjTypes}
211+
maxTagCount="responsive"
212+
maxTagPlaceholder={o => `+${o.length}`}
213+
>
214+
{allInjuryTypes.map(t => (
215+
<Option key={t} value={t}>
216+
{t}
217+
</Option>
218+
))}
219+
</Select>
220+
221+
<Select
222+
className={styles.filterSelect}
223+
mode="multiple"
224+
allowClear
225+
placeholder="Departments"
226+
value={selDepts}
227+
onChange={setSelDepts}
228+
maxTagCount="responsive"
229+
maxTagPlaceholder={o => `+${o.length}`}
230+
>
231+
{allDepartments.map(d => (
232+
<Option key={d} value={d}>
233+
{d}
234+
</Option>
235+
))}
236+
</Select>
237+
238+
<Select
239+
className={styles.filterSelect}
240+
mode="multiple"
241+
allowClear
242+
placeholder="Severity"
243+
value={selSeverities}
244+
onChange={setSelSeverities}
245+
maxTagCount="responsive"
246+
maxTagPlaceholder={o => `+${o.length}`}
247+
>
248+
{allSeverities.map(s => (
249+
<Option key={s} value={s}>
250+
{s}
251+
</Option>
252+
))}
253+
</Select>
254+
</div>
255+
256+
{loading ? (
257+
<div style={{ textAlign: 'center', padding: 50 }}>
258+
<Spin size="large" />
259+
</div>
260+
) : visibleProjects?.length > 0 ? (
261+
<div className={`${styles.chartCard} ${darkMode ? styles.chartCardDark : ''}`}>
262+
<ResponsiveContainer width="100%" height="100%">
263+
<LineChart data={chartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
264+
<CartesianGrid strokeDasharray="3 3" strokeOpacity={darkMode ? 0.2 : 1} />
265+
<XAxis dataKey="month" height={60} angle={-25} textAnchor="end" interval={0} />
266+
<YAxis
267+
label={{ value: 'Injury Count', angle: -90, position: 'insideLeft' }}
268+
allowDecimals={false}
269+
domain={[0, maxY]}
270+
ticks={ticks}
271+
/>
272+
<Tooltip content={<CustomTooltip darkMode={darkMode} />} />
273+
<Legend verticalAlign="top" align="left" wrapperStyle={{ paddingBottom: 20 }} />
274+
{visibleProjects.map((proj, idx) => (
275+
<Line
276+
key={proj.id}
277+
type="linear"
278+
dataKey={proj.id}
279+
name={proj.label}
280+
stroke={lineColors[idx]}
281+
dot={{ r: 3 }}
282+
activeDot={{ r: 5 }}
283+
connectNulls
284+
>
285+
<LabelList
286+
position="top"
287+
content={props => {
288+
const { x, y, value } = props;
289+
if (!value || value <= 0) return null;
290+
return (
291+
<text
292+
x={x}
293+
y={y - 6}
294+
fill={lineColors[idx]}
295+
textAnchor="middle"
296+
fontSize={12}
297+
fontWeight="bold"
298+
>
299+
{value}
300+
</text>
301+
);
302+
}}
303+
/>
304+
</Line>
305+
))}
306+
</LineChart>
307+
</ResponsiveContainer>
308+
</div>
309+
) : (
310+
<p className={`${styles.noData} ${darkMode ? styles.noDataDark : ''}`}>No Data Available</p>
311+
)}
312+
</div>
45313
);
46314
}
47315

48-
export default InjuriesOverTimeChart;
316+
const mapStateToProps = state => ({
317+
darkMode: state?.theme?.darkMode,
318+
});
319+
320+
export default connect(mapStateToProps)(InjuriesOverTimeLine);

0 commit comments

Comments
 (0)