Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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 && (
<div role="status" aria-live="polite">
Loading financial overview...
</div>
)}
{error && <div role="alert">{error}</div>}
<StatusCardGrid cards={cards} showComparison={showComparison} idPrefix="financial-overview" />
</>
);
}

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;
Original file line number Diff line number Diff line change
@@ -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(<FinancialOverview requestParams={PARAMS} comparisonPeriodFilter="No Comparison" />);

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(<FinancialOverview requestParams={PARAMS} comparisonPeriodFilter="No Comparison" />);

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(
<FinancialOverview
requestParams={{
...PARAMS,
comparisonStartDate: '2026-10-19',
comparisonEndDate: '2026-10-25',
}}
comparisonPeriodFilter="Week Over Week"
/>,
);

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(<FinancialOverview requestParams={PARAMS} comparisonPeriodFilter="No Comparison" />);

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(<FinancialOverview requestParams={PARAMS} comparisonPeriodFilter="Week Over Week" />);

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(<FinancialOverview requestParams={null} comparisonPeriodFilter="No Comparison" />);

await waitFor(() => expect(screen.getAllByText('N/A')).toHaveLength(6));
expect(axios.get).not.toHaveBeenCalled();
});
});
Loading
Loading