Skip to content

Commit d83eef6

Browse files
authored
Merge pull request #2568 from topcoder-platform/sales-dashboard-followups-dev
Update sales fiscal-year titles, date defaults and customer totals
2 parents 8b55790 + 02c1e58 commit d83eef6

12 files changed

Lines changed: 156 additions & 58 deletions

‎src/apps/sales/README.md‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ Multiple values use repeated query parameters. Deploy the PM-6639 Reports API
9696
change before this UI. Tests cover OR selection/removal, strict numeric bounds,
9797
combined filters, pagination resets, clearing, refresh and request serialization.
9898

99+
The pipeline title shows **Sales - FY '27** for April 2026–March 2027, using
100+
the fiscal year's ending year. Applied date bounds update the title; a range
101+
spanning fiscal years shows both (for example, **Sales - FY '26–'27**). With no
102+
date bounds, it shows the current UTC fiscal year. Monthly dashboards show the
103+
same fiscal-year suffix using their displayed months.
104+
99105
## Dashboard layout (PM-6392)
100106

101107
The page reads as an executive dashboard rather than one long column:
@@ -173,8 +179,9 @@ The date range panel, now the right half of the two-column row, filters the
173179
report by **Created Date** for pipeline generation, or by **Close Date** for
174180
revenue projection.
175181
The Filter type dropdown lists the report's own `date`/`datetime` columns rather
176-
than hard-coded Salesforce field IDs, and opens on the Created Date column when
177-
the report has one. From date and To date are inclusive and either may be left
182+
than hard-coded Salesforce field IDs, and opens on Close Date (or Closed Date)
183+
when the report has one, falling back to its first date field. Clear restores this
184+
default. From date and To date are inclusive and either may be left
178185
empty for an open-ended range.
179186

180187
Like the search and column filters, the range applies automatically (debounced)
@@ -365,7 +372,8 @@ The bottom **Customer** section replaces the Member payments by customer chart
365372
with two always-visible tables: **Revenue Customer-Wise**, from the optional API
366373
`customerRevenue` dataset, and **Goods Sold Customer-Wise**, from `customerPayments`.
367374
Each shows all customer names as rows and fiscal-year months as columns, with an
368-
Annual column, monthly totals and a grand total. No top-five/Other grouping is
375+
Annual column and a **Total** row directly below the Customer/month header,
376+
containing monthly totals and a grand total. No top-five/Other grouping is
369377
applied. Zero and unknown values remain distinct. Tables support keyboard
370378
scrolling, keep customer names visible while scrolling and show explicit empty
371379
states. Missing datasets and source notices are displayed beside the affected

‎src/apps/sales/src/SalesCustomerTable.tsx‎

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import styles from './SalesDashboardsPage.module.scss'
99
/**
1010
* Shows all customers as rows and fiscal-year months as columns in the bottom Customer section.
1111
* @param props Complete customer dataset, report title and source notices; no top-five/Other grouping.
12-
* @returns An accessible scrollable matrix with customer, month and grand totals, or an empty state.
12+
* @returns An accessible scrollable matrix with totals directly below the month headers, or an empty state.
1313
* @throws Does not throw for empty datasets. Unknown amounts stay distinct from zero.
1414
*/
1515
const SalesCustomerTable: FC<{ data: MonthlySalesChart; title: string; notices?: string[] }> = props => {
@@ -38,6 +38,13 @@ const SalesCustomerTable: FC<{ data: MonthlySalesChart; title: string; notices?:
3838
</tr>
3939
</thead>
4040
<tbody>
41+
<tr className={styles.annual}>
42+
<th scope='row'>Total</th>
43+
{data.months.map((month, index) => (
44+
<td key={month.month}>{salesChartValue(monthlyTotals[index], 'currency')}</td>
45+
))}
46+
<td>{salesChartValue(salesChartTotal(monthlyTotals), 'currency')}</td>
47+
</tr>
4148
{data.series.map(series => (
4249
<tr key={series.key}>
4350
<th scope='row'>{series.label}</th>
@@ -54,15 +61,6 @@ const SalesCustomerTable: FC<{ data: MonthlySalesChart; title: string; notices?:
5461
</tr>
5562
))}
5663
</tbody>
57-
<tfoot>
58-
<tr>
59-
<th scope='row'>Total</th>
60-
{data.months.map((month, index) => (
61-
<td key={month.month}>{salesChartValue(monthlyTotals[index], 'currency')}</td>
62-
))}
63-
<td>{salesChartValue(salesChartTotal(monthlyTotals), 'currency')}</td>
64-
</tr>
65-
</tfoot>
6664
</table>
6765
</div>
6866
) : <p>No customer data in this fiscal year.</p>}

‎src/apps/sales/src/SalesDashboardsPage.module.scss‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@
103103

104104
caption { color: #4a5577; padding: 0 0 16px; text-align: left; }
105105
th, td { white-space: nowrap; }
106-
thead, tfoot { background: #f4f6fa; font-weight: 700; }
106+
thead { background: #f4f6fa; font-weight: 700; }
107107
th:first-child { background: #fff; left: 0; position: sticky; }
108108
td:last-child { font-weight: 700; }
109109
}

‎src/apps/sales/src/SalesDashboardsPage.spec.tsx‎

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -360,28 +360,35 @@ const customerPayments: MonthlySalesChart = {
360360
unit: 'currency',
361361
}
362362

363-
it('renders a customer-by-month matrix with accurate row, column and grand totals', () => {
364-
render(<SalesCustomerTable title='Goods Sold Customer-Wise' data={customerPayments} />)
365-
const table = screen.getByRole('table')
366-
expect(within(table)
367-
.getAllByRole('columnheader')
368-
.map(cell => cell.textContent))
369-
.toEqual(['Customer', 'Apr 2026', 'May 2026', 'Jun 2026', 'Annual'])
370-
expect(within(table)
371-
.getByRole('row', { name: /Metro/ }))
372-
.toHaveTextContent('$8,500.00')
373-
expect(within(table)
374-
.getByRole('row', { name: /Rebel/ }))
375-
.toHaveTextContent('$21,000.00')
376-
expect(within(table)
377-
.getByRole('row', { name: /ANZ/ }))
378-
.toHaveTextContent('$15,500.00')
379-
expect(within(table)
380-
.getByRole('row', { name: /^Total/ }).textContent)
381-
.toBe('Total$12,500.00$14,500.00$18,000.00$45,000.00')
382-
expect(screen.getByRole('region'))
383-
.toHaveAttribute('tabindex', '0')
384-
})
363+
it.each(['Goods Sold Customer-Wise', 'Revenue Customer-Wise'])(
364+
'renders %s with totals directly below the month headers',
365+
title => {
366+
render(<SalesCustomerTable title={title} data={customerPayments} />)
367+
const table = screen.getByRole('table')
368+
expect(within(table)
369+
.getAllByRole('columnheader')
370+
.map(cell => cell.textContent))
371+
.toEqual(['Customer', 'Apr 2026', 'May 2026', 'Jun 2026', 'Annual'])
372+
expect(within(table)
373+
.getByRole('row', { name: /Metro/ }))
374+
.toHaveTextContent('$8,500.00')
375+
expect(within(table)
376+
.getByRole('row', { name: /Rebel/ }))
377+
.toHaveTextContent('$21,000.00')
378+
expect(within(table)
379+
.getByRole('row', { name: /ANZ/ }))
380+
.toHaveTextContent('$15,500.00')
381+
expect(within(table)
382+
.getByRole('row', { name: /^Total/ }).textContent)
383+
.toBe('Total$12,500.00$14,500.00$18,000.00$45,000.00')
384+
expect(within(table)
385+
.getAllByRole('row')[1])
386+
.toBe(within(table)
387+
.getByRole('row', { name: /^Total/ }))
388+
expect(screen.getByRole('region'))
389+
.toHaveAttribute('tabindex', '0')
390+
},
391+
)
385392

386393
it('keeps unknown customer amounts distinct from zero and handles an empty dataset', () => {
387394
const view = render(<SalesCustomerTable
@@ -443,6 +450,8 @@ it('places both customer tables at the bottom without duplicate requests or the
443450
expect(screen.queryByRole('heading', { name: 'Member payments by customer' })).not.toBeInTheDocument()
444451
ids.forEach(id => expect(screen.getByRole('heading', { name: id }))
445452
.toBeInTheDocument())
453+
expect(screen.getByRole('heading', { level: 1 }))
454+
.toHaveTextContent("Sales - FY '27")
446455
expect(screen.getByRole('heading', { name: 'Revenue' }))
447456
.toBeInTheDocument()
448457
expect(screen.queryByRole('heading', { name: 'Bookings' })).not.toBeInTheDocument()

‎src/apps/sales/src/SalesDashboardsPage.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { FC, ReactNode, useCallback, useState } from 'react'
33
import { Button, LoadingSpinner, PageTitle } from '~/libs/ui'
44

55
import { SalesDashboardPortal, SalesDashboardSection } from './sales-dashboard.service'
6+
import { salesFiscalYearLabel } from './sales-fiscal-year'
67
import { dashboardTitles, SalesDashboardSource, useSalesDashboard } from './useSalesDashboard'
78
import SalesCustomerTable from './SalesCustomerTable'
89
import SalesDashboardChart from './SalesDashboardChart'
@@ -91,15 +92,19 @@ const SalesDashboardsPage: FC<{ portal?: SalesDashboardPortal }> = props => {
9192
const talent = useSalesDashboard('talent', portal, dateBasis, refreshKey)
9293
const sources = { payments, pipeline, revenue, talent }
9394
const sections = ['pipeline', 'revenue', 'payments', 'talent'] as const
95+
const months = sections.map(section => sources[section].data?.charts[0]?.months)
96+
.find(value => value?.length)
97+
const fiscalYear = salesFiscalYearLabel(months?.[0]?.month, months?.[months.length - 1]?.month)
98+
const title = `${portal === 'sales' ? 'Sales' : 'Sales dashboards'} - ${fiscalYear}`
9499
const refresh = useCallback(() => setRefreshKey(value => value + 1), [])
95100
const chooseClose = useCallback(() => setDateBasis('close'), [])
96101
const chooseCreated = useCallback(() => setDateBasis('created'), [])
97102
return (
98103
<div className={styles.page}>
99-
<PageTitle>Sales dashboards</PageTitle>
104+
<PageTitle>{title}</PageTitle>
100105
<header className={styles.header}>
101106
<div>
102-
<h1>Sales dashboards</h1>
107+
<h1>{title}</h1>
103108
<p>Monthly business performance, from opportunity to member payments.</p>
104109
</div>
105110
<Button secondary onClick={refresh}>Refresh data</Button>

‎src/apps/sales/src/SalesPage.spec.tsx‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -468,17 +468,14 @@ describe('Sales page', () => {
468468
jest.useRealTimers()
469469
})
470470

471-
it('offers the report date fields, defaults to Created Date and applies a range as it changes', async () => {
471+
it('offers the report date fields, defaults to Close Date and applies a range as it changes', async () => {
472472
render(<SalesPage />)
473473
await screen.findByText('Example opportunity')
474474
const field = screen.getByLabelText('Filter type') as HTMLSelectElement
475475
expect([...field.options].map(option => option.text))
476476
.toEqual(['Created Date', 'Close Date'])
477-
expect(field.value)
478-
.toBe('CREATED_DATE')
479-
fireEvent.change(field, { target: { value: 'CLOSE_DATE' } })
480-
expect(screen.getByLabelText('Filter type'))
481-
.toHaveValue('CLOSE_DATE')
477+
await waitFor(() => expect(field)
478+
.toHaveValue('CLOSE_DATE'))
482479
fireEvent.change(screen.getByLabelText('From date'), { target: { value: '2026-07-01' } })
483480
fireEvent.change(screen.getByLabelText('To date'), { target: { value: '2026-09-30' } })
484481
expect(fetchReport)
@@ -488,10 +485,17 @@ describe('Sales page', () => {
488485
.toHaveBeenLastCalledWith(expect.objectContaining({
489486
dateColumn: 'CLOSE_DATE', dateFrom: '2026-07-01', dateTo: '2026-09-30', page: 1,
490487
}), expect.any(AbortSignal)))
491-
// The three changes within one pause are sent as a single request.
488+
// The date changes within one pause are sent as a single request.
492489
expect(fetchReport)
493490
.toHaveBeenCalledTimes(2)
494491
await screen.findByText(/Showing records by Close Date from 2026-07-01 through 2026-09-30/)
492+
expect(screen.getByRole('heading', { level: 1 }))
493+
.toHaveTextContent("Sales - FY '27")
494+
fireEvent.change(field, { target: { value: 'CREATED_DATE' } })
495+
await waitFor(() => expect(fetchReport)
496+
.toHaveBeenLastCalledWith(expect.objectContaining({ dateColumn: 'CREATED_DATE' }), expect.any(AbortSignal)))
497+
expect(field)
498+
.toHaveValue('CREATED_DATE')
495499
})
496500

497501
it('refuses an inverted range without sending a request and clears the error on Clear', async () => {
@@ -518,7 +522,7 @@ describe('Sales page', () => {
518522
fireEvent.change(screen.getByLabelText('From date'), { target: { value: '2026-09-01' } })
519523
await waitFor(() => expect(fetchReport)
520524
.toHaveBeenLastCalledWith(
521-
expect.objectContaining({ dateColumn: 'CREATED_DATE', dateFrom: '2026-09-01' }),
525+
expect.objectContaining({ dateColumn: 'CLOSE_DATE', dateFrom: '2026-09-01' }),
522526
expect.any(AbortSignal),
523527
))
524528
fireEvent.change(screen.getByLabelText('Search sales'), { target: { value: 'Example' } })
@@ -536,7 +540,7 @@ describe('Sales page', () => {
536540
))
537541
expect(fetchReport)
538542
.toHaveBeenLastCalledWith(
539-
expect.objectContaining({ dateColumn: 'CREATED_DATE', dateFrom: '2026-09-01' }),
543+
expect.objectContaining({ dateColumn: 'CLOSE_DATE', dateFrom: '2026-09-01' }),
540544
expect.any(AbortSignal),
541545
)
542546
fireEvent.click(clearButton('Date range filter'))

‎src/apps/sales/src/SalesPage.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
withDrilldown,
2929
wonSowSignedTotal,
3030
} from './sales.utils'
31+
import { salesFiscalYearLabel } from './sales-fiscal-year'
3132
import styles from './SalesPage.module.scss'
3233
import './sales.scss'
3334

@@ -351,18 +352,19 @@ const SalesPage: FC = () => {
351352
const totalAmount = summary && amountTotal(summary.amounts)
352353
const totalExpectedRevenue = summary && expectedRevenueTotal(summary.amounts)
353354
const totalWonSowSigned = wonSowSignedTotal(breakdown, totalAmount)
355+
const title = `Sales - ${salesFiscalYearLabel(query.dateFrom, query.dateTo)}`
354356
const firstRow = report?.total ? (report.page - 1) * report.perPage + 1 : 0
355357
const lastRow = report ? Math.min(report.page * report.perPage, report.total) : 0
356358
const updatedAt = report ? new Date(report.refreshedAt)
357359
.toLocaleString() : ''
358360

359361
return (
360362
<div className={`sales-app ${styles.page}`}>
361-
<PageTitle>Sales</PageTitle>
363+
<PageTitle>{title}</PageTitle>
362364
<header className={styles.header}>
363365
<div>
364366
<p className={styles.eyebrow}>WORK / SALES</p>
365-
<h1>Sales</h1>
367+
<h1>{title}</h1>
366368
<p className={styles.subtitle}>Your sales pipeline, directly from Salesforce.</p>
367369
</div>
368370
<div className={styles.headerActions}>

‎src/apps/sales/src/sales-dashboard.service.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { EnvironmentConfig } from '~/config'
22
import { xhrGetAsync } from '~/libs/core'
33

4+
import { salesFiscalYear } from './sales-fiscal-year'
5+
46
export type SalesDashboardSection = 'pipeline' | 'revenue' | 'payments' | 'talent' | 'gross-margin'
57
export type SalesDashboardPortal = 'reports' | 'sales'
68

@@ -41,8 +43,7 @@ export function fetchSalesDashboard(
4143
dateBasis: 'close' | 'created',
4244
signal: AbortSignal,
4345
): Promise<SalesDashboardResponse> {
44-
const now = new Date()
45-
const fiscalYear = now.getUTCFullYear() - (now.getUTCMonth() < 3 ? 1 : 0)
46+
const fiscalYear = salesFiscalYear() - 1
4647
const params = new URLSearchParams({
4748
dateBasis,
4849
endDate: new Date(Date.UTC(fiscalYear + 1, 3, 1))
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { salesFiscalYear, salesFiscalYearLabel } from './sales-fiscal-year'
2+
3+
describe('Sales fiscal year titles', () => {
4+
afterEach(() => jest.useRealTimers())
5+
6+
it.each([
7+
['2026-10-28T00:00:00Z', 2027],
8+
['2027-01-01T00:00:00Z', 2027],
9+
['2027-03-31T23:59:59Z', 2027],
10+
['2027-04-01T00:00:00Z', 2028],
11+
['2027-04-01T00:30:00+11:00', 2027],
12+
])('labels %s by the fiscal year ending in %s', (date, year) => {
13+
jest.useFakeTimers()
14+
.setSystemTime(new Date(date))
15+
expect(salesFiscalYear())
16+
.toBe(year)
17+
expect(salesFiscalYearLabel())
18+
.toBe(`FY '${String(year)
19+
.slice(-2)}`)
20+
})
21+
22+
it('uses the viewed bounds, including open-ended and multi-year ranges', () => {
23+
expect(salesFiscalYearLabel('2026-04-01', '2027-03-31'))
24+
.toBe("FY '27")
25+
expect(salesFiscalYearLabel('2025-07-01'))
26+
.toBe("FY '26")
27+
expect(salesFiscalYearLabel(undefined, '2026-02-01'))
28+
.toBe("FY '26")
29+
expect(salesFiscalYearLabel('2026-03-01', '2026-04-30'))
30+
.toBe("FY '26–'27")
31+
})
32+
})
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Resolves the ending year of Sales' April–March fiscal year in UTC.
3+
* @param date Date within the fiscal year; defaults to today.
4+
* @returns Ending calendar year, used by request bounds and report titles. Does not throw for valid dates.
5+
*/
6+
export function salesFiscalYear(date: Date = new Date()): number {
7+
return date.getUTCFullYear() + (date.getUTCMonth() >= 3 ? 1 : 0)
8+
}
9+
10+
/**
11+
* Labels the fiscal year or years being viewed without hardcoding the current year.
12+
* @param from Inclusive ISO start date, if the view has one.
13+
* @param to Inclusive ISO end date, if the view has one.
14+
* @returns A short FY label; a single bound selects its year and no bounds select today's year.
15+
* @throws Does not throw for valid report dates.
16+
*/
17+
export function salesFiscalYearLabel(from?: string, to?: string): string {
18+
const bound = from || to
19+
const first = salesFiscalYear(bound ? new Date(bound) : undefined)
20+
const last = to ? salesFiscalYear(new Date(to)) : first
21+
const start = String(first)
22+
.slice(-2)
23+
const end = String(last)
24+
.slice(-2)
25+
return first === last ? `FY '${start}` : `FY '${start}–'${end}`
26+
}

0 commit comments

Comments
 (0)