From a8fa284455e6899bc3b3d6c795103d2e54a9069d Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 13 May 2026 11:31:02 +1000 Subject: [PATCH 01/34] Ratings history graph fixes --- .../hooks/useRatingHistoryOptions.spec.tsx | 96 ++++++++++++ .../src/hooks/useRatingHistoryOptions.tsx | 61 ++++++-- .../BillingAccountLineItemsModal.spec.tsx | 82 ++++++++-- .../BillingAccountLineItemsModal.tsx | 142 ++++++++++++++++-- 4 files changed, 344 insertions(+), 37 deletions(-) create mode 100644 src/apps/profiles/src/hooks/useRatingHistoryOptions.spec.tsx diff --git a/src/apps/profiles/src/hooks/useRatingHistoryOptions.spec.tsx b/src/apps/profiles/src/hooks/useRatingHistoryOptions.spec.tsx new file mode 100644 index 000000000..b4b82487f --- /dev/null +++ b/src/apps/profiles/src/hooks/useRatingHistoryOptions.spec.tsx @@ -0,0 +1,96 @@ +import type { StatsHistory } from '~/libs/core' + +import { getRatingHistoryData } from './useRatingHistoryOptions' + +jest.mock('~/libs/core', () => ({ + getRatingColor: (rating: number): string => `rating-${rating}`, + TC_RATING_COLORS: [{ + color: '#555555', + limit: 900, + }, { + color: '#2D7E2D', + limit: 1200, + }, { + color: '#616BD5', + limit: 1500, + }, { + color: '#F2C900', + limit: 2200, + }, { + color: '#EF3A3A', + limit: Infinity, + }], +}), { + virtual: true, +}) + +describe('getRatingHistoryData', () => { + it('sorts rated history points chronologically without mutating the source history', () => { + const trackHistory: StatsHistory[] = [{ + challengeId: 'latest', + challengeName: 'Latest rated challenge', + date: 3000, + newRating: 2100, + rating: 2100, + ratingDate: 3000, + }, { + challengeId: 'oldest', + challengeName: 'Oldest rated challenge', + newRating: 1500, + ratingDate: 1000, + }] + const originalOrder: string[] = trackHistory.map(challenge => challenge.challengeId as string) + + expect(getRatingHistoryData(trackHistory)) + .toEqual([{ + color: 'rating-1500', + name: 'Oldest rated challenge', + x: 1000, + y: 1500, + }, { + color: 'rating-2100', + name: 'Latest rated challenge', + x: 3000, + y: 2100, + }]) + expect(trackHistory.map(challenge => challenge.challengeId)) + .toEqual(originalOrder) + }) + + it('omits unrated history entries so Highcharts can draw a continuous rated line', () => { + const trackHistory: StatsHistory[] = [{ + challengeId: 'rated-before', + challengeName: 'Rated before', + date: 1000, + newRating: 1800, + rating: 1800, + ratingDate: 1000, + }, { + challengeId: 'unrated', + challengeName: 'Unrated marathon match', + date: 2000, + newRating: undefined as unknown as number, + ratingDate: 2000, + }, { + challengeId: 'rated-after', + challengeName: 'Rated after', + date: 3000, + newRating: 2300, + rating: 2300, + ratingDate: 3000, + }] + + expect(getRatingHistoryData(trackHistory)) + .toEqual([{ + color: 'rating-1800', + name: 'Rated before', + x: 1000, + y: 1800, + }, { + color: 'rating-2300', + name: 'Rated after', + x: 3000, + y: 2300, + }]) + }) +}) diff --git a/src/apps/profiles/src/hooks/useRatingHistoryOptions.tsx b/src/apps/profiles/src/hooks/useRatingHistoryOptions.tsx index 696b11cb1..4aa9caa16 100644 --- a/src/apps/profiles/src/hooks/useRatingHistoryOptions.tsx +++ b/src/apps/profiles/src/hooks/useRatingHistoryOptions.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { cloneDeep, get } from 'lodash' +import { cloneDeep } from 'lodash' import Highcharts from 'highcharts' import { getRatingColor, StatsHistory, TC_RATING_COLORS } from '~/libs/core' @@ -44,13 +44,51 @@ export const RATING_CHART_CONFIG: Highcharts.Options = { }, } +/** + * Converts raw track history records into Highcharts rating points. + * + * @param trackHistory - Raw track history entries from the member stats API. + * @returns Chronologically sorted chart points. Entries without a finite date or rating are omitted + * because they do not represent a rating change and would split the line in Highcharts. + */ +export function getRatingHistoryData(trackHistory: Array): Highcharts.PointOptionsObject[] { + return trackHistory + .reduce((points, challenge) => { + const date: number | undefined = typeof challenge.date === 'number' && Number.isFinite(challenge.date) + ? challenge.date + : challenge.ratingDate + const rating: number | undefined = typeof challenge.rating === 'number' && Number.isFinite(challenge.rating) + ? challenge.rating + : challenge.newRating + + if ( + typeof date !== 'number' + || !Number.isFinite(date) + || typeof rating !== 'number' + || !Number.isFinite(rating) + ) { + return points + } + + points.push({ + color: getRatingColor(rating), + name: challenge.challengeName, + x: date, + y: rating, + }) + + return points + }, []) + .sort((a, b) => (a.x as number) - (b.x as number)) +} + /** * Custom hook to generate Highcharts options for a rating history chart. * - * @param {Array | undefined} trackHistory - The array of historical stats data. - * @param {string} seriesName - The name of the series for the chart. - * @returns {Highcharts.Options | undefined} - Highcharts options for the rating history chart or - * undefined if data is empty. + * @param trackHistory - The array of historical stats data. + * @param seriesName - The name of the series for the chart. + * @returns Highcharts options for the rating history chart, or undefined when there are no rated + * history entries to draw. */ export function useRatingHistoryOptions( trackHistory: Array | undefined, @@ -64,9 +102,8 @@ export function useRatingHistoryOptions( // Return undefined if the track history data is empty if (!trackHistory?.length) return undefined - // Determine the date and rating fields based on the first entry in the track history - const dateField: string = get(trackHistory[0], 'date') ? 'date' : 'ratingDate' - const ratingField: string = get(trackHistory[0], 'rating') ? 'rating' : 'newRating' + const historyData: Highcharts.PointOptionsObject[] = getRatingHistoryData(trackHistory) + if (!historyData.length) return undefined // Configure series for the chart options.plotOptions = { @@ -81,13 +118,7 @@ export function useRatingHistoryOptions( options.series = [{ color: 'transparent', - data: trackHistory.sort((a, b) => get(b, dateField) - get(a, dateField)) - .map((challenge: StatsHistory) => ({ - color: getRatingColor(challenge.newRating ?? challenge.rating), - name: challenge.challengeName, - x: get(challenge, dateField), - y: get(challenge, ratingField), - })), + data: historyData, name: seriesName, type: 'line', }] 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 1725414f7..8712d6f72 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx @@ -2,10 +2,13 @@ import { render, screen, + waitFor, } from '@testing-library/react' import { useFetchEngagements } from '../../hooks/useFetchEngagements' +import type { Challenge } from '../../models' import type { BillingAccountDetails } from '../../services' +import { fetchChallenge } from '../../services/challenges.service' import BillingAccountLineItemsModal from './BillingAccountLineItemsModal' @@ -17,6 +20,10 @@ jest.mock('../../hooks/useFetchEngagements', () => ({ useFetchEngagements: jest.fn(), })) +jest.mock('../../services/challenges.service', () => ({ + fetchChallenge: jest.fn(), +})) + jest.mock('~/config', () => ({ EnvironmentConfig: { API: { @@ -56,6 +63,8 @@ jest.mock('~/libs/ui', () => ({ }) const mockedUseFetchEngagements = useFetchEngagements as jest.MockedFunction +const mockedFetchChallenge = fetchChallenge as jest.MockedFunction +let challengeMarkupById: Map const baseBillingAccountDetails: BillingAccountDetails = { budget: 1000, @@ -85,6 +94,16 @@ function renderModal( describe('BillingAccountLineItemsModal', () => { beforeEach(() => { + challengeMarkupById = new Map() + mockedFetchChallenge.mockReset() + mockedFetchChallenge.mockImplementation(async (challengeId: string): Promise => ({ + billing: { + markup: challengeMarkupById.get(challengeId) ?? 0.33, + }, + id: challengeId, + name: `Challenge ${challengeId}`, + status: 'ACTIVE', + })) mockedUseFetchEngagements.mockReset() mockedUseFetchEngagements.mockReturnValue({ engagements: [], @@ -125,7 +144,7 @@ describe('BillingAccountLineItemsModal', () => { .toBe('/work/challenges/challenge%20%2F%20100') }) - it('shows challenge member payments without removing markup from the stored subtotal', () => { + it('shows challenge member payments without removing markup from the stored subtotal', async () => { renderModal({ ...baseBillingAccountDetails, lockedAmounts: [ @@ -146,17 +165,19 @@ describe('BillingAccountLineItemsModal', () => { .toBeTruthy() expect(screen.getByText('Challenge Fee')) .toBeTruthy() - expect(screen.getAllByText('$28.60')) - .toHaveLength(2) - expect(screen.getByText('$9.44')) - .toBeTruthy() + await waitFor(() => { + expect(screen.getAllByText('$28.60')) + .toHaveLength(2) + expect(screen.getByText('$9.44')) + .toBeTruthy() + }) expect(screen.queryByText('$21.50')) .toBeNull() expect(screen.queryByText('$7.10')) .toBeNull() }) - it('removes markup once from consumed challenge charges before showing member payments', () => { + it('removes challenge markup once from consumed challenge charges before showing member payments', async () => { renderModal({ ...baseBillingAccountDetails, consumedAmounts: [ @@ -173,14 +194,47 @@ describe('BillingAccountLineItemsModal', () => { totalBudgetRemaining: 966.75, }) - expect(screen.getByText('$25.00')) - .toBeTruthy() - expect(screen.getByText('$8.25')) - .toBeTruthy() + await waitFor(() => { + expect(screen.getByText('$25.00')) + .toBeTruthy() + expect(screen.getByText('$8.25')) + .toBeTruthy() + }) expect(screen.queryByText('$10.97')) .toBeNull() }) + it('uses zero challenge markup instead of billing-account default markup for consumed charges', async () => { + challengeMarkupById.set('0f4c801c-4d4d-4ac2-8e2e-60aeb16379d2', 0) + + renderModal({ + ...baseBillingAccountDetails, + consumedAmounts: [ + { + amount: '9', + date: '2026-05-12T00:00:00.000Z', + externalId: '0f4c801c-4d4d-4ac2-8e2e-60aeb16379d2', + externalName: 'Member payment retest 1', + externalType: 'CHALLENGE', + }, + ], + consumedBudget: 9, + markup: 0.33, + totalBudgetRemaining: 991, + }) + + await waitFor(() => { + expect(screen.getAllByText('$9.00')) + .toHaveLength(2) + expect(screen.getAllByText('$0.00')) + .toHaveLength(2) + }) + expect(screen.queryByText('$6.77')) + .toBeNull() + expect(screen.queryByText('$2.23')) + .toBeNull() + }) + it('builds engagement links from assignment-backed billing rows', () => { mockedUseFetchEngagements.mockReturnValue({ engagements: [ @@ -450,7 +504,7 @@ describe('BillingAccountLineItemsModal', () => { .toBeNull() }) - it('shows consumed challenge member payments without challenge fees for copilots', () => { + it('shows consumed challenge member payments without challenge fees for copilots', async () => { renderModal({ ...baseBillingAccountDetails, consumedAmounts: [ @@ -468,8 +522,10 @@ describe('BillingAccountLineItemsModal', () => { totalBudgetRemaining: 966.75, }, true) - expect(screen.getByText('$25.00')) - .toBeTruthy() + await waitFor(() => { + expect(screen.getByText('$25.00')) + .toBeTruthy() + }) expect(screen.queryByText('$33.25')) .toBeNull() expect(screen.queryByText('Challenge Fee')) diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx index 9141da438..ca4c34436 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx @@ -5,6 +5,7 @@ import { useMemo, useState, } from 'react' +import useSWR from 'swr' import { Button, @@ -14,12 +15,16 @@ import { import { rootRoute } from '../../../config/routes.config' import { useFetchEngagements } from '../../hooks/useFetchEngagements' -import type { Engagement } from '../../models' +import type { + Challenge, + Engagement, +} from '../../models' import { BillingAccountDetails, BillingAccountLineItem, combineBillingAccountLineItems, } from '../../services/billing-accounts.service' +import { fetchChallenge } from '../../services/challenges.service' import { calculatePaymentChallengeFee } from '../../utils/payment.utils' import { calculateMemberPaymentAmount, @@ -30,6 +35,7 @@ import styles from './BillingAccountLineItemsModal.module.scss' type SortField = 'amount' | 'status' | 'date' type SortOrder = 'asc' | 'desc' +type ChallengeDetailsById = Map interface BillingAccountModalLineItem extends BillingAccountLineItem { challengeFeeAmount?: number @@ -40,6 +46,8 @@ const ENGAGEMENT_ASSIGNMENT_FILTERS = { includePrivate: true, } +const EMPTY_CHALLENGE_DETAILS_BY_ID: ChallengeDetailsById = new Map() + const EXTERNAL_TYPE_LABELS: Record = { CHALLENGE: 'Challenge', ENGAGEMENT: 'Engagement', @@ -215,28 +223,110 @@ function formatLineItemChallengeFee(item: BillingAccountModalLineItem): string { : formatCurrency(item.challengeFeeAmount) } +/** + * Collects challenge ids from billing-account line items that can be hydrated from challenge-api. + * + * @param items Normalized billing-account line items. + * @returns Unique challenge ids from canonical external ids. + * @remarks Legacy-only challenge rows do not expose a canonical external id, + * so they are intentionally excluded from challenge billing hydration. + */ +function getChallengeLineItemIds(items: BillingAccountLineItem[]): string[] { + return Array.from(new Set( + items + .filter(item => item.externalType === 'CHALLENGE') + .map(item => normalizeRouteId(item.externalId)) + .filter((id): id is string => !!id), + )) +} + +/** + * Fetches challenge details for billing-account rows without failing the whole modal. + * + * @param challengeIds Challenge ids referenced by billing-account line items. + * @returns A map of successfully loaded challenges keyed by id. + * @remarks A missing challenge leaves that row without hydrated billing markup + * rather than blocking the rest of the billing-account details modal. + */ +async function fetchChallengeDetailsById( + challengeIds: string[], +): Promise { + const entries = await Promise.all(challengeIds.map(async challengeId => { + try { + const challenge = await fetchChallenge(challengeId) + + return [challengeId, challenge] as const + } catch { + return undefined + } + })) + + return new Map( + entries.filter((entry): entry is readonly [string, Challenge] => !!entry), + ) +} + +/** + * Resolves the billing markup that applies to a row's challenge fee. + * + * @param item Billing-account line item being displayed. + * @param billingAccountDetails Billing account detail payload. + * @param challengeDetailsById Hydrated challenge details, or `undefined` while loading. + * @returns Challenge-specific markup, billing-account fallback for legacy + * rows, or `undefined` when the challenge row is still being hydrated. + * @remarks Canonical challenge rows use challenge billing markup so `0` markup + * challenges do not inherit the billing account default fee. + */ +function getLineItemChallengeMarkup( + item: BillingAccountLineItem, + billingAccountDetails: BillingAccountDetails, + challengeDetailsById: ChallengeDetailsById | undefined, +): unknown { + const challengeId = item.externalType === 'CHALLENGE' + ? normalizeRouteId(item.externalId) + : undefined + + if (!challengeId) { + return billingAccountDetails.markup + } + + return challengeDetailsById?.get(challengeId)?.billing?.markup +} + /** * Resolves the challenge member-payment amount that should be visible in the row. * * @param item Raw locked or consumed billing-account challenge line item. * @param billingAccountDetails Billing account detail payload containing markup when available. + * @param challengeDetailsById Hydrated challenge details, or `undefined` while loading. * @returns Member payment amount for the challenge row. * @remarks Locked challenge rows store member payments directly. Consumed * challenge rows store the final billing-account charge, so the billing markup - * is removed once to recover the member-payment subtotal. + * is removed once using the challenge's own billing markup. */ function getChallengeMemberPaymentAmount( item: BillingAccountLineItem, billingAccountDetails: BillingAccountDetails, -): number { + challengeDetailsById: ChallengeDetailsById | undefined, +): number | undefined { if (item.status === 'locked') { return item.amount } + const challengeMarkup = getLineItemChallengeMarkup( + item, + billingAccountDetails, + challengeDetailsById, + ) + + if (challengeMarkup === undefined) { + return undefined + } + return calculateMemberPaymentAmount( item.amount, - billingAccountDetails.markup, - ) ?? item.amount + challengeMarkup, + ) } /** @@ -273,9 +363,10 @@ function getEngagementMemberPaymentAmount( function getLineItemMemberPaymentAmount( item: BillingAccountLineItem, billingAccountDetails: BillingAccountDetails, + challengeDetailsById: ChallengeDetailsById | undefined, ): number | undefined { return item.externalType === 'CHALLENGE' - ? getChallengeMemberPaymentAmount(item, billingAccountDetails) + ? getChallengeMemberPaymentAmount(item, billingAccountDetails, challengeDetailsById) : getEngagementMemberPaymentAmount(item, billingAccountDetails) } @@ -284,6 +375,7 @@ function getLineItemMemberPaymentAmount( * * @param item Raw locked or consumed billing-account line item. * @param billingAccountDetails Billing account detail payload containing hidden markup when available. + * @param challengeDetailsById Hydrated challenge details, or `undefined` while loading. * @param showChallengeFee Whether the caller can see billing challenge fees. * @returns A line item with `displayAmount` set to the visible member-payment * amount and, for non-copilots, `challengeFeeAmount` set to the billing markup fee. @@ -292,14 +384,19 @@ function getLineItemMemberPaymentAmount( function getDisplayLineItem( item: BillingAccountLineItem, billingAccountDetails: BillingAccountDetails, + challengeDetailsById: ChallengeDetailsById | undefined, showChallengeFee: boolean, ): BillingAccountModalLineItem { const displayAmount = getLineItemMemberPaymentAmount( item, billingAccountDetails, + challengeDetailsById, ) + const challengeMarkup = item.externalType === 'CHALLENGE' + ? getLineItemChallengeMarkup(item, billingAccountDetails, challengeDetailsById) + : billingAccountDetails.markup const challengeFeeAmount = showChallengeFee - ? calculatePaymentChallengeFee(displayAmount, billingAccountDetails.markup) + ? calculatePaymentChallengeFee(displayAmount, challengeMarkup) : undefined return { @@ -379,15 +476,42 @@ export const BillingAccountLineItemsModal: FC const [sortBy, setSortBy] = useState('date') const [sortOrder, setSortOrder] = useState('desc') const showChallengeFeeColumn = !props.showMemberPaymentsRemaining + const rawLineItems = useMemo( + () => combineBillingAccountLineItems(props.billingAccountDetails), + [props.billingAccountDetails], + ) + const challengeLineItemIds = useMemo( + () => getChallengeLineItemIds(rawLineItems), + [rawLineItems], + ) + const challengeDetailsResult = useSWR( + challengeLineItemIds.length > 0 + ? ['work/billing-account-line-item-challenges', challengeLineItemIds.join(',')] + : undefined, + () => fetchChallengeDetailsById(challengeLineItemIds), + { + errorRetryCount: 2, + shouldRetryOnError: true, + }, + ) + const challengeDetailsById = challengeLineItemIds.length > 0 + ? challengeDetailsResult.data + : EMPTY_CHALLENGE_DETAILS_BY_ID const lineItems = useMemo( - () => combineBillingAccountLineItems(props.billingAccountDetails) + () => rawLineItems .map(item => getDisplayLineItem( item, props.billingAccountDetails, + challengeDetailsById, showChallengeFeeColumn, )), - [props.billingAccountDetails, showChallengeFeeColumn], + [ + challengeDetailsById, + props.billingAccountDetails, + rawLineItems, + showChallengeFeeColumn, + ], ) const normalizedProjectId = useMemo( () => normalizeRouteId(props.projectId), From 3d40e28f2f866daf16847a4a5f994b200c50b40a Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Wed, 13 May 2026 11:57:16 +0530 Subject: [PATCH 02/34] UI issue when Opportunity Name is too long --- .../copilot-opportunity-details/styles.module.scss | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss b/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss index 1e82fa9ca..f7341cf6c 100644 --- a/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss @@ -15,11 +15,17 @@ margin-top: $sp-2; padding: $sp-6 0; color: $teal-100; + text-align: center; + word-break: break-word; + line-height: 1.2; + } @media (max-width: 767px) { .header { - line-height: 48px; + font-size: 32px; + line-height: 38px; + } } @@ -89,4 +95,5 @@ .textCaps { text-transform: capitalize; -} \ No newline at end of file +} + From 7fdadfaafd3f889ced1a0ea60e32b9175f9884d0 Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Wed, 13 May 2026 12:24:42 +0530 Subject: [PATCH 03/34] UI issue when Opportunity Name is too long --- .../src/pages/copilot-opportunity-details/styles.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss b/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss index f7341cf6c..1417cc4a8 100644 --- a/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss @@ -10,7 +10,7 @@ justify-content: center; text-transform: uppercase; font-family: $font-barlow-condensed; - font-size: 50px; + font-size: 40px; font-weight: $font-weight-medium; margin-top: $sp-2; padding: $sp-6 0; From 7521ea08f50263cf6f5f705582442050f0de9aaa Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Wed, 13 May 2026 12:57:02 +0530 Subject: [PATCH 04/34] Copilot App: Updated Text editor for Project Overview --- .../src/pages/copilot-request-form/index.tsx | 67 ++++++++++++++----- .../copilot-request-form/styles.module.scss | 17 +++++ 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/src/apps/copilots/src/pages/copilot-request-form/index.tsx b/src/apps/copilots/src/pages/copilot-request-form/index.tsx index 5ca6cb9cf..dcee9c1f6 100644 --- a/src/apps/copilots/src/pages/copilot-request-form/index.tsx +++ b/src/apps/copilots/src/pages/copilot-request-form/index.tsx @@ -1,3 +1,6 @@ +/* eslint-disable ordered-imports/ordered-imports */ +/* eslint-disable react/jsx-no-bind */ +/* eslint-disable max-len */ import { FC, useContext, useEffect, useMemo, useState } from 'react' import { bind, debounce, isEmpty, pick } from 'lodash' import { toast } from 'react-toastify' @@ -6,9 +9,10 @@ import classNames from 'classnames' import { profileContext, ProfileContextData } from '~/libs/core' import { Button, IconSolid, InputDatePicker, InputMultiselectOption, - InputRadio, InputSelect, InputSelectReact, InputText, InputTextarea } from '~/libs/ui' + InputRadio, InputSelect, InputSelectReact, InputText } from '~/libs/ui' import { extractSkillsFromText, InputSkillSelector } from '~/libs/shared' +import { BundledEditor } from '~/libs/shared/lib/components/field-html-editor/BundledEditor' import { getProject, getProjects, ProjectsResponse, useProjects } from '../../services/projects' import { ProjectTypes, ProjectTypeValues } from '../../constants' import { CopilotRequestResponse, saveCopilotRequest, useCopilotRequest } from '../../services/copilot-requests' @@ -266,8 +270,9 @@ const CopilotRequestForm: FC<{}> = () => { // Check if overview has enough content for AI processing const canGenerateSkills = useMemo(() => { - const overview = formValues.overview?.trim() || '' - return overview.length >= MIN_OVERVIEW_LENGTH && !isGeneratingSkills + const plainText = formValues.overview?.replace(/<[^>]*>/g, '') + .trim() || '' + return plainText.length >= MIN_OVERVIEW_LENGTH && !isGeneratingSkills }, [formValues.overview, isGeneratingSkills]) function handleFormAction(): void { @@ -289,7 +294,11 @@ const CopilotRequestForm: FC<{}> = () => { { condition: !formValues.paymentType, key: 'paymentType', message: 'Selection is required' }, { condition: !formValues.projectType, key: 'projectType', message: 'Selecting project type is required' }, { - condition: !formValues.overview || formValues.overview.trim().length < 10, + condition: (() => { + const plainText = formValues.overview?.replace(/<[^>]*>/g, '') + .trim() || '' + return plainText.length < 10 + })(), key: 'overview', message: 'Project overview must be at least 10 characters', }, @@ -529,16 +538,43 @@ const CopilotRequestForm: FC<{}> = () => {

Please provide an overview of the project the copilot will undertake

- +
+ { + setFormValues((prev: any) => ({ ...prev, overview: content })) + setFormErrors((prev: any) => { + const updated = { ...prev } + delete updated.overview + return updated + }) + setIsFormChanged(true) + }} + init={{ + browser_spellcheck: true, + content_style: + 'body {' + + 'font-family: "Roboto", Arial, Helvetica, sans-serif;' + + 'font-size: 14px; line-height: 22px;' + + '}', + height: 300, + menubar: false, + placeholder: 'A minimum of three sentences explaining the type of work and project which is to be undertaken.', + plugins: ['table', 'link'], + statusbar: false, + toolbar: 'undo redo | formatselect | bold italic underline strikethrough |' + + ' forecolor backcolor | link | alignleft aligncenter alignright alignjustify |' + + ' numlist bullist outdent indent | table | removeformat', + }} + /> +
+ {formErrors.overview && ( +

+ + {formErrors.overview} +

+ )}

@@ -555,7 +591,8 @@ const CopilotRequestForm: FC<{}> = () => {

{!canGenerateSkills && formValues.overview - && formValues.overview.trim().length < MIN_OVERVIEW_LENGTH +&& (formValues.overview?.replace(/<[^>]*>/g, '') + .trim().length ?? 0) < MIN_OVERVIEW_LENGTH && (

Add at least diff --git a/src/apps/copilots/src/pages/copilot-request-form/styles.module.scss b/src/apps/copilots/src/pages/copilot-request-form/styles.module.scss index 5eeef5331..4f9e74c29 100644 --- a/src/apps/copilots/src/pages/copilot-request-form/styles.module.scss +++ b/src/apps/copilots/src/pages/copilot-request-form/styles.module.scss @@ -190,3 +190,20 @@ $gradient: linear-gradient( color: black; } } +.richTextWrapper { + border: 1px solid #aaaaaa; + border-radius: 0.375rem; + overflow: hidden; + margin-bottom: 0.5rem; + + &:focus-within { + border-color: #137D60; + } +} + +.richTextError { + border: 2px solid $red-100; + border-radius: 0.375rem; + overflow: hidden; + margin-bottom: 0.5rem; +} \ No newline at end of file From 6934650caeeef24c1397e9a53ae44a4f19ec8dd3 Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Wed, 13 May 2026 13:10:39 +0530 Subject: [PATCH 05/34] Copilot App: Updated Text editor for Project Overview --- .../src/pages/copilot-request-form/index.tsx | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/apps/copilots/src/pages/copilot-request-form/index.tsx b/src/apps/copilots/src/pages/copilot-request-form/index.tsx index dcee9c1f6..cfb0771e8 100644 --- a/src/apps/copilots/src/pages/copilot-request-form/index.tsx +++ b/src/apps/copilots/src/pages/copilot-request-form/index.tsx @@ -23,6 +23,14 @@ import styles from './styles.module.scss' const MIN_OVERVIEW_LENGTH = 50 +// Safely strips HTML tags using the DOM parser instead of regex +// to avoid incomplete multi-character sanitization (CodeQL js/incomplete-multi-character-sanitization) +function stripHtml(html: string): string { + const doc = new DOMParser() + .parseFromString(html, 'text/html') + return doc.body.textContent ?? '' +} + const editableFields = [ 'projectId', 'opportunityTitle', @@ -270,8 +278,8 @@ const CopilotRequestForm: FC<{}> = () => { // Check if overview has enough content for AI processing const canGenerateSkills = useMemo(() => { - const plainText = formValues.overview?.replace(/<[^>]*>/g, '') - .trim() || '' + const plainText = stripHtml(formValues.overview || '') + .trim() return plainText.length >= MIN_OVERVIEW_LENGTH && !isGeneratingSkills }, [formValues.overview, isGeneratingSkills]) @@ -294,11 +302,8 @@ const CopilotRequestForm: FC<{}> = () => { { condition: !formValues.paymentType, key: 'paymentType', message: 'Selection is required' }, { condition: !formValues.projectType, key: 'projectType', message: 'Selecting project type is required' }, { - condition: (() => { - const plainText = formValues.overview?.replace(/<[^>]*>/g, '') - .trim() || '' - return plainText.length < 10 - })(), + condition: stripHtml(formValues.overview || '') + .trim().length < 10, key: 'overview', message: 'Project overview must be at least 10 characters', }, @@ -591,8 +596,9 @@ const CopilotRequestForm: FC<{}> = () => {

{!canGenerateSkills && formValues.overview -&& (formValues.overview?.replace(/<[^>]*>/g, '') - .trim().length ?? 0) < MIN_OVERVIEW_LENGTH + && stripHtml(formValues.overview || '') + .trim().length < MIN_OVERVIEW_LENGTH + && (

Add at least From 4268b42006438c327aa416a43e4d62077230ac24 Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Thu, 14 May 2026 08:53:17 +0530 Subject: [PATCH 06/34] UI issue when Opportunity Name is too long --- .../src/pages/copilot-opportunity-details/styles.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss b/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss index 1417cc4a8..36bee9379 100644 --- a/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss @@ -15,7 +15,7 @@ margin-top: $sp-2; padding: $sp-6 0; color: $teal-100; - text-align: center; + text-align: left; word-break: break-word; line-height: 1.2; From 8ee9d3064339d354ae4c05f9a3933086560c3882 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 14 May 2026 08:57:45 +0300 Subject: [PATCH 07/34] PM-5065 - Add a new Budget Approvals tab in Work Manager --- src/apps/work/src/config/routes.config.ts | 1 + .../NavTabs/config/tabs-config.spec.ts | 19 ++ .../components/NavTabs/config/tabs-config.ts | 9 +- .../work/src/lib/hooks/useFetchChallenges.ts | 19 +- .../src/lib/models/ChallengeFilters.model.ts | 1 + .../src/lib/services/challenges.service.ts | 24 ++ .../BudgetApprovalsPage.module.scss | 77 +++++ .../BudgetApprovalsPage.tsx | 279 ++++++++++++++++++ .../BudgetApprovalsPage/index.ts | 3 + .../work/src/pages/budget-approvals/index.ts | 1 + src/apps/work/src/work-app.routes.tsx | 26 ++ 11 files changed, 455 insertions(+), 4 deletions(-) create mode 100644 src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.module.scss create mode 100644 src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.tsx create mode 100644 src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/index.ts create mode 100644 src/apps/work/src/pages/budget-approvals/index.ts diff --git a/src/apps/work/src/config/routes.config.ts b/src/apps/work/src/config/routes.config.ts index 573aed6e7..91b847e7a 100644 --- a/src/apps/work/src/config/routes.config.ts +++ b/src/apps/work/src/config/routes.config.ts @@ -9,6 +9,7 @@ export const challengesRouteId = 'challenges' export const challengeCreateRouteId = 'challenge-create' export const challengeEditRouteId = 'challenge-edit' export const projectsRouteId = 'projects' +export const budgetApprovalsRouteId = 'budget-approvals' export const projectCreateRouteId = 'project-create' export const projectEditRouteId = 'project-edit' export const taasRouteId = 'taas' diff --git a/src/apps/work/src/lib/components/NavTabs/config/tabs-config.spec.ts b/src/apps/work/src/lib/components/NavTabs/config/tabs-config.spec.ts index 5df218728..217430eb5 100644 --- a/src/apps/work/src/lib/components/NavTabs/config/tabs-config.spec.ts +++ b/src/apps/work/src/lib/components/NavTabs/config/tabs-config.spec.ts @@ -30,4 +30,23 @@ describe('getTabsConfig', () => { .not .toContain('engagements') }) + + it('shows the budget approvals tab for admin users', () => { + expect(getTabsConfig(['administrator'], false) + .map(tab => tab.id)) + .toContain('budget-approvals') + }) + + it('shows the budget approvals tab for manager users', () => { + expect(getTabsConfig(['project manager'], false) + .map(tab => tab.id)) + .toContain('budget-approvals') + }) + + it('keeps the budget approvals tab hidden for copilot-only users', () => { + expect(getTabsConfig(['copilot'], false) + .map(tab => tab.id)) + .not + .toContain('budget-approvals') + }) }) diff --git a/src/apps/work/src/lib/components/NavTabs/config/tabs-config.ts b/src/apps/work/src/lib/components/NavTabs/config/tabs-config.ts index cbb88b62e..a634316ec 100644 --- a/src/apps/work/src/lib/components/NavTabs/config/tabs-config.ts +++ b/src/apps/work/src/lib/components/NavTabs/config/tabs-config.ts @@ -9,6 +9,7 @@ import { TASK_MANAGER_ROLES, } from '../../../../config/index.config' import { + budgetApprovalsRouteId, challengesRouteId, engagementsRouteId, groupsRouteId, @@ -27,6 +28,7 @@ export function getTabsConfig(userRoles: string[], isAnonymous: boolean): TabsNa } const isAdmin = hasAnyRole(userRoles, ADMIN_ROLES) + const isManager = hasAnyRole(userRoles, [...MANAGER_ROLES, ...TASK_MANAGER_ROLES]) const canViewEngagements = canViewAllEngagements(userRoles) const tabs: TabsNavItem[] = [ @@ -48,6 +50,12 @@ export function getTabsConfig(userRoles: string[], isAnonymous: boolean): TabsNa id: projectsRouteId, title: 'Projects', }, + ...(isAdmin || isManager + ? [{ + id: budgetApprovalsRouteId, + title: 'Budget Approvals', + }] + : []), { id: taasRouteId, title: 'TaaS Projects', @@ -55,7 +63,6 @@ export function getTabsConfig(userRoles: string[], isAnonymous: boolean): TabsNa ) const isCopilot = hasAnyRole(userRoles, COPILOT_ROLES) - const isManager = hasAnyRole(userRoles, [...MANAGER_ROLES, ...TASK_MANAGER_ROLES]) if (isAdmin || isCopilot || isManager) { tabs.push({ diff --git a/src/apps/work/src/lib/hooks/useFetchChallenges.ts b/src/apps/work/src/lib/hooks/useFetchChallenges.ts index 3b2bb1b65..3f2114c45 100644 --- a/src/apps/work/src/lib/hooks/useFetchChallenges.ts +++ b/src/apps/work/src/lib/hooks/useFetchChallenges.ts @@ -19,6 +19,16 @@ import { FetchChallengesResponse, } from '../services' +function toFilterKeyValue(value: string | string[] | undefined): string { + if (!value) { + return '' + } + + return Array.isArray(value) + ? value.join(',') + : value +} + export interface UseFetchChallengesParams extends ChallengeFilters { page?: number perPage?: number @@ -37,6 +47,7 @@ export interface UseFetchChallengesResult { export function useFetchChallenges( { + approvalStatus, appendResults = false, endDateEnd, enabled = true, @@ -71,6 +82,7 @@ export function useFetchChallenges( const requestParams = useMemo( () => ({ filters: { + approvalStatus, endDateEnd, endDateStart, memberId, @@ -89,6 +101,7 @@ export function useFetchChallenges( }, }), [ + approvalStatus, endDateEnd, endDateStart, memberId, @@ -107,6 +120,7 @@ export function useFetchChallenges( const appendKey = useMemo( () => [ + toFilterKeyValue(approvalStatus), endDateEnd || '', endDateStart || '', String(memberId ?? ''), @@ -116,14 +130,13 @@ export function useFetchChallenges( sortOrder || '', startDateEnd || '', startDateStart || '', - Array.isArray(status) - ? status.join(',') - : (status || ''), + toFilterKeyValue(status), type || '', String(perPage), ] .join('|'), [ + approvalStatus, endDateEnd, endDateStart, memberId, diff --git a/src/apps/work/src/lib/models/ChallengeFilters.model.ts b/src/apps/work/src/lib/models/ChallengeFilters.model.ts index b6b1d0c52..f0a0ec432 100644 --- a/src/apps/work/src/lib/models/ChallengeFilters.model.ts +++ b/src/apps/work/src/lib/models/ChallengeFilters.model.ts @@ -1,4 +1,5 @@ export interface ChallengeFilters { + approvalStatus?: string | string[] memberId?: number | string name?: string type?: string diff --git a/src/apps/work/src/lib/services/challenges.service.ts b/src/apps/work/src/lib/services/challenges.service.ts index 56ab56ad1..03c3f4c38 100644 --- a/src/apps/work/src/lib/services/challenges.service.ts +++ b/src/apps/work/src/lib/services/challenges.service.ts @@ -84,6 +84,28 @@ function normalizeStatusValue(status: string | string[] | undefined): string | u return status.toUpperCase() } +function normalizeApprovalStatusValue( + approvalStatus: string | string[] | undefined, +): string | undefined { + if (!approvalStatus) { + return undefined + } + + if (Array.isArray(approvalStatus)) { + const normalized = approvalStatus + .map(item => item.toUpperCase()) + .filter(Boolean) + + if (!normalized.length) { + return undefined + } + + return normalized.join(',') + } + + return approvalStatus.toUpperCase() +} + function asIsoDateString(value: unknown): string | undefined { if (!value) { return undefined @@ -277,7 +299,9 @@ function buildChallengeQuery( const query = new URLSearchParams() const normalizedStatus = normalizeStatusValue(filters.status) + const normalizedApprovalStatus = normalizeApprovalStatusValue(filters.approvalStatus) const values: Record = { + approvalStatus: normalizedApprovalStatus, endDateEnd: asIsoDateString(filters.endDateEnd), endDateStart: asIsoDateString(filters.endDateStart), memberId: filters.memberId !== undefined diff --git a/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.module.scss b/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.module.scss new file mode 100644 index 000000000..b1df43a19 --- /dev/null +++ b/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.module.scss @@ -0,0 +1,77 @@ +.searchRow { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + margin-bottom: 20px; +} + +.searchField { + display: flex; + flex-direction: column; + gap: 8px; +} + +.searchInput { + border: 1px solid #d4d4d4; + border-radius: 4px; + font-size: 14px; + min-height: 40px; + padding: 8px 12px; +} + +.summaryRow { + font-size: 16px; + font-weight: 600; + margin-bottom: 12px; +} + +.errorBanner { + background: #fff5f5; + border: 1px solid #f5c2c7; + border-radius: 6px; + color: #8c1d28; + margin-bottom: 16px; + padding: 10px 12px; +} + +.table { + border-collapse: collapse; + width: 100%; + + th, + td { + border-bottom: 1px solid #e8e8e8; + padding: 12px 10px; + text-align: left; + } + + th { + font-size: 12px; + letter-spacing: 0.3px; + text-transform: uppercase; + } +} + +.actionHeader { + text-align: center; + width: 80px; +} + +.actionCell { + text-align: center; +} + +.actionLink { + color: inherit; + display: inline-flex; +} + +.actionIcon { + height: 16px; + width: 16px; +} + +.emptyCell { + color: #666; + text-align: center; +} diff --git a/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.tsx b/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.tsx new file mode 100644 index 000000000..7ca40b2b7 --- /dev/null +++ b/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.tsx @@ -0,0 +1,279 @@ +import { + ChangeEvent, + FC, + useContext, + useMemo, + useState, +} from 'react' +import { Link } from 'react-router-dom' + +import { TableLoading } from '~/apps/admin/src/lib' +import { PageWrapper } from '~/apps/review/src/lib' +import { IconOutline } from '~/libs/ui' + +import { + CHALLENGE_APPROVAL_STATUS, + CHALLENGE_STATUS, +} from '../../../lib/constants' +import { WorkAppContext } from '../../../lib/contexts' +import { + useFetchChallenges, + UseFetchChallengesParams, + UseFetchChallengesResult, + useFetchProjects, + UseFetchProjectsResult, +} from '../../../lib/hooks' +import { + Challenge, + Project, + WorkAppContextModel, +} from '../../../lib/models' + +import styles from './BudgetApprovalsPage.module.scss' + +function normalizeSearchValue(value: string): string { + return value + .trim() + .toLowerCase() +} + +function normalizeStatus(value: unknown): string { + return String(value || '') + .trim() + .toUpperCase() +} + +function getProjectId(challenge: Challenge): string { + return String(challenge.projectId || '') +} + +function buildProjectPath(projectId: string): string { + return `/projects/${encodeURIComponent(projectId)}/challenges` +} + +function buildChallengePath(challenge: Challenge, projectId: string): string { + if (projectId) { + return `/projects/${encodeURIComponent(projectId)}/challenges/${encodeURIComponent(challenge.id)}/view` + } + + return `/challenges/${encodeURIComponent(challenge.id)}` +} + +function getProjectName( + projectMap: Map, + projectId: string, +): string { + return projectMap.get(projectId)?.name || projectId || 'Unknown project' +} + +export const BudgetApprovalsPage: FC = () => { + const { + isAdmin, + loginUserInfo, + }: WorkAppContextModel = useContext(WorkAppContext) + + const [challengeNameSearch, setChallengeNameSearch] = useState('') + const [projectNameSearch, setProjectNameSearch] = useState('') + + const memberId = isAdmin + ? undefined + : loginUserInfo?.userId + + const challengeFetchParams: UseFetchChallengesParams = { + approvalStatus: CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL, + enabled: isAdmin || memberId !== undefined, + memberId, + page: 1, + perPage: 100, + sortBy: 'updated', + sortOrder: 'desc', + status: CHALLENGE_STATUS.DRAFT, + } + + const challengesResult: UseFetchChallengesResult = useFetchChallenges(challengeFetchParams) + + const projectsResult: UseFetchProjectsResult = useFetchProjects({ + memberOnly: !isAdmin, + }) + + const projectMap = useMemo(() => { + const map = new Map() + + projectsResult.projects.forEach(project => { + const projectId = String(project.id || '') + if (!projectId) { + return + } + + map.set(projectId, project) + }) + + return map + }, [projectsResult.projects]) + + const accessibleProjectIds = useMemo( + () => new Set(projectsResult.projects.map(project => String(project.id || ''))), + [projectsResult.projects], + ) + + const normalizedChallengeNameSearch = useMemo( + () => normalizeSearchValue(challengeNameSearch), + [challengeNameSearch], + ) + + const normalizedProjectNameSearch = useMemo( + () => normalizeSearchValue(projectNameSearch), + [projectNameSearch], + ) + + const filteredChallenges = useMemo( + () => challengesResult.challenges + .filter(challenge => normalizeStatus(challenge.status) === CHALLENGE_STATUS.DRAFT) + .filter( + challenge => normalizeStatus(challenge.approvalStatus) + === CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL, + ) + .filter(challenge => { + const projectId = getProjectId(challenge) + + if (!projectId) { + return false + } + + if (isAdmin) { + return true + } + + return accessibleProjectIds.has(projectId) + }) + .filter(challenge => { + if (!normalizedChallengeNameSearch) { + return true + } + + return normalizeSearchValue(challenge.name) + .includes(normalizedChallengeNameSearch) + }) + .filter(challenge => { + if (!normalizedProjectNameSearch) { + return true + } + + const projectName = getProjectName(projectMap, getProjectId(challenge)) + + return normalizeSearchValue(projectName) + .includes(normalizedProjectNameSearch) + }), + [ + accessibleProjectIds, + challengesResult.challenges, + isAdmin, + normalizedChallengeNameSearch, + normalizedProjectNameSearch, + projectMap, + ], + ) + + function handleChallengeNameSearch(event: ChangeEvent): void { + setChallengeNameSearch(event.target.value) + } + + function handleProjectNameSearch(event: ChangeEvent): void { + setProjectNameSearch(event.target.value) + } + + if (challengesResult.isLoading || (!isAdmin && projectsResult.isLoading)) { + return + } + + const errorMessage = challengesResult.error?.message || projectsResult.error?.message + + return ( + + {errorMessage + ?

{errorMessage}
+ : undefined} + +
+
+ + +
+
+ + +
+
+ +
+ {filteredChallenges.length} + {' '} + pending approvals +
+ + + + + + + + + + + {filteredChallenges.length === 0 + ? ( + + + + ) + : filteredChallenges.map(challenge => { + const projectId = getProjectId(challenge) + const projectName = getProjectName(projectMap, projectId) + const challengePath = buildChallengePath(challenge, projectId) + + return ( + + + + + + ) + })} + +
Project NameChallenge NameAction
+ No pending budget approvals found. +
+ {projectName} + + {challenge.name} + + + + +
+ + ) +} + +export default BudgetApprovalsPage diff --git a/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/index.ts b/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/index.ts new file mode 100644 index 000000000..8d74100ba --- /dev/null +++ b/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/index.ts @@ -0,0 +1,3 @@ +import BudgetApprovalsPage from './BudgetApprovalsPage' + +export default BudgetApprovalsPage diff --git a/src/apps/work/src/pages/budget-approvals/index.ts b/src/apps/work/src/pages/budget-approvals/index.ts new file mode 100644 index 000000000..0caf717c0 --- /dev/null +++ b/src/apps/work/src/pages/budget-approvals/index.ts @@ -0,0 +1 @@ +export * from './BudgetApprovalsPage' diff --git a/src/apps/work/src/work-app.routes.tsx b/src/apps/work/src/work-app.routes.tsx index 250452f52..46ce6e58e 100644 --- a/src/apps/work/src/work-app.routes.tsx +++ b/src/apps/work/src/work-app.routes.tsx @@ -13,6 +13,7 @@ import { } from '~/libs/core' import { + budgetApprovalsRouteId, challengeCreateRouteId, challengeEditRouteId, challengesRouteId, @@ -65,6 +66,10 @@ const ProjectsListPage: LazyLoadedComponent = lazyLoad( () => import('./pages/projects/ProjectsListPage'), ) +const BudgetApprovalsPage: LazyLoadedComponent = lazyLoad( + () => import('./pages/budget-approvals/BudgetApprovalsPage'), +) + const ProjectEditorPage: LazyLoadedComponent = lazyLoad( () => import('./pages/projects/ProjectEditorPage'), ) @@ -149,6 +154,16 @@ const EngagementsRouteGuard: FC = (props: PropsWithChildren) return <>{props.children} } +const BudgetApprovalsRouteGuard: FC = (props: PropsWithChildren) => { + const contextValue: WorkAppContextModel = useContext(WorkAppContext) + + if (!contextValue.isAdmin && !contextValue.isManager) { + return + } + + return <>{props.children} +} + export const toolTitle: string = ToolTitle.work export const workRoutes: ReadonlyArray = [ @@ -241,6 +256,17 @@ export const workRoutes: ReadonlyArray = [ route: projectsRouteId, title: 'Projects', }, + { + authRequired: true, + element: ( + + + + ), + id: budgetApprovalsRouteId, + route: budgetApprovalsRouteId, + title: 'Budget Approvals', + }, { authRequired: true, element: , From 778169b617387005585fbaefd246d3210c50a864 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 14 May 2026 09:05:22 +0300 Subject: [PATCH 08/34] PM-5055 - Budget Approve/Reject button should not be displayed for TM/PM users without full access to the project --- .../components/ChallengeEditorForm.spec.tsx | 61 +++++++++++++++++++ .../components/ChallengeEditorForm.tsx | 9 ++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx index 600988685..790d9df4a 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx @@ -17,6 +17,7 @@ import { useAutosave, useFetchChallengeTracks, useFetchChallengeTypes, + useFetchProject, useFetchProjectBillingAccount, useFetchResourceRoles, useFetchResources, @@ -67,6 +68,7 @@ jest.mock('../../../../lib/hooks', () => ({ useAutosave: jest.fn(), useFetchChallengeTracks: jest.fn(), useFetchChallengeTypes: jest.fn(), + useFetchProject: jest.fn(), useFetchProjectBillingAccount: jest.fn(), useFetchResourceRoles: jest.fn(), useFetchResources: jest.fn(), @@ -190,7 +192,11 @@ jest.mock('~/libs/ui', () => ({ virtual: true, }) jest.mock('~/config', () => ({ + AppSubdomain: { + work: 'work', + }, EnvironmentConfig: { + SUBDOMAIN: 'work', ADMIN: { DIRECT_URL: 'https://example.com/direct', REVIEW_UI_URL: 'https://example.com/review', @@ -640,6 +646,7 @@ jest.mock('./TermsField', () => ({ const mockedUseAutosave = useAutosave as jest.Mock const mockedUseFetchChallengeTracks = useFetchChallengeTracks as jest.Mock const mockedUseFetchChallengeTypes = useFetchChallengeTypes as jest.Mock +const mockedUseFetchProject = useFetchProject as jest.Mock const mockedUseFetchProjectBillingAccount = useFetchProjectBillingAccount as jest.Mock const mockedUseFetchResourceRoles = useFetchResourceRoles as jest.Mock const mockedUseFetchResources = useFetchResources as jest.Mock @@ -684,6 +691,7 @@ describe('ChallengeEditorForm', () => { const draftChallenge = { id: '12345', name: 'Draft challenge', + projectId: '3001', status: 'DRAFT', } as Challenge const validDraftChallenge = { @@ -753,6 +761,20 @@ describe('ChallengeEditorForm', () => { challengeTypes: [], isLoading: false, }) + mockedUseFetchProject.mockReturnValue({ + error: undefined, + isLoading: false, + mutate: jest.fn(), + project: { + id: '3001', + members: [{ + role: 'manager', + userId: 123, + }], + name: 'Project 3001', + status: 'active', + }, + }) mockedUseFetchProjectBillingAccount.mockReturnValue({ billingAccount: undefined, isLoading: false, @@ -940,6 +962,45 @@ describe('ChallengeEditorForm', () => { }) }) + it('hides budget approval actions for manager users without full access', () => { + const managerContextValue: WorkAppContextModel = { + ...copilotContextValue, + isManager: true, + userRoles: ['project manager'], + } + + mockedUseFetchProject.mockReturnValue({ + error: undefined, + isLoading: false, + mutate: jest.fn(), + project: { + id: '3001', + members: [{ + role: 'customer', + userId: 123, + }], + name: 'Project 3001', + status: 'active', + }, + }) + + render( + + + + + , + ) + + expect(screen.queryByRole('button', { name: 'Approve Budget' })) + .toBeNull() + expect(screen.queryByRole('button', { name: 'Reject Budget' })) + .toBeNull() + }) + it('hides the editable timeline section for task challenges in edit mode', () => { mockedUseFetchChallengeTypes.mockReturnValue({ challengeTypes: [{ diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx index aeb4d3a1c..aa0120702 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -39,6 +39,7 @@ import { useAutosave, useFetchChallengeTracks, useFetchChallengeTypes, + useFetchProject, useFetchProjectBillingAccount, useFetchResourceRoles, useFetchResources, @@ -72,6 +73,7 @@ import { searchProfilesByUserIds, } from '../../../../lib/services' import { + checkCanEditProjectDetails, formatLastSaved, showErrorToast, showSuccessToast, @@ -1516,6 +1518,7 @@ export const ChallengeEditorForm: FC = ( (): string | undefined => resolveMatchingChallengeViewPath(location.pathname), [location.pathname], ) + const projectResult = useFetchProject(fallbackProjectId) const projectBillingAccountResult = useFetchProjectBillingAccount(fallbackProjectId) const projectBillingAccount = projectBillingAccountResult.billingAccount const projectBillingAccountRef = useRef(projectBillingAccount) @@ -1767,7 +1770,11 @@ export const ChallengeEditorForm: FC = ( values.approvalStatus, ], ) - const canApproveChallengeBudget = workAppContext.isAdmin || workAppContext.isManager + const canApproveChallengeBudget = checkCanEditProjectDetails( + workAppContext.userRoles, + workAppContext.loginUserInfo?.userId, + projectResult.project, + ) const hasPersistedPrizeSets = useMemo( () => Array.isArray(props.challenge?.prizeSets) && props.challenge?.prizeSets From 2b95d0af33e5d0626bfdf952be5b7bc7d72e9296 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 14 May 2026 09:09:17 +0300 Subject: [PATCH 09/34] PM-5070 - Do not display get approval message for Approvers --- .../ChallengeEditorPage/components/ChallengeEditorForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx index aa0120702..1c237a61a 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -3571,7 +3571,7 @@ export const ChallengeEditorForm: FC = ( {getApprovalStatusText(normalizedApprovalStatus)} - {isBudgetPending && ( + {isBudgetPending && !canApproveChallengeBudget && (
Kindly obtain Project Manager approval on the budget before launching the challenge From a8f39d249bdc051d1da230d5c4b51a878210b42c Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Thu, 14 May 2026 11:49:19 +0530 Subject: [PATCH 10/34] Copilot App : Updated Text editor for Project Overview --- .../src/pages/copilot-request-form/index.tsx | 86 ++++++------------- 1 file changed, 28 insertions(+), 58 deletions(-) diff --git a/src/apps/copilots/src/pages/copilot-request-form/index.tsx b/src/apps/copilots/src/pages/copilot-request-form/index.tsx index cfb0771e8..8000ae090 100644 --- a/src/apps/copilots/src/pages/copilot-request-form/index.tsx +++ b/src/apps/copilots/src/pages/copilot-request-form/index.tsx @@ -1,6 +1,3 @@ -/* eslint-disable ordered-imports/ordered-imports */ -/* eslint-disable react/jsx-no-bind */ -/* eslint-disable max-len */ import { FC, useContext, useEffect, useMemo, useState } from 'react' import { bind, debounce, isEmpty, pick } from 'lodash' import { toast } from 'react-toastify' @@ -10,9 +7,8 @@ import classNames from 'classnames' import { profileContext, ProfileContextData } from '~/libs/core' import { Button, IconSolid, InputDatePicker, InputMultiselectOption, InputRadio, InputSelect, InputSelectReact, InputText } from '~/libs/ui' -import { extractSkillsFromText, InputSkillSelector } from '~/libs/shared' +import { extractSkillsFromText, FieldHtmlEditor, InputSkillSelector } from '~/libs/shared' -import { BundledEditor } from '~/libs/shared/lib/components/field-html-editor/BundledEditor' import { getProject, getProjects, ProjectsResponse, useProjects } from '../../services/projects' import { ProjectTypes, ProjectTypeValues } from '../../constants' import { CopilotRequestResponse, saveCopilotRequest, useCopilotRequest } from '../../services/copilot-requests' @@ -23,14 +19,6 @@ import styles from './styles.module.scss' const MIN_OVERVIEW_LENGTH = 50 -// Safely strips HTML tags using the DOM parser instead of regex -// to avoid incomplete multi-character sanitization (CodeQL js/incomplete-multi-character-sanitization) -function stripHtml(html: string): string { - const doc = new DOMParser() - .parseFromString(html, 'text/html') - return doc.body.textContent ?? '' -} - const editableFields = [ 'projectId', 'opportunityTitle', @@ -206,6 +194,16 @@ const CopilotRequestForm: FC<{}> = () => { setIsFormChanged(true) } + function handleOverviewChange(content: string): void { + setFormValues((prev: any) => ({ ...prev, overview: content })) + setFormErrors((prev: any) => { + const updated = { ...prev } + delete updated.overview + return updated + }) + setIsFormChanged(true) + } + function handleSkillsChange(ev: any): void { const options = (ev.target.value as unknown) as InputMultiselectOption[] const updatedSkills = options.map(v => ({ @@ -278,9 +276,9 @@ const CopilotRequestForm: FC<{}> = () => { // Check if overview has enough content for AI processing const canGenerateSkills = useMemo(() => { - const plainText = stripHtml(formValues.overview || '') - .trim() - return plainText.length >= MIN_OVERVIEW_LENGTH && !isGeneratingSkills + const overview = formValues.overview?.replace(/<[^>]*>/g, '') + .trim() || '' + return overview.length >= MIN_OVERVIEW_LENGTH && !isGeneratingSkills }, [formValues.overview, isGeneratingSkills]) function handleFormAction(): void { @@ -302,8 +300,9 @@ const CopilotRequestForm: FC<{}> = () => { { condition: !formValues.paymentType, key: 'paymentType', message: 'Selection is required' }, { condition: !formValues.projectType, key: 'projectType', message: 'Selecting project type is required' }, { - condition: stripHtml(formValues.overview || '') - .trim().length < 10, + condition: !formValues.overview + || formValues.overview.replace(/<[^>]*>/g, '') + .trim().length < 10, key: 'overview', message: 'Project overview must be at least 10 characters', }, @@ -543,43 +542,16 @@ const CopilotRequestForm: FC<{}> = () => {

Please provide an overview of the project the copilot will undertake

-
- { - setFormValues((prev: any) => ({ ...prev, overview: content })) - setFormErrors((prev: any) => { - const updated = { ...prev } - delete updated.overview - return updated - }) - setIsFormChanged(true) - }} - init={{ - browser_spellcheck: true, - content_style: - 'body {' - + 'font-family: "Roboto", Arial, Helvetica, sans-serif;' - + 'font-size: 14px; line-height: 22px;' - + '}', - height: 300, - menubar: false, - placeholder: 'A minimum of three sentences explaining the type of work and project which is to be undertaken.', - plugins: ['table', 'link'], - statusbar: false, - toolbar: 'undo redo | formatselect | bold italic underline strikethrough |' - + ' forecolor backcolor | link | alignleft aligncenter alignright alignjustify |' - + ' numlist bullist outdent indent | table | removeformat', - }} - /> -
- {formErrors.overview && ( -

- - {formErrors.overview} -

- )} +

@@ -596,9 +568,7 @@ const CopilotRequestForm: FC<{}> = () => {

{!canGenerateSkills && formValues.overview - && stripHtml(formValues.overview || '') - .trim().length < MIN_OVERVIEW_LENGTH - + && formValues.overview.trim().length < MIN_OVERVIEW_LENGTH && (

Add at least From f70d5a5b88153f269ca67aefc6058771a31954fb Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 14 May 2026 09:27:30 +0300 Subject: [PATCH 11/34] PM-5062 - Budget Approve/Reject button should not be displayed for Completed /Cancelled challenges --- .../components/ChallengeEditorForm.spec.tsx | 29 ++++++++++++++++++- .../components/ChallengeEditorForm.tsx | 3 ++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx index 790d9df4a..c1442e393 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx @@ -196,7 +196,6 @@ jest.mock('~/config', () => ({ work: 'work', }, EnvironmentConfig: { - SUBDOMAIN: 'work', ADMIN: { DIRECT_URL: 'https://example.com/direct', REVIEW_UI_URL: 'https://example.com/review', @@ -212,6 +211,7 @@ jest.mock('~/config', () => ({ DIRECT_PROJECT_URL: 'https://example.com/direct-project', ENGAGEMENTS_URL: 'https://example.com/engagements', REVIEW_APP_URL: 'https://example.com/review', + SUBDOMAIN: 'work', TC_DOMAIN: 'example.com', TC_FINANCE_API: 'https://example.com/finance', TOPCODER_URL: 'https://example.com/topcoder', @@ -1001,6 +1001,33 @@ describe('ChallengeEditorForm', () => { .toBeNull() }) + it('hides budget approval actions when challenge status is approved', () => { + const managerContextValue: WorkAppContextModel = { + ...copilotContextValue, + isManager: true, + userRoles: ['project manager'], + } + + render( + + + + + , + ) + + expect(screen.queryByRole('button', { name: 'Approve Budget' })) + .toBeNull() + expect(screen.queryByRole('button', { name: 'Reject Budget' })) + .toBeNull() + }) + it('hides the editable timeline section for task challenges in edit mode', () => { mockedUseFetchChallengeTypes.mockReturnValue({ challengeTypes: [{ diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx index 1c237a61a..94e3bfe28 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -1800,7 +1800,10 @@ export const ChallengeEditorForm: FC = ( && !!currentChallengeId && hasPersistedPrizeSets && !hasUnsavedPrizeSetChanges + && normalizedChallengeStatus !== CHALLENGE_STATUS.APPROVED && normalizedChallengeStatus !== CHALLENGE_STATUS.ACTIVE + && normalizedChallengeStatus !== CHALLENGE_STATUS.COMPLETED + && !(normalizedChallengeStatus ?? '').startsWith(CHALLENGE_STATUS.CANCELLED) const isBudgetPending = normalizedApprovalStatus === CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL const isBudgetApproved = normalizedApprovalStatus === CHALLENGE_APPROVAL_STATUS.APPROVED const isBudgetRejected = normalizedApprovalStatus === CHALLENGE_APPROVAL_STATUS.REJECTED From 42c6bd438ef8c3f2046b5a89cc0518fa85ef418b Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Thu, 14 May 2026 11:57:32 +0530 Subject: [PATCH 12/34] Copilot App : Updated Text editor for Project Overview --- .../src/pages/copilot-request-form/index.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/apps/copilots/src/pages/copilot-request-form/index.tsx b/src/apps/copilots/src/pages/copilot-request-form/index.tsx index 8000ae090..32df34af8 100644 --- a/src/apps/copilots/src/pages/copilot-request-form/index.tsx +++ b/src/apps/copilots/src/pages/copilot-request-form/index.tsx @@ -275,9 +275,15 @@ const CopilotRequestForm: FC<{}> = () => { } // Check if overview has enough content for AI processing + function stripHtml(html: string): string { + const doc = new DOMParser() + .parseFromString(html, 'text/html') + return doc.body.textContent || '' + } + const canGenerateSkills = useMemo(() => { - const overview = formValues.overview?.replace(/<[^>]*>/g, '') - .trim() || '' + const overview = stripHtml(formValues.overview || '') + .trim() return overview.length >= MIN_OVERVIEW_LENGTH && !isGeneratingSkills }, [formValues.overview, isGeneratingSkills]) @@ -300,9 +306,8 @@ const CopilotRequestForm: FC<{}> = () => { { condition: !formValues.paymentType, key: 'paymentType', message: 'Selection is required' }, { condition: !formValues.projectType, key: 'projectType', message: 'Selecting project type is required' }, { - condition: !formValues.overview - || formValues.overview.replace(/<[^>]*>/g, '') - .trim().length < 10, + condition: stripHtml(formValues.overview || '') + .trim().length < 10, key: 'overview', message: 'Project overview must be at least 10 characters', }, @@ -568,7 +573,8 @@ const CopilotRequestForm: FC<{}> = () => {

{!canGenerateSkills && formValues.overview - && formValues.overview.trim().length < MIN_OVERVIEW_LENGTH + && stripHtml(formValues.overview) + .trim().length < MIN_OVERVIEW_LENGTH && (

Add at least From 0032330d709a18019cbe9a69d17297d30fb7e480 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 14 May 2026 09:58:41 +0300 Subject: [PATCH 13/34] PM-5015 - re-enable ai-only review mode --- .../ReviewersField/AiReviewTab.spec.tsx | 25 +++++++++++-------- .../components/ReviewersField/AiReviewTab.tsx | 12 +++------ 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.spec.tsx index 2c73fdd0b..40f8134eb 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.spec.tsx @@ -1,3 +1,6 @@ +/** + * @jest-environment jsdom + */ /* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ import { useCallback, @@ -91,6 +94,10 @@ jest.mock('~/libs/ui', () => ({ virtual: true, }) +jest.mock('./AiReviewTab.module.scss', () => ({}), { + virtual: true, +}) + const mockedUseFetchChallengeTracks = useFetchChallengeTracks as jest.Mock const mockedUseFetchChallengeTypes = useFetchChallengeTypes as jest.Mock const mockedCreateAiReviewConfig = createAiReviewConfig as jest.Mock @@ -169,7 +176,7 @@ describe('AiReviewTab review mode options', () => { }, ) - it('shows only AI_GATING as a visible review mode option for standard configs', async () => { + it('shows AI_GATING and AI_ONLY as visible review mode options for standard configs', async () => { render( { ) .toEqual([ 'AI_GATING', + 'AI_ONLY', ]) expect(screen.getByRole('option', { name: 'AI_GATING' })).not.toBeNull() expect( - screen.queryByRole('option', { name: 'AI_ONLY (legacy)' }), + screen.getByRole('option', { name: 'AI_ONLY' }), ) - .toBeNull() + .not.toBeNull() }) it('does not refetch the persisted AI review config when the parent callback changes', async () => { @@ -262,7 +270,7 @@ describe('AiReviewTab review mode options', () => { .toBeNull() }) - it('keeps legacy AI_ONLY configs visible without exposing AI_ONLY in the dropdown list', async () => { + it('supports AI_ONLY configs with both options now available in the dropdown', async () => { mockedFetchAiReviewConfigByChallenge.mockResolvedValueOnce({ ...baseConfiguration, autoFinalize: true, @@ -280,17 +288,14 @@ describe('AiReviewTab review mode options', () => { const visibleOptionLabels = Array.from(reviewModeSelect.querySelectorAll('option')) .filter(option => !option.hidden) .map(option => option.textContent) - const legacyOption = reviewModeSelect.querySelector('option[hidden]') expect(visibleOptionLabels) .toEqual([ 'AI_GATING', + 'AI_ONLY', ]) - expect(legacyOption?.textContent) - .toBe('AI_ONLY (legacy)') - expect(screen.getByText( - 'AI_ONLY is a legacy configuration and is no longer available for new setups.', - )).not.toBeNull() + expect((reviewModeSelect as HTMLSelectElement).value) + .toBe('AI_ONLY') }) it('renders manual workflow headings with a space before the workflow number', async () => { diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.tsx index 832985e17..30cc9b9e5 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.tsx @@ -97,7 +97,6 @@ const DEFAULT_CONFIGURATION: AiReviewConfigurationDraft = { templateId: undefined, workflows: [], } -const LEGACY_AI_ONLY_OPTION_VALUE = 'LEGACY_AI_ONLY' let workflowDraftIdCounter = 0 @@ -379,9 +378,6 @@ const ReviewSettings: FC = ( ) => { const minPassingThreshold = Number(props.configuration.minPassingThreshold || 0) const mode = props.configuration.mode || 'AI_GATING' - const selectedMode = mode === 'AI_ONLY' - ? LEGACY_AI_ONLY_OPTION_VALUE - : mode const handleModeChange = useCallback( (event: ChangeEvent): void => { props.onUpdate('mode', event.target.value as AiReviewConfigurationDraft['mode']) @@ -414,17 +410,15 @@ const ReviewSettings: FC = ( {mode === 'AI_GATING' ? 'AI blocks low-quality submissions and lets the rest continue to human review.' - : 'AI_ONLY is a legacy configuration and is no longer available for new setups.'} + : 'AI makes the final decision for all submissions.'} From 38616b3d3154e3b885dead05f137f015c2b831e3 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Thu, 14 May 2026 13:51:45 +0530 Subject: [PATCH 14/34] PM-5071 Enhance SFDC views --- .../styles.module.scss | 2 +- .../src/pages/reports/ReportsPage.module.scss | 37 +- .../reports/src/pages/reports/ReportsPage.tsx | 478 +++++++++++++----- 3 files changed, 376 insertions(+), 141 deletions(-) diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss b/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss index 36bee9379..456661485 100644 --- a/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss @@ -10,7 +10,7 @@ justify-content: center; text-transform: uppercase; font-family: $font-barlow-condensed; - font-size: 40px; + font-size: 50px; font-weight: $font-weight-medium; margin-top: $sp-2; padding: $sp-6 0; diff --git a/src/apps/reports/src/pages/reports/ReportsPage.module.scss b/src/apps/reports/src/pages/reports/ReportsPage.module.scss index 53cb25e50..4cfbe966b 100644 --- a/src/apps/reports/src/pages/reports/ReportsPage.module.scss +++ b/src/apps/reports/src/pages/reports/ReportsPage.module.scss @@ -1,3 +1,4 @@ +@import '@libs/ui/styles/includes'; .page { display: flex; flex-direction: column; @@ -176,17 +177,35 @@ color: #6b6f75; } -.billingSummary { - margin-top: 8px; - padding: 16px; - border: 1px solid #e4e6e9; - border-radius: 6px; - background: #fafbfc; +.billingAccountIdLink { + margin: 0; + padding: 0; + border: 0; + background: none; + color: $link-blue-dark; + cursor: pointer; + font: inherit; + text-align: inherit; } -.billingSummaryTitle { - font-weight: 600; - margin-bottom: 12px; +.billingAccountIdLink:hover { + color: #0a58ca; +} + +.billingModalBody { + min-width: 280px; + padding-top: 4px; +} + +.billingModalMeta { + font-size: 13px; + color: #6b6f75; + margin-bottom: 14px; +} + +.billingModalLoading { + padding: 16px 0; + color: #494f55; } .billingDetailGrid { diff --git a/src/apps/reports/src/pages/reports/ReportsPage.tsx b/src/apps/reports/src/pages/reports/ReportsPage.tsx index 6263c0038..77542c0a2 100644 --- a/src/apps/reports/src/pages/reports/ReportsPage.tsx +++ b/src/apps/reports/src/pages/reports/ReportsPage.tsx @@ -1,9 +1,21 @@ -import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react' +import { + ChangeEvent, + Dispatch, + FC, + SetStateAction, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' +import { format as formatIsoDate, isValid, parseISO } from 'date-fns' import { NavigateFunction, useNavigate } from 'react-router-dom' import { + BaseModal, Button, IconOutline, + InputDatePicker, InputSelect, InputSelectOption, InputText, @@ -16,6 +28,7 @@ import { Pagination } from '~/apps/admin/src/lib' import { bulkMemberLookupRouteId } from '../../config/routes.config' import { handleError } from '../../lib/utils' import { + BillingAccountDetail, BillingAccountProfileResponse, BillingAccountsViewData, downloadBlobFile, @@ -30,7 +43,7 @@ import { SfdcBillingAccountPaymentRow, } from '../../lib/services' -import { getReportParameterValidationError } from './reports-page.validation' +import { getReportParameterValidationError, isValidReportDateValue } from './reports-page.validation' import styles from './ReportsPage.module.scss' const pageTitle = 'Reports' @@ -38,15 +51,16 @@ 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 = { - description: 'View billing-account details and SFDC payments by billing account ID.', + description: + 'View SFDC payments across all billing accounts by default. Optionally filter by billing account ID and dates.', method: 'GET', name: 'Billing Accounts', parameters: [ { - description: 'Billing account ID', + description: 'Optional billing account ID to narrow payments to a single account.', location: 'query', name: 'billingAccountId', - required: true, + required: false, type: 'string', }, { @@ -68,12 +82,17 @@ const BILLING_ACCOUNTS_REPORT_DEFINITION: ReportDefinition = { type ReportsPageTab = 'reports' | 'billingAccounts' const buildSfdcPaymentsQueryPath = ( - billingAccountId: string, + billingAccountId: string | undefined, startDate?: string, endDate?: string, ): string => { const query = new URLSearchParams() - query.append('billingAccountIds', billingAccountId.trim()) + const trimmedBa = billingAccountId?.trim() + + if (trimmedBa) { + query.append('billingAccountIds', trimmedBa) + } + const start = startDate?.trim() const end = endDate?.trim() @@ -85,7 +104,8 @@ const buildSfdcPaymentsQueryPath = ( query.append('endDate', end) } - return `${SFDC_PAYMENTS_REPORT_PATH}?${query.toString()}` + const queryString = query.toString() + return queryString ? `${SFDC_PAYMENTS_REPORT_PATH}?${queryString}` : SFDC_PAYMENTS_REPORT_PATH } const formatReportCell = (value: unknown): string => { @@ -130,13 +150,177 @@ const PAYMENT_TABLE_COLUMNS: { key: keyof SfdcBillingAccountPaymentRow; label: s ] const PAYMENT_ROWS_PER_PAGE_OPTIONS = [10, 25, 50] +type BillingAccountDateParamInputProps = { + label: string + parameterErrors: Record + parameterName: 'startDate' | 'endDate' + parameterValues: Record + setParameterValues: Dispatch>> +} + +function billingAccountDatePickerBounds( + parameterName: 'startDate' | 'endDate', + parsedStart: Date | undefined, + parsedEnd: Date | undefined, +): { maxDate?: Date; minDate?: Date } { + const startOk = !!parsedStart && isValid(parsedStart) + const endOk = !!parsedEnd && isValid(parsedEnd) + + if (parameterName === 'endDate' && startOk) { + return { maxDate: undefined, minDate: parsedStart } + } + + if (parameterName === 'startDate' && endOk) { + return { maxDate: parsedEnd, minDate: undefined } + } + + return {} +} + +const BillingAccountDateParamInput: FC = ( + props: BillingAccountDateParamInputProps, +) => { + const startRaw = props.parameterValues.startDate?.trim() + const endRaw = props.parameterValues.endDate?.trim() + const parsedStart = startRaw && isValidReportDateValue(startRaw) ? parseISO(startRaw) : undefined + const parsedEnd = endRaw && isValidReportDateValue(endRaw) ? parseISO(endRaw) : undefined + const rawValue = props.parameterValues[props.parameterName]?.trim() + const selectedDate = rawValue && isValidReportDateValue(rawValue) ? parseISO(rawValue) : undefined + const dateBounds = billingAccountDatePickerBounds( + props.parameterName, + parsedStart, + parsedEnd, + ) + + function handleDateChange(date: Date | null): void { + props.setParameterValues(previous => ({ + ...previous, + [props.parameterName]: date && isValid(date) ? formatIsoDate(date, 'yyyy-MM-dd') : '', + })) + } + + return ( + + ) +} + +type BillingAccountIdCellProps = { + rawId: unknown + onOpen: (id: string) => void +} + +const BillingAccountIdCell: FC = (props: BillingAccountIdCellProps) => { + const displayed = formatReportCell(props.rawId) + + function handleClick(): void { + props.onOpen(String(props.rawId)) + } + + if (displayed === '—') { + return <>{displayed} + } + + return ( + + ) +} + +const BillingAccountSummaryBody = (props: { + billingAccount: BillingAccountDetail | undefined + billingAccountIdLabel: string +}): JSX.Element => ( + <> +

+ {`Billing account ID: ${props.billingAccountIdLabel}`} +
+ {props.billingAccount ? ( +
+
+ Name + {props.billingAccount.name} +
+
+ Description + + {formatReportCell(props.billingAccount.description)} + +
+
+ Subcontracting end customer + + {formatReportCell(props.billingAccount.subcontractingEndCustomer)} + +
+
+ Status + {props.billingAccount.status} +
+
+ Start date + + {props.billingAccount.startDate + ? formatPaymentDate(String(props.billingAccount.startDate)) + : '—'} + +
+
+ End date + + {props.billingAccount.endDate + ? formatPaymentDate(String(props.billingAccount.endDate)) + : '—'} + +
+
+ Budget + + {formatReportCell(props.billingAccount.budget)} + +
+
+ Markup + + {formatReportCell(props.billingAccount.markup)} + +
+
+ ) : ( +
+ No billing account profile was found for this ID. +
+ )} + +) + const BillingAccountReportResults = ( props: { data: BillingAccountsViewData }, ): JSX.Element => { - const billingAccount: BillingAccountsViewData['billingAccount'] = props.data.billingAccount const payments: BillingAccountsViewData['payments'] = props.data.payments const [currentPage, setCurrentPage] = useState(1) const [rowsPerPage, setRowsPerPage] = useState(PAYMENT_ROWS_PER_PAGE_OPTIONS[0]) + const [modalBaId, setModalBaId] = useState(undefined) + const [modalProfile, setModalProfile] = useState(undefined) + const [modalLoading, setModalLoading] = useState(false) + const openBillingProfileModal = useCallback((id: string) => { + setModalBaId(id) + }, []) const total = payments.length const totalPages = Math.max(1, Math.ceil(total / rowsPerPage)) const currentSliceStart = (currentPage - 1) * rowsPerPage @@ -148,73 +332,94 @@ const BillingAccountReportResults = ( setCurrentPage(1) }, [payments]) + useEffect(() => { + if (!modalBaId) { + setModalProfile(undefined) + setModalLoading(false) + return undefined + } + + let cancelled = false + setModalLoading(true) + setModalProfile(undefined) + + const profileQuery = new URLSearchParams({ billingAccountId: modalBaId }) + const profilePath = `${BILLING_ACCOUNTS_REPORT_PATH}?${profileQuery.toString()}` + + fetchReportJson(profilePath) + .then(response => { + if (!cancelled) { + setModalProfile(response.billingAccount) + } + }) + .catch(() => { + if (!cancelled) { + setModalProfile(undefined) + } + }) + .finally(() => { + if (!cancelled) { + setModalLoading(false) + } + }) + + return () => { + cancelled = true + } + }, [modalBaId]) + function handleRowsPerPageChange(event: ChangeEvent): void { setRowsPerPage(Number(event.target.value)) setCurrentPage(1) } + function handleCloseBillingModal(): void { + setModalBaId(undefined) + } + + function renderPaymentCell( + row: SfdcBillingAccountPaymentRow, + colKey: keyof SfdcBillingAccountPaymentRow, + ): JSX.Element | string { + const value = row[colKey] + + if (colKey === 'paymentDate') { + return formatPaymentDate(String(value)) + } + + if (colKey === 'billingAccountId') { + return + } + + return formatReportCell(value) + } + 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. + + Close + + )} + > + {modalBaId === undefined ? undefined : ( +
+ {modalLoading ? ( +
Loading billing account…
+ ) : ( + + )}
)} -
+
Payments
@@ -235,11 +440,7 @@ const BillingAccountReportResults = ( {paginatedPayments.map(row => ( {PAYMENT_TABLE_COLUMNS.map(col => ( - - {col.key === 'paymentDate' - ? formatPaymentDate(String(row[col.key])) - : formatReportCell(row[col.key])} - + {renderPaymentCell(row, col.key)} ))} ))} @@ -317,10 +518,6 @@ const buildParameterTooltipContent = (parameter: ReportParameter): JSX.Element = ) -const EMPTY_BILLING_ACCOUNT_PROFILE_RESPONSE: BillingAccountProfileResponse = { - billingAccount: undefined, -} - type ReportActionsProps = { handleCsvDownload: () => void handleJsonDownload: () => void @@ -610,67 +807,67 @@ const ReportsPageContent: FC = props => { Object.keys(parameterErrors).length > 0 ), [parameterErrors]) - const handleBillingAccountView = useCallback(async () => { - if (activeTab !== 'billingAccounts' || hasInvalidParameterValues) { - return - } - - const billingAccountId = parameterValues.billingAccountId?.trim() - - if (!billingAccountId) { - return - } - + const fetchBillingPaymentsForParams = useCallback(async (params: Record) => { try { setIsBillingAccountViewLoading(true) - const profileQuery = new URLSearchParams({ billingAccountId }) - const profilePath = `${BILLING_ACCOUNTS_REPORT_PATH}?${profileQuery.toString()}` + const billingAccountId = params.billingAccountId?.trim() const paymentsPath = buildSfdcPaymentsQueryPath( - billingAccountId, - parameterValues.startDate, - parameterValues.endDate, + billingAccountId || undefined, + params.startDate, + params.endDate, ) - - const paymentsPromise = fetchReportJson(paymentsPath) - const profilePromise = fetchReportJson(profilePath) - .catch(() => EMPTY_BILLING_ACCOUNT_PROFILE_RESPONSE) - const [profile, payments] = await Promise.all([profilePromise, paymentsPromise]) - - setBillingAccountViewData({ - billingAccount: profile.billingAccount, - payments, - }) + const payments = await fetchReportJson(paymentsPath) + setBillingAccountViewData({ payments }) } catch (error) { handleError(error) } finally { setIsBillingAccountViewLoading(false) } + }, []) + + useEffect(() => { + if (activeTab !== 'billingAccounts') { + return undefined + } + + fetchBillingPaymentsForParams({}) + .catch(handleError) + + return undefined + }, [activeTab, fetchBillingPaymentsForParams]) + + const handleBillingAccountView = useCallback(() => { + if (activeTab !== 'billingAccounts' || hasInvalidParameterValues) { + return + } + + fetchBillingPaymentsForParams(parameterValues) + .catch(handleError) }, [ activeTab, + fetchBillingPaymentsForParams, hasInvalidParameterValues, - parameterValues.billingAccountId, - parameterValues.endDate, - parameterValues.startDate, + parameterValues, ]) - const handleDownload = useCallback(async (format: 'json' | 'csv') => { + const handleDownload = useCallback(async (downloadFormat: 'json' | 'csv') => { if (!selectedReport || hasInvalidParameterValues) { return } try { - setDownloadingFormat(format) + setDownloadingFormat(downloadFormat) const requestPath = buildReportPathWithParams(selectedReport) - const blob = format === 'json' + const blob = downloadFormat === 'json' ? await downloadReportAsJson(requestPath) : await downloadReportAsCsv(requestPath) const challengeIdSuffix = parameterValues.challengeId?.trim() const fileName = buildDownloadName( selectedReport.name, - format, + downloadFormat, challengeIdSuffix, ) downloadBlobFile(blob, fileName) @@ -687,12 +884,18 @@ const ReportsPageContent: FC = props => { const handleResetFilters = useCallback(() => { setParameterValues({}) + + if (activeTab === 'billingAccounts') { + fetchBillingPaymentsForParams({}) + .catch(handleError) + return + } + setBillingAccountViewData(undefined) - }, []) + }, [activeTab, fetchBillingPaymentsForParams]) const handleBillingAccountViewClick = useCallback(() => { handleBillingAccountView() - .catch(handleError) }, [handleBillingAccountView]) const isDownloading = downloadingFormat !== undefined @@ -721,9 +924,8 @@ const ReportsPageContent: FC = props => { const billingAccountViewDisabled = !selectedReportForForm || isDownloading || isBillingAccountViewLoading - || requiredParamsMissing || hasInvalidParameterValues - || hasUnresolvedPathParams + const isResetDisabled = Object.keys(parameterValues).length === 0 const handleJsonDownload = useCallback(() => { @@ -766,6 +968,7 @@ const ReportsPageContent: FC = props => { /> ) + // eslint-disable-next-line complexity -- mirrors report parameter types (text, select, billing dates) const renderParameterInput = useCallback((parameter: ReportParameter) => { const commonProps = { label: formatParameterLabel(parameter.name), @@ -775,27 +978,32 @@ const ReportsPageContent: FC = props => { : (parameter.type.endsWith('[]') ? 'Comma-separated values' : 'Enter value'), } - if (parameter.type === 'boolean') { - const options: InputSelectOption[] = [ - { label: 'True', value: 'true' }, - { label: 'False', value: 'false' }, - ] + const isBillingDateField = selectedReportForForm?.path === BILLING_ACCOUNTS_REPORT_PATH + && parameter.type === 'date' + && (parameter.name === 'startDate' || parameter.name === 'endDate') + if (isBillingDateField) { return ( - ) } - if (parameter.type === 'enum') { - const options: InputSelectOption[] = (parameter.options ?? []).map(option => ({ - label: option, - value: option, - })) + if (parameter.type === 'boolean' || parameter.type === 'enum') { + const options: InputSelectOption[] = parameter.type === 'boolean' + ? [ + { label: 'True', value: 'true' }, + { label: 'False', value: 'false' }, + ] + : (parameter.options ?? []).map(option => ({ + label: option, + value: option, + })) return ( = props => { hint={parameter.type === 'date' ? 'Use ISO 8601 format (e.g. 2024-01-31)' : undefined} /> ) - }, [createSelectParamChange, handleParameterChange, parameterErrors, parameterValues]) + }, [ + createSelectParamChange, + handleParameterChange, + parameterErrors, + parameterValues, + selectedReportForForm?.path, + setParameterValues, + ]) return ( <> @@ -837,8 +1052,9 @@ const ReportsPageContent: FC = props => { + '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. '} + {'Payments load for all billing accounts by default. Optionally narrow by billing ' + + 'account ID and dates, then click View. Open a billing account profile from ' + + 'the Billing account ID column in the table. '} If no dates are specified, records from the past 45 days are displayed by default. From 2364d89b113efd6f11750fc0668fc8a2ca7345bf Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 14 May 2026 11:48:21 +0300 Subject: [PATCH 15/34] PM-5065 - Paginate the list for budget approvals --- .../BudgetApprovalsPage.module.scss | 21 +++++- .../BudgetApprovalsPage.tsx | 69 +++++++++++-------- 2 files changed, 59 insertions(+), 31 deletions(-) diff --git a/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.module.scss b/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.module.scss index b1df43a19..a8f218bd2 100644 --- a/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.module.scss +++ b/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.module.scss @@ -1,3 +1,5 @@ +@import '@libs/ui/styles/includes'; + .searchRow { display: grid; gap: 16px; @@ -61,9 +63,26 @@ text-align: center; } +.nameLink { + color: $link-blue-dark; + text-decoration: none; + + &:hover, + &:focus { + text-decoration: underline; + outline: none; + } +} + .actionLink { - color: inherit; + color: $link-blue-dark; display: inline-flex; + + &:hover, + &:focus { + text-decoration: underline; + outline: none; + } } .actionIcon { diff --git a/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.tsx b/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.tsx index 7ca40b2b7..94c2662a9 100644 --- a/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.tsx +++ b/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.tsx @@ -14,8 +14,10 @@ import { IconOutline } from '~/libs/ui' import { CHALLENGE_APPROVAL_STATUS, CHALLENGE_STATUS, + PAGE_SIZE, } from '../../../lib/constants' import { WorkAppContext } from '../../../lib/contexts' +import { Pagination } from '../../../lib/components' import { useFetchChallenges, UseFetchChallengesParams, @@ -37,12 +39,6 @@ function normalizeSearchValue(value: string): string { .toLowerCase() } -function normalizeStatus(value: unknown): string { - return String(value || '') - .trim() - .toUpperCase() -} - function getProjectId(challenge: Challenge): string { return String(challenge.projectId || '') } @@ -66,6 +62,7 @@ function getProjectName( return projectMap.get(projectId)?.name || projectId || 'Unknown project' } +// eslint-disable-next-line complexity export const BudgetApprovalsPage: FC = () => { const { isAdmin, @@ -74,6 +71,8 @@ export const BudgetApprovalsPage: FC = () => { const [challengeNameSearch, setChallengeNameSearch] = useState('') const [projectNameSearch, setProjectNameSearch] = useState('') + const [page, setPage] = useState(1) + const [perPage, setPerPage] = useState(PAGE_SIZE) const memberId = isAdmin ? undefined @@ -83,8 +82,9 @@ export const BudgetApprovalsPage: FC = () => { approvalStatus: CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL, enabled: isAdmin || memberId !== undefined, memberId, - page: 1, - perPage: 100, + name: normalizeSearchValue(challengeNameSearch) || undefined, + page, + perPage, sortBy: 'updated', sortOrder: 'desc', status: CHALLENGE_STATUS.DRAFT, @@ -116,11 +116,6 @@ export const BudgetApprovalsPage: FC = () => { [projectsResult.projects], ) - const normalizedChallengeNameSearch = useMemo( - () => normalizeSearchValue(challengeNameSearch), - [challengeNameSearch], - ) - const normalizedProjectNameSearch = useMemo( () => normalizeSearchValue(projectNameSearch), [projectNameSearch], @@ -128,11 +123,6 @@ export const BudgetApprovalsPage: FC = () => { const filteredChallenges = useMemo( () => challengesResult.challenges - .filter(challenge => normalizeStatus(challenge.status) === CHALLENGE_STATUS.DRAFT) - .filter( - challenge => normalizeStatus(challenge.approvalStatus) - === CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL, - ) .filter(challenge => { const projectId = getProjectId(challenge) @@ -146,14 +136,6 @@ export const BudgetApprovalsPage: FC = () => { return accessibleProjectIds.has(projectId) }) - .filter(challenge => { - if (!normalizedChallengeNameSearch) { - return true - } - - return normalizeSearchValue(challenge.name) - .includes(normalizedChallengeNameSearch) - }) .filter(challenge => { if (!normalizedProjectNameSearch) { return true @@ -168,7 +150,6 @@ export const BudgetApprovalsPage: FC = () => { accessibleProjectIds, challengesResult.challenges, isAdmin, - normalizedChallengeNameSearch, normalizedProjectNameSearch, projectMap, ], @@ -176,10 +157,21 @@ export const BudgetApprovalsPage: FC = () => { function handleChallengeNameSearch(event: ChangeEvent): void { setChallengeNameSearch(event.target.value) + setPage(1) } function handleProjectNameSearch(event: ChangeEvent): void { setProjectNameSearch(event.target.value) + setPage(1) + } + + function handlePageChange(newPage: number): void { + setPage(newPage) + } + + function handlePerPageChange(newPerPage: number): void { + setPerPage(newPerPage) + setPage(1) } if (challengesResult.isLoading || (!isAdmin && projectsResult.isLoading)) { @@ -187,6 +179,8 @@ export const BudgetApprovalsPage: FC = () => { } const errorMessage = challengesResult.error?.message || projectsResult.error?.message + const totalPendingApprovals = challengesResult.metadata.total ?? 0 + const shouldShowPagination = !errorMessage && totalPendingApprovals > 0 return ( {
- {filteredChallenges.length} + {totalPendingApprovals} {' '} pending approvals
@@ -253,10 +247,12 @@ export const BudgetApprovalsPage: FC = () => { return ( - {projectName} + + {projectName} + - {challenge.name} + {challenge.name} { })} + + {shouldShowPagination + ? ( + + ) + : undefined} ) } From a2af777a7cef5942ef8522ab37a22328d28a86fe Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Thu, 14 May 2026 14:46:48 +0530 Subject: [PATCH 16/34] Review app css --- .../src/lib/components/AiReviewsTable/AiReviewsTable.module.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss index bcd7b1eb5..8e48c1306 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss @@ -34,6 +34,7 @@ max-width: 100%; word-break: break-word; overflow-wrap: break-word; + white-space: pre-line; } .reRunIcon { From c842f5c81ddab239857d00ae03edb353d2b47f19 Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Thu, 14 May 2026 16:01:44 +0530 Subject: [PATCH 17/34] Updated Text Editor for Copilot Opportunity Overview --- .../OpportunityDetails.tsx | 6 +- .../opportunity-details/styles.module.scss | 64 +++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/OpportunityDetails.tsx b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/OpportunityDetails.tsx index b3e3e4013..d65edc7f1 100644 --- a/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/OpportunityDetails.tsx +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/OpportunityDetails.tsx @@ -15,9 +15,9 @@ const OpportunityDetails: FC<{

Description

{props.opportunity?.overview && ( -
'), - }} +
)}
diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/styles.module.scss b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/styles.module.scss index aad91a31d..2cdf48c40 100644 --- a/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/styles.module.scss +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/styles.module.scss @@ -40,4 +40,68 @@ border-radius: 10px; white-space: nowrap; font-size: 14px; +} + +.overviewContent { + font-size: 14px; + line-height: 22px; + font-family: 'Roboto', Arial, Helvetica, sans-serif; + + p { + margin: 0 0 8px 0; + } + + strong, b { + font-weight: 700; + } + + em, i { + font-style: italic; + } + + u { + text-decoration: underline; + } + + s { + text-decoration: line-through; + } + + a { + color: #0d61bf; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } + + ul, ol { + margin: 0 0 8px 0; + padding-left: 24px; + } + + ul { + list-style-type: disc; + } + + ol { + list-style-type: decimal; + } + + table { + border-collapse: collapse; + width: 100%; + margin: 0 0 8px 0; + + td, th { + border: 1px solid #d4d4d4; + padding: 8px 12px; + } + + th { + background-color: #f5f5f5; + font-weight: 700; + } + } } \ No newline at end of file From f0ed991507aa36cc18188cc83e7471059312ccef Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 14 May 2026 14:20:15 +0300 Subject: [PATCH 18/34] PM-5065 - fix budget approval list filtering by project --- .../BudgetApprovalsPage.tsx | 83 +++++++++---------- 1 file changed, 37 insertions(+), 46 deletions(-) diff --git a/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.tsx b/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.tsx index 94c2662a9..7a7407fd9 100644 --- a/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.tsx +++ b/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.tsx @@ -6,6 +6,7 @@ import { useState, } from 'react' import { Link } from 'react-router-dom' +import Select, { SingleValue } from 'react-select' import { TableLoading } from '~/apps/admin/src/lib' import { PageWrapper } from '~/apps/review/src/lib' @@ -31,6 +32,11 @@ import { WorkAppContextModel, } from '../../../lib/models' +interface ProjectOption { + label: string + value: string +} + import styles from './BudgetApprovalsPage.module.scss' function normalizeSearchValue(value: string): string { @@ -70,7 +76,7 @@ export const BudgetApprovalsPage: FC = () => { }: WorkAppContextModel = useContext(WorkAppContext) const [challengeNameSearch, setChallengeNameSearch] = useState('') - const [projectNameSearch, setProjectNameSearch] = useState('') + const [selectedProjectId, setSelectedProjectId] = useState('') const [page, setPage] = useState(1) const [perPage, setPerPage] = useState(PAGE_SIZE) @@ -85,6 +91,7 @@ export const BudgetApprovalsPage: FC = () => { name: normalizeSearchValue(challengeNameSearch) || undefined, page, perPage, + projectId: selectedProjectId || undefined, sortBy: 'updated', sortOrder: 'desc', status: CHALLENGE_STATUS.DRAFT, @@ -111,14 +118,19 @@ export const BudgetApprovalsPage: FC = () => { return map }, [projectsResult.projects]) - const accessibleProjectIds = useMemo( - () => new Set(projectsResult.projects.map(project => String(project.id || ''))), + const projectOptions = useMemo( + () => projectsResult.projects + .filter(project => !!project.id) + .map(project => ({ + label: project.name || String(project.id), + value: String(project.id), + })), [projectsResult.projects], ) - const normalizedProjectNameSearch = useMemo( - () => normalizeSearchValue(projectNameSearch), - [projectNameSearch], + const selectedProjectOption = useMemo( + () => projectOptions.find(opt => opt.value === selectedProjectId) ?? null, + [projectOptions, selectedProjectId], ) const filteredChallenges = useMemo( @@ -126,33 +138,9 @@ export const BudgetApprovalsPage: FC = () => { .filter(challenge => { const projectId = getProjectId(challenge) - if (!projectId) { - return false - } - - if (isAdmin) { - return true - } - - return accessibleProjectIds.has(projectId) - }) - .filter(challenge => { - if (!normalizedProjectNameSearch) { - return true - } - - const projectName = getProjectName(projectMap, getProjectId(challenge)) - - return normalizeSearchValue(projectName) - .includes(normalizedProjectNameSearch) + return !!projectId }), - [ - accessibleProjectIds, - challengesResult.challenges, - isAdmin, - normalizedProjectNameSearch, - projectMap, - ], + [challengesResult.challenges], ) function handleChallengeNameSearch(event: ChangeEvent): void { @@ -160,8 +148,8 @@ export const BudgetApprovalsPage: FC = () => { setPage(1) } - function handleProjectNameSearch(event: ChangeEvent): void { - setProjectNameSearch(event.target.value) + function handleProjectSelect(option: SingleValue): void { + setSelectedProjectId(option?.value ?? '') setPage(1) } @@ -174,10 +162,6 @@ export const BudgetApprovalsPage: FC = () => { setPage(1) } - if (challengesResult.isLoading || (!isAdmin && projectsResult.isLoading)) { - return - } - const errorMessage = challengesResult.error?.message || projectsResult.error?.message const totalPendingApprovals = challengesResult.metadata.total ?? 0 const shouldShowPagination = !errorMessage && totalPendingApprovals > 0 @@ -193,14 +177,17 @@ export const BudgetApprovalsPage: FC = () => {
- - Project + ) => { + const picked = raw.map(o => o.value) + if (picked.includes(SELECT_ALL_SENTINEL)) { + const allValues = baseOptions.map(o => o.value) + setSelectedValue(new Map(selectedValue.set(filter.key, allValues))) + props.onFilterChange(filter.key, allValues) + + return + } + + setSelectedValue(new Map(selectedValue.set(filter.key, picked))) + props.onFilterChange(filter.key, picked) + }} + /> +
+ ) + } + + const renderDate = (index: number, filter: Filter): JSX.Element => { + const override = props.selectedValueOverrides?.[filter.key] + const iso = typeof override === 'string' + ? override + : undefined + + return ( +
+ { + props.onFilterChange(filter.key, formatIsoDateOnly(date)) + }} + isClearable + /> +
+ ) + } + const renderMemberAutoComplete = (index: number, filter: Filter): JSX.Element => ( = (props: FilterBarProps) => { /> ) + const renderFilterControl = (index: number, filter: Filter): JSX.Element => { + if (filter.type === 'dropdown') { + return renderDropdown(index, filter) + } + + if (filter.type === 'multi_dropdown') { + return renderMultiDropdown(index, filter) + } + + if (filter.type === 'date') { + return renderDate(index, filter) + } + + if (filter.type === 'member_autocomplete') { + return renderMemberAutoComplete(index, filter) + } + + return ( + ) => { + setSelectedValue(new Map(selectedValue.set(filter.key, event.target.value))) + props.onFilterChange(filter.key, [event.target.value]) + }} + /> + ) + } + return (
{props.filters.slice(0, 1) - .map((options, index) => ( -
- {options.type === 'dropdown' && renderDropdown(index, options)} - {options.type === 'input' && ( - ) => { - setSelectedValue(new Map(selectedValue.set(options.key, event.target.value))) - props.onFilterChange(options.key, [event.target.value]) - }} - /> - )} - {options.type === 'member_autocomplete' && renderMemberAutoComplete(index, options)} + .map((filter, index) => ( +
+ {renderFilterControl(index, filter)}
))}
{props.filters.slice(1) - .map((options, index) => ( -
- {options.type === 'dropdown' && renderDropdown(index + 1, options)} - {options.type === 'input' && ( - ) => { - setSelectedValue(new Map(selectedValue.set(options.key, event.target.value))) - props.onFilterChange(options.key, [event.target.value]) - }} - /> - )} - {options.type === 'member_autocomplete' && renderMemberAutoComplete(index + 1, options)} + .map((filter, index) => ( +
+ {renderFilterControl(index + 1, filter)}
))}
diff --git a/src/apps/wallet-admin/src/lib/services/wallet.ts b/src/apps/wallet-admin/src/lib/services/wallet.ts index 78445ebae..4f14fb0c4 100644 --- a/src/apps/wallet-admin/src/lib/services/wallet.ts +++ b/src/apps/wallet-admin/src/lib/services/wallet.ts @@ -360,10 +360,16 @@ export async function exportSearchResults( const filteredFilters: Record = {} for (const key in filters) { - if (['categories'].includes(key)) { + if (['categories', 'winnerIds'].includes(key)) { filteredFilters[key] = filters[key] } else if (filters[key].length > 0 && key !== 'pageSize') { - filteredFilters[key] = filters[key][0] + if (key === 'status' && filters[key].length > 1) { + filteredFilters[key] = filters[key] + } else if (key === 'status') { + filteredFilters[key] = filters[key][0] + } else { + filteredFilters[key] = filters[key][0] + } } } @@ -402,10 +408,16 @@ export async function fetchWinnings( const filteredFilters: Record = {} for (const key in filters) { - if (['categories'].includes(key)) { + if (['categories', 'winnerIds'].includes(key)) { filteredFilters[key] = filters[key] } else if (filters[key].length > 0 && key !== 'pageSize') { - filteredFilters[key] = filters[key][0] + if (key === 'status' && filters[key].length > 1) { + filteredFilters[key] = filters[key] + } else if (key === 'status') { + filteredFilters[key] = filters[key][0] + } else { + filteredFilters[key] = filters[key][0] + } } } diff --git a/src/apps/wallet/src/home/tabs/winnings/PaymentsListView.tsx b/src/apps/wallet/src/home/tabs/winnings/PaymentsListView.tsx index 850d83202..4864b94ad 100644 --- a/src/apps/wallet/src/home/tabs/winnings/PaymentsListView.tsx +++ b/src/apps/wallet/src/home/tabs/winnings/PaymentsListView.tsx @@ -1,5 +1,6 @@ /* eslint-disable max-len */ /* eslint-disable react/jsx-no-bind */ +import { toast } from 'react-toastify' import React, { FC, useCallback, useEffect } from 'react' import { Collapsible, LoadingCircles } from '~/libs/ui' @@ -168,7 +169,8 @@ const PaymentsListView: FC = (props: PaymentsListViewProp setWinnings(winningsData) setPagination(payments.pagination) } catch (apiError) { - console.error('Failed to fetch winnings:', apiError) + const message = apiError instanceof Error ? apiError.message : 'Failed to fetch winnings' + toast.error(message) } finally { setIsLoading(false) } From 2e4d31c70ad378d875ed39a26018985b7684a3da Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 18 May 2026 15:08:15 +1000 Subject: [PATCH 23/34] PM-5043: Use consumed challenge member subtotals What was broken Completed challenge billing rows could still show the ledger total as Member Payments for non-admin users when markup data was hidden or unavailable, so rows such as a $33.25 consumed charge displayed as $33.25 with a $10.97 fee instead of $25.00 with an $8.25 fee. Root cause The prior fixes correctly handled locked challenge rows and markup-based fallbacks, but the modal ignored API-provided member-payment subtotals for all challenge rows. That was too broad: locked rows must ignore those aliases, while consumed rows can use them as the exact completed member-payment subtotal. What was changed Consumed challenge rows now prefer `memberPaymentAmount` when it is present. For manager/admin views, the challenge fee is derived from the consumed ledger amount minus that member-payment subtotal before falling back to markup-based calculation. Any added/updated tests Added BillingAccountLineItemsModal regression coverage for consumed challenge rows where challenge markup cannot be loaded, covering both fee-visible users and copilot-safe views. --- .../BillingAccountLineItemsModal.spec.tsx | 61 +++++++++++++++ .../BillingAccountLineItemsModal.tsx | 78 +++++++++++++++++-- 2 files changed, 133 insertions(+), 6 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 8712d6f72..faa3114c1 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx @@ -204,6 +204,37 @@ describe('BillingAccountLineItemsModal', () => { .toBeNull() }) + it('uses consumed challenge member-payment subtotals when markup is hidden', async () => { + mockedFetchChallenge.mockRejectedValueOnce(new Error('Forbidden')) + + renderModal({ + ...baseBillingAccountDetails, + consumedAmounts: [ + { + amount: '33.25', + date: '2026-05-12T00:00:00.000Z', + externalId: '2864601d-320a-45e2-85b4-a14f9f19785e', + externalName: 'May 12 challenge', + externalType: 'CHALLENGE', + memberPaymentAmount: '25', + }, + ], + consumedBudget: 33.25, + totalBudgetRemaining: 966.75, + }) + + await waitFor(() => { + expect(screen.getByText('$25.00')) + .toBeTruthy() + expect(screen.getByText('$8.25')) + .toBeTruthy() + }) + expect(screen.getAllByText('$33.25')) + .toHaveLength(1) + expect(screen.queryByText('$10.97')) + .toBeNull() + }) + it('uses zero challenge markup instead of billing-account default markup for consumed charges', async () => { challengeMarkupById.set('0f4c801c-4d4d-4ac2-8e2e-60aeb16379d2', 0) @@ -532,6 +563,36 @@ describe('BillingAccountLineItemsModal', () => { .toBeNull() }) + it('uses consumed challenge member-payment subtotals for copilots when markup is hidden', async () => { + mockedFetchChallenge.mockRejectedValueOnce(new Error('Forbidden')) + + renderModal({ + ...baseBillingAccountDetails, + consumedAmounts: [ + { + amount: '33.25', + date: '2026-05-12T00:00:00.000Z', + externalId: '2864601d-320a-45e2-85b4-a14f9f19785e', + externalName: 'May 12 challenge', + externalType: 'CHALLENGE', + memberPaymentAmount: '25', + }, + ], + consumedBudget: 33.25, + memberPaymentsRemaining: 200, + totalBudgetRemaining: 966.75, + }, true) + + await waitFor(() => { + expect(screen.getByText('$25.00')) + .toBeTruthy() + }) + expect(screen.queryByText('$33.25')) + .toBeNull() + expect(screen.queryByText('Challenge Fee')) + .toBeNull() + }) + it('uses API-provided engagement member-payment row amounts for copilot responses without markup', () => { renderModal({ ...baseBillingAccountDetails, diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx index ca4c34436..8849d0d81 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx @@ -301,8 +301,9 @@ function getLineItemChallengeMarkup( * @param challengeDetailsById Hydrated challenge details, or `undefined` while loading. * @returns Member payment amount for the challenge row. * @remarks Locked challenge rows store member payments directly. Consumed - * challenge rows store the final billing-account charge, so the billing markup - * is removed once using the challenge's own billing markup. + * challenge rows prefer the API-provided member-payment subtotal when present. + * Older payloads only expose the final billing-account charge, so the billing + * markup is removed once using the challenge's own billing markup. */ function getChallengeMemberPaymentAmount( item: BillingAccountLineItem, @@ -313,6 +314,10 @@ function getChallengeMemberPaymentAmount( return item.amount } + if (item.memberPaymentAmount !== undefined) { + return item.memberPaymentAmount + } + const challengeMarkup = getLineItemChallengeMarkup( item, billingAccountDetails, @@ -329,6 +334,65 @@ function getChallengeMemberPaymentAmount( ) } +/** + * Resolves a persisted challenge fee from a consumed billing row. + * + * @param item Raw billing-account line item. + * @returns Fee amount derived from the ledger charge and API-provided member + * payment subtotal, or `undefined` when the row does not expose both values. + * @remarks Completed challenge rows can expose the exact member-payment + * subtotal even when markup is hidden from the current caller, so the + * difference is the safest fee value for the manager/admin fee column. + */ +function getConsumedChallengeFeeAmount( + item: BillingAccountLineItem, +): number | undefined { + if ( + item.externalType !== 'CHALLENGE' + || item.status !== 'consumed' + || item.memberPaymentAmount === undefined + ) { + return undefined + } + + const feeAmount = Number((item.amount - item.memberPaymentAmount).toFixed(2)) + + return feeAmount >= 0 + ? feeAmount + : undefined +} + +/** + * Resolves the row challenge fee amount for callers allowed to see markup. + * + * @param item Raw locked or consumed billing-account line item. + * @param displayAmount Member-payment amount selected for display. + * @param billingAccountDetails Billing account detail payload containing hidden markup when available. + * @param challengeDetailsById Hydrated challenge details, or `undefined` while loading. + * @returns Persisted consumed challenge fee, calculated markup fee, or + * `undefined` when the fee cannot be derived. + * @remarks Consumed challenge rows with an explicit member subtotal do not + * need challenge markup hydration to show the correct fee. + */ +function getLineItemChallengeFeeAmount( + item: BillingAccountLineItem, + displayAmount: number | undefined, + billingAccountDetails: BillingAccountDetails, + challengeDetailsById: ChallengeDetailsById | undefined, +): number | undefined { + const consumedChallengeFeeAmount = getConsumedChallengeFeeAmount(item) + + if (consumedChallengeFeeAmount !== undefined) { + return consumedChallengeFeeAmount + } + + const challengeMarkup = item.externalType === 'CHALLENGE' + ? getLineItemChallengeMarkup(item, billingAccountDetails, challengeDetailsById) + : billingAccountDetails.markup + + return calculatePaymentChallengeFee(displayAmount, challengeMarkup) +} + /** * Resolves the engagement member-payment amount that should be visible in the row. * @@ -392,11 +456,13 @@ function getDisplayLineItem( billingAccountDetails, challengeDetailsById, ) - const challengeMarkup = item.externalType === 'CHALLENGE' - ? getLineItemChallengeMarkup(item, billingAccountDetails, challengeDetailsById) - : billingAccountDetails.markup const challengeFeeAmount = showChallengeFee - ? calculatePaymentChallengeFee(displayAmount, challengeMarkup) + ? getLineItemChallengeFeeAmount( + item, + displayAmount, + billingAccountDetails, + challengeDetailsById, + ) : undefined return { From 565581c5e92cc59b2b5381a7b12efa27e42b18d3 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 18 May 2026 15:24:02 +1000 Subject: [PATCH 24/34] PM-5060: Add Topgear submission reprocess action What was broken Review app admins could copy Topgear Task submission IDs, but they had no inline way to re-raise the topgear.submission.received bus event for a failed processing attempt. Root cause The review submission table only exposed download and copy controls. The existing bus API reprocess contract was only used outside the review app. What was changed Added a review-app reprocess service that builds the topgear.submission.received bus payload and posts it to the V5 bus events endpoint. Added an admin-only refresh icon next to the submission ID copy button for Topgear Task challenges. The action sends the selected submission ID, challenge ID, URL, member handle, member ID, and submitted date. Any added/updated tests Added submission-reprocess.service.spec.ts to cover Topgear/admin gating, payload construction, required URL validation, and the bus API request envelope. --- .../TabContentSubmissions.tsx | 73 +++++++ src/apps/review/src/lib/services/index.ts | 1 + .../submission-reprocess.service.spec.ts | 151 ++++++++++++++ .../services/submission-reprocess.service.ts | 195 ++++++++++++++++++ 4 files changed, 420 insertions(+) create mode 100644 src/apps/review/src/lib/services/submission-reprocess.service.spec.ts create mode 100644 src/apps/review/src/lib/services/submission-reprocess.service.ts diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx index 85e83888f..2d8a4e668 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx @@ -18,6 +18,7 @@ import { TableLoading } from '~/apps/admin/src/lib' import { TableMobile } from '~/apps/admin/src/lib/components/common/TableMobile' import { IsRemovingType } from '~/apps/admin/src/lib/models' import { MobileTableColumn } from '~/apps/admin/src/lib/models/MobileTableColumn.model' +import { handleError } from '~/apps/admin/src/lib/utils' import { copyTextToClipboard, useWindowSize, WindowSize } from '~/libs/shared' import { IconOutline, Table, TableColumn, Tooltip } from '~/libs/ui' @@ -46,6 +47,10 @@ import { TABLE_DATE_FORMAT } from '../../../config/index.config' import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow' import { useRolePermissions, UseRolePermissionsResult } from '../../hooks' import { SUBMISSION_DOWNLOAD_RESTRICTION_MESSAGE } from '../../constants' +import { + canReprocessTopgearSubmission, + reprocessTopgearSubmission, +} from '../../services' import { canDownloadSubmissionFromSubmissionsTab } from './submissionDownloadPermissions' import styles from './TabContentSubmissions.module.scss' @@ -81,6 +86,12 @@ export const TabContentSubmissions: FC = props => { }: UseRolePermissionsResult = useRolePermissions() const { challengeInfo, registrants }: ChallengeDetailContextModel = useContext(ChallengeDetailContext) + const [isReprocessingSubmission, setIsReprocessingSubmission] = useState({}) + + const canShowTopgearReprocess = useMemo( + () => canReprocessTopgearSubmission(challengeInfo, isAdmin), + [challengeInfo, isAdmin], + ) const isCompletedDesignChallenge = useMemo(() => { if (!challengeInfo) return false @@ -212,6 +223,52 @@ export const TabContentSubmissions: FC = props => { [openHistoryModalForKey], ) + const handleReprocessSubmission = useCallback( + async (event: MouseEvent): Promise => { + event.stopPropagation() + event.preventDefault() + + const submissionId = event.currentTarget.dataset.submissionId + if (!submissionId) { + return + } + + const submission = submissionMetaById.get(submissionId) + if (!submission) { + toast.error('Submission could not be found for reprocess', { + toastId: `topgear-submission-reprocess-${submissionId}`, + }) + return + } + + setIsReprocessingSubmission(previous => ({ + ...previous, + [submissionId]: true, + })) + + try { + await reprocessTopgearSubmission({ + submission, + submissionInfo: submissionInfoById.get(submissionId), + }) + toast.success('Reprocess submission request sent', { + toastId: `topgear-submission-reprocess-${submissionId}`, + }) + } catch (error) { + handleError(error as Error) + } finally { + setIsReprocessingSubmission(previous => ({ + ...previous, + [submissionId]: false, + })) + } + }, + [ + submissionInfoById, + submissionMetaById, + ], + ) + const resolveSubmissionMeta = useCallback( (submissionId: string): SubmissionInfo | undefined => submissionInfoById.get(submissionId), [submissionInfoById], @@ -353,6 +410,19 @@ export const TabContentSubmissions: FC = props => { > + {canShowTopgearReprocess && ( + + )} ) }, @@ -458,9 +528,12 @@ export const TabContentSubmissions: FC = props => { restrictionMessage, props.downloadSubmission, props.isDownloading, + isReprocessingSubmission, historyByMember, handleHistoryButtonClick, + handleReprocessSubmission, shouldShowHistoryActions, + canShowTopgearReprocess, isAdmin, isProjectManager, hasCopilotRole, diff --git a/src/apps/review/src/lib/services/index.ts b/src/apps/review/src/lib/services/index.ts index 2ea3bbe2c..2c3fba054 100644 --- a/src/apps/review/src/lib/services/index.ts +++ b/src/apps/review/src/lib/services/index.ts @@ -7,3 +7,4 @@ export * from './payments.service' export * from './challenge-phases.service' export * from './aiReviewEscalation.service' export * from './aiReview.service' +export * from './submission-reprocess.service' diff --git a/src/apps/review/src/lib/services/submission-reprocess.service.spec.ts b/src/apps/review/src/lib/services/submission-reprocess.service.spec.ts new file mode 100644 index 000000000..4285889da --- /dev/null +++ b/src/apps/review/src/lib/services/submission-reprocess.service.spec.ts @@ -0,0 +1,151 @@ +import { xhrPostAsync } from '~/libs/core' + +import type { + BackendSubmission, + ChallengeInfo, + SubmissionInfo, +} from '../models' + +import { + canReprocessTopgearSubmission, + createTopgearSubmissionReprocessPayload, + reprocessTopgearSubmission, + TOPGEAR_SUBMISSION_REPROCESS_TOPIC, +} from './submission-reprocess.service' + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + API: { + V5: 'https://api.topcoder.test', + }, + }, +}), { virtual: true }) + +jest.mock('~/libs/core', () => ({ + xhrPostAsync: jest.fn(), +}), { virtual: true }) + +const baseSubmission: Pick< + BackendSubmission, + | 'id' + | 'challengeId' + | 'createdAt' + | 'createdBy' + | 'memberId' + | 'submittedDate' + | 'url' +> = { + challengeId: '26d1497e-b818-4f6c-8fab-f4b41b6d88bf', + createdAt: '2026-05-11T14:17:12.364Z', + createdBy: 'FallbackHandle', + id: 'okuFYPovWRXWoF', + memberId: '90428298', + submittedDate: '2026-05-11T14:17:12.364Z', + url: 'https://example.com/topgear/submission', +} + +const submissionInfo: Pick< + SubmissionInfo, + | 'submittedDate' + | 'submitterHandle' + | 'userInfo' +> = { + submittedDate: '2026-05-11T14:17:12.364Z', + submitterHandle: 'FallbackSubmitter', + userInfo: { + challengeId: baseSubmission.challengeId, + created: '2026-05-11T14:17:12.364Z', + createdBy: 'review-api-v6', + id: 'resource-id', + memberHandle: 'AsimH', + memberId: baseSubmission.memberId, + roleId: 'submitter-role-id', + }, +} + +describe('submission reprocess service', () => { + afterEach(() => { + jest.clearAllMocks() + jest.useRealTimers() + }) + + it('allows reprocess only for admins on Topgear Task challenges', () => { + const topgearChallenge = { + type: { + id: 'type-id', + name: 'Topgear Task', + }, + } as ChallengeInfo + const designChallenge = { + type: { + id: 'design-type-id', + name: 'Design', + }, + } as ChallengeInfo + + expect(canReprocessTopgearSubmission(topgearChallenge, true)) + .toBe(true) + expect(canReprocessTopgearSubmission(topgearChallenge, false)) + .toBe(false) + expect(canReprocessTopgearSubmission(designChallenge, true)) + .toBe(false) + }) + + it('builds the Topgear submission reprocess payload from submission and registrant data', () => { + expect(createTopgearSubmissionReprocessPayload({ + submission: baseSubmission, + submissionInfo, + })) + .toEqual({ + challengeId: '26d1497e-b818-4f6c-8fab-f4b41b6d88bf', + memberHandle: 'AsimH', + memberId: '90428298', + submissionId: 'okuFYPovWRXWoF', + submissionUrl: 'https://example.com/topgear/submission', + submittedDate: '2026-05-11T14:17:12.364Z', + }) + }) + + it('requires a submission URL before building the reprocess payload', () => { + expect(() => createTopgearSubmissionReprocessPayload({ + submission: { + ...baseSubmission, + url: '', + }, + submissionInfo, + })) + .toThrow('Submission url is not valid') + }) + + it('posts the Topgear reprocess event to the bus API', async () => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2026-05-18T10:20:30.000Z')) + const mockedPost = xhrPostAsync as jest.MockedFunction + mockedPost.mockResolvedValue('ok') + + await expect(reprocessTopgearSubmission({ + submission: baseSubmission, + submissionInfo, + })) + .resolves.toBe('ok') + + expect(mockedPost) + .toHaveBeenCalledWith( + 'https://api.topcoder.test/bus/events', + { + 'mime-type': 'application/json', + originator: 'review-api-v6', + payload: { + challengeId: '26d1497e-b818-4f6c-8fab-f4b41b6d88bf', + memberHandle: 'AsimH', + memberId: '90428298', + submissionId: 'okuFYPovWRXWoF', + submissionUrl: 'https://example.com/topgear/submission', + submittedDate: '2026-05-11T14:17:12.364Z', + }, + timestamp: '2026-05-18T10:20:30.000Z', + topic: TOPGEAR_SUBMISSION_REPROCESS_TOPIC, + }, + ) + }) +}) diff --git a/src/apps/review/src/lib/services/submission-reprocess.service.ts b/src/apps/review/src/lib/services/submission-reprocess.service.ts new file mode 100644 index 000000000..0596d85dd --- /dev/null +++ b/src/apps/review/src/lib/services/submission-reprocess.service.ts @@ -0,0 +1,195 @@ +import { EnvironmentConfig } from '~/config' +import { xhrPostAsync } from '~/libs/core' + +import type { + BackendSubmission, + ChallengeInfo, + SubmissionInfo, +} from '../models' + +export const TOPGEAR_SUBMISSION_REPROCESS_TOPIC = 'topgear.submission.received' + +const REPROCESS_ORIGINATOR = 'review-api-v6' + +export interface TopgearSubmissionReprocessPayload { + submissionId: string + challengeId: string + submissionUrl: string + memberHandle: string + memberId: string + submittedDate: string +} + +interface TopgearSubmissionReprocessEvent { + topic: string + originator: string + timestamp: string + 'mime-type': string + payload: TopgearSubmissionReprocessPayload +} + +interface TopgearSubmissionReprocessInput { + submission: Pick< + BackendSubmission, + | 'id' + | 'challengeId' + | 'createdAt' + | 'createdBy' + | 'memberId' + | 'submittedDate' + | 'url' + > + submissionInfo?: Pick< + SubmissionInfo, + | 'submittedDate' + | 'submitterHandle' + | 'userInfo' + > +} + +type ChallengeInfoWithRuntimeType = Pick & { + type?: ChallengeInfo['type'] | string +} + +function normalizeName(value?: string): string { + return value?.replace(/\s+/g, '') + .trim() + .toLowerCase() ?? '' +} + +function requireNonEmptyString(value: unknown, errorMessage: string): string { + const normalized = value === undefined || value === null + ? '' + : String(value) + .trim() + + if (!normalized) { + throw new Error(errorMessage) + } + + return normalized +} + +function toIsoDate(value: unknown): string | undefined { + if (!value) { + return undefined + } + + const date = value instanceof Date ? value : new Date(String(value)) + return Number.isNaN(date.valueOf()) ? undefined : date.toISOString() +} + +function resolveSubmittedDate(input: TopgearSubmissionReprocessInput): string { + const submittedDate = toIsoDate( + input.submissionInfo?.submittedDate + ?? input.submission.submittedDate + ?? input.submission.createdAt, + ) + + if (!submittedDate) { + throw new Error('Submitted date is not valid') + } + + return submittedDate +} + +function resolveMemberHandle(input: TopgearSubmissionReprocessInput): string { + return requireNonEmptyString( + input.submissionInfo?.userInfo?.memberHandle + ?? input.submissionInfo?.submitterHandle + ?? input.submission.createdBy, + 'Member handle is not valid', + ) +} + +/** + * Determines whether a challenge is a Topgear Task challenge. + * + * @param challengeInfo - Challenge details returned by the review app data loaders. + * @returns True when the challenge type name normalizes to `topgeartask`. + */ +export function isTopgearTaskChallenge( + challengeInfo?: ChallengeInfoWithRuntimeType, +): boolean { + const type = challengeInfo?.type + const typeName = typeof type === 'string' ? type : type?.name + + return normalizeName(typeName) === 'topgeartask' +} + +/** + * Determines whether the current viewer may reprocess Topgear submissions. + * + * @param challengeInfo - Challenge details used to identify Topgear Task challenges. + * @param isAdmin - Whether the current viewer has admin privileges. + * @returns True when the button should be available to the viewer. + */ +export function canReprocessTopgearSubmission( + challengeInfo: ChallengeInfoWithRuntimeType | undefined, + isAdmin: boolean, +): boolean { + return isAdmin && isTopgearTaskChallenge(challengeInfo) +} + +/** + * Builds the bus API payload used to reprocess a Topgear Task submission. + * + * @param input - Backend submission data plus optional normalized review submission info. + * @returns Payload for the `topgear.submission.received` Kafka topic. + * @throws Error when any required submission, member, URL, or date field is missing. + */ +export function createTopgearSubmissionReprocessPayload( + input: TopgearSubmissionReprocessInput, +): TopgearSubmissionReprocessPayload { + return { + challengeId: requireNonEmptyString( + input.submission.challengeId, + 'Challenge id is not valid', + ), + memberHandle: resolveMemberHandle(input), + memberId: requireNonEmptyString( + input.submission.memberId, + 'Member id is not valid', + ), + submissionId: requireNonEmptyString( + input.submission.id, + 'Submission id is not valid', + ), + submissionUrl: requireNonEmptyString( + input.submission.url, + 'Submission url is not valid', + ), + submittedDate: resolveSubmittedDate(input), + } +} + +function createTopgearSubmissionReprocessEvent( + payload: TopgearSubmissionReprocessPayload, +): TopgearSubmissionReprocessEvent { + return { + 'mime-type': 'application/json', + originator: REPROCESS_ORIGINATOR, + payload, + timestamp: new Date() + .toISOString(), + topic: TOPGEAR_SUBMISSION_REPROCESS_TOPIC, + } +} + +/** + * Sends a Topgear Task submission reprocess message through the bus API. + * + * @param input - Submission data used to build the bus API message payload. + * @returns Resolves when the bus API accepts the event. + * @throws Error when payload construction fails or the bus API request rejects. + */ +export async function reprocessTopgearSubmission( + input: TopgearSubmissionReprocessInput, +): Promise { + const payload = createTopgearSubmissionReprocessPayload(input) + + return xhrPostAsync( + `${EnvironmentConfig.API.V5}/bus/events`, + createTopgearSubmissionReprocessEvent(payload), + ) +} From ad9522e177ef7914c366d5e80fe26677622a282c Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Mon, 18 May 2026 11:07:07 +0530 Subject: [PATCH 25/34] Updated Copilot Text Editor --- .../src/pages/copilot-request-form/index.tsx | 16 +++++++++++++--- .../field-html-editor/FieldHtmlEditor.tsx | 4 ++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/apps/copilots/src/pages/copilot-request-form/index.tsx b/src/apps/copilots/src/pages/copilot-request-form/index.tsx index 32df34af8..27ec407c2 100644 --- a/src/apps/copilots/src/pages/copilot-request-form/index.tsx +++ b/src/apps/copilots/src/pages/copilot-request-form/index.tsx @@ -1,4 +1,4 @@ -import { FC, useContext, useEffect, useMemo, useState } from 'react' +import { FC, useContext, useEffect, useMemo, useRef, useState } from 'react' import { bind, debounce, isEmpty, pick } from 'lodash' import { toast } from 'react-toastify' import { Params, useNavigate, useParams, useSearchParams } from 'react-router-dom' @@ -194,7 +194,9 @@ const CopilotRequestForm: FC<{}> = () => { setIsFormChanged(true) } + const overviewInitialized = useRef(false) function handleOverviewChange(content: string): void { + overviewInitialized.current = true setFormValues((prev: any) => ({ ...prev, overview: content })) setFormErrors((prev: any) => { const updated = { ...prev } @@ -399,6 +401,7 @@ const CopilotRequestForm: FC<{}> = () => { setIsFormChanged(false) setFormErrors({}) setPaymentType('') + overviewInitialized.current = false // Added a small timeout for the toast to be visible properly to the users setTimeout(() => { navigate(`${rootRoute}/requests`) @@ -422,7 +425,13 @@ const CopilotRequestForm: FC<{}> = () => { handleProjectSearch(inputValue) .then(callback) }, 300), []) - + const editorKey = useMemo( + () => (copilotRequestData?.id ?? 'new'), + [copilotRequestData?.id], + ) + useEffect(() => { + overviewInitialized.current = false + }, [editorKey]) return (
@@ -548,11 +557,12 @@ const CopilotRequestForm: FC<{}> = () => { Please provide an overview of the project the copilot will undertake

= ( onInit={function onInit(_evt: any, editor: any) { (editorRef.current = editor) }} - onChange={function onChange() { - props.onChange(editorRef.current.getContent()) + onEditorChange={function onEditorChange(content: string) { + props.onChange(content) }} onBlur={props.onBlur} initialValue={initValue} From e8b0ff4e5a7f70b5b9fe884587bf591d43a9ae27 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Mon, 18 May 2026 11:11:17 +0530 Subject: [PATCH 26/34] fix linting --- .../src/home/tabs/payments/PaymentsListView.spec.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.spec.tsx b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.spec.tsx index 9a8a016bf..3740c748c 100644 --- a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.spec.tsx +++ b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.spec.tsx @@ -248,9 +248,9 @@ describe('PaymentsListView', () => { .toHaveBeenCalled() expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides) .toEqual(expect.objectContaining({ - status: ['ON_HOLD_ADMIN'], category: ['TASK_PAYMENT', 'ENGAGEMENT_PAYMENT'], date: 'last30days', + status: ['ON_HOLD_ADMIN'], })) }) @@ -300,9 +300,9 @@ describe('PaymentsListView', () => { expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides) .toEqual(expect.objectContaining({ - status: ['ON_HOLD_ADMIN'], category: ['TASK_PAYMENT', 'ENGAGEMENT_PAYMENT'], date: 'last30days', + status: ['ON_HOLD_ADMIN'], })) }) @@ -349,9 +349,9 @@ describe('PaymentsListView', () => { expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides) .toEqual(expect.objectContaining({ - status: ['PAID'], category: ['TASK_PAYMENT', 'ENGAGEMENT_PAYMENT'], date: 'last30days', + status: ['PAID'], })) }) From e409cf42d446db489ba59a96855fa91643decef7 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 18 May 2026 15:42:49 +1000 Subject: [PATCH 27/34] PM-5059: Highlight Topgear active review issues What was broken The My Active Challenges table did not show submission counts, and support had no visual indicator for Topgear Task challenges with submissions where Iterative Review had not opened. Root cause (if identifiable) The review app model and table only used the current active-review assignment fields, so it ignored submission totals and Iterative Review phase state from the backend. What was changed Threaded submission count and Iterative Review state through the active review models, added a Submissions column, and highlighted affected Topgear Task rows with a faint red background and red border on desktop and mobile tables. Any added/updated tests Added active review assignment transformation coverage for the Topgear Task issue condition. --- .../common/TableMobile/TableMobile.tsx | 12 ++++--- .../TableActiveReviews.module.scss | 16 ++++++++++ .../TableActiveReviews/TableActiveReviews.tsx | 31 ++++++++++++++++++- .../lib/hooks/useFetchActiveReviews.spec.tsx | 27 +++++++++++++++- .../src/lib/hooks/useFetchActiveReviews.ts | 7 +++++ .../models/ActiveReviewAssignment.model.ts | 2 ++ .../models/BackendMyReviewAssignment.model.ts | 2 ++ src/libs/ui/lib/components/table/Table.tsx | 2 ++ .../components/table/table-row/TableRow.tsx | 11 +++++-- 9 files changed, 101 insertions(+), 9 deletions(-) diff --git a/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.tsx b/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.tsx index ee63a4a90..e032bfd96 100644 --- a/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.tsx +++ b/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.tsx @@ -15,6 +15,7 @@ interface Props { readonly columns: ReadonlyArray[]> readonly data: ReadonlyArray className?: string + readonly rowClassName?: (data: T) => string | undefined } function getKey(key: (string | number)[]): string { @@ -33,10 +34,13 @@ export const TableMobile: ( {props.columns.map((itemColumns, indexColumns) => ( {itemColumns.map( (itemItemColumns, indexItemColumns) => ( diff --git a/src/apps/review/src/lib/components/TableActiveReviews/TableActiveReviews.module.scss b/src/apps/review/src/lib/components/TableActiveReviews/TableActiveReviews.module.scss index 6679068ba..50795c793 100644 --- a/src/apps/review/src/lib/components/TableActiveReviews/TableActiveReviews.module.scss +++ b/src/apps/review/src/lib/components/TableActiveReviews/TableActiveReviews.module.scss @@ -42,6 +42,22 @@ vertical-align: middle; } +.iterativeReviewIssueRow { + td { + background-color: rgba($red-25, 0.45) !important; + border-bottom: 1px solid $red-140; + border-top: 1px solid $red-140; + + &:first-child { + border-left: 1px solid $red-140; + } + + &:last-child { + border-right: 1px solid $red-140; + } + } +} + .blockMyRoles { display: flex; flex-direction: column; diff --git a/src/apps/review/src/lib/components/TableActiveReviews/TableActiveReviews.tsx b/src/apps/review/src/lib/components/TableActiveReviews/TableActiveReviews.tsx index 014430141..7116b384c 100644 --- a/src/apps/review/src/lib/components/TableActiveReviews/TableActiveReviews.tsx +++ b/src/apps/review/src/lib/components/TableActiveReviews/TableActiveReviews.tsx @@ -249,6 +249,13 @@ export const TableActiveReviews: FC = (props: Props) => { if (!hideStatusColumns) { baseColumns.push(myRoleColumn) baseColumns.push( + { + className: styles.tableCell, + isSortable: false, + label: 'Submissions', + propertyName: 'numOfSubmissions', + type: 'text', + }, { className: styles.tableCell, isSortable: true, @@ -385,6 +392,23 @@ export const TableActiveReviews: FC = (props: Props) => { [onToggleSort, sortMapping], ) + /** + * Returns the highlight class for active Topgear Task rows with submissions + * where Iterative Review is still closed. + * + * @param data active challenge row data + * @returns row CSS class when the issue condition is met; otherwise undefined + * @throws This callback does not throw. + */ + const getRowClassName = useCallback( + (data: ActiveReviewAssignment) => ( + data.hasIterativeReviewIssue + ? styles.iterativeReviewIssueRow + : undefined + ), + [], + ) + return ( = (props: Props) => { )} > {isTablet ? ( - + ) : ( ({ @@ -107,4 +107,29 @@ describe('useFetchActiveReviews', () => { expect(screen.getByText('No active reviews')) .toBeTruthy() }) + + it('marks topgear tasks with submissions and closed iterative review phase', () => { + const [assignment] = transformAssignments([ + { + challengeEndDate: '2026-01-01T00:00:00.000Z', + challengeId: 'challenge-1', + challengeName: 'Topgear Problem', + challengeTypeId: 'topgear-task-type', + challengeTypeName: 'Topgear Task', + currentPhaseEndDate: '2026-01-01T00:00:00.000Z', + currentPhaseName: 'Topgear Submission', + hasAIReview: false, + isIterativeReviewPhaseOpen: false, + numOfSubmissions: 2, + resourceRoleName: 'Reviewer', + reviewProgress: 0, + timeLeftInCurrentPhase: 0, + }, + ]) + + expect(assignment.numOfSubmissions) + .toBe(2) + expect(assignment.hasIterativeReviewIssue) + .toBe(true) + }) }) diff --git a/src/apps/review/src/lib/hooks/useFetchActiveReviews.ts b/src/apps/review/src/lib/hooks/useFetchActiveReviews.ts index 643e2f058..1bfd39a99 100644 --- a/src/apps/review/src/lib/hooks/useFetchActiveReviews.ts +++ b/src/apps/review/src/lib/hooks/useFetchActiveReviews.ts @@ -121,6 +121,11 @@ export const transformAssignments = ( / normalizedReviewProgressValues.length, ) : undefined + const numOfSubmissions = base.numOfSubmissions ?? 0 + const hasIterativeReviewIssue = base.challengeTypeName?.trim() + .toLowerCase() === 'topgear task' + && numOfSubmissions > 0 + && base.isIterativeReviewPhaseOpen === false const currentIndex = index index += 1 @@ -142,9 +147,11 @@ export const transformAssignments = ( .format(TABLE_DATE_FORMAT) : undefined, hasAIReview: base.hasAIReview, + hasIterativeReviewIssue, id: base.challengeId, index: currentIndex, name: base.challengeName, + numOfSubmissions, resourceRoles, reviewProgress: aggregatedReviewProgress, status: base.status, diff --git a/src/apps/review/src/lib/models/ActiveReviewAssignment.model.ts b/src/apps/review/src/lib/models/ActiveReviewAssignment.model.ts index 0fd16bf73..bdf3fe662 100644 --- a/src/apps/review/src/lib/models/ActiveReviewAssignment.model.ts +++ b/src/apps/review/src/lib/models/ActiveReviewAssignment.model.ts @@ -18,6 +18,8 @@ export interface ActiveReviewAssignment { resourceRoles: string[] challengeTypeId: string challengeTypeName: string + numOfSubmissions: number + hasIterativeReviewIssue: boolean winnerHandle?: string winnerHandleColor?: string winnerProfileUrl?: string diff --git a/src/apps/review/src/lib/models/BackendMyReviewAssignment.model.ts b/src/apps/review/src/lib/models/BackendMyReviewAssignment.model.ts index 7819ce591..2bd68ad2f 100644 --- a/src/apps/review/src/lib/models/BackendMyReviewAssignment.model.ts +++ b/src/apps/review/src/lib/models/BackendMyReviewAssignment.model.ts @@ -24,6 +24,8 @@ export interface BackendMyReviewAssignment { timeLeftInCurrentPhase: number | null resourceRoleName: string reviewProgress: number | null + numOfSubmissions?: number | null + isIterativeReviewPhaseOpen?: boolean | null winners?: BackendMyReviewAssignmentWinner[] | null status?: string } diff --git a/src/libs/ui/lib/components/table/Table.tsx b/src/libs/ui/lib/components/table/Table.tsx index 3b1c794f7..c611bd46d 100644 --- a/src/libs/ui/lib/components/table/Table.tsx +++ b/src/libs/ui/lib/components/table/Table.tsx @@ -34,6 +34,7 @@ interface TableProps { readonly onLoadMoreClick?: () => void readonly onRowClick?: (data: T) => void readonly onToggleSort?: (sort: Sort | undefined) => void + readonly rowClassName?: (data: T) => string | undefined readonly removeDefaultSort?: boolean readonly colWidth?: colWidthType | undefined, readonly setColWidth?: Dispatch> | undefined @@ -248,6 +249,7 @@ const Table: (props: TableProps) = allRows={sortedData} onRowClick={props.onRowClick} columns={props.columns} + className={props.rowClassName?.(sorted)} index={index} showExpand={props.showExpand} expandMode={props.expandMode} diff --git a/src/libs/ui/lib/components/table/table-row/TableRow.tsx b/src/libs/ui/lib/components/table/table-row/TableRow.tsx index d8a74870e..260afee63 100644 --- a/src/libs/ui/lib/components/table/table-row/TableRow.tsx +++ b/src/libs/ui/lib/components/table/table-row/TableRow.tsx @@ -78,6 +78,7 @@ export const TableRow: ( ( {props.showExpand && isExpanded && (
{expandColumns.map((col, colIndex) => { From 03b9b92b242c893f17ee8081f74fed19cbab25c6 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Mon, 18 May 2026 11:48:45 +0530 Subject: [PATCH 28/34] Change tab name to SFDC --- src/apps/reports/src/config/routes.config.ts | 2 +- src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apps/reports/src/config/routes.config.ts b/src/apps/reports/src/config/routes.config.ts index 914a95cd9..6545d0afc 100644 --- a/src/apps/reports/src/config/routes.config.ts +++ b/src/apps/reports/src/config/routes.config.ts @@ -10,4 +10,4 @@ export const rootRoute: string export const reportsPageRouteId = 'reports' export const bulkMemberLookupRouteId = 'bulk-member-lookup' -export const billingAccountsPageRouteId = 'billing-accounts' +export const billingAccountsPageRouteId = 'sfdc-payments' diff --git a/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx b/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx index 2a73aa4fe..3ee088f20 100644 --- a/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx +++ b/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx @@ -40,7 +40,7 @@ const NavTabs: FC = () => { }, { id: billingAccountsPageRouteId, - title: 'Billing Accounts', + title: 'SFDC Payments', }, ], []) From 6f1281ba8d89104ad557d0c0738c91d2ea9d8e67 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 18 May 2026 11:01:37 +0300 Subject: [PATCH 29/34] PM-5065 - restrict access for TM/PM --- .../work/src/lib/hooks/useFetchChallenges.ts | 4 + .../src/lib/models/ChallengeFilters.model.ts | 1 + .../src/lib/services/challenges.service.ts | 4 + .../work/src/lib/services/projects.service.ts | 1 + .../BudgetApprovalsPage.tsx | 79 ++++++++++++++----- 5 files changed, 70 insertions(+), 19 deletions(-) diff --git a/src/apps/work/src/lib/hooks/useFetchChallenges.ts b/src/apps/work/src/lib/hooks/useFetchChallenges.ts index 3f2114c45..afdc63506 100644 --- a/src/apps/work/src/lib/hooks/useFetchChallenges.ts +++ b/src/apps/work/src/lib/hooks/useFetchChallenges.ts @@ -57,6 +57,7 @@ export function useFetchChallenges( page = 1, perPage = PAGE_SIZE, projectId, + projectIds, sortBy = 'startDate', sortOrder = 'desc', startDateEnd, @@ -88,6 +89,7 @@ export function useFetchChallenges( memberId, name, projectId, + projectIds, sortBy, sortOrder, startDateEnd, @@ -109,6 +111,7 @@ export function useFetchChallenges( page, perPage, projectId, + projectIds, sortBy, sortOrder, startDateEnd, @@ -126,6 +129,7 @@ export function useFetchChallenges( String(memberId ?? ''), name || '', String(projectId ?? ''), + (projectIds ?? []).join(','), sortBy || '', sortOrder || '', startDateEnd || '', diff --git a/src/apps/work/src/lib/models/ChallengeFilters.model.ts b/src/apps/work/src/lib/models/ChallengeFilters.model.ts index f0a0ec432..46f017ca5 100644 --- a/src/apps/work/src/lib/models/ChallengeFilters.model.ts +++ b/src/apps/work/src/lib/models/ChallengeFilters.model.ts @@ -5,6 +5,7 @@ export interface ChallengeFilters { type?: string status?: string | string[] projectId?: number | string + projectIds?: (number | string)[] startDateStart?: string startDateEnd?: string endDateStart?: string diff --git a/src/apps/work/src/lib/services/challenges.service.ts b/src/apps/work/src/lib/services/challenges.service.ts index 03c3f4c38..f831ad9bf 100644 --- a/src/apps/work/src/lib/services/challenges.service.ts +++ b/src/apps/work/src/lib/services/challenges.service.ts @@ -328,6 +328,10 @@ function buildChallengeQuery( } }) + if (Array.isArray(filters.projectIds) && filters.projectIds.length > 0) { + filters.projectIds.forEach(id => query.append('projectIds[]', String(id))) + } + return query.toString() } diff --git a/src/apps/work/src/lib/services/projects.service.ts b/src/apps/work/src/lib/services/projects.service.ts index e039a019d..f3827ccf1 100644 --- a/src/apps/work/src/lib/services/projects.service.ts +++ b/src/apps/work/src/lib/services/projects.service.ts @@ -559,6 +559,7 @@ function normalizeProjectSummary(project: ProjectSummary): ProjectSummary { function buildProjectsUrl(page: number, memberOnly: boolean): string { const query = new URLSearchParams({ + fields: 'members', page: String(page), perPage: String(PROJECTS_PER_PAGE), sort: 'lastActivityAt desc', diff --git a/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.tsx b/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.tsx index 5c8baa917..ae17ccf68 100644 --- a/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.tsx +++ b/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.tsx @@ -15,6 +15,7 @@ import { CHALLENGE_APPROVAL_STATUS, CHALLENGE_STATUS, PAGE_SIZE, + PROJECT_ROLES, } from '../../../lib/constants' import { WorkAppContext } from '../../../lib/contexts' import { Pagination } from '../../../lib/components' @@ -79,23 +80,7 @@ export const BudgetApprovalsPage: FC = () => { const [page, setPage] = useState(1) const [perPage, setPerPage] = useState(PAGE_SIZE) - const memberId = isAdmin - ? undefined - : loginUserInfo?.userId - - const challengeFetchParams: UseFetchChallengesParams = { - approvalStatus: CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL, - enabled: isAdmin || memberId !== undefined, - name: normalizeSearchValue(challengeNameSearch) || undefined, - page, - perPage, - projectId: selectedProjectId || undefined, - sortBy: 'updated', - sortOrder: 'desc', - status: CHALLENGE_STATUS.DRAFT, - } - - const challengesResult: UseFetchChallengesResult = useFetchChallenges(challengeFetchParams) + const userId = loginUserInfo?.userId const projectsResult: UseFetchProjectsResult = useFetchProjects({ memberOnly: !isAdmin, @@ -116,14 +101,70 @@ export const BudgetApprovalsPage: FC = () => { return map }, [projectsResult.projects]) + /** + * For non-admins, restrict project options to projects where the user has + * "full write" access (manager or copilot project membership role). + * Admins see all projects. + */ + const accessibleProjects = useMemo( + () => { + if (isAdmin) { + return projectsResult.projects + } + + return projectsResult.projects.filter(project => { + const normalizedUserId = userId !== undefined ? String(userId) : undefined + + if (!normalizedUserId || !Array.isArray(project.members)) { + return false + } + + const memberRole = project.members + .find(m => m.userId !== undefined && String(m.userId) === normalizedUserId) + ?.role + ?.toLowerCase() + ?.trim() + + return memberRole === PROJECT_ROLES.MANAGER || memberRole === PROJECT_ROLES.COPILOT + }) + }, + [isAdmin, projectsResult.projects, userId], + ) + + const fullWriteProjectIds = useMemo( + () => accessibleProjects + .filter(project => !!project.id) + .map(project => String(project.id)), + [accessibleProjects], + ) + + // For non-admins: pass all full-write project IDs when no specific project is selected, + // or a single projectId when one is explicitly selected. + // Fetch is enabled once we know whether the user is admin or have loaded their projects. + const isProjectsReady = isAdmin || !projectsResult.isLoading + const challengeFetchParams: UseFetchChallengesParams = { + approvalStatus: CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL, + enabled: isProjectsReady, + name: normalizeSearchValue(challengeNameSearch) || undefined, + page, + perPage, + projectId: selectedProjectId || undefined, + projectIds: !isAdmin && !selectedProjectId ? fullWriteProjectIds : undefined, + sortBy: 'updated', + sortOrder: 'desc', + status: CHALLENGE_STATUS.DRAFT, + } + + const challengesResult: UseFetchChallengesResult = useFetchChallenges(challengeFetchParams) + const projectOptions = useMemo( - () => projectsResult.projects + () => accessibleProjects .filter(project => !!project.id) .map(project => ({ label: project.name || String(project.id), value: String(project.id), })), - [projectsResult.projects], + [accessibleProjects], ) const selectedProjectOption = useMemo( From 9a6d904141c98ccc521163e2b7e59a029f0a6100 Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Mon, 18 May 2026 14:09:47 +0530 Subject: [PATCH 30/34] Updated Copilot Text Editor --- .../copilots/src/pages/copilot-request-form/index.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/apps/copilots/src/pages/copilot-request-form/index.tsx b/src/apps/copilots/src/pages/copilot-request-form/index.tsx index 27ec407c2..0e71834f4 100644 --- a/src/apps/copilots/src/pages/copilot-request-form/index.tsx +++ b/src/apps/copilots/src/pages/copilot-request-form/index.tsx @@ -1,9 +1,11 @@ import { FC, useContext, useEffect, useMemo, useRef, useState } from 'react' import { bind, debounce, isEmpty, pick } from 'lodash' +import { mutate } from 'swr' import { toast } from 'react-toastify' import { Params, useNavigate, useParams, useSearchParams } from 'react-router-dom' import classNames from 'classnames' +import { EnvironmentConfig } from '~/config' import { profileContext, ProfileContextData } from '~/libs/core' import { Button, IconSolid, InputDatePicker, InputMultiselectOption, InputRadio, InputSelect, InputSelectReact, InputText } from '~/libs/ui' @@ -35,12 +37,13 @@ const editableFields = [ 'tzRestrictions', 'numHoursPerWeek', ] - // eslint-disable-next-line const CopilotRequestForm: FC<{}> = () => { const { profile }: ProfileContextData = useContext(profileContext) const navigate = useNavigate() const routeParams: Params = useParams() + const requestUrl = routeParams.requestId + ? `${EnvironmentConfig.API.V6}/projects/copilots/requests/${routeParams.requestId}` : undefined const [params] = useSearchParams() const [formValues, setFormValues] = useState({}) @@ -385,6 +388,10 @@ const CopilotRequestForm: FC<{}> = () => { copilotRequestData ? 'Copilot request updated successfully' : 'Copilot request sent successfully', ) + if (requestUrl) { + mutate(requestUrl) + } + setFormValues({ complexity: '', numHoursPerWeek: '', @@ -431,7 +438,7 @@ const CopilotRequestForm: FC<{}> = () => { ) useEffect(() => { overviewInitialized.current = false - }, [editorKey]) + }, [copilotRequestData]) return (
From 28b6a8190207f56d3d4a4362f2703833eae3c8cc Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Mon, 18 May 2026 17:17:06 +0530 Subject: [PATCH 31/34] Revert "Add categories filter" This reverts commit afeb522483d38ac9a94b7a526c52cdf2ba8fd801. --- .../reports/src/pages/reports/ReportsPage.tsx | 66 +-- .../home/tabs/payments/Payments.module.scss | 29 - .../tabs/payments/PaymentsListView.spec.tsx | 58 +- .../home/tabs/payments/PaymentsListView.tsx | 494 +++++++----------- .../filter-bar/FilterBar.module.scss | 44 +- .../lib/components/filter-bar/FilterBar.tsx | 207 ++------ .../wallet-admin/src/lib/services/wallet.ts | 20 +- .../home/tabs/winnings/PaymentsListView.tsx | 4 +- 8 files changed, 256 insertions(+), 666 deletions(-) diff --git a/src/apps/reports/src/pages/reports/ReportsPage.tsx b/src/apps/reports/src/pages/reports/ReportsPage.tsx index 2011aafb8..77542c0a2 100644 --- a/src/apps/reports/src/pages/reports/ReportsPage.tsx +++ b/src/apps/reports/src/pages/reports/ReportsPage.tsx @@ -75,55 +75,12 @@ const BILLING_ACCOUNTS_REPORT_DEFINITION: ReportDefinition = { name: 'endDate', type: 'date', }, - { - description: 'Optional payment category group', - location: 'query', - name: 'paymentCategory', - options: ['TAAS_PAYMENT', 'TOPGEAR_PAYMENT', 'POINTS_AWARD', 'TOPCODER'], - type: 'enum', - }, ], path: BILLING_ACCOUNTS_REPORT_PATH, } type ReportsPageTab = 'reports' | 'billingAccounts' -/** API category values excluded from the Topcoder filter group. */ -const BILLING_TOPCODER_EXCLUDED_CATEGORIES = [ - 'TAAS_PAYMENT', - 'TOPGEAR_PAYMENT', - 'POINTS_AWARD', -] as const - -/** UI-only value for payments whose category is not TaaS, Topgear, or Points. */ -const BILLING_TOPCODER_CATEGORY_FILTER = 'TOPCODER' - -const BILLING_PAYMENT_CATEGORY_OPTIONS: InputSelectOption[] = [ - { label: 'All categories', value: '' }, - { label: 'TaaS', value: 'TAAS_PAYMENT' }, - { label: 'Topgear', value: 'TOPGEAR_PAYMENT' }, - { label: 'Points', value: 'POINTS_AWARD' }, - { label: 'Topcoder', value: BILLING_TOPCODER_CATEGORY_FILTER }, -] - -const filterBillingPaymentsByCategory = ( - payments: SfdcBillingAccountPaymentRow[], - paymentCategory?: string, -): SfdcBillingAccountPaymentRow[] => { - const filter = paymentCategory?.trim() - - if (!filter) { - return payments - } - - if (filter === BILLING_TOPCODER_CATEGORY_FILTER) { - const excludedCategories = new Set(BILLING_TOPCODER_EXCLUDED_CATEGORIES) - return payments.filter(row => !excludedCategories.has(row.category)) - } - - return payments.filter(row => row.category === filter) -} - const buildSfdcPaymentsQueryPath = ( billingAccountId: string | undefined, startDate?: string, @@ -860,9 +817,7 @@ const ReportsPageContent: FC = props => { params.endDate, ) const payments = await fetchReportJson(paymentsPath) - setBillingAccountViewData({ - payments: filterBillingPaymentsByCategory(payments, params.paymentCategory), - }) + setBillingAccountViewData({ payments }) } catch (error) { handleError(error) } finally { @@ -1023,23 +978,10 @@ const ReportsPageContent: FC = props => { : (parameter.type.endsWith('[]') ? 'Comma-separated values' : 'Enter value'), } - const isBillingForm = selectedReportForForm?.path === BILLING_ACCOUNTS_REPORT_PATH - - const isBillingDateField = isBillingForm + const isBillingDateField = selectedReportForForm?.path === BILLING_ACCOUNTS_REPORT_PATH && parameter.type === 'date' && (parameter.name === 'startDate' || parameter.name === 'endDate') - if (isBillingForm && parameter.name === 'paymentCategory') { - return ( - - ) - } - if (isBillingDateField) { return ( = props => { : ( <> {'Payments load for all billing accounts by default. Optionally narrow by billing ' - + 'account ID, dates, or category, then click View. Open a billing account profile ' - + 'from the Billing account ID column in the table. '} + + 'account ID and dates, then click View. Open a billing account profile from ' + + 'the Billing account ID column in the table. '} If no dates are specified, records from the past 45 days are displayed by default. diff --git a/src/apps/wallet-admin/src/home/tabs/payments/Payments.module.scss b/src/apps/wallet-admin/src/home/tabs/payments/Payments.module.scss index 59b259e1c..25d4e4412 100644 --- a/src/apps/wallet-admin/src/home/tabs/payments/Payments.module.scss +++ b/src/apps/wallet-admin/src/home/tabs/payments/Payments.module.scss @@ -62,35 +62,6 @@ color: white; } - .paymentListingTabs { - display: flex; - align-items: flex-end; - gap: $sp-6; - margin-bottom: $sp-4; - border-bottom: 1px solid $black-20; - } - - .paymentListingTab { - border: none; - background: transparent; - padding: $sp-2 0; - margin-bottom: -1px; - cursor: pointer; - color: $black-60; - font-weight: 600; - border-bottom: 2px solid transparent; - - &:focus-visible { - outline: 2px solid $turq-160; - outline-offset: 2px; - } - } - - .paymentListingTabActive { - color: $turq-160; - border-bottom-color: $turq-160; - } - .centered { height: 200px; display: flex; diff --git a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.spec.tsx b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.spec.tsx index 3740c748c..b73f60a1d 100644 --- a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.spec.tsx +++ b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.spec.tsx @@ -209,14 +209,6 @@ const paymentsResponse = { ], } -const TOPCODER_TAB_CATEGORIES = [ - 'TASK_PAYMENT', - 'CONTEST_PAYMENT', - 'COPILOT_PAYMENT', - 'REVIEW_BOARD_PAYMENT', - 'ENGAGEMENT_PAYMENT', -] - describe('PaymentsListView', () => { beforeEach(() => { mockFilterBar.mockClear() @@ -248,9 +240,7 @@ describe('PaymentsListView', () => { .toHaveBeenCalled() expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides) .toEqual(expect.objectContaining({ - category: ['TASK_PAYMENT', 'ENGAGEMENT_PAYMENT'], - date: 'last30days', - status: ['ON_HOLD_ADMIN'], + status: 'ON_HOLD_ADMIN', })) }) @@ -283,9 +273,7 @@ describe('PaymentsListView', () => { await screen.findByText('Member earnings will appear here.') expect(mockedGetPayments) - .toHaveBeenLastCalledWith(10, 0, { - categories: TOPCODER_TAB_CATEGORIES, - }) + .toHaveBeenLastCalledWith(10, 0, {}) fireEvent.click(screen.getByRole('button', { name: 'Approver View' })) @@ -300,9 +288,7 @@ describe('PaymentsListView', () => { expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides) .toEqual(expect.objectContaining({ - category: ['TASK_PAYMENT', 'ENGAGEMENT_PAYMENT'], - date: 'last30days', - status: ['ON_HOLD_ADMIN'], + status: 'ON_HOLD_ADMIN', })) }) @@ -316,13 +302,13 @@ describe('PaymentsListView', () => { await screen.findByText('Member earnings will appear here.') expect(mockedGetPayments) - .toHaveBeenLastCalledWith(10, 0, { - categories: TOPCODER_TAB_CATEGORIES, - }) + .toHaveBeenLastCalledWith(10, 0, {}) expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides) - .toEqual(expect.objectContaining({ - category: TOPCODER_TAB_CATEGORIES, - })) + .toEqual({ + category: 'all', + date: 'all', + status: 'all', + }) }) it('lets an explicit status filter override the default approver status', async () => { @@ -349,9 +335,7 @@ describe('PaymentsListView', () => { expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides) .toEqual(expect.objectContaining({ - category: ['TASK_PAYMENT', 'ENGAGEMENT_PAYMENT'], - date: 'last30days', - status: ['PAID'], + status: 'PAID', })) }) @@ -379,7 +363,7 @@ describe('PaymentsListView', () => { expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides) .toEqual(expect.objectContaining({ category: 'TAAS_PAYMENT', - status: ['PAID'], + status: 'PAID', })) }) @@ -447,7 +431,7 @@ describe('PaymentsListView', () => { }) }) - it('scopes the type filter to topcoder categories and lists Topgear winnings on its own tab', async () => { + it('includes the topgear payment type in the category filter options', async () => { render( { const filterProps = mockFilterBar.mock.calls.at(-1)?.[0] const typeFilter = filterProps.filters.find((filter: any) => filter.key === 'category') - expect(typeFilter.options.some((option: any) => option.value === 'TOPGEAR_PAYMENT')) - .toBe(false) - - expect(screen.getByRole('tab', { name: 'Topgear' })) - .toBeTruthy() - - fireEvent.click(screen.getByRole('tab', { name: 'Topgear' })) - - await waitFor(() => { - expect(mockedGetPayments) - .toHaveBeenLastCalledWith(10, 0, { - category: ['TOPGEAR_PAYMENT'], - }) - }) + expect(typeFilter.options.some((option: any) => ( + option.value === 'TOPGEAR_PAYMENT' && option.label === 'Topgear Payment' + ))) + .toBe(true) }) }) diff --git a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx index c8677c243..ece7f1f47 100644 --- a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx +++ b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx @@ -31,35 +31,6 @@ const topgearPaymentCategory = 'TOPGEAR_PAYMENT' const defaultPageSize = 10 const approverDefaultDateFilter = 'last30days' -type PaymentListingTab = 'topcoder' | 'topgear' | 'taas' - -const TOPCODER_PAYMENT_CATEGORIES: ReadonlyArray = [ - 'TASK_PAYMENT', - 'CONTEST_PAYMENT', - 'COPILOT_PAYMENT', - 'REVIEW_BOARD_PAYMENT', - 'ENGAGEMENT_PAYMENT', -] - -const STATUS_FILTER_OPTIONS: { label: string, value: string }[] = [ - { label: 'Owed', value: 'OWED' }, - { label: 'On Hold (Admin)', value: 'ON_HOLD_ADMIN' }, - { label: 'On Hold (Member)', value: 'ON_HOLD' }, - { label: 'Paid', value: 'PAID' }, - { label: 'Cancelled', value: 'CANCELLED' }, - { label: 'Processing', value: 'PROCESSING' }, - { label: 'Failed', value: 'FAILED' }, - { label: 'Returned', value: 'RETURNED' }, -] - -const TOPCODER_TYPE_FILTER_OPTIONS: { label: string, value: string }[] = [ - { label: 'Task Payment', value: 'TASK_PAYMENT' }, - { label: 'Contest Payment', value: 'CONTEST_PAYMENT' }, - { label: 'Copilot Payment', value: 'COPILOT_PAYMENT' }, - { label: 'Review Board Payment', value: 'REVIEW_BOARD_PAYMENT' }, - { label: 'Engagement Payment', value: 'ENGAGEMENT_PAYMENT' }, -] - interface PaymentsListViewProps { profile: UserProfile isCollapsed?: boolean @@ -242,28 +213,20 @@ const PaymentsListView: FC = (props: PaymentsListViewProp const restrictedDefaultStatus = isApproverView ? restrictedRoleDefaultStatus : undefined const isRestrictedApproverView = isApproverView const [filters, setFilters] = React.useState>({}) - const [paymentListingTab, setPaymentListingTab] = React.useState('topcoder') - - const showPaymentListingTabs = !isApproverView - && !(restrictedCategory && !hasPaymentAdminRole) - - React.useEffect(() => { - if (restrictedCategory && !hasPaymentAdminRole) { - setPaymentListingTab('taas') - } - }, [restrictedCategory, hasPaymentAdminRole]) // eslint-disable-next-line complexity const appliedFilters = React.useMemo>(() => { + // Strip 'all' sentinel values — never forward them to the API const activeFilters = Object.fromEntries( Object.entries(filters) - .filter(([, v]) => v.length > 0 && !(v.length === 1 && v[0] === 'all')), + .filter(([, v]) => v.length > 0 && v[0] !== 'all'), ) if (restrictedCategory) { + // WiproTaasAdmin scoped to a single category let statusFilter: Record = {} - if (filters.status?.length) { - statusFilter = { status: filters.status } + if (filters.status && filters.status[0] !== 'all') { + statusFilter = { status: activeFilters.status } } return { @@ -274,166 +237,85 @@ const PaymentsListView: FC = (props: PaymentsListViewProp } if (isApproverView) { + // Payment Approver: restrict to allowed categories, default status ON_HOLD_ADMIN let statusFilter: Record = {} - if (filters.status?.length) { - statusFilter = { status: filters.status } - } else if (restrictedDefaultStatus) { + if (filters.status && filters.status[0] !== 'all') { + statusFilter = { status: activeFilters.status } + } else if (!filters.status && restrictedDefaultStatus) { statusFilter = { status: [restrictedDefaultStatus] } } let dateFilter: Record = {} - if (filters.date?.length && filters.date[0] !== 'all') { + if (filters.date && filters.date[0] !== 'all') { dateFilter = { date: activeFilters.date } - } else if (!filters.date?.length) { + } else if (!filters.date) { dateFilter = { date: [approverDefaultDateFilter] } } - const pickedApproverTypes = (filters.category ?? []) - .filter(c => approverAllowedCategories.includes(c)) - const categories = pickedApproverTypes.length > 0 - ? pickedApproverTypes - : [...approverAllowedCategories] + let categoryFilter: Record = {} + if ( + activeFilters.category + && approverAllowedCategories.includes(activeFilters.category[0]) + ) { + categoryFilter = { category: activeFilters.category } + } else if (!filters.category || filters.category[0] === 'all') { + categoryFilter = { categories: ([] as string[]).concat(approverAllowedCategories) } + } const rest = { ...activeFilters } delete rest.category - delete rest.date - delete rest.status return { ...rest, - categories, + ...categoryFilter, ...statusFilter, ...dateFilter, } } - const base = { ...activeFilters } - delete base.category - - const allStatusesCount = STATUS_FILTER_OPTIONS.length - const statusSelection = filters.status ?? [] - const statusIsFullSelection = statusSelection.length === 0 - || statusSelection.length === allStatusesCount - - if (statusIsFullSelection) { - delete base.status - } - - if (paymentListingTab === 'topgear') { - return { - ...base, - category: [topgearPaymentCategory], - } - } - - if (paymentListingTab === 'taas') { - return { - ...base, - category: [taasPaymentCategory], - } - } - - const pickedTopcoderTypes = (filters.category ?? []) - .filter(c => TOPCODER_PAYMENT_CATEGORIES.includes(c)) - const categories = pickedTopcoderTypes.length > 0 - ? pickedTopcoderTypes - : [...TOPCODER_PAYMENT_CATEGORIES] - - return { - ...base, - categories, - } - }, [ - filters, - restrictedCategory, - restrictedDefaultStatus, - isApproverView, - paymentListingTab, - ]) + return activeFilters + }, [filters, restrictedCategory, restrictedDefaultStatus, isApproverView]) const hasActiveFilters = React.useMemo( () => Object.entries(appliedFilters) .some(([key, value]) => key !== 'category' && key !== 'categories' && value.length > 0), [appliedFilters], ) - const selectedValueOverrides = React.useMemo>(() => { + const selectedValueOverrides = React.useMemo>(() => { if (restrictedCategory) { + const statusOverride = filters.status?.[0] !== 'all' ? filters.status?.[0] : undefined + return { category: restrictedCategory, - ...(filters.status?.length ? { status: filters.status } : {}), + ...(statusOverride ? { status: statusOverride } : {}), } } if (isApproverView) { + const statusOverride = filters.status?.[0] !== 'all' ? filters.status?.[0] : undefined + return { - ...(filters.status?.length ? { status: filters.status } : { status: [restrictedDefaultStatus ?? 'ON_HOLD_ADMIN'] }), - ...(filters.category?.length - ? { category: filters.category } - : { category: [...approverAllowedCategories] }), + ...(statusOverride ? { status: statusOverride } : {}), } } - const overrides: Record = {} + return {} as Record + }, [filters.status, restrictedCategory, isApproverView]) - if (filters.status?.length) { - overrides.status = filters.status - } + const defaultDropdownValues = React.useMemo>(() => { + const defaults: Record = {} - if (filters.category?.length) { - overrides.category = filters.category + if (!restrictedCategory) { + defaults.category = filters.category?.[0] ?? 'all' } - if (filters.dateFrom?.[0]) { - overrides.dateFrom = filters.dateFrom[0] - } + defaults.date = filters.date?.[0] ?? (isApproverView ? approverDefaultDateFilter : 'all') - if (filters.dateTo?.[0]) { - overrides.dateTo = filters.dateTo[0] - } - - return overrides - }, [ - filters.status, - filters.category, - filters.dateFrom, - filters.dateTo, - restrictedCategory, - isApproverView, - restrictedDefaultStatus, - ]) - - const defaultDropdownValues = React.useMemo((): Record => { - if (restrictedCategory) { - return {} - } + // Fall back to the restricted default if no filter is applied + defaults.status = filters.status?.[0] ?? (restrictedDefaultStatus || 'all') - if (isApproverView) { - const approverDefaults: Record = { - category: filters.category?.length - ? filters.category - : [...approverAllowedCategories], - date: filters.date?.[0] ?? approverDefaultDateFilter, - status: filters.status?.length - ? filters.status - : [restrictedDefaultStatus ?? 'ON_HOLD_ADMIN'], - } - return approverDefaults - } - - const topcoderDefaults: Record = { - category: filters.category?.length - ? filters.category - : [...TOPCODER_PAYMENT_CATEGORIES], - } - return topcoderDefaults - }, [ - filters.category, - filters.date, - filters.status, - restrictedCategory, - restrictedDefaultStatus, - isApproverView, - ]) + return defaults + }, [filters.category, filters.date, filters.status, restrictedCategory, restrictedDefaultStatus, isApproverView]) const [pagination, setPagination] = React.useState({ currentPage: 1, pageSize: defaultPageSize, @@ -620,7 +502,6 @@ const PaymentsListView: FC = (props: PaymentsListViewProp } setPaymentRoleView(nextView) - setPaymentListingTab('topcoder') setBulkAuditNote('') setSelectedPaymentAction(undefined) setSelectedPayments({}) @@ -636,23 +517,6 @@ const PaymentsListView: FC = (props: PaymentsListViewProp }) }, [paymentRoleView]) - const onPaymentListingTabChange = useCallback((tab: PaymentListingTab) => { - setPaymentListingTab(tab) - setBulkAuditNote('') - setSelectedPaymentAction(undefined) - setSelectedPayments({}) - setPagination(prev => ({ - ...prev, - currentPage: 1, - })) - setFilters(prev => { - const nextFilters = { ...prev } - delete nextFilters.category - - return nextFilters - }) - }, []) - /** * Applies the selected approver action to the current payment selection. * @@ -714,112 +578,6 @@ const PaymentsListView: FC = (props: PaymentsListViewProp await fetchWinnings() }, [selectedPayments, fetchWinnings]) - const listingFilters = React.useMemo((): Filter[] => { - const pageSizeFilter: Filter = { - key: 'pageSize', - label: 'Payments per page', - options: [ - { label: '10', value: '10' }, - { label: '50', value: '50' }, - { label: '100', value: '100' }, - ], - type: 'dropdown', - } - - const handleFilter: Filter = { - key: 'winnerIds', - label: 'Username/Handle', - type: 'member_autocomplete', - } - - const statusFilter: Filter = { - key: 'status', - label: 'Status', - options: STATUS_FILTER_OPTIONS, - type: 'multi_dropdown', - } - - if (isWiproTaasAdmin && !hasPaymentAdminRole) { - return [ - handleFilter, - statusFilter, - { - key: 'dateFrom', - label: 'Date from', - type: 'date', - }, - { - key: 'dateTo', - label: 'Date to', - type: 'date', - }, - pageSizeFilter, - ] - } - - if (isApproverView) { - return [ - handleFilter, - statusFilter, - { - key: 'category', - label: 'Payment Type', - options: [ - { label: 'Task Payments', value: taskPaymentCategory }, - { label: 'Engagement Payments', value: engagementPaymentCategory }, - ], - type: 'multi_dropdown', - }, - { - key: 'date', - label: 'Date', - options: [ - { label: 'Last 7 days', value: 'last7days' }, - { label: 'Last 30 days', value: 'last30days' }, - { label: 'All', value: 'all' }, - ], - type: 'dropdown', - }, - pageSizeFilter, - ] - } - - const filtersOut: Filter[] = [ - handleFilter, - statusFilter, - ] - - if (paymentListingTab === 'topcoder') { - filtersOut.push({ - key: 'category', - label: 'Type', - options: TOPCODER_TYPE_FILTER_OPTIONS, - type: 'multi_dropdown', - }) - } - - filtersOut.push( - { - key: 'dateFrom', - label: 'Date from', - type: 'date', - }, - { - key: 'dateTo', - label: 'Date to', - type: 'date', - }, - pageSizeFilter, - ) - - return filtersOut - }, [ - hasPaymentAdminRole, - isApproverView, - isWiproTaasAdmin, - paymentListingTab, - ]) - const selectedPaymentActions = selectedPaymentsCount > 0 ? [ { @@ -870,26 +628,6 @@ const PaymentsListView: FC = (props: PaymentsListViewProp
)} - {showPaymentListingTabs && ( -
- {([ - { id: 'topcoder' as const, label: 'Topcoder' }, - { id: 'topgear' as const, label: 'Topgear' }, - { id: 'taas' as const, label: 'TaaS' }, - ]).map(tab => ( - - ))} -
- )} = (props: PaymentsListViewProp toast.success('Download complete', { position: toast.POSITION.BOTTOM_RIGHT }) }} selectedValueOverrides={{ ...defaultDropdownValues, ...selectedValueOverrides }} - filters={listingFilters} + filters={[ + { + key: 'winnerIds', + label: 'Username/Handle', + type: 'member_autocomplete', + }, + { + key: 'status', + label: 'Status', + options: [ + { + label: 'All', + value: 'all', + }, + { + label: 'Owed', + value: 'OWED', + }, + { + label: 'On Hold (Admin)', + value: 'ON_HOLD_ADMIN', + }, + { + label: 'On Hold (Member)', + value: 'ON_HOLD', + }, + { + label: 'Paid', + value: 'PAID', + }, + { + label: 'Cancelled', + value: 'CANCELLED', + }, + { + label: 'Processing', + value: 'PROCESSING', + }, + { + label: 'Failed', + value: 'FAILED', + }, + { + label: 'Returned', + value: 'RETURNED', + }, + ], + type: 'dropdown', + }, + ...(isWiproTaasAdmin && !hasPaymentAdminRole ? [] : isApproverView ? [ + { + key: 'category', + label: 'Payment Type', + options: [ + { + label: 'All', + value: 'all', + }, + { + label: 'Task Payments', + value: taskPaymentCategory, + }, + { + label: 'Engagement Payments', + value: engagementPaymentCategory, + }, + ], + type: 'dropdown', + }, + ] as Filter[] : [ + { + key: 'category', + label: 'Type', + options: [ + { + label: 'All', + value: 'all', + }, + { + label: 'Task Payment', + value: 'TASK_PAYMENT', + }, + { + label: 'Contest Payment', + value: 'CONTEST_PAYMENT', + }, + { + label: 'Copilot Payment', + value: 'COPILOT_PAYMENT', + }, + { + label: 'Review Board Payment', + value: 'REVIEW_BOARD_PAYMENT', + }, + { + label: 'Engagement Payment', + value: 'ENGAGEMENT_PAYMENT', + }, + { + label: 'TaaS Payment', + value: 'TAAS_PAYMENT', + }, + { + label: 'Topgear Payment', + value: topgearPaymentCategory, + }, + ], + type: 'dropdown', + }, + ] as Filter[]), + { + key: 'date', + label: 'Date', + options: [ + { + label: 'Last 7 days', + value: 'last7days', + }, + { + label: 'Last 30 days', + value: 'last30days', + }, + { + label: 'All', + value: 'all', + }, + ], + type: 'dropdown', + }, + { + key: 'pageSize', + label: 'Payments per page', + options: [ + { + label: '10', + value: '10', + }, + { + label: '50', + value: '50', + }, + { + label: '100', + value: '100', + }, + ], + type: 'dropdown', + }, + ]} onFilterChange={(key: string, value: string[]) => { const newPagination = { ...pagination, diff --git a/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.module.scss b/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.module.scss index a452da020..c836fe226 100644 --- a/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.module.scss +++ b/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.module.scss @@ -37,49 +37,7 @@ .filter { width: 165px; max-width: 100%; - min-height: 47px; - } - - .multiSelectWrap { - width: 200px; - max-width: 100%; - display: flex; - flex-direction: column; - gap: 2px; - min-height: 47px; - } - - .multiSelectLabel { - line-height: 1.2; - } - - .dateFilterWrap { - width: 175px; - max-width: 100%; - min-height: 47px; - - :global(.container) { - margin-bottom: 0; - } - } - - :global(.wallet-admin-ms__control) { - min-height: 47px; - border-radius: 4px; - border: 1px solid #aaa7a7; - box-shadow: none; - } - - :global(.wallet-admin-ms__value-container) { - padding: 2px 8px; - } - - :global(.wallet-admin-ms__placeholder) { - color: #6b6b6b; - } - - :global(.wallet-admin-ms__menu-portal) { - z-index: 20; + height: 47px; } .firstFilterElement { diff --git a/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.tsx b/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.tsx index ec298d5b2..c575a8d6a 100644 --- a/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.tsx +++ b/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.tsx @@ -1,9 +1,7 @@ /* eslint-disable react/jsx-no-bind */ -import React, { ChangeEvent, useEffect, useRef } from 'react' -import Select, { MultiValue } from 'react-select' -import classNames from 'classnames' +import React, { ChangeEvent, useRef } from 'react' -import { Button, IconOutline, InputDatePicker, InputSelect, InputText } from '~/libs/ui' +import { Button, IconOutline, InputSelect, InputText } from '~/libs/ui' import { InputHandleAutocomplete, MembersAutocompeteResult, @@ -16,12 +14,10 @@ type FilterOptions = { value: string; }; -const SELECT_ALL_SENTINEL = '__select_all__' - export type Filter = { key: string; label: string; - type: 'input' | 'dropdown' | 'member_autocomplete' | 'multi_dropdown' | 'date'; + type: 'input' | 'dropdown' | 'member_autocomplete'; options?: FilterOptions[]; }; @@ -48,38 +44,10 @@ interface FilterBarProps { selectedCount?: number; onBulkClick?: () => void; selectionActions?: FilterBarSelectionAction[]; - selectedValueOverrides?: Record; + selectedValueOverrides?: Record; hasActiveFilters?: boolean; } -function parseIsoDateOnly(value: string | undefined): Date | undefined { - if (!value) { - return undefined - } - - const [y, m, d] = value.split('-') - .map(part => parseInt(part, 10)) - if (!y || !m || !d) { - return undefined - } - - return new Date(y, m - 1, d) -} - -function formatIsoDateOnly(date: Date | null): string[] { - if (!date) { - return [] - } - - const y = date.getFullYear() - const mo = String(date.getMonth() + 1) - .padStart(2, '0') - const day = String(date.getDate()) - .padStart(2, '0') - - return [`${y}-${mo}-${day}`] -} - const FilterBar: React.FC = (props: FilterBarProps) => { const [selectedValue, setSelectedValue] = React.useState>(new Map()) const selectedMembers = useRef([]) @@ -94,33 +62,11 @@ const FilterBar: React.FC = (props: FilterBarProps) => { }] : []) - useEffect(() => { - props.filters.forEach(filter => { - if (filter.type !== 'multi_dropdown') { - return - } - - const override = props.selectedValueOverrides?.[filter.key] - const next = Array.isArray(override) - ? override - : (override ? [override] : []) - setSelectedValue(prev => { - const cur = (prev.get(filter.key) as string[] | undefined) ?? [] - if (cur.length === next.length && cur.every((v, i) => v === next[i])) { - return prev - } - - return new Map(prev.set(filter.key, next)) - }) - }) - }, [props.filters, props.selectedValueOverrides]) - const renderDropdown = (index: number, filter: Filter): JSX.Element => ( ) => { @@ -134,74 +80,6 @@ const FilterBar: React.FC = (props: FilterBarProps) => { /> ) - const renderMultiDropdown = (index: number, filter: Filter): JSX.Element => { - const baseOptions = filter.options ?? [] - const menuOptions = [ - { label: 'Select All', value: SELECT_ALL_SENTINEL }, - ...baseOptions, - ] - const override = props.selectedValueOverrides?.[filter.key] - const valuesFromParent: string[] = Array.isArray(override) - ? override as string[] - : (override ? [override as string] : (selectedValue.get(filter.key) as string[] | undefined) ?? []) - const selectedOptions = baseOptions.filter(o => valuesFromParent.includes(o.value)) - - return ( -
- - {filter.label} - -