diff --git a/src/actions/bmdashboard/issueChartActions.js b/src/actions/bmdashboard/issueChartActions.js index 961a2c41e9..0e7228c9aa 100644 --- a/src/actions/bmdashboard/issueChartActions.js +++ b/src/actions/bmdashboard/issueChartActions.js @@ -6,6 +6,12 @@ import { FETCH_ISSUE_TYPES_YEARS_REQUEST, FETCH_ISSUE_TYPES_YEARS_SUCCESS, FETCH_ISSUE_TYPES_YEARS_FAILURE, + FETCH_LONGEST_OPEN_ISSUES_REQUEST, + FETCH_LONGEST_OPEN_ISSUES_SUCCESS, + FETCH_LONGEST_OPEN_ISSUES_FAILURE, + FETCH_MOST_EXPENSIVE_ISSUES_REQUEST, + FETCH_MOST_EXPENSIVE_ISSUES_SUCCESS, + FETCH_MOST_EXPENSIVE_ISSUES_FAILURE } from '../../constants/bmdashboard/issueConstants'; import { ENDPOINTS } from '../../utils/URL'; // Import the endpoints @@ -52,3 +58,77 @@ export const fetchIssueTypesAndYears = () => async dispatch => { }); } }; + +const formatFilters = (filters) => { + const { projectIds, startDate, endDate } = filters; + const formatted = {}; + + // Only add projectIds if it's a non-empty string or non-empty array + if ( + (typeof projectIds === 'string' && projectIds.trim() !== '') || + (Array.isArray(projectIds) && projectIds.length > 0) + ) { + formatted.projectIds = Array.isArray(projectIds) + ? projectIds.join(',') + : projectIds.trim(); + } + + if (startDate !== undefined) { + formatted.startDate = startDate; + } + + if (endDate !== undefined) { + formatted.endDate = endDate; + } + return formatted; +}; + +export const fetchLongestOpenIssues = (filters) => async (dispatch) => { + try { + dispatch({ type: FETCH_LONGEST_OPEN_ISSUES_REQUEST }); + + const formattedFilters = formatFilters(filters); + + const response = await axios.get(ENDPOINTS.BM_LONGEST_OPEN_ISSUES, { params: formattedFilters }); +// console.log( +// "DEBUG - filters --> ",formattedFilters +// ) + if (!response.data) { + throw new Error('Empty response from server'); + } + + dispatch({ + type: FETCH_LONGEST_OPEN_ISSUES_SUCCESS, + payload: response.data + }); + } catch (error) { + dispatch({ + type: FETCH_LONGEST_OPEN_ISSUES_FAILURE, + payload: error.message || 'Failed to fetch longest open issues' + }); + } +}; + +export const fetchMostExpensiveIssues = (filters) => async (dispatch) => { + try { + dispatch({ type: FETCH_MOST_EXPENSIVE_ISSUES_REQUEST }); + + const formattedFilters = formatFilters(filters); + + const response = await axios.get(ENDPOINTS.BM_MOST_EXPENSIVE_ISSUES, { params: formattedFilters }); + + if (!response.data) { + throw new Error('Empty response from server'); + } + + dispatch({ + type: FETCH_MOST_EXPENSIVE_ISSUES_SUCCESS, + payload: response.data + }); + } catch (error) { + dispatch({ + type: FETCH_MOST_EXPENSIVE_ISSUES_FAILURE, + payload: error.message || 'Failed to fetch most expensive issues', + }); + } +}; diff --git a/src/components/BMDashboard/Issues/IssueCharts.module.css b/src/components/BMDashboard/Issues/IssueCharts.module.css new file mode 100644 index 0000000000..2f26dd4778 --- /dev/null +++ b/src/components/BMDashboard/Issues/IssueCharts.module.css @@ -0,0 +1,65 @@ +.dark { + background: #2b3e59; + color: white; +} +.dark h2 { + color: white +} +.container { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + flex-wrap: wrap; + align-items: center; + padding:1rem; +} + +.inputGroup { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.inputGroup label { + font-size: 0.75rem; + font-weight: 500; +} + +.inputGroup input, +.inputGroup select { + padding: 0.25rem 0.5rem; + border: 1px solid #ced4da; + border-radius: 0.25rem; + font-size: 0.75rem; +} + +.dateInputs { + display: flex; + gap: 0.5rem; +} + +.dateInputs input { + max-width: 120px; +} + +.noData { + text-align: center; + padding: 2rem; + color: #6c757d; + font-style: italic; +} + +.chartContainer { + width: 100%; + margin: 0; +} + +.dateDark { + background: #2b3e59; + color: white; +} + +.selectDark{ + background: #2b3e59; + color: white; +} diff --git a/src/components/BMDashboard/Issues/LongestOpenIssuesChart.jsx b/src/components/BMDashboard/Issues/LongestOpenIssuesChart.jsx new file mode 100644 index 0000000000..03c2e373c9 --- /dev/null +++ b/src/components/BMDashboard/Issues/LongestOpenIssuesChart.jsx @@ -0,0 +1,215 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { Bar } from 'react-chartjs-2'; +import ChartDataLabels from 'chartjs-plugin-datalabels'; +import DatePicker from 'react-datepicker'; +import styles from './IssueCharts.module.css'; + +// Import Redux actions +import { + fetchLongestOpenIssues, + fetchMostExpensiveIssues, +} from '../../../actions/bmdashboard/issueChartActions'; + +function IssuesCharts({ bmProjects = [] }) { + const dispatch = useDispatch(); + + const [graphType, setGraphType] = useState('Longest Open'); + const [selectedProject, setSelectedProject] = useState('all'); + const [dateRange, setDateRange] = useState({ start: '', end: '' }); + + const { longestOpenIssues = [], mostExpensiveIssues = [] } = useSelector( + state => state.issue || {}, + ); + const darkMode = useSelector(state => state.theme.darkMode); + + const formatFilters = ({ projectIds, startDate, endDate }) => { + const formatted = {}; + if ( + (typeof projectIds === 'string' && projectIds.trim() !== '') || + (Array.isArray(projectIds) && projectIds.length > 0) + ) { + formatted.projectIds = Array.isArray(projectIds) ? projectIds.join(',') : projectIds.trim(); + } + if (startDate !== undefined && startDate !== '') { + formatted.startDate = startDate; + } + if (endDate !== undefined && endDate !== '') { + formatted.endDate = endDate; + } + return formatted; + }; + useEffect(() => { + const params = formatFilters({ + projectIds: selectedProject === 'all' ? undefined : selectedProject, + startDate: dateRange.start, + endDate: dateRange.end, + }); + + if (graphType === 'Longest Open') { + dispatch(fetchLongestOpenIssues(params)); + } else { + dispatch(fetchMostExpensiveIssues(params)); + } + }, [graphType, selectedProject, dateRange.start, dateRange.end, dispatch, darkMode]); + + const chartData = graphType === 'Longest Open' ? longestOpenIssues : mostExpensiveIssues; + + const data = { + labels: chartData.map(issue => issue.title || issue.IssueId), + datasets: [ + { + label: graphType === 'Longest Open' ? 'Days Open' : 'Total Cost ($)', + data: chartData.map(issue => + graphType === 'Longest Open' ? issue.daysOpen : issue.totalCost, + ), + backgroundColor: + graphType === 'Longest Open' ? 'rgba(54, 162, 235, 0.7)' : 'rgba(255, 99, 132, 0.7)', + borderColor: + graphType === 'Longest Open' ? 'rgba(54, 162, 235, 1)' : 'rgba(255, 99, 132, 1)', + borderWidth: 1, + }, + ], + }; + + const options = useMemo( + () => ({ + indexAxis: 'y', + responsive: true, + plugins: { + legend: { display: false }, + datalabels: { + anchor: 'end', + align: 'right', + formatter: value => (graphType === 'Longest Open' ? `${value} days` : `$${value}`), + color: darkMode ? '#fff' : '#000', + font: { weight: 'bold' }, + }, + title: { + display: true, + text: + graphType === 'Longest Open' + ? `Longest Open Issues${ + selectedProject !== 'all' + ? ` (${bmProjects.find(p => p._id === selectedProject)?.name || ''})` + : '' + }` + : `Most Expensive Issues${ + selectedProject !== 'all' + ? ` (${bmProjects.find(p => p._id === selectedProject)?.name || ''})` + : '' + }`, + font: { size: 12 }, + color: darkMode ? '#fff' : '#000', + }, + }, + scales: { + x: { + title: { + display: true, + text: graphType === 'Longest Open' ? 'Days Open' : 'Total Cost ($)', + font: { size: 12 }, + color: darkMode ? '#fff' : '#000', + }, + ticks: { + color: darkMode ? '#ccc' : '#333', + }, + }, + y: { + title: { + display: true, + text: 'Issue Title', + font: { size: 12 }, + color: darkMode ? '#fff' : '#000', + }, + ticks: { + color: darkMode ? '#ccc' : '#333', + }, + }, + }, + elements: { + bar: { + borderRadius: 4, + borderSkipped: false, + }, + }, + }), + [graphType, selectedProject, darkMode, bmProjects], + ); + + const handleDateChange = (dateName, dateValue) => { + setDateRange({ ...dateRange, [dateName]: dateValue }); + }; + + return ( +
+

Issue Chart

+
+
+
+ {/* */} + + handleDateChange('start', value)} + className={darkMode ? styles.dateDark : ''} + /> +
+ to +
+ {/* */} + + handleDateChange('end', value)} + className={darkMode ? styles.dateDark : ''} + /> +
+
+
+ {/* */} + +
+
+ {/* */} + +
+
+
+ {chartData.length > 0 ? ( + + ) : ( +

No issues found.

+ )} +
+
+ ); +} + +export default IssuesCharts; diff --git a/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.jsx b/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.jsx index c987dba2c6..6eedce6c48 100644 --- a/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.jsx +++ b/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.jsx @@ -4,7 +4,10 @@ import { useDispatch, useSelector } from 'react-redux'; import { v4 as uuidv4 } from 'uuid'; import WeeklyProjectSummaryHeader from './WeeklyProjectSummaryHeader'; import { fetchAllMaterials } from '../../../actions/bmdashboard/materialsActions'; + +import { fetchBMProjects } from '../../../actions/bmdashboard/projectActions'; import QuantityOfMaterialsUsed from './QuantityOfMaterialsUsed/QuantityOfMaterialsUsed'; +import IssuesCharts from '../Issues/LongestOpenIssuesChart'; const projectStatusButtons = [ { @@ -131,6 +134,19 @@ export default function WeeklyProjectSummary() { })); }; + const bmProjects = useSelector(state => state.bmProjects || []); + + // Fetch initial data + useEffect(() => { + if (materials.length === 0) { + dispatch(fetchAllMaterials()); + } + + if (bmProjects.length === 0) { + dispatch(fetchBMProjects()); + } + }, [dispatch, materials.length, bmProjects.length]); + const sections = useMemo( () => [ { @@ -187,7 +203,7 @@ export default function WeeklyProjectSummary() { title: 'Issue Tracking', key: 'Issue Tracking', className: 'small', - content:
📊 Card
, + content: , }, { title: 'Tools and Equipment Tracking', diff --git a/src/constants/bmdashboard/issueConstants.js b/src/constants/bmdashboard/issueConstants.js index 08b118095e..0d22cecd85 100644 --- a/src/constants/bmdashboard/issueConstants.js +++ b/src/constants/bmdashboard/issueConstants.js @@ -6,3 +6,11 @@ export const FETCH_ISSUES_BARCHART_FAILURE = 'FETCH_ISSUES_BARCHART_FAILURE'; export const FETCH_ISSUE_TYPES_YEARS_REQUEST = 'FETCH_ISSUE_TYPES_YEARS_REQUEST'; export const FETCH_ISSUE_TYPES_YEARS_SUCCESS = 'FETCH_ISSUE_TYPES_YEARS_SUCCESS'; export const FETCH_ISSUE_TYPES_YEARS_FAILURE = 'FETCH_ISSUE_TYPES_YEARS_FAILURE'; + +export const FETCH_LONGEST_OPEN_ISSUES_REQUEST = 'FETCH_LONGEST_OPEN_ISSUES_REQUEST'; +export const FETCH_LONGEST_OPEN_ISSUES_SUCCESS = 'FETCH_LONGEST_OPEN_ISSUES_SUCCESS'; +export const FETCH_LONGEST_OPEN_ISSUES_FAILURE = 'FETCH_LONGEST_OPEN_ISSUES_FAILURE'; + +export const FETCH_MOST_EXPENSIVE_ISSUES_REQUEST = 'FETCH_MOST_EXPENSIVE_ISSUES_REQUEST'; +export const FETCH_MOST_EXPENSIVE_ISSUES_SUCCESS = 'FETCH_MOST_EXPENSIVE_ISSUES_SUCCESS'; +export const FETCH_MOST_EXPENSIVE_ISSUES_FAILURE = 'FETCH_MOST_EXPENSIVE_ISSUES_FAILURE'; diff --git a/src/reducers/bmdashboard/issueReducer.js b/src/reducers/bmdashboard/issueReducer.js index 91fd36fd57..0ea0edc3ae 100644 --- a/src/reducers/bmdashboard/issueReducer.js +++ b/src/reducers/bmdashboard/issueReducer.js @@ -5,6 +5,12 @@ import { FETCH_ISSUE_TYPES_YEARS_REQUEST, FETCH_ISSUE_TYPES_YEARS_SUCCESS, FETCH_ISSUE_TYPES_YEARS_FAILURE, + FETCH_LONGEST_OPEN_ISSUES_REQUEST, + FETCH_LONGEST_OPEN_ISSUES_SUCCESS, + FETCH_LONGEST_OPEN_ISSUES_FAILURE, + FETCH_MOST_EXPENSIVE_ISSUES_REQUEST, + FETCH_MOST_EXPENSIVE_ISSUES_SUCCESS, + FETCH_MOST_EXPENSIVE_ISSUES_FAILURE, } from '../../constants/bmdashboard/issueConstants'; const initialState = { @@ -12,6 +18,8 @@ const initialState = { issues: [], issueTypes: [], // Store for issue types years: [], // Store for years + longestOpenIssues: [], // Store for longest open issues + mostExpensiveIssues: [], // Store for most expensive issues error: null, }; @@ -37,6 +45,21 @@ const issueReducer = (state = initialState, action) => { }; case FETCH_ISSUE_TYPES_YEARS_FAILURE: return { ...state, loading: false, error: action.payload }; + // Cases for longest open issues + case FETCH_LONGEST_OPEN_ISSUES_REQUEST: + return { ...state, loading: true, error: null }; + case FETCH_LONGEST_OPEN_ISSUES_SUCCESS: + return { ...state, loading: false, longestOpenIssues: action.payload.data }; + case FETCH_LONGEST_OPEN_ISSUES_FAILURE: + return { ...state, loading: false, error: action.payload }; + + // Cases for most expensive issues + case FETCH_MOST_EXPENSIVE_ISSUES_REQUEST: + return { ...state, loading: true, error: null }; + case FETCH_MOST_EXPENSIVE_ISSUES_SUCCESS: + return { ...state, loading: false, mostExpensiveIssues: action.payload.data }; + case FETCH_MOST_EXPENSIVE_ISSUES_FAILURE: + return { ...state, loading: false, error: action.payload }; default: return state; diff --git a/src/reducers/index.js b/src/reducers/index.js index f4922911b4..9366c223d3 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -57,6 +57,7 @@ import { totalOrgSummaryReducer } from './totalOrgSummaryReducer'; import { allUsersTimeEntriesReducer } from './allUsersTimeEntriesReducer'; import HGNFormReducer from './hgnFormReducers'; import { weeklyProjectSummaryReducer } from './bmdashboard/weeklyProjectSummaryReducer'; +import issueReducer from './bmdashboard/issueReducer'; const localReducers = { auth: authReducer, @@ -107,6 +108,7 @@ const localReducers = { bmReusables: reusablesReducer, dashboard: dashboardReducer, weeklyProjectSummary: weeklyProjectSummaryReducer, + issue: issueReducer, }; const sessionReducers = { diff --git a/src/utils/URL.js b/src/utils/URL.js index 5ff2ff74a5..ae90dbc456 100644 --- a/src/utils/URL.js +++ b/src/utils/URL.js @@ -226,6 +226,10 @@ export const ENDPOINTS = { BM_TAG_ADD: `${APIEndpoint}/bm/tags`, BM_TAGS_DELETE: `${APIEndpoint}/bm/tags`, + BM_LONGEST_OPEN_ISSUES: `${APIEndpoint}/issues/longest-open`, + BM_MOST_EXPENSIVE_ISSUES: `${APIEndpoint}/issues/most-expensive`, + + GET_TIME_OFF_REQUESTS: () => `${APIEndpoint}/getTimeOffRequests`, ADD_TIME_OFF_REQUEST: () => `${APIEndpoint}/setTimeOffRequest`, UPDATE_TIME_OFF_REQUEST: id => `${APIEndpoint}/updateTimeOffRequest/${id}`,