From 92a1a2ad1f9c8b84c0ada88bbba85aa6b40c0d78 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 6 Oct 2026 14:46:33 +1100 Subject: [PATCH] Show project and billing history in Sales opportunity modal --- src/apps/sales/README.md | 16 +++ .../sales/src/OpportunityModal.module.scss | 27 ++++ src/apps/sales/src/OpportunityModal.spec.tsx | 132 ++++++++++++++++++ src/apps/sales/src/OpportunityModal.tsx | 62 +++++++- src/apps/sales/src/SalesPage.spec.tsx | 4 + src/apps/sales/src/opportunity.service.ts | 13 +- src/apps/sales/src/sales.utils.spec.ts | 28 ++++ src/apps/sales/src/sales.utils.ts | 14 ++ 8 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 src/apps/sales/src/OpportunityModal.spec.tsx diff --git a/src/apps/sales/README.md b/src/apps/sales/README.md index 3bffbc546..14f0649ef 100644 --- a/src/apps/sales/README.md +++ b/src/apps/sales/README.md @@ -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: diff --git a/src/apps/sales/src/OpportunityModal.module.scss b/src/apps/sales/src/OpportunityModal.module.scss index 37b3c02dc..264794bce 100644 --- a/src/apps/sales/src/OpportunityModal.module.scss +++ b/src/apps/sales/src/OpportunityModal.module.scss @@ -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; diff --git a/src/apps/sales/src/OpportunityModal.spec.tsx b/src/apps/sales/src/OpportunityModal.spec.tsx new file mode 100644 index 000000000..607d11d7b --- /dev/null +++ b/src/apps/sales/src/OpportunityModal.spec.tsx @@ -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 ? ( +
+

{props.title}

+ {props.children} +
+ ) : undefined + ), + Button: () => , + LoadingSpinner: () => Loading, +}), { virtual: true }) + +const fetchDetails = fetchOpportunity as jest.MockedFunction +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 { + fetchDetails.mockResolvedValue(details) + render() + 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() + }) +}) diff --git a/src/apps/sales/src/OpportunityModal.tsx b/src/apps/sales/src/OpportunityModal.tsx index cfdf38973..65bb747d1 100644 --- a/src/apps/sales/src/OpportunityModal.tsx +++ b/src/apps/sales/src/OpportunityModal.tsx @@ -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' @@ -8,6 +7,7 @@ import { opportunityErrorMessage, SalesOpportunity, } from './opportunity.service' +import { workProjectUrl } from './sales.utils' import styles from './OpportunityModal.module.scss' export interface OpportunityModalProps { @@ -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. @@ -56,6 +58,10 @@ export const OpportunityModal: FC = 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 }, @@ -100,6 +106,60 @@ export const OpportunityModal: FC = props => { )} + {billingAccount && ( +
+
+
Current billing account
+
+ {billingAccount.name || 'Name unavailable'} + + ID: + {' '} + {billingAccount.id} + +
+
+ {projectUrl && ( + + )} +
+
Past billing accounts
+
+ {pastBillingAccounts.length > 0 ? ( +
    + {pastBillingAccounts.map(account => ( +
  • + {account.name || 'Name unavailable'} + + ID: + {' '} + {account.id} + +
  • + ))} +
+ ) : 'No past billing accounts found.'} +
+
+
+ )} + ({ + 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.', diff --git a/src/apps/sales/src/opportunity.service.ts b/src/apps/sales/src/opportunity.service.ts index 31b979aec..22c5628d7 100644 --- a/src/apps/sales/src/opportunity.service.ts +++ b/src/apps/sales/src/opportunity.service.ts @@ -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 @@ -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( diff --git a/src/apps/sales/src/sales.utils.spec.ts b/src/apps/sales/src/sales.utils.spec.ts index 7014d9b64..8c8bc63bb 100644 --- a/src/apps/sales/src/sales.utils.spec.ts +++ b/src/apps/sales/src/sales.utils.spec.ts @@ -1,3 +1,5 @@ +import { EnvironmentConfig } from '~/config' + import { SalesReport, SalesSummary, SalesSummaryAmount } from './sales.models' import { amountTotal, @@ -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 { @@ -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()) diff --git a/src/apps/sales/src/sales.utils.ts b/src/apps/sales/src/sales.utils.ts index 1079e27c4..650e9d1ef 100644 --- a/src/apps/sales/src/sales.utils.ts +++ b/src/apps/sales/src/sales.utils.ts @@ -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']