diff --git a/src/actions/bmdashboard/issueChartActions.js b/src/actions/bmdashboard/issueChartActions.js index e5b005bf69..12f8b4ad4f 100644 --- a/src/actions/bmdashboard/issueChartActions.js +++ b/src/actions/bmdashboard/issueChartActions.js @@ -3,20 +3,23 @@ import { FETCH_ISSUES_BARCHART_REQUEST, FETCH_ISSUES_BARCHART_SUCCESS, FETCH_ISSUES_BARCHART_FAILURE, + 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, SET_DATE_FILTER, SET_PROJECT_FILTER, } from '../../constants/bmdashboard/issueConstants'; -import { ENDPOINTS, ApiEndpoint } from '../../utils/URL'; +import { ENDPOINTS } from '../../utils/URL'; -// eslint-disable-next-line import/prefer-default-export export const fetchIssues = () => async dispatch => { try { dispatch({ type: FETCH_ISSUES_BARCHART_REQUEST }); - - // Fetch all data without applying any filters const { data } = await axios.get(ENDPOINTS.BM_ISSUE_CHART); dispatch({ type: FETCH_ISSUES_BARCHART_SUCCESS, payload: data }); } catch (error) { @@ -27,34 +30,70 @@ export const fetchIssues = () => async dispatch => { } }; - -export const fetchLongestOpenIssues = (dates = [], projects = []) => async dispatch => { +export const fetchIssueTypesAndYears = () => async dispatch => { try { - dispatch({ type: FETCH_LONGEST_OPEN_ISSUES_REQUEST }); + dispatch({ type: FETCH_ISSUE_TYPES_YEARS_REQUEST }); + const { data } = await axios.get(ENDPOINTS.BM_ISSUE_CHART); + const issueTypes = [...new Set(data.map(item => item._id.issueType))]; + const years = [...new Set(data.map(item => item._id.issueYear))]; + dispatch({ type: FETCH_ISSUE_TYPES_YEARS_SUCCESS, payload: { issueTypes, years } }); + } catch (error) { + dispatch({ + type: FETCH_ISSUE_TYPES_YEARS_FAILURE, + payload: error.message || 'Failed to fetch issue types and years', + }); + } +}; - const params = new URLSearchParams(); - if (dates.length) params.append('dates', dates.join(',')); - if (projects.length) params.append('projects', projects.join(',')); +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; +}; - const response = await axios.get(`${ApiEndpoint}/bm/issues/longest-open?${params}`); - dispatch({ - type: FETCH_LONGEST_OPEN_ISSUES_SUCCESS, - payload: response.data, +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, }); + dispatch({ type: FETCH_LONGEST_OPEN_ISSUES_SUCCESS, payload: response.data }); } catch (error) { dispatch({ type: FETCH_LONGEST_OPEN_ISSUES_FAILURE, - payload: error.message, + 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, + }); + 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', }); } }; -export const setDateFilter = dates => ({ - type: SET_DATE_FILTER, - payload: dates, -}); +export const setDateFilter = dates => ({ type: SET_DATE_FILTER, payload: dates }); -export const setProjectFilter = projects => ({ - type: SET_PROJECT_FILTER, - payload: projects, -}); +export const setProjectFilter = projects => ({ type: SET_PROJECT_FILTER, payload: projects }); diff --git a/src/components/BMDashboard/Issues/IssueCharts.module.css b/src/components/BMDashboard/Issues/IssueCharts.module.css new file mode 100644 index 0000000000..d063acd937 --- /dev/null +++ b/src/components/BMDashboard/Issues/IssueCharts.module.css @@ -0,0 +1,444 @@ +/* stylelint-disable no-descending-specificity */ + +/* =========================== +Issue Charts Module CSS +=========================== */ + +/* ----- Longest/Most Expensive Bar Charts ----- */ +.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; +} + +.dateInputs { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.multiSelectWrapper { + min-width: 220px; + flex: 1; +} + +.noData { + text-align: center; + padding: 2rem; + color: #6c757d; + font-style: italic; +} + +.dateInput { + border: 1px solid #ced4da; + border-radius: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: 0.875rem; +} + +.dateDark { + background: #2b3e59; + color: white; + border: 1px solid #4a5a72; + border-radius: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: 0.875rem; +} + +/* Fix react-datepicker navigation arrow contrast */ +:global(.react-datepicker__navigation-icon::before) { + border-color: #555 !important; + border-width: 2px 2px 0 0 !important; +} + +:global(.react-datepicker__navigation:hover .react-datepicker__navigation-icon::before) { + border-color: #111 !important; +} + +/* Dark mode datepicker — scoped via calendarClassName prop */ +.darkCalendar { + background-color: #1e2d42 !important; + border-color: #4a5a72 !important; +} + +.darkCalendar :global(.react-datepicker__header) { + background-color: #243450 !important; + border-bottom-color: #4a5a72 !important; +} + +.darkCalendar :global(.react-datepicker__current-month), +.darkCalendar :global(.react-datepicker__day-name) { + color: #e0e6f0 !important; +} + +.darkCalendar :global(.react-datepicker__day) { + color: #ccd1dc !important; +} + +.darkCalendar :global(.react-datepicker__day:hover) { + background-color: #4a5a72 !important; + color: #fff !important; +} + +.darkCalendar :global(.react-datepicker__day--selected), +.darkCalendar :global(.react-datepicker__day--selected:hover) { + background-color: #1e40af !important; + color: #fff !important; +} + +.darkCalendar :global(.react-datepicker__day--today) { + background-color: #1e3a5f !important; + color: #fff !important; + font-weight: bold !important; +} + +.darkCalendar :global(.react-datepicker__day--outside-month) { + color: #6b7a8d !important; +} + +.darkCalendar :global(.react-datepicker__navigation-icon::before) { + border-color: #ccc !important; +} + +.select { + padding: 0.25rem 0.5rem; + border: 1px solid #ced4da; + border-radius: 0.25rem; + font-size: 0.75rem; +} + +.selectDark { + background: #2b3e59; + color: white; + border: 1px solid #4a5a72; + border-radius: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; +} + +/* ----- Containers ----- */ +.issueChartContainer, +.issueChartEventContainer { + width: 100%; + max-width: 100%; + margin: 0; + padding: 30px 40px; + background: #f9f9f9; + border-radius: 8px; + box-shadow: 0 4px 6px rgb(0 0 0 / 10%); + display: flex; /* match dark mode layout */ + flex-direction: column; /* match dark mode */ + align-items: center; /* center filters like dark mode */ +} + +.issueChartContainerDark, +.issueChartEventContainerDark { + width: 100%; + max-width: 100%; + margin: 0; + padding: 30px 40px; + background:#1B2A41; + border-radius: 0; + box-shadow: none; + color: #ccd1dc; + display: flex; + flex-direction: column; + align-items: center; +} + +/* ----- Titles ----- */ +.issueChartEventTitle { + text-align: center; + font-size: 24px; + color: #333; + margin-bottom: 20px; +} + +.issueChartEventTitleDark { + color: #e0e6f0; +} + +/* ----- Buttons ----- */ +.buttonGroup { + display: flex; + justify-content: center; + margin-bottom: 20px; +} + +.chartButton { + padding: 10px 20px; + margin: 0 10px; + border: 2px solid #888; + background-color: white; + color: #333; + font-size: 16px; + cursor: pointer; + border-radius: 4px; + transition: all 0.3s ease; +} + +.chartButtonDark { + border-color: #3d444d; + background-color: #22272e; + color: #cfd7e3; +} + +.chartButtonActive, +.chartButtonActiveDark { + background-color: #276228; + color: #fff; + border-color: #276228; +} + +/* ----- Chart Wrapper ----- */ +.chartWrapper { + width:100%; + padding: 20px; + background: #fff; + border-radius: 8px; + box-shadow: 0 4px 6px rgb(0 0 0 / 10%); +} + +.chartWrapperDark { + background: #1a1c20; + width:100%; + padding: 25px; + border-radius: 12px; + box-shadow: 0 2px 6px rgb(0 0 0 / 60%); +} + +/* ----- Labels & Select Controls ----- */ +.issueChartLabel { + width:100%; + font-size: 16px; + color: #555; + margin-right: 10px; +} + +.issueChartLabelDark { + color: #aab1bf; +} + +.selectContainer { + width:100%; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 20px; +} + +.issueChartSelect { + padding: 2px 10px; + font-size: 16px; + border-radius: 4px; + border: 1px solid #ccc; + background-color: #fff; + color: #333; + cursor: pointer; + transition: border-color 0.3s ease; + outline: none; + margin-bottom: 20px; +} + +.issueChartSelectDark { + background-color: #22272e; + border: 1px solid #3d444d; + color: #cfd7e3; +} + +.filterSelect, +.issueChartSelect { + height: 60px; + padding: 8px 12px; + font-size: 15px; + border-radius: 6px; + border: 1px solid #cbd5e0; + background-color: #fff; + color: #2d3748; + width: 100%; + box-sizing: border-box; +} + +.filterSelectDark, +.issueChartSelectDark { + background-color: #2d3748; + border: 1px solid #4a5568; + color: #edf2f7; +} + + +.issueChartSelect:focus, +.issueChartSelectDark:focus { + border-color: #4caf50; +} + +/* ----- Filters ----- */ +.filtersContainer { + display: flex; + gap: 45px; + margin-top: 10px; + margin-bottom: 25px; + flex-wrap: wrap; +} + +.filter { + display: flex; + flex-direction: column; + gap: 8px; +} + +.dateRangePicker { + display: flex; + align-items: center; + gap: 8px; +} + +.projectFilter{ + width: 220px; +} + +.filterSelect { + padding: 6px 8px; + border: 1px solid #ccc; + border-radius: 4px; + background-color: #fff; + color: #333; +} + +.filterSelectDark { + background-color: #22272e; + border: 1px solid #3d444d; + color: #cfd7e3; +} + +/* fixed width for each DatePicker wrapper div from react-datepicker */ +.dateRangePickerStart :global(.react-datepicker-wrapper), +.dateRangePickerEnd :global(.react-datepicker-wrapper) { + display: inline-block; + width: 220px; /* tweak width if needed */ +} + +/* inner container full width */ +.dateRangePickerStart :global(.react-datepicker__input-container), +.dateRangePickerEnd :global(.react-datepicker__input-container) { + width: 100%; +} + +/* actual input fills wrapper and doesn’t change layout */ +.dateRangePickerStart :global(.react-datepicker__input-container input), +.dateRangePickerEnd :global(.react-datepicker__input-container input) { + width: 100%; + box-sizing: border-box; + height: 38px; +} + +.filterSelect:focus, +.issueChartSelect:focus { + border-color: #4caf50; + outline: none; +} + +/* ---------- Chart container ---------- */ + +/* ----- Chart Container ----- */ +.chartContainer { + margin: 20px; + width: 100%; + height: 400px; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 4px rgb(0 0 0 / 10%); + color: #000; +} + +.chartContainerDark { + width: 100%; + max-width: 900px; /* centers the chart while panel stays 100% */ + background: #1a1c20; + padding: 25px; + border-radius: 12px; + margin: 0 auto; + box-shadow: 0 2px 6px rgb(0 0 0 / 60%); + color: #cfd7e3; +} + +/* ----- No Data Messages ----- */ +.noDataMessage, +.noDataContent { + text-align: center; +} + +.noDataMessage { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + color: #666; +} + +.noDataMessageDark { + background: #1e1e1e; + color: #999; +} + +.noDataContent { + padding: 40px 20px; + max-width: 400px; +} + +.noDataContent h3 { + color: #666; + font-size: 20px; + margin-bottom: 16px; + font-weight: 500; +} + +.noDataContentDark h3 { + color: #ccc; +} + +.noDataContent p { + color: #888; + font-size: 14px; + line-height: 1.5; + margin-bottom: 8px; +} + +.noDataContentDark p { + color: #aaa; +} + +/* ----- Responsive ----- */ +@media (width <= 768px) { + .chartContainer { + height: 250px; + padding: 10px; + } + + .filtersContainer { + flex-direction: column; + } + + .filter { + width: 100%; + } +} diff --git a/src/components/BMDashboard/Issues/LongestOpenIssuesChart.jsx b/src/components/BMDashboard/Issues/LongestOpenIssuesChart.jsx new file mode 100644 index 0000000000..804a3dbf60 --- /dev/null +++ b/src/components/BMDashboard/Issues/LongestOpenIssuesChart.jsx @@ -0,0 +1,216 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import PropTypes from 'prop-types'; +import { Bar } from 'react-chartjs-2'; +import 'chart.js/auto'; +import ChartDataLabels from 'chartjs-plugin-datalabels'; +import DatePicker from 'react-datepicker'; +import Select from 'react-select'; +import styles from './IssueCharts.module.css'; + +import { + fetchLongestOpenIssues, + fetchMostExpensiveIssues, +} from '../../../actions/bmdashboard/issueChartActions'; + +const truncateLabel = str => (str.length > 28 ? `${str.substring(0, 28)}…` : str); + +function IssuesCharts({ bmProjects = [] }) { + const dispatch = useDispatch(); + + const [graphType, setGraphType] = useState('Longest Open'); + const [selectedProjects, setSelectedProjects] = useState([]); + const [dateRange, setDateRange] = useState({ start: null, end: null }); + + const { longestOpenIssues = [], mostExpensiveIssues = [] } = useSelector( + state => state.issue || {}, + ); + const darkMode = useSelector(state => state.theme.darkMode); + + useEffect(() => { + const params = { + projectIds: selectedProjects.length > 0 ? selectedProjects : undefined, + startDate: dateRange.start || undefined, + endDate: dateRange.end || undefined, + }; + + if (graphType === 'Longest Open') { + dispatch(fetchLongestOpenIssues(params)); + } else { + dispatch(fetchMostExpensiveIssues(params)); + } + }, [graphType, selectedProjects, dateRange.start, dateRange.end, dispatch]); + + const chartData = graphType === 'Longest Open' ? longestOpenIssues : mostExpensiveIssues; + + const data = { + labels: chartData.map(issue => truncateLabel(issue.title || String(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(() => { + const vals = chartData.map(d => (graphType === 'Longest Open' ? d.daysOpen : d.totalCost)); + const dataMax = vals.length > 0 ? Math.max(...vals) : 600; + const xMax = Math.ceil((dataMax + 50) / 250) * 250; + + return { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + layout: { + padding: { right: 80, left: 10 }, + }, + plugins: { + legend: { display: false }, + datalabels: { + anchor: 'end', + align: 'right', + clip: false, + formatter: value => (graphType === 'Longest Open' ? `${value} days` : `$${value}`), + color: darkMode ? '#fff' : '#000', + font: { weight: 'bold' }, + }, + title: { + display: true, + text: + graphType === 'Longest Open' + ? 'Top 5 Longest Open Issues' + : 'Most Expensive Issues by Time Open', + font: { size: 14, weight: 'bold' }, + color: darkMode ? '#fff' : '#000', + }, + }, + scales: { + x: { + max: xMax, + title: { + display: true, + text: graphType === 'Longest Open' ? 'Days Open' : 'Total Cost ($)', + font: { size: 12 }, + color: darkMode ? '#fff' : '#000', + }, + ticks: { stepSize: 250, color: darkMode ? '#ccc' : '#333' }, + }, + y: { + afterFit: scale => { + scale.width = 200; + }, + title: { + display: true, + text: 'Issue Title', + font: { size: 12 }, + color: darkMode ? '#fff' : '#000', + }, + ticks: { + color: darkMode ? '#ccc' : '#333', + maxRotation: 0, + autoSkip: false, + }, + }, + }, + elements: { + bar: { borderRadius: 4, borderSkipped: false }, + }, + }; + }, [graphType, darkMode, chartData]); + + const projectOptions = bmProjects.map(p => ({ value: p._id, label: p.name })); + const selectedProjectOptions = projectOptions.filter(opt => selectedProjects.includes(opt.value)); + + const darkSelectStyles = darkMode + ? { + control: base => ({ + ...base, + background: '#2b3e59', + borderColor: '#4a5a72', + color: '#fff', + }), + menu: base => ({ ...base, background: '#2b3e59' }), + option: (base, { isFocused }) => ({ + ...base, + background: isFocused ? '#4a5a72' : '#2b3e59', + color: '#fff', + }), + multiValue: base => ({ ...base, background: '#4a5a72' }), + multiValueLabel: base => ({ ...base, color: '#fff' }), + singleValue: base => ({ ...base, color: '#fff' }), + input: base => ({ ...base, color: '#fff' }), + placeholder: base => ({ ...base, color: '#aaa' }), + } + : {}; + + return ( +
No issues found.
+ )} +