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
41 changes: 41 additions & 0 deletions infrastructure/sales/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Sales dev route

PM-6343 serves the branch build only on `sales.topcoder-dev.com`. The shared
Platform UI S3 root and other app builds are preserved.

| Resource | Dev value |
| --- | --- |
| AWS account / region | `811668436784` / `us-east-1` |
| CloudFront distribution | `EFA7R1KH3UX5M` (`d10303ypawq4ub.cloudfront.net`) |
| S3 bucket / prefix | `platform-mvp.topcoder-dev.com` / `sales-app/` |
| Route 53 public hosted zone | `Z2CIRG3R0ZSGFQ` |
| Host | `sales.topcoder-dev.com` |
| Viewer request function | `platform-sales-dev-viewer-request` |

Build with `nvm use` followed by
`LOGICAL_ENV=dev PUBLIC_URL=/sales-app yarn run build`. Upload build files under
the prefix without deleting shared objects. Give hashed assets immutable cache
headers and `sales-app/index.html` `no-cache,no-store,max-age=0`.

`dev-viewer-request.js` includes the existing Contact and Accounts routing from
the live distribution. Its Sales branch rewrites navigation requests to
`/sales-app/index.html` while allowing prefixed assets through. Test Sales root,
deep links and assets, Contact and Accounts, and unrelated Work/Reports hosts
before publishing. Re-read the live function when applying later changes to
avoid overwriting newer routing. Attach the published function to the default
behavior's viewer-request event, preserving every other association.

Add the Sales alias to the existing distribution. Its ACM certificate already
covers `*.topcoder-dev.com`. Add a Route 53 A alias (and AAAA when IPv6 is enabled)
to the same distribution, preserving all existing DNS records. Invalidate only
`/sales-app/*`. Authentication continues through the standard Platform UI login.

Deploy the reports-api-v6 PM-6343 branch to dev with its normal `dev-PM-6343` tag
workflow. Server-only Salesforce configuration is documented in that repo's
`SALES.md`. The Sales page uses `/v6/reports/sales`; WIN uses the separate
`/v6/reports/win/sales` endpoint and requires an M2M grant for `reports:sales`.

Rollback: remove the Sales alias/DNS entries and restore the prior viewer-request
function association, leaving shared settings intact. The isolated S3 prefix can
be retained for investigation. Restore the prior Reports API ECS task revision
if rolling back the backend. No database migration is involved.
27 changes: 27 additions & 0 deletions infrastructure/sales/dev-viewer-request.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Routes the Sales, Contact and Accounts dev hosts to their isolated S3 shells.
* @param {Object} event CloudFront viewer-request event containing the URI and Host header.
* @returns {Object} Request with an app-specific SPA fallback; assets and unrelated hosts are preserved.
* @throws No exceptions for valid CloudFront request events; performs no network I/O.
*/
function handler(event) {
var request = event.request;
var host = request.headers.host ? request.headers.host.value.toLowerCase() : '';
var prefix = '';
if (host === 'sales.topcoder-dev.com' || host === 'sales.topcoder-dev.com:443') {
prefix = '/sales-app/';
} else if (host === 'contact.topcoder-dev.com' || host === 'contact.topcoder-dev.com:443') {
prefix = '/contact-app/';
} else if (host === 'account-settings.topcoder-dev.com' || host === 'account-settings.topcoder-dev.com:443') {
// Keep shared assets available to Accounts pages opened before its isolated deployment.
if (request.uri.indexOf('/static/') === 0
|| ['/global.css', '/favicon.png', '/manifest.json', '/logo_512x512.png', '/robots.txt'].indexOf(request.uri) !== -1) {
return request;
}
prefix = '/accounts-preferences-app/';
}
if (prefix && request.uri.indexOf(prefix) !== 0) {
request.uri = prefix + 'index.html';
}
return request;
}
2 changes: 2 additions & 0 deletions src/apps/platform/src/platform.routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { walletAdminRoutes } from '~/apps/wallet-admin'
import { copilotsRoutes } from '~/apps/copilots'
import { adminRoutes } from '~/apps/admin'
import { reportsRoutes } from '~/apps/reports'
import { salesRoutes } from '~/apps/sales'
import { reviewRoutes } from '~/apps/review'
import { workRoutes } from '~/apps/work'
import { calendarRoutes } from '~/apps/calendar'
Expand Down Expand Up @@ -83,6 +84,7 @@ export const platformRoutes: Array<PlatformRoute> = [
...homeRoutes,
...adminRoutes,
...reportsRoutes,
...salesRoutes,
...customerPortalRoutes,
...notFoundRoutes,
]
33 changes: 33 additions & 0 deletions src/apps/sales/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Sales (PM-6343)

Read-only Salesforce reporting for Administrators and Talent Managers. Available
at `sales.topcoder.com` / `sales.topcoder-dev.com`, `/sales` on the combined host,
and the **Sales** tab inside Work (`/sales` on the Work host, `/work/sales` on the
combined host). Route guards and the Reports API independently enforce access.

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.

Report metadata determines every displayed column, including grouped Stage.
Search and column substring filters apply on **Apply**; headers sort globally
before server pagination. Changing a filter, sort or page size starts at page
one. Clear resets filters and sorting. Null display values use an em dash.
The semantic HTML table provides keyboard sorting, `aria-sort`, and a focusable
horizontal scroll region for wide reports; the shared Table component performs
client sorting and is intentionally not used for this server-paginated report.

Refresh preserves filters and sorting. Visible tabs poll every 60 seconds and
refresh on returning from a hidden tab; obsolete requests are aborted and
ignored. Failed refreshes keep the prior snapshot visibly marked as stale;
401/403 responses remove it. Loading, retry, empty and upstream truncation states
are explicit. `allData: false` warns that filters and totals cover only received
rows, because Salesforce Analytics caps report details at 2,000 records.

The app opts into scoped shared 2026 foundations, using Figtree headings, Nunito
Sans body text, semantic color tokens, shared buttons, inputs and loading
controls. Source design guidance:
[Topcoder Design System — August 2026](https://www.figma.com/design/C2cA6508RhpjWJDp7MLKbO/Topcoder-Design-System---Aug-2026?node-id=1-54).

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`.
1 change: 1 addition & 0 deletions src/apps/sales/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { salesRoutes } from './src/sales.routes'
73 changes: 73 additions & 0 deletions src/apps/sales/src/SalesPage.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
@import '@libs/ui/styles/2026/tokens';

.page {
width: 100%;
max-width: 1800px;
margin: 0 auto;
padding: 40px 48px 64px;
min-height: 70vh;
}

.header, .headerActions, .panelHeader, .status, .pagination, .pageControls, .filterActions {
display: flex;
align-items: center;
gap: 16px;
}

.header, .panelHeader, .status, .pagination { justify-content: space-between; }

.eyebrow {
font-size: 12px;
font-weight: 700;
letter-spacing: 1.5px;
color: $tc-2026-muted;
margin-bottom: 8px;
}

.subtitle { margin-top: 8px; font-size: 16px; color: $tc-2026-muted; }
.readOnly, .count { border-radius: 20px; background: $tc-2026-canvas; padding: 6px 12px; font-weight: 700; }
.readOnly { border: 1px solid $tc-2026-border; color: $tc-2026-muted; }
.refresh { background: $tc-2026-teal !important; border-color: $tc-2026-teal !important; }
.status { margin: 24px 0; color: $tc-2026-muted; font-size: 13px; flex-wrap: wrap; gap: 8px; }
.panel { background: $tc-2026-surface; border: 1px solid $tc-2026-border; border-radius: $tc-2026-radius-lg; overflow: hidden; box-shadow: $tc-2026-shadow-card; }
.panelHeader { padding: 24px; border-bottom: 1px solid $tc-2026-border; }
.panelHeader h2 { font-size: $tc-2026-h4-size; line-height: 32px; }
.panelHeader p { margin-top: 4px; color: $tc-2026-muted; }
.filters { padding: 24px; display: flex; align-items: flex-end; flex-wrap: wrap; gap: 16px; background: $tc-2026-canvas; }
.search { flex: 1 1 220px; min-width: 160px; margin-bottom: 0; }
.selectField { display: flex; flex-direction: column; gap: 8px; font-weight: 700; flex: 1 1 180px; }
.selectField select, .pageControls select { background: $tc-2026-surface; border: 1px solid $tc-2026-border-strong; padding: 10px 12px; min-height: 40px; color: $tc-2026-body; font-weight: 400; max-width: 100%; }
.filterActions { min-height: 48px; }
.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; }
.table th button { padding: 16px 20px; width: 100%; text-align: left; color: $tc-2026-body; font-weight: 700; border: 0; background: transparent; cursor: pointer; }
.table th button:hover { color: $tc-2026-teal; }
.table td { border-bottom: 1px solid $tc-2026-border; padding: 16px 20px; min-width: 140px; max-width: 420px; overflow-wrap: anywhere; vertical-align: top; }
.table tr:last-child td { border-bottom: 0; }
.table tbody tr:hover { background: $tc-2026-canvas; }
.pagination { padding: 20px 24px; border-top: 1px solid $tc-2026-border; flex-wrap: wrap; font-size: 13px; }
.pageControls { flex-wrap: wrap; gap: 12px; }
.error, .warning { padding: 20px 24px; margin-bottom: 24px; border-radius: $tc-2026-radius-md; background: $tc-2026-surface; }
.error { display: flex; align-items: center; justify-content: space-between; gap: 16px; border: 1px solid $tc-2026-danger; color: $tc-2026-danger; }
.warning { border: 1px solid $tc-2026-warning; color: $tc-2026-body; }
.empty { padding: 48px 24px; min-height: 180px; text-align: center; position: relative; }
.empty h3 { font-size: 24px; }
.empty p { color: $tc-2026-muted; margin-top: 8px; }
.visuallyHidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

@media (max-width: 900px) {
.page { padding: 24px; }
.header { align-items: flex-start; flex-wrap: wrap; }
.pageControls { gap: 8px; }
}

@media (max-width: 600px) {
.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 { align-items: flex-start; flex-direction: column; }
.search, .selectField { flex-basis: 100%; }
.error { align-items: flex-start; flex-direction: column; }
}
144 changes: 144 additions & 0 deletions src/apps/sales/src/SalesPage.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/* 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 { ButtonHTMLAttributes, ChangeEvent, ReactNode } from 'react'

import SalesPage from './SalesPage'
import { SalesReport } from './sales.models'
import { fetchSalesReport } from './sales.service'

jest.mock('./sales.service', () => ({
fetchSalesReport: jest.fn(),
salesErrorMessage: () => 'Unable to refresh. Please try again.',
}))

jest.mock('~/libs/ui', () => ({
Button: (props: ButtonHTMLAttributes<HTMLButtonElement>) => (
<button disabled={props.disabled} onClick={props.onClick} type={props.type === 'submit' ? 'submit' : 'button'}>
{props.children}
</button>
),
IconOutline: { RefreshIcon: () => <span /> },
InputText: (props: {
label: string; disabled?: boolean; name: string; value: string;
onChange: (event: ChangeEvent<HTMLInputElement>) => void
}) => (
<label htmlFor={props.name}>
{props.label}
<input disabled={props.disabled} id={props.name} value={props.value} onChange={props.onChange} />
</label>
),
LoadingSpinner: (props: { message: string }) => <span>{props.message}</span>,
PageTitle: (props: { children: ReactNode }) => <title>{props.children}</title>,
}), { virtual: true })

const fetchReport = fetchSalesReport as jest.MockedFunction<typeof fetchSalesReport>

/** Creates non-customer Sales test data. @returns A synthetic report page. Does not throw. */
function fixture(): SalesReport {
return {
allData: true,
columns: [{ dataType: 'string', id: 'NAME', label: 'Opportunity' }],
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' }],
sourceRowCount: 30,
total: 30,
totalPages: 2,
}
}

describe('Sales page', () => {
beforeEach(() => fetchReport.mockReset()
.mockResolvedValue(fixture()))

it('renders live metadata and sends search, column filters, sorting and pagination to the API', async () => {
render(<SalesPage />)
await screen.findByText('Example opportunity')
fireEvent.change(screen.getByLabelText('Search sales'), { target: { value: 'Example' } })
fireEvent.change(screen.getByLabelText('Filter field'), { target: { value: 'NAME' } })
fireEvent.change(screen.getByLabelText('Contains'), { target: { value: 'opportunity' } })
fireEvent.click(screen.getByRole('button', { name: 'Apply' }))
await waitFor(() => expect(fetchReport)
.toHaveBeenLastCalledWith(expect.objectContaining({
filterColumn: 'NAME', filterValue: 'opportunity', page: 1, search: 'Example',
}), expect.any(AbortSignal)))
await waitFor(() => expect(screen.getByRole('button', { name: 'Opportunity' }))
.not.toBeDisabled())
fireEvent.click(screen.getByRole('button', { name: 'Opportunity' }))
await waitFor(() => expect(fetchReport)
.toHaveBeenLastCalledWith(
expect.objectContaining({ sortBy: 'NAME', sortOrder: 'asc' }),
expect.any(AbortSignal),
))
await waitFor(() => expect(screen.getByRole('button', { name: 'Next' }))
.not.toBeDisabled())
fireEvent.click(screen.getByRole('button', { name: 'Next' }))
await waitFor(() => expect(fetchReport)
.toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 }), expect.any(AbortSignal)))
})

it('refreshes without losing filters and labels retained data after failure', async () => {
render(<SalesPage />)
await screen.findByText('Example opportunity')
fetchReport.mockRejectedValueOnce(new Error('upstream'))
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
await screen.findByText('Showing previously loaded data')
expect(fetchReport)
.toHaveBeenLastCalledWith(expect.objectContaining({ refresh: true }), expect.any(AbortSignal))
expect(screen.getByText('Example opportunity'))
.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
})

it('removes previously loaded sales data when authorization is revoked', async () => {
render(<SalesPage />)
await screen.findByText('Example opportunity')
fetchReport.mockRejectedValueOnce({ response: { status: 403 } })
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
await screen.findByText('Unable to load sales data')
expect(screen.queryByText('Example opportunity')).not.toBeInTheDocument()
})

it('shows completeness and empty-result states without rendering upstream markup', async () => {
fetchReport.mockResolvedValueOnce({ ...fixture(), allData: false, rows: [], total: 0, totalPages: 0 })
render(<SalesPage />)
await screen.findByText('No sales records found')
expect(screen.getByText(/Salesforce returned a limited set/))
.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Next' }))
.toBeDisabled()
})

it('refreshes visible tabs every minute and stops polling after unmount', async () => {
jest.useFakeTimers()
const visibility = jest.spyOn(document, 'visibilityState', 'get')
.mockReturnValue('visible')
const view = render(<SalesPage />)
await act(async () => { await Promise.resolve() })
expect(fetchReport)
.toHaveBeenCalledTimes(1)
await act(async () => { jest.advanceTimersByTime(60000) })
expect(fetchReport)
.toHaveBeenCalledTimes(2)
visibility.mockReturnValue('hidden')
await act(async () => { jest.advanceTimersByTime(60000) })
expect(fetchReport)
.toHaveBeenCalledTimes(2)
visibility.mockReturnValue('visible')
await act(async () => { fireEvent(document, new Event('visibilitychange')) })
expect(fetchReport)
.toHaveBeenCalledTimes(3)
view.unmount()
await act(async () => { jest.advanceTimersByTime(60000) })
expect(fetchReport)
.toHaveBeenCalledTimes(3)
visibility.mockRestore()
jest.useRealTimers()
})
})
Loading
Loading