Skip to content
Merged
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
23 changes: 14 additions & 9 deletions src/apps/sales/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,19 @@ with its Close button or the X icon; obsolete lookups are aborted.

## Date range filter (PM-6364)

A date range section at the top of the page filters the report by **Created
Date** for pipeline generation, or by **Close Date** for revenue projection.
A date range panel at the top of the page, headed inside its box like the
report panel, filters the report by **Created Date** for pipeline generation,
or by **Close Date** for revenue projection.
The Filter type dropdown lists the report's own `date`/`datetime` columns rather
than hard-coded Salesforce field IDs, and opens on the Created Date column when
the report has one. From date and To date are inclusive and either may be left
empty for an open-ended range.

Unlike the search and column filters, the range is only sent when **Apply
filter** is pressed, and **Reset filter** clears it without disturbing search,
column filters or sorting. Clearing the report filters likewise leaves the range
intact. An inverted range is reported inline and never sent. A report with no
date columns disables the section.
Like the search and column filters, the range applies automatically (debounced)
as the Filter type, From date or To date change. **Clear** empties it without
disturbing search, column filters or sorting, and clearing the report filters
likewise leaves the range intact. An inverted range is reported inline and never
sent. A report with no date columns disables the panel.

The Reports API applies the range across the whole received snapshot before
paginating, so a filtered count is the real matching count and not a per-page
Expand All @@ -42,8 +43,12 @@ Summary tiles above the report show the metrics the current filters produce over
every matching record: opportunity count, a total per numeric column (pipeline
value and revenue projections), and a breakdown per category column such as
Stage. The API computes them, so they never describe only the visible page.
Totals show their shared currency; a total that sums different currencies is
rendered as a plain number and labelled as mixed. Tiles are hidden when the API
Each tile shows only its label and value. A total whose converted counterpart
the report also provides, such as Amount beside Amount (converted), is hidden
for now as redundant. Totals show their shared currency, and a single-currency
total the report leaves uncoded is shown in US dollars to match the converted
columns; a total that sums different currencies is rendered as a plain number
and labelled as mixed. Tiles are hidden when the API
returns no `summary`, which keeps the page working against an API that predates
this feature.

Expand Down
9 changes: 3 additions & 6 deletions src/apps/sales/src/SalesPage.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,8 @@
.filterField input:disabled { background: $tc-2026-border; cursor: not-allowed; }
.filterActions { min-height: 48px; }
.dateFilters { margin-bottom: 24px; }
.dateFieldset { background: $tc-2026-surface; border: 1px solid $tc-2026-border; border-radius: $tc-2026-radius-lg; box-shadow: $tc-2026-shadow-card; padding: 24px; margin: 0; }
.dateLegend { font-size: $tc-2026-h4-size; line-height: 32px; font-weight: 700; padding: 0; }
.dateHint { margin-top: 4px; color: $tc-2026-muted; max-width: 820px; }
.dateControls { display: flex; align-items: flex-end; flex-wrap: wrap; gap: 16px; margin-top: 20px; }
.dateStatus { margin-top: 16px; font-size: 13px; color: $tc-2026-muted; }
.dateHint { max-width: 820px; }
.dateStatus { flex-basis: 100%; font-size: 13px; color: $tc-2026-muted; }
.dateError { color: $tc-2026-danger; font-weight: 700; }
.summary { margin-bottom: 24px; display: flex; flex-direction: column; gap: 16px; }
.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; }
Expand Down Expand Up @@ -92,7 +89,7 @@
.page { padding: 20px 12px 40px; }
.header h1 { font-size: 36px; line-height: 44px; }
.headerActions { width: 100%; justify-content: space-between; }
.panelHeader, .filters, .pagination, .dateFieldset, .metric, .breakdown { padding: 16px; }
.panelHeader, .filters, .pagination, .metric, .breakdown { padding: 16px; }
.panelHeader { align-items: flex-start; flex-direction: column; }
.filterField { flex-basis: 100%; }
.filterActions { width: 100%; }
Expand Down
68 changes: 53 additions & 15 deletions src/apps/sales/src/SalesPage.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
import '@testing-library/jest-dom'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { ButtonHTMLAttributes, ReactNode } from 'react'

import SalesPage from './SalesPage'
Expand Down Expand Up @@ -71,12 +71,30 @@ function fixture(): SalesReport {
sourceRowCount: 30,
summary: {
amounts: [{
columnId: 'AMOUNT',
columnId: 'AMOUNT_CONVERTED',
count: 28,
currencyCode: 'USD',
label: 'Amount',
label: 'Amount (converted)',
mixedCurrency: false,
total: 1234567,
}, {
columnId: 'AMOUNT',
count: 28,
label: 'Amount',
mixedCurrency: false,
total: 987654,
}, {
columnId: 'EXP_AMOUNT',
count: 28,
label: 'Expected Revenue',
mixedCurrency: false,
total: 555555,
}, {
columnId: 'LOCAL_FEE',
count: 3,
label: 'Local fee',
mixedCurrency: true,
total: 2468,
}],
groups: [{
amountColumnId: 'AMOUNT',
Expand All @@ -94,6 +112,12 @@ function fixture(): SalesReport {
}
}

/** @returns The Clear control of the named section, keeping the two Clear buttons apart. Does not throw. */
function clearButton(section: string): HTMLElement {
return within(screen.getByRole('region', { name: section }))
.getByRole('button', { name: 'Clear' })
}

describe('Sales page', () => {
beforeEach(() => {
fetchOpportunityDetails.mockReset()
Expand Down Expand Up @@ -213,7 +237,7 @@ describe('Sales page', () => {
jest.useRealTimers()
})

it('offers the report date fields, defaults to Created Date and applies an inclusive range', async () => {
it('offers the report date fields, defaults to Created Date and applies a range as it changes', async () => {
render(<SalesPage />)
await screen.findByText('Example opportunity')
const field = screen.getByLabelText('Filter type') as HTMLSelectElement
Expand All @@ -226,37 +250,39 @@ describe('Sales page', () => {
fireEvent.change(screen.getByLabelText('To date'), { target: { value: '2026-09-30' } })
expect(fetchReport)
.toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByRole('button', { name: 'Apply filter' }))
expect(screen.queryByRole('button', { name: 'Apply filter' })).not.toBeInTheDocument()
await waitFor(() => expect(fetchReport)
.toHaveBeenLastCalledWith(expect.objectContaining({
dateColumn: 'CLOSE_DATE', dateFrom: '2026-07-01', dateTo: '2026-09-30', page: 1,
}), expect.any(AbortSignal)))
// The three changes within one pause are sent as a single request.
expect(fetchReport)
.toHaveBeenCalledTimes(2)
await screen.findByText(/Showing records by Close Date from 2026-07-01 through 2026-09-30/)
})

it('refuses an inverted range without sending a request and clears the error on reset', async () => {
it('refuses an inverted range without sending a request and clears the error on Clear', async () => {
render(<SalesPage />)
await screen.findByText('Example opportunity')
fireEvent.change(screen.getByLabelText('From date'), { target: { value: '2026-09-30' } })
fireEvent.change(screen.getByLabelText('To date'), { target: { value: '2026-09-01' } })
fireEvent.click(screen.getByRole('button', { name: 'Apply filter' }))
await screen.findByText('The From date must be on or before the To date.')
expect(fetchReport)
.toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByRole('button', { name: 'Reset filter' }))
fireEvent.click(clearButton('Date range filter'))
await waitFor(() => expect(screen.queryByText('The From date must be on or before the To date.'))
.not.toBeInTheDocument())
expect(screen.getByLabelText('From date'))
.toHaveValue('')
await screen.findByText('No date range applied.')
expect(fetchReport)
.toHaveBeenCalledTimes(1)
})

it('resets an applied range and keeps the range when report filters are cleared', async () => {
it('clears an applied range and keeps the range when report filters are cleared', async () => {
render(<SalesPage />)
await screen.findByText('Example opportunity')
fireEvent.change(screen.getByLabelText('From date'), { target: { value: '2026-09-01' } })
fireEvent.click(screen.getByRole('button', { name: 'Apply filter' }))
await waitFor(() => expect(fetchReport)
.toHaveBeenLastCalledWith(
expect.objectContaining({ dateColumn: 'CREATED_DATE', dateFrom: '2026-09-01' }),
Expand All @@ -269,7 +295,7 @@ describe('Sales page', () => {
expect.any(AbortSignal),
))
// Clearing the report filters must not silently empty the separate date range.
fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
fireEvent.click(clearButton('Sales report'))
await waitFor(() => expect(fetchReport)
.toHaveBeenLastCalledWith(
expect.not.objectContaining({ search: 'Example' }),
Expand All @@ -280,7 +306,7 @@ describe('Sales page', () => {
expect.objectContaining({ dateColumn: 'CREATED_DATE', dateFrom: '2026-09-01' }),
expect.any(AbortSignal),
)
fireEvent.click(screen.getByRole('button', { name: 'Reset filter' }))
fireEvent.click(clearButton('Date range filter'))
await waitFor(() => expect(fetchReport)
.toHaveBeenLastCalledWith(
expect.objectContaining({ dateColumn: undefined, dateFrom: undefined, dateTo: undefined }),
Expand All @@ -289,13 +315,25 @@ describe('Sales page', () => {
await screen.findByText('No date range applied.')
})

it('shows totals for every matching record rather than the returned page', async () => {
it('shows totals for every matching record without per-tile record counts', async () => {
render(<SalesPage />)
await screen.findByText('Example opportunity')
expect(screen.getByText('$1,234,567'))
.toBeInTheDocument()
expect(screen.getByText('28 of 30 records with a value'))
// The plain Amount is redundant beside Amount (converted), so its tile is hidden.
expect(screen.queryByText('$987,654'))
.not.toBeInTheDocument()
// An uncoded single-currency total reads in dollars like the converted columns.
expect(screen.getByText('$555,555'))
.toBeInTheDocument()
expect(screen.getByText('2,468'))
.toBeInTheDocument()
expect(screen.getByText('Totals mix currencies.'))
.toBeInTheDocument()
expect(screen.queryByText('Matching records'))
.not.toBeInTheDocument()
expect(screen.queryByText(/records with a value/))
.not.toBeInTheDocument()
expect(screen.getByText('Stage breakdown'))
.toBeInTheDocument()
expect(screen.getByText('18 records'))
Expand All @@ -315,7 +353,7 @@ describe('Sales page', () => {
await screen.findByText('Example opportunity')
expect(screen.getByLabelText('Filter type'))
.toBeDisabled()
expect(screen.getByRole('button', { name: 'Apply filter' }))
expect(screen.getByLabelText('From date'))
.toBeDisabled()
expect(screen.queryByText('Opportunities'))
.not.toBeInTheDocument()
Expand Down
Loading
Loading