diff --git a/src/actions/bmdashboard/issueGraphActions.js b/src/actions/bmdashboard/issueGraphActions.js new file mode 100644 index 00000000000..4c67fdc1465 --- /dev/null +++ b/src/actions/bmdashboard/issueGraphActions.js @@ -0,0 +1,43 @@ +import axios from 'axios'; +import { + FETCH_ISSUES_SUMMARY_REQUEST, + FETCH_ISSUES_SUMMARY_SUCCESS, + FETCH_ISSUES_SUMMARY_FAILURE, + FETCH_ISSUES_TREND_REQUEST, + FETCH_ISSUES_TREND_SUCCESS, + FETCH_ISSUES_TREND_FAILURE, +} from '../../constants/bmdashboard/issueGraphConstants'; +import { ENDPOINTS } from '../../utils/URL'; + +export const fetchIssueSummary = (params = {}) => async dispatch => { + try{ + dispatch({ type: FETCH_ISSUES_SUMMARY_REQUEST }); + const { data } = await axios.get(ENDPOINTS.BM_ISSUES_BARGRAPH_SUMMARY, { params }); + const normalizedData = data.data ? { + total: data.data.totalIssues, + newThisWeek: data.data.newIssues, + resolved: data.data.resolvedIssues, + avgResolution: data.data.averageResolutionTimeDays, + } : {}; + dispatch({ type: FETCH_ISSUES_SUMMARY_SUCCESS, payload: normalizedData }); + }catch(error){ + dispatch({ + type: FETCH_ISSUES_SUMMARY_FAILURE, + payload: error.message || 'Failed to fetch issue summary', + }); + } +} + +export const fetchIssueTrend = (params = {}) => async dispatch => { + try{ + dispatch({ type: FETCH_ISSUES_TREND_REQUEST }); + const { data } = await axios.get(ENDPOINTS.BM_ISSUES_BARGRAPH_TREND, { params }); + const trendData = data.data || []; + dispatch({ type: FETCH_ISSUES_TREND_SUCCESS, payload: trendData }); + }catch(error){ + dispatch({ + type: FETCH_ISSUES_TREND_FAILURE, + payload: error.message || 'Failed to fetch issue trend', + }); + } +}; \ No newline at end of file diff --git a/src/components/BMDashboard/Issues/IssueGraph.jsx b/src/components/BMDashboard/Issues/IssueGraph.jsx new file mode 100644 index 00000000000..a66966901f7 --- /dev/null +++ b/src/components/BMDashboard/Issues/IssueGraph.jsx @@ -0,0 +1,244 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import styles from './IssueGraph.module.css'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, + LabelList, +} from 'recharts'; +import { fetchIssueSummary, fetchIssueTrend } from '../../../actions/bmdashboard/issueGraphActions'; + +// Constants and Static Helpers +const TWELVE_WEEKS_IN_MS = 12 * 7 * 24 * 60 * 60 * 1000; +const today = new Date(); +const formattedDate = date => date.toISOString().split('T')[0]; +const maxEndDate = formattedDate(today); +const minStartDate = formattedDate(new Date(today.getTime() - TWELVE_WEEKS_IN_MS)); + +const NO_DATA_STYLE = { color: '#ff4d4f' }; +const CHART_MARGIN = { top: 20, right: 20, left: 0, bottom: 30 }; + +// Isolated Validation Helper to reduce Cognitive Complexity below 15 +const getDatesValidationError = (startDate, endDate) => { + if (!startDate || !endDate) { + return 'Please select both a Start Date and an End Date.'; + } + if (startDate === endDate) { + return 'Start date and End date cannot be the same day. Please select a range.'; + } + if (new Date(startDate) > new Date(endDate)) { + return 'Start date must be before the End date.'; + } + return ''; +}; + +function IssueGraph() { + const dispatch = useDispatch(); + const darkMode = useSelector(state => state.theme.darkMode); + const { loading, issueSummary, issueTrend, error } = useSelector(state => state.issueGraph); + + const [weeks, setWeeks] = useState(8); + const [graphData, setGraphData] = useState([]); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [validationError, setValidationError] = useState(''); + + const maxStartDate = endDate || maxEndDate; + const minEndDate = startDate || minStartDate; + + const chartTheme = useMemo( + () => ({ + gridStroke: darkMode ? '#3a4a5a' : '#e0e0e0', + tickColor: darkMode ? '#ffffff' : '#666', + cursorFill: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.05)', + tooltipContent: { + backgroundColor: darkMode ? '#253342' : '#fff', + border: '1px solid #555', + color: darkMode ? '#fff' : '#000', + }, + tooltipText: { color: darkMode ? '#fff' : '#000' }, + createdBarFill: darkMode ? '#4fc3f7' : '#007bff', + resolvedBarFill: darkMode ? '#81c784' : '#28a745', + labelFill: darkMode ? '#ffffff' : '#000000', + }), + [darkMode], + ); + + useEffect(() => { + if (weeks) { + dispatch(fetchIssueSummary({ weeks })); + dispatch(fetchIssueTrend({ weeks })); + } + }, [dispatch, weeks]); + + useEffect(() => { + if (Array.isArray(issueTrend)) { + const sortedData = [...issueTrend].sort( + (a, b) => new Date(a.week).getTime() - new Date(b.week).getTime(), + ); + setGraphData(sortedData); + } + }, [issueTrend]); + + const handleWeeksChange = e => { + const val = Number.parseInt(e.target.value, 10); + setWeeks(Number.isNaN(val) ? '' : val); + setStartDate(''); + setEndDate(''); + setValidationError(''); + }; + + const handleStartDateChange = e => { + setStartDate(e.target.value); + setValidationError(''); + }; + + const handleEndDateChange = e => { + setEndDate(e.target.value); + setValidationError(''); + }; + + const handleGoClick = () => { + const errorMsg = getDatesValidationError(startDate, endDate); + if (errorMsg) { + setValidationError(errorMsg); + return; + } + + setValidationError(''); + setWeeks(''); + dispatch(fetchIssueTrend({ start: startDate, end: endDate })); + dispatch(fetchIssueSummary({ start: startDate, end: endDate })); + }; + + return ( +
+
+
+
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+
+ + {validationError &&

{validationError}

} + + {issueSummary && ( +
+
+ + The total number of issues reported within the selected date range. + +

Total Issues

+

{issueSummary.total}

+
+
+ + The number of new issues created within the selected date range. + +

New Issues

+

{issueSummary.newThisWeek}

+
+
+ + The number of issues marked as resolved within the selected date range. + +

Resolved Issues

+

{issueSummary.resolved}

+
+
+ + The average time it takes to resolve an issue within the selected date range. + +

Avg. Resolution Time

+

{issueSummary.avgResolution} days

+
+
+ )} + +
+

Issues Created vs. Resolved

+ {loading &&

Loading...

} + {error && ( +

+ {error} +

+ )} + {graphData.length > 0 ? ( + + + + + + + + + + + + + + + + ) : ( + !loading && + !error && ( +

+ No issue data found for the selected timeframe. +

+ ) + )} +
+
+
+ ); +} + +export default IssueGraph; diff --git a/src/components/BMDashboard/Issues/IssueGraph.module.css b/src/components/BMDashboard/Issues/IssueGraph.module.css new file mode 100644 index 00000000000..1e222cd86fe --- /dev/null +++ b/src/components/BMDashboard/Issues/IssueGraph.module.css @@ -0,0 +1,298 @@ +.issueGraphPage { + min-height: 100vh; + width: 100%; + background-color: #f0f4f8; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 40px 0; + box-sizing: border-box; + overflow-y: auto; +} + +.issueGraphEventContainer { + max-width: 1200px; + width: 100%; + margin: 0 24px; + padding: 24px 24px 40px; + background: #f9f9f9; + border-radius: 8px; + box-shadow: 0 2px 8px rgb(0 0 0 / 10%); + display: flex; + flex-direction: column; + gap: 20px; + box-sizing: border-box; + max-height: calc(100vh - 80px); + overflow: auto; +} + +.graphWrapper { + display: flex; + flex-direction: column; + justify-content: center; + align-items: stretch; + height: 420px; + min-height: 300px; + padding: 20px; +} + +.graphWrapper .recharts-responsive-container { + width: 100%; + height: 100%; +} + + +.filterRow { + display: flex; + justify-content: space-between; + align-items: flex-end; + background: #fff; + padding: 16px 20px; + border-radius: 8px; + box-shadow: 0 4px 6px rgb(0 0 0 / 10%); + margin-bottom: 5px; + flex-wrap: wrap; + gap: 20px; +} + +.filterGroup { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 200px; + flex: 1; +} + +.filterRow input, +.filterRow select { + padding: 7px 10px; + border-radius: 4px; + border: 1px solid #ccc; + font-size: 0.9rem; + height: 38px; + box-sizing: border-box; + width: 100%; + appearance: none; +} + +.goButton { + background-color: #0069d9; + color: white; + padding: 7px 14px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + height: 38px; + transition: background-color 0.2s ease; + white-space: nowrap; +} + +.inputWithButton { + display: flex; + align-items: center; + gap: 10px; +} + +.goButton:hover { + background-color: #0056b3; +} + +.goButton:focus { + outline: 2px solid #0056b3; + outline-offset: 2px; +} + +.tileRow { + display: flex; + justify-content: space-between; + align-items: stretch; + gap: 20px; + margin-bottom: 5px; + flex-wrap: nowrap; + padding: 15px 30px; +} + +.tile { + position: relative; + flex: 1; + background: #fff; + padding: 20px; + border-radius: 8px; + box-shadow: 0 4px 6px rgb(0 0 0 / 10%); + text-align: center; + min-width: 0; + cursor: help; +} + +.tileTooltip { + visibility: hidden; + width: 200px; + background-color: #333; + color: #fff; + text-align: center; + border-radius: 6px; + padding: 8px; + position: absolute; + z-index: 1; + bottom: 110%; + left: 50%; + transform: translateX(-50%); + opacity: 0; + transition: opacity 0.3s; + font-size: 0.8rem; + font-weight: normal; + line-height: 1.4; + pointer-events: none; + box-shadow: 0 2px 4px rgb(0 0 0 / 20%); +} + +.darkMode .tileTooltip { + background-color: #2b3e59; + border: 1px solid #30b8f8; + color: #fff; +} + +.tileTooltip::after { + content: ""; + position: absolute; + top: 100%; + left: 50%; + margin-left: -5px; + border-width: 5px; + border-style: solid; + border-color: #333 transparent transparent; +} + +.tile:hover .tileTooltip { + visibility: visible; + opacity: 1; +} + +.darkMode .tileTooltip::after { + border-color: #30b8f8 transparent transparent; + top: calc(100% + 1px); +} + +.tile h3 { + margin-bottom: 10px; + font-size: 18px; + color: #333; +} + +.tile p { + font-size: 24px; + font-weight: bold; + color: #000; +} + +@media (width <= 768px) { + .tileRow { + flex-wrap: wrap; + justify-content: center; + } + + .tile { + min-width: 200px; + flex: 1 1 auto; + } +} + +/* dark mode */ +.darkMode { + background-color: #1b2a41; + color: #f5f5f5; +} + +.darkMode .issueGraphPage { + background-color: #1b2a41; + color: #f5f5f5; +} + +.darkMode .issueGraphEventContainer { + background: #253342; + color: #f5f5f5; +} + +.darkMode .filterRow, +.darkMode .tileRow, +.darkMode .graphWrapper { + background-color: #1b2a41; + color: #f5f5f5; +} + +/* Filter groups */ +.darkMode .filterGroup label { + color: #f5f5f5; +} + +.darkMode .filterRow label { + background-color: transparent; + color: #f5f5f5; + border: none; +} + +.darkMode input[type='date'], +.darkMode select, +.darkMode .goButton { + background-color: #253342; + color: #f5f5f5; + border: 1px solid #555; +} + +.darkMode input[type='date'] { + color-scheme: dark; +} + +/* Tiles */ +.darkMode .tile { + background-color: #2b3e59; + color: #f5f5f5; + border: 1px solid #30b8f8; +} + +.darkMode .tile h3 { + color: #fff; +} + +.darkMode .graphWrapper h2 { + color: #fff; +} + +.darkMode .recharts-cartesian-axis-tick-value { + fill: #fff; +} + +.darkMode .recharts-legend-item-text { + fill: #fff; +} + +.darkMode .goButton :hover { + background-color: #1a73e8; +} + +.darkMode .goButton :focus { + outline: 2px solid #1a73e8; + outline-offset: 2px; +} + +.issueGraphEventContainer .errorText { + color: #ff4d4f; + margin: 10px 0; + font-weight: 500; +} + +.darkMode .goButton { + background-color: #0069d9; + color: #fff; + border: none; +} + +.darkMode .goButton:hover { + background-color: #0056b3; +} + +.darkMode .goButton:focus { + outline: 2px solid #0056b3; + outline-offset: 2px; +} \ No newline at end of file diff --git a/src/components/CommunityPortal/Activities/ActivityList.jsx b/src/components/CommunityPortal/Activities/ActivityList.jsx index aa8d9b4b410..4d698de32c0 100644 --- a/src/components/CommunityPortal/Activities/ActivityList.jsx +++ b/src/components/CommunityPortal/Activities/ActivityList.jsx @@ -318,22 +318,28 @@ function ActivityList() { {loading &&

Loading activities...

} {!loading && hasActivities && ( -