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

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.
The page calls `GET {REPORTS_API}/sales` with the signed-in user's token, and
`GET {API.V6}/projects/salesforce/opportunities/{id}` for the opportunity popup.
All Salesforce credentials stay in `reports-api-v6` and `projects-api-v6`. No
create, update, delete, export, machine credentials or direct Salesforce API
calls exist in the UI.

Report cells whose value is a Salesforce opportunity id (the `006` key prefix)
render the opportunity name as a button. Opening it shows a popup with the
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.

Report metadata determines every displayed column, including grouped Stage.
Search and column substring filters apply automatically as the user types (debounced); headers sort globally
Expand Down
60 changes: 60 additions & 0 deletions src/apps/sales/src/OpportunityModal.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
@import '@libs/ui/styles/2026/tokens';

.body {
display: block;
}

.state {
padding: 40px 0;
min-height: 140px;
position: relative;
}

.error {
border: 1px solid $tc-2026-danger;
border-radius: $tc-2026-radius-md;
color: $tc-2026-danger;
padding: 16px;
}

.sectionTitle {
font-size: 12px;
font-weight: 700;
letter-spacing: 1.5px;
text-transform: uppercase;
color: $tc-2026-muted;
}

.description {
margin-top: 8px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}

.details {
margin-top: 24px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}

.detail dt {
font-size: 12px;
font-weight: 700;
letter-spacing: 1.5px;
text-transform: uppercase;
color: $tc-2026-muted;
}

.detail dd {
margin-top: 4px;
overflow-wrap: anywhere;
}

.link {
display: inline-block;
margin-top: 24px;
font-weight: 700;
color: $tc-2026-teal;
text-decoration: underline;
}
117 changes: 117 additions & 0 deletions src/apps/sales/src/OpportunityModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/* The modal body renders only the opportunity fields the report already exposes. */
import { FC, useEffect, useState } from 'react'

import { BaseModal, Button, LoadingSpinner } from '~/libs/ui'

import {
fetchOpportunity,
opportunityErrorMessage,
SalesOpportunity,
} from './opportunity.service'
import styles from './OpportunityModal.module.scss'

export interface OpportunityModalProps {
/** Salesforce opportunity id taken from the report cell. */
opportunityId: string
/** Opportunity name shown while the details are still loading. */
opportunityName: string
onClose: () => void
open: boolean
}

type ModalState =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; opportunity: SalesOpportunity }

/**
* Shows the details of one Salesforce opportunity, starting with its description.
* @param props The opportunity to load, whether the popup is open, and the close handler.
* @returns A dialog closed with either the Close button or the X icon.
* @throws Does not throw request failures; shows the sanitized message inline.
*/
export const OpportunityModal: FC<OpportunityModalProps> = props => {
const [state, setState] = useState<ModalState>({ status: 'loading' })

useEffect(() => {
if (!props.open) {
return undefined
}

const controller = new AbortController()
setState({ status: 'loading' })
fetchOpportunity(props.opportunityId, controller.signal)
.then(opportunity => {
if (!controller.signal.aborted) {
setState({ opportunity, status: 'ready' })
}
})
.catch(error => {
if (!controller.signal.aborted) {
setState({ message: opportunityErrorMessage(error), status: 'error' })
}
})

return () => controller.abort()
}, [props.open, props.opportunityId])

const opportunity = state.status === 'ready' ? state.opportunity : undefined
const details: Array<{ label: string; value: string | undefined }> = [
{ label: 'Customer', value: opportunity?.customer },
{ label: 'SMU', value: opportunity?.reportingSmu },
{ label: 'Close Date', value: opportunity?.closeDate },
{ label: 'Stage', value: opportunity?.stageName },
].filter(item => !!item.value)

return (
<BaseModal
bodyClassName={styles.body}
buttons={(
<Button noCaps onClick={props.onClose} secondary>Close</Button>
)}
onClose={props.onClose}
open={props.open}
size='md'
title={opportunity?.name || props.opportunityName}
>
{state.status === 'loading' && (
<div className={styles.state}><LoadingSpinner message='Loading opportunity details…' /></div>
)}

{state.status === 'error' && (
<div className={styles.error} role='alert'>{state.message}</div>
)}

{opportunity && (
<>
<h3 className={styles.sectionTitle}>Description</h3>
<p className={styles.description}>
{opportunity.description || 'This opportunity does not have a description yet.'}
</p>

{details.length > 0 && (
<dl className={styles.details}>
{details.map(item => (
<div className={styles.detail} key={item.label}>
<dt>{item.label}</dt>
<dd>{item.value}</dd>
</div>
))}
</dl>
)}

<a
className={styles.link}
href={opportunity.url}
rel='noopener noreferrer'
target='_blank'
>
View in Salesforce
</a>
</>
)}
</BaseModal>
)
}

export default OpportunityModal
2 changes: 2 additions & 0 deletions src/apps/sales/src/SalesPage.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
.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; }
.opportunityButton { padding: 0; border: 0; background: transparent; text-align: left; color: $tc-2026-teal; font-weight: 700; text-decoration: underline; cursor: pointer; overflow-wrap: anywhere; }
.opportunityButton:hover, .opportunityButton:focus-visible { color: $tc-2026-body; }
.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; }
Expand Down
79 changes: 77 additions & 2 deletions src/apps/sales/src/SalesPage.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { ButtonHTMLAttributes, ReactNode } from 'react'

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

Expand All @@ -12,7 +13,21 @@ jest.mock('./sales.service', () => ({
salesErrorMessage: () => 'Unable to refresh. Please try again.',
}))

jest.mock('./opportunity.service', () => ({
fetchOpportunity: jest.fn(),
opportunityErrorMessage: () => 'We could not load the opportunity details. Please try again.',
}))

jest.mock('~/libs/ui', () => ({
BaseModal: (props: { buttons?: ReactNode; children: ReactNode; open: boolean; title: ReactNode }) => (
props.open ? (
<div role='dialog'>
<h2>{props.title}</h2>
{props.children}
{props.buttons}
</div>
) : undefined
),
Button: (props: ButtonHTMLAttributes<HTMLButtonElement>) => (
<button disabled={props.disabled} onClick={props.onClick} type={props.type === 'submit' ? 'submit' : 'button'}>
{props.children}
Expand All @@ -24,6 +39,7 @@ jest.mock('~/libs/ui', () => ({
}), { virtual: true })

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

/** Creates non-customer Sales test data. @returns A synthetic report page. Does not throw. */
function fixture(): SalesReport {
Expand All @@ -44,8 +60,11 @@ function fixture(): SalesReport {
}

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

it('renders live metadata and sends search, column filters, sorting and pagination to the API', async () => {
render(<SalesPage />)
Expand Down Expand Up @@ -158,4 +177,60 @@ describe('Sales page', () => {
visibility.mockRestore()
jest.useRealTimers()
})

it('opens the opportunity description in a popup and closes it again', async () => {
fetchReport.mockResolvedValue({
...fixture(),
rows: [{
cells: [{ label: 'EMEA - AWS - PS BFSI', value: '006UN00000XamntYAB' }],
id: '0:0',
}],
})
fetchOpportunityDetails.mockResolvedValue({
closeDate: '2026-07-31',
description: 'Next AWS MVP for the BFSI practice.',
id: '006UN00000XamntYAB',
name: 'EMEA - AWS - PS BFSI',
url: 'https://topcoder.my.salesforce.com/006UN00000XamntYAB',
})

render(<SalesPage />)
fireEvent.click(await screen.findByRole('button', { name: 'EMEA - AWS - PS BFSI' }))

expect(fetchOpportunityDetails)
.toHaveBeenCalledWith('006UN00000XamntYAB', expect.any(AbortSignal))
expect(await screen.findByText('Next AWS MVP for the BFSI practice.'))
.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'View in Salesforce' }))
.toHaveAttribute('href', 'https://topcoder.my.salesforce.com/006UN00000XamntYAB')

fireEvent.click(screen.getByRole('button', { name: 'Close' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
})

it('explains a failed opportunity lookup inside the popup', async () => {
fetchReport.mockResolvedValue({
...fixture(),
rows: [{
cells: [{ label: 'EMEA - AWS - PS BFSI', value: '006UN00000XamntYAB' }],
id: '0:0',
}],
})
fetchOpportunityDetails.mockRejectedValue({ response: { status: 404 } })

render(<SalesPage />)
fireEvent.click(await screen.findByRole('button', { name: 'EMEA - AWS - PS BFSI' }))

expect(await screen.findByRole('alert'))
.toHaveTextContent('We could not load the opportunity details. Please try again.')
})

it('leaves cells that do not carry an opportunity id as plain text', async () => {
render(<SalesPage />)
await screen.findByText('Example opportunity')

expect(screen.queryByRole('button', { name: 'Example opportunity' }))
.not
.toBeInTheDocument()
})
})
Loading
Loading