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/copilots/src/pages/copilot-opportunity-details/styles.module.scss b/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss index 1e82fa9ca..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 @@ -15,11 +15,17 @@ margin-top: $sp-2; padding: $sp-6 0; color: $teal-100; + text-align: left; + 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 +} + 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 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..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,13 +1,15 @@ -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 { 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, InputTextarea } from '~/libs/ui' -import { extractSkillsFromText, InputSkillSelector } from '~/libs/shared' + InputRadio, InputSelect, InputSelectReact, InputText } from '~/libs/ui' +import { extractSkillsFromText, FieldHtmlEditor, InputSkillSelector } from '~/libs/shared' import { getProject, getProjects, ProjectsResponse, useProjects } from '../../services/projects' import { ProjectTypes, ProjectTypeValues } from '../../constants' @@ -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({}) @@ -194,6 +197,18 @@ 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 } + 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 => ({ @@ -265,8 +280,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?.trim() || '' + const overview = stripHtml(formValues.overview || '') + .trim() return overview.length >= MIN_OVERVIEW_LENGTH && !isGeneratingSkills }, [formValues.overview, isGeneratingSkills]) @@ -289,7 +311,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.trim().length < 10, + condition: stripHtml(formValues.overview || '') + .trim().length < 10, key: 'overview', message: 'Project overview must be at least 10 characters', }, @@ -365,6 +388,10 @@ const CopilotRequestForm: FC<{}> = () => { copilotRequestData ? 'Copilot request updated successfully' : 'Copilot request sent successfully', ) + if (requestUrl) { + mutate(requestUrl) + } + setFormValues({ complexity: '', numHoursPerWeek: '', @@ -381,6 +408,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`) @@ -404,7 +432,13 @@ const CopilotRequestForm: FC<{}> = () => { handleProjectSearch(inputValue) .then(callback) }, 300), []) - + const editorKey = useMemo( + () => (copilotRequestData?.id ?? 'new'), + [copilotRequestData?.id], + ) + useEffect(() => { + overviewInitialized.current = false + }, [copilotRequestData]) return (
@@ -529,13 +563,14 @@ const CopilotRequestForm: FC<{}> = () => {

Please provide an overview of the project the copilot will undertake

- @@ -555,7 +590,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 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 diff --git a/src/apps/copilots/src/pages/copilot-requests/copilot-request-modal/CopilotRequestModal.tsx b/src/apps/copilots/src/pages/copilot-requests/copilot-request-modal/CopilotRequestModal.tsx index 202f61924..371c65bc4 100644 --- a/src/apps/copilots/src/pages/copilot-requests/copilot-request-modal/CopilotRequestModal.tsx +++ b/src/apps/copilots/src/pages/copilot-requests/copilot-request-modal/CopilotRequestModal.tsx @@ -114,7 +114,10 @@ const CopilotRequestModal: FC = props => {

Overview
-
{props.request.overview}
+
Skills
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/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', }, ], []) 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..2011aafb8 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', }, { @@ -61,19 +75,67 @@ 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, + 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 +147,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 +193,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 +375,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 +483,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 +561,6 @@ const buildParameterTooltipContent = (parameter: ReportParameter): JSX.Element = ) -const EMPTY_BILLING_ACCOUNT_PROFILE_RESPONSE: BillingAccountProfileResponse = { - billingAccount: undefined, -} - type ReportActionsProps = { handleCsvDownload: () => void handleJsonDownload: () => void @@ -610,67 +850,69 @@ 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]) - + const payments = await fetchReportJson(paymentsPath) setBillingAccountViewData({ - billingAccount: profile.billingAccount, - payments, + payments: filterBillingPaymentsByCategory(payments, params.paymentCategory), }) } 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 +929,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 +969,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 +1013,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 +1023,45 @@ 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 isBillingForm = selectedReportForForm?.path === BILLING_ACCOUNTS_REPORT_PATH + const isBillingDateField = isBillingForm + && parameter.type === 'date' + && (parameter.name === 'startDate' || parameter.name === 'endDate') + + if (isBillingForm && parameter.name === 'paymentCategory') { return ( ) } - if (parameter.type === 'enum') { - const options: InputSelectOption[] = (parameter.options ?? []).map(option => ({ - label: option, - value: option, - })) + if (isBillingDateField) { + return ( + + ) + } + + 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 +1110,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, dates, or category, 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/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 { 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/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/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), + ) +} 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/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx index 1725414f7..faa3114c1 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,78 @@ 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 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) + + 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 +535,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 +553,40 @@ 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')) + .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')) diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx index 9141da438..8849d0d81 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,174 @@ 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. + * 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, billingAccountDetails: BillingAccountDetails, -): number { + challengeDetailsById: ChallengeDetailsById | undefined, +): number | undefined { if (item.status === 'locked') { return item.amount } + if (item.memberPaymentAmount !== undefined) { + return item.memberPaymentAmount + } + + const challengeMarkup = getLineItemChallengeMarkup( + item, + billingAccountDetails, + challengeDetailsById, + ) + + if (challengeMarkup === undefined) { + return undefined + } + return calculateMemberPaymentAmount( item.amount, - billingAccountDetails.markup, - ) ?? item.amount + challengeMarkup, + ) +} + +/** + * 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) } /** @@ -273,9 +427,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 +439,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 +448,21 @@ function getLineItemMemberPaymentAmount( function getDisplayLineItem( item: BillingAccountLineItem, billingAccountDetails: BillingAccountDetails, + challengeDetailsById: ChallengeDetailsById | undefined, showChallengeFee: boolean, ): BillingAccountModalLineItem { const displayAmount = getLineItemMemberPaymentAmount( item, billingAccountDetails, + challengeDetailsById, ) const challengeFeeAmount = showChallengeFee - ? calculatePaymentChallengeFee(displayAmount, billingAccountDetails.markup) + ? getLineItemChallengeFeeAmount( + item, + displayAmount, + billingAccountDetails, + challengeDetailsById, + ) : undefined return { @@ -379,15 +542,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), 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..afdc63506 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, @@ -46,6 +57,7 @@ export function useFetchChallenges( page = 1, perPage = PAGE_SIZE, projectId, + projectIds, sortBy = 'startDate', sortOrder = 'desc', startDateEnd, @@ -71,11 +83,13 @@ export function useFetchChallenges( const requestParams = useMemo( () => ({ filters: { + approvalStatus, endDateEnd, endDateStart, memberId, name, projectId, + projectIds, sortBy, sortOrder, startDateEnd, @@ -89,6 +103,7 @@ export function useFetchChallenges( }, }), [ + approvalStatus, endDateEnd, endDateStart, memberId, @@ -96,6 +111,7 @@ export function useFetchChallenges( page, perPage, projectId, + projectIds, sortBy, sortOrder, startDateEnd, @@ -107,23 +123,24 @@ export function useFetchChallenges( const appendKey = useMemo( () => [ + toFilterKeyValue(approvalStatus), endDateEnd || '', endDateStart || '', String(memberId ?? ''), name || '', String(projectId ?? ''), + (projectIds ?? []).join(','), sortBy || '', 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..46f017ca5 100644 --- a/src/apps/work/src/lib/models/ChallengeFilters.model.ts +++ b/src/apps/work/src/lib/models/ChallengeFilters.model.ts @@ -1,9 +1,11 @@ export interface ChallengeFilters { + approvalStatus?: string | string[] memberId?: number | string name?: string 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 56ab56ad1..f831ad9bf 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 @@ -304,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.module.scss b/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.module.scss new file mode 100644 index 000000000..a8f218bd2 --- /dev/null +++ b/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.module.scss @@ -0,0 +1,96 @@ +@import '@libs/ui/styles/includes'; + +.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; +} + +.nameLink { + color: $link-blue-dark; + text-decoration: none; + + &:hover, + &:focus { + text-decoration: underline; + outline: none; + } +} + +.actionLink { + color: $link-blue-dark; + display: inline-flex; + + &:hover, + &:focus { + text-decoration: underline; + outline: none; + } +} + +.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..ae17ccf68 --- /dev/null +++ b/src/apps/work/src/pages/budget-approvals/BudgetApprovalsPage/BudgetApprovalsPage.tsx @@ -0,0 +1,308 @@ +import { + ChangeEvent, + FC, + useContext, + useMemo, + useState, +} from 'react' +import { Link } from 'react-router-dom' +import Select, { SingleValue } from 'react-select' + +import { PageWrapper } from '~/apps/review/src/lib' +import { IconOutline } from '~/libs/ui' + +import { + CHALLENGE_APPROVAL_STATUS, + CHALLENGE_STATUS, + PAGE_SIZE, + PROJECT_ROLES, +} from '../../../lib/constants' +import { WorkAppContext } from '../../../lib/contexts' +import { Pagination } from '../../../lib/components' +import { + useFetchChallenges, + UseFetchChallengesParams, + UseFetchChallengesResult, + useFetchProjects, + UseFetchProjectsResult, +} from '../../../lib/hooks' +import { + Challenge, + Project, + WorkAppContextModel, +} from '../../../lib/models' + +import styles from './BudgetApprovalsPage.module.scss' + +interface ProjectOption { + label: string + value: string +} + +function normalizeSearchValue(value: string): string { + return value + .trim() + .toLowerCase() +} + +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' +} + +// eslint-disable-next-line complexity +export const BudgetApprovalsPage: FC = () => { + const { + isAdmin, + loginUserInfo, + }: WorkAppContextModel = useContext(WorkAppContext) + + const [challengeNameSearch, setChallengeNameSearch] = useState('') + const [selectedProjectId, setSelectedProjectId] = useState('') + const [page, setPage] = useState(1) + const [perPage, setPerPage] = useState(PAGE_SIZE) + + const userId = loginUserInfo?.userId + + 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]) + + /** + * 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( + () => accessibleProjects + .filter(project => !!project.id) + .map(project => ({ + label: project.name || String(project.id), + value: String(project.id), + })), + [accessibleProjects], + ) + + const selectedProjectOption = useMemo( + () => projectOptions.find(opt => opt.value === selectedProjectId) ?? undefined, + [projectOptions, selectedProjectId], + ) + + function handleChallengeNameSearch(event: ChangeEvent): void { + setChallengeNameSearch(event.target.value) + setPage(1) + } + + function handleProjectSelect(option: SingleValue): void { + setSelectedProjectId(option?.value ?? '') + setPage(1) + } + + function handlePageChange(newPage: number): void { + setPage(newPage) + } + + function handlePerPageChange(newPerPage: number): void { + setPerPage(newPerPage) + setPage(1) + } + + const errorMessage = challengesResult.error?.message || projectsResult.error?.message + const totalPendingApprovals = challengesResult.metadata.total ?? 0 + const shouldShowPagination = !errorMessage && totalPendingApprovals > 0 + + return ( + + {errorMessage + ?
{errorMessage}
+ : undefined} + +
+
+ + +
+
+ +
+ {totalPendingApprovals} + {' '} + pending approvals +
+ +
+ + + + + + + + + {challengesResult.challenges.length === 0 + ? ( + + + + ) + : challengesResult.challenges.map(challenge => { + const projectId = getProjectId(challenge) + const projectName = getProjectName(projectMap, projectId) + const challengePath = buildChallengePath(challenge, projectId) + + return ( + + + + + + ) + })} + +
Project NameChallenge NameAction
+ {challengesResult.isLoading || (!isAdmin && projectsResult.isLoading) ? ( + 'Loading ...' + ) : ( + 'No pending budget approvals found.' + )} +
+ + {projectName} + + + {challenge.name} + + + + +
+ + {shouldShowPagination + ? ( + + ) + : undefined} + + ) +} + +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/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx index 600988685..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 @@ -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,6 +192,9 @@ jest.mock('~/libs/ui', () => ({ virtual: true, }) jest.mock('~/config', () => ({ + AppSubdomain: { + work: 'work', + }, EnvironmentConfig: { ADMIN: { DIRECT_URL: 'https://example.com/direct', @@ -206,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', @@ -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,72 @@ 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 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 aeb4d3a1c..94e3bfe28 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 @@ -1793,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 @@ -3564,7 +3574,7 @@ export const ChallengeEditorForm: FC = ( {getApprovalStatusText(normalizedApprovalStatus)}
- {isBudgetPending && ( + {isBudgetPending && !canApproveChallengeBudget && (
Kindly obtain Project Manager approval on the budget before launching the challenge 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.'} 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: , diff --git a/src/libs/shared/lib/components/field-html-editor/FieldHtmlEditor.tsx b/src/libs/shared/lib/components/field-html-editor/FieldHtmlEditor.tsx index 32004f1d4..9deaa6cf9 100644 --- a/src/libs/shared/lib/components/field-html-editor/FieldHtmlEditor.tsx +++ b/src/libs/shared/lib/components/field-html-editor/FieldHtmlEditor.tsx @@ -53,8 +53,8 @@ const FieldHtmlEditor: FC = ( 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} 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) => {