diff --git a/src/components/BMDashboard/WeeklyProjectSummary/CardSelector.jsx b/src/components/BMDashboard/WeeklyProjectSummary/CardSelector.jsx
index 5207e535d7..3a351646cc 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 0000000000..695f8fc005
--- /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 0000000000..2033462667
--- /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 0000000000..789f2803ee
--- /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 050e2faa08..105258fb50 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 0a1e385653..fe62e4d0c6 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 6dc578912f..2620498ab0 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 57df9efcd6..b035754e1e 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`,