diff --git a/src/components/Reports/Reports.jsx b/src/components/Reports/Reports.jsx index 3432ae800e..d991ee27b8 100644 --- a/src/components/Reports/Reports.jsx +++ b/src/components/Reports/Reports.jsx @@ -708,7 +708,7 @@ endDate: moment() fontSize={15} isPermissionPage darkMode={darkMode} - defaultText="Click this to see only people who logged/contributed a minimum of 10 tangible hours. This is used for identifying actual contributors vs. people who never started, were immediately terminated, etc." + defaultText="This report only shows Team Members who have contributed more than 10 Hours." /> diff --git a/src/components/Reports/TotalReport/TotalContributorsReport.jsx b/src/components/Reports/TotalReport/TotalContributorsReport.jsx index 5371db1232..9923786981 100644 --- a/src/components/Reports/TotalReport/TotalContributorsReport.jsx +++ b/src/components/Reports/TotalReport/TotalContributorsReport.jsx @@ -1,235 +1,237 @@ -import axios from 'axios'; import PropTypes from 'prop-types'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { ENDPOINTS } from '~/utils/URL'; +import { useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; import Loading from '../../common/Loading'; import EditableInfoModal from '../../UserProfile/EditableModal/EditableInfoModal'; -import { - getCachedData, - logApiRequest, - logApiResponse, - setCachedData, - validateUserList, -} from './cacheUtils'; -import { generateBarData as generateBarDataUtil } from './generateBarData'; +import useContributorsData from './useContributorsData'; import styles from './TotalReport.module.css'; import TotalReportBarGraph from './TotalReportBarGraph'; -function TotalContributorsReport({ startDate, endDate, userProfiles, darkMode, userRole }) { - const [contributors, setContributors] = useState([]); - const [loading, setLoading] = useState(true); - const [timeEntries, setTimeEntries] = useState([]); - const [showMonthly, setShowMonthly] = useState(false); - const [showYearly, setShowYearly] = useState(false); - const [contributorsInMonth, setContributorsInMonth] = useState([]); - const [contributorsInYear, setContributorsInYear] = useState([]); - - const fromDate = useMemo(() => startDate.toLocaleDateString('en-CA'), [startDate]); - const toDate = useMemo(() => endDate.toLocaleDateString('en-CA'), [endDate]); - const userList = useMemo(() => { - return userProfiles?.map(({ _id }) => _id) || []; - }, [userProfiles]); - - // Fetch time entries for the selected period - const loadTimeEntriesForPeriod = useCallback(async (controller) => { - const reportName = 'TotalContributorsReport'; - const url = ENDPOINTS.TIME_ENTRIES_REPORTS; - - if (!url) { - return; - } +const MIN_DATE = new Date('01/01/2010'); - // Validate userList - if (!validateUserList(userList, userProfiles, reportName)) { - setTimeEntries([]); - setLoading(false); - return; - } +const formatDate = date => + date.toLocaleDateString('en-US', { month: '2-digit', day: '2-digit', year: 'numeric' }); - // Check cache with date range key - const cacheKey = `${reportName}_${fromDate}_${toDate}`; - const cached = getCachedData(cacheKey, reportName); - if (cached.data) { - setTimeEntries(cached.data); - setLoading(false); - return; - } +const getDateRangeLabel = (startDate, endDate) => `${formatDate(startDate)} to ${formatDate(endDate)}`; - try { - logApiRequest(reportName, url, { users: userList, fromDate, toDate }, { - usersCount: userList?.length, - }); - - const response = await axios.post( - url, - { users: userList, fromDate, toDate }, - { signal: controller.signal } - ); - - logApiResponse(reportName, response.data?.length); - - const mappedTimeEntries = response.data.map(entry => ({ - userId: entry.personId, - hours: entry.hours, - minutes: entry.minutes, - isTangible: entry.isTangible, - date: entry.dateOfWork, - })); - - setTimeEntries(mappedTimeEntries); - setCachedData(cacheKey, mappedTimeEntries, reportName); - } catch (error) { - // eslint-disable-next-line import/no-named-as-default-member - if (!axios.isCancel(error)) { - // eslint-disable-next-line no-console - console.error(`${reportName} API Error:`, error); - setTimeEntries([]); - } - } - }, [fromDate, toDate, userList, userProfiles]); - - // Group time entries by user and calculate total hours - const sumByUser = useCallback((entries) => { - return entries.reduce((acc, { userId, hours = 0, minutes = 0, isTangible = false }) => { - if (!acc[userId]) { - acc[userId] = { - userId, - hours: 0, - minutes: 0, - tangibleTime: 0, - }; - } - const numHours = parseFloat(hours) || 0; - const numMinutes = parseFloat(minutes) || 0; - acc[userId].hours += numHours; - acc[userId].minutes += numMinutes; - if (isTangible) { - acc[userId].tangibleTime += numHours + numMinutes / 60; - } - return acc; - }, {}); - }, []); - - // Filter users who have contributed more than 10 hours - const filterContributors = useCallback((users) => { - return users.filter(user => - (user.hours + user.minutes/60) >= 10 - ); - }, []); - - // Group entries by time range (month/year) - const groupByTimeRange = useCallback((entries, timeRange) => { - return entries.reduce((acc, entry) => { - const date = new Date(entry.date); - const key = timeRange === 'month' - ? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` - : `${date.getFullYear()}`; - - if (!acc[key]) { - acc[key] = []; - } - acc[key].push(entry); - return acc; - }, {}); - }, []); - - // Generate summary data for the specified time range - const summaryOfTimeRange = useCallback((timeRange) => { - const groupedEntries = Object.entries(groupByTimeRange(timeEntries, timeRange)); - return groupedEntries.map(([key, entries]) => { - const groupedUsers = Object.values(sumByUser(entries)); - const contributedUsers = filterContributors(groupedUsers); - return { timeRange: key, usersOfTime: contributedUsers }; - }); - }, [timeEntries, groupByTimeRange, sumByUser, filterContributors]); - - // Generate bar chart data - const generateBarData = useCallback((groupedDate, isYear = false) => { - return generateBarDataUtil(groupedDate, isYear, startDate, endDate, 'usersOfTime'); - }, [startDate, endDate]); - - // Check if we should show monthly/yearly summaries - const checkPeriodForSummary = useCallback(() => { - const oneMonth = 1000 * 60 * 60 * 24 * 31; - const diffDate = endDate - startDate; - if (diffDate > oneMonth) { - setContributorsInMonth(generateBarData(summaryOfTimeRange('month'))); - setContributorsInYear(generateBarData(summaryOfTimeRange('year'), true)); - if (diffDate <= oneMonth * 12) { - setShowMonthly(true); - } - if (startDate.getFullYear() !== endDate.getFullYear()) { - setShowYearly(true); - } +// Renders the summary stats and charts for a single period. +function ContributorsPeriodPanel({ startDate, endDate, data, idSuffix, title }) { + const { + contributors, + totalTangibleTime, + showMonthly, + showYearly, + contributorsInMonth, + contributorsInYear, + } = data; + const dateRangeLabel = getDateRangeLabel(startDate, endDate); + + return ( +
+ {title &&
{title}
} +
+ In the period from {dateRangeLabel}: +
+
+
+
+ {contributors.length} +
+
+ Members have contributed more than 10 Hours. +
+
+
+
+ {totalTangibleTime.toFixed(2)} +
+
+ Hours of tangible time have been logged. +
+
+
+
+ {showMonthly && contributorsInMonth.length > 0 && ( + + )} + {showYearly && contributorsInYear.length > 0 && ( + + )} +
+
+ ); +} + +ContributorsPeriodPanel.propTypes = { + startDate: PropTypes.instanceOf(Date).isRequired, + endDate: PropTypes.instanceOf(Date).isRequired, + data: PropTypes.shape({ + contributors: PropTypes.arrayOf(PropTypes.shape({})), + totalTangibleTime: PropTypes.number, + showMonthly: PropTypes.bool, + showYearly: PropTypes.bool, + contributorsInMonth: PropTypes.arrayOf(PropTypes.shape({})), + contributorsInYear: PropTypes.arrayOf(PropTypes.shape({})), + }).isRequired, + idSuffix: PropTypes.string, + title: PropTypes.string, +}; + +ContributorsPeriodPanel.defaultProps = { + idSuffix: '', + title: '', +}; + +function TotalContributorsReport({ startDate, endDate, userProfiles, darkMode, userRole }) { + const [comparisonEnabled, setComparisonEnabled] = useState(false); + // Default the comparison window to the equal-length period immediately + // preceding the primary start date (e.g. 2023-2024 vs 2024-2025). + const [compareStartDate, setCompareStartDate] = useState(() => { + const length = endDate - startDate; + const start = new Date(startDate.getTime() - length); + return start < MIN_DATE ? MIN_DATE : start; + }); + const [compareEndDate, setCompareEndDate] = useState(() => new Date(startDate)); + + const primary = useContributorsData({ startDate, endDate, userProfiles, enabled: true }); + const comparison = useContributorsData({ + startDate: compareStartDate, + endDate: compareEndDate, + userProfiles, + enabled: comparisonEnabled, + }); + const onToggleComparison = e => { + const enabled = e.target.checked; + if (enabled) { + // Default the comparison to the equal-length period immediately preceding + // the currently-selected primary range. + const length = endDate - startDate; + const start = new Date(startDate.getTime() - length); + setCompareStartDate(start < MIN_DATE ? MIN_DATE : start); + setCompareEndDate(new Date(startDate)); } - }, [endDate, startDate, generateBarData, summaryOfTimeRange]); + setComparisonEnabled(enabled); + }; - // Load data when date range changes - useEffect(() => { - // Only make API call if userList has data - if (!userList || userList.length === 0) { - return; + const onCompareStartDateChange = date => { + if (date && date <= compareEndDate) { + setCompareStartDate(date); } + }; - setLoading(true); - const controller = new AbortController(); - loadTimeEntriesForPeriod(controller).then(() => { - setLoading(false); - }); - return () => controller.abort(); - }, [loadTimeEntriesForPeriod, userList]); - - // Process data when time entries are loaded - useEffect(() => { - if (!loading && timeEntries.length > 0) { - setShowMonthly(false); - setShowYearly(false); - const groupedUsers = Object.values(sumByUser(timeEntries)); - const contributedUsers = filterContributors(groupedUsers); - setContributors(contributedUsers); - checkPeriodForSummary(); + const onCompareEndDateChange = date => { + if (date && date >= compareStartDate) { + setCompareEndDate(date); } - }, [loading, timeEntries, sumByUser, filterContributors, checkPeriodForSummary]); + }; - if (loading) { + if (primary.loading) { return ; } - const totalTangibleTime = contributors.reduce((acc, obj) => acc + Number(obj.tangibleTime), 0); + const today = new Date(); return (
-

Contributors Report

+

+ Contributors Report +

-
- In the period from {startDate.toLocaleDateString('en-US', { month: '2-digit', day: '2-digit', year: 'numeric' })} to {endDate.toLocaleDateString('en-US', { month: '2-digit', day: '2-digit', year: 'numeric' })}: -
-
-
{contributors.length}
-
members have contributed more than 10 hours.
-
-
-
{totalTangibleTime.toFixed(2)}
-
hours of tangible time have been logged.
-
-
- {showMonthly && contributorsInMonth.length > 0 && ( - - )} - {showYearly && contributorsInYear.length > 0 && ( - + +
+ + {comparisonEnabled && ( +
+
+ + +
+
+ + +
+
)}
+ + {comparisonEnabled ? ( +
+ + {comparison.loading ? ( +
+ +
+ ) : ( + + )} +
+ ) : ( + + )}
); } @@ -237,9 +239,11 @@ function TotalContributorsReport({ startDate, endDate, userProfiles, darkMode, u TotalContributorsReport.propTypes = { startDate: PropTypes.instanceOf(Date).isRequired, endDate: PropTypes.instanceOf(Date).isRequired, - userProfiles: PropTypes.arrayOf(PropTypes.shape({ - _id: PropTypes.string, - })), + userProfiles: PropTypes.arrayOf( + PropTypes.shape({ + _id: PropTypes.string, + }), + ), darkMode: PropTypes.bool, userRole: PropTypes.string, }; @@ -250,4 +254,4 @@ TotalContributorsReport.defaultProps = { userRole: '', }; -export default TotalContributorsReport; \ No newline at end of file +export default TotalContributorsReport; diff --git a/src/components/Reports/TotalReport/TotalReport.module.css b/src/components/Reports/TotalReport/TotalReport.module.css index d94ed96449..78e03e9ba4 100644 --- a/src/components/Reports/TotalReport/TotalReport.module.css +++ b/src/components/Reports/TotalReport/TotalReport.module.css @@ -39,6 +39,63 @@ font-weight: 400; } +.comparisonControls { + display: flex; + flex-direction: column; + gap: 12px; + margin: 12px 0 8px; +} + +.comparisonToggle { + display: flex; + align-items: center; + gap: 8px; + font-size: 16px; + font-weight: 500; + cursor: pointer; + margin: 0; +} + +.comparePickers { + display: flex; + flex-flow: row wrap; + gap: 16px; +} + +.comparePickerItem { + display: flex; + flex-direction: column; + gap: 4px; +} + +.comparisonGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 28px; + align-items: start; +} + +.comparisonPanel { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} + +.panelTitle { + font-size: 20px; + font-weight: 600; + line-height: 1.3; + margin-bottom: 0; + color: steelblue; +} + +@media screen and (width <= 700px) { + .comparisonGrid { + grid-template-columns: 1fr; + } +} + .totalDetail { margin-top: 20px; } @@ -65,6 +122,39 @@ font-size: inherit; } +.contributorPeriodLabel { + font-size: 17px; + line-height: 1.45; +} + +.contributorSummaryMetrics { + display: flex; + flex-direction: column; + gap: 12px; +} + +.contributorSummaryItem { + display: grid; + grid-template-columns: 112px minmax(0, 1fr); + align-items: center; + column-gap: 14px; + min-height: 44px; +} + +.contributorSummaryNumber { + margin-right: 0; + font-size: 30px; + line-height: 1.15; + text-align: right; +} + +.contributorSummaryText { + flex: 1 1 auto; + min-width: 0; + font-size: 18px; + line-height: 1.45; +} + @media screen and (width <= 1100px) { .totalTitle, .totalNumber, @@ -75,6 +165,24 @@ .totalText { font-size: 12px; } + + .contributorPeriodLabel { + font-size: 16px; + } + + .contributorSummaryItem { + grid-template-columns: 96px minmax(0, 1fr); + column-gap: 12px; + } + + .contributorSummaryNumber { + font-size: 26px; + } + + .contributorSummaryText { + font-size: 16px; + line-height: 1.45; + } } @media screen and (width <= 700px) { @@ -87,12 +195,42 @@ .totalText { font-size: 14px; } + + .comparisonPanel { + gap: 12px; + } + + .contributorPeriodLabel { + font-size: 16px; + } + + .contributorSummaryItem { + grid-template-columns: 104px minmax(0, 1fr); + } + + .contributorSummaryNumber { + font-size: 28px; + } + + .contributorSummaryText { + font-size: 16px; + line-height: 1.45; + } } @media screen and (width <= 550px) { .totalContainer { padding: 15px; } + + .contributorSummaryItem { + grid-template-columns: 88px minmax(0, 1fr); + column-gap: 10px; + } + + .contributorSummaryNumber { + font-size: 24px; + } } /* Dark Mode Styles - Using global dark-mode class for consistency across builds */ @@ -105,6 +243,10 @@ color: #60a5fa; } +:global(.dark-mode) .panelTitle { + color: #60a5fa; +} + :global(.dark-mode) .totalPeriod { color: #d1d5db; } @@ -134,4 +276,3 @@ :global(.dark-mode) .totalWarning { color: #fbbf24; } - diff --git a/src/components/Reports/TotalReport/TotalReportBarGraph.jsx b/src/components/Reports/TotalReport/TotalReportBarGraph.jsx index 7f809eb5a7..2f7d35a40d 100644 --- a/src/components/Reports/TotalReport/TotalReportBarGraph.jsx +++ b/src/components/Reports/TotalReport/TotalReportBarGraph.jsx @@ -3,23 +3,52 @@ import * as d3 from 'd3'; import styles from './TotalReportBarGraph.module.css'; import { useSelector } from 'react-redux'; -function TotalReportBarGraph({ barData, range }) { - const svgId = `svg-container-${range}`; +function TotalReportBarGraph({ + barData, + range, + idSuffix = '', + fallbackLabel = '', +}) { + const svgId = `svg-container-${range}${idSuffix}`; const darkMode = useSelector(state => state.theme.darkMode); const containerRef = useRef(null); + const isComparisonChart = idSuffix.includes('compare'); + const barColor = isComparisonChart + ? darkMode + ? '#f59e0b' + : '#d97706' + : darkMode + ? '#4a90e2' + : '#5b6ee1'; + const aboveBarLabelColor = darkMode ? '#f8fafc' : '#1f2937'; + const insideLabelMinHeight = 34; + + const getReadableLabel = label => { + const text = label === null || label === undefined ? '' : String(label); + if (!text || text.includes('NaN') || text === 'Invalid Date') { + return fallbackLabel || 'Selected date range'; + } + return text; + }; const drawChart = (data, darkmode) => { - data.sort((a, b) => (a.label > b.label ? 1 : -1)); + const normalizedData = data + .map(item => ({ + ...item, + label: getReadableLabel(item.label), + value: Number.isNaN(Number(item.value)) ? 0 : Number(item.value), + })) + .sort((a, b) => (a.label > b.label ? 1 : -1)); const container = containerRef.current; - const { width: containerWidth } = container.getBoundingClientRect(); - const containerHeight = containerWidth; + const { width: containerWidth } = container.getBoundingClientRect(); + const containerHeight = Math.max(containerWidth * 0.75, 320); const margin = { top: 10, right: 8, bottom: 100, left: 20 }; const width = containerWidth - margin.left - margin.right; const height = containerHeight - margin.top - margin.bottom; - const maxValue = Math.max(...data.map(d => d.value)); + const maxValue = Math.max(...normalizedData.map(d => d.value), 1); const svg = d3 // eslint-disable-next-line testing-library/no-node-access @@ -37,7 +66,7 @@ function TotalReportBarGraph({ barData, range }) { const xScale = d3 .scaleBand() - .domain(data.map(d => d.label)) + .domain(normalizedData.map(d => d.label)) .range([0, width]) .padding(0.4); @@ -46,43 +75,32 @@ function TotalReportBarGraph({ barData, range }) { .domain([0, maxValue]) .range([height, 0]); - const colorScale = d3 - .scaleLinear() - .domain([0, maxValue]) - .range(darkMode ? ['#4a90e2', '#003366'] : ['#f5a3a3', '#c3b6f7']); - data.forEach(d => { + normalizedData.forEach(d => { const x = xScale(d.label); const barHeight = height - yScale(d.value); const y = yScale(d.value); + const labelFitsInside = barHeight >= insideLabelMinHeight; - chart .append('rect') .attr('x', x) .attr('y', y) .attr('width', xScale.bandwidth()) .attr('height', barHeight) - .attr('fill', colorScale(d.value)); - - - const yText = - barHeight >= 30 - ? y + barHeight / 2 - : - y - 10; + .attr('fill', barColor); chart .append('text') .attr('x', x + xScale.bandwidth() / 2) - .attr('y', yText) + .attr('y', labelFitsInside ? y + barHeight / 2 : Math.max(y - 8, 14)) .attr('text-anchor', 'middle') - .style('fill', darkmode ? 'white' : 'black') - .style('font-size', '20px') + .attr('dominant-baseline', labelFitsInside ? 'middle' : 'baseline') + .style('fill', labelFitsInside ? 'white' : aboveBarLabelColor) + .style('font-size', '24px') .style('font-weight', 'bold') - .text(Number.isNaN(d.value) ? '' : d.value); + .text(d.value > 0 ? d.value : ''); }); - chart .append('g') .attr('transform', `translate(0, ${height})`) @@ -92,7 +110,7 @@ function TotalReportBarGraph({ barData, range }) { .style('text-anchor', 'middle') .style('font-size', '14px') .style('fill', 'black') - .style('fill', darkmode ? 'white' : 'black'); + .style('fill', darkmode ? 'white' : 'black'); }; useEffect(() => { diff --git a/src/components/Reports/TotalReport/useContributorsData.js b/src/components/Reports/TotalReport/useContributorsData.js new file mode 100644 index 0000000000..73637e69ef --- /dev/null +++ b/src/components/Reports/TotalReport/useContributorsData.js @@ -0,0 +1,229 @@ +import axios from 'axios'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ENDPOINTS } from '~/utils/URL'; +import { + getCachedData, + logApiRequest, + logApiResponse, + setCachedData, + validateUserList, +} from './cacheUtils'; +import { generateBarData as generateBarDataUtil } from './generateBarData'; + +const REPORT_NAME = 'TotalContributorsReport'; +const ONE_MONTH = 1000 * 60 * 60 * 24 * 31; + +// Group time entries by user and calculate total hours +const sumByUser = entries => { + return entries.reduce((acc, { userId, hours = 0, minutes = 0, isTangible = false }) => { + if (!acc[userId]) { + acc[userId] = { userId, hours: 0, minutes: 0, tangibleTime: 0 }; + } + const numHours = parseFloat(hours) || 0; + const numMinutes = parseFloat(minutes) || 0; + acc[userId].hours += numHours; + acc[userId].minutes += numMinutes; + if (isTangible) { + acc[userId].tangibleTime += numHours + numMinutes / 60; + } + return acc; + }, {}); +}; + +// Filter users who have contributed more than 10 hours +const filterContributors = users => users.filter(user => user.hours + user.minutes / 60 >= 10); + +// Group entries by time range (month/year) +const groupByTimeRange = (entries, timeRange) => { + return entries.reduce((acc, entry) => { + const date = new Date(entry.date); + const key = + timeRange === 'month' + ? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` + : `${date.getFullYear()}`; + if (!acc[key]) { + acc[key] = []; + } + acc[key].push(entry); + return acc; + }, {}); +}; + +// Generate summary data for the specified time range +const summaryOfTimeRange = (entries, timeRange) => { + const groupedEntries = Object.entries(groupByTimeRange(entries, timeRange)); + return groupedEntries.map(([key, rangeEntries]) => { + const groupedUsers = Object.values(sumByUser(rangeEntries)); + const contributedUsers = filterContributors(groupedUsers); + return { timeRange: key, usersOfTime: contributedUsers }; + }); +}; + +/** + * Encapsulates fetching + processing the Contributors Report data for a single + * date range. Returns the computed summary so it can be rendered for both the + * primary period and an optional comparison period. + * + * @param {Object} params + * @param {Date} params.startDate + * @param {Date} params.endDate + * @param {Array} params.userProfiles + * @param {boolean} params.enabled - When false, no fetching/processing happens + * (used to skip work for the comparison period until the user enables it). + */ +export default function useContributorsData({ startDate, endDate, userProfiles, enabled = true }) { + const [loading, setLoading] = useState(true); + const [contributors, setContributors] = useState([]); + const [showMonthly, setShowMonthly] = useState(false); + const [showYearly, setShowYearly] = useState(false); + const [contributorsInMonth, setContributorsInMonth] = useState([]); + const [contributorsInYear, setContributorsInYear] = useState([]); + + const fromDate = useMemo(() => startDate.toLocaleDateString('en-CA'), [startDate]); + const toDate = useMemo(() => endDate.toLocaleDateString('en-CA'), [endDate]); + const userList = useMemo(() => userProfiles?.map(({ _id }) => _id) || [], [userProfiles]); + + const resetReportData = useCallback(() => { + setContributors([]); + setShowMonthly(false); + setShowYearly(false); + setContributorsInMonth([]); + setContributorsInYear([]); + }, []); + + const processTimeEntries = useCallback( + entries => { + if (entries.length === 0) { + resetReportData(); + return; + } + + const groupedUsers = Object.values(sumByUser(entries)); + setContributors(filterContributors(groupedUsers)); + + const diffDate = endDate - startDate; + let monthly = false; + let yearly = false; + let monthData = []; + let yearData = []; + if (diffDate > ONE_MONTH) { + monthData = generateBarDataUtil( + summaryOfTimeRange(entries, 'month'), + false, + startDate, + endDate, + 'usersOfTime', + ); + yearData = generateBarDataUtil( + summaryOfTimeRange(entries, 'year'), + true, + startDate, + endDate, + 'usersOfTime', + ); + if (diffDate <= ONE_MONTH * 12) { + monthly = true; + } + if (startDate.getFullYear() !== endDate.getFullYear()) { + yearly = true; + } + } + + setShowMonthly(monthly); + setShowYearly(yearly); + setContributorsInMonth(monthData); + setContributorsInYear(yearData); + }, + [endDate, resetReportData, startDate], + ); + + // Fetch time entries for the selected period + const loadTimeEntriesForPeriod = useCallback( + async controller => { + const url = ENDPOINTS.TIME_ENTRIES_REPORTS; + if (!url) { + return; + } + + if (!validateUserList(userList, userProfiles, REPORT_NAME)) { + resetReportData(); + setLoading(false); + return; + } + + const cacheKey = `${REPORT_NAME}_${fromDate}_${toDate}`; + const cached = getCachedData(cacheKey, REPORT_NAME); + if (cached.data) { + processTimeEntries(cached.data); + setLoading(false); + return; + } + + try { + logApiRequest( + REPORT_NAME, + url, + { users: userList, fromDate, toDate }, + { usersCount: userList?.length }, + ); + + const response = await axios.post( + url, + { users: userList, fromDate, toDate }, + { signal: controller.signal }, + ); + + logApiResponse(REPORT_NAME, response.data?.length); + + const mappedTimeEntries = response.data.map(entry => ({ + userId: entry.personId, + hours: entry.hours, + minutes: entry.minutes, + isTangible: entry.isTangible, + date: entry.dateOfWork, + })); + + processTimeEntries(mappedTimeEntries); + setCachedData(cacheKey, mappedTimeEntries, REPORT_NAME); + } catch (error) { + // eslint-disable-next-line import/no-named-as-default-member + if (!axios.isCancel(error)) { + // eslint-disable-next-line no-console + console.error(`${REPORT_NAME} API Error:`, error); + resetReportData(); + } + } + }, + [fromDate, processTimeEntries, resetReportData, toDate, userList, userProfiles], + ); + + // Load data when the date range changes + useEffect(() => { + if (!enabled) { + setLoading(false); + return undefined; + } + if (!userList || userList.length === 0) { + return undefined; + } + + setLoading(true); + const controller = new AbortController(); + loadTimeEntriesForPeriod(controller).then(() => { + setLoading(false); + }); + return () => controller.abort(); + }, [enabled, loadTimeEntriesForPeriod, userList]); + + const totalTangibleTime = contributors.reduce((acc, obj) => acc + Number(obj.tangibleTime), 0); + + return { + loading, + contributors, + totalTangibleTime, + showMonthly, + showYearly, + contributorsInMonth, + contributorsInYear, + }; +}