From c67ad9278e4b0df43e1d0ed8d4da98bbba3a9c30 Mon Sep 17 00:00:00 2001 From: Sripriya Srikanth Date: Thu, 5 Nov 2026 14:22:33 -0700 Subject: [PATCH] Add Financials: Overview section with 6 cost cards to the Total Construction Summary --- .../WeeklyProjectSummary/CardSelector.jsx | 8 +- .../Financials/FinancialOverview.jsx | 89 +++++++++ .../__tests__/FinancialOverview.test.jsx | 123 ++++++++++++ .../WeeklyProjectSummary/StatusCards.jsx | 183 ++++++++++++++++++ .../WeeklyProjectSummary.jsx | 179 +++-------------- ...eeklyProjectSummary.projectStatus.test.jsx | 3 + .../__tests__/WeeklyProjectSummary.test.jsx | 4 + src/utils/URL.js | 1 + 8 files changed, 432 insertions(+), 158 deletions(-) create mode 100644 src/components/BMDashboard/WeeklyProjectSummary/Financials/FinancialOverview.jsx create mode 100644 src/components/BMDashboard/WeeklyProjectSummary/Financials/__tests__/FinancialOverview.test.jsx create mode 100644 src/components/BMDashboard/WeeklyProjectSummary/StatusCards.jsx diff --git a/src/components/BMDashboard/WeeklyProjectSummary/CardSelector.jsx b/src/components/BMDashboard/WeeklyProjectSummary/CardSelector.jsx index 5207e535d7e..3a351646cc5 100644 --- a/src/components/BMDashboard/WeeklyProjectSummary/CardSelector.jsx +++ b/src/components/BMDashboard/WeeklyProjectSummary/CardSelector.jsx @@ -9,7 +9,13 @@ export const CARD_PRESETS = [ { id: 'finance', label: 'Finance Focus', - keys: ['Financials', 'Financials Tracking', 'Loss Tracking', 'Material Consumption'], + keys: [ + 'Financials', + 'Financials: Overview', + 'Financials Tracking', + 'Loss Tracking', + 'Material Consumption', + ], }, { id: 'schedule', diff --git a/src/components/BMDashboard/WeeklyProjectSummary/Financials/FinancialOverview.jsx b/src/components/BMDashboard/WeeklyProjectSummary/Financials/FinancialOverview.jsx new file mode 100644 index 00000000000..695f8fc0051 --- /dev/null +++ b/src/components/BMDashboard/WeeklyProjectSummary/Financials/FinancialOverview.jsx @@ -0,0 +1,89 @@ +import { useEffect, useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; +import axios from 'axios'; +import { ENDPOINTS } from '~/utils/URL'; +import { StatusCardGrid, buildStatusCards, toCardButtons } from '../StatusCards'; + +// Order and colors follow the "Financials: Overview" row in the HGN Phase 2 Dashboard Figma. +export const financialOverviewButtons = toCardButtons([ + ['Total Projected Cost', 'totalProjectedCost', '#E8E8FF', '#CBCBFE', '#328D1B'], + ['Predicted Cost Overrun', 'predictedCostOverrun', '#F3FCFF', '#C1EFFB', '#328D1B'], + ['Total Project Cost', 'totalProjectCost', '#EEF0FF', '#C9CEFB', '#328D1B'], + ['Total Material Cost', 'totalMaterialCost', '#F3FCFF', '#C1EFFB', '#328D1B'], + ['Total Labor Cost', 'totalLaborCost', '#FFE9FA', '#FECFF3', '#328D1B'], + ['Total Equipment Cost', 'totalEquipmentCost', '#F0FFEE', '#BAF0B6', '#328D1B'], +]); + +const EMPTY_METRICS = {}; + +function FinancialOverview({ requestParams, comparisonPeriodFilter }) { + const [metrics, setMetrics] = useState(EMPTY_METRICS); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!requestParams) return undefined; + + // Drop a slower, older response if the filters change while it is still loading. + let isCurrent = true; + setLoading(true); + setError(null); + + axios + .get(ENDPOINTS.BM_WEEKLY_PROJECT_SUMMARY_FINANCIAL_OVERVIEW, { params: requestParams }) + .then(response => { + if (isCurrent) setMetrics(response.data?.current?.metrics || EMPTY_METRICS); + }) + .catch(err => { + if (!isCurrent) return; + setMetrics(EMPTY_METRICS); + setError( + err?.response?.data?.message || + 'Unable to load the financial overview. Please try again.', + ); + }) + .finally(() => { + if (isCurrent) setLoading(false); + }); + + return () => { + isCurrent = false; + }; + }, [requestParams]); + + const cards = useMemo( + () => buildStatusCards(financialOverviewButtons, metrics, comparisonPeriodFilter), + [metrics, comparisonPeriodFilter], + ); + + const showComparison = comparisonPeriodFilter !== 'No Comparison' && !loading && !error; + + return ( + <> + {loading && ( +
+ Loading financial overview... +
+ )} + {error &&
{error}
} + + + ); +} + +FinancialOverview.propTypes = { + requestParams: PropTypes.shape({ + startDate: PropTypes.string, + endDate: PropTypes.string, + projectId: PropTypes.string, + comparisonStartDate: PropTypes.string, + comparisonEndDate: PropTypes.string, + }), + comparisonPeriodFilter: PropTypes.string.isRequired, +}; + +FinancialOverview.defaultProps = { + requestParams: null, +}; + +export default FinancialOverview; diff --git a/src/components/BMDashboard/WeeklyProjectSummary/Financials/__tests__/FinancialOverview.test.jsx b/src/components/BMDashboard/WeeklyProjectSummary/Financials/__tests__/FinancialOverview.test.jsx new file mode 100644 index 00000000000..2033462667d --- /dev/null +++ b/src/components/BMDashboard/WeeklyProjectSummary/Financials/__tests__/FinancialOverview.test.jsx @@ -0,0 +1,123 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import axios from 'axios'; +import FinancialOverview, { financialOverviewButtons } from '../FinancialOverview'; + +vi.mock('axios'); + +const PARAMS = { startDate: '2026-10-26', endDate: '2026-11-01' }; +const unavailable = { + value: null, + unit: 'USD', + comparisonType: 'UNAVAILABLE', + unavailableReason: 'No budget yet.', +}; +const cost = (value, percentageChange) => ({ + value, + unit: 'USD', + comparisonType: 'PERIOD_COMPARABLE', + percentageChange, +}); + +function mockMetrics() { + axios.get.mockResolvedValue({ + data: { + current: { + metrics: { + totalProjectedCost: unavailable, + predictedCostOverrun: unavailable, + totalProjectCost: cost(63234, -0.01), + totalMaterialCost: cost(53678, 0.16), + totalLaborCost: cost(9556, -0.45), + totalEquipmentCost: { + ...unavailable, + unavailableReason: 'No data exists for the selected period.', + }, + }, + }, + }, + }); +} + +describe('FinancialOverview', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows the six cards in the Figma order', async () => { + mockMetrics(); + render(); + + expect(await screen.findByText('$63,234')).toBeInTheDocument(); + expect(financialOverviewButtons.map(button => button.title)).toEqual([ + 'Total Projected Cost', + 'Predicted Cost Overrun', + 'Total Project Cost', + 'Total Material Cost', + 'Total Labor Cost', + 'Total Equipment Cost', + ]); + financialOverviewButtons.forEach(button => { + expect(screen.getByText(button.title)).toBeInTheDocument(); + }); + expect(screen.getByText('$53,678')).toBeInTheDocument(); + expect(screen.getByText('$9,556')).toBeInTheDocument(); + expect(screen.getAllByText('N/A')).toHaveLength(3); + expect(screen.queryByText(/week over week/)).not.toBeInTheDocument(); + }); + + it('loads the page filters from the financial overview endpoint', async () => { + mockMetrics(); + render(); + + await screen.findByText('$63,234'); + expect(axios.get).toHaveBeenCalledWith( + expect.stringContaining('/bm/weekly-project-summary/financial-overview'), + { params: PARAMS }, + ); + }); + + it('adds a week over week line under each card', async () => { + mockMetrics(); + render( + , + ); + + expect(await screen.findByText('+16% week over week')).toBeInTheDocument(); + expect(screen.getByText('-1% week over week')).toBeInTheDocument(); + expect(screen.getByText('-45% week over week')).toBeInTheDocument(); + expect(screen.getAllByText('No week over week comparison available')).toHaveLength(3); + }); + + it('explains why a card has no value', async () => { + mockMetrics(); + render(); + + expect(await screen.findAllByRole('button', { name: 'No budget yet.' })).toHaveLength(2); + expect( + screen.getByRole('button', { name: 'No data exists for the selected period.' }), + ).toBeInTheDocument(); + }); + + it('shows an error when the overview cannot load', async () => { + axios.get.mockRejectedValue({ response: { data: { message: 'endDate is required' } } }); + render(); + + expect(await screen.findByRole('alert')).toHaveTextContent('endDate is required'); + expect(screen.queryByText(/week over week/)).not.toBeInTheDocument(); + }); + + it('waits for a date range before loading', async () => { + render(); + + await waitFor(() => expect(screen.getAllByText('N/A')).toHaveLength(6)); + expect(axios.get).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/BMDashboard/WeeklyProjectSummary/StatusCards.jsx b/src/components/BMDashboard/WeeklyProjectSummary/StatusCards.jsx new file mode 100644 index 00000000000..789f2803eec --- /dev/null +++ b/src/components/BMDashboard/WeeklyProjectSummary/StatusCards.jsx @@ -0,0 +1,183 @@ +import PropTypes from 'prop-types'; +import { UncontrolledTooltip } from 'reactstrap'; +import styles from './WeeklyProjectSummary.module.css'; + +// Shared by the Project Status and Financials: Overview cards, which use the same metric format: +// { value, unit, comparisonType, percentageChange, unavailableReason } from the weekly summary API. +const PERIOD_COMPARABLE = 'PERIOD_COMPARABLE'; +const UNAVAILABLE = 'UNAVAILABLE'; + +export function toCardButtons(buttonData) { + return buttonData.map(([title, metricKey, bgColor, buttonColor, textColor]) => ({ + title, + metricKey, + bgColor, + buttonColor, + textColor, + })); +} + +export function formatProjectStatusValue(metric) { + if (!metric || metric.comparisonType === UNAVAILABLE || metric.value === null) { + return 'N/A'; + } + + if (metric.unit === 'USD') { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(metric.value); + } + + if (metric.unit === 'hrs') { + return `${metric.value} hrs`; + } + + if (typeof metric.value === 'number') { + return new Intl.NumberFormat('en-US').format(metric.value); + } + + return metric.value; +} + +export function getProjectStatusUnavailableReason(metric) { + if (!metric || (metric.comparisonType !== UNAVAILABLE && metric.value !== null)) { + return null; + } + + return metric.unavailableReason || 'Data does not exist for the selected period.'; +} + +function getProjectStatusComparisonUnavailableText(comparisonPeriodFilter) { + return `No ${comparisonPeriodFilter.toLowerCase()} comparison available`; +} + +export function formatProjectStatusChange(metric, comparisonPeriodFilter) { + const percentageChange = metric?.percentageChange; + + if (comparisonPeriodFilter === 'No Comparison') { + return null; + } + + if ( + metric?.comparisonType !== PERIOD_COMPARABLE || + percentageChange === null || + percentageChange === undefined || + typeof percentageChange === 'string' + ) { + return getProjectStatusComparisonUnavailableText(comparisonPeriodFilter); + } + + const percent = Math.round(percentageChange * 100); + const prefix = percent > 0 ? '+' : ''; + return `${prefix}${percent}% ${comparisonPeriodFilter.toLowerCase()}`; +} + +export function getProjectStatusChangeColor(metric) { + if (typeof metric?.percentageChange !== 'number') { + return '#C82F2F'; + } + + if (metric.percentageChange === 0) { + return '#000'; + } + + return metric.percentageChange > 0 ? '#328D1B' : '#C82F2F'; +} + +export function buildStatusCards(buttons, metrics, comparisonPeriodFilter) { + return buttons.map(button => { + const metric = metrics[button.metricKey]; + return { + ...button, + value: formatProjectStatusValue(metric), + unavailableReason: getProjectStatusUnavailableReason(metric), + change: formatProjectStatusChange(metric, comparisonPeriodFilter), + changeColor: getProjectStatusChangeColor(metric), + }; + }); +} + +export function StatusCardGrid({ cards, showComparison, idPrefix }) { + return ( +
+ {cards.map(button => { + const unavailableTooltipId = `${idPrefix}-unavailable-${button.metricKey}`; + return ( +
+
{button.title}
+
+ + {button.value} + {button.unavailableReason && ( + <> + + + + + {button.unavailableReason} + + + )} + +
+ {showComparison && button.change && ( +
+ {button.change} +
+ )} +
+ ); + })} +
+ ); +} + +StatusCardGrid.propTypes = { + cards: PropTypes.arrayOf( + PropTypes.shape({ + title: PropTypes.string.isRequired, + metricKey: PropTypes.string.isRequired, + bgColor: PropTypes.string, + buttonColor: PropTypes.string, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + unavailableReason: PropTypes.string, + change: PropTypes.string, + changeColor: PropTypes.string, + }), + ).isRequired, + showComparison: PropTypes.bool.isRequired, + idPrefix: PropTypes.string.isRequired, +}; diff --git a/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.jsx b/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.jsx index 050e2faa08d..105258fb508 100644 --- a/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.jsx +++ b/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.jsx @@ -3,7 +3,6 @@ /* eslint-disable import/no-unresolved */ import { useState, useEffect, useMemo, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { UncontrolledTooltip } from 'reactstrap'; import html2canvas from 'html2canvas'; import { jsPDF } from 'jspdf'; import { toast } from 'react-toastify'; @@ -40,6 +39,8 @@ import RentalChart from '../RentalChart/RentalChart'; import ReturnedLateChart from '../RentalChart/ReturnedLateChart'; import InjurySeverityChart from '../Injuries/InjurySeverityChart'; import { calculateComparisonDates, parseWeeklySummaryDateRange } from './comparisonDateUtils'; +import { StatusCardGrid, buildStatusCards, toCardButtons } from './StatusCards'; +import FinancialOverview from './Financials/FinancialOverview'; const projectStatusButtonData = [ ['Total Projects', 'totalProjects', '#F0FFEE', '#BAF0B6', '#328D1B'], @@ -55,89 +56,9 @@ const projectStatusButtonData = [ ['Material Wasted', 'materialWasted', '#EFBABB', '#F79395', '#328D1B'], ]; -const projectStatusButtons = projectStatusButtonData.map( - ([title, metricKey, bgColor, buttonColor, textColor]) => ({ - title, - metricKey, - bgColor, - buttonColor, - textColor, - }), -); - -const PERIOD_COMPARABLE = 'PERIOD_COMPARABLE'; -const UNAVAILABLE = 'UNAVAILABLE'; -const EMPTY_PROJECT_STATUS_METRICS = {}; - -function formatProjectStatusValue(metric) { - if (!metric || metric.comparisonType === UNAVAILABLE || metric.value === null) { - return 'N/A'; - } - - if (metric.unit === 'USD') { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 0, - maximumFractionDigits: 2, - }).format(metric.value); - } - - if (metric.unit === 'hrs') { - return `${metric.value} hrs`; - } - - if (typeof metric.value === 'number') { - return new Intl.NumberFormat('en-US').format(metric.value); - } - - return metric.value; -} - -function getProjectStatusUnavailableReason(metric) { - if (!metric || (metric.comparisonType !== UNAVAILABLE && metric.value !== null)) { - return null; - } - - return metric.unavailableReason || 'Data does not exist for the selected period.'; -} - -function getProjectStatusComparisonUnavailableText(comparisonPeriodFilter) { - return `No ${comparisonPeriodFilter.toLowerCase()} comparison available`; -} - -function formatProjectStatusChange(metric, comparisonPeriodFilter) { - const percentageChange = metric?.percentageChange; - - if (comparisonPeriodFilter === 'No Comparison') { - return null; - } - - if ( - metric?.comparisonType !== PERIOD_COMPARABLE || - percentageChange === null || - percentageChange === undefined || - typeof percentageChange === 'string' - ) { - return getProjectStatusComparisonUnavailableText(comparisonPeriodFilter); - } - - const percent = Math.round(percentageChange * 100); - const prefix = percent > 0 ? '+' : ''; - return `${prefix}${percent}% ${comparisonPeriodFilter.toLowerCase()}`; -} - -function getProjectStatusChangeColor(metric) { - if (typeof metric?.percentageChange !== 'number') { - return '#C82F2F'; - } - - if (metric.percentageChange === 0) { - return '#000'; - } +const projectStatusButtons = toCardButtons(projectStatusButtonData); - return metric.percentageChange > 0 ? '#328D1B' : '#C82F2F'; -} +const EMPTY_PROJECT_STATUS_METRICS = {}; function getProjectStatusErrorMessage(error) { return ( @@ -298,17 +219,7 @@ function WeeklyProjectSummary() { : EMPTY_PROJECT_STATUS_METRICS; const projectStatusCards = useMemo( - () => - projectStatusButtons.map(button => { - const metric = projectStatusMetrics[button.metricKey]; - return { - ...button, - value: formatProjectStatusValue(metric), - unavailableReason: getProjectStatusUnavailableReason(metric), - change: formatProjectStatusChange(metric, comparisonPeriodFilter), - changeColor: getProjectStatusChangeColor(metric), - }; - }), + () => buildStatusCards(projectStatusButtons, projectStatusMetrics, comparisonPeriodFilter), [comparisonPeriodFilter, projectStatusMetrics], ); @@ -334,69 +245,11 @@ function WeeklyProjectSummary() { {projectStatusDisplayError && (
{getProjectStatusErrorMessage(projectStatusDisplayError)}
)} -
- {projectStatusCards.map(button => { - const unavailableTooltipId = `project-status-unavailable-${button.metricKey}`; - return ( -
-
{button.title}
-
- - {button.value} - {button.unavailableReason && ( - <> - - - - - {button.unavailableReason} - - - )} - -
- {showProjectStatusComparison && button.change && ( -
- {button.change} -
- )} -
- ); - })} -
+ ), }, @@ -628,6 +481,17 @@ function WeeklyProjectSummary() { ), }, + { + title: 'Financials: Overview', + key: 'Financials: Overview', + className: 'full', + content: ( + + ), + }, { title: 'Financials Tracking', key: 'Financials Tracking', @@ -649,6 +513,7 @@ function WeeklyProjectSummary() { showProjectStatusComparison, currentDateRange, comparisonDateRange, + projectStatusRequestParams, ], ); diff --git a/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.projectStatus.test.jsx b/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.projectStatus.test.jsx index 0a1e3856535..fe62e4d0c67 100644 --- a/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.projectStatus.test.jsx +++ b/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.projectStatus.test.jsx @@ -63,6 +63,9 @@ vi.mock('./Financials/CostBreakDown/CostBreakDown', () => ({ vi.mock('./ExpenditureChart/FinancialsTrackingSection', () => ({ default: () =>
Financials Tracking
, })); +vi.mock('./Financials/FinancialOverview', () => ({ + default: () =>
Financial Overview
, +})); vi.mock('../InteractiveMap/InteractiveMap', () => ({ default: () =>
Interactive Map
})); vi.mock('./Financials/LossTrackingLineCharts/LossTrackingLineChart', () => ({ default: () =>
Loss Tracking
, diff --git a/src/components/BMDashboard/WeeklyProjectSummary/__tests__/WeeklyProjectSummary.test.jsx b/src/components/BMDashboard/WeeklyProjectSummary/__tests__/WeeklyProjectSummary.test.jsx index 6dc578912fc..2620498ab0f 100644 --- a/src/components/BMDashboard/WeeklyProjectSummary/__tests__/WeeklyProjectSummary.test.jsx +++ b/src/components/BMDashboard/WeeklyProjectSummary/__tests__/WeeklyProjectSummary.test.jsx @@ -54,6 +54,10 @@ vi.mock('../ExpenditureChart/FinancialsTrackingSection', () => ({ default: () =>
Financial Tracking
, })); +vi.mock('../Financials/FinancialOverview', () => ({ + default: () =>
Financial Overview
, +})); + vi.mock('../../InteractiveMap/InteractiveMap', () => ({ default: () =>
Interactive Map
, })); diff --git a/src/utils/URL.js b/src/utils/URL.js index 57df9efcd63..b035754e1e2 100644 --- a/src/utils/URL.js +++ b/src/utils/URL.js @@ -388,6 +388,7 @@ export const ENDPOINTS = { BM_PROJECT_EXPENSE_BY_ID: projectId => `${APIEndpoint}/bm/project/${projectId}/expenses`, BM_PROJECT_BY_ID: projectId => `${APIEndpoint}/bm/project/${projectId}`, BM_WEEKLY_PROJECT_SUMMARY_PROJECT_STATUS: `${APIEndpoint}/bm/weekly-project-summary/project-status`, + BM_WEEKLY_PROJECT_SUMMARY_FINANCIAL_OVERVIEW: `${APIEndpoint}/bm/weekly-project-summary/financial-overview`, BM_DELETE_INVENTORTY_TYPE_BY_ID: id => `${APIEndpoint}/bm/invtypes/${id}`, BM_UPDATE_INVENTORY_TYPE_BY_ID: id => `${APIEndpoint}/bm/invtypes/${id}`, BM_PROJECT_MATERIALS_COST: `${APIEndpoint}/material-costs`,