diff --git a/infrastructure/sales/README.md b/infrastructure/sales/README.md new file mode 100644 index 000000000..0a144b964 --- /dev/null +++ b/infrastructure/sales/README.md @@ -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. diff --git a/infrastructure/sales/dev-viewer-request.js b/infrastructure/sales/dev-viewer-request.js new file mode 100644 index 000000000..a5c358290 --- /dev/null +++ b/infrastructure/sales/dev-viewer-request.js @@ -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; +} diff --git a/src/apps/platform/src/platform.routes.tsx b/src/apps/platform/src/platform.routes.tsx index ef61267ef..96c23c5a7 100644 --- a/src/apps/platform/src/platform.routes.tsx +++ b/src/apps/platform/src/platform.routes.tsx @@ -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' @@ -83,6 +84,7 @@ export const platformRoutes: Array = [ ...homeRoutes, ...adminRoutes, ...reportsRoutes, + ...salesRoutes, ...customerPortalRoutes, ...notFoundRoutes, ] diff --git a/src/apps/sales/README.md b/src/apps/sales/README.md new file mode 100644 index 000000000..db6d1eb67 --- /dev/null +++ b/src/apps/sales/README.md @@ -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`. diff --git a/src/apps/sales/index.ts b/src/apps/sales/index.ts new file mode 100644 index 000000000..eaa1e970e --- /dev/null +++ b/src/apps/sales/index.ts @@ -0,0 +1 @@ +export { salesRoutes } from './src/sales.routes' diff --git a/src/apps/sales/src/SalesPage.module.scss b/src/apps/sales/src/SalesPage.module.scss new file mode 100644 index 000000000..80e29a24c --- /dev/null +++ b/src/apps/sales/src/SalesPage.module.scss @@ -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; } +} diff --git a/src/apps/sales/src/SalesPage.spec.tsx b/src/apps/sales/src/SalesPage.spec.tsx new file mode 100644 index 000000000..7cdb02112 --- /dev/null +++ b/src/apps/sales/src/SalesPage.spec.tsx @@ -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) => ( + + ), + IconOutline: { RefreshIcon: () => }, + InputText: (props: { + label: string; disabled?: boolean; name: string; value: string; + onChange: (event: ChangeEvent) => void + }) => ( + + ), + LoadingSpinner: (props: { message: string }) => {props.message}, + PageTitle: (props: { children: ReactNode }) => {props.children}, +}), { virtual: true }) + +const fetchReport = fetchSalesReport as jest.MockedFunction + +/** 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() + 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() + 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() + 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() + 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() + 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() + }) +}) diff --git a/src/apps/sales/src/SalesPage.tsx b/src/apps/sales/src/SalesPage.tsx new file mode 100644 index 000000000..0756d1886 --- /dev/null +++ b/src/apps/sales/src/SalesPage.tsx @@ -0,0 +1,340 @@ +/* Handlers capture the active report column or control value. */ +/* 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 { Button, IconOutline, InputText, LoadingSpinner, PageTitle } from '~/libs/ui' + +import { SalesQuery, SalesReport } from './sales.models' +import { fetchSalesReport, salesErrorMessage } from './sales.service' +import styles from './SalesPage.module.scss' +import './sales.scss' + +const initialQuery: SalesQuery = { page: 1, perPage: 25 } + +/** + * 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. + * @throws Does not throw request failures; shows inline recovery and stale-data status. + */ +const SalesPage: FC = () => { + const [query, setQuery] = useState(initialQuery) + const [search, setSearch] = useState('') + const [filterColumn, setFilterColumn] = useState('') + const [filterValue, setFilterValue] = useState('') + const [report, setReport] = useState() + const [error, setError] = useState('') + const [loading, setLoading] = useState(true) + const [refreshVersion, setRefreshVersion] = useState(0) + const forceRefresh = useRef(false) + const busy = useRef(false) + + /** + * Schedules a manual server refresh without resetting active filters or sorting. + * @returns Nothing; increments the request version. Does not throw. + */ + const refresh = useCallback((): void => { + if (busy.current) return + forceRefresh.current = true + setRefreshVersion(value => value + 1) + }, []) + + useEffect(() => { + const controller = new AbortController() + const shouldRefresh = forceRefresh.current + forceRefresh.current = false + busy.current = true + setLoading(true) + setError('') + fetchSalesReport({ ...query, refresh: shouldRefresh }, controller.signal) + .then(result => { + if (!controller.signal.aborted) setReport(result) + }) + .catch(failure => { + if (controller.signal.aborted) return + const status = failure?.response?.status + // Do not retain protected data if the session or role is no longer valid. + if (status === 401 || status === 403) setReport(undefined) + setError(salesErrorMessage(failure)) + }) + .finally(() => { + if (!controller.signal.aborted) { + busy.current = false + setLoading(false) + } + }) + return () => { + controller.abort() + busy.current = false + } + }, [query, refreshVersion]) + + useEffect(() => { + /** Refreshes a visible, idle page on the timer or on return to the tab; returns void and does not throw. */ + function refreshVisible(): void { + if (document.visibilityState !== 'visible' || busy.current) return + refresh() + } + + const timer = window.setInterval(refreshVisible, Math.max(60, report?.refreshAfterSeconds ?? 60) * 1000) + document.addEventListener('visibilitychange', refreshVisible) + return () => { + window.clearInterval(timer) + document.removeEventListener('visibilitychange', refreshVisible) + } + }, [refresh, report?.refreshAfterSeconds]) + + /** @param event Filter form submission. @returns Nothing; applies controls from page one. Does not throw. */ + function applyFilters(event: FormEvent): void { + event.preventDefault() + setQuery(current => ({ + ...current, + filterColumn: filterColumn && filterValue.trim() ? filterColumn : undefined, + filterValue: filterColumn && filterValue.trim() ? filterValue.trim() : undefined, + page: 1, + search: search.trim(), + })) + } + + /** Clears filters and sorting after a schema change or empty search; returns void and does not throw. */ + function clearFilters(): void { + setSearch('') + setFilterColumn('') + setFilterValue('') + setQuery({ ...initialQuery, perPage: query.perPage }) + } + + /** @param id Report column ID. @returns Nothing; toggles global sorting and resets pagination. Does not throw. */ + function sortBy(id: string): void { + setQuery(current => ({ + ...current, + page: 1, + sortBy: id, + sortOrder: current.sortBy === id && current.sortOrder === 'asc' ? 'desc' : 'asc', + })) + } + + 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) + .toLocaleString() : '' + + return ( +
+ Sales +
+
+

WORK / SALES

+

Sales

+

Your sales pipeline, directly from Salesforce.

+
+
+ Read only + +
+
+ +
+ + {report ? `Last updated ${updatedAt}` : 'Connecting to Salesforce'} + + Refreshes every minute while this page is visible +
+ + {error && ( +
+
+ {report ? 'Showing previously loaded data' : 'Unable to load sales data'} +

{error}

+
+ +
+ )} + + {report && !report.allData && ( +
+ Salesforce returned a limited set of records. Search, filters and totals apply to the + {' '} + {report.sourceRowCount.toLocaleString()} + {' '} + received records. Refine the source report in Salesforce to view a complete result. +
+ )} + +
+
+
+

{report?.reportName || 'Sales report'}

+

Salesforce is the source of truth. Changes are made there.

+
+ {report && ( + + {report.total.toLocaleString()} + {' '} + records + + )} +
+ +
+ setSearch(event.target.value)} + placeholder='Search all report fields' + tabIndex={0} + type='text' + value={search} + /> + + setFilterValue(event.target.value)} + placeholder='Filter value' + tabIndex={0} + type='text' + value={filterValue} + /> +
+ + +
+ + + {loading && !report && ( +
+ )} + {report && ( + <> +
+ + + + + {report.columns.map(column => ( + + ))} + + + + {report.rows.map(row => ( + + {row.cells.map((cell, index) => ( + + ))} + + ))} + +
{report.reportName}
+ +
+ {cell.label || '—'} +
+
+ {report.total === 0 && ( +
+

No sales records found

+

+ {query.search || query.filterValue + ? 'Try a different search or clear the filters.' + : 'The Salesforce report does not contain any records yet.'} +

+
+ )} +
+

+ {`Showing ${firstRow}–${lastRow} of ${report.total.toLocaleString()} records`} +

+
+ + + + {`Page ${report.page} of ${Math.max(1, report.totalPages)}`} + +
+
+ + )} +
+
+ ) +} + +export default SalesPage diff --git a/src/apps/sales/src/sales.models.ts b/src/apps/sales/src/sales.models.ts new file mode 100644 index 000000000..41664ddc7 --- /dev/null +++ b/src/apps/sales/src/sales.models.ts @@ -0,0 +1,29 @@ +/** Metadata-driven report contract shared by Sales and the WIN integration. */ +export interface SalesReport { + reportId: string + reportName: string + columns: Array<{ id: string; label: string; dataType: string }> + rows: Array<{ + id: string + cells: Array<{ label: string; value: string | number | boolean | null; currencyCode?: string }> + }> + allData: boolean + sourceRowCount: number + total: number + page: number + perPage: number + totalPages: number + refreshedAt: string + refreshAfterSeconds: number +} + +export interface SalesQuery { + page: number + perPage: number + search?: string + filterColumn?: string + filterValue?: string + sortBy?: string + sortOrder?: 'asc' | 'desc' + refresh?: boolean +} diff --git a/src/apps/sales/src/sales.routes.spec.tsx b/src/apps/sales/src/sales.routes.spec.tsx new file mode 100644 index 000000000..e84c8641c --- /dev/null +++ b/src/apps/sales/src/sales.routes.spec.tsx @@ -0,0 +1,26 @@ +import { UserRole } from '~/libs/core' + +import { salesRoutes } from './sales.routes' + +jest.mock('~/config', () => ({ + AppSubdomain: { sales: 'sales' }, + EnvironmentConfig: { SUBDOMAIN: 'platform-ui' }, + ToolTitle: { sales: 'Sales' }, +}), { virtual: true }) + +jest.mock('~/libs/core', () => ({ + lazyLoad: () => (): JSX.Element =>
, + UserRole: { administrator: 'administrator', talentManager: 'Talent Manager' }, +}), { virtual: true }) + +describe('Sales routes', () => { + it('restricts combined-host and dedicated sales access to the two authorized roles', () => { + expect(salesRoutes[0]) + .toMatchObject({ + authRequired: true, + domain: 'sales', + rolesRequired: [UserRole.administrator, UserRole.talentManager], + route: '/sales', + }) + }) +}) diff --git a/src/apps/sales/src/sales.routes.tsx b/src/apps/sales/src/sales.routes.tsx new file mode 100644 index 000000000..757d4116a --- /dev/null +++ b/src/apps/sales/src/sales.routes.tsx @@ -0,0 +1,17 @@ +import { AppSubdomain, EnvironmentConfig, ToolTitle } from '~/config' +import { lazyLoad, LazyLoadedComponent, PlatformRoute, UserRole } from '~/libs/core' + +const SalesPage: LazyLoadedComponent = lazyLoad(() => import('./SalesPage')) + +/** Dedicated Sales host and combined-host route; the API independently verifies these roles. */ +export const salesRoutes: ReadonlyArray = [ + { + authRequired: true, + domain: AppSubdomain.sales, + element: , + id: ToolTitle.sales, + rolesRequired: [UserRole.administrator, UserRole.talentManager], + route: EnvironmentConfig.SUBDOMAIN === AppSubdomain.sales ? '' : '/sales', + title: ToolTitle.sales, + }, +] diff --git a/src/apps/sales/src/sales.scss b/src/apps/sales/src/sales.scss new file mode 100644 index 000000000..3dafeb7c5 --- /dev/null +++ b/src/apps/sales/src/sales.scss @@ -0,0 +1 @@ +@import '@libs/ui/styles/2026/index'; diff --git a/src/apps/sales/src/sales.service.ts b/src/apps/sales/src/sales.service.ts new file mode 100644 index 000000000..8aefc94ed --- /dev/null +++ b/src/apps/sales/src/sales.service.ts @@ -0,0 +1,38 @@ +import { EnvironmentConfig } from '~/config' +import { xhrGetAsync } from '~/libs/core' + +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 signal Cancels obsolete requests when controls change or the page unmounts. + * @returns The current report schema and one page of rows. + * @throws Propagates network, authorization and sanitized Reports API errors. + */ +export function fetchSalesReport(query: SalesQuery, signal?: AbortSignal): Promise { + const params = new URLSearchParams() + Object.entries(query) + .forEach(([key, value]) => { + if (value !== undefined && value !== '') params.set(key, String(value)) + }) + return xhrGetAsync(`${EnvironmentConfig.REPORTS_API}/sales?${params.toString()}`, undefined, { + signal, + timeout: 120000, + }) +} + +/** + * Explains report failures without exposing transport internals or upstream responses. + * @param error Unknown request rejection. + * @returns User-facing recovery guidance, including explicit authorization failures. + * @throws Does not throw. + */ +export function salesErrorMessage(error: unknown): string { + const status = (error as { response?: { status?: number } })?.response?.status + 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.' + return 'We could not refresh the Salesforce report. Please try again.' +} diff --git a/src/apps/work/README.md b/src/apps/work/README.md index f04ba6d99..0f0031ea9 100644 --- a/src/apps/work/README.md +++ b/src/apps/work/README.md @@ -44,3 +44,10 @@ default and white on a teal hover background. Original implementation reference: - `work-manager/` + +## Sales + +The Sales tab opens the read-only Salesforce report for Administrators and Talent +Managers. It is also available on the dedicated Sales host. Search, column +filters, sorting, pagination and refresh use `reports-api-v6`; all edits remain +in Salesforce. See [Sales app documentation](../sales/README.md). diff --git a/src/apps/work/src/config/routes.config.ts b/src/apps/work/src/config/routes.config.ts index 0f52369d1..458c98215 100644 --- a/src/apps/work/src/config/routes.config.ts +++ b/src/apps/work/src/config/routes.config.ts @@ -6,6 +6,7 @@ export const rootRoute: string : `/${AppSubdomain.work}` export const challengesRouteId = 'challenges' +export const salesRouteId = 'sales' export const challengeCreateRouteId = 'challenge-create' export const challengeEditRouteId = 'challenge-edit' export const projectsRouteId = 'projects' diff --git a/src/apps/work/src/lib/components/NavTabs/config/tabs-config.spec.ts b/src/apps/work/src/lib/components/NavTabs/config/tabs-config.spec.ts index 217430eb5..8bd087b2e 100644 --- a/src/apps/work/src/lib/components/NavTabs/config/tabs-config.spec.ts +++ b/src/apps/work/src/lib/components/NavTabs/config/tabs-config.spec.ts @@ -18,6 +18,16 @@ jest.mock('../../../utils/permissions.utils', () => ({ })) describe('getTabsConfig', () => { + it.each(['administrator', 'Talent Manager'])('shows sales for %s', role => { + expect(getTabsConfig([role], false) + .map(tab => tab.id)) + .toContain('sales') + }) + + it.each(['copilot', 'Project Manager', 'Topcoder User'])('hides sales for %s', role => { + expect(getTabsConfig([role], false) + .map(tab => tab.id)).not.toContain('sales') + }) it('shows the engagements tab for talent managers on the common work page', () => { expect(getTabsConfig(['talent manager'], false) .map(tab => tab.id)) diff --git a/src/apps/work/src/lib/components/NavTabs/config/tabs-config.ts b/src/apps/work/src/lib/components/NavTabs/config/tabs-config.ts index f43b72cf2..a437cbb16 100644 --- a/src/apps/work/src/lib/components/NavTabs/config/tabs-config.ts +++ b/src/apps/work/src/lib/components/NavTabs/config/tabs-config.ts @@ -15,6 +15,7 @@ import { engagementsRouteId, groupsRouteId, projectsRouteId, + salesRouteId, taasRouteId, } from '../../../../config/routes.config' import { canViewAllEngagements } from '../../../utils/permissions.utils' @@ -23,6 +24,13 @@ function hasAnyRole(userRoles: string[], roles: string[]): boolean { return userRoles.some(role => roles.includes(role.toLowerCase())) } +/** + * Builds Work navigation, including Sales for Administrators and Talent Managers. + * @param userRoles Authenticated caller roles. + * @param isAnonymous Whether the visitor has no authenticated profile. + * @returns Visible Work tabs; anonymous visitors receive none. + * @throws Does not throw. + */ export function getTabsConfig(userRoles: string[], isAnonymous: boolean): TabsNavItem[] { if (isAnonymous) { return [] @@ -72,6 +80,10 @@ export function getTabsConfig(userRoles: string[], isAnonymous: boolean): TabsNa const isCopilot = hasAnyRole(userRoles, COPILOT_ROLES) + if (isAdmin || hasAnyRole(userRoles, ['talent manager'])) { + tabs.push({ id: salesRouteId, title: 'Sales' }) + } + if (isAdmin || isCopilot || isManager) { tabs.push({ id: groupsRouteId, diff --git a/src/apps/work/src/work-app.routes.tsx b/src/apps/work/src/work-app.routes.tsx index 9fc8cd5d4..72eb2b418 100644 --- a/src/apps/work/src/work-app.routes.tsx +++ b/src/apps/work/src/work-app.routes.tsx @@ -10,6 +10,7 @@ import { LazyLoadedComponent, PlatformRoute, Rewrite, + UserRole, } from '~/libs/core' import { @@ -38,6 +39,7 @@ import { roleErrorRoute, roleErrorRouteId, rootRoute, + salesRouteId, taasCreateRouteId, taasEditRouteId, taasRouteId, @@ -53,6 +55,7 @@ import { WorkAppContextModel } from './lib/models' import { canViewAllEngagements } from './lib/utils' const WorkApp: LazyLoadedComponent = lazyLoad(() => import('./WorkApp')) +const SalesPage: LazyLoadedComponent = lazyLoad(() => import('~/apps/sales/src/SalesPage')) const ChallengesListPage: LazyLoadedComponent = lazyLoad( () => import('./pages/challenges/ChallengesListPage'), @@ -287,6 +290,14 @@ export const workRoutes: ReadonlyArray = [ route: projectsRouteId, title: 'Projects', }, + { + authRequired: true, + element: , + id: salesRouteId, + rolesRequired: [UserRole.administrator, UserRole.talentManager], + route: salesRouteId, + title: 'Sales', + }, { authRequired: true, element: ( diff --git a/src/config/constants.ts b/src/config/constants.ts index 9c52ca030..8d42331b0 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -19,6 +19,7 @@ export enum AppSubdomain { engagements = 'engagements', customer = 'customer', reports = 'reports', + sales = 'sales', procurement = 'procurement', status = 'status', support = 'support', @@ -47,6 +48,7 @@ export enum ToolTitle { engagements = 'Engagements', customer = 'Customer', reports = 'Reports', + sales = 'Sales', procurement = 'Procurement', status = 'Status', support = 'Support', diff --git a/src/libs/ui/lib/styles/2026/README.md b/src/libs/ui/lib/styles/2026/README.md index 80fa4a168..a5ebdc1b4 100644 --- a/src/libs/ui/lib/styles/2026/README.md +++ b/src/libs/ui/lib/styles/2026/README.md @@ -3,7 +3,7 @@ This folder contains the scoped foundations for the August 2026 Topcoder design system. The shared theme is imported only by the internal Work, System Admin, Calendar, Procurement, and Reports applications, plus Support, Opportunities, -and Gigs. +Gigs, and Sales. Engagements, the Copilots Portal, Review, and other non-migrated apps do not opt into these shared foundations. Their existing app and component styles continue @@ -13,8 +13,8 @@ the shared theme. `_tokens.scss` is the source for the 2026 color, type, radius, and elevation tokens. `_scope.scss` exposes matching custom properties and base element styles under explicit internal app and Support body classes, and the low-specificity -`:where(.opportunities-app)` and `:where(.gigs-app)` content containers. -Opportunities and Gigs must not apply their theme classes to `body`, so their +`:where(.opportunities-app)`, `:where(.gigs-app)`, and `:where(.sales-app)` content containers. +Opportunities, Gigs, and Sales must not apply their theme classes to `body`, so their shared navigation retains the legacy styles. Keep the allowlist explicit: adding an unscoped `body`, heading, form, or table diff --git a/src/libs/ui/lib/styles/2026/_scope.scss b/src/libs/ui/lib/styles/2026/_scope.scss index 171c7da7f..c41c065b3 100644 --- a/src/libs/ui/lib/styles/2026/_scope.scss +++ b/src/libs/ui/lib/styles/2026/_scope.scss @@ -13,6 +13,7 @@ $tc-2026-apps: // Keep Opportunities and Gigs isolated from universal navigation while allowing // CSS-module component colors to override these low-specificity defaults. ':where(.opportunities-app)', + ':where(.sales-app)', ':where(.gigs-app)'; @each $app in $tc-2026-apps {