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: 16 additions & 0 deletions src/apps/sales/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@ 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.

When the opportunity has a local billing account, the popup also shows its
current ID/name, a **View project … in Work** link to
`{WORK_APP}/projects/{projectId}/challenges`, and a **Past billing accounts** list
with each account's ID/name. The Projects API resolves the account from
`BillingAccount.opportunity`, the project from `Project.billingAccountId`, and
`relatedBillingAccounts` from all unique billing account IDs referenced by that
project's challenges (all statuses, including inactive accounts). The popup
excludes the current account from the past-account list; the response retains it
when a challenge references it. An empty past list displays “No past billing
accounts found.” Missing names display “Name unavailable” alongside the ID.

All new fields are hidden when `billingAccount` is null or absent. An account
without a matching project still displays its details, with no Work link.
Links use the active environment's Work host. Deploy the Billing Accounts API
`opportunity` filter, then the Projects API response expansion, before the UI.

## Dashboard layout (PM-6392)

The page reads as an executive dashboard rather than one long column:
Expand Down
27 changes: 27 additions & 0 deletions src/apps/sales/src/OpportunityModal.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,33 @@
overflow-wrap: anywhere;
}

.billingDetails {
display: grid;
gap: 16px;
margin-top: 24px;
}

.accountId {
display: block;
font-size: 12px;
color: $tc-2026-muted;
}

.accountList {
list-style: disc;
padding-left: 20px;

li + li {
margin-top: 8px;
}
}

.projectLink {
font-weight: 700;
color: $tc-2026-teal;
text-decoration: underline;
}

.link {
display: inline-block;
margin-top: 24px;
Expand Down
132 changes: 132 additions & 0 deletions src/apps/sales/src/OpportunityModal.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
import '@testing-library/jest-dom'
import { render, screen, within } from '@testing-library/react'
import { ReactNode } from 'react'

import OpportunityModal from './OpportunityModal'
import { fetchOpportunity, SalesOpportunity } from './opportunity.service'

jest.mock('~/config', () => ({
EnvironmentConfig: { URLS: { WORK_APP: 'https://work.topcoder-dev.com/' } },
}), { virtual: true })

jest.mock('./opportunity.service', () => ({
fetchOpportunity: jest.fn(),
opportunityErrorMessage: () => 'Unable to load opportunity details.',
}))

jest.mock('~/libs/ui', () => ({
BaseModal: (props: { children: ReactNode; open: boolean; title: ReactNode }) => (
props.open ? (
<div role='dialog'>
<h2>{props.title}</h2>
{props.children}
</div>
) : undefined
),
Button: () => <button type='button'>Close</button>,
LoadingSpinner: () => <span>Loading</span>,
}), { virtual: true })

const fetchDetails = fetchOpportunity as jest.MockedFunction<typeof fetchOpportunity>
const opportunity: SalesOpportunity = {
billingAccount: { id: '22', name: 'Current contract' },
description: 'Example description',
id: '006UN00000XamntYAB',
name: 'Example opportunity',
projectId: '123',
relatedBillingAccounts: [
{ id: '3', name: 'Original contract' },
{ id: '11', name: 'Extension' },
{ id: '22', name: 'Current contract' },
{ id: '99' },
],
url: 'https://topcoder.my.salesforce.com/006UN00000XamntYAB',
}

/**
* Opens the popup with a mocked opportunity response for billing behavior tests.
* @param details Opportunity data, including nullable or absent billing context.
* @returns Resolves after the opportunity has loaded in the rendered modal.
* @throws Testing Library errors if the expected description does not render.
*/
async function openOpportunity(details: SalesOpportunity): Promise<void> {
fetchDetails.mockResolvedValue(details)
render(<OpportunityModal
onClose={jest.fn()}
open
opportunityId={details.id}
opportunityName={details.name}
/>)
await screen.findByText('Example description')
}

describe('OpportunityModal billing context', () => {
beforeEach(() => jest.clearAllMocks())

it('shows the current account, Work challenges link, and only past accounts in the list', async () => {
await openOpportunity(opportunity)
expect(screen.getByText('Current contract'))
.toBeInTheDocument()
expect(screen.getByText('ID: 22'))
.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'View project 123 in Work' }))
.toHaveAttribute('href', 'https://work.topcoder-dev.com/projects/123/challenges')
expect(screen.getByRole('link', { name: 'View project 123 in Work' }))
.toHaveAttribute('rel', 'noopener noreferrer')
const list = screen.getByRole('list')
expect(within(list)
.getAllByRole('listitem'))
.toHaveLength(3)
expect(within(list)
.getByText('Original contract'))
.toBeInTheDocument()
expect(within(list)
.getByText('Extension'))
.toBeInTheDocument()
expect(within(list)
.getByText('Name unavailable'))
.toBeInTheDocument()
expect(within(list)
.getByText('ID: 99'))
.toBeInTheDocument()
expect(within(list)
.queryByText('Current contract')).not.toBeInTheDocument()
})

// The API sends JSON null, while older responses omit the new field.
// eslint-disable-next-line unicorn/no-null
it.each([null, undefined])('hides every new field without an associated billing account (%s)', async account => {
await openOpportunity({ ...opportunity, billingAccount: account })
expect(screen.queryByText('Current billing account')).not.toBeInTheDocument()
expect(screen.queryByText('Past billing accounts')).not.toBeInTheDocument()
expect(screen.queryByText('Work project')).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /in Work/ })).not.toBeInTheDocument()
expect(screen.queryByRole('list')).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'View in Salesforce' }))
.toBeInTheDocument()
})

it('keeps the account visible when no matching project exists', async () => {
// eslint-disable-next-line unicorn/no-null -- The API sends a null project ID when no project matches.
await openOpportunity({ ...opportunity, projectId: null, relatedBillingAccounts: [] })
expect(screen.getByText('ID: 22'))
.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /in Work/ })).not.toBeInTheDocument()
expect(screen.getByText('No past billing accounts found.'))
.toBeInTheDocument()
})

it('shows an empty past list when only the current account was used by challenges', async () => {
await openOpportunity({ ...opportunity, relatedBillingAccounts: [{ id: '22', name: 'Current contract' }] })
expect(screen.getAllByText('Current contract'))
.toHaveLength(1)
expect(screen.getByText('No past billing accounts found.'))
.toBeInTheDocument()
})

it('does not construct a Work link for an invalid project ID', async () => {
await openOpportunity({ ...opportunity, projectId: '../other' })
expect(screen.queryByRole('link', { name: /in Work/ })).not.toBeInTheDocument()
})
})
62 changes: 61 additions & 1 deletion src/apps/sales/src/OpportunityModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* 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'
Expand All @@ -8,6 +7,7 @@ import {
opportunityErrorMessage,
SalesOpportunity,
} from './opportunity.service'
import { workProjectUrl } from './sales.utils'
import styles from './OpportunityModal.module.scss'

export interface OpportunityModalProps {
Expand All @@ -26,6 +26,8 @@ type ModalState =

/**
* Shows the details of one Salesforce opportunity, starting with its description.
* When a billing account exists, also shows its ID/name, the Work challenges
* link, and past accounts used by the project, excluding the current account.
* @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.
Expand Down Expand Up @@ -56,6 +58,10 @@ export const OpportunityModal: FC<OpportunityModalProps> = props => {
}, [props.open, props.opportunityId])

const opportunity = state.status === 'ready' ? state.opportunity : undefined
const billingAccount = opportunity?.billingAccount
const projectUrl = billingAccount ? workProjectUrl(opportunity?.projectId) : undefined
const pastBillingAccounts = (opportunity?.relatedBillingAccounts ?? [])
.filter(account => account.id !== billingAccount?.id)
const details: Array<{ label: string; value: string | undefined }> = [
{ label: 'Customer', value: opportunity?.customer },
{ label: 'SMU', value: opportunity?.reportingSmu },
Expand Down Expand Up @@ -100,6 +106,60 @@ export const OpportunityModal: FC<OpportunityModalProps> = props => {
</dl>
)}

{billingAccount && (
<dl className={styles.billingDetails}>
<div className={styles.detail}>
<dt>Current billing account</dt>
<dd>
{billingAccount.name || 'Name unavailable'}
<span className={styles.accountId}>
ID:
{' '}
{billingAccount.id}
</span>
</dd>
</div>
{projectUrl && (
<div className={styles.detail}>
<dt>Work project</dt>
<dd>
<a
className={styles.projectLink}
href={`${projectUrl}/challenges`}
rel='noopener noreferrer'
target='_blank'
>
View project
{' '}
{opportunity.projectId}
{' '}
in Work
</a>
</dd>
</div>
)}
<div className={styles.detail}>
<dt>Past billing accounts</dt>
<dd>
{pastBillingAccounts.length > 0 ? (
<ul className={styles.accountList}>
{pastBillingAccounts.map(account => (
<li key={account.id}>
{account.name || 'Name unavailable'}
<span className={styles.accountId}>
ID:
{' '}
{account.id}
</span>
</li>
))}
</ul>
) : 'No past billing accounts found.'}
</dd>
</div>
</dl>
)}

<a
className={styles.link}
href={opportunity.url}
Expand Down
4 changes: 4 additions & 0 deletions src/apps/sales/src/SalesPage.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { fetchOpportunity } from './opportunity.service'
import { SalesReport } from './sales.models'
import { fetchSalesReport } from './sales.service'

jest.mock('~/config', () => ({
EnvironmentConfig: { URLS: { WORK_APP: 'https://work.topcoder-dev.com' } },
}), { virtual: true })

jest.mock('./sales.service', () => ({
fetchSalesReport: jest.fn(),
salesErrorMessage: () => 'Unable to refresh. Please try again.',
Expand Down
13 changes: 12 additions & 1 deletion src/apps/sales/src/opportunity.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { EnvironmentConfig } from '~/config'
import { xhrGetAsync } from '~/libs/core'

/** Current or historical account identity returned by the opportunity endpoint. */
export interface OpportunityBillingAccount {
id: string
name?: string
}

/** Salesforce details and optional project billing context for the Sales popup. */
export interface SalesOpportunity {
id: string
name: string
Expand All @@ -11,13 +18,17 @@ export interface SalesOpportunity {
closeDate?: string
stageName?: string
url: string
billingAccount?: OpportunityBillingAccount | null
projectId?: string | null
relatedBillingAccounts?: OpportunityBillingAccount[]
}

/**
* Reads a Salesforce opportunity through the role-protected Projects API.
* @param opportunityId Salesforce opportunity id taken from a report cell.
* @param signal Cancels the request when the popup closes or another row is opened.
* @returns The opportunity details shown in the popup.
* @returns Opportunity details, current billing account, project ID, and unique
* billing accounts referenced by the project's challenges, for the popup.
* @throws Propagates network, authorization and sanitized Projects API errors.
*/
export function fetchOpportunity(
Expand Down
28 changes: 28 additions & 0 deletions src/apps/sales/src/sales.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { EnvironmentConfig } from '~/config'

import { SalesReport, SalesSummary, SalesSummaryAmount } from './sales.models'
import {
amountTotal,
Expand All @@ -12,8 +14,13 @@ import {
withDateRange,
withDrilldown,
wonSowSignedTotal,
workProjectUrl,
} from './sales.utils'

jest.mock('~/config', () => ({
EnvironmentConfig: { URLS: { WORK_APP: 'https://work.topcoder-dev.com' } },
}), { virtual: true })

/** @returns A synthetic report schema with both pipeline date fields. Does not throw. */
function report(): SalesReport {
return {
Expand All @@ -37,6 +44,27 @@ function report(): SalesReport {
}
}

describe('Work project links', () => {
it('uses the configured Work host and only positive integer project IDs', () => {
expect(workProjectUrl(' 12345 '))
.toBe(`${EnvironmentConfig.URLS.WORK_APP}/projects/12345`)
expect(workProjectUrl(12345))
.toBe(`${EnvironmentConfig.URLS.WORK_APP}/projects/12345`)
const invalidIds = [
// Salesforce nulls and unsafe values must never produce a link.
// eslint-disable-next-line unicorn/no-null
null, undefined, '', '-', true, false, 0, -1, 1.5, '006000000000001AAA',
// eslint-disable-next-line no-script-url
'https://connect.topcoder.com/projects/12345', 'javascript:alert(1)', '../12345',
'9007199254740992',
]
invalidIds.forEach(value => {
expect(workProjectUrl(value))
.toBeUndefined()
})
})
})

describe('Sales date range utilities', () => {
it('offers only date fields and opens on Created Date for pipeline analysis', () => {
const columns = dateColumns(report())
Expand Down
14 changes: 14 additions & 0 deletions src/apps/sales/src/sales.utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
import { EnvironmentConfig } from '~/config'

import { SalesQuery, SalesReport, SalesSummary, SalesSummaryAmount, SalesSummaryGroup } from './sales.models'

/**
* Builds the Work app destination for a project ID using the active environment.
* @param value Project ID from an opportunity or report cell; Salesforce URLs and record IDs are not project IDs.
* @returns A project URL for a positive integer ID, otherwise undefined so no invalid link is rendered.
* @throws Does not throw; missing and malformed IDs do not create links.
*/
export function workProjectUrl(value: string | number | boolean | null | undefined): string | undefined {
const projectId = typeof value === 'string' ? value.trim() : String(value)
if (!/^[1-9]\d*$/.test(projectId) || !Number.isSafeInteger(Number(projectId))) return undefined
return `${EnvironmentConfig.URLS.WORK_APP.replace(/\/$/, '')}/projects/${projectId}`
}

/** Report column types a date range can be applied to; mirrors the Reports API contract. */
const dateTypes = ['date', 'datetime']

Expand Down
Loading