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
35 changes: 34 additions & 1 deletion src/apps/sales/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Sales (PM-6343, PM-6363)
# Sales (PM-6343, PM-6363, PM-6364)

Read-only Salesforce reporting for Administrators and Talent Managers. Available
at `sales.topcoder.com` / `sales.topcoder-dev.com`, `/sales` on the combined host,
Expand All @@ -17,6 +17,36 @@ opportunity description first, followed by the customer, SMU, close date and
stage when Salesforce provides them, plus a link to the record. The popup closes
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.
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.

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
figure.

## Filtered totals (PM-6364)

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
returns no `summary`, which keeps the page working against an API that predates
this feature.

Report metadata determines every displayed column, including grouped Stage.
Search and column substring filters apply automatically as the user types (debounced); headers sort globally
before server pagination. Changing a filter, sort or page size starts at page
Expand All @@ -39,5 +69,8 @@ Sans body text, semantic color tokens, shared buttons and loading controls, and
explicitly labelled native filter controls. Source design guidance:
[Topcoder Design System — August 2026](https://www.figma.com/design/C2cA6508RhpjWJDp7MLKbO/Topcoder-Design-System---Aug-2026?node-id=1-54).

Aggregates and the date range both cover received rows only, so `allData: false`
limits them exactly as it limits the record count.

Run `nvm use` in platform-ui before `yarn lint`, `LOGICAL_ENV=dev yarn run build`,
and `CI=true yarn test:no-watch --runInBand --watch=false sales`.
25 changes: 24 additions & 1 deletion src/apps/sales/src/SalesPage.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,27 @@
.filterField input::placeholder { color: $tc-2026-muted; opacity: 1; }
.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; }
.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; }
.metric { background: $tc-2026-surface; border: 1px solid $tc-2026-border; border-radius: $tc-2026-radius-lg; box-shadow: $tc-2026-shadow-card; padding: 20px 24px; }
.metricLabel { font-size: 12px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; color: $tc-2026-muted; }
.metricValue { font-size: 32px; line-height: 40px; font-weight: 700; margin-top: 8px; overflow-wrap: anywhere; }
.metricNote { margin-top: 8px; font-size: 13px; color: $tc-2026-muted; }
.breakdown { background: $tc-2026-surface; border: 1px solid $tc-2026-border; border-radius: $tc-2026-radius-lg; box-shadow: $tc-2026-shadow-card; padding: 20px 24px; }
.breakdown h3 { font-size: 20px; line-height: 28px; }
.breakdown ul { list-style: none; margin-top: 12px; }
.breakdown li { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 8px; padding: 10px 0; border-bottom: 1px solid $tc-2026-border; }
.breakdown li:last-child { border-bottom: 0; }
.bucketLabel { font-weight: 700; flex: 1 1 200px; overflow-wrap: anywhere; }
.bucketCount { color: $tc-2026-muted; font-size: 13px; }
.bucketTotal { font-weight: 700; min-width: 120px; text-align: right; }
.tableScroll { overflow-x: auto; width: 100%; }
.table { width: 100%; border-collapse: collapse; text-align: left; }
.table th { background: $tc-2026-canvas; white-space: nowrap; border-bottom: 1px solid $tc-2026-border; }
Expand Down Expand Up @@ -71,8 +92,10 @@
.page { padding: 20px 12px 40px; }
.header h1 { font-size: 36px; line-height: 44px; }
.headerActions { width: 100%; justify-content: space-between; }
.panelHeader, .filters, .pagination { padding: 16px; }
.panelHeader, .filters, .pagination, .dateFieldset, .metric, .breakdown { padding: 16px; }
.panelHeader { align-items: flex-start; flex-direction: column; }
.filterField { flex-basis: 100%; }
.filterActions { width: 100%; }
.bucketTotal { text-align: left; min-width: 0; }
.error { align-items: flex-start; flex-direction: column; }
}
147 changes: 145 additions & 2 deletions src/apps/sales/src/SalesPage.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,50 @@ const fetchOpportunityDetails = fetchOpportunity as jest.MockedFunction<typeof f
function fixture(): SalesReport {
return {
allData: true,
columns: [{ dataType: 'string', id: 'NAME', label: 'Opportunity' }],
columns: [
{ dataType: 'picklist', id: 'STAGE_NAME', label: 'Stage' },
{ dataType: 'string', id: 'NAME', label: 'Opportunity' },
{ dataType: 'currency', id: 'AMOUNT', label: 'Amount' },
{ dataType: 'datetime', id: 'CREATED_DATE', label: 'Created Date' },
{ dataType: 'date', id: 'CLOSE_DATE', label: 'Close Date' },
],
page: 1,
perPage: 25,
refreshAfterSeconds: 60,
refreshedAt: '2026-09-16T02:00:00Z',
reportId: 'test-report',
reportName: 'Bookings By Stage',
rows: [{ cells: [{ label: 'Example opportunity', value: 'record-id' }], id: '0:0' }],
rows: [{
cells: [
{ label: 'Proposal', value: 'Proposal' },
{ label: 'Example opportunity', value: 'record-id' },
{ currencyCode: 'USD', label: '$1,000', value: 1000 },
{ label: '9/1/2026', value: '2026-09-01T10:00:00Z' },
{ label: '9/30/2026', value: '2026-09-30' },
],
id: '0:0',
}],
sourceRowCount: 30,
summary: {
amounts: [{
columnId: 'AMOUNT',
count: 28,
currencyCode: 'USD',
label: 'Amount',
mixedCurrency: false,
total: 1234567,
}],
groups: [{
amountColumnId: 'AMOUNT',
buckets: [{ count: 18, label: 'Proposal', total: 900000 }],
columnId: 'STAGE_NAME',
currencyCode: 'USD',
label: 'Stage',
mixedCurrency: false,
otherBuckets: 2,
}],
recordCount: 30,
},
total: 30,
totalPages: 2,
}
Expand Down Expand Up @@ -178,6 +213,114 @@ describe('Sales page', () => {
jest.useRealTimers()
})

it('offers the report date fields, defaults to Created Date and applies an inclusive range', async () => {
render(<SalesPage />)
await screen.findByText('Example opportunity')
const field = screen.getByLabelText('Filter type') as HTMLSelectElement
expect([...field.options].map(option => option.text))
.toEqual(['Created Date', 'Close Date'])
expect(field.value)
.toBe('CREATED_DATE')
fireEvent.change(field, { target: { value: 'CLOSE_DATE' } })
fireEvent.change(screen.getByLabelText('From date'), { target: { value: '2026-07-01' } })
fireEvent.change(screen.getByLabelText('To date'), { target: { value: '2026-09-30' } })
expect(fetchReport)
.toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByRole('button', { name: 'Apply filter' }))
await waitFor(() => expect(fetchReport)
.toHaveBeenLastCalledWith(expect.objectContaining({
dateColumn: 'CLOSE_DATE', dateFrom: '2026-07-01', dateTo: '2026-09-30', page: 1,
}), expect.any(AbortSignal)))
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 () => {
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' }))
await waitFor(() => expect(screen.queryByText('The From date must be on or before the To date.'))
.not.toBeInTheDocument())
expect(screen.getByLabelText('From date'))
.toHaveValue('')
expect(fetchReport)
.toHaveBeenCalledTimes(1)
})

it('resets 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' }),
expect.any(AbortSignal),
))
fireEvent.change(screen.getByLabelText('Search sales'), { target: { value: 'Example' } })
await waitFor(() => expect(fetchReport)
.toHaveBeenLastCalledWith(
expect.objectContaining({ dateFrom: '2026-09-01', search: 'Example' }),
expect.any(AbortSignal),
))
// Clearing the report filters must not silently empty the separate date range.
fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
await waitFor(() => expect(fetchReport)
.toHaveBeenLastCalledWith(
expect.not.objectContaining({ search: 'Example' }),
expect.any(AbortSignal),
))
expect(fetchReport)
.toHaveBeenLastCalledWith(
expect.objectContaining({ dateColumn: 'CREATED_DATE', dateFrom: '2026-09-01' }),
expect.any(AbortSignal),
)
fireEvent.click(screen.getByRole('button', { name: 'Reset filter' }))
await waitFor(() => expect(fetchReport)
.toHaveBeenLastCalledWith(
expect.objectContaining({ dateColumn: undefined, dateFrom: undefined, dateTo: undefined }),
expect.any(AbortSignal),
))
await screen.findByText('No date range applied.')
})

it('shows totals for every matching record rather than the returned page', 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'))
.toBeInTheDocument()
expect(screen.getByText('Stage breakdown'))
.toBeInTheDocument()
expect(screen.getByText('18 records'))
.toBeInTheDocument()
expect(screen.getByText('2 further values not shown.'))
.toBeInTheDocument()
})

it('disables the range and hides totals for a report that provides neither', async () => {
fetchReport.mockResolvedValue({
...fixture(),
columns: [{ dataType: 'string', id: 'NAME', label: 'Opportunity' }],
rows: [{ cells: [{ label: 'Example opportunity', value: 'record-id' }], id: '0:0' }],
summary: undefined,
})
render(<SalesPage />)
await screen.findByText('Example opportunity')
expect(screen.getByLabelText('Filter type'))
.toBeDisabled()
expect(screen.getByRole('button', { name: 'Apply filter' }))
.toBeDisabled()
expect(screen.queryByText('Opportunities'))
.not.toBeInTheDocument()
})

it('opens the opportunity description in a popup and closes it again', async () => {
fetchReport.mockResolvedValue({
...fixture(),
Expand Down
Loading
Loading