From 035de8e919a424229ded96965cb3f6784c2372d7 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 29 Apr 2026 18:04:31 +1000 Subject: [PATCH 01/52] PM-4970: Correct BA challenge fee split What was broken BA summary divided challenge line-item amounts by markup before displaying Member Payments, so a challenge with $50 in member payments and 33% markup showed $37.59 member payments and $12.40 fee instead of $50.00 and $16.50. Root cause Challenge billing-account rows already expose the member-payment subtotal for manager/admin challenge rows, but the modal treated every non-copilot row as a ledger total that still needed markup removed. What was changed Use the raw challenge line-item amount as manager/admin member payments and calculate the challenge fee from markup. Keep the existing reverse-markup behavior for engagement ledger rows and copilot fallback responses. Any added/updated tests Updated BillingAccountLineItemsModal coverage for the PM-4970 challenge split and added an assertion that engagement rows still reverse-calculate payment and fee amounts. --- .../BillingAccountLineItemsModal.spec.tsx | 22 ++++++------ .../BillingAccountLineItemsModal.tsx | 35 ++++++++++++------- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx index e784634f4..7ea21a693 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx @@ -125,33 +125,31 @@ describe('BillingAccountLineItemsModal', () => { .toBe('/work/challenges/challenge%20%2F%20100') }) - it('shows member payments and challenge fees for non-copilot users', () => { + it('shows challenge member payments and calculated challenge fees for non-copilot users', () => { renderModal({ ...baseBillingAccountDetails, lockedAmounts: [ { - amount: '125.25', + amount: '50', date: '2026-02-10T00:00:00.000Z', externalId: 'challenge-100', externalName: 'Markup Challenge', externalType: 'CHALLENGE', }, ], - lockedBudget: 125.25, - markup: 0.25, - totalBudgetRemaining: 874.75, + lockedBudget: 66.5, + markup: 0.33, + totalBudgetRemaining: 933.5, }) expect(screen.getByText('Member Payments')) .toBeTruthy() expect(screen.getByText('Challenge Fee')) .toBeTruthy() - expect(screen.getByText('$100.20')) - .toBeTruthy() - expect(screen.getByText('$25.05')) - .toBeTruthy() - expect(screen.getAllByText('$125.25')) + expect(screen.getAllByText('$50.00')) .toHaveLength(1) + expect(screen.getByText('$16.50')) + .toBeTruthy() }) it('builds engagement links from assignment-backed billing rows', () => { @@ -226,6 +224,10 @@ describe('BillingAccountLineItemsModal', () => { expect(engagementLink.getAttribute('href')) .toBe('/work/projects/project%20200/engagements/engagement-300') + expect(screen.getByText('$100.00')) + .toBeTruthy() + expect(screen.getByText('$20.00')) + .toBeTruthy() expect(mockedUseFetchEngagements) .toHaveBeenLastCalledWith( 'project 200', diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx index 0447b7034..637395e23 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx @@ -192,8 +192,9 @@ function buildEngagementUrl(projectId: string, engagementId: string): string { * @param item Line item already mapped for the current caller role. * @returns Formatted currency or `-` when a copilot-safe amount is unavailable. * @remarks Copilot rows use member payment amounts without exposing markup. - * Other roles use the member payment amount derived from billing markup and - * show the fee separately. + * Manager/admin challenge rows use the challenge subtotal returned by the + * billing-account API, while engagement rows still derive the payment amount + * from the billing ledger total. */ function formatLineItemAmount(item: BillingAccountModalLineItem): string { return item.displayAmount === undefined @@ -223,19 +224,29 @@ function formatLineItemChallengeFee(item: BillingAccountModalLineItem): string { * @param showMemberPaymentsRemaining Whether the caller needs the copilot-safe view. * @returns Member payment amount, or `undefined` for copilot rows when it cannot * be safely calculated. - * @remarks Non-copilot rows fall back to the raw amount if markup is missing so - * legacy billing-account payloads keep rendering an amount. + * @remarks Challenge budget rows already expose the member-payment subtotal + * for manager/admin users. Engagement budget rows store the billing ledger + * total, so they still need markup removed before display. Copilot rows prefer + * API-provided member-payment amounts and fall back to the legacy markup math + * only when that safe field is missing. */ function getLineItemMemberPaymentAmount( item: BillingAccountLineItem, billingAccountDetails: BillingAccountDetails, showMemberPaymentsRemaining: boolean | undefined, ): number | undefined { - const memberPaymentAmount = item.memberPaymentAmount - ?? calculateMemberPaymentAmount( - item.amount, - billingAccountDetails.markup, - ) + if (item.memberPaymentAmount !== undefined) { + return item.memberPaymentAmount + } + + if (!showMemberPaymentsRemaining && item.externalType === 'CHALLENGE') { + return item.amount + } + + const memberPaymentAmount = calculateMemberPaymentAmount( + item.amount, + billingAccountDetails.markup, + ) return memberPaymentAmount !== undefined || showMemberPaymentsRemaining ? memberPaymentAmount @@ -251,9 +262,9 @@ function getLineItemMemberPaymentAmount( * @returns A line item with `displayAmount` set to the visible member-payment * amount and, for non-copilots, `challengeFeeAmount` set to the billing markup fee. * @remarks Copilot rows prefer the API-provided member payment amount because - * their response intentionally omits markup. Non-copilot rows derive member - * payments from the raw amount and billing-account markup, then render the fee - * in its own column. + * their response intentionally omits markup. Manager/admin challenge rows use + * the raw challenge subtotal and calculate the fee from markup; engagement rows + * derive member payments from the raw ledger amount and billing-account markup. */ function getDisplayLineItem( item: BillingAccountLineItem, From cf82cc057e77082c8424c42ff768baa82468f263 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 29 Apr 2026 18:18:26 +1000 Subject: [PATCH 02/52] PM-4973: Restrict project user management actions What was broken Work Manager users with global Copilot, Project Manager, or Talent Manager access could open a project users URL directly and still get member-management controls for a project they could not manage. Root cause (if identifiable) The users page enabled add, invite, remove, and role-edit controls from global Work Manager role flags instead of requiring manage access to the loaded project. What was changed The users page now derives member-management permission from the existing project management access helper for the loaded project. The same permission guards the header actions, editable member cards, and add/invite modal rendering. Any added/updated tests Added a UsersManagementPage regression test that verifies a global Project Manager who cannot manage the loaded project does not see add, invite, or edit controls. Validation note The focused UsersManagementPage test, lint, and build pass. The full test command still fails in an unrelated wallet-admin PaymentView spec that also fails when run by itself. --- .../UsersManagementPage.spec.tsx | 43 ++++++++++++++++++- .../UsersManagementPage.tsx | 12 ++---- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/apps/work/src/pages/users/UsersManagementPage/UsersManagementPage.spec.tsx b/src/apps/work/src/pages/users/UsersManagementPage/UsersManagementPage.spec.tsx index 54d53f8ed..39d056ca2 100644 --- a/src/apps/work/src/pages/users/UsersManagementPage/UsersManagementPage.spec.tsx +++ b/src/apps/work/src/pages/users/UsersManagementPage/UsersManagementPage.spec.tsx @@ -96,7 +96,6 @@ jest.mock('../../../lib/services', () => ({ })) jest.mock('../../../lib/utils', () => ({ checkCanManageProject: jest.fn(() => true), - checkIsCopilotOrManager: jest.fn(() => false), showErrorToast: jest.fn(), showSuccessToast: jest.fn(), })) @@ -211,4 +210,46 @@ describe('UsersManagementPage', () => { expect(screen.queryByTestId('page-back-link')) .toBeNull() }) + + it('hides member management actions when a global manager role cannot manage the project', () => { + mockedCheckCanManageProject.mockReturnValue(false) + mockedUseFetchProject.mockReturnValue({ + error: undefined, + isLoading: false, + mutate: jest.fn(), + project: { + id: 200, + name: 'Restricted Project', + status: 'active', + }, + }) + + renderPage('/projects/200/users', { + ...defaultContextValue, + isAdmin: false, + isManager: true, + loginUserInfo: { + email: 'manager@example.com', + exp: 0, + handle: 'manager-user', + iat: 0, + roles: ['project manager'], + userId: 12345, + } as WorkAppContextModel['loginUserInfo'], + userRoles: ['project manager'], + }) + + const pageRightHeader = screen.getByTestId('page-right-header') + const pageTitleAction = screen.getByTestId('page-title-action') + + expect(within(pageRightHeader) + .queryByRole('button', { name: 'Add User' })) + .toBeNull() + expect(within(pageRightHeader) + .queryByRole('button', { name: 'Invite User' })) + .toBeNull() + expect(within(pageTitleAction) + .queryByRole('link', { name: 'Edit project' })) + .toBeNull() + }) }) diff --git a/src/apps/work/src/pages/users/UsersManagementPage/UsersManagementPage.tsx b/src/apps/work/src/pages/users/UsersManagementPage/UsersManagementPage.tsx index ad2dd1f1e..8b029e7be 100644 --- a/src/apps/work/src/pages/users/UsersManagementPage/UsersManagementPage.tsx +++ b/src/apps/work/src/pages/users/UsersManagementPage/UsersManagementPage.tsx @@ -39,7 +39,6 @@ import { } from '../../../lib/services' import { checkCanManageProject, - checkIsCopilotOrManager, showErrorToast, showSuccessToast, } from '../../../lib/utils' @@ -98,9 +97,6 @@ export const UsersManagementPage: FC = () => { const [showInviteUserModal, setShowInviteUserModal] = useState(false) const { - isAdmin, - isCopilot, - isManager, loginUserInfo, userRoles, }: WorkAppContextModel = useContext(WorkAppContext) @@ -116,15 +112,13 @@ export const UsersManagementPage: FC = () => { const selectedProjectName = toOptionalString(projectResult.project?.name) const pageTitle = selectedProjectName || 'Project users' - const loginHandle = loginUserInfo?.handle || '' - const canManageMembers = (isAdmin || isCopilot || isManager) - || checkIsCopilotOrManager(members, loginHandle) const canManageProject = !!projectResult.project && checkCanManageProject( userRoles, loginUserInfo?.userId, projectResult.project, ) + const canManageMembers = canManageProject const hasMembers = members.length > 0 const hasDeclinedInvites = declinedInvites.length > 0 @@ -392,7 +386,7 @@ export const UsersManagementPage: FC = () => { ) : undefined} - {showAddUserModal && projectId + {showAddUserModal && projectId && canManageMembers ? ( { ) : undefined} - {showInviteUserModal && projectId + {showInviteUserModal && projectId && canManageMembers ? ( Date: Wed, 29 Apr 2026 18:33:13 +1000 Subject: [PATCH 03/52] PM-4393: Guard project workspace routes What was broken The previous PM-4393 fixes blocked unauthorized project challenge list and challenge detail URLs, but other project workspace URLs still mounted for users who were not members of the project. QA found TM, PM, and copilot users could open project engagement, engagement detail, users, and asset library URLs and in some cases add project members. Root cause The existing project access check was applied inside challenge pages only. Project-scoped route entries for engagements, users, and assets rendered their child pages before confirming project membership, allowing those pages to fetch and display child project data. What was changed Added a reusable ProjectRouteAccessGuard that checks the current route project with checkProjectAccess before rendering protected route content. Wrapped the project engagement list/detail/application/assignment/feedback/experience routes, project users route, and project asset library route so unauthorized users see the required project access message. Any added/updated tests Added ProjectRouteAccessGuard tests covering allowed access, loading, denied membership, and failed project fetches. --- .../ProjectRouteAccessGuard.spec.tsx | 192 ++++++++++++++++++ .../ProjectRouteAccessGuard.tsx | 76 +++++++ .../ProjectRouteAccessGuard/index.ts | 1 + src/apps/work/src/lib/components/index.ts | 1 + src/apps/work/src/work-app.routes.tsx | 59 +++++- 5 files changed, 319 insertions(+), 10 deletions(-) create mode 100644 src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.spec.tsx create mode 100644 src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.tsx create mode 100644 src/apps/work/src/lib/components/ProjectRouteAccessGuard/index.ts diff --git a/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.spec.tsx b/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.spec.tsx new file mode 100644 index 000000000..3b51e79fd --- /dev/null +++ b/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.spec.tsx @@ -0,0 +1,192 @@ +/* eslint-disable no-var, global-require, @typescript-eslint/no-var-requires */ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import type { + Context, + PropsWithChildren, +} from 'react' +import { + render, + screen, +} from '@testing-library/react' +import { + MemoryRouter, + Route, + Routes, +} from 'react-router-dom' + +import { WorkAppContextModel } from '../../models' +import { useFetchProject } from '../../hooks' +import { checkProjectAccess } from '../../utils' + +import { + PROJECT_ACCESS_DENIED_MESSAGE, + ProjectRouteAccessGuard, +} from './ProjectRouteAccessGuard' + +var mockWorkAppContext: Context + +jest.mock('~/apps/review/src/lib', () => ({ + PageWrapper: ( + props: PropsWithChildren<{ + pageTitle?: string + }>, + ) => ( +
+

{props.pageTitle}

+
{props.children}
+
+ ), +}), { + virtual: true, +}) +jest.mock('~/libs/ui', () => ({ + Button: (props: { label: string }) => ( + + ), + LoadingSpinner: () =>
Loading Spinner
, +}), { + virtual: true, +}) +jest.mock('../../contexts', () => { + const React = require('react') as typeof import('react') + + mockWorkAppContext = React.createContext({ + isAdmin: false, + isAnonymous: false, + isCopilot: false, + isManager: false, + isReadOnly: false, + loginUserInfo: undefined, + userRoles: [], + }) + + return { + WorkAppContext: mockWorkAppContext, + } +}) +jest.mock('../../hooks', () => ({ + useFetchProject: jest.fn(), +})) +jest.mock('../../utils', () => ({ + checkProjectAccess: jest.fn(), +})) + +const mockedUseFetchProject = useFetchProject as jest.Mock +const mockedCheckProjectAccess = checkProjectAccess as jest.Mock + +const defaultContextValue: WorkAppContextModel = { + isAdmin: false, + isAnonymous: false, + isCopilot: false, + isManager: true, + isReadOnly: false, + loginUserInfo: { + email: 'manager@example.com', + exp: 0, + handle: 'manager-user', + iat: 0, + roles: ['Project Manager'], + userId: 12345, + } as WorkAppContextModel['loginUserInfo'], + userRoles: ['Project Manager'], +} + +function renderGuard( + route: string, + contextValue: WorkAppContextModel = defaultContextValue, +): void { + const MockWorkAppContext = mockWorkAppContext + + render( + + + + +
Protected Project Users
+ + )} + /> +
+
+
, + ) +} + +describe('ProjectRouteAccessGuard', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedUseFetchProject.mockReturnValue({ + error: undefined, + isLoading: false, + mutate: jest.fn(), + project: { + id: 200, + members: [{ + userId: 12345, + }], + }, + }) + mockedCheckProjectAccess.mockReturnValue(true) + }) + + it('renders the protected route when project access is allowed', () => { + renderGuard('/projects/200/users') + + expect(mockedCheckProjectAccess) + .toHaveBeenCalledWith(defaultContextValue.userRoles, 12345, expect.objectContaining({ id: 200 })) + expect(screen.getByText('Protected Project Users')) + .toBeTruthy() + }) + + it('shows loading while project access is resolving', () => { + mockedUseFetchProject.mockReturnValue({ + error: undefined, + isLoading: true, + mutate: jest.fn(), + project: undefined, + }) + + renderGuard('/projects/200/users') + + expect(screen.getByRole('heading', { level: 1, name: 'Users' })) + .toBeTruthy() + expect(screen.getByText('Loading Spinner')) + .toBeTruthy() + expect(screen.queryByText('Protected Project Users')) + .toBeNull() + expect(mockedCheckProjectAccess) + .not.toHaveBeenCalled() + }) + + it('shows the project access denial message when project access is rejected', () => { + mockedCheckProjectAccess.mockReturnValue(false) + + renderGuard('/projects/200/users') + + expect(screen.getByRole('heading', { level: 1, name: 'Users' })) + .toBeTruthy() + expect(screen.getByText(PROJECT_ACCESS_DENIED_MESSAGE)) + .toBeTruthy() + expect(screen.queryByText('Protected Project Users')) + .toBeNull() + }) + + it('shows the project access denial message when the project fetch fails', () => { + mockedUseFetchProject.mockReturnValue({ + error: new Error('Forbidden'), + isLoading: false, + mutate: jest.fn(), + project: undefined, + }) + + renderGuard('/projects/200/users') + + expect(screen.getByText(PROJECT_ACCESS_DENIED_MESSAGE)) + .toBeTruthy() + expect(screen.queryByText('Protected Project Users')) + .toBeNull() + }) +}) diff --git a/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.tsx b/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.tsx new file mode 100644 index 000000000..3c75a69e3 --- /dev/null +++ b/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.tsx @@ -0,0 +1,76 @@ +import { + FC, + PropsWithChildren, + useContext, +} from 'react' +import { useParams } from 'react-router-dom' + +import { PageWrapper } from '~/apps/review/src/lib' + +import { WorkAppContext } from '../../contexts' +import { useFetchProject } from '../../hooks' +import { WorkAppContextModel } from '../../models' +import { checkProjectAccess } from '../../utils' +import { ErrorMessage } from '../ErrorMessage' +import { LoadingSpinner } from '../LoadingSpinner' + +export const PROJECT_ACCESS_DENIED_MESSAGE + = 'You don’t have access to this project. Please contact support@topcoder.com.' + +interface ProjectRouteAccessGuardProps extends PropsWithChildren { + pageTitle: string +} + +/** + * Blocks project-scoped Work routes until the current user has project access. + * + * @param props child route content and fallback page title used while access is loading or denied. + * @returns child route content when the project exists and the caller is an admin or project member. + * @remarks Used by project workspace routes so unauthorized users do not mount pages that fetch project child data. + * @throws Does not throw; project fetch failures render the standard project access denial message. + */ +export const ProjectRouteAccessGuard: FC = ( + props: ProjectRouteAccessGuardProps, +) => { + const params: Readonly<{ projectId?: string }> = useParams<'projectId'>() + const projectId = params.projectId?.trim() + + const workAppContext = useContext(WorkAppContext) as WorkAppContextModel + const projectResult = useFetchProject(projectId || undefined) + + if (!projectId) { + return <>{props.children} + } + + if (projectResult.isLoading) { + return ( + + + + ) + } + + const hasProjectAccess = checkProjectAccess( + workAppContext.userRoles, + workAppContext.loginUserInfo?.userId, + projectResult.project, + ) + + if (projectResult.error || !hasProjectAccess) { + return ( + + + + ) + } + + return <>{props.children} +} + +export default ProjectRouteAccessGuard diff --git a/src/apps/work/src/lib/components/ProjectRouteAccessGuard/index.ts b/src/apps/work/src/lib/components/ProjectRouteAccessGuard/index.ts new file mode 100644 index 000000000..c9831bfb9 --- /dev/null +++ b/src/apps/work/src/lib/components/ProjectRouteAccessGuard/index.ts @@ -0,0 +1 @@ +export * from './ProjectRouteAccessGuard' diff --git a/src/apps/work/src/lib/components/index.ts b/src/apps/work/src/lib/components/index.ts index ee606c570..29122df08 100644 --- a/src/apps/work/src/lib/components/index.ts +++ b/src/apps/work/src/lib/components/index.ts @@ -22,6 +22,7 @@ export * from './Pagination' export * from './ProjectCard' export * from './ProjectBillingAccountExpiredNotice' export * from './ProjectListTabs' +export * from './ProjectRouteAccessGuard' export * from './ProjectStatus' export * from './PaymentFormModal' export * from './PaymentHistoryModal' diff --git a/src/apps/work/src/work-app.routes.tsx b/src/apps/work/src/work-app.routes.tsx index 68758d959..250452f52 100644 --- a/src/apps/work/src/work-app.routes.tsx +++ b/src/apps/work/src/work-app.routes.tsx @@ -39,7 +39,10 @@ import { usersRouteId, } from './config/routes.config' import { WORK_MANAGER_ALLOWED_ROLES } from './config/access.config' -import { ErrorMessage } from './lib/components' +import { + ErrorMessage, + ProjectRouteAccessGuard, +} from './lib/components' import { WorkAppContext } from './lib/contexts' import { WorkAppContextModel } from './lib/models' import { canViewAllEngagements } from './lib/utils' @@ -222,7 +225,11 @@ export const workRoutes: ReadonlyArray = [ }, { authRequired: true, - element: , + element: ( + + + + ), id: projectAssetsRouteId, route: '/projects/:projectId/assets', title: 'Project Assets', @@ -261,48 +268,76 @@ export const workRoutes: ReadonlyArray = [ }, { authRequired: true, - element: , + element: ( + + + + ), route: '/projects/:projectId/engagements', title: 'Engagements', }, { authRequired: true, - element: , + element: ( + + + + ), id: engagementCreateRouteId, route: '/projects/:projectId/engagements/new', title: 'Create Engagement', }, { authRequired: true, - element: , + element: ( + + + + ), id: engagementEditRouteId, route: '/projects/:projectId/engagements/:engagementId', title: 'Edit Engagement', }, { authRequired: true, - element: , + element: ( + + + + ), id: engagementApplicationsRouteId, route: '/projects/:projectId/engagements/:engagementId/applications', title: 'Applications', }, { authRequired: true, - element: , + element: ( + + + + ), id: engagementAssignmentsRouteId, route: '/projects/:projectId/engagements/:engagementId/assignments', title: 'Assignments', }, { authRequired: true, - element: , + element: ( + + + + ), id: engagementFeedbackRouteId, route: '/projects/:projectId/engagements/:engagementId/assignments/:assignmentId/feedback', title: 'Feedback', }, { authRequired: true, - element: , + element: ( + + + + ), id: engagementExperienceRouteId, route: '/projects/:projectId/engagements/:engagementId/assignments/:assignmentId/experience', title: 'Experience', @@ -349,7 +384,11 @@ export const workRoutes: ReadonlyArray = [ }, { authRequired: true, - element: , + element: ( + + + + ), id: usersRouteId, route: '/projects/:projectId/users', title: 'Users', From 52dea342e393e7921d461046a328e097a5c1c71f Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Thu, 30 Apr 2026 13:50:34 +0530 Subject: [PATCH 04/52] PM-4961 View Billing Accountdetails --- src/apps/reports/src/config/routes.config.ts | 1 + .../src/lib/components/NavTabs/NavTabs.tsx | 10 +- src/apps/reports/src/lib/services/index.ts | 5 + .../src/lib/services/reports.service.ts | 50 ++ .../src/pages/reports/BillingAccountsPage.tsx | 3 + .../src/pages/reports/ReportsPage.module.scss | 187 ++++++- .../reports/src/pages/reports/ReportsPage.tsx | 499 +++++++++++++++--- src/apps/reports/src/reports-app.routes.tsx | 9 + 8 files changed, 683 insertions(+), 81 deletions(-) create mode 100644 src/apps/reports/src/pages/reports/BillingAccountsPage.tsx diff --git a/src/apps/reports/src/config/routes.config.ts b/src/apps/reports/src/config/routes.config.ts index fb7d07091..914a95cd9 100644 --- a/src/apps/reports/src/config/routes.config.ts +++ b/src/apps/reports/src/config/routes.config.ts @@ -10,3 +10,4 @@ export const rootRoute: string export const reportsPageRouteId = 'reports' export const bulkMemberLookupRouteId = 'bulk-member-lookup' +export const billingAccountsPageRouteId = 'billing-accounts' diff --git a/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx b/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx index cb0c2e34a..2a73aa4fe 100644 --- a/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx +++ b/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx @@ -15,7 +15,11 @@ import classNames from 'classnames' import { useClickOutside } from '~/libs/shared/lib/hooks' import { TabsNavItem } from '~/libs/ui' -import { bulkMemberLookupRouteId, reportsPageRouteId } from '../../../config/routes.config' +import { + billingAccountsPageRouteId, + bulkMemberLookupRouteId, + reportsPageRouteId, +} from '../../../config/routes.config' import styles from './NavTabs.module.scss' @@ -34,6 +38,10 @@ const NavTabs: FC = () => { id: bulkMemberLookupRouteId, title: 'Bulk Member Lookup', }, + { + id: billingAccountsPageRouteId, + title: 'Billing Accounts', + }, ], []) const activeTabPathName: string = useMemo(() => { diff --git a/src/apps/reports/src/lib/services/index.ts b/src/apps/reports/src/lib/services/index.ts index b2c6dc18b..567bbcf6a 100644 --- a/src/apps/reports/src/lib/services/index.ts +++ b/src/apps/reports/src/lib/services/index.ts @@ -2,6 +2,7 @@ export { downloadBlobFile, downloadReportAsCsv, downloadReportAsJson, + fetchReportJson, fetchReportsIndex, postReportAsCsv, postReportAsJson, @@ -10,8 +11,12 @@ export { } from './reports.service' export type { + BillingAccountDetail, + BillingAccountProfileResponse, + BillingAccountsViewData, ReportDefinition, ReportGroup, ReportParameter, ReportsIndexResponse, + SfdcBillingAccountPaymentRow, } from './reports.service' diff --git a/src/apps/reports/src/lib/services/reports.service.ts b/src/apps/reports/src/lib/services/reports.service.ts index d752087c8..97930cec0 100644 --- a/src/apps/reports/src/lib/services/reports.service.ts +++ b/src/apps/reports/src/lib/services/reports.service.ts @@ -28,6 +28,46 @@ export type ReportGroup = { export type ReportsIndexResponse = Record +export type BillingAccountDetail = { + name: string + description: string | null + subcontractingEndCustomer: string | null + status: string + startDate: string | null + endDate: string | null + budget: string | number + markup: string | number +} + +export type SfdcBillingAccountPaymentRow = { + paymentId: string + paymentDate: string + billingAccountId: string + paymentStatus: string + challengeFee: string | number + paymentAmount: string | number + challengeId: string + category: string + isTask: boolean + challengeName: string | null + challengeStatus: string | null + winnerHandle: string + winnerId: string + winnerFirstName: string + winnerLastName: string +} + +/** Response from GET /sfdc/billing-accounts */ +export type BillingAccountProfileResponse = { + billingAccount: BillingAccountDetail | null +} + +/** Billing Accounts in-app view: profile + rows from GET /sfdc/payments */ +export type BillingAccountsViewData = { + billingAccount: BillingAccountDetail | null + payments: SfdcBillingAccountPaymentRow[] +} + const reportsDownloadClient: AxiosInstance = xhrCreateInstance() const buildReportUrl = (path: string): string => { @@ -137,6 +177,16 @@ export const downloadReportAsJson = (path: string): Promise => ( downloadReportBlob(path, 'application/json') ) +export const fetchReportJson = async (path: string): Promise => { + if (!path) { + throw new Error('Report path is required') + } + + const normalizedPath = path.startsWith('/') ? path : `/${path}` + const url = `${EnvironmentConfig.API.V6}/reports${normalizedPath}` + return xhrGetAsync(url, reportsDownloadClient) +} + export const downloadReportAsCsv = (path: string): Promise => ( downloadReportBlob(path, 'text/csv') ) diff --git a/src/apps/reports/src/pages/reports/BillingAccountsPage.tsx b/src/apps/reports/src/pages/reports/BillingAccountsPage.tsx new file mode 100644 index 000000000..fc399e312 --- /dev/null +++ b/src/apps/reports/src/pages/reports/BillingAccountsPage.tsx @@ -0,0 +1,3 @@ +import { BillingAccountsPage } from './ReportsPage' + +export default BillingAccountsPage diff --git a/src/apps/reports/src/pages/reports/ReportsPage.module.scss b/src/apps/reports/src/pages/reports/ReportsPage.module.scss index e804f2221..4b60a8919 100644 --- a/src/apps/reports/src/pages/reports/ReportsPage.module.scss +++ b/src/apps/reports/src/pages/reports/ReportsPage.module.scss @@ -26,26 +26,109 @@ gap: 4px; } +.filtersPanel { + margin-top: 12px; + padding: 16px; + border: 1px solid #e4e6e9; + border-radius: 8px; + background: #fcfcfd; +} + .params { display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 16px; - margin-top: 12px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 24px 20px; + margin-top: 0; + align-items: start; +} + +@media (max-width: 1200px) { + .params { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .params { + grid-template-columns: 1fr; + } +} + +.paramCard { + display: grid; + grid-template-rows: auto 40px auto; + row-gap: 8px; + align-content: start; +} + +.paramHeader { + min-height: 28px; +} + +.paramTitleRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.paramHeaderActions { + display: flex; + align-items: center; + gap: 8px; } .paramLabel { font-weight: 600; + color: #2f3338; +} + +.paramTypePill { + font-size: 11px; + color: #5e6369; + background: #f3f4f6; + border-radius: 999px; + padding: 2px 8px; + white-space: nowrap; } .paramMeta { color: #6b6f75; font-size: 12px; + line-height: 1.35; + min-height: 40px; + overflow: hidden; + display: -webkit-box; + line-clamp: 2; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; } -.paramHint { +.actionsBar { + display: flex; + justify-content: flex-start; + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid #eceef1; +} + +.paramInfoButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border: 0; + padding: 0; + border-radius: 50%; + background: transparent; color: #6b6f75; - font-size: 12px; - font-style: italic; + cursor: pointer; + + svg { + width: 16px; + height: 16px; + } } .reportTitle { @@ -94,3 +177,95 @@ font-style: italic; color: #6b6f75; } + +.billingSummary { + margin-top: 8px; + padding: 16px; + border: 1px solid #e4e6e9; + border-radius: 6px; + background: #fafbfc; +} + +.billingSummaryTitle { + font-weight: 600; + margin-bottom: 12px; +} + +.billingDetailGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px 24px; +} + +.billingDetailItem { + display: flex; + flex-direction: column; + gap: 2px; +} + +.billingDetailLabel { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #6b6f75; +} + +.billingDetailValue { + font-size: 14px; + color: #1a1d21; + word-break: break-word; +} + +.billingMissingNotice { + margin-top: 8px; + padding: 12px; + border-radius: 4px; + background: #f3f4f6; + color: #494f55; + font-size: 14px; +} + +.paymentsSection { + margin-top: 24px; +} + +.paymentsSectionTitle { + font-weight: 600; + margin-bottom: 8px; +} + +.tableWrap { + overflow-x: auto; + border: 1px solid #e4e6e9; + border-radius: 6px; +} + +.paymentsTable { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.paymentsTable th, +.paymentsTable td { + padding: 8px 10px; + text-align: left; + border-bottom: 1px solid #e4e6e9; + vertical-align: top; +} + +.paymentsTable th { + background: #f3f4f6; + font-weight: 600; + white-space: nowrap; +} + +.paymentsTable tbody tr:last-child td { + border-bottom: none; +} + +.paymentsEmpty { + margin-top: 8px; + color: #6b6f75; + font-style: italic; +} diff --git a/src/apps/reports/src/pages/reports/ReportsPage.tsx b/src/apps/reports/src/pages/reports/ReportsPage.tsx index b00f5cf2f..e37bdfd87 100644 --- a/src/apps/reports/src/pages/reports/ReportsPage.tsx +++ b/src/apps/reports/src/pages/reports/ReportsPage.tsx @@ -1,19 +1,32 @@ import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react' import { NavigateFunction, useNavigate } from 'react-router-dom' -import { Button, InputSelect, InputSelectOption, InputText, LoadingSpinner, PageTitle } from '~/libs/ui' +import { + Button, + IconOutline, + InputSelect, + InputSelectOption, + InputText, + LoadingSpinner, + PageTitle, + Tooltip, +} from '~/libs/ui' import { bulkMemberLookupRouteId } from '../../config/routes.config' import { handleError } from '../../lib/utils' import { + BillingAccountProfileResponse, + BillingAccountsViewData, downloadBlobFile, downloadReportAsCsv, downloadReportAsJson, + fetchReportJson, fetchReportsIndex, ReportDefinition, ReportGroup, ReportParameter, ReportsIndexResponse, + SfdcBillingAccountPaymentRow, } from '../../lib/services' import { getReportParameterValidationError } from './reports-page.validation' @@ -21,6 +34,199 @@ import styles from './ReportsPage.module.scss' const pageTitle = 'Reports' const bulkMembersByHandlesPath = '/identity/users-by-handles' +const BILLING_ACCOUNTS_REPORT_PATH = '/sfdc/billing-accounts' +const SFDC_PAYMENTS_REPORT_PATH = '/sfdc/payments' +const BILLING_ACCOUNTS_REPORT_DEFINITION: ReportDefinition = { + name: 'Billing Accounts', + path: BILLING_ACCOUNTS_REPORT_PATH, + description: 'View billing-account details and SFDC payments by billing account ID.', + method: 'GET', + parameters: [ + { + name: 'billingAccountId', + type: 'string', + description: 'Billing account ID', + required: true, + location: 'query', + }, + { + name: 'startDate', + type: 'date', + description: 'Optional start date for payment filtering (ISO 8601)', + location: 'query', + }, + { + name: 'endDate', + type: 'date', + description: 'Optional end date for payment filtering (ISO 8601)', + location: 'query', + }, + ], +} + +type ReportsPageTab = 'reports' | 'billingAccounts' + +const buildSfdcPaymentsQueryPath = ( + billingAccountId: string, + startDate?: string, + endDate?: string, +): string => { + const query = new URLSearchParams() + query.append('billingAccountIds', billingAccountId.trim()) + const start = startDate?.trim() + const end = endDate?.trim() + + if (start) { + query.append('startDate', start) + } + + if (end) { + query.append('endDate', end) + } + + return `${SFDC_PAYMENTS_REPORT_PATH}?${query.toString()}` +} + +const formatReportCell = (value: unknown): string => { + if (value === null || value === undefined || value === '') { + return '—' + } + + if (typeof value === 'boolean') { + return value ? 'Yes' : 'No' + } + + return String(value) +} + +const formatPaymentDate = (iso: string): string => { + const parsed = Date.parse(iso) + + if (Number.isNaN(parsed)) { + return iso + } + + return new Date(parsed).toLocaleString() +} + +const PAYMENT_TABLE_COLUMNS: { key: keyof SfdcBillingAccountPaymentRow; label: string }[] = [ + { key: 'paymentId', label: 'Payment ID' }, + { key: 'paymentDate', label: 'Payment date' }, + { key: 'billingAccountId', label: 'Billing account ID' }, + { key: 'paymentStatus', label: 'Status' }, + { key: 'challengeFee', label: 'Challenge fee' }, + { key: 'paymentAmount', label: 'Payment amount' }, + { key: 'challengeId', label: 'Challenge ID' }, + { key: 'category', label: 'Category' }, + { key: 'isTask', label: 'Task' }, + { key: 'challengeName', label: 'Challenge name' }, + { key: 'challengeStatus', label: 'Challenge status' }, + { key: 'winnerHandle', label: 'Winner handle' }, + { key: 'winnerId', label: 'Winner ID' }, + { key: 'winnerFirstName', label: 'Winner first name' }, + { key: 'winnerLastName', label: 'Winner last name' }, +] + +const BillingAccountReportResults = ({ data }: { data: BillingAccountsViewData }): JSX.Element => { + const { billingAccount, payments } = data + + return ( +
+
+
Billing account
+ {billingAccount ? ( +
+
+ Name + {billingAccount.name} +
+
+ Description + + {formatReportCell(billingAccount.description)} + +
+
+ Subcontracting end customer + + {formatReportCell(billingAccount.subcontractingEndCustomer)} + +
+
+ Status + {billingAccount.status} +
+
+ Start date + + {billingAccount.startDate + ? formatPaymentDate(String(billingAccount.startDate)) + : '—'} + +
+
+ End date + + {billingAccount.endDate + ? formatPaymentDate(String(billingAccount.endDate)) + : '—'} + +
+
+ Budget + + {formatReportCell(billingAccount.budget)} + +
+
+ Markup + + {formatReportCell(billingAccount.markup)} + +
+
+ ) : ( +
+ No billing account profile was found for this ID. Payments for this account may still + appear below. +
+ )} +
+ +
+
Payments
+ {payments.length === 0 ? ( +
No payments matched the selected filters.
+ ) : ( +
+ + + + {PAYMENT_TABLE_COLUMNS.map(col => ( + + ))} + + + + {payments.map(row => ( + + {PAYMENT_TABLE_COLUMNS.map(col => ( + + ))} + + ))} + +
{col.label}
+ {col.key === 'paymentDate' + ? formatPaymentDate(String(row[col.key])) + : formatReportCell(row[col.key])} +
+
+ )} +
+
+ ) +} const buildDownloadName = ( name: string, @@ -48,12 +254,21 @@ const formatMethod = (method?: string): string => ( method ? method.toUpperCase() : 'GET' ) +const formatParameterLabel = (name: string): string => ( + name + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/Ids\b/g, 'IDs') + .replace(/^./, char => char.toUpperCase()) +) + type ReportActionsProps = { handleCsvDownload: () => void handleJsonDownload: () => void + handleResetFilters: () => void handleOpenBulkMemberLookup: () => void isDownloadDisabled: boolean isHandleLookupPostReport: boolean + isResetDisabled: boolean isPostReport: boolean } @@ -94,6 +309,13 @@ const ReportActions = (props: ReportActionsProps): JSX.Element => { > Download as CSV + ) } @@ -125,50 +347,69 @@ const SelectedReportSection = (props: SelectedReportSectionProps): JSX.Element = - {(props.selectedReport.parameters?.length ?? 0) > 0 && ( -
- {props.selectedReport.parameters?.map(parameter => ( -
-
- {parameter.name} - {parameter.required ? ' *' : ''} -
- {parameter.description && ( -
{parameter.description}
- )} -
- Location: - {' '} - {parameter.location || 'query'} - {' '} - • Type: - {' '} - {parameter.type} -
- {parameter.type.endsWith('[]') && ( -
- Use comma-separated values for lists. + {(props.selectedReport.parameters?.length ?? 0) > 0 ? ( +
+
+ {props.selectedReport.parameters?.map(parameter => ( +
+
+
+
+ {formatParameterLabel(parameter.name)} + {parameter.required ? ' *' : ''} +
+
+
{parameter.type}
+ + + +
+
- )} - {props.renderParameterInput(parameter)} -
- ))} +
+ {parameter.description || '\u00A0'} +
+ {props.renderParameterInput(parameter)} +
+ ))} +
+
+ {props.reportActions} +
+ ) : ( + props.reportActions )} - - {props.reportActions} ) } -export const ReportsPage: FC = () => { +type ReportsPageContentProps = { + initialTab: ReportsPageTab +} + +const ReportsPageContent: FC = ({ initialTab }) => { const navigate: NavigateFunction = useNavigate() + const [activeTab] = useState(initialTab) const [reportsIndex, setReportsIndex] = useState({}) const [selectedBasePath, setSelectedBasePath] = useState('') const [selectedReportPath, setSelectedReportPath] = useState('') const [isLoading, setIsLoading] = useState(false) const [downloadingFormat, setDownloadingFormat] = useState<'json' | 'csv' | undefined>(undefined) const [parameterValues, setParameterValues] = useState>({}) + const [billingAccountViewData, setBillingAccountViewData] = useState< + BillingAccountsViewData | undefined + >(undefined) + const [isBillingAccountViewLoading, setIsBillingAccountViewLoading] = useState(false) useEffect(() => { let isMounted = true @@ -230,15 +471,21 @@ export const ReportsPage: FC = () => { selectedGroup?.reports?.find(report => report.path === selectedReportPath) ), [selectedGroup, selectedReportPath]) + const selectedReportForForm = activeTab === 'billingAccounts' + ? BILLING_ACCOUNTS_REPORT_DEFINITION + : selectedReport + const handleBasePathChange = useCallback((event: ChangeEvent) => { setSelectedBasePath(event.target.value) setSelectedReportPath('') setParameterValues({}) + setBillingAccountViewData(undefined) }, []) const handleReportChange = useCallback((event: ChangeEvent) => { setSelectedReportPath(event.target.value) setParameterValues({}) + setBillingAccountViewData(undefined) }, []) const handleParameterChange = useCallback((event: ChangeEvent) => { @@ -291,7 +538,7 @@ export const ReportsPage: FC = () => { }, [parameterValues]) const parameterErrors = useMemo>(() => ( - (selectedReport?.parameters ?? []).reduce>((errors, parameter) => { + (selectedReportForForm?.parameters ?? []).reduce>((errors, parameter) => { const error = getReportParameterValidationError(parameter, parameterValues[parameter.name]) if (error) { @@ -300,12 +547,55 @@ export const ReportsPage: FC = () => { return errors }, {}) - ), [parameterValues, selectedReport]) + ), [parameterValues, selectedReportForForm]) const hasInvalidParameterValues = useMemo(() => ( Object.keys(parameterErrors).length > 0 ), [parameterErrors]) + const handleBillingAccountView = useCallback(async () => { + if (activeTab !== 'billingAccounts' || hasInvalidParameterValues) { + return + } + + const billingAccountId = parameterValues.billingAccountId?.trim() + + if (!billingAccountId) { + return + } + + try { + setIsBillingAccountViewLoading(true) + const profileQuery = new URLSearchParams({ billingAccountId }) + const profilePath = `${BILLING_ACCOUNTS_REPORT_PATH}?${profileQuery.toString()}` + const paymentsPath = buildSfdcPaymentsQueryPath( + billingAccountId, + parameterValues.startDate, + parameterValues.endDate, + ) + + const paymentsPromise = fetchReportJson(paymentsPath) + const profilePromise = fetchReportJson(profilePath) + .catch(() => ({ billingAccount: null })) + const [profile, payments] = await Promise.all([profilePromise, paymentsPromise]) + + setBillingAccountViewData({ + billingAccount: profile.billingAccount, + payments, + }) + } catch (error) { + handleError(error) + } finally { + setIsBillingAccountViewLoading(false) + } + }, [ + activeTab, + hasInvalidParameterValues, + parameterValues.billingAccountId, + parameterValues.endDate, + parameterValues.startDate, + ]) + const handleDownload = useCallback(async (format: 'json' | 'csv') => { if (!selectedReport || hasInvalidParameterValues) { return @@ -338,18 +628,24 @@ export const ReportsPage: FC = () => { navigate(bulkMemberLookupRouteId) }, [navigate]) + const handleResetFilters = useCallback(() => { + setParameterValues({}) + setBillingAccountViewData(undefined) + }, []) + const isDownloading = downloadingFormat !== undefined + const isBusy = isDownloading || isBillingAccountViewLoading const requiredParamsMissing = useMemo(() => { - const params = selectedReport?.parameters ?? [] + const params = selectedReportForForm?.parameters ?? [] return params.some(param => param.required && !(parameterValues[param.name]?.trim())) - }, [parameterValues, selectedReport]) + }, [parameterValues, selectedReportForForm]) const hasUnresolvedPathParams = useMemo(() => ( - (selectedReport?.parameters ?? []) + (selectedReportForForm?.parameters ?? []) .filter(param => param.location === 'path') .some(param => !parameterValues[param.name]?.trim()) - ), [parameterValues, selectedReport]) + ), [parameterValues, selectedReportForForm]) const isPostReport = selectedReport?.method?.toUpperCase() === 'POST' const isHandleLookupPostReport = isPostReport && selectedReport.path === bulkMembersByHandlesPath @@ -360,6 +656,14 @@ export const ReportsPage: FC = () => { || hasInvalidParameterValues || hasUnresolvedPathParams + const billingAccountViewDisabled = !selectedReportForForm + || isDownloading + || isBillingAccountViewLoading + || requiredParamsMissing + || hasInvalidParameterValues + || hasUnresolvedPathParams + const isResetDisabled = Object.keys(parameterValues).length === 0 + const handleJsonDownload = useCallback(() => { handleDownload('json') }, [handleDownload]) @@ -368,20 +672,41 @@ export const ReportsPage: FC = () => { handleDownload('csv') }, [handleDownload]) + const billingAccountReportActions = ( +
+ + +
+ ) + const reportActions = ( ) const renderParameterInput = useCallback((parameter: ReportParameter) => { const commonProps = { - label: parameter.name, + label: formatParameterLabel(parameter.name), name: parameter.name, placeholder: parameter.type === 'date' ? 'YYYY-MM-DD' @@ -435,14 +760,18 @@ export const ReportsPage: FC = () => { return ( <> - {isDownloading && ( - + {isBusy && ( + )}
{pageTitle}

- Select a base path to view the available reports. After choosing a report, provide any - required parameters and download the data as JSON or CSV directly from the reports API. + {activeTab === 'reports' + ? 'Select a base path to view available reports. Choose a report, fill required parameters, and download JSON or CSV from the reports API.' + : 'Enter a billing account ID and optional start/end dates, then click View to load billing account payment data.'}

{isLoading ? ( @@ -451,42 +780,56 @@ export const ReportsPage: FC = () => {
) : ( <> - {basePathOptions.length ? ( -
- - - {selectedGroup && ( - + {activeTab === 'reports' ? ( + <> + {basePathOptions.length ? ( +
+ + + {selectedGroup && ( + + )} +
+ ) : ( +
+ No reports are currently available. +
)} -
+ + + ) : ( -
- No reports are currently available. -
+ )} - + {activeTab === 'billingAccounts' && billingAccountViewData ? ( + + ) : undefined} )}
@@ -494,4 +837,12 @@ export const ReportsPage: FC = () => { ) } +export const ReportsPage: FC = () => ( + +) + +export const BillingAccountsPage: FC = () => ( + +) + export default ReportsPage diff --git a/src/apps/reports/src/reports-app.routes.tsx b/src/apps/reports/src/reports-app.routes.tsx index df4b56ecd..01c720710 100644 --- a/src/apps/reports/src/reports-app.routes.tsx +++ b/src/apps/reports/src/reports-app.routes.tsx @@ -11,6 +11,7 @@ import { } from '~/libs/core' import { + billingAccountsPageRouteId, bulkMemberLookupRouteId, reportsPageRouteId, rootRoute, @@ -21,6 +22,9 @@ const ReportsPage: LazyLoadedComponent = lazyLoad( () => import('./pages/reports/ReportsPage'), 'ReportsPage', ) +const BillingAccountsPage: LazyLoadedComponent = lazyLoad( + () => import('./pages/reports/BillingAccountsPage'), +) const BulkMemberLookupPage: LazyLoadedComponent = lazyLoad( () => import('./pages/bulk-member-lookup/BulkMemberLookupPage'), 'BulkMemberLookupPage', @@ -43,6 +47,11 @@ export const reportsRoutes: ReadonlyArray = [ element: , route: reportsPageRouteId, }, + { + authRequired: true, + element: , + route: billingAccountsPageRouteId, + }, { authRequired: true, element: , From 333abcc64fd73c4f0a7ca5171d34c44bad141fc1 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Thu, 30 Apr 2026 14:04:28 +0530 Subject: [PATCH 05/52] fix linting --- .../reports/src/pages/reports/ReportsPage.tsx | 48 ++++++++++++------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/src/apps/reports/src/pages/reports/ReportsPage.tsx b/src/apps/reports/src/pages/reports/ReportsPage.tsx index e37bdfd87..7c8c824df 100644 --- a/src/apps/reports/src/pages/reports/ReportsPage.tsx +++ b/src/apps/reports/src/pages/reports/ReportsPage.tsx @@ -37,31 +37,31 @@ const bulkMembersByHandlesPath = '/identity/users-by-handles' const BILLING_ACCOUNTS_REPORT_PATH = '/sfdc/billing-accounts' const SFDC_PAYMENTS_REPORT_PATH = '/sfdc/payments' const BILLING_ACCOUNTS_REPORT_DEFINITION: ReportDefinition = { - name: 'Billing Accounts', - path: BILLING_ACCOUNTS_REPORT_PATH, description: 'View billing-account details and SFDC payments by billing account ID.', method: 'GET', + name: 'Billing Accounts', parameters: [ { - name: 'billingAccountId', - type: 'string', description: 'Billing account ID', - required: true, location: 'query', + name: 'billingAccountId', + required: true, + type: 'string', }, { - name: 'startDate', - type: 'date', description: 'Optional start date for payment filtering (ISO 8601)', location: 'query', + name: 'startDate', + type: 'date', }, { - name: 'endDate', - type: 'date', description: 'Optional end date for payment filtering (ISO 8601)', location: 'query', + name: 'endDate', + type: 'date', }, ], + path: BILLING_ACCOUNTS_REPORT_PATH, } type ReportsPageTab = 'reports' | 'billingAccounts' @@ -106,7 +106,8 @@ const formatPaymentDate = (iso: string): string => { return iso } - return new Date(parsed).toLocaleString() + return new Date(parsed) + .toLocaleString() } const PAYMENT_TABLE_COLUMNS: { key: keyof SfdcBillingAccountPaymentRow; label: string }[] = [ @@ -127,8 +128,11 @@ const PAYMENT_TABLE_COLUMNS: { key: keyof SfdcBillingAccountPaymentRow; label: s { key: 'winnerLastName', label: 'Winner last name' }, ] -const BillingAccountReportResults = ({ data }: { data: BillingAccountsViewData }): JSX.Element => { - const { billingAccount, payments } = data +const BillingAccountReportResults = ( + props: { data: BillingAccountsViewData }, +): JSX.Element => { + const billingAccount: BillingAccountsViewData['billingAccount'] = props.data.billingAccount + const payments: BillingAccountsViewData['payments'] = props.data.payments return (
@@ -261,6 +265,8 @@ const formatParameterLabel = (name: string): string => ( .replace(/^./, char => char.toUpperCase()) ) +const EMPTY_BILLING_ACCOUNT_PROFILE_RESPONSE = {} as BillingAccountProfileResponse + type ReportActionsProps = { handleCsvDownload: () => void handleJsonDownload: () => void @@ -361,7 +367,10 @@ const SelectedReportSection = (props: SelectedReportSectionProps): JSX.Element =
{parameter.type}
-
- {parameter.description || '\u00A0'} -
{props.renderParameterInput(parameter)} ))} From 6cc4b306f583510460f9cbd4941128aca19d5c97 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Thu, 30 Apr 2026 21:50:33 +0530 Subject: [PATCH 07/52] Fix devin feedback --- src/apps/reports/src/lib/services/reports.service.ts | 4 ++-- src/apps/reports/src/pages/reports/ReportsPage.tsx | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/apps/reports/src/lib/services/reports.service.ts b/src/apps/reports/src/lib/services/reports.service.ts index 97930cec0..a6c4ed649 100644 --- a/src/apps/reports/src/lib/services/reports.service.ts +++ b/src/apps/reports/src/lib/services/reports.service.ts @@ -59,12 +59,12 @@ export type SfdcBillingAccountPaymentRow = { /** Response from GET /sfdc/billing-accounts */ export type BillingAccountProfileResponse = { - billingAccount: BillingAccountDetail | null + billingAccount?: BillingAccountDetail } /** Billing Accounts in-app view: profile + rows from GET /sfdc/payments */ export type BillingAccountsViewData = { - billingAccount: BillingAccountDetail | null + billingAccount?: BillingAccountDetail payments: SfdcBillingAccountPaymentRow[] } diff --git a/src/apps/reports/src/pages/reports/ReportsPage.tsx b/src/apps/reports/src/pages/reports/ReportsPage.tsx index 53ca22dc4..96792adf3 100644 --- a/src/apps/reports/src/pages/reports/ReportsPage.tsx +++ b/src/apps/reports/src/pages/reports/ReportsPage.tsx @@ -265,7 +265,9 @@ const formatParameterLabel = (name: string): string => ( .replace(/^./, char => char.toUpperCase()) ) -const EMPTY_BILLING_ACCOUNT_PROFILE_RESPONSE = {} as BillingAccountProfileResponse +const EMPTY_BILLING_ACCOUNT_PROFILE_RESPONSE: BillingAccountProfileResponse = { + billingAccount: undefined, +} type ReportActionsProps = { handleCsvDownload: () => void @@ -369,7 +371,7 @@ const SelectedReportSection = (props: SelectedReportSectionProps): JSX.Element = From 331f6f31a7ce5c0618d57bff3d19b791f6579506 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Thu, 30 Apr 2026 21:59:47 +0530 Subject: [PATCH 08/52] Fix tooltip content --- src/apps/reports/src/pages/reports/ReportsPage.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/apps/reports/src/pages/reports/ReportsPage.tsx b/src/apps/reports/src/pages/reports/ReportsPage.tsx index 96792adf3..b27b76195 100644 --- a/src/apps/reports/src/pages/reports/ReportsPage.tsx +++ b/src/apps/reports/src/pages/reports/ReportsPage.tsx @@ -369,10 +369,14 @@ const SelectedReportSection = (props: SelectedReportSectionProps): JSX.Element =
{parameter.type}
+ {parameter.description || 'No description available'} +
+ {`Location: ${parameter.location || 'query'} + (${parameter.name})`} + + )} place='top' >
- +
@@ -3166,6 +3291,66 @@ export const ChallengeEditorForm: FC = (
+
+
+ + Approval status: + + + {getApprovalStatusText(normalizedApprovalStatus)} + +
+ {normalizedApprovalStatus === CHALLENGE_APPROVAL_STATUS.REJECTED + && normalizeTextValue(values.approvalRejectionReason) + ? ( +
+ {`Reason: ${values.approvalRejectionReason}`} +
+ ) + : undefined} + {normalizedApprovalStatus === CHALLENGE_APPROVAL_STATUS.APPROVED + && normalizeTextValue(values.approvalApprovedBy) + ? ( +
+ {`Approved by ${values.approvalApprovedBy}`} +
+ ) + : undefined} + {canRenderApprovalActions + ? ( + <> +