From 7b76f962dbfad6ebe26deeb97f6d437f982bc421 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 18 Sep 2026 05:47:00 +1000 Subject: [PATCH] PM-6364 Add Created/Close date range filters and totals to Sales Adds a date range section at the top of the Sales Portal so sales and leadership can separate pipeline generation, analysed by Created Date, from revenue realization, analysed by Close Date. The Filter type dropdown lists the report's own date columns rather than hard-coded Salesforce field IDs, and opens on Created Date. From and To are inclusive and either may be left empty. The range applies on Apply filter, Reset filter clears it without disturbing search, column filters or sorting, and clearing the report filters leaves the range intact. An inverted range is reported inline and never sent. Summary tiles show the metrics the active filters produce: opportunity count, a total per numeric column, and a per-stage breakdown. The Reports API computes these over every matching record, so they describe the filtered result rather than the page on screen, and a cross-currency total is rendered as a plain number and labelled as mixed. The tiles are hidden when the API returns no summary, so the page still works against an API that predates it. Co-Authored-By: Claude Opus 5 (1M context) --- src/apps/sales/README.md | 35 ++++- src/apps/sales/src/SalesPage.module.scss | 25 ++- src/apps/sales/src/SalesPage.spec.tsx | 147 ++++++++++++++++- src/apps/sales/src/SalesPage.tsx | 192 ++++++++++++++++++++++- src/apps/sales/src/sales.models.ts | 43 +++++ src/apps/sales/src/sales.service.ts | 9 +- src/apps/sales/src/sales.utils.spec.ts | 95 +++++++++++ src/apps/sales/src/sales.utils.ts | 87 ++++++++++ 8 files changed, 623 insertions(+), 10 deletions(-) create mode 100644 src/apps/sales/src/sales.utils.spec.ts create mode 100644 src/apps/sales/src/sales.utils.ts diff --git a/src/apps/sales/README.md b/src/apps/sales/README.md index 321b259e6..570ad3e9b 100644 --- a/src/apps/sales/README.md +++ b/src/apps/sales/README.md @@ -1,4 +1,4 @@ -# Sales (PM-6343) +# Sales (PM-6343, 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, @@ -9,6 +9,36 @@ The page calls `GET {REPORTS_API}/sales` with the signed-in user's token. All Salesforce credentials stay in `reports-api-v6`. No create, update, delete, export, machine credentials or direct Salesforce API calls exist in the UI. +## 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 @@ -31,5 +61,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`. diff --git a/src/apps/sales/src/SalesPage.module.scss b/src/apps/sales/src/SalesPage.module.scss index 83de541e5..77e127c91 100644 --- a/src/apps/sales/src/SalesPage.module.scss +++ b/src/apps/sales/src/SalesPage.module.scss @@ -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; } @@ -69,8 +90,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; } } diff --git a/src/apps/sales/src/SalesPage.spec.tsx b/src/apps/sales/src/SalesPage.spec.tsx index c51c352e3..18d205609 100644 --- a/src/apps/sales/src/SalesPage.spec.tsx +++ b/src/apps/sales/src/SalesPage.spec.tsx @@ -29,15 +29,50 @@ const fetchReport = fetchSalesReport as jest.MockedFunction { visibility.mockRestore() jest.useRealTimers() }) + + it('offers the report date fields, defaults to Created Date and applies an inclusive range', async () => { + render() + 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() + 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() + 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() + 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() + 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() + }) }) diff --git a/src/apps/sales/src/SalesPage.tsx b/src/apps/sales/src/SalesPage.tsx index 31b9cf32d..2c45a0761 100644 --- a/src/apps/sales/src/SalesPage.tsx +++ b/src/apps/sales/src/SalesPage.tsx @@ -2,12 +2,19 @@ /* eslint react/jsx-no-bind: ["error", { "allowArrowFunctions": true, "allowFunctions": true }] */ /* The horizontal report viewport must be focusable for keyboard scrolling. */ /* eslint jsx-a11y/no-noninteractive-tabindex: ["error", { "roles": ["region"] }] */ -import { FC, FormEvent, useCallback, useEffect, useRef, useState } from 'react' +import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, IconOutline, LoadingSpinner, PageTitle } from '~/libs/ui' import { SalesQuery, SalesReport } from './sales.models' import { fetchSalesReport, salesErrorMessage } from './sales.service' +import { + dateColumns, + dateRangeError, + defaultDateColumn, + formatSummaryAmount, + withDateRange, +} from './sales.utils' import styles from './SalesPage.module.scss' import './sales.scss' @@ -42,7 +49,8 @@ function withFilters(current: SalesQuery, search: string, filterColumn: string, /** * Read-only Sales workspace, used on the dedicated host and inside Work. - * @returns An accessible metadata-driven report with server-side view controls and live refresh. + * @returns An accessible metadata-driven report with a Created/Close date range filter, + * snapshot-wide totals, server-side view controls and live refresh. * @throws Does not throw request failures; shows inline recovery and stale-data status. */ const SalesPage: FC = () => { @@ -50,6 +58,10 @@ const SalesPage: FC = () => { const [search, setSearch] = useState('') const [filterColumn, setFilterColumn] = useState('') const [filterValue, setFilterValue] = useState('') + const [dateColumn, setDateColumn] = useState('') + const [dateFrom, setDateFrom] = useState('') + const [dateTo, setDateTo] = useState('') + const [dateError, setDateError] = useState('') const [report, setReport] = useState() const [error, setError] = useState('') const [loading, setLoading] = useState(true) @@ -130,7 +142,15 @@ const SalesPage: FC = () => { setSearch('') setFilterColumn('') setFilterValue('') - setQuery({ ...initialQuery, perPage: query.perPage }) + // The date range is its own section with its own reset, so clearing the + // report filters must not empty it behind the user's back. + setQuery(current => ({ + ...initialQuery, + dateColumn: current.dateColumn, + dateFrom: current.dateFrom, + dateTo: current.dateTo, + perPage: current.perPage, + })) } /** @param id Report column ID. @returns Nothing; toggles global sorting and resets pagination. Does not throw. */ @@ -143,6 +163,36 @@ const SalesPage: FC = () => { })) } + const availableDates = useMemo(() => dateColumns(report), [report]) + + useEffect(() => { + // The report defines its own date fields, so the selection follows the + // live schema instead of hard-coded Salesforce column IDs. + if (!availableDates.length) return + if (availableDates.some(column => column.id === dateColumn)) return + setDateColumn(defaultDateColumn(availableDates)) + }, [availableDates, dateColumn]) + + /** @param event Date range submission. @returns Nothing; applies a valid range. Does not throw. */ + function applyDateRange(event: FormEvent): void { + event.preventDefault() + const invalid = dateRangeError(dateColumn, dateFrom, dateTo) + setDateError(invalid) + if (invalid) return + setQuery(current => withDateRange(current, dateColumn, dateFrom, dateTo)) + } + + /** Clears the date range without disturbing search, column filters or sorting. Does not throw. */ + function resetDateRange(): void { + setDateFrom('') + setDateTo('') + setDateError('') + setDateColumn(defaultDateColumn(availableDates)) + setQuery(current => withDateRange(current, '', '', '')) + } + + const rangeApplied = !!query.dateColumn + const summary = report?.summary const firstRow = report?.total ? (report.page - 1) * report.perPage + 1 : 0 const lastRow = report ? Math.min(report.page * report.perPage, report.total) : 0 const updatedAt = report ? new Date(report.refreshedAt) @@ -200,6 +250,142 @@ const SalesPage: FC = () => { )} +
+
+ Date range filter +

+ Filter by Created Date for pipeline generation, or by Close Date for revenue + projections. Counts and totals below cover every matching record, not just this page. +

+
+
+ + +
+
+ + setDateFrom(event.target.value)} + type='date' + value={dateFrom} + /> +
+
+ + setDateTo(event.target.value)} + type='date' + value={dateTo} + /> +
+
+ + +
+
+

+ {dateError && {dateError}} + {!dateError && rangeApplied && ( + + {`Showing records by ${availableDates + .find(column => column.id === query.dateColumn)?.label ?? query.dateColumn}`} + {query.dateFrom ? ` from ${query.dateFrom}` : ''} + {query.dateTo ? ` through ${query.dateTo}` : ''} + . + + )} + {!dateError && !rangeApplied && No date range applied.} +

+
+
+ + {summary && ( +
+
+
+

Opportunities

+

{summary.recordCount.toLocaleString()}

+

Matching records

+
+ {summary.amounts.map(amount => ( +
+

{amount.label}

+

{formatSummaryAmount(amount)}

+

+ {`${amount.count.toLocaleString()} of `} + {`${summary.recordCount.toLocaleString()} records with a value`} + {amount.mixedCurrency ? ' ยท totals mixed currencies' : ''} +

+
+ ))} +
+ {summary.groups.map(group => ( +
+

{`${group.label} breakdown`}

+
    + {group.buckets.map(bucket => ( +
  • + {bucket.label || 'โ€”'} + + {`${bucket.count.toLocaleString()} records`} + + + {formatSummaryAmount({ + columnId: group.columnId, + count: bucket.count, + currencyCode: group.currencyCode, + label: bucket.label, + mixedCurrency: group.mixedCurrency, + total: bucket.total, + })} + +
  • + ))} + {!group.buckets.length &&
  • No matching records.
  • } +
+ {group.mixedCurrency && ( +

Totals mix currencies.

+ )} + {group.otherBuckets > 0 && ( +

+ {`${group.otherBuckets.toLocaleString()} further values not shown.`} +

+ )} +
+ ))} +
+ )} +
diff --git a/src/apps/sales/src/sales.models.ts b/src/apps/sales/src/sales.models.ts index 41664ddc7..2c3b2ffcf 100644 --- a/src/apps/sales/src/sales.models.ts +++ b/src/apps/sales/src/sales.models.ts @@ -1,3 +1,39 @@ +/** A numeric report column totalled across every matching row, not only the current page. */ +export interface SalesSummaryAmount { + columnId: string + label: string + total: number + count: number + /** True when contributing rows declared different currencies, making the total a bare sum. */ + mixedCurrency: boolean + currencyCode?: string +} + +/** One distinct value of a category column, such as a pipeline stage. */ +export interface SalesSummaryBucket { + label: string + count: number + total: number +} + +/** A category column broken down into its distinct values, largest total first. */ +export interface SalesSummaryGroup { + columnId: string + label: string + amountColumnId?: string + mixedCurrency: boolean + currencyCode?: string + buckets: SalesSummaryBucket[] + otherBuckets: number +} + +/** Aggregates the API recomputes over every matching row for the active query. */ +export interface SalesSummary { + recordCount: number + amounts: SalesSummaryAmount[] + groups: SalesSummaryGroup[] +} + /** Metadata-driven report contract shared by Sales and the WIN integration. */ export interface SalesReport { reportId: string @@ -15,6 +51,7 @@ export interface SalesReport { totalPages: number refreshedAt: string refreshAfterSeconds: number + summary?: SalesSummary } export interface SalesQuery { @@ -23,6 +60,12 @@ export interface SalesQuery { search?: string filterColumn?: string filterValue?: string + /** Date or datetime column the range applies to, such as Created Date or Close Date. */ + dateColumn?: string + /** Inclusive YYYY-MM-DD lower bound; requires dateColumn. */ + dateFrom?: string + /** Inclusive YYYY-MM-DD upper bound; requires dateColumn. */ + dateTo?: string sortBy?: string sortOrder?: 'asc' | 'desc' refresh?: boolean diff --git a/src/apps/sales/src/sales.service.ts b/src/apps/sales/src/sales.service.ts index 8aefc94ed..113b08160 100644 --- a/src/apps/sales/src/sales.service.ts +++ b/src/apps/sales/src/sales.service.ts @@ -5,9 +5,9 @@ import { SalesQuery, SalesReport } from './sales.models' /** * Reads Salesforce report data through the role-protected Reports API using the user's token. - * @param query Server-side search, filter, sorting, pagination and refresh options. + * @param query Server-side search, filter, date range, sorting, pagination and refresh options. * @param signal Cancels obsolete requests when controls change or the page unmounts. - * @returns The current report schema and one page of rows. + * @returns The current report schema, snapshot-wide totals and one page of rows. * @throws Propagates network, authorization and sanitized Reports API errors. */ export function fetchSalesReport(query: SalesQuery, signal?: AbortSignal): Promise { @@ -33,6 +33,9 @@ export function salesErrorMessage(error: unknown): string { if (status === 401) return 'Your session has expired. Sign in again to view sales data.' if (status === 403) return 'Sales data is available to Administrators and Talent Managers.' if (status === 503) return 'The sales connection is not configured yet. Contact your administrator.' - if (status === 400) return 'The report columns have changed. Clear the filters and sorting, then try again.' + if (status === 400) { + return 'The report columns have changed. Reset the date range, clear the filters and sorting, then try again.' + } + return 'We could not refresh the Salesforce report. Please try again.' } diff --git a/src/apps/sales/src/sales.utils.spec.ts b/src/apps/sales/src/sales.utils.spec.ts new file mode 100644 index 000000000..9c9e11790 --- /dev/null +++ b/src/apps/sales/src/sales.utils.spec.ts @@ -0,0 +1,95 @@ +import { SalesReport } from './sales.models' +import { + dateColumns, + dateRangeError, + defaultDateColumn, + formatSummaryAmount, + withDateRange, +} from './sales.utils' + +/** @returns A synthetic report schema with both pipeline date fields. Does not throw. */ +function report(): SalesReport { + return { + allData: true, + columns: [ + { 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: [], + sourceRowCount: 0, + total: 0, + totalPages: 0, + } +} + +describe('Sales date range utilities', () => { + it('offers only date fields and opens on Created Date for pipeline analysis', () => { + const columns = dateColumns(report()) + expect(columns.map(column => column.id)) + .toEqual(['CREATED_DATE', 'CLOSE_DATE']) + expect(defaultDateColumn(columns)) + .toBe('CREATED_DATE') + expect(dateColumns(undefined)) + .toEqual([]) + expect(defaultDateColumn([])) + .toBe('') + expect(defaultDateColumn([{ dataType: 'date', id: 'CLOSE_DATE', label: 'Close Date' }])) + .toBe('CLOSE_DATE') + }) + + it('rejects an inverted range and a bound without a field before any request', () => { + expect(dateRangeError('CLOSE_DATE', '2026-09-30', '2026-09-01')) + .toBe('The From date must be on or before the To date.') + expect(dateRangeError('', '2026-09-01', '')) + .toBe('Choose the date field this range applies to.') + expect(dateRangeError('CLOSE_DATE', '2026-09-01', '2026-09-30')) + .toBe('') + expect(dateRangeError('CLOSE_DATE', '', '')) + .toBe('') + expect(dateRangeError('', '', '')) + .toBe('') + }) + + it('sends a range only once a bound is set and restarts at the first page', () => { + const base = { page: 4, perPage: 25 } + expect(withDateRange(base, 'CLOSE_DATE', '', '')) + .toBe(base) + expect(withDateRange(base, 'CLOSE_DATE', '2026-09-01', '')) + .toEqual({ dateColumn: 'CLOSE_DATE', dateFrom: '2026-09-01', dateTo: undefined, page: 1, perPage: 25 }) + expect(withDateRange(base, 'CLOSE_DATE', '', '2026-09-30')) + .toEqual({ dateColumn: 'CLOSE_DATE', dateFrom: undefined, dateTo: '2026-09-30', page: 1, perPage: 25 }) + const applied = withDateRange(base, 'CLOSE_DATE', '2026-09-01', '2026-09-30') + expect(withDateRange(applied, 'CLOSE_DATE', '2026-09-01', '2026-09-30')) + .toBe(applied) + expect(withDateRange(applied, '', '', '')) + .toMatchObject({ dateColumn: undefined, dateFrom: undefined, dateTo: undefined, page: 1 }) + }) + + it('labels a total with its shared currency and leaves a mixed sum unlabelled', () => { + expect(formatSummaryAmount({ + columnId: 'AMOUNT', count: 2, currencyCode: 'USD', label: 'Amount', mixedCurrency: false, total: 1234.56, + })) + .toBe('$1,235') + expect(formatSummaryAmount({ + columnId: 'AMOUNT', count: 2, label: 'Amount', mixedCurrency: true, total: 1234.56, + })) + .toBe('1,235') + expect(formatSummaryAmount({ + columnId: 'AMOUNT', + count: 0, + currencyCode: 'not-a-currency', + label: 'Amount', + mixedCurrency: false, + total: 0, + })) + .toBe('0') + }) +}) diff --git a/src/apps/sales/src/sales.utils.ts b/src/apps/sales/src/sales.utils.ts new file mode 100644 index 000000000..6fe8ea710 --- /dev/null +++ b/src/apps/sales/src/sales.utils.ts @@ -0,0 +1,87 @@ +import { SalesQuery, SalesReport, SalesSummaryAmount } from './sales.models' + +/** Report column types a date range can be applied to; mirrors the Reports API contract. */ +const dateTypes = ['date', 'datetime'] + +/** Matches the Created Date column so the portal opens on pipeline generation. */ +const createdDatePattern = /creat/i + +export type SalesDateColumn = SalesReport['columns'][number] + +/** + * Lists the report columns a date range can filter on, such as Created Date and Close Date. + * @param report Loaded report, or undefined before the first response. + * @returns Date and datetime columns in report order; empty when the report has none. + * @throws Does not throw. + */ +export function dateColumns(report?: SalesReport): SalesDateColumn[] { + return (report?.columns ?? []).filter(column => dateTypes.includes(column.dataType)) +} + +/** + * Chooses the date column the portal starts on, preferring Created Date for pipeline analysis. + * @param columns Available date columns, in report order. + * @returns The preferred column ID, or an empty string when the report has no date column. + * @throws Does not throw. + */ +export function defaultDateColumn(columns: SalesDateColumn[]): string { + const created = columns.find(column => createdDatePattern.test(column.label)) + return (created ?? columns[0])?.id ?? '' +} + +/** + * Explains why a chosen range cannot be applied, so the request is never sent. + * @param column Selected date column ID. + * @param from Inclusive lower bound, as a YYYY-MM-DD value from a date input. + * @param to Inclusive upper bound, as a YYYY-MM-DD value from a date input. + * @returns A message to display, or an empty string when the range is usable. + * @throws Does not throw. + */ +export function dateRangeError(column: string, from: string, to: string): string { + if (!column && (from || to)) return 'Choose the date field this range applies to.' + if (from && to && from > to) return 'The From date must be on or before the To date.' + return '' +} + +/** + * Merges the date range controls into the report query. + * @param current Active report query. + * @param column Selected date column ID. + * @param from Inclusive lower bound. + * @param to Inclusive upper bound. + * @returns The current query when nothing changed, otherwise a new query reset to page one. Does not throw. + */ +export function withDateRange(current: SalesQuery, column: string, from: string, to: string): SalesQuery { + // The column alone never filters, so it is only sent once a bound is set. + const ranged = !!column && !!(from || to) + const next = { + dateColumn: ranged ? column : undefined, + dateFrom: ranged && from ? from : undefined, + dateTo: ranged && to ? to : undefined, + } + if ( + next.dateColumn === current.dateColumn + && next.dateFrom === current.dateFrom + && next.dateTo === current.dateTo + ) { + return current + } + + return { ...current, ...next, page: 1 } +} + +/** + * Formats a snapshot-wide total for display, using the currency the matching rows agree on. + * @param amount Summary entry for one numeric column. + * @returns A localized currency amount, or a plain number when the rows mix currencies. + * @throws Does not throw for an unexpected currency code; falls back to a plain number. + */ +export function formatSummaryAmount(amount: SalesSummaryAmount): string { + try { + return amount.total.toLocaleString(undefined, amount.currencyCode + ? { currency: amount.currencyCode, maximumFractionDigits: 0, style: 'currency' } + : { maximumFractionDigits: 0 }) + } catch { + return amount.total.toLocaleString(undefined, { maximumFractionDigits: 0 }) + } +}