diff --git a/src/apps/sales/README.md b/src/apps/sales/README.md index 1a15c71a9..5f9bd5c6b 100644 --- a/src/apps/sales/README.md +++ b/src/apps/sales/README.md @@ -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, @@ -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 @@ -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`. diff --git a/src/apps/sales/src/SalesPage.module.scss b/src/apps/sales/src/SalesPage.module.scss index f6bc8d41b..251a3cd03 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; } @@ -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; } } diff --git a/src/apps/sales/src/SalesPage.spec.tsx b/src/apps/sales/src/SalesPage.spec.tsx index 621104511..d7b652ae8 100644 --- a/src/apps/sales/src/SalesPage.spec.tsx +++ b/src/apps/sales/src/SalesPage.spec.tsx @@ -45,15 +45,50 @@ const fetchOpportunityDetails = fetchOpportunity as jest.MockedFunction { 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() + }) + it('opens the opportunity description in a popup and closes it again', async () => { fetchReport.mockResolvedValue({ ...fixture(), diff --git a/src/apps/sales/src/SalesPage.tsx b/src/apps/sales/src/SalesPage.tsx index 114056eac..adb9e0a9b 100644 --- a/src/apps/sales/src/SalesPage.tsx +++ b/src/apps/sales/src/SalesPage.tsx @@ -2,13 +2,20 @@ /* 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 { OpportunityModal } from './OpportunityModal' import { SalesQuery, SalesReport, toOpportunityId } 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' @@ -48,7 +55,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 = () => { @@ -56,6 +64,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) @@ -137,7 +149,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. */ @@ -150,6 +170,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) @@ -207,6 +257,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 3b2b39513..271a3953f 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 }) + } +}