From 1d0cfb1558c1b39a331f1b93cfd2c6ba480a47d8 Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Fri, 10 Apr 2026 14:42:01 +0530 Subject: [PATCH 01/81] PM-4777: Fixed select All in status dropdown --- .../home/tabs/payments/PaymentsListView.tsx | 56 ++++++++++++++++--- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx index 7f1dd257d..f0b3005bd 100644 --- a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx +++ b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx @@ -192,8 +192,10 @@ const PaymentsListView: FC = (props: PaymentsListViewProp const restrictedDefaultStatus = isEngagementApproverView ? restrictedRoleDefaultStatus : undefined const isRestrictedApproverView = isEngagementApproverView const [filters, setFilters] = React.useState>({}) - const hasSelectedStatusFilter = (filters.status?.length ?? 0) > 0 - const appliedFilters = React.useMemo>(() => { + // const hasSelectedStatusFilter = (filters.status?.length ?? 0) > 0 + const hasSelectedStatusFilter = (filters.status?.length ?? 0) > 0 && filters.status?.[0] !== 'all' + + /* const appliedFilters = React.useMemo>(() => { if (!restrictedCategory) { return filters } @@ -205,13 +207,33 @@ const PaymentsListView: FC = (props: PaymentsListViewProp ? { status: filters.status } : (restrictedDefaultStatus ? { status: [restrictedDefaultStatus] } : {})), } + }, [filters, hasSelectedStatusFilter, restrictedCategory, restrictedDefaultStatus]) */ + const appliedFilters = React.useMemo>(() => { + // Strip 'all' sentinel values — never forward them to the API + const activeFilters = Object.fromEntries( + Object.entries(filters) + .filter(([, v]) => v.length > 0 && v[0] !== 'all'), + ) + + if (!restrictedCategory) { + return activeFilters + } + + return { + ...activeFilters, + category: [restrictedCategory], + ...(hasSelectedStatusFilter + ? { status: activeFilters.status } + : (restrictedDefaultStatus ? { status: [restrictedDefaultStatus] } : {})), + } }, [filters, hasSelectedStatusFilter, restrictedCategory, restrictedDefaultStatus]) + const hasActiveFilters = React.useMemo( () => Object.entries(appliedFilters) .some(([key, value]) => key !== 'category' && value.length > 0), [appliedFilters], ) - const selectedValueOverrides = React.useMemo>(() => { + /* const selectedValueOverrides = React.useMemo>(() => { if (!restrictedCategory) { return {} as Record } @@ -222,17 +244,32 @@ const PaymentsListView: FC = (props: PaymentsListViewProp category: restrictedCategory, ...(statusOverride ? { status: statusOverride } : {}), } - }, [filters.status, restrictedCategory, restrictedDefaultStatus]) + }, [filters.status, restrictedCategory, restrictedDefaultStatus]) */ + const selectedValueOverrides = React.useMemo>(() => { + if (!restrictedCategory) { + return {} as Record + } + + // Reflect the user's explicit status choice in the dropdown display. + // Do not inject restrictedDefaultStatus here — it applies to the API query + // via appliedFilters but must not override the dropdown's "All" default. + const statusOverride = filters.status?.[0] !== 'all' ? filters.status?.[0] : undefined + + return { + category: restrictedCategory, + ...(statusOverride ? { status: statusOverride } : {}), + } + }, [filters.status, restrictedCategory]) const defaultDropdownValues = React.useMemo>(() => { const defaults: Record = {} if (!restrictedCategory) { - defaults.status = filters.status?.[0] ?? 'all' defaults.category = filters.category?.[0] ?? 'all' } defaults.date = filters.date?.[0] ?? 'all' + defaults.status = filters.status?.[0] ?? 'all' // ← moved out return defaults }, [filters.category, filters.date, filters.status, restrictedCategory]) @@ -702,7 +739,7 @@ const PaymentsListView: FC = (props: PaymentsListViewProp ...filters, [key]: value, }) */ - setFilters(prev => { + /* setFilters(prev => { const newFilters = { ...prev } if (value[0] === 'all') { delete newFilters[key] @@ -711,7 +748,12 @@ const PaymentsListView: FC = (props: PaymentsListViewProp } return newFilters - }) + }) */ + + setFilters(prev => ({ + ...prev, + [key]: value, // store 'all' explicitly; appliedFilters strips it before the API call + })) setSelectedPayments({}) }} onResetFilters={() => { From f541db9d61157f5edd054bf3bccd4dcea6870c91 Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Fri, 10 Apr 2026 14:50:54 +0530 Subject: [PATCH 02/81] PM-4777: Fixed select All in status dropdown --- .../home/tabs/payments/PaymentsListView.tsx | 43 +------------------ 1 file changed, 1 insertion(+), 42 deletions(-) diff --git a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx index f0b3005bd..b45eefea8 100644 --- a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx +++ b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx @@ -192,22 +192,7 @@ const PaymentsListView: FC = (props: PaymentsListViewProp const restrictedDefaultStatus = isEngagementApproverView ? restrictedRoleDefaultStatus : undefined const isRestrictedApproverView = isEngagementApproverView const [filters, setFilters] = React.useState>({}) - // const hasSelectedStatusFilter = (filters.status?.length ?? 0) > 0 const hasSelectedStatusFilter = (filters.status?.length ?? 0) > 0 && filters.status?.[0] !== 'all' - - /* const appliedFilters = React.useMemo>(() => { - if (!restrictedCategory) { - return filters - } - - return { - ...filters, - category: [restrictedCategory], - ...(hasSelectedStatusFilter - ? { status: filters.status } - : (restrictedDefaultStatus ? { status: [restrictedDefaultStatus] } : {})), - } - }, [filters, hasSelectedStatusFilter, restrictedCategory, restrictedDefaultStatus]) */ const appliedFilters = React.useMemo>(() => { // Strip 'all' sentinel values — never forward them to the API const activeFilters = Object.fromEntries( @@ -233,18 +218,6 @@ const PaymentsListView: FC = (props: PaymentsListViewProp .some(([key, value]) => key !== 'category' && value.length > 0), [appliedFilters], ) - /* const selectedValueOverrides = React.useMemo>(() => { - if (!restrictedCategory) { - return {} as Record - } - - const statusOverride = filters.status?.[0] ?? restrictedDefaultStatus - - return { - category: restrictedCategory, - ...(statusOverride ? { status: statusOverride } : {}), - } - }, [filters.status, restrictedCategory, restrictedDefaultStatus]) */ const selectedValueOverrides = React.useMemo>(() => { if (!restrictedCategory) { return {} as Record @@ -735,21 +708,7 @@ const PaymentsListView: FC = (props: PaymentsListViewProp } setPagination(newPagination) - /* setFilters({ - ...filters, - [key]: value, - }) */ - /* setFilters(prev => { - const newFilters = { ...prev } - if (value[0] === 'all') { - delete newFilters[key] - } else { - newFilters[key] = value - } - - return newFilters - }) */ - + setFilters(prev => ({ ...prev, [key]: value, // store 'all' explicitly; appliedFilters strips it before the API call From 2e29025d2d3179753df5e58a15dcca1a42924452 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 10 Apr 2026 16:21:51 +0530 Subject: [PATCH 03/81] Linting fix --- .../wallet-admin/src/home/tabs/payments/PaymentsListView.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx index b45eefea8..fb1235d24 100644 --- a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx +++ b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx @@ -708,7 +708,6 @@ const PaymentsListView: FC = (props: PaymentsListViewProp } setPagination(newPagination) - setFilters(prev => ({ ...prev, [key]: value, // store 'all' explicitly; appliedFilters strips it before the API call From 2d097dea414cefafe41c0ec0d968caeb8edc3105 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 15 Apr 2026 10:41:52 +1000 Subject: [PATCH 04/81] PM-4840: rehydrate persisted reviewer assignments after draft saves What was broken The previous PM-4840 fix restored persisted copilot and reviewer assignments during refresh hydration, but saving an existing draft could still reset the editor from a sparse challenge payload that omitted resource-backed reviewer members. That left refreshed or reopened drafts showing blank reviewer assignments again even though the resources were already saved. Root cause (if identifiable) The post-save reset path rebuilt form state from the `patchChallenge` response and only reapplied single-member assignments from the in-memory hook state. When the hook cache did not already contain the saved reviewer resources, manual reviewer members were dropped from the form state until a later fetch happened. What was changed The draft save flow now reloads persisted resources and resource roles before resetting the form, then reapplies the shared copilot and manual-reviewer hydration logic to the saved draft state. This keeps saved reviewer assignments attached immediately after save and when the draft is reopened. Any added/updated tests Added ChallengeEditorForm coverage for saving a draft when the hook resource cache is empty but persisted reviewer assignments exist in freshly fetched resources. --- .../challenges/ChallengeEditorPage/README.md | 4 +- .../components/ChallengeEditorForm.spec.tsx | 90 +++++++++++++++++++ .../components/ChallengeEditorForm.tsx | 41 ++++++++- 3 files changed, 131 insertions(+), 4 deletions(-) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index 88c9876a3..e51e5447e 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -19,7 +19,7 @@ remains visible until a fetched challenge payload is available, so the create route can expand to the full editor immediately after the initial draft is created. - `components/*Field.tsx`: field-level components for each challenge section. -- `components/ReviewersField/*`: tabbed human/AI review configuration. Human reviewers stay on the challenge form, while AI reviewer configs load/save through the review API and sync saved AI workflows back into the challenge `reviewers` array. Existing AI configs are reloaded once per saved challenge even if the challenge payload is temporarily missing synced AI reviewer rows, while still avoiding empty-config lookups for unsaved challenges, ordinary parent rerenders in edit mode, and same-session re-fetches right after a config is intentionally removed. Removing an AI config also detaches the synced AI workflow reviewers from the challenge. In read-only view mode the tab switcher remains clickable so users can inspect AI config details inside the disabled challenge form, and the review summary surfaces the human-review table, AI workflow details, resolved scorecard names, review flow, and estimated reviewer cost without requiring edits. Repeated human-review rows that share the same resource role now consume persisted challenge-resource assignments in row order so every assigned reviewer still appears once in the summary, and mixed legacy resource layouts continue into the generic `Reviewer` fallback pool when a phase-specific role runs out of persisted assignments. The editor hydration, editable tab, and summary now tolerate persisted resource rows that only expose role names, member handles, or member ids instead of the full modern payload shape, so refreshed drafts reopen with the saved reviewer assignments intact. The AI-gating failure path keeps the locked state grouped under the gate so the diagram matches the legacy work-manager layout, including `AI_GATING` configs whose workflows do not explicitly mark `isGating`. On narrow screens the review-flow diagram switches to a compact portrait branch: submission stays full width, the `AI Gate` and `Locked` states sit side by side as narrower cards, the `< threshold` connector sits between those two cards, and the human-review path continues only from the gate column. When AI reviewers exist without a persisted AI screening phase, the schedule editor injects a virtual `AI Screening` row after submission phases. This `Review` section is hidden for `Task` and `Marathon Match` challenges because those flows use dedicated reviewer assignment UIs. +- `components/ReviewersField/*`: tabbed human/AI review configuration. Human reviewers stay on the challenge form, while AI reviewer configs load/save through the review API and sync saved AI workflows back into the challenge `reviewers` array. Existing AI configs are reloaded once per saved challenge even if the challenge payload is temporarily missing synced AI reviewer rows, while still avoiding empty-config lookups for unsaved challenges, ordinary parent rerenders in edit mode, and same-session re-fetches right after a config is intentionally removed. Removing an AI config also detaches the synced AI workflow reviewers from the challenge. In read-only view mode the tab switcher remains clickable so users can inspect AI config details inside the disabled challenge form, and the review summary surfaces the human-review table, AI workflow details, resolved scorecard names, review flow, and estimated reviewer cost without requiring edits. Repeated human-review rows that share the same resource role now consume persisted challenge-resource assignments in row order so every assigned reviewer still appears once in the summary, and mixed legacy resource layouts continue into the generic `Reviewer` fallback pool when a phase-specific role runs out of persisted assignments. The editor hydration, editable tab, summary, and post-save reset now tolerate persisted resource rows that only expose role names, member handles, or member ids instead of the full modern payload shape, so refreshed drafts and newly saved drafts reopen with the saved reviewer assignments intact. The AI-gating failure path keeps the locked state grouped under the gate so the diagram matches the legacy work-manager layout, including `AI_GATING` configs whose workflows do not explicitly mark `isGating`. On narrow screens the review-flow diagram switches to a compact portrait branch: submission stays full width, the `AI Gate` and `Locked` states sit side by side as narrower cards, the `< threshold` connector sits between those two cards, and the human-review path continues only from the gate column. When AI reviewers exist without a persisted AI screening phase, the schedule editor injects a virtual `AI Screening` row after submission phases. This `Review` section is hidden for `Task` and `Marathon Match` challenges because those flows use dedicated reviewer assignment UIs. - `ChallengeEditorPage.module.scss` and `components/ChallengeEditorForm.module.scss`: page and form layout styling, including the grouped `Prizes & Billing` layout that keeps the challenge-prizes and copilot-fee inputs at fixed widths on larger screens, preserves whitespace to the right, and moves the billing summary underneath them. ## Validation Rules @@ -68,7 +68,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha - `ChallengeSkillsField`: async multi skills picker with billing-account-based required behavior. - `ChallengePrizesField`: placement-prize editor with an inline USD/POINTS selector that uses the challenge editor's green selected state, keeps the `Challenge Prizes` header on one line, and stays right-aligned above the fixed-width prize inputs. Each row always shows a numbered `Prize X` label, descending-value validation still applies to multi-prize setups, older payloads that omit the placement set are hydrated on demand, and only removable rows render the delete action so the first prize stays aligned with the selector. - `AssignedMemberField`: task-only assignee selector backed by member ids; persisted through the challenge `Submitter` resource assignment and restored from resources when task payloads omit the legacy field. -- `CopilotField`: clearable dropdown populated with copilot handles from the current project; persisted through the challenge `Copilot` resource assignment and restored from resources when draft payloads omit the legacy field. Persisted selections are matched case-insensitively so refreshes still show the saved copilot even when the resource payload and project-member option list disagree on handle casing, and member-id-only copilot resources are normalized back to handles during refresh hydration. When a refreshed draft still carries a legacy member-id-only copilot resource, the next save deletes that stale resource before writing the canonical handle-based assignment so the challenge does not keep duplicate copilot rows. The initial `New` draft-creation step also saves any selected copilot assignment before the editor resets from fetched challenge data, so the basic-information selection survives the transition into the full draft form. A copilot is required whenever the copilot fee is greater than 0, and that rule is enforced by form validation before save or launch actions run. +- `CopilotField`: clearable dropdown populated with copilot handles from the current project; persisted through the challenge `Copilot` resource assignment and restored from resources when draft payloads omit the legacy field. Persisted selections are matched case-insensitively so refreshes still show the saved copilot even when the resource payload and project-member option list disagree on handle casing, and member-id-only copilot resources are normalized back to handles during refresh hydration. Save-time form resets also reload the persisted copilot resource before the editor reopens the saved draft, so sparse challenge responses do not blank the field. When a refreshed draft still carries a legacy member-id-only copilot resource, the next save deletes that stale resource before writing the canonical handle-based assignment so the challenge does not keep duplicate copilot rows. The initial `New` draft-creation step also saves any selected copilot assignment before the editor resets from fetched challenge data, so the basic-information selection survives the transition into the full draft form. A copilot is required whenever the copilot fee is greater than 0, and that rule is enforced by form validation before save or launch actions run. - `CopilotFeeField`: optional copilot payment input that updates only the underlying copilot prize set, preserving placement prize edits and removing the copilot prize set when cleared so empty fees do not leave hidden validation errors. - `ChallengeFeeField`: derived summary value that uses the challenge billing markup together with the current prize and reviewer estimates so draft saves do not fall back to a stale `challengeFee` snapshot. It uses the same reviewer-cost estimate shown in `Review cost` and always renders two decimal places. For point-based challenges, the derived fee only uses the USD-denominated billable total so point prizes do not inflate the dollar billing summary. When the challenge payload does not yet include billing, or challenge-api returns the draft's billing markup as `0` for the same project billing account, the editor hydrates billing-account id and markup from the parent project billing account so draft pages still show the correct fee. - `ChallengeTotalField`: derived billing summary that always renders a dollar total and adds the current challenge fee on top of the billable subtotal from placement prizes, copilot fee, and estimated review cost. For point-based challenges it matches legacy work-manager behavior by counting only the USD-denominated copilot payment and its derived fee, excluding point prizes from the monetary total. 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 fd1ef31c5..133f1af22 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 @@ -1697,6 +1697,96 @@ describe('ChallengeEditorForm', () => { .toBeLessThan(mockedCreateResource.mock.invocationCallOrder[0]) }) + it('rehydrates persisted reviewer assignments from fresh resources after saving a draft', async () => { + const user = userEvent.setup() + + mockedUseFetchResourceRoles.mockReturnValue({ + error: undefined, + isError: false, + isLoading: false, + resourceRoles: [], + }) + mockedUseFetchResources.mockReturnValue({ + error: undefined, + isError: false, + isLoading: false, + mutate: jest.fn(), + resources: [], + }) + mockedFetchResourceRolesService.mockResolvedValue([{ + id: 'reviewer-role-id', + name: 'Reviewer', + }]) + mockedFetchResourcesService.mockResolvedValue([{ + challengeId: '12345', + memberId: 'manual-reviewer-member-id', + role: 'Reviewer', + roleId: 'reviewer-role-id', + }]) + mockedPatchChallenge.mockResolvedValue({ + ...validDraftChallenge, + phases: [{ + duration: 60, + name: 'Review', + phaseId: 'review-phase-id', + }], + reviewers: [{ + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'review-phase-id', + scorecardId: 'review-scorecard-id', + shouldOpenOpportunity: false, + }], + }) + + render( + + + , + ) + + expect(screen.getByTestId('reviewers-field') + .getAttribute('data-reviewers')) + .toContain('"memberId":"manual-reviewer-member-id"') + + mockedFetchResourceRolesService.mockClear() + mockedFetchResourcesService.mockClear() + + await user.type(screen.getByLabelText('Challenge Name'), ' updated') + await user.click(screen.getByRole('button', { name: 'Save Challenge' })) + + await waitFor(() => { + expect(mockedPatchChallenge) + .toHaveBeenCalledTimes(1) + expect(screen.getByLabelText('Challenge Name')) + .toHaveValue(validDraftChallenge.name) + }) + expect(mockedFetchResourceRolesService) + .toHaveBeenCalledTimes(1) + expect(mockedFetchResourcesService) + .toHaveBeenCalledWith('12345') + expect(screen.getByTestId('reviewers-field') + .getAttribute('data-reviewers')) + .toContain('"memberId":"manual-reviewer-member-id"') + }) + it('keeps the review section after submission settings in read-only mode', () => { mockedUseFetchChallengeTracks.mockReturnValue({ isLoading: false, 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 a9094ce7b..bb3b80b77 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -1875,6 +1875,42 @@ export const ChallengeEditorForm: FC = ( mutateChallengeResources, syncSingleAssignmentResource, ]) + /** + * Reapplies resource-backed assignments after a save response resets the form. + * + * Challenge patch responses may omit persisted copilot and manual-reviewer member selections + * even though those resources were saved successfully. Reloading resources before the post-save + * reset keeps the editor aligned with the persisted draft state. + * + * @param challengeId saved challenge identifier whose persisted resources should be reloaded. + * @param formData form-state snapshot derived from the saved challenge payload. + * @returns the same form data with persisted resource assignments restored. + */ + const hydratePersistedSavedFormData = useCallback(async ( + challengeId: string, + formData: ChallengeEditorFormData, + ): Promise => { + const [ + persistedResources, + persistedResourceRoles, + ] = await Promise.all([ + fetchResources(challengeId), + loadSingleAssignmentResourceRoles(), + ]) + + return hydratePersistedManualReviewerAssignments( + applyPersistedSingleAssignments( + formData, + persistedResources, + persistedResourceRoles, + ), + persistedResources, + persistedResourceRoles, + ) + }, [ + applyPersistedSingleAssignments, + loadSingleAssignmentResourceRoles, + ]) const handleScorerConfigChange = useCallback( (hasUnsavedChanges: boolean, hasError: boolean): void => { @@ -2519,7 +2555,8 @@ export const ChallengeEditorForm: FC = ( ) const nextValues = applySingleAssignmentFieldValues( - applyPersistedSingleAssignments( + await hydratePersistedSavedFormData( + currentChallengeId, { ...persistedFormData, attachments: Array.isArray(persistedFormData.attachments) @@ -2579,10 +2616,10 @@ export const ChallengeEditorForm: FC = ( } }, [ - applyPersistedSingleAssignments, clearErrors, currentChallengeId, fallbackProjectId, + hydratePersistedSavedFormData, isEditMode, isTaskSingleAssignmentChallenge, navigate, From 59867b3dd8d1f346b5ff1cf000191144d8517c1e Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 15 Apr 2026 16:09:00 +1000 Subject: [PATCH 05/81] Fix for this config value to point to new work app --- src/config/environments/prod.env.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/environments/prod.env.ts b/src/config/environments/prod.env.ts index d42a3d64e..a334890d9 100644 --- a/src/config/environments/prod.env.ts +++ b/src/config/environments/prod.env.ts @@ -26,7 +26,7 @@ export const ADMIN = { ONLINE_REVIEW_URL: 'https://software.topcoder.com/review', REVIEW_UI_URL: 'https://review.topcoder.com', SUBMISSION_SCAN_TOPIC: 'submission.scan.complete', - WORK_MANAGER_URL: 'https://challenges.topcoder.com', + WORK_MANAGER_URL: 'https://work.topcoder.com', } export const REVIEW = { From 32c8e4a98b8f0d39ea752650b13c4f0730d0a264 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:12:52 +0000 Subject: [PATCH 06/81] Bump axios from 1.13.2 to 1.15.0 Bumps [axios](https://github.com/axios/axios) from 1.13.2 to 1.15.0. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.13.2...v1.15.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.15.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- package.json | 2 +- yarn.lock | 31 ++++++++++++++++++------------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 215e645de..23d209db8 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "@uiw/react-codemirror": "^4.25.8", "amazon-s3-uri": "^0.1.1", "apexcharts": "^3.54.1", - "axios": "^1.13.2", + "axios": "^1.15.0", "browser-cookies": "^1.2.0", "city-timezones": "^1.3.2", "classnames": "^2.5.1", diff --git a/yarn.lock b/yarn.lock index 3d71a1d8d..d9d83014d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5813,14 +5813,14 @@ axe-core@^4.10.0: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.11.0.tgz#16f74d6482e343ff263d4f4503829e9ee91a86b6" integrity sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ== -axios@*, axios@^1.13.2, axios@^1.7.4: - version "1.13.2" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.2.tgz#9ada120b7b5ab24509553ec3e40123521117f687" - integrity sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA== +axios@*, axios@^1.13.2, axios@^1.15.0, axios@^1.7.4: + version "1.15.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.15.0.tgz#0fcee91ef03d386514474904b27863b2c683bf4f" + integrity sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q== dependencies: - follow-redirects "^1.15.6" - form-data "^4.0.4" - proxy-from-env "^1.1.0" + follow-redirects "^1.15.11" + form-data "^4.0.5" + proxy-from-env "^2.1.0" axobject-query@^4.1.0: version "4.1.0" @@ -9602,10 +9602,10 @@ flux-standard-action@^2.0.3: lodash.isplainobject "^4.0.6" lodash.isstring "^4.0.1" -follow-redirects@^1.0.0, follow-redirects@^1.15.2, follow-redirects@^1.15.6: - version "1.15.11" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" - integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== +follow-redirects@^1.0.0, follow-redirects@^1.15.11, follow-redirects@^1.15.2: + version "1.16.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc" + integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw== for-each@^0.3.3, for-each@^0.3.5: version "0.3.5" @@ -9678,7 +9678,7 @@ form-data@^3.0.0: hasown "^2.0.2" mime-types "^2.1.35" -form-data@^4.0.4: +form-data@^4.0.4, form-data@^4.0.5: version "4.0.5" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== @@ -15468,11 +15468,16 @@ proxy-addr@~2.0.7: forwarded "0.2.0" ipaddr.js "1.9.1" -proxy-from-env@^1.0.0, proxy-from-env@^1.1.0: +proxy-from-env@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== +proxy-from-env@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba" + integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA== + ps-tree@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/ps-tree/-/ps-tree-1.2.0.tgz#5e7425b89508736cdd4f2224d028f7bb3f722ebd" From 6f8d7a80f113b5d665981b0bbfbd7e61a7152c51 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:14:26 +0000 Subject: [PATCH 07/81] Bump handlebars from 4.7.8 to 4.7.9 Bumps [handlebars](https://github.com/handlebars-lang/handlebars.js) from 4.7.8 to 4.7.9. - [Release notes](https://github.com/handlebars-lang/handlebars.js/releases) - [Changelog](https://github.com/handlebars-lang/handlebars.js/blob/v4.7.9/release-notes.md) - [Commits](https://github.com/handlebars-lang/handlebars.js/compare/v4.7.8...v4.7.9) --- updated-dependencies: - dependency-name: handlebars dependency-version: 4.7.9 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 3d71a1d8d..ad5fde3f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10082,9 +10082,9 @@ handle-thing@^2.0.0: integrity sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg== handlebars@^4.7.7: - version "4.7.8" - resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9" - integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ== + version "4.7.9" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.9.tgz#6f139082ab58dc4e5a0e51efe7db5ae890d56a0f" + integrity sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ== dependencies: minimist "^1.2.5" neo-async "^2.6.2" From 207a266a7b95f396b8ea40f95bb443fd30572d13 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:14:46 +0000 Subject: [PATCH 08/81] Bump fast-xml-parser from 4.5.3 to 4.5.6 Bumps [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) from 4.5.3 to 4.5.6. - [Release notes](https://github.com/NaturalIntelligence/fast-xml-parser/releases) - [Changelog](https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/CHANGELOG.md) - [Commits](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v4.5.3...v4.5.6) --- updated-dependencies: - dependency-name: fast-xml-parser dependency-version: 4.5.6 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/yarn.lock b/yarn.lock index 3d71a1d8d..b640f57e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9305,11 +9305,11 @@ fast-uri@^3.0.1: integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA== fast-xml-parser@^4.4.1: - version "4.5.3" - resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz#c54d6b35aa0f23dc1ea60b6c884340c006dc6efb" - integrity sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig== + version "4.5.6" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz#4ff57d4aca13a2d11aa42ad460495cf00f32b655" + integrity sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A== dependencies: - strnum "^1.1.1" + strnum "^1.0.5" fastest-levenshtein@^1.0.12: version "1.0.16" @@ -17771,7 +17771,7 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== -strnum@^1.1.1: +strnum@^1.0.5: version "1.1.2" resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.1.2.tgz#57bca4fbaa6f271081715dbc9ed7cee5493e28e4" integrity sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA== From 3f31e2b3ae29c8dd5e36963202576777ee1b0a6b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:27:21 +0000 Subject: [PATCH 09/81] Bump rollup from 2.79.2 to 2.80.0 Bumps [rollup](https://github.com/rollup/rollup) from 2.79.2 to 2.80.0. - [Release notes](https://github.com/rollup/rollup/releases) - [Changelog](https://github.com/rollup/rollup/blob/v2.80.0/CHANGELOG.md) - [Commits](https://github.com/rollup/rollup/compare/v2.79.2...v2.80.0) --- updated-dependencies: - dependency-name: rollup dependency-version: 2.80.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index a42acecd7..7a0c999a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16760,9 +16760,9 @@ rollup-plugin-terser@^7.0.0: terser "^5.0.0" rollup@^2.43.1: - version "2.79.2" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.2.tgz#f150e4a5db4b121a21a747d762f701e5e9f49090" - integrity sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ== + version "2.80.0" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.80.0.tgz#a82efc15b748e986a7c76f0f771221b1fa108a2c" + integrity sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ== optionalDependencies: fsevents "~2.3.2" From 6d7f4229a58437389948be0aec287f9ab1ec1e8a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:30:04 +0000 Subject: [PATCH 10/81] Bump flatted from 3.3.3 to 3.4.2 Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.2. - [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2) --- updated-dependencies: - dependency-name: flatted dependency-version: 3.4.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index a42acecd7..4470da243 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9585,9 +9585,9 @@ flat@^5.0.2: integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== flatted@^3.2.9: - version "3.3.3" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" - integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== + version "3.4.2" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726" + integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== flow-parser@0.*: version "0.293.0" From 8f4b3a4107da8527f5680c687588cf257deacdfd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:57:09 +0000 Subject: [PATCH 11/81] Bump lodash from 4.17.21 to 4.18.1 Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.18.1. - [Release notes](https://github.com/lodash/lodash/releases) - [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.18.1) --- updated-dependencies: - dependency-name: lodash dependency-version: 4.18.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 23d209db8..abdc9d5d4 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "highcharts-react-official": "^3.2.3", "highlight.js": "^11.11.1", "html2canvas": "^1.4.1", - "lodash": "^4.17.21", + "lodash": "^4.18.1", "markdown-it": "^13.0.2", "marked": "4.3.0", "moment": "^2.30.1", diff --git a/yarn.lock b/yarn.lock index 1c615fd92..cff4432d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12625,10 +12625,10 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ== -lodash@^4.0.1, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.5, lodash@^4.2.1, lodash@^4.7.0: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" - integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== +lodash@^4.0.1, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.5, lodash@^4.18.1, lodash@^4.2.1, lodash@^4.7.0: + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" + integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== log-symbols@^4.1.0: version "4.1.0" From 6691946543a3c153edaf2b974f5cc11101563e9d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:15:52 +0000 Subject: [PATCH 12/81] Bump node-forge from 1.3.3 to 1.4.0 Bumps [node-forge](https://github.com/digitalbazaar/forge) from 1.3.3 to 1.4.0. - [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md) - [Commits](https://github.com/digitalbazaar/forge/compare/v1.3.3...v1.4.0) --- updated-dependencies: - dependency-name: node-forge dependency-version: 1.4.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index cff4432d6..9f872fb72 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13871,9 +13871,9 @@ node-fetch@2.6.7, node-fetch@^1.0.1, node-fetch@^2.0.0, node-fetch@^2.7.0: whatwg-url "^5.0.0" node-forge@^1: - version "1.3.3" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.3.tgz#0ad80f6333b3a0045e827ac20b7f735f93716751" - integrity sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg== + version "1.4.0" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.4.0.tgz#1c7b7d8bdc2d078739f58287d589d903a11b2fc2" + integrity sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ== node-int64@^0.4.0: version "0.4.0" From 41d8a2a3d5dfddf6ab71e7d6ac415e09498bb587 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 07:46:12 +0000 Subject: [PATCH 13/81] Bump storybook from 7.6.20 to 7.6.21 Bumps [storybook](https://github.com/storybookjs/storybook/tree/HEAD/code/core) from 7.6.20 to 7.6.21. - [Release notes](https://github.com/storybookjs/storybook/releases) - [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.v1-5.md) - [Commits](https://github.com/storybookjs/storybook/commits/v7.6.21/code/core) --- updated-dependencies: - dependency-name: storybook dependency-version: 7.6.21 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- package.json | 2 +- yarn.lock | 217 ++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 162 insertions(+), 57 deletions(-) diff --git a/package.json b/package.json index abdc9d5d4..8824556b9 100644 --- a/package.json +++ b/package.json @@ -213,7 +213,7 @@ "sass-loader": "^13.3.3", "serve": "^14.2.5", "start-server-and-test": "^2.1.3", - "storybook": "7.6.20", + "storybook": "7.6.21", "style-loader": "^3.3.4", "systemjs-webpack-interop": "^2.3.7", "tsconfig-paths-webpack-plugin": "^4.2.0", diff --git a/yarn.lock b/yarn.lock index 9f872fb72..43d3eccf9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3239,15 +3239,15 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/builder-manager@7.6.20": - version "7.6.20" - resolved "https://registry.yarnpkg.com/@storybook/builder-manager/-/builder-manager-7.6.20.tgz#d550a3f209012e4e383e61320ea756cddfdb416e" - integrity sha512-e2GzpjLaw6CM/XSmc4qJRzBF8GOoOyotyu3JrSPTYOt4RD8kjUsK4QlismQM1DQRu8i39aIexxmRbiJyD74xzQ== +"@storybook/builder-manager@7.6.21": + version "7.6.21" + resolved "https://registry.yarnpkg.com/@storybook/builder-manager/-/builder-manager-7.6.21.tgz#13a76fa3312f2a1afe1f62a25e2f3d7c3d6fa1a3" + integrity sha512-j6N/OiwUGHzvDSpWKlrjuR8Fp3unEAhowgtKpc8fV3Qw0xi5lEmJc4yu0R5cIGkOsSoA5Oe6nLGhjRjvddioQA== dependencies: "@fal-works/esbuild-plugin-global-externals" "^2.1.2" - "@storybook/core-common" "7.6.20" - "@storybook/manager" "7.6.20" - "@storybook/node-logger" "7.6.20" + "@storybook/core-common" "7.6.21" + "@storybook/manager" "7.6.21" + "@storybook/node-logger" "7.6.21" "@types/ejs" "^3.1.1" "@types/find-cache-dir" "^3.2.1" "@yarnpkg/esbuild-plugin-pnp" "^3.0.0-rc.10" @@ -3317,23 +3317,35 @@ telejson "^7.2.0" tiny-invariant "^1.3.1" -"@storybook/cli@7.6.20": - version "7.6.20" - resolved "https://registry.yarnpkg.com/@storybook/cli/-/cli-7.6.20.tgz#498625db5f2447e8e1ad34827a7803c5940527f0" - integrity sha512-ZlP+BJyqg7HlnXf7ypjG2CKMI/KVOn03jFIiClItE/jQfgR6kRFgtjRU7uajh427HHfjv9DRiur8nBzuO7vapA== +"@storybook/channels@7.6.21": + version "7.6.21" + resolved "https://registry.yarnpkg.com/@storybook/channels/-/channels-7.6.21.tgz#882e2be537e147d40411460463940645a4394c2a" + integrity sha512-899XbW60IXIkWDo90bS5ovjxnFUDgD8B2ZwUEJUmuhIXqQeSg2iJ8uYI699Csei+DoDn5gZYJD+BHbSUuc4g+Q== + dependencies: + "@storybook/client-logger" "7.6.21" + "@storybook/core-events" "7.6.21" + "@storybook/global" "^5.0.0" + qs "^6.10.0" + telejson "^7.2.0" + tiny-invariant "^1.3.1" + +"@storybook/cli@7.6.21": + version "7.6.21" + resolved "https://registry.yarnpkg.com/@storybook/cli/-/cli-7.6.21.tgz#bcd2bc231325a3d523672150e87f61d8b0be08c5" + integrity sha512-8SCDEeoBm+RAQDiH4HOjsQFJhReI7EJRylXVtllVhmq6TpxyJNZz8CSWEIU0zFhznIHktevriVzRR/qAKdUXng== dependencies: "@babel/core" "^7.23.2" "@babel/preset-env" "^7.23.2" "@babel/types" "^7.23.0" "@ndelangen/get-tarball" "^3.0.7" - "@storybook/codemod" "7.6.20" - "@storybook/core-common" "7.6.20" - "@storybook/core-events" "7.6.20" - "@storybook/core-server" "7.6.20" - "@storybook/csf-tools" "7.6.20" - "@storybook/node-logger" "7.6.20" - "@storybook/telemetry" "7.6.20" - "@storybook/types" "7.6.20" + "@storybook/codemod" "7.6.21" + "@storybook/core-common" "7.6.21" + "@storybook/core-events" "7.6.21" + "@storybook/core-server" "7.6.21" + "@storybook/csf-tools" "7.6.21" + "@storybook/node-logger" "7.6.21" + "@storybook/telemetry" "7.6.21" + "@storybook/types" "7.6.21" "@types/semver" "^7.3.4" "@yarnpkg/fslib" "2.10.3" "@yarnpkg/libzip" "2.3.0" @@ -3370,18 +3382,25 @@ dependencies: "@storybook/global" "^5.0.0" -"@storybook/codemod@7.6.20": - version "7.6.20" - resolved "https://registry.yarnpkg.com/@storybook/codemod/-/codemod-7.6.20.tgz#0aa7e0c1aacc605c7691b4b06baef0a9abefe114" - integrity sha512-8vmSsksO4XukNw0TmqylPmk7PxnfNfE21YsxFa7mnEBmEKQcZCQsNil4ZgWfG0IzdhTfhglAN4r++Ew0WE+PYA== +"@storybook/client-logger@7.6.21": + version "7.6.21" + resolved "https://registry.yarnpkg.com/@storybook/client-logger/-/client-logger-7.6.21.tgz#96d53fdbb3d9df203e0e98bb46b07b260c292137" + integrity sha512-NWh32K+N6htmmPfqSPOlA6gy80vFQZLnusK8+/7Hp0sSG//OV5ahlnlSveLUOub2e97CU5EvYUL1xNmSuqk2jQ== + dependencies: + "@storybook/global" "^5.0.0" + +"@storybook/codemod@7.6.21": + version "7.6.21" + resolved "https://registry.yarnpkg.com/@storybook/codemod/-/codemod-7.6.21.tgz#48257a0771b2f4b80b5f61a47b2b8dc7a613e77b" + integrity sha512-AFkOB+2vSRXbjUdTI5rsvL8YdqVcmKgmJB3QgwbmLp804Qhqn/WcbOkPOT6zqdcgDTLGaFUIFigvjc7cly3fkw== dependencies: "@babel/core" "^7.23.2" "@babel/preset-env" "^7.23.2" "@babel/types" "^7.23.0" "@storybook/csf" "^0.1.2" - "@storybook/csf-tools" "7.6.20" - "@storybook/node-logger" "7.6.20" - "@storybook/types" "7.6.20" + "@storybook/csf-tools" "7.6.21" + "@storybook/node-logger" "7.6.21" + "@storybook/types" "7.6.21" "@types/cross-spawn" "^6.0.2" cross-spawn "^7.0.3" globby "^11.0.2" @@ -3443,6 +3462,35 @@ resolve-from "^5.0.0" ts-dedent "^2.0.0" +"@storybook/core-common@7.6.21": + version "7.6.21" + resolved "https://registry.yarnpkg.com/@storybook/core-common/-/core-common-7.6.21.tgz#b1a83afa17e39b5b66917ba18f7b23e0b31248dd" + integrity sha512-3xeEAsEwPIEdnWiFJcxD3ObRrF7Vy1q/TKIExbk6p8Flx+XPXQKRZd/T+m5/8/zLYevasvY6hdVN91Fhcw9S2Q== + dependencies: + "@storybook/core-events" "7.6.21" + "@storybook/node-logger" "7.6.21" + "@storybook/types" "7.6.21" + "@types/find-cache-dir" "^3.2.1" + "@types/node" "^18.0.0" + "@types/node-fetch" "^2.6.4" + "@types/pretty-hrtime" "^1.0.0" + chalk "^4.1.0" + esbuild "^0.18.0" + esbuild-register "^3.5.0" + file-system-cache "2.3.0" + find-cache-dir "^3.0.0" + find-up "^5.0.0" + fs-extra "^11.1.0" + glob "^10.0.0" + handlebars "^4.7.7" + lazy-universal-dotenv "^4.0.0" + node-fetch "^2.0.0" + picomatch "^2.3.0" + pkg-dir "^5.0.0" + pretty-hrtime "^1.0.3" + resolve-from "^5.0.0" + ts-dedent "^2.0.0" + "@storybook/core-events@7.6.20": version "7.6.20" resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-7.6.20.tgz#6648d661d1c96841a4c2a710a35759b01b6a06a1" @@ -3450,26 +3498,33 @@ dependencies: ts-dedent "^2.0.0" -"@storybook/core-server@7.6.20": - version "7.6.20" - resolved "https://registry.yarnpkg.com/@storybook/core-server/-/core-server-7.6.20.tgz#fa143fbcad64fb7b0f0dc6d555d083c506a44ab4" - integrity sha512-qC5BdbqqwMLTdCwMKZ1Hbc3+3AaxHYWLiJaXL9e8s8nJw89xV8c8l30QpbJOGvcDmsgY6UTtXYaJ96OsTr7MrA== +"@storybook/core-events@7.6.21": + version "7.6.21" + resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-7.6.21.tgz#801fe0369ecaeee3518344feb6fb47deee2c79a6" + integrity sha512-Ez6bhYuXbEkHVCmnNB/oqN0sQwphsmtPmjYdPMlTtEpVEIXHAw2qOlaDiGakoDHkgrTaxiYvdJrPH0UcEJcWDQ== + dependencies: + ts-dedent "^2.0.0" + +"@storybook/core-server@7.6.21": + version "7.6.21" + resolved "https://registry.yarnpkg.com/@storybook/core-server/-/core-server-7.6.21.tgz#c521ed971345b7344a7a41365989d0338c1b09aa" + integrity sha512-1Z92JjUumCFrLNJY7ZNH9bRXyNggtFvfrhVsHjIxvOJcXvI9cfXJQtN1Pcx2Gc7tQNLQfHp6CifmDCmAw3sbXA== dependencies: "@aw-web-design/x-default-browser" "1.4.126" "@discoveryjs/json-ext" "^0.5.3" - "@storybook/builder-manager" "7.6.20" - "@storybook/channels" "7.6.20" - "@storybook/core-common" "7.6.20" - "@storybook/core-events" "7.6.20" + "@storybook/builder-manager" "7.6.21" + "@storybook/channels" "7.6.21" + "@storybook/core-common" "7.6.21" + "@storybook/core-events" "7.6.21" "@storybook/csf" "^0.1.2" - "@storybook/csf-tools" "7.6.20" + "@storybook/csf-tools" "7.6.21" "@storybook/docs-mdx" "^0.1.0" "@storybook/global" "^5.0.0" - "@storybook/manager" "7.6.20" - "@storybook/node-logger" "7.6.20" - "@storybook/preview-api" "7.6.20" - "@storybook/telemetry" "7.6.20" - "@storybook/types" "7.6.20" + "@storybook/manager" "7.6.21" + "@storybook/node-logger" "7.6.21" + "@storybook/preview-api" "7.6.21" + "@storybook/telemetry" "7.6.21" + "@storybook/types" "7.6.21" "@types/detect-port" "^1.3.0" "@types/node" "^18.0.0" "@types/pretty-hrtime" "^1.0.0" @@ -3530,6 +3585,21 @@ recast "^0.23.1" ts-dedent "^2.0.0" +"@storybook/csf-tools@7.6.21": + version "7.6.21" + resolved "https://registry.yarnpkg.com/@storybook/csf-tools/-/csf-tools-7.6.21.tgz#44cf46d6ce8d7c6113346885183471f86337fd51" + integrity sha512-DBdwDo4nOsXF/QV6Ru08xgb54M1o9A0E7D8VW0+PcFK+Y8naq8+I47PkijHloTxgZxUyX8OvboaLBMTGUV275w== + dependencies: + "@babel/generator" "^7.23.0" + "@babel/parser" "^7.23.0" + "@babel/traverse" "^7.23.2" + "@babel/types" "^7.23.0" + "@storybook/csf" "^0.1.2" + "@storybook/types" "7.6.21" + fs-extra "^11.1.0" + recast "^0.23.1" + ts-dedent "^2.0.0" + "@storybook/csf@^0.1.2": version "0.1.13" resolved "https://registry.yarnpkg.com/@storybook/csf/-/csf-0.1.13.tgz#c8a9bea2ae518a3d9700546748fa30a8b07f7f80" @@ -3580,10 +3650,10 @@ telejson "^7.2.0" ts-dedent "^2.0.0" -"@storybook/manager@7.6.20": - version "7.6.20" - resolved "https://registry.yarnpkg.com/@storybook/manager/-/manager-7.6.20.tgz#eb619fe8d33446e581a7b1c3050644c196364d39" - integrity sha512-0Cf6WN0t7yEG2DR29tN5j+i7H/TH5EfPppg9h9/KiQSoFHk+6KLoy2p5do94acFU+Ro4+zzxvdCGbcYGKuArpg== +"@storybook/manager@7.6.21": + version "7.6.21" + resolved "https://registry.yarnpkg.com/@storybook/manager/-/manager-7.6.21.tgz#8692bf57a8c65f3b25935963b433165f81cc6d07" + integrity sha512-kwtG7HfxYQIZeGwDg7xFkORhNf0PH+4jRLf/9M6amR537Hctay+Vlv2MGHO6LFzw6IwT4qCtO8xNgzcV9TxZtg== "@storybook/mdx2-csf@^1.0.0": version "1.1.0" @@ -3595,6 +3665,11 @@ resolved "https://registry.yarnpkg.com/@storybook/node-logger/-/node-logger-7.6.20.tgz#c0ca90cf68cf31d84cdcf53c76cec22769407ece" integrity sha512-l2i4qF1bscJkOplNffcRTsgQWYR7J51ewmizj5YrTM8BK6rslWT1RntgVJWB1RgPqvx6VsCz1gyP3yW1oKxvYw== +"@storybook/node-logger@7.6.21": + version "7.6.21" + resolved "https://registry.yarnpkg.com/@storybook/node-logger/-/node-logger-7.6.21.tgz#a70e829c54c119f37f5a4f9d3660c2f4f1510fbd" + integrity sha512-X4LwhWQ0KuLU7O2aEi7U9hhg+klnuvkXqhXIqAQCZEKogUxz7ywek+2h+7QqdgHFi6V7VYNtiMmMJKllzhg+OA== + "@storybook/postinstall@7.6.20": version "7.6.20" resolved "https://registry.yarnpkg.com/@storybook/postinstall/-/postinstall-7.6.20.tgz#5a77ce7913375b11bd7c72388798854bd8507b91" @@ -3656,6 +3731,26 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" +"@storybook/preview-api@7.6.21": + version "7.6.21" + resolved "https://registry.yarnpkg.com/@storybook/preview-api/-/preview-api-7.6.21.tgz#eb90a17752a37fddadfe4c58a697731bb601a376" + integrity sha512-L5e6VjphfsnJk/kkOIRJzDaTfX5sNpiusocqEbHKTM7c9ZDAuaLPZKluP87AJ0u16UdWMuCu6YaQ6eAakDa9gg== + dependencies: + "@storybook/channels" "7.6.21" + "@storybook/client-logger" "7.6.21" + "@storybook/core-events" "7.6.21" + "@storybook/csf" "^0.1.2" + "@storybook/global" "^5.0.0" + "@storybook/types" "7.6.21" + "@types/qs" "^6.9.5" + dequal "^2.0.2" + lodash "^4.17.21" + memoizerific "^1.11.3" + qs "^6.10.0" + synchronous-promise "^2.0.15" + ts-dedent "^2.0.0" + util-deprecate "^1.0.2" + "@storybook/preview@7.6.20": version "7.6.20" resolved "https://registry.yarnpkg.com/@storybook/preview/-/preview-7.6.20.tgz#df39739dce6e183efaf06a8c15a9459f019e631b" @@ -3725,14 +3820,14 @@ memoizerific "^1.11.3" qs "^6.10.0" -"@storybook/telemetry@7.6.20": - version "7.6.20" - resolved "https://registry.yarnpkg.com/@storybook/telemetry/-/telemetry-7.6.20.tgz#5b3705eb5100b21070d76767dde1040ed5d9b35b" - integrity sha512-dmAOCWmOscYN6aMbhCMmszQjoycg7tUPRVy2kTaWg6qX10wtMrvEtBV29W4eMvqdsoRj5kcvoNbzRdYcWBUOHQ== +"@storybook/telemetry@7.6.21": + version "7.6.21" + resolved "https://registry.yarnpkg.com/@storybook/telemetry/-/telemetry-7.6.21.tgz#f28dd3173ce04c3372c806079391dc6ee2cd3bc0" + integrity sha512-bE68Ac6daL0JE9vjtHKwsM+uSXZ94QdoZL9RCTVvp0dI7htm7s7w7+Arm/aCxG9lnYTAjioWNRpHfeALVjsjIg== dependencies: - "@storybook/client-logger" "7.6.20" - "@storybook/core-common" "7.6.20" - "@storybook/csf-tools" "7.6.20" + "@storybook/client-logger" "7.6.21" + "@storybook/core-common" "7.6.21" + "@storybook/csf-tools" "7.6.21" chalk "^4.1.0" detect-package-manager "^2.0.1" fetch-retry "^5.0.2" @@ -3768,6 +3863,16 @@ "@types/express" "^4.7.0" file-system-cache "2.3.0" +"@storybook/types@7.6.21": + version "7.6.21" + resolved "https://registry.yarnpkg.com/@storybook/types/-/types-7.6.21.tgz#b8815c6701fd286e85be9b89e1e31b7a9fd75876" + integrity sha512-rJaBMxzXZOsJpqZGhebFJxOguZQBw5j+MVpqbFBA6vLZPx9wEbDBeVsPUxCxj+V1XkVcrNXf9qfThyJ8ETmLBw== + dependencies: + "@storybook/channels" "7.6.21" + "@types/babel__core" "^7.0.0" + "@types/express" "^4.7.0" + file-system-cache "2.3.0" + "@stripe/react-stripe-js@1.16.5": version "1.16.5" resolved "https://registry.yarnpkg.com/@stripe/react-stripe-js/-/react-stripe-js-1.16.5.tgz#51cf862b50ca91ae6193c77a5bec889e81047f10" @@ -17529,12 +17634,12 @@ store2@^2.14.2: resolved "https://registry.yarnpkg.com/store2/-/store2-2.14.4.tgz#81b313abaddade4dcd7570c5cc0e3264a8f7a242" integrity sha512-srTItn1GOvyvOycgxjAnPA63FZNwy0PTyUBFMHRM+hVFltAeoh0LmNBz9SZqUS9mMqGk8rfyWyXn3GH5ReJ8Zw== -storybook@7.6.20: - version "7.6.20" - resolved "https://registry.yarnpkg.com/storybook/-/storybook-7.6.20.tgz#6204ff0c28471536a1a64cb16d1c97872dd33f95" - integrity sha512-Wt04pPTO71pwmRmsgkyZhNo4Bvdb/1pBAMsIFb9nQLykEdzzpXjvingxFFvdOG4nIowzwgxD+CLlyRqVJqnATw== +storybook@7.6.21: + version "7.6.21" + resolved "https://registry.yarnpkg.com/storybook/-/storybook-7.6.21.tgz#0856e00cbbeb5d6ec16cb413cd6aa4398fac114d" + integrity sha512-zmicrWNy5GbrO7hZwVp6uZ6m93VWULePkhYB300jAer7Z+CH4yso/nNcyRO00rnD4zizJLy2MXeUJvydh7rOaw== dependencies: - "@storybook/cli" "7.6.20" + "@storybook/cli" "7.6.21" stream-combiner@~0.0.4: version "0.0.4" From dad051ca2622a6430b26e5f353422d837caea654 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 07:48:51 +0000 Subject: [PATCH 14/81] Bump defu from 6.1.4 to 6.1.7 Bumps [defu](https://github.com/unjs/defu) from 6.1.4 to 6.1.7. - [Release notes](https://github.com/unjs/defu/releases) - [Changelog](https://github.com/unjs/defu/blob/main/CHANGELOG.md) - [Commits](https://github.com/unjs/defu/compare/v6.1.4...v6.1.7) --- updated-dependencies: - dependency-name: defu dependency-version: 6.1.7 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 9f872fb72..8a1757913 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7750,9 +7750,9 @@ define-properties@^1.1.3, define-properties@^1.2.1: object-keys "^1.1.1" defu@^6.1.4: - version "6.1.4" - resolved "https://registry.yarnpkg.com/defu/-/defu-6.1.4.tgz#4e0c9cf9ff68fe5f3d7f2765cc1a012dfdcb0479" - integrity sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg== + version "6.1.7" + resolved "https://registry.yarnpkg.com/defu/-/defu-6.1.7.tgz#72543567c8e9f97ff13ce402b6dbe09ac5ae4d23" + integrity sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ== del@^6.0.0: version "6.1.1" From ca97d7d8c67466a21af09036b2c0528ea6702deb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 07:49:31 +0000 Subject: [PATCH 15/81] Bump lodash-es from 4.17.21 to 4.18.1 Bumps [lodash-es](https://github.com/lodash/lodash) from 4.17.21 to 4.18.1. - [Release notes](https://github.com/lodash/lodash/releases) - [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.18.1) --- updated-dependencies: - dependency-name: lodash-es dependency-version: 4.18.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 9f872fb72..e3af1d4f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12510,9 +12510,9 @@ locate-path@^7.1.0: p-locate "^6.0.0" lodash-es@^4.17.15, lodash-es@^4.2.1: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" - integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d" + integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A== lodash._arrayeach@^3.0.0: version "3.0.0" From 069d90adb73b28cdd538f273572f0ef724b52cf1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 07:49:50 +0000 Subject: [PATCH 16/81] Bump jsonpath from 1.1.1 to 1.3.0 Bumps [jsonpath](https://github.com/dchester/jsonpath) from 1.1.1 to 1.3.0. - [Commits](https://github.com/dchester/jsonpath/commits) --- updated-dependencies: - dependency-name: jsonpath dependency-version: 1.3.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 90 ++++++++++++++----------------------------------------- 1 file changed, 23 insertions(+), 67 deletions(-) diff --git a/yarn.lock b/yarn.lock index 9f872fb72..285f92e6f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7682,7 +7682,7 @@ deep-extend@^0.6.0: resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== -deep-is@^0.1.3, deep-is@~0.1.3: +deep-is@^0.1.3: version "0.1.4" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== @@ -8665,18 +8665,6 @@ escape-string-regexp@^5.0.0: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8" integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== -escodegen@^1.8.1: - version "1.14.3" - resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.14.3.tgz#4e7b81fba61581dc97582ed78cab7f0e8d63f503" - integrity sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw== - dependencies: - esprima "^4.0.1" - estraverse "^4.2.0" - esutils "^2.0.2" - optionator "^0.8.1" - optionalDependencies: - source-map "~0.6.1" - escodegen@^2.0.0, escodegen@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.1.0.tgz#ba93bbb7a43986d29d6041f99f5262da773e2e17" @@ -9007,10 +8995,10 @@ espree@^9.6.0, espree@^9.6.1: acorn-jsx "^5.3.2" eslint-visitor-keys "^3.4.1" -esprima@1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/esprima/-/esprima-1.2.2.tgz#76a0fd66fcfe154fd292667dc264019750b1657b" - integrity sha512-+JpPZam9w5DuJ3Q67SqsMGtiHKENSMRVoxvArfJZK01/BfLEObtZ6orJa/MtoGNR/rfMgp5837T41PAmTwAv/A== +esprima@1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-1.2.5.tgz#0993502feaf668138325756f30f9a51feeec11e9" + integrity sha512-S9VbPDU0adFErpDai3qDkjq8+G05ONtKzcyNrPKg/ZKa+tf879nX2KexNU95b31UoTJjRLInNBHHHjFPoCd7lQ== esprima@^4.0.0, esprima@^4.0.1, esprima@~4.0.0: version "4.0.1" @@ -9031,7 +9019,7 @@ esrecurse@^4.1.0, esrecurse@^4.3.0: dependencies: estraverse "^5.2.0" -estraverse@^4.1.1, estraverse@^4.2.0: +estraverse@^4.1.1: version "4.3.0" resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== @@ -9294,7 +9282,7 @@ fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== -fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: +fast-levenshtein@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== @@ -12272,13 +12260,13 @@ jsonfile@^6.0.1: graceful-fs "^4.1.6" jsonpath@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/jsonpath/-/jsonpath-1.1.1.tgz#0ca1ed8fb65bb3309248cc9d5466d12d5b0b9901" - integrity sha512-l6Cg7jRpixfbgoWgkrl77dgEj8RPvND0wMH6TwQmi9Qs4TFfS9u5cUFnbeKTwj5ga5Y3BTGGNI28k117LJ009w== + version "1.3.0" + resolved "https://registry.yarnpkg.com/jsonpath/-/jsonpath-1.3.0.tgz#623197970fb433845c68024bf9e2b864f5376ab2" + integrity sha512-0kjkYHJBkAy50Z5QzArZ7udmvxrJzkpKYW27fiF//BrMY7TQibYLl+FYIXN2BiYmwMIVzSfD8aDRj6IzgBX2/w== dependencies: - esprima "1.2.2" - static-eval "2.0.2" - underscore "1.12.1" + esprima "1.2.5" + static-eval "2.1.1" + underscore "1.13.6" jsonpointer@^5.0.0: version "5.0.1" @@ -12389,14 +12377,6 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" -levn@~0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" - integrity sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA== - dependencies: - prelude-ls "~1.1.2" - type-check "~0.3.2" - lilconfig@2.1.0, lilconfig@^2.0.3: version "2.1.0" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52" @@ -14137,18 +14117,6 @@ opener@^1.5.2: resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A== -optionator@^0.8.1: - version "0.8.3" - resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" - integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA== - dependencies: - deep-is "~0.1.3" - fast-levenshtein "~2.0.6" - levn "~0.3.0" - prelude-ls "~1.1.2" - type-check "~0.3.2" - word-wrap "~1.2.3" - optionator@^0.9.3: version "0.9.4" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" @@ -15314,11 +15282,6 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -prelude-ls@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" - integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w== - prettier@^2.8.0: version "2.8.8" resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da" @@ -17494,12 +17457,12 @@ start-server-and-test@^2.1.3: ps-tree "1.2.0" wait-on "9.0.3" -static-eval@2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/static-eval/-/static-eval-2.0.2.tgz#2d1759306b1befa688938454c546b7871f806a42" - integrity sha512-N/D219Hcr2bPjLxPiV+TQE++Tsmrady7TqAJugLy7Xk1EumfDWS/f5dtBbkRCGE7wKKXuYockQoj8Rm2/pVKyg== +static-eval@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/static-eval/-/static-eval-2.1.1.tgz#71ac6a13aa32b9e14c5b5f063c362176b0d584ba" + integrity sha512-MgWpQ/ZjGieSVB3eOJVs4OA2LT/q1vx98KPCTTQPzq/aLr0YUXTsgryTXr4SLfR0ZfUUCiedM9n/ABeDIyy4mA== dependencies: - escodegen "^1.8.1" + escodegen "^2.1.0" statuses@2.0.1: version "2.0.1" @@ -18471,13 +18434,6 @@ type-check@^0.4.0, type-check@~0.4.0: dependencies: prelude-ls "^1.2.1" -type-check@~0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" - integrity sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg== - dependencies: - prelude-ls "~1.1.2" - type-detect@4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" @@ -18659,10 +18615,10 @@ unbox-primitive@^1.1.0: has-symbols "^1.1.0" which-boxed-primitive "^1.1.1" -underscore@1.12.1: - version "1.12.1" - resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.12.1.tgz#7bb8cc9b3d397e201cf8553336d262544ead829e" - integrity sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw== +underscore@1.13.6: + version "1.13.6" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.6.tgz#04786a1f589dc6c09f761fc5f45b89e935136441" + integrity sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A== undici-types@~5.26.4: version "5.26.5" @@ -19589,7 +19545,7 @@ wildcard@^2.0.0: resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.1.tgz#5ab10d02487198954836b6349f74fff961e10f67" integrity sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ== -word-wrap@^1.2.5, word-wrap@~1.2.3: +word-wrap@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== From 568836575c2dedc97d8a4ac72773cefeed933522 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 16 Apr 2026 16:09:46 +0300 Subject: [PATCH 17/81] PM-4878 load appeals in bulk --- .../src/lib/hooks/useFetchAppealQueue.ts | 37 +++++--- .../review/src/lib/models/AppealInfo.model.ts | 1 + .../src/lib/models/BackendAppeal.model.ts | 2 + .../src/lib/services/reviews.service.ts | 94 +++++++++++++++---- 4 files changed, 103 insertions(+), 31 deletions(-) diff --git a/src/apps/review/src/lib/hooks/useFetchAppealQueue.ts b/src/apps/review/src/lib/hooks/useFetchAppealQueue.ts index e07cfbaff..016a68832 100644 --- a/src/apps/review/src/lib/hooks/useFetchAppealQueue.ts +++ b/src/apps/review/src/lib/hooks/useFetchAppealQueue.ts @@ -7,7 +7,7 @@ import { filter } from 'lodash' import { MappingReviewAppeal, } from '../models' -import { fetchAppealsWithReviewId } from '../services' +import { fetchAllAppealsWithReviewIds } from '../services' export interface useFetchAppealQueueProps { mappingReviewAppeal: MappingReviewAppeal // from review id to appeal info @@ -35,9 +35,11 @@ export function useFetchAppealQueue(): useFetchAppealQueueProps { return } - const nextId = idLoadQueue.current[0] - idLoadQueue.current = idLoadQueue.current.slice(1) - if (mappingReviewAppealRef.current[nextId]) { + const nextIds = Array.from(new Set(idLoadQueue.current)) + .filter(id => !mappingReviewAppealRef.current[id]) + idLoadQueue.current = [] + + if (!nextIds.length) { fetchNextDataInQueue() return } @@ -49,24 +51,29 @@ export function useFetchAppealQueue(): useFetchAppealQueueProps { } const fetchDataFail = (): void => { - mappingReviewAppealRef.current[nextId] = { - finishAppeals: 0, - totalAppeals: 0, - } + nextIds.forEach(id => { + mappingReviewAppealRef.current[id] = { + finishAppeals: 0, + totalAppeals: 0, + } + }) setMappingReviewAppeal({ ...mappingReviewAppealRef.current, }) finish() } - // Fetch appeal datas - fetchAppealsWithReviewId(1, 100, nextId) + fetchAllAppealsWithReviewIds(nextIds) .then(res => { - mappingReviewAppealRef.current[nextId] = { - finishAppeals: filter(res, item => !!item.appealResponse) - .length, - totalAppeals: res.length, - } + nextIds.forEach(id => { + const reviewAppeals = res.filter(item => item.reviewId === id) + + mappingReviewAppealRef.current[id] = { + finishAppeals: filter(reviewAppeals, item => !!item.appealResponse) + .length, + totalAppeals: reviewAppeals.length, + } + }) setMappingReviewAppeal({ ...mappingReviewAppealRef.current, }) diff --git a/src/apps/review/src/lib/models/AppealInfo.model.ts b/src/apps/review/src/lib/models/AppealInfo.model.ts index db6aaf197..b516e048a 100644 --- a/src/apps/review/src/lib/models/AppealInfo.model.ts +++ b/src/apps/review/src/lib/models/AppealInfo.model.ts @@ -5,6 +5,7 @@ import { BackendAppealResponse } from './BackendAppealResponse.model' */ export interface AppealInfo { id: string + reviewId?: string reviewItemCommentId: string content: string appealResponse?: BackendAppealResponse diff --git a/src/apps/review/src/lib/models/BackendAppeal.model.ts b/src/apps/review/src/lib/models/BackendAppeal.model.ts index 45d043294..39f63033f 100644 --- a/src/apps/review/src/lib/models/BackendAppeal.model.ts +++ b/src/apps/review/src/lib/models/BackendAppeal.model.ts @@ -12,6 +12,7 @@ export interface BackendAppealBase { export interface BackendAppeal extends BackendAppealBase { resourceId: string id: string + reviewId?: string appealResponse?: BackendAppealResponse createdAt: string createdBy: string @@ -30,6 +31,7 @@ export function convertBackendAppeal(data: BackendAppeal): AppealInfo { appealResponse: data.appealResponse, content: data.content, id: data.id, + reviewId: data.reviewId, reviewItemCommentId: data.reviewItemCommentId, } } diff --git a/src/apps/review/src/lib/services/reviews.service.ts b/src/apps/review/src/lib/services/reviews.service.ts index 00debc4cb..9ee2995b0 100644 --- a/src/apps/review/src/lib/services/reviews.service.ts +++ b/src/apps/review/src/lib/services/reviews.service.ts @@ -455,20 +455,33 @@ export const fetchAllChallengeReviews = async ( * @param resourceId resource id * @returns resolves to the array of appeals */ -export const fetchAppeals = async ( +const fetchAppealsPage = async ( page: number, perPage: number, - resourceId: string, -): Promise => { - const results = await xhrGetAsync< - BackendResponseWithMeta - >( + filters: { + resourceId?: string + reviewId?: string + reviewIds?: string[] + challengeId?: string + }, +): Promise> => ( + xhrGetAsync>( `${EnvironmentConfig.API.V6}/appeals?${qs.stringify({ page, perPage, - resourceId, + ...filters, + }, { + arrayFormat: 'comma', })}`, ) +) + +export const fetchAppeals = async ( + page: number, + perPage: number, + resourceId: string, +): Promise => { + const results = await fetchAppealsPage(page, perPage, { resourceId }) return results.data.map(convertBackendAppeal) } @@ -484,19 +497,68 @@ export const fetchAppealsWithReviewId = async ( page: number, perPage: number, reviewId: string, +): Promise => fetchAppealsPage(page, perPage, { reviewId }) + .then(results => results.data.map(convertBackendAppeal)) + +/** + * Fetch appeals with review ids + * + * @param page current page + * @param perPage number of item per page + * @param reviewIds review ids + * @returns resolves to the array of appeals + */ +export const fetchAppealsWithReviewIds = async ( + page: number, + perPage: number, + reviewIds: string[], ): Promise => { - const results = await xhrGetAsync< - BackendResponseWithMeta - >( - `${EnvironmentConfig.API.V6}/appeals?${qs.stringify({ - page, - perPage, - reviewId, - })}`, - ) + const results = await fetchAppealsPage(page, perPage, { reviewIds }) return results.data.map(convertBackendAppeal) } +/** + * Fetch all appeals with review ids using API pagination metadata. + * + * @param reviewIds review ids + * @param perPage number of items per page + * @returns resolves to the array of appeals + */ +export const fetchAllAppealsWithReviewIds = async ( + reviewIds: string[], + perPage = 500, +): Promise => { + if (!reviewIds.length) { + return [] + } + + const safePerPage = Number.isFinite(perPage) && perPage > 0 ? perPage : 500 + const firstPage = await fetchAppealsPage(1, safePerPage, { reviewIds }) + const combined = [...firstPage.data] + const totalPages = Math.max(firstPage.meta?.totalPages ?? 1, 1) + + const fetchRemainingPages = async ( + page: number, + currentTotal: number, + ): Promise => { + if (page > currentTotal) { + return + } + + const nextPage = await fetchAppealsPage(page, safePerPage, { reviewIds }) + combined.push(...nextPage.data) + const nextTotal = typeof nextPage.meta?.totalPages === 'number' + ? Math.max(nextPage.meta.totalPages, currentTotal) + : currentTotal + + await fetchRemainingPages(page + 1, nextTotal) + } + + await fetchRemainingPages(2, totalPages) + + return combined.map(convertBackendAppeal) +} + /** * Create review * From 63329e57b51ec9f067230c68b7dbd0dec86b1849 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 17 Apr 2026 10:07:43 +1000 Subject: [PATCH 18/81] Update the placement prize handling to be a little less restrictive, allowing us to have multiple prizes of the same value, liek 10 3rd place prizes of 0 --- .../ManageSubmissionPage.tsx | 49 +++++++++++++- .../admin/src/lib/utils/challenge.spec.ts | 63 ++++++++++++++++++ src/apps/admin/src/lib/utils/challenge.ts | 35 ++++++++++ .../schemas/challenge-editor.schema.spec.ts | 64 +++++++++++++++++++ .../lib/schemas/challenge-editor.schema.ts | 4 +- .../challenges/ChallengeEditorPage/README.md | 2 +- .../ChallengePrizesField.spec.tsx | 64 +++++++++++++++++++ .../ChallengePrizesField.tsx | 10 +-- 8 files changed, 280 insertions(+), 11 deletions(-) create mode 100644 src/apps/admin/src/lib/utils/challenge.spec.ts diff --git a/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx b/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx index 2d9e172b9..b61fbc30e 100644 --- a/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx +++ b/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx @@ -51,6 +51,7 @@ import { checkIsMM, getSubmissionReprocessTopic, handleError, + resolveManualUploadSubmissionType, } from '../../lib/utils' import styles from './ManageSubmissionPage.module.scss' @@ -92,7 +93,9 @@ interface ManualSubmissionUploadModalProps { selectedHandle?: SelectOption setSelectedHandle: (value: SelectOption) => void isUploading: boolean + isLoadingChallenge: boolean isLoadingSubmitters: boolean + submissionTypeLabel: string submitterOptions: SelectOption[] handleFileChange: (event: ChangeEvent) => void selectedFile?: File @@ -160,6 +163,7 @@ const ManualSubmissionUploadModal: FC = ( props: ManualSubmissionUploadModalProps, ) => { const isHandleSelectDisabled = props.isUploading + || props.isLoadingChallenge || props.isLoadingSubmitters || props.submitterOptions.length === 0 const memberHandleHint = !props.isLoadingSubmitters @@ -179,6 +183,16 @@ const ManualSubmissionUploadModal: FC = ( >
+
+ + Submission type + + + {props.isLoadingChallenge + ? 'Loading challenge phases...' + : props.submissionTypeLabel} + +
= ( onClick={props.handleUploadSubmission} disabled={ props.isUploading + || props.isLoadingChallenge || props.isLoadingSubmitters || !props.selectedHandle?.value || !props.selectedFile @@ -256,6 +271,18 @@ export const ManageSubmissionPage: FC = (props: Props) => { challengeInfo, }: useFetchChallengeProps = useFetchChallenge(challengeId) const isMM = useMemo(() => checkIsMM(challengeInfo), [challengeInfo]) + const manualUploadSubmissionType = useMemo( + () => resolveManualUploadSubmissionType(challengeInfo), + [challengeInfo], + ) + const manualUploadSubmissionTypeLabel = useMemo( + () => ( + manualUploadSubmissionType === 'CHECKPOINT_SUBMISSION' + ? 'Checkpoint Submission' + : 'Submission' + ), + [manualUploadSubmissionType], + ) const submissionReprocessTopic = useMemo( () => getSubmissionReprocessTopic(challengeInfo), [challengeInfo], @@ -329,7 +356,12 @@ export const ManageSubmissionPage: FC = (props: Props) => { ) const handleUploadSubmission = useCallback(async () => { - if (!challengeId || !selectedFile || !selectedHandle?.value) { + if ( + !challengeId + || !challengeInfo + || !selectedFile + || !selectedHandle?.value + ) { return } @@ -341,6 +373,7 @@ export const ManageSubmissionPage: FC = (props: Props) => { fileName: selectedFile.name, memberHandle: String(selectedHandle.label), memberId: selectedHandle.value, + type: manualUploadSubmissionType, }) toast.success('Submission uploaded successfully', { @@ -354,7 +387,15 @@ export const ManageSubmissionPage: FC = (props: Props) => { } finally { setIsUploading(false) } - }, [challengeId, selectedFile, selectedHandle, resetUploadForm, refresh]) + }, [ + challengeId, + challengeInfo, + manualUploadSubmissionType, + refresh, + resetUploadForm, + selectedFile, + selectedHandle, + ]) useEffect(() => { let isCancelled = false @@ -415,7 +456,7 @@ export const ManageSubmissionPage: FC = (props: Props) => {
+
@@ -460,19 +515,20 @@ export const TalentSearchPage: FC = () => {

Searching talent...

)} - {!isSearchingMembers && filteredResults.length === 0 && ( + {!isSearchingMembers && displayedResults.length === 0 && (

No matching talent found

Try changing filters or using a different job description.

)} - {!isSearchingMembers && filteredResults.length > 0 && ( + {!isSearchingMembers && displayedResults.length > 0 && ( <>
- {filteredResults.map(talent => ( + {displayedResults.map(talent => ( ))}
diff --git a/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.module.scss b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.module.scss index 83b5982ba..2cea74382 100644 --- a/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.module.scss +++ b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.module.scss @@ -194,6 +194,10 @@ font-weight: 400; } +.cardFooterWithoutMatch { + justify-content: flex-end; +} + .footerMatched { display: flex; align-items: center; diff --git a/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx index 7d636e228..b2f47e90f 100644 --- a/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx @@ -30,6 +30,7 @@ interface TalentResultCardTalent { interface TalentResultCardProps { talent: TalentResultCardTalent + showSkillMatch: boolean } function getUniqueMatchedSkills(talent: TalentResultCardTalent): TalentResultCardTalent['matchedSkills'] { @@ -68,6 +69,7 @@ function buildMatchedSkillsTooltipContent( export const TalentResultCard: FC = (props: TalentResultCardProps) => { const talent: TalentResultCardTalent = props.talent + const showSkillMatch = props.showSkillMatch const uniqueSkills = useMemo(() => getUniqueMatchedSkills(talent), [talent]) const isVerifiedProfile = talent.isVerified === true const displayName = String(talent.name || '') @@ -105,9 +107,11 @@ export const TalentResultCard: FC = (props: TalentResultC
{displayHandle} - - {`${talent.matchIndex}% Match`} - + {showSkillMatch && ( + + {`${talent.matchIndex}% Match`} + + )}

{talent.name}

@@ -148,27 +152,29 @@ export const TalentResultCard: FC = (props: TalentResultC
-
-
- - {`${uniqueSkills.length} ${matchedSkillLabel}`} - - {uniqueSkills.length > 0 && ( - - - - )} -
+ + + )} +
+ )} Date: Sun, 19 Apr 2026 15:00:02 +1000 Subject: [PATCH 25/81] PM-4895: restore required assignee field markers What was broken The work app assignees page did not show mandatory markers for Billing Start Date, Rate Per Hour, and Standard Hours Per Week, so it no longer matched the legacy work-manager behavior. Root cause The assignee summary card used simplified static labels and never rendered a required indicator for the three mandatory assignment fields. What was changed Updated the assignee summary labels to use the established field names and render a required marker for Billing Start Date, Rate Per Hour, and Standard Hours Per Week. Added scoped styling for the required marker so it is visually distinct on the summary card. Any added/updated tests Updated the EngagementPaymentPage spec to assert that the assignee summary renders those three labels with mandatory markers. --- .../EngagementPaymentPage.module.scss | 5 +++++ .../EngagementPaymentPage.spec.tsx | 12 +++++++++++- .../EngagementPaymentPage.tsx | 15 ++++++++++++--- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.module.scss b/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.module.scss index fe3a6314a..4361ed177 100644 --- a/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.module.scss +++ b/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.module.scss @@ -100,6 +100,11 @@ margin-bottom: 4px; } +.required { + color: #db3030; + margin-left: 2px; +} + .value { color: #2a2a2a; font-size: 14px; diff --git a/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.spec.tsx b/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.spec.tsx index 6bc914a78..51a5bce9c 100644 --- a/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.spec.tsx +++ b/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.spec.tsx @@ -193,7 +193,7 @@ describe('EngagementPaymentPage', () => { }, } as unknown as ReturnType) - render( + const renderedPage: ReturnType = render( { , ) + const container: HTMLElement = renderedPage.container expect(screen.queryByText('testing 123')) .toBeNull() + const labels: Array = Array.from(container.querySelectorAll('.label')) + .map(element => element.textContent) + + expect(labels) + .toEqual(expect.arrayContaining([ + 'Billing Start Date*', + 'Rate Per Hour*', + 'Standard Hours Per Week*', + ])) fireEvent.click(screen.getByRole('button', { name: 'View other remarks for testaws1', diff --git a/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.tsx b/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.tsx index 8fcff1220..df538f47c 100644 --- a/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.tsx +++ b/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.tsx @@ -853,7 +853,10 @@ export const EngagementPaymentPage: FC = () => {
- Billing Start + + Billing Start Date + + {formatDate(assignment.startDate)}
@@ -861,11 +864,17 @@ export const EngagementPaymentPage: FC = () => { {formatDurationMonths(assignment.durationMonths)}
- Rate Per Hour + + Rate Per Hour + + {formatCurrency(assignment.ratePerHour)}
- Hours Per Week + + Standard Hours Per Week + + {assignment.standardHoursPerWeek || '-'} From 66a98c8d46dd195c21a8fe6470705ddbacff374f Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sun, 19 Apr 2026 15:56:49 +1000 Subject: [PATCH 26/81] PM-4890: allow past engagement start dates What was broken The Accept Application and Assign Member modals blocked users from selecting past dates for the engagement start date fields. Root cause (if identifiable) Both modals created a minimum start date from the current day and passed it to the shared start-date picker, which prevented choosing any earlier date. What was changed Removed the min-date restriction from the billing start date field in Accept Application and from the engagement start date field in Assign Member so both modals can select past dates. Any added/updated tests Added focused regression tests for AcceptApplicationModal and AssignmentDetailsModal to verify those start-date inputs no longer receive a minimum date constraint. --- .../AcceptApplicationModal.spec.tsx | 75 +++++++++++++++++++ .../AcceptApplicationModal.tsx | 2 - .../AssignmentDetailsModal.spec.tsx | 75 +++++++++++++++++++ .../components/AssignmentDetailsModal.tsx | 2 - 4 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.spec.tsx create mode 100644 src/apps/work/src/pages/engagements/EngagementEditorPage/components/AssignmentDetailsModal.spec.tsx diff --git a/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.spec.tsx b/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.spec.tsx new file mode 100644 index 000000000..e6b9fdbad --- /dev/null +++ b/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.spec.tsx @@ -0,0 +1,75 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { render } from '@testing-library/react' + +import AcceptApplicationModal from './AcceptApplicationModal' + +const mockStartDateTimeInput = jest.fn((props: { label: string }): JSX.Element => ( +
{props.label}
+)) + +jest.mock('~/libs/ui', () => ({ + BaseModal: (props: { + buttons?: JSX.Element + children: JSX.Element + open: boolean + }): JSX.Element => ( + props.open ? ( +
+ {props.children} + {props.buttons} +
+ ) : <> + ), + Button: (props: { + disabled?: boolean + label: string + onClick?: () => void + }): JSX.Element => ( + + ), +}), { + virtual: true, +}) + +jest.mock('../form', () => ({ + StartDateTimeInput: (props: { label: string }): JSX.Element => mockStartDateTimeInput(props), +})) + +jest.mock('../../utils', () => ({ + calculateAssignmentRatePerWeek: jest.fn(() => ''), + sanitizePositiveNumericInput: jest.fn((value: string) => value), + serializeTentativeAssignmentDate: jest.fn((value: Date) => value.toISOString()), + toPositiveInteger: jest.fn(() => 1), + toPositiveNumber: jest.fn(() => 1), + toPositiveNumberWithMaxDecimalPlaces: jest.fn(() => 1), +})) + +describe('AcceptApplicationModal', () => { + beforeEach(() => { + mockStartDateTimeInput.mockClear() + }) + + it('does not restrict the billing start date to today or later', () => { + render( + , + ) + + const startDateTimeInputProps = mockStartDateTimeInput + .mock.calls[mockStartDateTimeInput.mock.calls.length - 1][0] as { + label: string + minDate?: Date | null + } + + expect(startDateTimeInputProps.label) + .toBe('Billing start date') + expect(startDateTimeInputProps.minDate) + .toBeUndefined() + }) +}) diff --git a/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.tsx b/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.tsx index 79a3b9c3e..b5f76f83c 100644 --- a/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.tsx +++ b/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.tsx @@ -65,7 +65,6 @@ const AcceptApplicationModal: FC = ( const isSubmitting = props.isSubmitting === true - const minStartDate = useMemo(() => new Date(), []) const timezone = useMemo( () => Intl.DateTimeFormat() .resolvedOptions() @@ -191,7 +190,6 @@ const AcceptApplicationModal: FC = ( ( +
{props.label}
+)) + +jest.mock('~/libs/ui', () => ({ + BaseModal: (props: { + buttons?: JSX.Element + children: JSX.Element + open: boolean + }): JSX.Element => ( + props.open ? ( +
+ {props.children} + {props.buttons} +
+ ) : <> + ), + Button: (props: { + label: string + onClick?: () => void + }): JSX.Element => ( + + ), +}), { + virtual: true, +}) + +jest.mock('../../../../lib/components/form', () => ({ + StartDateTimeInput: (props: { label: string }): JSX.Element => mockStartDateTimeInput(props), +})) + +jest.mock('../../../../lib/utils', () => ({ + calculateAssignmentRatePerWeek: jest.fn(() => ''), + deserializeTentativeAssignmentDate: jest.fn(() => undefined), + sanitizePositiveNumericInput: jest.fn((value: string) => value), + serializeTentativeAssignmentDate: jest.fn((value: Date) => value.toISOString()), + toPositiveInteger: jest.fn(() => 1), + toPositiveNumber: jest.fn(() => 1), + toPositiveNumberWithMaxDecimalPlaces: jest.fn(() => 1), +})) + +describe('AssignmentDetailsModal', () => { + beforeEach(() => { + mockStartDateTimeInput.mockClear() + }) + + it('allows past engagement start dates in the assignment form', () => { + render( + , + ) + + const startDateTimeInputProps = mockStartDateTimeInput + .mock.calls[mockStartDateTimeInput.mock.calls.length - 1][0] as { + label: string + minDate?: Date | null + } + + expect(startDateTimeInputProps.label) + .toBe('Engagement start date *') + expect(startDateTimeInputProps.minDate) + .toBeUndefined() + }) +}) diff --git a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/AssignmentDetailsModal.tsx b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/AssignmentDetailsModal.tsx index 17c5f1636..709109a02 100644 --- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/AssignmentDetailsModal.tsx +++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/AssignmentDetailsModal.tsx @@ -67,7 +67,6 @@ export const AssignmentDetailsModal: FC = ( props.initialValue?.standardHoursPerWeek || '', ) - const minStartDate = useMemo(() => new Date(), []) const timezone = useMemo( () => Intl.DateTimeFormat() .resolvedOptions() @@ -186,7 +185,6 @@ export const AssignmentDetailsModal: FC = (

{ setStartDate(value || undefined) setErrors(previous => ({ From f378b6e638fd6c292c7ca7de27265f8b061c45d7 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sun, 19 Apr 2026 16:24:43 +1000 Subject: [PATCH 27/81] PM-4859: restore Any option in engagement location fields What was broken The new engagement editor in the Work app did not offer the legacy Any choice in the Countries and Timezones multiselects. Existing Any values could still hydrate into the form, but users could not reselect that option from the dropdowns. Root cause The new location option builders only listed concrete countries and timezones, and the multiselect persisted raw selections instead of treating the legacy Any sentinel as mutually exclusive. What was changed Added Any as the first option in both location dropdowns. Normalized the multi-select values so choosing Any saves only Any. Kept the fix scoped to the engagement location field component. Any added/updated tests Added a regression test for the engagement location fields that covers both the visible Any option and the Any-only normalization behavior. Validated with yarn test:no-watch, yarn lint, and yarn run build. --- .../EngagementLocationFields.spec.tsx | 86 +++++++++++++++++++ .../components/EngagementLocationFields.tsx | 43 ++++++++-- 2 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementLocationFields.spec.tsx diff --git a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementLocationFields.spec.tsx b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementLocationFields.spec.tsx new file mode 100644 index 000000000..0c886c1de --- /dev/null +++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementLocationFields.spec.tsx @@ -0,0 +1,86 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { + render, +} from '@testing-library/react' +import moment from 'moment-timezone' + +import { EngagementLocationFields } from './EngagementLocationFields' + +const mockRecordedFields = new Map() + +jest.mock('../../../../lib/components/form', () => ({ + FormSelectField: function FormSelectField(props: any) { + mockRecordedFields.set(props.name, props) + + return
{props.label}
+ }, +})) + +describe('EngagementLocationFields', () => { + beforeEach(() => { + mockRecordedFields.clear() + jest.spyOn(moment.tz, 'countries') + .mockReturnValue(['DE', 'US']) + jest.spyOn(moment.tz, 'names') + .mockReturnValue(['Europe/Berlin']) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('prepends Any to the timezone and country option lists', () => { + render() + + const timezoneField = mockRecordedFields.get('timezones') + const countryField = mockRecordedFields.get('countries') + + expect(timezoneField.options[0]) + .toEqual({ + label: 'Any', + value: 'Any', + }) + expect(countryField.options[0]) + .toEqual({ + label: 'Any', + value: 'Any', + }) + }) + + it('stores Any as the only selected value when present', () => { + render() + + const timezoneField = mockRecordedFields.get('timezones') + const countryField = mockRecordedFields.get('countries') + + expect(timezoneField.toFieldValue([ + { + label: 'Any', + value: 'Any', + }, + { + label: '(UTC+01:00) Europe/Berlin', + value: 'Europe/Berlin', + }, + ])) + .toEqual(['Any']) + expect(countryField.toFieldValue([ + { + label: 'Germany', + value: 'DE', + }, + { + label: 'Any', + value: 'Any', + }, + ])) + .toEqual(['Any']) + expect(countryField.toFieldValue([ + { + label: 'Germany', + value: 'DE', + }, + ])) + .toEqual(['DE']) + }) +}) diff --git a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementLocationFields.tsx b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementLocationFields.tsx index e6661972b..8472d3ab0 100644 --- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementLocationFields.tsx +++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementLocationFields.tsx @@ -1,5 +1,6 @@ import { FC, + useCallback, useMemo, } from 'react' import moment from 'moment-timezone' @@ -13,6 +14,11 @@ interface EngagementLocationFieldsProps { disabled?: boolean } +const ANY_OPTION: FormSelectOption = { + label: 'Any', + value: 'Any', +} + function formatTimezoneLabel(timezone: string): string { const now = new Date() @@ -59,11 +65,14 @@ function formatTimezoneLabel(timezone: string): string { } function getTimezoneOptions(): FormSelectOption[] { - return moment.tz.names() - .map(timezone => ({ - label: formatTimezoneLabel(timezone), - value: timezone, - })) + return [ + ANY_OPTION, + ...moment.tz.names() + .map(timezone => ({ + label: formatTimezoneLabel(timezone), + value: timezone, + })), + ] } function getCountryOptions(): FormSelectOption[] { @@ -77,7 +86,10 @@ function getCountryOptions(): FormSelectOption[] { value: countryCode, })) - return options.sort((optionA, optionB) => optionA.label.localeCompare(optionB.label)) + return [ + ANY_OPTION, + ...options.sort((optionA, optionB) => optionA.label.localeCompare(optionB.label)), + ] } export const EngagementLocationFields: FC = ( @@ -85,6 +97,23 @@ export const EngagementLocationFields: FC = ( ) => { const timezoneOptions = useMemo(() => getTimezoneOptions(), []) const countryOptions = useMemo(() => getCountryOptions(), []) + /** + * Preserves the legacy "Any" sentinel as a mutually exclusive selection. + * + * @param selected Select value emitted by the form field. + * @returns Normalized form values for react-hook-form state. + */ + const toAnyOnlyFieldValue = useCallback((selected: unknown): string[] => { + const selectedOptions = Array.isArray(selected) + ? selected + : [] + + if (selectedOptions.some(option => option.value === ANY_OPTION.value)) { + return [ANY_OPTION.value] + } + + return selectedOptions.map(option => option.value) + }, []) return ( <> @@ -96,6 +125,7 @@ export const EngagementLocationFields: FC = ( options={timezoneOptions} placeholder='Select timezones' required + toFieldValue={toAnyOnlyFieldValue} /> = ( options={countryOptions} placeholder='Select countries' required + toFieldValue={toAnyOnlyFieldValue} /> ) From 85fe77f3074808878c5ed5e3e66375d99f02fa4c Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sun, 19 Apr 2026 16:53:00 +1000 Subject: [PATCH 28/81] PM-4840: preserve hydrated assignments after refresh What was broken Saved design challenges could still lose the visible copilot and assigned reviewers after a full refresh or when reopening edit mode, and the review configuration could surface validation noise even though the persisted assignments and scorecards already existed. Root cause (if identifiable) The earlier refresh fix still stopped the initial persisted-resource hydration path as soon as mount-time form normalization marked the editor dirty. In the remaining legacy payload path, a saved copilot handle could also be replaced by a member-id-only resource match. What was changed Allowed the first persisted-resource hydration pass to continue during the initial normalization window while keeping the existing dirty-form guards for later user edits. Also preserved an already hydrated copilot handle when the refreshed resources only provide a legacy member-id match, and documented the behavior in the challenge editor README. Any added/updated tests Added ChallengeEditorForm coverage for rehydrating persisted copilot and reviewer assignments when a compatibility field dirties the form during initial hydration. --- .../challenges/ChallengeEditorPage/README.md | 2 +- .../components/ChallengeEditorForm.spec.tsx | 100 +++++++++++++++++- .../components/ChallengeEditorForm.tsx | 52 +++++++-- 3 files changed, 141 insertions(+), 13 deletions(-) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index 5b45c9246..5925d5ca6 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -19,7 +19,7 @@ remains visible until a fetched challenge payload is available, so the create route can expand to the full editor immediately after the initial draft is created. - `components/*Field.tsx`: field-level components for each challenge section. -- `components/ReviewersField/*`: tabbed human/AI review configuration. Human reviewers stay on the challenge form, while AI reviewer configs load/save through the review API and sync saved AI workflows back into the challenge `reviewers` array. Existing AI configs are reloaded once per saved challenge even if the challenge payload is temporarily missing synced AI reviewer rows, while still avoiding empty-config lookups for unsaved challenges, ordinary parent rerenders in edit mode, and same-session re-fetches right after a config is intentionally removed. Removing an AI config also detaches the synced AI workflow reviewers from the challenge. In read-only view mode the tab switcher remains clickable so users can inspect AI config details inside the disabled challenge form, and the review summary surfaces the human-review table, AI workflow details, resolved scorecard names, review flow, and estimated reviewer cost without requiring edits. Repeated human-review rows that share the same resource role now consume persisted challenge-resource assignments in row order so every assigned reviewer still appears once in the summary, and mixed legacy resource layouts continue into the generic `Reviewer` fallback pool when a phase-specific role runs out of persisted assignments. The editor hydration, editable tab, summary, and post-save reset now tolerate persisted resource rows that only expose role names, member handles, or member ids instead of the full modern payload shape, so refreshed drafts and newly saved drafts reopen with the saved reviewer assignments intact. The AI-gating failure path keeps the locked state grouped under the gate so the diagram matches the legacy work-manager layout, including `AI_GATING` configs whose workflows do not explicitly mark `isGating`. On narrow screens the review-flow diagram switches to a compact portrait branch: submission stays full width, the `AI Gate` and `Locked` states sit side by side as narrower cards, the `< threshold` connector sits between those two cards, and the human-review path continues only from the gate column. When AI reviewers exist without a persisted AI screening phase, the schedule editor injects a virtual `AI Screening` row after submission phases. This `Review` section is hidden for `Task` and `Marathon Match` challenges because those flows use dedicated reviewer assignment UIs. +- `components/ReviewersField/*`: tabbed human/AI review configuration. Human reviewers stay on the challenge form, while AI reviewer configs load/save through the review API and sync saved AI workflows back into the challenge `reviewers` array. Existing AI configs are reloaded once per saved challenge even if the challenge payload is temporarily missing synced AI reviewer rows, while still avoiding empty-config lookups for unsaved challenges, ordinary parent rerenders in edit mode, and same-session re-fetches right after a config is intentionally removed. Removing an AI config also detaches the synced AI workflow reviewers from the challenge. In read-only view mode the tab switcher remains clickable so users can inspect AI config details inside the disabled challenge form, and the review summary surfaces the human-review table, AI workflow details, resolved scorecard names, review flow, and estimated reviewer cost without requiring edits. Repeated human-review rows that share the same resource role now consume persisted challenge-resource assignments in row order so every assigned reviewer still appears once in the summary, and mixed legacy resource layouts continue into the generic `Reviewer` fallback pool when a phase-specific role runs out of persisted assignments. The editor hydration, editable tab, summary, and post-save reset now tolerate persisted resource rows that only expose role names, member handles, or member ids instead of the full modern payload shape, so refreshed drafts and newly saved drafts reopen with the saved reviewer assignments intact. Initial persisted-resource hydration also keeps running while the form is still in its mount-time normalization window, so internal dirty flags from compatibility fields do not block restored copilot or reviewer assignments after a full refresh. The AI-gating failure path keeps the locked state grouped under the gate so the diagram matches the legacy work-manager layout, including `AI_GATING` configs whose workflows do not explicitly mark `isGating`. On narrow screens the review-flow diagram switches to a compact portrait branch: submission stays full width, the `AI Gate` and `Locked` states sit side by side as narrower cards, the `< threshold` connector sits between those two cards, and the human-review path continues only from the gate column. When AI reviewers exist without a persisted AI screening phase, the schedule editor injects a virtual `AI Screening` row after submission phases. This `Review` section is hidden for `Task` and `Marathon Match` challenges because those flows use dedicated reviewer assignment UIs. - `ChallengeEditorPage.module.scss` and `components/ChallengeEditorForm.module.scss`: page and form layout styling, including the grouped `Prizes & Billing` layout that keeps the challenge-prizes and copilot-fee inputs at fixed widths on larger screens, preserves whitespace to the right, and moves the billing summary underneath them. ## Validation Rules 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 133f1af22..e28ea505a 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 @@ -48,6 +48,8 @@ import { } from './ChallengeEditorForm' import { TermsField } from './TermsField' +let mockShouldAutoDirtyDuringInitialHydration = false + jest.mock('../../../../lib/components/form', () => ({ FormCheckboxField: () => <>, })) @@ -484,7 +486,29 @@ jest.mock('./RoundTypeField', () => ({ RoundTypeField: () => <>, })) jest.mock('./StockArtsField', () => ({ - StockArtsField: () => <>Stock Arts Field, + StockArtsField: function StockArtsField() { + const React: typeof import('react') = jest.requireActual('react') + const reactHookForm: typeof import('react-hook-form') = jest.requireActual('react-hook-form') + const formContext = reactHookForm.useFormContext() + const hasAutoDirtiedRef = React.useRef(false) + + React.useEffect(() => { + if (!mockShouldAutoDirtyDuringInitialHydration || hasAutoDirtiedRef.current) { + return + } + + hasAutoDirtiedRef.current = true + formContext.setValue('metadata', [{ + name: 'autoDirty', + value: 'true', + }], { + shouldDirty: true, + shouldValidate: false, + }) + }, [formContext]) + + return <>Stock Arts Field + }, })) jest.mock('./SubmissionVisibilityField', () => ({ SubmissionVisibilityField: () => <>Submission Visibility Field, @@ -622,6 +646,7 @@ describe('ChallengeEditorForm', () => { }) afterEach(() => { + mockShouldAutoDirtyDuringInitialHydration = false jest.clearAllMocks() }) @@ -1585,6 +1610,79 @@ describe('ChallengeEditorForm', () => { }) }) + it('rehydrates persisted assignments during initial hydration when mount-time dirty state exists', async () => { + let resolveFetchedResources: ((value: unknown[]) => void) | undefined + let resolveFetchedResourceRoles: ((value: unknown[]) => void) | undefined + + mockShouldAutoDirtyDuringInitialHydration = true + mockedFetchResourceRolesService.mockImplementation( + () => new Promise(resolve => { + resolveFetchedResourceRoles = resolve as (value: unknown[]) => void + }), + ) + mockedFetchResourcesService.mockImplementation( + () => new Promise(resolve => { + resolveFetchedResources = resolve as (value: unknown[]) => void + }), + ) + + render( + + + , + ) + + await act(async () => { + resolveFetchedResourceRoles?.([ + { + id: 'copilot-role-id', + name: 'Copilot', + }, + { + id: 'reviewer-role-id', + name: 'Reviewer', + }, + ]) + resolveFetchedResources?.([ + { + challengeId: '12345', + memberHandle: 'saved-copilot', + roleId: 'copilot-role-id', + }, + { + challengeId: '12345', + memberId: 'manual-reviewer-member-id', + role: 'Reviewer', + roleId: 'reviewer-role-id', + }, + ]) + }) + + await waitFor(() => { + expect(screen.getByLabelText('Copilot Field')) + .toHaveValue('saved-copilot') + }) + expect(screen.getByTestId('reviewers-field') + .getAttribute('data-reviewers')) + .toContain('"memberId":"manual-reviewer-member-id"') + }) + it('rehydrates handle-only reviewer resources before the refreshed form settles', async () => { mockedFetchResourceRolesService.mockResolvedValue([{ id: 'reviewer-role-id', 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 bb3b80b77..aed940e09 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -1395,6 +1395,7 @@ export const ChallengeEditorForm: FC = ( const [isInitialResourceHydrationPending, setIsInitialResourceHydrationPending] = useState( !!props.challenge?.id, ) + const isInitialResourceHydrationPendingRef = useRef(!!props.challenge?.id) const [lastSaved, setLastSaved] = useState() const [saveError, setSaveError] = useState() const [saveValidationError, setSaveValidationError] = useState() @@ -1661,14 +1662,31 @@ export const ChallengeEditorForm: FC = ( fallbackValue: string | undefined, resourcesOverride?: typeof challengeResources, resourceRolesOverride?: typeof resourceRoles, - ): string | undefined => getPersistedAssignmentValueByFields( - fallbackValue, - COPILOT_RESOURCE_ROLE_NAMES, - getSingleAssignmentResourceValueFields(COPILOT_ASSIGNMENT_CONFIG), - resourcesOverride, - resourceRolesOverride, - ), [ - getPersistedAssignmentValueByFields, + ): string | undefined => { + const resourceAssignment = resolvePersistedResourceAssignment({ + resourceRoles: resourceRolesOverride || resourceRoles, + resources: resourcesOverride || challengeResources, + roleNames: COPILOT_RESOURCE_ROLE_NAMES, + valueFields: getSingleAssignmentResourceValueFields(COPILOT_ASSIGNMENT_CONFIG), + }) + const normalizedFallbackValue = normalizeTextValue(fallbackValue) + + if (!resourceAssignment) { + return normalizedFallbackValue || undefined + } + + if ( + resourceAssignment.valueField === 'memberId' + && normalizedFallbackValue + && !hasSameNormalizedValue(resourceAssignment.value, normalizedFallbackValue) + ) { + return normalizedFallbackValue + } + + return resourceAssignment.value + }, [ + challengeResources, + resourceRoles, ]) const isTaskSingleAssignmentChallenge = useCallback(( formData: ChallengeEditorFormData, @@ -1943,6 +1961,10 @@ export const ChallengeEditorForm: FC = ( currentChallengeIdRef.current = currentChallengeId }, [currentChallengeId]) + useEffect(() => { + isInitialResourceHydrationPendingRef.current = isInitialResourceHydrationPending + }, [isInitialResourceHydrationPending]) + useEffect(() => { projectBillingAccountRef.current = projectBillingAccount }, [projectBillingAccount]) @@ -1966,6 +1988,7 @@ export const ChallengeEditorForm: FC = ( const isRefreshingCurrentChallenge = !!challengeId && challengeId === currentChallengeIdRef.current && isFormDirtyRef.current + && !isInitialResourceHydrationPendingRef.current if (isRefreshingCurrentChallenge) { return () => { @@ -2003,7 +2026,10 @@ export const ChallengeEditorForm: FC = ( fetchedResources, fetchedResourceRoles, ]) => { - if (!isActive || isFormDirtyRef.current) { + if ( + !isActive + || (isFormDirtyRef.current && !isInitialResourceHydrationPendingRef.current) + ) { return } @@ -2017,7 +2043,10 @@ export const ChallengeEditorForm: FC = ( fetchedResourceRoles, ) - if (!isActive || isFormDirtyRef.current) { + if ( + !isActive + || (isFormDirtyRef.current && !isInitialResourceHydrationPendingRef.current) + ) { return } @@ -2044,7 +2073,7 @@ export const ChallengeEditorForm: FC = ( useEffect(() => { if ( !currentChallengeId - || formState.isDirty + || (formState.isDirty && !isInitialResourceHydrationPending) || challengeResourcesResult.isLoading || resourceRolesResult.isLoading ) { @@ -2104,6 +2133,7 @@ export const ChallengeEditorForm: FC = ( currentChallengeId, formState.isDirty, getValues, + isInitialResourceHydrationPending, isTaskSingleAssignmentChallenge, resourceRoles, resourceRolesResult.isLoading, From b1555310e8bb99020c4f69eb0484b015a3a65416 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sun, 19 Apr 2026 17:11:16 +1000 Subject: [PATCH 29/81] PM-2698: hydrate saved challenge groups from accessible list What was broken Saved draft challenges could keep their group association, but reopening the challenge in the work app could show the raw group UUID instead of the group name. Root cause (if identifiable) The work-app groups field rehydrated persisted group IDs through the per-group detail endpoint. That endpoint can return 403 for saved challenge groups even when the accessible groups list/search endpoint can resolve the same group, so the field fell back to the raw ID. What was changed Updated work-app group hydration to resolve persisted group IDs from the accessible groups list instead of per-group detail fetches, and expanded the groups service to collect all paginated accessible group results before resolving labels. Any added/updated tests Added a FormGroupsSelect regression test covering saved-group hydration and a groups.service test covering paginated group list aggregation. --- .../FormGroupsSelect.spec.tsx | 97 +++++++++++++++++++ .../FormGroupsSelect/FormGroupsSelect.tsx | 40 +++++--- .../src/lib/services/groups.service.spec.ts | 87 +++++++++++++++++ .../work/src/lib/services/groups.service.ts | 52 +++++++--- 4 files changed, 248 insertions(+), 28 deletions(-) create mode 100644 src/apps/work/src/lib/components/form/FormGroupsSelect/FormGroupsSelect.spec.tsx create mode 100644 src/apps/work/src/lib/services/groups.service.spec.ts diff --git a/src/apps/work/src/lib/components/form/FormGroupsSelect/FormGroupsSelect.spec.tsx b/src/apps/work/src/lib/components/form/FormGroupsSelect/FormGroupsSelect.spec.tsx new file mode 100644 index 000000000..171f2bda8 --- /dev/null +++ b/src/apps/work/src/lib/components/form/FormGroupsSelect/FormGroupsSelect.spec.tsx @@ -0,0 +1,97 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { FC } from 'react' +import { + render, + waitFor, +} from '@testing-library/react' +import { + FormProvider, + useForm, +} from 'react-hook-form' + +import { fetchGroups } from '../../../services' + +import { FormGroupsSelect } from './FormGroupsSelect' + +let latestAsyncSelectProps: Record | undefined + +const fetchGroupsMock = fetchGroups as jest.Mock + +jest.mock('react-select/async', () => ({ + __esModule: true, + default: (props: Record) => { + latestAsyncSelectProps = props + + return false + }, +})) + +jest.mock('react-select/async-creatable', () => ({ + __esModule: true, + default: (props: Record) => { + latestAsyncSelectProps = props + + return false + }, +})) + +jest.mock('../../../services', () => ({ + createGroup: jest.fn(), + fetchGroups: jest.fn(), +})) + +interface TestFormValues { + groups: string[] +} + +const TestHarness: FC = () => { + const formMethods = useForm({ + defaultValues: { + groups: ['db53f15b-2d61-4d9e-8263-8cfc3f98337e'], + }, + }) + + return ( + + + + ) +} + +describe('FormGroupsSelect', () => { + beforeEach(() => { + latestAsyncSelectProps = undefined + jest.clearAllMocks() + }) + + it('hydrates saved group ids from the accessible groups list before falling back to raw ids', async () => { + fetchGroupsMock.mockResolvedValue([ + { + id: 'db53f15b-2d61-4d9e-8263-8cfc3f98337e', + name: 'Hide Challenges', + }, + ]) + + render( + , + ) + + await waitFor(() => { + expect(fetchGroupsMock) + .toHaveBeenCalledWith() + }) + + await waitFor(() => { + expect(latestAsyncSelectProps?.value) + .toEqual([ + { + label: 'Hide Challenges', + value: 'db53f15b-2d61-4d9e-8263-8cfc3f98337e', + }, + ]) + }) + }) +}) diff --git a/src/apps/work/src/lib/components/form/FormGroupsSelect/FormGroupsSelect.tsx b/src/apps/work/src/lib/components/form/FormGroupsSelect/FormGroupsSelect.tsx index 1bddbe35c..1454c1a2f 100644 --- a/src/apps/work/src/lib/components/form/FormGroupsSelect/FormGroupsSelect.tsx +++ b/src/apps/work/src/lib/components/form/FormGroupsSelect/FormGroupsSelect.tsx @@ -23,7 +23,6 @@ import { } from '../../../models' import { createGroup, - fetchGroupById, fetchGroups, } from '../../../services' import { FormFieldWrapper } from '../FormFieldWrapper' @@ -178,26 +177,37 @@ export const FormGroupsSelect: FC = (props: FormGroupsSel let isMounted = true - Promise.all(missingGroupIds.map(async groupId => { - try { - const group = await fetchGroupById(groupId) - - return toOption(group) - } catch { - return { - label: groupId, - value: groupId, - } - } - })) - .then(resolvedOptions => { + fetchGroups() + .then(accessibleGroups => { if (!isMounted) { return } + const accessibleGroupsById = new Map( + accessibleGroups.map(group => [group.id, toOption(group)]), + ) + const resolvedOptions = missingGroupIds.map(groupId => ( + accessibleGroupsById.get(groupId) || { + label: groupId, + value: groupId, + } + )) + setOptionCache(previousOptions => mergeOptions(previousOptions, resolvedOptions)) }) - .catch(() => undefined) + .catch(() => { + if (!isMounted) { + return + } + + setOptionCache(previousOptions => mergeOptions( + previousOptions, + missingGroupIds.map(groupId => ({ + label: groupId, + value: groupId, + })), + )) + }) return () => { isMounted = false diff --git a/src/apps/work/src/lib/services/groups.service.spec.ts b/src/apps/work/src/lib/services/groups.service.spec.ts new file mode 100644 index 000000000..c4925ffe0 --- /dev/null +++ b/src/apps/work/src/lib/services/groups.service.spec.ts @@ -0,0 +1,87 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { xhrGetPaginatedAsync } from '~/libs/core' + +import { fetchGroups } from './groups.service' + +jest.mock('~/libs/core', () => ({ + xhrCreateInstance: jest.fn(() => ({ + defaults: { + headers: { + common: {}, + }, + }, + })), + xhrDeleteAsync: jest.fn(), + xhrGetAsync: jest.fn(), + xhrGetPaginatedAsync: jest.fn(), + xhrPatchAsync: jest.fn(), + xhrPostAsync: jest.fn(), + xhrPutAsync: jest.fn(), +}), { + virtual: true, +}) +jest.mock('../constants', () => ({ + GROUPS_API_URL: 'https://example.com/groups', +})) + +describe('fetchGroups', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('merges all paginated accessible group results when hydrating saved group ids', async () => { + const mockedGetPaginated = xhrGetPaginatedAsync as jest.Mock + + mockedGetPaginated + .mockResolvedValueOnce({ + data: [ + { + id: 'group-1', + name: ' Hide Challenges ', + }, + ], + page: 1, + perPage: 1000, + total: 2, + totalPages: 2, + }) + .mockResolvedValueOnce({ + data: [ + { + id: 'group-2', + name: 'QA - Public', + }, + ], + page: 2, + perPage: 1000, + total: 2, + totalPages: 2, + }) + + await expect(fetchGroups({ + name: 'Hide', + })) + .resolves + .toEqual([ + expect.objectContaining({ + id: 'group-1', + name: 'Hide Challenges', + }), + expect.objectContaining({ + id: 'group-2', + name: 'QA - Public', + }), + ]) + + expect(mockedGetPaginated) + .toHaveBeenNthCalledWith( + 1, + 'https://example.com/groups?page=1&perPage=1000&name=Hide', + ) + expect(mockedGetPaginated) + .toHaveBeenNthCalledWith( + 2, + 'https://example.com/groups?page=2&perPage=1000&name=Hide', + ) + }) +}) diff --git a/src/apps/work/src/lib/services/groups.service.ts b/src/apps/work/src/lib/services/groups.service.ts index 2a09f376a..35202fb77 100644 --- a/src/apps/work/src/lib/services/groups.service.ts +++ b/src/apps/work/src/lib/services/groups.service.ts @@ -193,24 +193,50 @@ function normalizeGroupMember(value: unknown): GroupMember | undefined { } export async function fetchGroups(filters?: { name?: string }): Promise { - const query = new URLSearchParams({ - page: '1', - perPage: String(GROUPS_PER_PAGE), - }) - - const groupNameFilter = filters?.name?.trim() - if (groupNameFilter) { - query.set('name', groupNameFilter) - } - try { - const response = await xhrGetPaginatedAsync( - `${GROUPS_API_URL}?${query.toString()}`, + const buildGroupsUrl = (page: number): string => { + const query = new URLSearchParams({ + page: String(page), + perPage: String(GROUPS_PER_PAGE), + }) + const groupNameFilter = filters?.name?.trim() + + if (groupNameFilter) { + query.set('name', groupNameFilter) + } + + return `${GROUPS_API_URL}?${query.toString()}` + } + + const firstPageResponse = await xhrGetPaginatedAsync( + buildGroupsUrl(1), ) + const firstPageGroups = (firstPageResponse.data || []) + .map(group => normalizeGroup(group)) + .filter((group): group is Group => !!group) - return (response.data || []) + if ((firstPageResponse.totalPages || 1) <= 1) { + return firstPageGroups + } + + const extraPageNumbers = Array.from({ + length: firstPageResponse.totalPages - 1, + }, (_, index) => index + 2) + + const extraPageResponses = await Promise.all(extraPageNumbers + .map(pageNumber => xhrGetPaginatedAsync( + buildGroupsUrl(pageNumber), + ))) + + const extraPageGroups = extraPageResponses + .flatMap(response => response.data || []) .map(group => normalizeGroup(group)) .filter((group): group is Group => !!group) + + return [ + ...firstPageGroups, + ...extraPageGroups, + ] } catch (error) { throw normalizeError(error, 'Failed to fetch groups') } From 19223a6630f8daf3b2a8b072bd012f1b96ac60bf Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sun, 19 Apr 2026 17:34:19 +1000 Subject: [PATCH 30/81] PM-4834: Replay refreshed challenge schedule after dirty edits clear What was broken Editing a challenge could still appear to save successfully while keeping stale phase end dates in the editor when the same challenge id was re-fetched during an edit session. In that state QA could update registration or submission end dates, save, and still see the old values afterward. Root cause (if identifiable) The earlier fix persisted scheduled end dates into the update payload, but the form intentionally skipped rehydrating same-id challenge refreshes while the form was dirty. Those skipped refreshes were discarded instead of being replayed once the form became clean again, so later same-id responses could leave the form on stale schedule data. What was changed Queued same-id challenge refreshes that arrive while the form is dirty and replay them after the form becomes clean again. Also keyed the hydration effect to the incoming challenge object so refreshed snapshots still apply even when the backend payload does not advance the challenge updated timestamp. Documented the refreshed hydration behavior in the Challenge Editor README. Any added/updated tests Added a ChallengeEditorForm regression test covering a same-id challenge refresh that arrives while the schedule is dirty and verifying the refreshed phase end date is applied after the form becomes clean again. --- .../challenges/ChallengeEditorPage/README.md | 8 +- .../components/ChallengeEditorForm.spec.tsx | 99 ++++++++++++- .../components/ChallengeEditorForm.tsx | 136 +++++++++++------- 3 files changed, 182 insertions(+), 61 deletions(-) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index 5b45c9246..34ea54747 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -15,9 +15,11 @@ footer actions. Manual saves from an existing `/edit` route, including trailing-slash variants, navigate back to the matching `/view` route after the update succeeds. When challenge detail revalidation returns a fresher snapshot for the same challenge id, the form rehydrates from that - updated payload while still avoiding resets over in-progress edits. Local post-create draft state - remains visible until a fetched challenge payload is available, so the create route can expand to - the full editor immediately after the initial draft is created. + updated payload while still avoiding resets over in-progress edits, then reapplies that snapshot + once the form becomes clean again even if the refreshed payload did not bump the challenge's + `updated` timestamp. Local post-create draft state remains visible until a fetched challenge + payload is available, so the create route can expand to the full editor immediately after the + initial draft is created. - `components/*Field.tsx`: field-level components for each challenge section. - `components/ReviewersField/*`: tabbed human/AI review configuration. Human reviewers stay on the challenge form, while AI reviewer configs load/save through the review API and sync saved AI workflows back into the challenge `reviewers` array. Existing AI configs are reloaded once per saved challenge even if the challenge payload is temporarily missing synced AI reviewer rows, while still avoiding empty-config lookups for unsaved challenges, ordinary parent rerenders in edit mode, and same-session re-fetches right after a config is intentionally removed. Removing an AI config also detaches the synced AI workflow reviewers from the challenge. In read-only view mode the tab switcher remains clickable so users can inspect AI config details inside the disabled challenge form, and the review summary surfaces the human-review table, AI workflow details, resolved scorecard names, review flow, and estimated reviewer cost without requiring edits. Repeated human-review rows that share the same resource role now consume persisted challenge-resource assignments in row order so every assigned reviewer still appears once in the summary, and mixed legacy resource layouts continue into the generic `Reviewer` fallback pool when a phase-specific role runs out of persisted assignments. The editor hydration, editable tab, summary, and post-save reset now tolerate persisted resource rows that only expose role names, member handles, or member ids instead of the full modern payload shape, so refreshed drafts and newly saved drafts reopen with the saved reviewer assignments intact. The AI-gating failure path keeps the locked state grouped under the gate so the diagram matches the legacy work-manager layout, including `AI_GATING` configs whose workflows do not explicitly mark `isGating`. On narrow screens the review-flow diagram switches to a compact portrait branch: submission stays full width, the `AI Gate` and `Locked` states sit side by side as narrower cards, the `< threshold` connector sits between those two cards, and the human-review path continues only from the gate column. When AI reviewers exist without a persisted AI screening phase, the schedule editor injects a virtual `AI Screening` row after submission phases. This `Review` section is hidden for `Task` and `Marathon Match` challenges because those flows use dedicated reviewer assignment UIs. - `ChallengeEditorPage.module.scss` and `components/ChallengeEditorForm.module.scss`: page and form layout styling, including the grouped `Prizes & Billing` layout that keeps the challenge-prizes and copilot-fee inputs at fixed widths on larger screens, preserves whitespace to the right, and moves the billing summary underneath them. 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 133f1af22..9423dd9d7 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 @@ -251,15 +251,56 @@ jest.mock('./ChallengeScheduleSection', () => ({ control: formContext.control, name: 'phases', }) as Array<{ + duration?: number + phaseId?: string scheduledEndDate?: string + scheduledStartDate?: string }> | undefined + const handleSetDirtyPhaseEnd = (): void => { + const currentPhases = formContext.getValues('phases') as typeof phases + + formContext.setValue('phases', (currentPhases || []).map((phase, index) => ( + index === 0 + ? { + ...phase, + duration: 1440, + phaseId: phase?.phaseId || 'submission-phase-id', + scheduledEndDate: '2026-04-18T04:58:51.000Z', + scheduledStartDate: phase?.scheduledStartDate || '2026-04-11T04:58:51.000Z', + } + : phase + )), { + shouldDirty: true, + shouldValidate: true, + }) + } + + const handleMarkFormClean = (): void => { + formContext.reset(formContext.getValues()) + } return ( -
+ <> +
+ + + ) }, })) @@ -2295,6 +2336,54 @@ describe('ChallengeEditorForm', () => { }) }) + it('reapplies a same-id challenge refresh after the form becomes clean again', async () => { + const user = userEvent.setup() + const initialChallenge = { + ...validDraftChallenge, + phases: [{ + duration: 1440, + name: 'Submission', + phaseId: 'submission-phase-id', + scheduledEndDate: '2026-04-17T04:58:51.000Z', + scheduledStartDate: '2026-04-11T04:58:51.000Z', + }], + } as Challenge + const refreshedChallenge = { + ...initialChallenge, + phases: [{ + ...initialChallenge.phases?.[0], + scheduledEndDate: '2026-04-19T04:58:51.000Z', + }], + } as Challenge + + const renderResult = render( + + + , + ) + + await user.click(screen.getByTestId('mock-dirty-phase-end')) + + expect(screen.getByTestId('challenge-schedule-section')) + .toHaveAttribute('data-first-phase-end', '2026-04-18T04:58:51.000Z') + + renderResult.rerender( + + + , + ) + + expect(screen.getByTestId('challenge-schedule-section')) + .toHaveAttribute('data-first-phase-end', '2026-04-18T04:58:51.000Z') + + await user.click(screen.getByTestId('mock-clean-form')) + + await waitFor(() => { + expect(screen.getByTestId('challenge-schedule-section')) + .toHaveAttribute('data-first-phase-end', '2026-04-19T04:58:51.000Z') + }) + }) + it('returns to view mode after saving a new draft from the create route', async () => { const user = userEvent.setup() 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 bb3b80b77..89e09d9d7 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -1366,6 +1366,7 @@ export const ChallengeEditorForm: FC = ( const onSavingChange = props.onSavingChange const formElementRef = useRef(null) const challengeRef = useRef(props.challenge) + const pendingChallengeRefreshRef = useRef() const defaultedDiscussionForumTypeIdRef = useRef() const fallbackProjectId = useMemo( () => normalizeProjectId(props.projectId) || normalizeProjectId(props.challenge?.projectId), @@ -1919,60 +1920,11 @@ export const ChallengeEditorForm: FC = ( }, [], ) - - useEffect(() => { - if (!onSavingChange) { - return undefined - } - - onSavingChange(isSaving) - - return () => { - onSavingChange(false) - } - }, [ - isSaving, - onSavingChange, - ]) - - useEffect(() => { - challengeRef.current = props.challenge - }, [props.challenge]) - - useEffect(() => { - currentChallengeIdRef.current = currentChallengeId - }, [currentChallengeId]) - - useEffect(() => { - projectBillingAccountRef.current = projectBillingAccount - }, [projectBillingAccount]) - - useEffect(() => { - resourceRolesRef.current = resourceRoles - }, [resourceRoles]) - - useEffect(() => { - isFormDirtyRef.current = formState.isDirty - }, [formState.isDirty]) - - useEffect(() => { - applyPersistedSingleAssignmentsRef.current = applyPersistedSingleAssignments - }, [applyPersistedSingleAssignments]) - - useEffect(() => { + const hydrateChallengeSnapshot = useCallback(( + challenge?: Challenge, + ): (() => void) => { let isActive = true - const challenge = challengeRef.current const challengeId = challenge?.id - const isRefreshingCurrentChallenge = !!challengeId - && challengeId === currentChallengeIdRef.current - && isFormDirtyRef.current - - if (isRefreshingCurrentChallenge) { - return () => { - isActive = false - } - } - const baseFormData = applyProjectBillingToChallengeFormData( transformChallengeToFormData(challenge), projectBillingAccountRef.current, @@ -1986,6 +1938,7 @@ export const ChallengeEditorForm: FC = ( if (!challengeId) { setIsInitialResourceHydrationPending(false) + return () => { isActive = false } @@ -2035,10 +1988,87 @@ export const ChallengeEditorForm: FC = ( return () => { isActive = false } + }, [reset]) + + useEffect(() => { + if (!onSavingChange) { + return undefined + } + + onSavingChange(isSaving) + + return () => { + onSavingChange(false) + } }, [ + isSaving, + onSavingChange, + ]) + + useEffect(() => { + challengeRef.current = props.challenge + }, [props.challenge]) + + useEffect(() => { + currentChallengeIdRef.current = currentChallengeId + }, [currentChallengeId]) + + useEffect(() => { + projectBillingAccountRef.current = projectBillingAccount + }, [projectBillingAccount]) + + useEffect(() => { + resourceRolesRef.current = resourceRoles + }, [resourceRoles]) + + useEffect(() => { + isFormDirtyRef.current = formState.isDirty + }, [formState.isDirty]) + + useEffect(() => { + applyPersistedSingleAssignmentsRef.current = applyPersistedSingleAssignments + }, [applyPersistedSingleAssignments]) + + useEffect(() => { + const challenge = challengeRef.current + const challengeId = challenge?.id + const isRefreshingCurrentChallenge = !!challengeId + && challengeId === currentChallengeIdRef.current + && isFormDirtyRef.current + + if (isRefreshingCurrentChallenge) { + pendingChallengeRefreshRef.current = challenge + + return undefined + } + + pendingChallengeRefreshRef.current = undefined + + return hydrateChallengeSnapshot(challenge) + }, [ + hydrateChallengeSnapshot, + props.challenge, props.challenge?.id, props.challenge?.updated, - reset, + ]) + + useEffect(() => { + if (formState.isDirty) { + return undefined + } + + const pendingChallengeRefresh = pendingChallengeRefreshRef.current + + if (!pendingChallengeRefresh) { + return undefined + } + + pendingChallengeRefreshRef.current = undefined + + return hydrateChallengeSnapshot(pendingChallengeRefresh) + }, [ + formState.isDirty, + hydrateChallengeSnapshot, ]) useEffect(() => { From 6cccc16d1a8ed62e93a112b39d035a3c7765538c Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Sun, 19 Apr 2026 21:57:38 +0300 Subject: [PATCH 31/81] PM-4879 - disable ai workflows --- .../work/src/lib/models/AiReview.model.ts | 1 + .../work/src/lib/models/Reviewer.model.ts | 1 + .../services/ai-review-templates.service.ts | 1 + .../src/lib/services/challenges.service.ts | 1 + .../src/lib/services/reviewers.service.ts | 1 + .../components/ReviewersField/AiReviewTab.tsx | 68 ++++++++++++++++--- 6 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/apps/work/src/lib/models/AiReview.model.ts b/src/apps/work/src/lib/models/AiReview.model.ts index d784c170f..c4842fd31 100644 --- a/src/apps/work/src/lib/models/AiReview.model.ts +++ b/src/apps/work/src/lib/models/AiReview.model.ts @@ -37,6 +37,7 @@ export interface AiReviewTemplate { challengeTrack: string challengeType: string createdAt?: string | Date + disabled?: boolean description: string formula?: Record id: string diff --git a/src/apps/work/src/lib/models/Reviewer.model.ts b/src/apps/work/src/lib/models/Reviewer.model.ts index fe195c291..9c2cbb799 100644 --- a/src/apps/work/src/lib/models/Reviewer.model.ts +++ b/src/apps/work/src/lib/models/Reviewer.model.ts @@ -30,6 +30,7 @@ export interface Scorecard { } export interface Workflow { + disabled?: boolean id: string name: string scorecardId?: string diff --git a/src/apps/work/src/lib/services/ai-review-templates.service.ts b/src/apps/work/src/lib/services/ai-review-templates.service.ts index cf2f40cb5..b92cfa18d 100644 --- a/src/apps/work/src/lib/services/ai-review-templates.service.ts +++ b/src/apps/work/src/lib/services/ai-review-templates.service.ts @@ -132,6 +132,7 @@ function normalizeTemplate( challengeType, createdAt: normalizeText(typedTemplate.createdAt), description: normalizeText(typedTemplate.description) || '', + disabled: normalizeBoolean(typedTemplate.disabled) === true, formula: typeof typedTemplate.formula === 'object' && typedTemplate.formula ? typedTemplate.formula as Record : undefined, diff --git a/src/apps/work/src/lib/services/challenges.service.ts b/src/apps/work/src/lib/services/challenges.service.ts index 2ffb70c0d..56ab56ad1 100644 --- a/src/apps/work/src/lib/services/challenges.service.ts +++ b/src/apps/work/src/lib/services/challenges.service.ts @@ -404,6 +404,7 @@ function normalizeWorkflow(workflow: Partial): Workflow | undefined { } return { + disabled: (workflow as Record).disabled === true, id, name, scorecardId: workflow.scorecardId !== undefined && workflow.scorecardId !== null diff --git a/src/apps/work/src/lib/services/reviewers.service.ts b/src/apps/work/src/lib/services/reviewers.service.ts index cbdac908f..c1cc3f6f5 100644 --- a/src/apps/work/src/lib/services/reviewers.service.ts +++ b/src/apps/work/src/lib/services/reviewers.service.ts @@ -89,6 +89,7 @@ function normalizeWorkflow(workflow: Partial): Workflow | undefined { } return { + disabled: (workflow as Record).disabled === true, id, name, scorecardId: workflow.scorecardId !== undefined && workflow.scorecardId !== null 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 3f4de24ca..d6b527e22 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 @@ -541,6 +541,22 @@ export const AiReviewTab: FC = ( () => templates.find(template => template.id === configuration.templateId), [configuration.templateId, templates], ) + const activeTemplates = useMemo( + () => templates.filter(template => template.disabled !== true), + [templates], + ) + const activeWorkflows = useMemo( + () => availableWorkflows.filter(workflow => workflow.disabled !== true), + [availableWorkflows], + ) + const activeWorkflowIdSet = useMemo( + () => new Set(activeWorkflows.map(workflow => normalizeReviewerText(workflow.id))), + [activeWorkflows], + ) + const activeTemplateIdSet = useMemo( + () => new Set(activeTemplates.map(template => normalizeReviewerText(template.id))), + [activeTemplates], + ) const normalizedConfiguration = useMemo( (): SaveAiReviewConfigInput | undefined => ( normalizedChallengeId @@ -554,10 +570,46 @@ export const AiReviewTab: FC = ( [configuration, configurationMode, normalizedChallengeId], ) const validationErrors = useMemo( - () => (normalizedConfiguration - ? validateAiReviewConfiguration(normalizedConfiguration) - : []), - [normalizedConfiguration], + () => { + if (!normalizedConfiguration) { + return [] + } + + const errors = validateAiReviewConfiguration(normalizedConfiguration) + + const selectedTemplateId = normalizeReviewerText(configuration.templateId) + if ( + configurationMode === 'template' + && selectedTemplateId + && !activeTemplateIdSet.has(selectedTemplateId) + ) { + errors.push('Selected AI review template is deactivated. Please select an active template.') + } + + if (configurationMode === 'manual') { + const hasDeactivatedWorkflow = (configuration.workflows || []) + .map(workflow => normalizeReviewerText(workflow.workflowId)) + .filter(Boolean) + .some(workflowId => !activeWorkflowIdSet.has(workflowId)) + + if (hasDeactivatedWorkflow) { + errors.push( + 'One or more selected AI workflows are deactivated. ' + + 'Please select active workflows only.', + ) + } + } + + return errors + }, + [ + activeTemplateIdSet, + activeWorkflowIdSet, + configuration.templateId, + configuration.workflows, + configurationMode, + normalizedConfiguration, + ], ) const hasPersistedConfigForCurrentChallenge = useMemo( () => ( @@ -654,7 +706,7 @@ export const AiReviewTab: FC = ( const handleTemplateSelect = useCallback( (templateId: string): void => { - const selected = templates.find(template => template.id === templateId) + const selected = activeTemplates.find(template => template.id === templateId) if (!selected) { setConfiguration(previousConfiguration => ({ ...previousConfiguration, @@ -672,7 +724,7 @@ export const AiReviewTab: FC = ( workflows: selected.workflows.map(toDraftWorkflow), }) }, - [templates], + [activeTemplates], ) const performModeSwitch = useCallback(async (targetMode: ConfigurationMode): Promise => { @@ -1123,7 +1175,7 @@ export const AiReviewTab: FC = ( value={configuration.templateId || ''} > - {templates.map(template => ( + {activeTemplates.map(template => ( @@ -1191,7 +1243,7 @@ export const AiReviewTab: FC = ( {(configuration.workflows || []).map((workflow, index) => ( Date: Sun, 19 Apr 2026 22:23:12 +0300 Subject: [PATCH 32/81] PM-4879 - pr feedback --- .../components/ReviewersField/AiReviewTab.tsx | 4 ++++ 1 file changed, 4 insertions(+) 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 d6b527e22..84b9275d9 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 @@ -581,6 +581,8 @@ export const AiReviewTab: FC = ( if ( configurationMode === 'template' && selectedTemplateId + && !templatesLoading + && templates.length > 0 && !activeTemplateIdSet.has(selectedTemplateId) ) { errors.push('Selected AI review template is deactivated. Please select an active template.') @@ -609,6 +611,8 @@ export const AiReviewTab: FC = ( configuration.workflows, configurationMode, normalizedConfiguration, + templates, + templatesLoading, ], ) const hasPersistedConfigForCurrentChallenge = useMemo( From 42d30489bea8f92b96cb6a446a41965631048bf2 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Sun, 19 Apr 2026 22:30:17 +0300 Subject: [PATCH 33/81] fix loading --- .../components/ReviewersField/AiReviewTab.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 84b9275d9..832985e17 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 @@ -588,7 +588,11 @@ export const AiReviewTab: FC = ( errors.push('Selected AI review template is deactivated. Please select an active template.') } - if (configurationMode === 'manual') { + if ( + configurationMode === 'manual' + && !isWorkflowsLoading + && availableWorkflows.length > 0 + ) { const hasDeactivatedWorkflow = (configuration.workflows || []) .map(workflow => normalizeReviewerText(workflow.workflowId)) .filter(Boolean) @@ -610,6 +614,8 @@ export const AiReviewTab: FC = ( configuration.templateId, configuration.workflows, configurationMode, + availableWorkflows, + isWorkflowsLoading, normalizedConfiguration, templates, templatesLoading, From 336af61348acdc1fd2aa1fb43c8290e58333e789 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 20 Apr 2026 10:05:59 +1000 Subject: [PATCH 34/81] feat(work): add billing account budget visibility and line items modal - Add budget display with color coding next to billing account info - Green: >30% remaining, Orange: 10-30% remaining, Red: <10% remaining - Add info button to view line items modal with: - Summary: Locked, Consumed, Remaining budget totals - Sortable table with Amount, Status (Locked/Consumed), Challenge ID link, Date - Add BillingAccountLineItemsModal component - Add useFetchBillingAccountDetails hook - Extend BillingAccount service with details types and line items combiner Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../BillingAccountLineItemsModal.module.scss | 193 ++++++++++++ .../BillingAccountLineItemsModal.tsx | 297 ++++++++++++++++++ .../BillingAccountLineItemsModal/index.ts | 1 + ...ectBillingAccountExpiredNotice.module.scss | 41 +++ .../ProjectBillingAccountExpiredNotice.tsx | 131 +++++++- src/apps/work/src/lib/components/index.ts | 1 + src/apps/work/src/lib/hooks/index.ts | 1 + .../hooks/useFetchBillingAccountDetails.ts | 58 ++++ .../lib/services/billing-accounts.service.ts | 64 +++- 9 files changed, 770 insertions(+), 17 deletions(-) create mode 100644 src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.module.scss create mode 100644 src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx create mode 100644 src/apps/work/src/lib/components/BillingAccountLineItemsModal/index.ts create mode 100644 src/apps/work/src/lib/hooks/useFetchBillingAccountDetails.ts diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.module.scss b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.module.scss new file mode 100644 index 000000000..22f110332 --- /dev/null +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.module.scss @@ -0,0 +1,193 @@ +@import '@libs/ui/styles/includes'; + +.overlay { + align-items: center; + background: rgb(44 44 44 / 45%); + bottom: 0; + display: flex; + justify-content: center; + left: 0; + padding: 20px; + position: fixed; + right: 0; + top: 0; + z-index: 1000; +} + +.container { + background: $tc-white; + border-radius: 12px; + display: flex; + flex-direction: column; + max-height: 90vh; + max-width: 800px; + width: 100%; +} + +.header { + align-items: center; + border-bottom: 1px solid $black-10; + display: flex; + justify-content: space-between; + padding: 18px 20px 12px; +} + +.title { + color: $black-80; + font-size: 18px; + margin: 0; +} + +.closeButton { + background: none; + border: none; + cursor: pointer; + padding: 4px; +} + +.closeIcon { + color: $black-60; + height: 20px; + width: 20px; +} + +.closeButton:hover .closeIcon { + color: $black-80; +} + +.summary { + background: $black-5; + border-bottom: 1px solid $black-10; + display: flex; + gap: 24px; + padding: 16px 20px; +} + +.summaryItem { + display: flex; + flex-direction: column; + gap: 4px; +} + +.summaryLabel { + color: $black-60; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; +} + +.summaryValue { + color: $black-80; + font-size: 18px; + font-weight: 600; +} + +.body { + flex: 1; + overflow-y: auto; + padding: 16px 20px; +} + +.emptyState { + color: $black-60; + font-size: 14px; + padding: 24px 0; + text-align: center; +} + +.table { + border-collapse: collapse; + width: 100%; +} + +.table th, +.table td { + border-bottom: 1px solid $black-10; + font-size: 14px; + padding: 12px 8px; + text-align: left; +} + +.table th { + color: $black-60; + font-weight: 600; +} + +.table td { + color: $black-80; +} + +.table th:first-child, +.table td:first-child { + padding-left: 0; +} + +.table th:last-child, +.table td:last-child { + padding-right: 0; +} + +.sortButton { + align-items: center; + background: none; + border: none; + color: $black-60; + cursor: pointer; + display: inline-flex; + font-size: 14px; + font-weight: 600; + gap: 4px; + padding: 0; +} + +.sortButton:hover { + color: $black-80; +} + +.sortIcon { + height: 14px; + width: 14px; +} + +.statusLocked, +.statusConsumed { + align-items: center; + border-radius: 4px; + display: inline-flex; + font-size: 12px; + font-weight: 500; + gap: 4px; + padding: 4px 8px; +} + +.statusLocked { + background: #fef3c7; + color: #b45309; +} + +.statusConsumed { + background: #d1fae5; + color: #047857; +} + +.statusIcon { + height: 14px; + width: 14px; +} + +.challengeLink { + color: #137d60; + font-weight: 500; + text-decoration: none; +} + +.challengeLink:hover { + text-decoration: underline; +} + +.footer { + border-top: 1px solid $black-10; + display: flex; + justify-content: flex-end; + padding: 16px 20px; +} diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx new file mode 100644 index 000000000..17659d3ac --- /dev/null +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx @@ -0,0 +1,297 @@ +import { + FC, + MouseEvent, + useCallback, + useMemo, + useState, +} from 'react' + +import { + Button, + IconOutline, + IconSolid, +} from '~/libs/ui' + +import { + BillingAccountDetails, + BillingAccountLineItem, + combineBillingAccountLineItems, +} from '../../services' + +import styles from './BillingAccountLineItemsModal.module.scss' + +type SortField = 'amount' | 'type' | 'createdAt' +type SortOrder = 'asc' | 'desc' + +export interface BillingAccountLineItemsModalProps { + billingAccountDetails: BillingAccountDetails + onClose: () => void + workBaseUrl?: string +} + +function formatCurrency(amount: number): string { + return new Intl.NumberFormat('en-US', { + currency: 'USD', + maximumFractionDigits: 2, + minimumFractionDigits: 2, + style: 'currency', + }) + .format(amount) +} + +function formatDate(dateString: string): string { + const date = new Date(dateString) + const year = date.getFullYear() + const month = String(date.getMonth() + 1) + .padStart(2, '0') + const day = String(date.getDate()) + .padStart(2, '0') + return `${year}-${month}-${day}` +} + +function compareByAmount(a: BillingAccountLineItem, b: BillingAccountLineItem): number { + return a.amount - b.amount +} + +function compareByType(a: BillingAccountLineItem, b: BillingAccountLineItem): number { + return a.type.localeCompare(b.type) +} + +function compareByCreatedAt(a: BillingAccountLineItem, b: BillingAccountLineItem): number { + const dateA = new Date(a.createdAt) + const dateB = new Date(b.createdAt) + return dateA.getTime() - dateB.getTime() +} + +function sortLineItems( + items: BillingAccountLineItem[], + sortBy: SortField, + sortOrder: SortOrder, +): BillingAccountLineItem[] { + return [...items].sort((a, b) => { + let comparison = 0 + + switch (sortBy) { + case 'amount': + comparison = compareByAmount(a, b) + break + case 'type': + comparison = compareByType(a, b) + break + case 'createdAt': + comparison = compareByCreatedAt(a, b) + break + default: + comparison = 0 + } + + return sortOrder === 'asc' ? comparison : -comparison + }) +} + +function buildChallengeUrl(workBaseUrl: string, challengeId: string): string { + const baseUrl = workBaseUrl.replace(/\/$/, '') + return `${baseUrl}/challenges/${encodeURIComponent(challengeId)}` +} + +export const BillingAccountLineItemsModal: FC = ( + props: BillingAccountLineItemsModalProps, +) => { + const [sortBy, setSortBy] = useState('createdAt') + const [sortOrder, setSortOrder] = useState('desc') + + const lineItems = useMemo( + () => combineBillingAccountLineItems(props.billingAccountDetails), + [props.billingAccountDetails], + ) + + const sortedLineItems = useMemo( + () => sortLineItems(lineItems, sortBy, sortOrder), + [lineItems, sortBy, sortOrder], + ) + + const workBaseUrl = props.workBaseUrl || window.location.origin + + const handleContainerClick = useCallback( + (event: MouseEvent): void => { + event.stopPropagation() + }, + [], + ) + + const handleSortAmount = useCallback((): void => { + if (sortBy === 'amount') { + setSortOrder(current => (current === 'asc' ? 'desc' : 'asc')) + } else { + setSortBy('amount') + setSortOrder('desc') + } + }, [sortBy]) + + const handleSortType = useCallback((): void => { + if (sortBy === 'type') { + setSortOrder(current => (current === 'asc' ? 'desc' : 'asc')) + } else { + setSortBy('type') + setSortOrder('desc') + } + }, [sortBy]) + + const handleSortCreatedAt = useCallback((): void => { + if (sortBy === 'createdAt') { + setSortOrder(current => (current === 'asc' ? 'desc' : 'asc')) + } else { + setSortBy('createdAt') + setSortOrder('desc') + } + }, [sortBy]) + + const renderSortIcon = useCallback((field: SortField): JSX.Element | undefined => { + if (field !== sortBy) { + return undefined + } + + return sortOrder === 'asc' + ? + : + }, [sortBy, sortOrder]) + + return ( +
+
+
+

Billing Account Details

+ +
+ +
+
+ Locked + + {formatCurrency(props.billingAccountDetails.lockedBudget)} + +
+
+ Consumed + + {formatCurrency(props.billingAccountDetails.consumedBudget)} + +
+
+ Remaining + + {formatCurrency(props.billingAccountDetails.totalBudgetRemaining)} + +
+
+ +
+ {sortedLineItems.length === 0 ? ( +
+ No line items found for this billing account. +
+ ) : ( + + + + + + + + + + + {sortedLineItems.map(item => ( + + + + + + + ))} + +
+ + + + Challenge ID + +
{formatCurrency(item.amount)} + + {item.type === 'locked' ? ( + + ) : ( + + )} + {item.type === 'locked' ? 'Locked' : 'Consumed'} + + + + {item.challengeId} + + {formatDate(item.createdAt)}
+ )} +
+ +
+
+
+
+ ) +} + +export default BillingAccountLineItemsModal diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/index.ts b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/index.ts new file mode 100644 index 000000000..6a622481a --- /dev/null +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/index.ts @@ -0,0 +1 @@ +export * from './BillingAccountLineItemsModal' diff --git a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.module.scss b/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.module.scss index 4a4be4b8b..f5e303fe1 100644 --- a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.module.scss +++ b/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.module.scss @@ -49,3 +49,44 @@ .requestCopilotLink:hover { color: #0f5e48; } + +.budgetDisplay { + border-radius: 4px; + font-size: 13px; + font-weight: 600; + padding: 4px 8px; +} + +.budgetHealthy { + background: #d1fae5; + color: #047857; +} + +.budgetWarning { + background: #fef3c7; + color: #b45309; +} + +.budgetCritical { + background: #fee4e2; + color: #b42318; +} + +.infoButton { + align-items: center; + background: none; + border: none; + cursor: pointer; + display: inline-flex; + padding: 2px; +} + +.infoIcon { + color: #4a5568; + height: 18px; + width: 18px; +} + +.infoButton:hover .infoIcon { + color: #137d60; +} diff --git a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.tsx b/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.tsx index 2a824973d..23820888c 100644 --- a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.tsx +++ b/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.tsx @@ -1,21 +1,28 @@ import { FC, + useCallback, useMemo, + useState, } from 'react' import { Link } from 'react-router-dom' +import { IconOutline } from '~/libs/ui' + import { + useFetchBillingAccountDetails, useFetchBillingAccounts, useFetchProjectBillingAccount, } from '../../hooks' -import { - getProjectBillingAccountChallengeIssue, - getProjectBillingAccountNoticeMessage, -} from '../../utils/project-billing-account.utils' import type { + UseFetchBillingAccountDetailsResult, UseFetchBillingAccountsResult, UseFetchProjectBillingAccountResult, } from '../../hooks' +import { + getProjectBillingAccountChallengeIssue, + getProjectBillingAccountNoticeMessage, +} from '../../utils/project-billing-account.utils' +import { BillingAccountLineItemsModal } from '../BillingAccountLineItemsModal' import styles from './ProjectBillingAccountExpiredNotice.module.scss' @@ -26,6 +33,8 @@ interface ProjectBillingAccountExpiredNoticeProps { projectId: string } +type BudgetStatus = 'healthy' | 'warning' | 'critical' + function normalizeOptionalString(value: unknown): string | undefined { if (value === undefined || value === null) { return undefined @@ -37,16 +46,52 @@ function normalizeOptionalString(value: unknown): string | undefined { return normalizedValue || undefined } +function formatCurrency(amount: number): string { + return new Intl.NumberFormat('en-US', { + currency: 'USD', + maximumFractionDigits: 0, + minimumFractionDigits: 0, + style: 'currency', + }) + .format(amount) +} + +function getBudgetStatus(remaining: number, total: number): BudgetStatus { + if (total <= 0) { + return 'healthy' + } + + const percentage = (remaining / total) * 100 + + if (percentage < 10) { + return 'critical' + } + + if (percentage < 30) { + return 'warning' + } + + return 'healthy' +} + export const ProjectBillingAccountExpiredNotice: FC = ( props: ProjectBillingAccountExpiredNoticeProps, ) => { + const [isModalOpen, setIsModalOpen] = useState(false) + const projectBillingAccountResult: UseFetchProjectBillingAccountResult = useFetchProjectBillingAccount( props.projectId, ) const billingAccountsResult: UseFetchBillingAccountsResult = useFetchBillingAccounts() + const billingAccountDetailsResult: UseFetchBillingAccountDetailsResult = useFetchBillingAccountDetails( + props.billingAccountId, + ) + const billingAccount = projectBillingAccountResult.billingAccount + const billingAccountDetails = billingAccountDetailsResult.billingAccountDetails const normalizedBillingAccountId = normalizeOptionalString(props.billingAccountId) const normalizedBillingAccountName = normalizeOptionalString(props.billingAccountName) + const billingAccountNameFromLookup: string | undefined = useMemo( (): string | undefined => { if (!normalizedBillingAccountId) { @@ -64,9 +109,34 @@ export const ProjectBillingAccountExpiredNotice: FC { + if (!billingAccountDetails) { + return undefined + } + + const totalBudget = Number(billingAccountDetails.budget) || 0 + const remaining = Number(billingAccountDetails.totalBudgetRemaining) || 0 + const status = getBudgetStatus(remaining, totalBudget) + + return { + remaining, + status, + totalBudget, + } + }, [billingAccountDetails]) + + const handleOpenModal = useCallback((): void => { + setIsModalOpen(true) + }, []) + + const handleCloseModal = useCallback((): void => { + setIsModalOpen(false) + }, []) + if (billingAccountIssue) { const noticeMessage = getProjectBillingAccountNoticeMessage(billingAccountIssue) const managedNoticeMessage = `${noticeMessage.slice(0, -1)}, ` @@ -93,18 +163,49 @@ export const ProjectBillingAccountExpiredNotice: FC } + const budgetStatusClass = budgetInfo + ? styles[`budget${budgetInfo.status.charAt(0) + .toUpperCase()}${budgetInfo.status.slice(1)}`] + : '' + return ( -
- - Billing account: - {' '} - {billingAccountName || 'Unknown'} - {' '} - / - {' '} - {normalizedBillingAccountId} - -
+ <> +
+ + Billing account: + {' '} + {billingAccountName || 'Unknown'} + {' '} + / + {' '} + {normalizedBillingAccountId} + + {budgetInfo && ( + <> + + {formatCurrency(budgetInfo.remaining)} + {' / '} + {formatCurrency(budgetInfo.totalBudget)} + {' remaining'} + + + + )} +
+ {isModalOpen && billingAccountDetails && ( + + )} + ) } diff --git a/src/apps/work/src/lib/components/index.ts b/src/apps/work/src/lib/components/index.ts index d79cc006d..ee606c570 100644 --- a/src/apps/work/src/lib/components/index.ts +++ b/src/apps/work/src/lib/components/index.ts @@ -1,3 +1,4 @@ +export * from './BillingAccountLineItemsModal' export * from './ChallengesFilter' export * from './ChallengesTable' export * from './EngagementCard' diff --git a/src/apps/work/src/lib/hooks/index.ts b/src/apps/work/src/lib/hooks/index.ts index 53ac1fe47..b742153d4 100644 --- a/src/apps/work/src/lib/hooks/index.ts +++ b/src/apps/work/src/lib/hooks/index.ts @@ -13,6 +13,7 @@ export * from './useFetchGroups' export * from './useFetchChallengeTracks' export * from './useFetchChallengeTypes' export * from './useFetchBillingAccounts' +export * from './useFetchBillingAccountDetails' export * from './useFetchEngagement' export * from './useFetchEngagements' export * from './useFetchProject' diff --git a/src/apps/work/src/lib/hooks/useFetchBillingAccountDetails.ts b/src/apps/work/src/lib/hooks/useFetchBillingAccountDetails.ts new file mode 100644 index 000000000..ca89026dd --- /dev/null +++ b/src/apps/work/src/lib/hooks/useFetchBillingAccountDetails.ts @@ -0,0 +1,58 @@ +import useSWR, { SWRResponse } from 'swr' + +import { + BillingAccountDetails, + fetchBillingAccountById, +} from '../services' + +export interface UseFetchBillingAccountDetailsResult { + billingAccountDetails: BillingAccountDetails | undefined + error: Error | undefined + isError: boolean + isLoading: boolean +} + +function normalizeId(billingAccountId: string | number | undefined): string { + if (billingAccountId === undefined || billingAccountId === null) { + return '' + } + + return String(billingAccountId) + .trim() +} + +/** + * Fetches detailed billing account information including locked and consumed amounts. + * + * @param billingAccountId The billing account identifier to fetch. + * @returns Billing account details with budget information and line items. + */ +export function useFetchBillingAccountDetails( + billingAccountId: string | number | undefined, +): UseFetchBillingAccountDetailsResult { + const normalizedId = normalizeId(billingAccountId) + + const swrKey = normalizedId + ? ['work/billing-account-details', normalizedId] + : undefined + + const { + data, + error, + }: SWRResponse + = useSWR( + swrKey, + () => fetchBillingAccountById(normalizedId), + { + errorRetryCount: 2, + shouldRetryOnError: true, + }, + ) + + return { + billingAccountDetails: data, + error, + isError: !!error, + isLoading: !!normalizedId && !data && !error, + } +} diff --git a/src/apps/work/src/lib/services/billing-accounts.service.ts b/src/apps/work/src/lib/services/billing-accounts.service.ts index b9d6e237b..679f6ecf1 100644 --- a/src/apps/work/src/lib/services/billing-accounts.service.ts +++ b/src/apps/work/src/lib/services/billing-accounts.service.ts @@ -13,6 +13,41 @@ export interface BillingAccount { [key: string]: unknown } +export interface BillingAccountLockedAmount { + id: string + billingAccountId: number + challengeId: string + amount: number + createdAt: string + updatedAt: string +} + +export interface BillingAccountConsumedAmount { + id: string + billingAccountId: number + challengeId: string + amount: number + createdAt: string + updatedAt: string +} + +export interface BillingAccountLineItem { + id: string + challengeId: string + amount: number + createdAt: string + type: 'locked' | 'consumed' +} + +export interface BillingAccountDetails extends BillingAccount { + budget: number + lockedBudget: number + consumedBudget: number + totalBudgetRemaining: number + lockedAmounts: BillingAccountLockedAmount[] + consumedAmounts: BillingAccountConsumedAmount[] +} + interface BillingAccountsResponse { data?: BillingAccount[] page?: number @@ -136,7 +171,7 @@ export async function searchBillingAccounts( */ export async function fetchBillingAccountById( billingAccountId: string, -): Promise { +): Promise { const normalizedBillingAccountId = billingAccountId.trim() if (!normalizedBillingAccountId) { @@ -144,10 +179,35 @@ export async function fetchBillingAccountById( } try { - return await xhrGetAsync( + return await xhrGetAsync( `${EnvironmentConfig.API.V6}/billing-accounts/${encodeURIComponent(normalizedBillingAccountId)}`, ) } catch (error) { throw normalizeError(error, 'Failed to fetch billing account details') } } + +/** + * Combines locked and consumed amounts into a unified line items array. + */ +export function combineBillingAccountLineItems( + details: BillingAccountDetails, +): BillingAccountLineItem[] { + const lockedItems: BillingAccountLineItem[] = (details.lockedAmounts || []).map(item => ({ + amount: Number(item.amount), + challengeId: item.challengeId, + createdAt: item.createdAt, + id: item.id, + type: 'locked' as const, + })) + + const consumedItems: BillingAccountLineItem[] = (details.consumedAmounts || []).map(item => ({ + amount: Number(item.amount), + challengeId: item.challengeId, + createdAt: item.createdAt, + id: item.id, + type: 'consumed' as const, + })) + + return [...lockedItems, ...consumedItems] +} From ee123b60bd4a659853d9bb0664d60324c78c52d6 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 20 Apr 2026 10:32:07 +1000 Subject: [PATCH 35/81] Update src/apps/work/src/lib/services/billing-accounts.service.ts Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/apps/work/src/lib/services/billing-accounts.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apps/work/src/lib/services/billing-accounts.service.ts b/src/apps/work/src/lib/services/billing-accounts.service.ts index 679f6ecf1..555671afe 100644 --- a/src/apps/work/src/lib/services/billing-accounts.service.ts +++ b/src/apps/work/src/lib/services/billing-accounts.service.ts @@ -197,7 +197,7 @@ export function combineBillingAccountLineItems( amount: Number(item.amount), challengeId: item.challengeId, createdAt: item.createdAt, - id: item.id, + id: `locked-${item.id}`, type: 'locked' as const, })) @@ -205,7 +205,7 @@ export function combineBillingAccountLineItems( amount: Number(item.amount), challengeId: item.challengeId, createdAt: item.createdAt, - id: item.id, + id: `consumed-${item.id}`, type: 'consumed' as const, })) From 0abf84a9107f26516a354f0d12c3ebc9c9c3d2b8 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 20 Apr 2026 10:34:51 +1000 Subject: [PATCH 36/81] Switch to use work.topcoder-dev.com / work.topcoder.com --- .../components/ChallengeEditorForm.spec.tsx | 113 +++++++++++++++--- .../components/ChallengeEditorForm.tsx | 32 ++++- src/config/environments/default.env.ts | 2 +- 3 files changed, 128 insertions(+), 19 deletions(-) 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 9423dd9d7..1919c3bc0 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 @@ -1697,6 +1697,15 @@ describe('ChallengeEditorForm', () => { roleId: 'copilot-role-id', }], }) + mockedFetchResourceRolesService.mockResolvedValue([{ + id: 'copilot-role-id', + name: 'Copilot', + }]) + mockedFetchResourcesService.mockResolvedValue([{ + challengeId: '12345', + memberId: '40158994', + roleId: 'copilot-role-id', + }]) mockedPatchChallenge.mockResolvedValue({ ...validDraftChallenge, copilot: 'resolved-copilot', @@ -1713,8 +1722,12 @@ describe('ChallengeEditorForm', () => { , ) - expect(screen.getByLabelText('Copilot Field')) - .toHaveValue('resolved-copilot') + await waitFor(() => { + expect(screen.getByLabelText('Copilot Field')) + .toHaveValue('40158994') + }) + await user.clear(screen.getByLabelText('Copilot Field')) + await user.type(screen.getByLabelText('Copilot Field'), 'resolved-copilot') await user.type(screen.getByLabelText('Challenge Name'), ' updated') await user.click(screen.getByRole('button', { name: 'Save Challenge' })) @@ -1722,22 +1735,86 @@ describe('ChallengeEditorForm', () => { expect(mockedPatchChallenge) .toHaveBeenCalledTimes(1) }) - expect(mockedDeleteResource) - .toHaveBeenCalledWith({ - challengeId: '12345', - memberId: '40158994', - roleId: 'copilot-role-id', - }) - expect(mockedCreateResource) - .toHaveBeenCalledWith({ - challengeId: '12345', - memberHandle: 'resolved-copilot', - roleId: 'copilot-role-id', - }) + await waitFor(() => { + expect(mockedDeleteResource) + .toHaveBeenCalledWith({ + challengeId: '12345', + memberId: '40158994', + roleId: 'copilot-role-id', + }) + }) + await waitFor(() => { + expect(mockedCreateResource) + .toHaveBeenCalledWith({ + challengeId: '12345', + memberHandle: 'resolved-copilot', + roleId: 'copilot-role-id', + }) + }) expect(mockedDeleteResource.mock.invocationCallOrder[0]) .toBeLessThan(mockedCreateResource.mock.invocationCallOrder[0]) }) + it('creates a copilot resource from the selected dropdown value even when cached resources are stale', async () => { + const user = userEvent.setup() + + mockedUseFetchResourceRoles.mockReturnValue({ + error: undefined, + isError: false, + isLoading: false, + resourceRoles: [{ + id: 'copilot-role-id', + name: 'Copilot', + }], + }) + mockedUseFetchResources.mockReturnValue({ + error: undefined, + isError: false, + isLoading: false, + mutate: jest.fn(), + resources: [{ + challengeId: '12345', + memberHandle: 'selected-copilot', + roleId: 'copilot-role-id', + }], + }) + mockedFetchResourceRolesService.mockResolvedValue([{ + id: 'copilot-role-id', + name: 'Copilot', + }]) + mockedFetchResourcesService.mockResolvedValue([]) + mockedPatchChallenge.mockResolvedValue({ + ...validDraftChallenge, + copilot: 'selected-copilot', + }) + + render( + + + , + ) + + await user.type(screen.getByLabelText('Copilot Field'), 'selected-copilot') + await user.click(screen.getByRole('button', { name: 'Save Challenge' })) + + await waitFor(() => { + expect(mockedPatchChallenge) + .toHaveBeenCalledWith('12345', expect.objectContaining({ + copilot: 'selected-copilot', + })) + }) + await waitFor(() => { + expect(mockedCreateResource) + .toHaveBeenCalledWith({ + challengeId: '12345', + memberHandle: 'selected-copilot', + roleId: 'copilot-role-id', + }) + }) + expect(mockedDeleteResource) + .not.toHaveBeenCalled() + }) + it('rehydrates persisted reviewer assignments from fresh resources after saving a draft', async () => { const user = userEvent.setup() @@ -1820,9 +1897,13 @@ describe('ChallengeEditorForm', () => { .toHaveValue(validDraftChallenge.name) }) expect(mockedFetchResourceRolesService) - .toHaveBeenCalledTimes(1) + .toHaveBeenCalledTimes(2) + expect(mockedFetchResourcesService) + .toHaveBeenCalledTimes(2) + expect(mockedFetchResourcesService) + .toHaveBeenNthCalledWith(1, '12345') expect(mockedFetchResourcesService) - .toHaveBeenCalledWith('12345') + .toHaveBeenNthCalledWith(2, '12345') expect(screen.getByTestId('reviewers-field') .getAttribute('data-reviewers')) .toContain('"memberId":"manual-reviewer-member-id"') 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 89e09d9d7..146345a7b 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -235,6 +235,8 @@ interface SingleAssignmentConfig { interface SyncSingleAssignmentResourceParams extends Omit { challengeId: string nextValue?: string + resourceRolesOverride?: ResourceRole[] + resourcesOverride?: Resource[] } interface PersistCreatedChallengeCopilotParams { @@ -1768,8 +1770,10 @@ export const ChallengeEditorForm: FC = ( const syncSingleAssignmentResource = useCallback(async ( params: SyncSingleAssignmentResourceParams, ): Promise => { - const resolvedResourceRoles = await loadSingleAssignmentResourceRoles() - const resolvedResources = await loadSingleAssignmentResources(params.challengeId) + const resolvedResourceRoles = params.resourceRolesOverride + || await loadSingleAssignmentResourceRoles() + const resolvedResources = params.resourcesOverride + || await loadSingleAssignmentResources(params.challengeId) const currentAssignment = resolvePersistedResourceAssignment({ resourceRoles: resolvedResourceRoles, resources: resolvedResources, @@ -1837,10 +1841,29 @@ export const ChallengeEditorForm: FC = ( loadSingleAssignmentResourceRoles, loadSingleAssignmentResources, ]) + /** + * Synchronizes single-member assignments against the latest persisted challenge resources. + * + * The edit flow keeps a SWR cache of resources for the Resources tab, but challenge saves + * should compare against the freshest backend state so a newly selected copilot still creates + * the required `Copilot` resource even when the local cache is stale. + * + * @param challengeId saved challenge identifier whose assignments should be synchronized. + * @param formData current form snapshot containing the selected assignment values. + * @returns Resolves after all changed single-member assignments are saved and the local + * resource cache is revalidated. + */ const syncDraftSingleAssignments = useCallback(async ( challengeId: string, formData: ChallengeEditorFormData, ): Promise => { + const [ + persistedResources, + persistedResourceRoles, + ] = await Promise.all([ + fetchResources(challengeId), + loadSingleAssignmentResourceRoles(), + ]) const resourceSyncOperations = getSingleAssignmentConfigs( isTaskSingleAssignmentChallenge(formData), ) @@ -1850,6 +1873,8 @@ export const ChallengeEditorForm: FC = ( undefined, config.roleNames, getSingleAssignmentResourceValueFields(config), + persistedResources, + persistedResourceRoles, ) return hasSameNormalizedValue(nextValue, persistedValue) @@ -1857,6 +1882,8 @@ export const ChallengeEditorForm: FC = ( : syncSingleAssignmentResource({ challengeId, nextValue, + resourceRolesOverride: persistedResourceRoles, + resourcesOverride: persistedResources, resourceValueFields: config.resourceValueFields, roleNames: config.roleNames, valueField: config.valueField, @@ -1873,6 +1900,7 @@ export const ChallengeEditorForm: FC = ( }, [ getPersistedAssignmentValueByFields, isTaskSingleAssignmentChallenge, + loadSingleAssignmentResourceRoles, mutateChallengeResources, syncSingleAssignmentResource, ]) diff --git a/src/config/environments/default.env.ts b/src/config/environments/default.env.ts index 399dee2d1..d29f0f7a8 100644 --- a/src/config/environments/default.env.ts +++ b/src/config/environments/default.env.ts @@ -190,7 +190,7 @@ export const ADMIN = { ONLINE_REVIEW_URL: 'https://software.topcoder-dev.com/review', REVIEW_UI_URL: 'https://review.topcoder-dev.com', SUBMISSION_SCAN_TOPIC: 'submission.scan.complete', - WORK_MANAGER_URL: 'https://challenges.topcoder-dev.com', + WORK_MANAGER_URL: 'https://work.topcoder-dev.com', } const REVIEW_OPPORTUNITIES_URL_DEFAULT = getReactEnv( From 5da946e04fa9b8170aadc4c1e662c13495de5de0 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 20 Apr 2026 11:00:38 +1000 Subject: [PATCH 37/81] Make sure to set copilot appropriately --- .../components/ChallengeEditorForm.spec.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 770a44919..c73e03528 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 @@ -1820,12 +1820,8 @@ describe('ChallengeEditorForm', () => { , ) - await waitFor(() => { - expect(screen.getByLabelText('Copilot Field')) - .toHaveValue('40158994') - }) - await user.clear(screen.getByLabelText('Copilot Field')) - await user.type(screen.getByLabelText('Copilot Field'), 'resolved-copilot') + expect(screen.getByLabelText('Copilot Field')) + .toHaveValue('resolved-copilot') await user.type(screen.getByLabelText('Challenge Name'), ' updated') await user.click(screen.getByRole('button', { name: 'Save Challenge' })) @@ -1892,7 +1888,11 @@ describe('ChallengeEditorForm', () => { , ) - await user.type(screen.getByLabelText('Copilot Field'), 'selected-copilot') + await waitFor(() => { + expect(screen.getByLabelText('Copilot Field')) + .toHaveValue('selected-copilot') + }) + await user.type(screen.getByLabelText('Challenge Name'), ' updated') await user.click(screen.getByRole('button', { name: 'Save Challenge' })) await waitFor(() => { From d94d62d00304261e52e07210086d3e0bc7ba1bd9 Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Mon, 20 Apr 2026 11:19:52 +0530 Subject: [PATCH 38/81] updated --- .../TalentSearchPage/TalentSearchPage.tsx | 98 +++++++++---------- 1 file changed, 45 insertions(+), 53 deletions(-) diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index fa88968ff..c74c70939 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -30,6 +30,8 @@ import styles from './TalentSearchPage.module.scss' export const TalentSearchPage: FC = () => { const skipNextAutoSearchRef = useRef(false) + const lastSearchedDescriptionRef = useRef('') + const countryLookup: CountryLookup[] | undefined = useCountryLookup() const [jobDescription, setJobDescription] = useState('') const [isExtractingSkills, setIsExtractingSkills] = useState(false) @@ -45,7 +47,7 @@ export const TalentSearchPage: FC = () => { const [results, setResults] = useState([]) const [totalResults, setTotalResults] = useState(0) const [currentPage, setCurrentPage] = useState(1) - + const [isLoading, setIsLoading] = useState(false) // const breadCrumb = useMemo( // () => [{ index: 1, label: 'Talent Search' }], // [], @@ -84,9 +86,7 @@ export const TalentSearchPage: FC = () => { return true }), [countryOptions, onlyActive, results, selectedCountry]) - const foundMembersCount = selectedCountry === 'all' - ? (totalResults || filteredResults.length) - : filteredResults.length + const hasMoreResults = results.length < totalResults const loadSkillOptions = useCallback(async (query: string): Promise => { @@ -196,8 +196,11 @@ export const TalentSearchPage: FC = () => { return } + lastSearchedDescriptionRef.current = normalizedDescription // <-- add this + setErrorMessage('') setIsExtractingSkills(true) + setIsLoading(true) // <-- add this try { const extractedSkillsResult = await extractSkillsFromText(normalizedDescription) @@ -243,6 +246,8 @@ export const TalentSearchPage: FC = () => { setHasSearched(true) } finally { setIsExtractingSkills(false) + setIsLoading(false) // <-- add this + } }, [isExtractingSkills, jobDescription, runMemberSearch]) @@ -276,7 +281,12 @@ export const TalentSearchPage: FC = () => { page: currentPage + 1, }) }, [currentPage, hasMoreResults, isLoadingMore, isSearchingMembers, runMemberSearch, selectedSkills]) - + const isSearchButtonDisabled = useMemo( + () => isExtractingSkills + || !jobDescription.trim() + || jobDescription.trim() === lastSearchedDescriptionRef.current, + [isExtractingSkills, jobDescription], + ) return ( { + + {filteredResults.length === 0 ? ( +
+

No matching talent found

+

Try changing filters or using a different job description.

+ ) : ( + <> +
+ {filteredResults.map(talent => ( + + ))} +
+ {hasMoreResults && ( +
+ +
+ )} + )} )} From f803d1d6e16736f5c1690863ba283c92415d937d Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 20 Apr 2026 08:54:36 +0300 Subject: [PATCH 39/81] PM-4539 - human overriden status --- .../AiReviewsTable/AiWorkflowRunStatus.tsx | 12 +++++++++++- .../AiReviewsTable/StatusLabel.module.scss | 5 +++++ .../lib/components/AiReviewsTable/StatusLabel.tsx | 2 +- .../CollapsibleAiReviewsRow.tsx | 6 +++++- .../SubmissionHistoryModal.tsx | 6 +++++- .../components/ReviewsSidebar/ReviewsSidebar.tsx | 7 +++++++ 6 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.tsx index 078d0ec29..976d3079d 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.tsx +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.tsx @@ -8,7 +8,7 @@ import StatusLabel from './StatusLabel' interface AiWorkflowRunStatusProps { run?: Pick - status?: 'passed' | 'pending' | 'failed-score' | 'failed' + status?: 'passed' | 'pending' | 'failed-score' | 'failed' | 'human-override' score?: number hideLabel?: boolean showScore?: boolean @@ -85,6 +85,16 @@ export const AiWorkflowRunStatus: FC = props => { action={props.action} /> )} + {displayStatus === 'human-override' && ( + } + hideLabel={props.hideLabel} + status={displayStatus} + label='Unlocked' + score={score} + action={props.action} + /> + )} ) } diff --git a/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss index b416ee905..107b2b86b 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss +++ b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss @@ -29,6 +29,11 @@ color: #e9ecef; border-color: #e9ecef; } + + &.human-override { + color: #7B61FF; + border-color: #7B61FF; + } } .aiIcon { diff --git a/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.tsx b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.tsx index 0458c02c9..404fa19ca 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.tsx +++ b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.tsx @@ -8,7 +8,7 @@ interface StatusLabelProps { hideLabel?: boolean label?: string score?: number - status: 'pending' | 'failed' | 'passed' | 'failed-score' + status: 'pending' | 'failed' | 'passed' | 'failed-score' | 'human-override' action?: ReactNode isAiIcon?: boolean } diff --git a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx index 326e86db1..084022e09 100644 --- a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx +++ b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx @@ -26,7 +26,7 @@ interface CollapsibleAiReviewsRowProps { export function normalizeDecisionStatus( status?: AiReviewDecisionStatus, -): 'passed' | 'failed-score' | 'pending' | 'failed' { +): 'passed' | 'failed-score' | 'pending' | 'failed' | 'human-override' { if (!status || status === 'PENDING') { return 'pending' } @@ -43,6 +43,10 @@ export function normalizeDecisionStatus( return 'failed' } + if (status === 'HUMAN_OVERRIDE') { + return 'human-override' + } + return 'pending' } diff --git a/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx b/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx index 4f0607625..52beb361c 100644 --- a/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx +++ b/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx @@ -104,7 +104,7 @@ function formatScore(value?: number | null): string { function normalizeDecisionStatus( status?: string | null, -): 'passed' | 'failed-score' | 'pending' | 'failed' { +): 'passed' | 'failed-score' | 'pending' | 'failed' | 'human-override' { if (!status || status === 'PENDING') { return 'pending' } @@ -121,6 +121,10 @@ function normalizeDecisionStatus( return 'failed' } + if (status === 'HUMAN_OVERRIDE') { + return 'human-override' + } + return 'pending' } diff --git a/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.tsx b/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.tsx index c3ce57b3f..df0f254e9 100644 --- a/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.tsx +++ b/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.tsx @@ -252,6 +252,13 @@ const ReviewsSidebar: FC = props => { status='pending' /> +
  • + } + label='Unlocked' + status='human-override' + /> +
  • Date: Mon, 20 Apr 2026 09:04:48 +0300 Subject: [PATCH 40/81] PM-4512 - Allow multiple comments in scorecard reply --- .../ScorecardQuestion/AiFeedback/AiFeedback.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx index a9bf48993..7b7803960 100644 --- a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx +++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx @@ -87,9 +87,13 @@ const AiFeedback: FC = props => { + {commentsArr.length > 0 && ( + + )} + { showReply && ( = props => { /> ) } - - {commentsArr.length > 0 && ( - - )} ) } From 8a78d19eddac56dbd0c47222bbd89df758c4241b Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Mon, 20 Apr 2026 11:35:29 +0530 Subject: [PATCH 41/81] PM-4882: Hide Search Results and Enable Search Button --- .../talent-search/TalentSearchPage/TalentSearchPage.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index c74c70939..a41aed087 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -31,7 +31,6 @@ import styles from './TalentSearchPage.module.scss' export const TalentSearchPage: FC = () => { const skipNextAutoSearchRef = useRef(false) const lastSearchedDescriptionRef = useRef('') - const countryLookup: CountryLookup[] | undefined = useCountryLookup() const [jobDescription, setJobDescription] = useState('') const [isExtractingSkills, setIsExtractingSkills] = useState(false) @@ -196,11 +195,11 @@ export const TalentSearchPage: FC = () => { return } - lastSearchedDescriptionRef.current = normalizedDescription // <-- add this + lastSearchedDescriptionRef.current = normalizedDescription setErrorMessage('') setIsExtractingSkills(true) - setIsLoading(true) // <-- add this + setIsLoading(true) try { const extractedSkillsResult = await extractSkillsFromText(normalizedDescription) @@ -246,7 +245,7 @@ export const TalentSearchPage: FC = () => { setHasSearched(true) } finally { setIsExtractingSkills(false) - setIsLoading(false) // <-- add this + setIsLoading(false) } }, [isExtractingSkills, jobDescription, runMemberSearch]) From b6608fc9903a8f7494ef39e3d98e2b7a4ed98583 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 20 Apr 2026 09:14:47 +0300 Subject: [PATCH 42/81] Fix show reply --- .../ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx index 7b7803960..0aa841c61 100644 --- a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx +++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx @@ -33,7 +33,7 @@ const AiFeedback: FC = props => { const commentsArr: any[] = (feedback?.comments) || [] const onShowReply = useCallback(() => { - setShowReply(!showReply) + setShowReply(prevShowReply => !prevShowReply) }, []) const onSubmitReply = useCallback(async (content: string) => { From 6067ad675f1b1569994ba8349ec722f5e9a934f4 Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Mon, 20 Apr 2026 11:46:41 +0530 Subject: [PATCH 43/81] PM-4882: Hide Search Results and Enable Search Button --- .../talent-search/TalentSearchPage/TalentSearchPage.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index a41aed087..41f2a6e44 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -134,6 +134,7 @@ export const TalentSearchPage: FC = () => { setIsLoadingMore(true) } else { setIsSearchingMembers(true) + setIsLoading(true) } setErrorMessage('') @@ -173,6 +174,7 @@ export const TalentSearchPage: FC = () => { setIsLoadingMore(false) } else { setIsSearchingMembers(false) + setIsLoading(false) } } }, [onlyActive, onlyOpenToWork]) @@ -187,6 +189,7 @@ export const TalentSearchPage: FC = () => { setTotalResults(0) setCurrentPage(1) setErrorMessage('') + lastSearchedDescriptionRef.current = '' }, []) const handleAiSearch = useCallback(async (): Promise => { @@ -199,7 +202,6 @@ export const TalentSearchPage: FC = () => { setErrorMessage('') setIsExtractingSkills(true) - setIsLoading(true) try { const extractedSkillsResult = await extractSkillsFromText(normalizedDescription) @@ -245,7 +247,6 @@ export const TalentSearchPage: FC = () => { setHasSearched(true) } finally { setIsExtractingSkills(false) - setIsLoading(false) } }, [isExtractingSkills, jobDescription, runMemberSearch]) @@ -322,6 +323,7 @@ export const TalentSearchPage: FC = () => { onClick={() => { setJobDescription('') setErrorMessage('') + lastSearchedDescriptionRef.current = '' }} > Clear From a096db229134143f07ac0131f7caf85c3537e388 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 20 Apr 2026 16:18:01 +1000 Subject: [PATCH 44/81] https://topcoder.atlassian.net/browse/PM-4903 --- .../work/src/lib/models/Engagement.model.ts | 2 +- .../src/lib/utils/engagement.utils.spec.ts | 34 +++++ .../work/src/lib/utils/engagement.utils.ts | 12 +- .../components/EngagementEditorForm.spec.tsx | 120 ++++++++++++++++++ .../components/EngagementEditorForm.tsx | 14 +- .../EngagementPaymentPage.spec.tsx | 118 +++++++++++++++-- .../EngagementPaymentPage.tsx | 3 +- 7 files changed, 283 insertions(+), 20 deletions(-) diff --git a/src/apps/work/src/lib/models/Engagement.model.ts b/src/apps/work/src/lib/models/Engagement.model.ts index d1bd7a742..c28831c41 100644 --- a/src/apps/work/src/lib/models/Engagement.model.ts +++ b/src/apps/work/src/lib/models/Engagement.model.ts @@ -16,7 +16,7 @@ export type EngagementStatus = export type ApplicationStatus = 'REJECTED' | 'SELECTED' | 'SUBMITTED' | 'UNDER_REVIEW' -export type AssignmentStatus = 'ACTIVE' | 'ASSIGNED' | 'COMPLETED' | 'TERMINATED' +export type AssignmentStatus = 'ACTIVE' | 'ASSIGNED' | 'COMPLETED' | 'OFFER_REJECTED' | 'SELECTED' | 'TERMINATED' export interface Assignment { agreementRate: string diff --git a/src/apps/work/src/lib/utils/engagement.utils.spec.ts b/src/apps/work/src/lib/utils/engagement.utils.spec.ts index 1e5c51375..2859ce0d1 100644 --- a/src/apps/work/src/lib/utils/engagement.utils.spec.ts +++ b/src/apps/work/src/lib/utils/engagement.utils.spec.ts @@ -5,7 +5,9 @@ import { import { formatEngagementStatus, fromEngagementStatusApi, + getCountableEngagementAssignments, getEngagementStatusPillVariant, + normalizeEngagement, toEngagementStatusApi, } from './engagement.utils' @@ -50,4 +52,36 @@ describe('engagement.utils status mappings', () => { expect(getEngagementStatusPillVariant('Pending Assignment')) .toBe('yellow') }) + + it('preserves assignment history while deriving assigned handles from active rows', () => { + const normalized = normalizeEngagement({ + assignedMemberHandles: ['stale_member'], + assignments: [ + { + id: 'assignment-active', + memberHandle: 'active_member', + status: 'ASSIGNED', + }, + { + id: 'assignment-completed', + memberHandle: 'completed_member', + status: 'COMPLETED', + }, + { + id: 'assignment-terminated', + memberHandle: 'terminated_member', + status: 'TERMINATED', + }, + ], + id: 'engagement-1', + } as any) + + expect(normalized.assignments.map(assignment => assignment.memberHandle)) + .toEqual(['active_member', 'completed_member', 'terminated_member']) + expect(normalized.assignedMemberHandles) + .toEqual(['active_member']) + expect(getCountableEngagementAssignments(normalized.assignments) + .map(assignment => assignment.memberHandle)) + .toEqual(['active_member']) + }) }) diff --git a/src/apps/work/src/lib/utils/engagement.utils.ts b/src/apps/work/src/lib/utils/engagement.utils.ts index acc2dc20d..df54c53f9 100644 --- a/src/apps/work/src/lib/utils/engagement.utils.ts +++ b/src/apps/work/src/lib/utils/engagement.utils.ts @@ -394,11 +394,15 @@ export function normalizeEngagement(data: Partial = {}): Engagement const skills = normalizeEngagementSkills(data) - const assignedMemberHandles = Array.isArray(data.assignedMemberHandles) - ? data.assignedMemberHandles - .map(value => normalizeString(value)) + const assignedMemberHandles = assignments.length > 0 + ? getCountableEngagementAssignments(assignments) + .map(assignment => normalizeString(assignment.memberHandle)) .filter(Boolean) - : [] + : (Array.isArray(data.assignedMemberHandles) + ? data.assignedMemberHandles + .map(value => normalizeString(value)) + .filter(Boolean) + : []) const countries = Array.isArray(data.countries) ? data.countries diff --git a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.spec.tsx b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.spec.tsx index 68b9559cc..e8da02aee 100644 --- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.spec.tsx +++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.spec.tsx @@ -132,6 +132,14 @@ jest.mock('../../../../lib/utils', () => ({ ? 'On Hold' : status ), + getCountableEngagementAssignments: (assignments: Array<{ status?: string }> = []) => ( + assignments.filter(assignment => !['COMPLETED', 'OFFER_REJECTED', 'TERMINATED'].includes( + String(assignment.status || '') + .trim() + .replace(/[\s-]+/g, '_') + .toUpperCase(), + )) + ), showErrorToast: jest.fn(), showSuccessToast: jest.fn(), })) @@ -492,6 +500,118 @@ describe('EngagementEditorForm', () => { }) }) + it('keeps terminal assignment history out of the edit payload', async () => { + const user = userEvent.setup() + const activeAssignment = { + agreementRate: '800', + durationMonths: 3, + endDate: '', + engagementId: 'engagement-history', + id: 'assignment-active', + memberHandle: 'active_member', + memberId: '111', + otherRemarks: 'active notes', + ratePerHour: '20', + standardHoursPerWeek: 40, + startDate: '2026-05-01T00:00:00.000Z', + status: 'ASSIGNED', + termsAccepted: true, + } + const terminatedAssignment = { + ...activeAssignment, + agreementRate: '600', + endDate: '2026-04-01T00:00:00.000Z', + id: 'assignment-terminated', + memberHandle: 'terminated_member', + memberId: '222', + status: 'TERMINATED', + terminationReason: 'Finished early', + } + + mockedUpdateEngagement.mockResolvedValue({ + anticipatedStart: 'Immediate', + assignedMemberHandles: ['active_member'], + assignments: [activeAssignment, terminatedAssignment], + compensationRange: '', + countries: ['US'], + createdAt: '', + description: 'History engagement description', + durationWeeks: 4, + id: 'engagement-history', + isPrivate: true, + projectId: '123', + requiredMemberCount: 2, + role: 'SOFTWARE_DEVELOPER', + skills: [ + { + id: 'skill-1', + name: 'React', + }, + ], + status: 'Open', + timezones: ['America/New_York'], + title: 'History engagement', + updatedAt: '', + workload: 'FULL_TIME', + } as any) + + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Save Engagement' })) + + await waitFor(() => { + expect(mockedUpdateEngagement) + .toHaveBeenCalled() + }) + + const payload = mockedUpdateEngagement.mock.calls[0][1] as { + assignedMemberHandles?: string[] + assignmentDetails?: Array<{ memberHandle: string }> + } + + expect(payload.assignedMemberHandles) + .toEqual(['active_member']) + expect(payload.assignmentDetails) + .toHaveLength(1) + expect(payload.assignmentDetails?.[0]) + .toEqual(expect.objectContaining({ + memberHandle: 'active_member', + })) + }) + it('redirects to the saved parent project engagements list after creating an engagement', async () => { const user = userEvent.setup() diff --git a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.tsx b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.tsx index 352a23a3a..6318a89e1 100644 --- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.tsx +++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.tsx @@ -49,6 +49,7 @@ import { } from '../../../../lib/services' import { formatEngagementStatus, + getCountableEngagementAssignments, showErrorToast, showSuccessToast, } from '../../../../lib/utils' @@ -224,6 +225,16 @@ function toAssignmentDetailsValue(assignment: EngagementAssignment): AssignmentD } } +/** + * Builds private-assignment form defaults from active assignment slots only. + * + * Historical completed or terminated assignments remain on the engagement + * response, but editing an engagement should only submit currently countable + * assignments so closed history rows are not modified. + * + * @param engagement engagement being edited, if one exists. + * @returns member handles and details for active private-assignment slots. + */ function getAssignmentDefaults(engagement: Engagement | undefined): { assignedMemberHandles: string[] assignmentDetails: AssignmentDetailsFormValue[] @@ -231,7 +242,8 @@ function getAssignmentDefaults(engagement: Engagement | undefined): { const assignments = engagement?.assignments if (Array.isArray(assignments) && assignments.length > 0) { - const assignmentDetails = assignments.map(toAssignmentDetailsValue) + const assignmentDetails = getCountableEngagementAssignments(assignments) + .map(toAssignmentDetailsValue) return { assignedMemberHandles: assignmentDetails.map(assignment => assignment.memberHandle), diff --git a/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.spec.tsx b/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.spec.tsx index 51a5bce9c..bea41f532 100644 --- a/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.spec.tsx +++ b/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.spec.tsx @@ -22,6 +22,9 @@ import { useFetchEngagement, useFetchProject, } from '../../../lib/hooks' +import { + partiallyUpdateEngagement, +} from '../../../lib/services' import { EditAssignmentModal, @@ -101,7 +104,7 @@ jest.mock('../../../lib/services', () => ({ })) jest.mock('../../../lib/utils', () => ({ - calculateAssignmentRatePerWeek: jest.fn((ratePerHour?: string, standardHoursPerWeek?: string) => { + calculateAssignmentRatePerWeek: (ratePerHour?: string, standardHoursPerWeek?: string) => { const rate = Number(ratePerHour || 0) const hours = Number(standardHoursPerWeek || 0) @@ -109,38 +112,46 @@ jest.mock('../../../lib/utils', () => ({ ? (rate * hours) .toFixed(2) : '' - }), - deserializeTentativeAssignmentDate: jest.fn((value?: string) => ( + }, + deserializeTentativeAssignmentDate: (value?: string) => ( value ? new Date(value) : undefined - )), - normalizeAssignmentStatus: jest.fn((status: string) => status), - sanitizePositiveNumericInput: jest.fn((value: string) => value), - serializeTentativeAssignmentDate: jest.fn((value: Date) => value.toISOString()), + ), + getCountableEngagementAssignments: (assignments: Array<{ status?: string }> = []) => ( + assignments.filter(assignment => !['COMPLETED', 'OFFER_REJECTED', 'TERMINATED'].includes( + String(assignment.status || '') + .trim() + .replace(/[\s-]+/g, '_') + .toUpperCase(), + )) + ), + normalizeAssignmentStatus: (status: string) => status, + sanitizePositiveNumericInput: (value: string) => value, + serializeTentativeAssignmentDate: (value: Date) => value.toISOString(), showErrorToast: jest.fn(), showSuccessToast: jest.fn(), - toPositiveInteger: jest.fn((value: string) => { + toPositiveInteger: (value: string) => { const parsed = Number.parseInt(value, 10) return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined - }), - toPositiveNumber: jest.fn((value: string) => { + }, + toPositiveNumber: (value: string) => { const parsed = Number(value) return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined - }), - toPositiveNumberWithMaxDecimalPlaces: jest.fn((value: string) => { + }, + toPositiveNumberWithMaxDecimalPlaces: (value: string) => { const parsed = Number(value) return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined - }), + }, })) jest.mock('../../../lib/utils/payment.utils', () => ({ @@ -165,6 +176,9 @@ const assignment: Assignment = { const mockedUseFetchEngagement = useFetchEngagement as jest.MockedFunction const mockedUseFetchProject = useFetchProject as jest.MockedFunction +const mockedPartiallyUpdateEngagement = partiallyUpdateEngagement as jest.MockedFunction< + typeof partiallyUpdateEngagement +> beforeEach(() => { jest.clearAllMocks() @@ -242,6 +256,84 @@ describe('EngagementPaymentPage', () => { .toBeNull() }) }) + + it('updates active assignment details without resubmitting terminal assignment history', async () => { + const mutateEngagement = jest.fn() + .mockResolvedValue(undefined) + const terminatedAssignment: Assignment = { + ...assignment, + agreementRate: '200.00', + endDate: '2026-04-01T00:00:00.000Z', + id: 'assignment-terminated', + memberHandle: 'finished_member', + memberId: '67890', + status: 'TERMINATED', + terminationReason: 'Completed elsewhere', + } + + mockedUseFetchEngagement.mockReturnValue({ + engagement: { + assignments: [assignment, terminatedAssignment], + title: 'Test Engagement', + }, + error: undefined, + isError: false, + isLoading: false, + mutate: mutateEngagement, + } as unknown as ReturnType) + + mockedUseFetchProject.mockReturnValue({ + error: undefined, + isLoading: false, + mutate: jest.fn(), + project: { + billingAccountId: 'billing-account-1', + name: 'Test Project', + }, + } as unknown as ReturnType) + + mockedPartiallyUpdateEngagement.mockResolvedValue({ + assignments: [assignment, terminatedAssignment], + title: 'Test Engagement', + } as any) + + render( + + + } + path='/projects/:projectId/engagements/:engagementId/assignments' + /> + + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + fireEvent.click(within(await screen.findByRole('dialog')) + .getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(mockedPartiallyUpdateEngagement) + .toHaveBeenCalled() + }) + + const payload = mockedPartiallyUpdateEngagement.mock.calls[0][1] as { + assignmentDetails?: Array<{ memberHandle?: string }> + } + + expect(payload.assignmentDetails) + .toHaveLength(1) + expect(payload.assignmentDetails?.[0]) + .toEqual(expect.objectContaining({ + memberHandle: 'testaws1', + })) + expect(payload.assignmentDetails) + .toEqual(expect.not.arrayContaining([ + expect.objectContaining({ + memberHandle: 'finished_member', + }), + ])) + }) }) describe('EditAssignmentModal', () => { diff --git a/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.tsx b/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.tsx index df538f47c..4b0f79127 100644 --- a/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.tsx +++ b/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.tsx @@ -50,6 +50,7 @@ import { import { calculateAssignmentRatePerWeek, deserializeTentativeAssignmentDate, + getCountableEngagementAssignments, normalizeAssignmentStatus, sanitizePositiveNumericInput, serializeTentativeAssignmentDate, @@ -244,7 +245,7 @@ function buildAssignmentDetailsUpdatePayload( const assignmentIdText = String(assignmentId) return { - assignmentDetails: assignments + assignmentDetails: getCountableEngagementAssignments(assignments) .map(assignment => { const baseEntry = buildAssignmentDetailsPayloadEntry(assignment) From 26b4225168d2c8e90d32588da7c077b157f2028a Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Mon, 20 Apr 2026 11:51:41 +0530 Subject: [PATCH 45/81] PM-4882: Hide Search Results and Enable Search Button --- .../pages/talent-search/TalentSearchPage/TalentSearchPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index 41f2a6e44..e01702baf 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -198,8 +198,6 @@ export const TalentSearchPage: FC = () => { return } - lastSearchedDescriptionRef.current = normalizedDescription - setErrorMessage('') setIsExtractingSkills(true) @@ -237,6 +235,8 @@ export const TalentSearchPage: FC = () => { return } + lastSearchedDescriptionRef.current = normalizedDescription + setHasSearched(true) skipNextAutoSearchRef.current = true await runMemberSearch(extractedOptions, { page: 1 }) From 5817f6fd69067e6478c162424de70fbdf671c8dc Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Mon, 20 Apr 2026 11:59:01 +0530 Subject: [PATCH 46/81] PM-4882: Hide Search Results and Enable Search Button --- .../TalentSearchPage/TalentSearchPage.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index e01702baf..cd01bd61c 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -30,7 +30,7 @@ import styles from './TalentSearchPage.module.scss' export const TalentSearchPage: FC = () => { const skipNextAutoSearchRef = useRef(false) - const lastSearchedDescriptionRef = useRef('') + const [lastSearchedDescription, setLastSearchedDescription] = useState('') const countryLookup: CountryLookup[] | undefined = useCountryLookup() const [jobDescription, setJobDescription] = useState('') const [isExtractingSkills, setIsExtractingSkills] = useState(false) @@ -189,7 +189,7 @@ export const TalentSearchPage: FC = () => { setTotalResults(0) setCurrentPage(1) setErrorMessage('') - lastSearchedDescriptionRef.current = '' + setLastSearchedDescription('') }, []) const handleAiSearch = useCallback(async (): Promise => { @@ -235,8 +235,7 @@ export const TalentSearchPage: FC = () => { return } - lastSearchedDescriptionRef.current = normalizedDescription - + setLastSearchedDescription(normalizedDescription) setHasSearched(true) skipNextAutoSearchRef.current = true await runMemberSearch(extractedOptions, { page: 1 }) @@ -284,8 +283,8 @@ export const TalentSearchPage: FC = () => { const isSearchButtonDisabled = useMemo( () => isExtractingSkills || !jobDescription.trim() - || jobDescription.trim() === lastSearchedDescriptionRef.current, - [isExtractingSkills, jobDescription], + || jobDescription.trim() === lastSearchedDescription, + [isExtractingSkills, jobDescription, lastSearchedDescription], ) return ( { onClick={() => { setJobDescription('') setErrorMessage('') - lastSearchedDescriptionRef.current = '' + setLastSearchedDescription('') }} > Clear From f2551396adfb2183d0f1fbf22a146062f9f0217a Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Mon, 20 Apr 2026 12:16:42 +0530 Subject: [PATCH 47/81] PM-4882: Hide Search Results and Enable Search Button --- .../pages/talent-search/TalentSearchPage/TalentSearchPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index cd01bd61c..cc4ff17fe 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -166,6 +166,7 @@ export const TalentSearchPage: FC = () => { setResults([]) setTotalResults(0) setCurrentPage(1) + setLastSearchedDescription('') } setErrorMessage('Failed to search matching members. Please try again.') @@ -235,10 +236,10 @@ export const TalentSearchPage: FC = () => { return } - setLastSearchedDescription(normalizedDescription) setHasSearched(true) skipNextAutoSearchRef.current = true await runMemberSearch(extractedOptions, { page: 1 }) + setLastSearchedDescription(normalizedDescription) } catch { // Prevent stale auto-search when extraction fails and loading flips to false. skipNextAutoSearchRef.current = true From 4360a5b0cec45aba27074ba18f535c7bc7878d6c Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Mon, 20 Apr 2026 12:36:51 +0530 Subject: [PATCH 48/81] PM-4882: Hide Search Results and Enable Search Button --- .../TalentSearchPage/TalentSearchPage.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index cc4ff17fe..259052031 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -107,7 +107,7 @@ export const TalentSearchPage: FC = () => { page?: number recentlyActive?: boolean }, - ): Promise => { + ): Promise => { const append = overrides?.append === true const openToWork = overrides?.openToWork ?? onlyOpenToWork const page = overrides?.page ?? 1 @@ -161,6 +161,7 @@ export const TalentSearchPage: FC = () => { }) setTotalResults(Number(response?.total || 0)) setCurrentPage(Number(response?.page || page)) + return true } catch { if (!append) { setResults([]) @@ -170,6 +171,7 @@ export const TalentSearchPage: FC = () => { } setErrorMessage('Failed to search matching members. Please try again.') + return false } finally { if (append) { setIsLoadingMore(false) @@ -238,8 +240,10 @@ export const TalentSearchPage: FC = () => { setHasSearched(true) skipNextAutoSearchRef.current = true - await runMemberSearch(extractedOptions, { page: 1 }) - setLastSearchedDescription(normalizedDescription) + const searchSucceeded = await runMemberSearch(extractedOptions, { page: 1 }) + if (searchSucceeded) { + setLastSearchedDescription(normalizedDescription) + } } catch { // Prevent stale auto-search when extraction fails and loading flips to false. skipNextAutoSearchRef.current = true From f64db6c890d71bafec3839a841641e38ca6fcf11 Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Mon, 20 Apr 2026 12:46:34 +0530 Subject: [PATCH 49/81] PM-4882: Hide Search Results and Enable Search Button --- .../pages/talent-search/TalentSearchPage/TalentSearchPage.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index 259052031..2b710831c 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -360,6 +360,9 @@ export const TalentSearchPage: FC = () => { const value = (event.target.value || []) as InputMultiselectOption[] setSelectedSkills(value) setHasSearched(value.length > 0) + if (value.length === 0) { + setLastSearchedDescription('') + } }} />
  • From 9093b5aa9bc9928db19a648015464d6972012d5f Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Mon, 20 Apr 2026 13:02:48 +0530 Subject: [PATCH 50/81] PM-4882: Hide Search Results and Enable Search Button --- .../TalentSearchPage/TalentSearchPage.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index 2b710831c..0fd1f438e 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -30,6 +30,8 @@ import styles from './TalentSearchPage.module.scss' export const TalentSearchPage: FC = () => { const skipNextAutoSearchRef = useRef(false) + const searchGenerationRef = useRef(0) // ← add this + const [lastSearchedDescription, setLastSearchedDescription] = useState('') const countryLookup: CountryLookup[] | undefined = useCountryLookup() const [jobDescription, setJobDescription] = useState('') @@ -183,6 +185,7 @@ export const TalentSearchPage: FC = () => { }, [onlyActive, onlyOpenToWork]) const clearAllFilters = useCallback((): void => { + searchGenerationRef.current += 1 setSelectedCountry('all') setOnlyOpenToWork(true) setOnlyActive(true) @@ -201,11 +204,15 @@ export const TalentSearchPage: FC = () => { return } + const generation = searchGenerationRef.current // ← capture before async work + setErrorMessage('') setIsExtractingSkills(true) try { const extractedSkillsResult = await extractSkillsFromText(normalizedDescription) + if (searchGenerationRef.current !== generation) return + const extractedSkills = Array.isArray(extractedSkillsResult?.matches) ? extractedSkillsResult.matches : [] @@ -241,11 +248,15 @@ export const TalentSearchPage: FC = () => { setHasSearched(true) skipNextAutoSearchRef.current = true const searchSucceeded = await runMemberSearch(extractedOptions, { page: 1 }) + if (searchGenerationRef.current !== generation) return + if (searchSucceeded) { setLastSearchedDescription(normalizedDescription) } } catch { // Prevent stale auto-search when extraction fails and loading flips to false. + if (searchGenerationRef.current !== generation) return + skipNextAutoSearchRef.current = true setErrorMessage('Failed to extract skills. Please try again.') setHasSearched(true) @@ -325,6 +336,7 @@ export const TalentSearchPage: FC = () => { secondary disabled={isExtractingSkills} onClick={() => { + searchGenerationRef.current += 1 setJobDescription('') setErrorMessage('') setLastSearchedDescription('') From c42a7bf8fc19d56be70c29d307f68ddc74c07edd Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Mon, 20 Apr 2026 13:13:28 +0530 Subject: [PATCH 51/81] PM-4882: Hide Search Results and Enable Search Button --- .../pages/talent-search/TalentSearchPage/TalentSearchPage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index 0fd1f438e..55ef81b0a 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -256,7 +256,6 @@ export const TalentSearchPage: FC = () => { } catch { // Prevent stale auto-search when extraction fails and loading flips to false. if (searchGenerationRef.current !== generation) return - skipNextAutoSearchRef.current = true setErrorMessage('Failed to extract skills. Please try again.') setHasSearched(true) From cc75c49f686ecac79848f28dfe417af5c1f600f8 Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Mon, 20 Apr 2026 14:24:01 +0530 Subject: [PATCH 52/81] PM-4882: Hide Search Results and Enable Search Button --- .../TalentSearchPage/TalentSearchPage.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index 55ef81b0a..e8c8a8675 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -105,16 +105,17 @@ export const TalentSearchPage: FC = () => { skillsToSearch: InputMultiselectOption[], overrides?: { append?: boolean + generation?: number openToWork?: boolean page?: number recentlyActive?: boolean }, ): Promise => { const append = overrides?.append === true + const generation = overrides?.generation const openToWork = overrides?.openToWork ?? onlyOpenToWork const page = overrides?.page ?? 1 const recentlyActive = overrides?.recentlyActive ?? onlyActive - const payload: MemberSearchPayload = { limit: MEMBER_SEARCH_LIMIT, openToWork, @@ -131,7 +132,6 @@ export const TalentSearchPage: FC = () => { skillSearchType: 'OR', verifiedProfile: true, } - if (append) { setIsLoadingMore(true) } else { @@ -140,11 +140,14 @@ export const TalentSearchPage: FC = () => { } setErrorMessage('') - try { const response = await searchMembers(payload) - const fetchedData = Array.isArray(response?.data) ? response.data : [] + // If generation was provided and has changed, discard stale results + if (generation !== undefined && searchGenerationRef.current !== generation) { + return false + } + const fetchedData = Array.isArray(response?.data) ? response.data : [] setResults(prevResults => { if (!append) { return fetchedData @@ -158,7 +161,6 @@ export const TalentSearchPage: FC = () => { merged.push(item) } }) - return merged }) setTotalResults(Number(response?.total || 0)) @@ -247,7 +249,7 @@ export const TalentSearchPage: FC = () => { setHasSearched(true) skipNextAutoSearchRef.current = true - const searchSucceeded = await runMemberSearch(extractedOptions, { page: 1 }) + const searchSucceeded = await runMemberSearch(extractedOptions, { generation, page: 1 }) if (searchGenerationRef.current !== generation) return if (searchSucceeded) { @@ -275,7 +277,7 @@ export const TalentSearchPage: FC = () => { return } - runMemberSearch(selectedSkills) + runMemberSearch(selectedSkills, { generation: searchGenerationRef.current }) }, [ hasSearched, isExtractingSkills, From c6baf31ec53166d4b429875d88fd0fc9664f4555 Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Mon, 20 Apr 2026 14:40:34 +0530 Subject: [PATCH 53/81] PM-4882: Hide Search Results and Enable Search Button --- .../pages/talent-search/TalentSearchPage/TalentSearchPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index e8c8a8675..ac2382d29 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -257,8 +257,8 @@ export const TalentSearchPage: FC = () => { } } catch { // Prevent stale auto-search when extraction fails and loading flips to false. - if (searchGenerationRef.current !== generation) return skipNextAutoSearchRef.current = true + if (searchGenerationRef.current !== generation) return setErrorMessage('Failed to extract skills. Please try again.') setHasSearched(true) } finally { From 6d87d2ce0114122568351104e0b34a3cb5bbfd9f Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Mon, 20 Apr 2026 12:18:44 +0300 Subject: [PATCH 54/81] adds claude-icon for LLMs --- public/llm-icons/claude-icon.svg | 1 + 1 file changed, 1 insertion(+) create mode 100644 public/llm-icons/claude-icon.svg diff --git a/public/llm-icons/claude-icon.svg b/public/llm-icons/claude-icon.svg new file mode 100644 index 000000000..1dfcc85bd --- /dev/null +++ b/public/llm-icons/claude-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file From c100f2ba44ae9787a0f4f2a686ec5748605f5cbe Mon Sep 17 00:00:00 2001 From: Harshit Chudasama Date: Mon, 20 Apr 2026 14:51:49 +0530 Subject: [PATCH 55/81] PM-4882: Hide Search Results and Enable Search Button --- .../talent-search/TalentSearchPage/TalentSearchPage.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index ac2382d29..c54a96a34 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -49,10 +49,6 @@ export const TalentSearchPage: FC = () => { const [totalResults, setTotalResults] = useState(0) const [currentPage, setCurrentPage] = useState(1) const [isLoading, setIsLoading] = useState(false) - // const breadCrumb = useMemo( - // () => [{ index: 1, label: 'Talent Search' }], - // [], - // ) const countryOptions = useMemo( (): InputSelectOption[] => [ { label: 'All Countries', value: 'all' }, @@ -206,7 +202,7 @@ export const TalentSearchPage: FC = () => { return } - const generation = searchGenerationRef.current // ← capture before async work + const generation = searchGenerationRef.current setErrorMessage('') setIsExtractingSkills(true) @@ -256,7 +252,6 @@ export const TalentSearchPage: FC = () => { setLastSearchedDescription(normalizedDescription) } } catch { - // Prevent stale auto-search when extraction fails and loading flips to false. skipNextAutoSearchRef.current = true if (searchGenerationRef.current !== generation) return setErrorMessage('Failed to extract skills. Please try again.') From ea0d25ab0db495e1e705e56b451ab5feb36be320 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 20 Apr 2026 12:27:09 +0300 Subject: [PATCH 56/81] PM-4799 - fix submissions order in iterrative reviews --- .../iterativeReviewFiltering.spec.ts | 119 ++++++++++++ .../iterativeReviewFiltering.ts | 171 +++++++++++++++++- 2 files changed, 289 insertions(+), 1 deletion(-) diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts index 338c4cfe0..5865ec9d2 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts @@ -119,6 +119,125 @@ describe('filterIterativeReviewRows', () => { .toBe('iterative-phase-1') }) + it('maps phase-less AI-failed submissions to iterative tabs by submission order', () => { + const multiIterativePhases: BackendPhase[] = [ + createPhase('submission-1', 'Submission'), + createPhase('iterative-1', 'Iterative Review', 'iterative-phase-1'), + createPhase('iterative-2', 'Iterative Review', 'iterative-phase-2'), + createPhase('review-1', 'Review', 'review-phase-1'), + ] + + const iterativeReviewer = createResource('iterative-resource-1', 'Iterative Reviewer') + const firstAiFailedSubmission: SubmissionInfo = { + ...createSubmission(iterativeReviewer.id), + id: 'submission-1', + status: 'AI_FAILED_REVIEW', + submittedDate: '2026-04-01T04:56:13.405Z', + } + + const secondAiFailedSubmission: SubmissionInfo = { + ...createSubmission(iterativeReviewer.id), + id: 'submission-2', + status: 'AI_FAILED_REVIEW', + submittedDate: '2026-04-01T04:57:13.405Z', + } + + const phase1Results = filterIterativeReviewRows({ + challengePhases: multiIterativePhases, + isPostMortemPhase: false, + phaseIdFilter: 'iterative-1', + reviewerResources: [iterativeReviewer], + sourceRows: [secondAiFailedSubmission, firstAiFailedSubmission], + }) + + const phase2Results = filterIterativeReviewRows({ + challengePhases: multiIterativePhases, + isPostMortemPhase: false, + phaseIdFilter: 'iterative-2', + reviewerResources: [iterativeReviewer], + sourceRows: [secondAiFailedSubmission, firstAiFailedSubmission], + }) + + expect(phase1Results) + .toHaveLength(1) + expect(phase1Results[0].id) + .toBe('submission-1') + + expect(phase2Results) + .toHaveLength(1) + expect(phase2Results[0].id) + .toBe('submission-2') + }) + + it('skips iterative phases that already have assigned reviews when mapping phase-less AI-failed submissions', () => { + const multiIterativePhases: BackendPhase[] = [ + createPhase('submission-1', 'Submission'), + createPhase('iterative-1', 'Iterative Review', 'iterative-phase-1'), + createPhase('iterative-2', 'Iterative Review', 'iterative-phase-2'), + createPhase('review-1', 'Review', 'review-phase-1'), + ] + + const iterativeReviewer = createResource('iterative-resource-1', 'Iterative Reviewer') + const assignedSubmission: SubmissionInfo = { + ...createSubmission('assigned-reviewer', 'iterative-phase-1'), + id: 'assigned-submission', + } + + const aiFailedSubmission: SubmissionInfo = { + ...createSubmission(iterativeReviewer.id), + id: 'ai-failed-submission', + status: 'AI_FAILED_REVIEW', + submittedDate: '2026-04-01T04:56:13.405Z', + } + + const phase1Results = filterIterativeReviewRows({ + challengePhases: multiIterativePhases, + isPostMortemPhase: false, + phaseIdFilter: 'iterative-1', + reviewerResources: [iterativeReviewer], + sourceRows: [assignedSubmission, aiFailedSubmission], + }) + + const phase2Results = filterIterativeReviewRows({ + challengePhases: multiIterativePhases, + isPostMortemPhase: false, + phaseIdFilter: 'iterative-2', + reviewerResources: [iterativeReviewer], + sourceRows: [assignedSubmission, aiFailedSubmission], + }) + + expect(phase1Results) + .toHaveLength(1) + expect(phase1Results[0].id) + .toBe('assigned-submission') + + expect(phase2Results) + .toHaveLength(1) + expect(phase2Results[0].id) + .toBe('ai-failed-submission') + }) + + it('keeps a phase-less AI-failed submission when only one iterative phase exists', () => { + const iterativeReviewer = createResource('iterative-resource-1', 'Iterative Reviewer') + const aiFailedSubmission: SubmissionInfo = { + ...createSubmission(iterativeReviewer.id), + status: 'AI_FAILED_REVIEW', + } + + const results = filterIterativeReviewRows({ + challengePhases, + isPostMortemPhase: false, + phaseIdFilter: 'iterative-1', + reviewerResources: [iterativeReviewer], + sourceRows: [aiFailedSubmission], + }) + + expect(results) + .toHaveLength(1) + expect(results[0].id) + .toBe(`submission-${iterativeReviewer.id}`) + }) + it('limits completed F2F rows to the supplied winning submission ids', () => { const iterativeReviewer = createResource('iterative-resource-1', 'Iterative Reviewer') const losingReviewer = createResource('iterative-resource-2', 'Iterative Reviewer') diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts index 64940ae67..64c26f29e 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts @@ -142,6 +142,167 @@ function countIterativeReviewPhases(challengePhases: BackendPhase[] | undefined) .includes('iterative review')).length } +interface OrderedIterativePhase { + id: string + phaseTypeId?: string +} + +function getOrderedIterativePhases(challengePhases: BackendPhase[] | undefined): OrderedIterativePhase[] { + const iterativePhases = (challengePhases ?? []) + .map((phase, index) => ({ + id: normalizeIdentifier(phase.id), + index, + phaseTypeId: normalizeIdentifier(phase.phaseId), + startedAt: parseSortableDate(phase.actualStartDate ?? phase.scheduledStartDate), + })) + .filter(phase => Boolean(phase.id)) + .filter(phase => (challengePhases?.[phase.index].name ?? '') + .toLowerCase() + .includes('iterative review')) + .sort((left, right) => { + const leftStartedAt = Number.isFinite(left.startedAt) + ? left.startedAt + : Number.POSITIVE_INFINITY + const rightStartedAt = Number.isFinite(right.startedAt) + ? right.startedAt + : Number.POSITIVE_INFINITY + + if (leftStartedAt !== rightStartedAt) { + return leftStartedAt - rightStartedAt + } + + return left.index - right.index + }) + + return iterativePhases + .map(phase => ({ + id: phase.id, + phaseTypeId: phase.phaseTypeId, + } as OrderedIterativePhase)) + .filter((phase): phase is OrderedIterativePhase => Boolean(phase.id)) +} + +function getAiFailedSubmissionIdsForSelectedIterativePhase( + sourceRows: SubmissionInfo[], + challengePhases: BackendPhase[] | undefined, + phaseIdFilterSet: Set, + aiReviewDecisionsBySubmissionId?: Record, +): Set { + const orderedIterativePhases = getOrderedIterativePhases(challengePhases) + if (!orderedIterativePhases.length) { + return new Set() + } + + const assignedIterativePhaseIds = new Set() + sourceRows.forEach(submission => { + const reviewPhaseId = normalizeIdentifier(submission.review?.phaseId) + if (!reviewPhaseId) { + return + } + + const matchedByPhaseId = orderedIterativePhases.find(phase => phase.id === reviewPhaseId) + if (matchedByPhaseId) { + assignedIterativePhaseIds.add(matchedByPhaseId.id) + return + } + + const matchedByPhaseTypeId = orderedIterativePhases.filter( + phase => phase.phaseTypeId === reviewPhaseId, + ) + if (matchedByPhaseTypeId.length === 1) { + assignedIterativePhaseIds.add(matchedByPhaseTypeId[0].id) + } + }) + + const unassignedIterativePhaseIds = orderedIterativePhases + .map(phase => phase.id) + .filter(phaseId => !assignedIterativePhaseIds.has(phaseId)) + + if (!unassignedIterativePhaseIds.length) { + return new Set() + } + + const selectedPhase = orderedIterativePhases.find(phase => phaseIdFilterSet.has(phase.id)) + ?? (() => { + const matchedByPhaseTypeId = orderedIterativePhases.filter( + phase => Boolean(phase.phaseTypeId && phaseIdFilterSet.has(phase.phaseTypeId)), + ) + + if (matchedByPhaseTypeId.length === 1) { + return matchedByPhaseTypeId[0] + } + + return undefined + })() + + const aiFailedRows = sourceRows + .filter(submission => !normalizeIdentifier(submission.review?.phaseId)) + .filter(submission => shouldTreatAsAiFailedSubmission(submission, aiReviewDecisionsBySubmissionId)) + .map((submission, index) => ({ + index, + reviewCreatedAt: parseSortableDate(submission.review?.createdAt), + submission, + submittedAt: parseSortableDate(submission.submittedDate), + })) + .sort((left, right) => { + const leftSubmittedAt = Number.isFinite(left.submittedAt) + ? left.submittedAt + : Number.POSITIVE_INFINITY + const rightSubmittedAt = Number.isFinite(right.submittedAt) + ? right.submittedAt + : Number.POSITIVE_INFINITY + + if (leftSubmittedAt !== rightSubmittedAt) { + return leftSubmittedAt - rightSubmittedAt + } + + const leftReviewCreatedAt = Number.isFinite(left.reviewCreatedAt) + ? left.reviewCreatedAt + : Number.POSITIVE_INFINITY + const rightReviewCreatedAt = Number.isFinite(right.reviewCreatedAt) + ? right.reviewCreatedAt + : Number.POSITIVE_INFINITY + + if (leftReviewCreatedAt !== rightReviewCreatedAt) { + return leftReviewCreatedAt - rightReviewCreatedAt + } + + return left.index - right.index + }) + + if (!aiFailedRows.length) { + return new Set() + } + + if (!selectedPhase) { + return new Set() + } + + const selectedPhaseIndex = unassignedIterativePhaseIds.findIndex(phaseId => phaseId === selectedPhase.id) + if (selectedPhaseIndex < 0) { + return new Set() + } + + if (unassignedIterativePhaseIds.length === 1) { + return new Set( + aiFailedRows + .map(row => normalizeIdentifier(row.submission.id)) + .filter((id): id is string => Boolean(id)), + ) + } + + const isLastIterativePhase = selectedPhaseIndex === unassignedIterativePhaseIds.length - 1 + const assignedRows = isLastIterativePhase + ? aiFailedRows.slice(selectedPhaseIndex) + : aiFailedRows.slice(selectedPhaseIndex, selectedPhaseIndex + 1) + + return new Set( + assignedRows + .map(row => normalizeIdentifier(row.submission.id)) + .filter((id): id is string => Boolean(id)), + ) +} + /** * Collect resource ids assigned to iterative-review roles. * @@ -288,6 +449,13 @@ export function filterIterativeReviewRows(args: FilterIterativeReviewRowsArgs): const iterativeReviewerResourceIds = collectIterativeReviewerResourceIds(reviewerResources) if (phaseIdFilterSet?.size) { + const aiFailedSubmissionIdsForSelectedPhase = getAiFailedSubmissionIdsForSelectedIterativePhase( + sourceRows, + challengePhases, + phaseIdFilterSet, + aiReviewDecisionsBySubmissionId, + ) + const filteredRows = sourceRows.filter(submission => { const reviewPhaseId = normalizeIdentifier(submission.review?.phaseId) if (reviewPhaseId) { @@ -295,7 +463,8 @@ export function filterIterativeReviewRows(args: FilterIterativeReviewRowsArgs): } if (shouldTreatAsAiFailedSubmission(submission, aiReviewDecisionsBySubmissionId)) { - return true + const submissionId = normalizeIdentifier(submission.id) + return submissionId ? aiFailedSubmissionIdsForSelectedPhase.has(submissionId) : false } // New WM F2F flows can surface assigned submissions before the review row has a phase id. From cd45f3b69039e5414dba2b460b435565ed2aa347 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 20 Apr 2026 12:34:16 +0300 Subject: [PATCH 57/81] lint --- .../iterativeReviewFiltering.spec.ts | 97 ++++++++++--------- 1 file changed, 50 insertions(+), 47 deletions(-) diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts index 5865ec9d2..40498ddc9 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts @@ -169,53 +169,56 @@ describe('filterIterativeReviewRows', () => { .toBe('submission-2') }) - it('skips iterative phases that already have assigned reviews when mapping phase-less AI-failed submissions', () => { - const multiIterativePhases: BackendPhase[] = [ - createPhase('submission-1', 'Submission'), - createPhase('iterative-1', 'Iterative Review', 'iterative-phase-1'), - createPhase('iterative-2', 'Iterative Review', 'iterative-phase-2'), - createPhase('review-1', 'Review', 'review-phase-1'), - ] - - const iterativeReviewer = createResource('iterative-resource-1', 'Iterative Reviewer') - const assignedSubmission: SubmissionInfo = { - ...createSubmission('assigned-reviewer', 'iterative-phase-1'), - id: 'assigned-submission', - } - - const aiFailedSubmission: SubmissionInfo = { - ...createSubmission(iterativeReviewer.id), - id: 'ai-failed-submission', - status: 'AI_FAILED_REVIEW', - submittedDate: '2026-04-01T04:56:13.405Z', - } - - const phase1Results = filterIterativeReviewRows({ - challengePhases: multiIterativePhases, - isPostMortemPhase: false, - phaseIdFilter: 'iterative-1', - reviewerResources: [iterativeReviewer], - sourceRows: [assignedSubmission, aiFailedSubmission], - }) - - const phase2Results = filterIterativeReviewRows({ - challengePhases: multiIterativePhases, - isPostMortemPhase: false, - phaseIdFilter: 'iterative-2', - reviewerResources: [iterativeReviewer], - sourceRows: [assignedSubmission, aiFailedSubmission], - }) - - expect(phase1Results) - .toHaveLength(1) - expect(phase1Results[0].id) - .toBe('assigned-submission') - - expect(phase2Results) - .toHaveLength(1) - expect(phase2Results[0].id) - .toBe('ai-failed-submission') - }) + it( + 'skips iterative phases that already have assigned reviews when mapping phase-less AI-failed submissions', + () => { + const multiIterativePhases: BackendPhase[] = [ + createPhase('submission-1', 'Submission'), + createPhase('iterative-1', 'Iterative Review', 'iterative-phase-1'), + createPhase('iterative-2', 'Iterative Review', 'iterative-phase-2'), + createPhase('review-1', 'Review', 'review-phase-1'), + ] + + const iterativeReviewer = createResource('iterative-resource-1', 'Iterative Reviewer') + const assignedSubmission: SubmissionInfo = { + ...createSubmission('assigned-reviewer', 'iterative-phase-1'), + id: 'assigned-submission', + } + + const aiFailedSubmission: SubmissionInfo = { + ...createSubmission(iterativeReviewer.id), + id: 'ai-failed-submission', + status: 'AI_FAILED_REVIEW', + submittedDate: '2026-04-01T04:56:13.405Z', + } + + const phase1Results = filterIterativeReviewRows({ + challengePhases: multiIterativePhases, + isPostMortemPhase: false, + phaseIdFilter: 'iterative-1', + reviewerResources: [iterativeReviewer], + sourceRows: [assignedSubmission, aiFailedSubmission], + }) + + const phase2Results = filterIterativeReviewRows({ + challengePhases: multiIterativePhases, + isPostMortemPhase: false, + phaseIdFilter: 'iterative-2', + reviewerResources: [iterativeReviewer], + sourceRows: [assignedSubmission, aiFailedSubmission], + }) + + expect(phase1Results) + .toHaveLength(1) + expect(phase1Results[0].id) + .toBe('assigned-submission') + + expect(phase2Results) + .toHaveLength(1) + expect(phase2Results[0].id) + .toBe('ai-failed-submission') + }, + ) it('keeps a phase-less AI-failed submission when only one iterative phase exists', () => { const iterativeReviewer = createResource('iterative-resource-1', 'Iterative Reviewer') From cd59aa80f86d79edaaf2669654d051bb950d4139 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 20 Apr 2026 13:58:15 +0300 Subject: [PATCH 58/81] PM-4804 - for f2f challenges when completed force-show the winners tab --- .../review/src/lib/utils/challenge.spec.ts | 23 +++++++++++++++++++ src/apps/review/src/lib/utils/challenge.ts | 6 ++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/apps/review/src/lib/utils/challenge.spec.ts b/src/apps/review/src/lib/utils/challenge.spec.ts index 8b4366a94..551435bb3 100644 --- a/src/apps/review/src/lib/utils/challenge.spec.ts +++ b/src/apps/review/src/lib/utils/challenge.spec.ts @@ -230,6 +230,29 @@ describe('challenge phase tab helpers', () => { .toBe(false) }) + it('force-shows winners for past challenges with winners even when a phase remains open', () => { + expect(shouldAllowWinnersTabForPastChallenge({ + status: 'COMPLETED', + phases: [ + createBackendPhase('iterative-1', 'Iterative Review', '2026-04-20T00:00:00Z', { + isOpen: true, + }), + ], + })) + .toBe(false) + + expect(shouldForceWinnersTabForPastChallenge({ + status: 'COMPLETED', + phases: [ + createBackendPhase('iterative-1', 'Iterative Review', '2026-04-20T00:00:00Z', { + isOpen: true, + }), + ], + winners: [{ handle: 'winner-one', placement: 1, userId: 1 }], + })) + .toBe(true) + }) + it('keeps winners hidden when a follow-up approval review is still pending', () => { const challengeInfo = { phases: [ diff --git a/src/apps/review/src/lib/utils/challenge.ts b/src/apps/review/src/lib/utils/challenge.ts index 44aa240e8..a4e45352f 100644 --- a/src/apps/review/src/lib/utils/challenge.ts +++ b/src/apps/review/src/lib/utils/challenge.ts @@ -743,11 +743,15 @@ export function shouldForceWinnersTabForPastChallenge( challengeInfo?: WinnersTabFallbackChallengeInfo, approvalReviews?: ApprovalReviewStatusLike[] | null, ): boolean { + if (!isPastChallengeStatus(challengeInfo?.status)) { + return false + } + if (!(challengeInfo?.winners?.length)) { return false } - return shouldAllowWinnersTabForPastChallenge(challengeInfo, approvalReviews) + return !hasPendingApprovalReview(approvalReviews) } export function isReviewPhaseCurrentlyOpen( From ae3e4a7dddcb06338f644e0a5916295895a00010 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 20 Apr 2026 14:21:33 +0300 Subject: [PATCH 59/81] lint --- src/apps/review/src/lib/utils/challenge.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apps/review/src/lib/utils/challenge.spec.ts b/src/apps/review/src/lib/utils/challenge.spec.ts index 551435bb3..64ee4197d 100644 --- a/src/apps/review/src/lib/utils/challenge.spec.ts +++ b/src/apps/review/src/lib/utils/challenge.spec.ts @@ -232,22 +232,22 @@ describe('challenge phase tab helpers', () => { it('force-shows winners for past challenges with winners even when a phase remains open', () => { expect(shouldAllowWinnersTabForPastChallenge({ - status: 'COMPLETED', phases: [ createBackendPhase('iterative-1', 'Iterative Review', '2026-04-20T00:00:00Z', { isOpen: true, }), ], + status: 'COMPLETED', })) .toBe(false) expect(shouldForceWinnersTabForPastChallenge({ - status: 'COMPLETED', phases: [ createBackendPhase('iterative-1', 'Iterative Review', '2026-04-20T00:00:00Z', { isOpen: true, }), ], + status: 'COMPLETED', winners: [{ handle: 'winner-one', placement: 1, userId: 1 }], })) .toBe(true) From 044dda6e913abf229803eb81a8f89a784ebc609d Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 20 Apr 2026 15:09:34 +0300 Subject: [PATCH 60/81] PM-4879 - throw error if user saves challenge with disabled workflow --- .../components/ChallengeEditorForm.spec.tsx | 99 +++++++++++++++++++ .../components/ChallengeEditorForm.tsx | 65 +++++++++++- 2 files changed, 161 insertions(+), 3 deletions(-) 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 c73e03528..bc41c544f 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 @@ -31,6 +31,7 @@ import { createChallenge, deleteResource, fetchChallenge, + fetchWorkflows, fetchProfile, fetchProjectBillingAccount, patchChallenge, @@ -67,6 +68,7 @@ jest.mock('../../../../lib/services', () => ({ createResource: jest.fn(), deleteResource: jest.fn(), fetchChallenge: jest.fn(), + fetchWorkflows: jest.fn(), fetchProfile: jest.fn(), fetchProjectBillingAccount: jest.fn(), fetchResourceRoles: jest.fn(), @@ -569,6 +571,7 @@ const mockedCreateResource = createResource as jest.Mock const mockedCreateChallenge = createChallenge as jest.Mock const mockedDeleteResource = deleteResource as jest.Mock const mockedFetchChallenge = fetchChallenge as jest.Mock +const mockedFetchWorkflows = fetchWorkflows as jest.Mock const mockedFetchProfile = fetchProfile as jest.Mock const mockedFetchProjectBillingAccountService = fetchProjectBillingAccount as jest.Mock const mockedPatchChallenge = patchChallenge as jest.Mock @@ -677,6 +680,7 @@ describe('ChallengeEditorForm', () => { mockedUseFetchTimelineTemplates.mockReturnValue({ timelineTemplates: [], }) + mockedFetchWorkflows.mockResolvedValue([]) mockedFetchProjectBillingAccountService.mockResolvedValue({ billingAccount: undefined, }) @@ -2434,6 +2438,61 @@ describe('ChallengeEditorForm', () => { .not.toHaveBeenCalledWith('Failed to save challenge') }) + it('blocks launching when an assigned AI workflow has been disabled', async () => { + let launchAction: (() => Promise) | undefined + let launchError: Error | undefined + + mockedFetchWorkflows.mockResolvedValue([{ + disabled: true, + id: 'workflow-disabled', + name: 'Disabled workflow', + }]) + + render( + + { + launchAction = action + }} + /> + , + ) + + await waitFor(() => { + expect(launchAction) + .toEqual(expect.any(Function)) + }) + + await act(async () => { + try { + await (launchAction as () => Promise)() + } catch (error) { + launchError = error as Error + } + }) + + expect(launchError) + .toEqual(expect.objectContaining({ + message: 'One or more saved AI workflows were disabled. ' + + 'Update the AI workflow configuration before saving or launching this challenge.', + })) + expect(mockedPatchChallenge) + .not.toHaveBeenCalled() + expect(mockedShowErrorToast) + .toHaveBeenCalledWith( + 'One or more saved AI workflows were disabled. ' + + 'Update the AI workflow configuration before saving or launching this challenge.', + ) + }) + it('does not render the attachments section while editing a draft', () => { render( @@ -2473,6 +2532,46 @@ describe('ChallengeEditorForm', () => { }) }) + it('blocks saving when an assigned AI workflow has been disabled', async () => { + const user = userEvent.setup() + + mockedFetchWorkflows.mockResolvedValue([{ + disabled: true, + id: 'workflow-disabled', + name: 'Disabled workflow', + }]) + + render( + + + , + ) + + await user.type(screen.getByLabelText('Challenge Name'), ' updated') + await user.click(screen.getByRole('button', { name: 'Save Challenge' })) + + await waitFor(() => { + expect(mockedPatchChallenge) + .not.toHaveBeenCalled() + }) + expect(mockedShowErrorToast) + .toHaveBeenCalledWith( + 'One or more saved AI workflows were disabled. ' + + 'Update the AI workflow configuration before saving or launching this challenge.', + ) + expect(mockedShowErrorToast) + .not.toHaveBeenCalledWith('Failed to save challenge') + }) + it('refreshes phase data when the fetched challenge updates for the same id', async () => { const initialChallenge = { ...validDraftChallenge, 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 03cc3b7ef..e17804efc 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -54,6 +54,7 @@ import { createResource, deleteResource, fetchChallenge, + fetchWorkflows, fetchProfile, fetchProjectBillingAccount, fetchResourceRoles, @@ -254,6 +255,8 @@ const SAVE_VALIDATION_ERROR_MESSAGE = 'Please fix validation errors before savin const DESIGN_WORK_TYPE_REQUIRED_MESSAGE = 'Select a work type' const TASK_ASSIGNED_MEMBER_REQUIRED_FOR_LAUNCH_MESSAGE = 'Assign a member before launching a task challenge.' +const DISABLED_AI_WORKFLOW_FOR_CHALLENGE_ACTION_MESSAGE + = 'One or more saved AI workflows were disabled. Update the AI workflow configuration before saving or launching this challenge.' const CHALLENGE_TYPE_CHALLENGE_ABBREVIATION = 'CH' const CHALLENGE_TYPE_CHALLENGE_NAME = 'CHALLENGE' const CHALLENGE_TYPE_FIRST_2_FINISH_ABBREVIATION = 'F2F' @@ -1116,6 +1119,37 @@ function getReviewerValidationError( return getMissingRequiredPhaseCoverageError(reviewers, requiredPhases) } +async function getDisabledAiWorkflowForActionError( + formData: ChallengeEditorFormData, +): Promise { + const selectedAiWorkflowIds = Array.from(new Set((Array.isArray(formData.reviewers) + ? formData.reviewers + : []) + .map(reviewer => normalizeTextValue(reviewer?.aiWorkflowId)) + .filter(Boolean))) + + if (!selectedAiWorkflowIds.length) { + return undefined + } + + const workflows = await fetchWorkflows() + const workflowMapById = new Map( + workflows.map(workflow => [ + normalizeTextValue(workflow.id), + workflow, + ] as const), + ) + const hasDisabledWorkflow = selectedAiWorkflowIds.some(workflowId => { + const matchedWorkflow = workflowMapById.get(workflowId) + + return matchedWorkflow?.disabled === true + }) + + return hasDisabledWorkflow + ? DISABLED_AI_WORKFLOW_FOR_CHALLENGE_ACTION_MESSAGE + : undefined +} + function getStatusText( saveStatus: 'error' | 'idle' | 'saved' | 'saving', ): string { @@ -2599,6 +2633,23 @@ export const ChallengeEditorForm: FC = ( throw createHandledLaunchBlockError(taskLaunchValidationError) } + const disabledAiWorkflowError = await getDisabledAiWorkflowForActionError(formData) + + if (disabledAiWorkflowError) { + setSaveStatus('idle') + setError('reviewers', { + message: disabledAiWorkflowError, + type: 'manual', + }) + setSaveValidationError(disabledAiWorkflowError) + + if (!options.isAutosave) { + showErrorToast(disabledAiWorkflowError) + } + + throw createHandledLaunchBlockError(disabledAiWorkflowError) + } + if (!options.isAutosave) { setIsSaving(true) setSaveStatus('saving') @@ -2826,9 +2877,17 @@ export const ChallengeEditorForm: FC = ( } clearErrors('reviewers') - await saveChallenge(formData, { - redirectToViewOnSuccess: true, - }) + try { + await saveChallenge(formData, { + redirectToViewOnSuccess: true, + }) + } catch (error) { + if (isHandledLaunchBlockError(error)) { + return + } + + throw error + } }, [ clearErrors, From 289ee0ab87a1d46497b8aaaf8b1a1f5f0344ac35 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 20 Apr 2026 15:19:46 +0300 Subject: [PATCH 61/81] lint --- .../ChallengeEditorPage/components/ChallengeEditorForm.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 e17804efc..c5e674bbc 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -54,11 +54,11 @@ import { createResource, deleteResource, fetchChallenge, - fetchWorkflows, fetchProfile, fetchProjectBillingAccount, fetchResourceRoles, fetchResources, + fetchWorkflows, patchChallenge, } from '../../../../lib/services' import { @@ -256,7 +256,8 @@ const DESIGN_WORK_TYPE_REQUIRED_MESSAGE = 'Select a work type' const TASK_ASSIGNED_MEMBER_REQUIRED_FOR_LAUNCH_MESSAGE = 'Assign a member before launching a task challenge.' const DISABLED_AI_WORKFLOW_FOR_CHALLENGE_ACTION_MESSAGE - = 'One or more saved AI workflows were disabled. Update the AI workflow configuration before saving or launching this challenge.' + = `One or more saved AI workflows were disabled. + Update the AI workflow configuration before saving or launching this challenge.` const CHALLENGE_TYPE_CHALLENGE_ABBREVIATION = 'CH' const CHALLENGE_TYPE_CHALLENGE_NAME = 'CHALLENGE' const CHALLENGE_TYPE_FIRST_2_FINISH_ABBREVIATION = 'F2F' From f1a494e832a5db84031c198299a17b9cdb15a147 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 20 Apr 2026 15:27:37 +0300 Subject: [PATCH 62/81] lint --- .../ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx index bc41c544f..e11e3f157 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 @@ -31,9 +31,9 @@ import { createChallenge, deleteResource, fetchChallenge, - fetchWorkflows, fetchProfile, fetchProjectBillingAccount, + fetchWorkflows, patchChallenge, fetchResourceRoles, fetchResources, From bfd7e66e37aead8fe666aa447cbfd2b63509abe0 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 20 Apr 2026 15:41:40 +0300 Subject: [PATCH 63/81] lint --- .../ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx index e11e3f157..640b561ea 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 @@ -68,11 +68,11 @@ jest.mock('../../../../lib/services', () => ({ createResource: jest.fn(), deleteResource: jest.fn(), fetchChallenge: jest.fn(), - fetchWorkflows: jest.fn(), fetchProfile: jest.fn(), fetchProjectBillingAccount: jest.fn(), fetchResourceRoles: jest.fn(), fetchResources: jest.fn(), + fetchWorkflows: jest.fn(), patchChallenge: jest.fn(), })) jest.mock('../../../../lib/utils', () => ({ From 5a5e131a603c160ce9b423fdfa1860e0e3e2558a Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 21 Apr 2026 11:17:13 +1000 Subject: [PATCH 64/81] Add root /project/{id} route to work app --- src/apps/work/src/work-app.routes.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/apps/work/src/work-app.routes.tsx b/src/apps/work/src/work-app.routes.tsx index dc0a6385f..68758d959 100644 --- a/src/apps/work/src/work-app.routes.tsx +++ b/src/apps/work/src/work-app.routes.tsx @@ -176,6 +176,12 @@ export const workRoutes: ReadonlyArray = [ route: '/projects/:projectId/challenges', title: 'Challenges', }, + { + authRequired: true, + element: , + route: '/projects/:projectId', + title: 'Project Overview', + }, { authRequired: true, element: , From 01e66073795913fe38a5e84deb3d56b328ab2678 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 21 Apr 2026 09:24:18 +0300 Subject: [PATCH 65/81] PM-4879 - prevent challenge save when workflow is disabled --- .../components/ChallengeEditorForm.spec.tsx | 66 +++++++++++++++---- .../components/ChallengeEditorForm.tsx | 33 +++++++--- 2 files changed, 78 insertions(+), 21 deletions(-) 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 640b561ea..1a45ccaf7 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 @@ -30,6 +30,7 @@ import { createResource, createChallenge, deleteResource, + fetchAiReviewConfigByChallenge, fetchChallenge, fetchProfile, fetchProjectBillingAccount, @@ -67,6 +68,7 @@ jest.mock('../../../../lib/services', () => ({ createChallenge: jest.fn(), createResource: jest.fn(), deleteResource: jest.fn(), + fetchAiReviewConfigByChallenge: jest.fn(), fetchChallenge: jest.fn(), fetchProfile: jest.fn(), fetchProjectBillingAccount: jest.fn(), @@ -570,6 +572,7 @@ const mockedUseFetchTimelineTemplates = useFetchTimelineTemplates as jest.Mock const mockedCreateResource = createResource as jest.Mock const mockedCreateChallenge = createChallenge as jest.Mock const mockedDeleteResource = deleteResource as jest.Mock +const mockedFetchAiReviewConfigByChallenge = fetchAiReviewConfigByChallenge as jest.Mock const mockedFetchChallenge = fetchChallenge as jest.Mock const mockedFetchWorkflows = fetchWorkflows as jest.Mock const mockedFetchProfile = fetchProfile as jest.Mock @@ -680,6 +683,7 @@ describe('ChallengeEditorForm', () => { mockedUseFetchTimelineTemplates.mockReturnValue({ timelineTemplates: [], }) + mockedFetchAiReviewConfigByChallenge.mockResolvedValue(undefined) mockedFetchWorkflows.mockResolvedValue([]) mockedFetchProjectBillingAccountService.mockResolvedValue({ billingAccount: undefined, @@ -2479,18 +2483,12 @@ describe('ChallengeEditorForm', () => { } }) - expect(launchError) - .toEqual(expect.objectContaining({ - message: 'One or more saved AI workflows were disabled. ' - + 'Update the AI workflow configuration before saving or launching this challenge.', - })) + expect(launchError?.message) + .toContain('One or more saved AI workflows were disabled.') expect(mockedPatchChallenge) .not.toHaveBeenCalled() expect(mockedShowErrorToast) - .toHaveBeenCalledWith( - 'One or more saved AI workflows were disabled. ' - + 'Update the AI workflow configuration before saving or launching this challenge.', - ) + .toHaveBeenCalledWith(expect.stringContaining('One or more saved AI workflows were disabled.')) }) it('does not render the attachments section while editing a draft', () => { @@ -2564,14 +2562,56 @@ describe('ChallengeEditorForm', () => { .not.toHaveBeenCalled() }) expect(mockedShowErrorToast) - .toHaveBeenCalledWith( - 'One or more saved AI workflows were disabled. ' - + 'Update the AI workflow configuration before saving or launching this challenge.', - ) + .toHaveBeenCalledWith(expect.stringContaining('One or more saved AI workflows were disabled.')) expect(mockedShowErrorToast) .not.toHaveBeenCalledWith('Failed to save challenge') }) + it('blocks saving when disabled workflow exists only in persisted AI config', async () => { + const user = userEvent.setup() + + mockedFetchAiReviewConfigByChallenge.mockResolvedValue({ + challengeId: '12345', + id: 'config-1', + minPassingThreshold: 75, + mode: 'AI_HUMAN', + workflows: [{ + id: 'config-workflow-1', + isGating: false, + weightPercent: 100, + workflowId: 'workflow-disabled', + }], + }) + mockedFetchWorkflows.mockResolvedValue([{ + disabled: true, + id: 'workflow-disabled', + name: 'Disabled workflow', + }]) + + render( + + + , + ) + + await user.type(screen.getByLabelText('Challenge Name'), ' updated') + await user.click(screen.getByRole('button', { name: 'Save Challenge' })) + + await waitFor(() => { + expect(mockedPatchChallenge) + .not.toHaveBeenCalled() + }) + expect(mockedFetchAiReviewConfigByChallenge) + .toHaveBeenCalledWith('12345') + expect(mockedShowErrorToast) + .toHaveBeenCalledWith(expect.stringContaining('One or more saved AI workflows were disabled.')) + }) + it('refreshes phase data when the fetched challenge updates for the same id', async () => { const initialChallenge = { ...validDraftChallenge, 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 c5e674bbc..c2e18e4ef 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -53,6 +53,7 @@ import { createChallenge, createResource, deleteResource, + fetchAiReviewConfigByChallenge, fetchChallenge, fetchProfile, fetchProjectBillingAccount, @@ -256,8 +257,8 @@ const DESIGN_WORK_TYPE_REQUIRED_MESSAGE = 'Select a work type' const TASK_ASSIGNED_MEMBER_REQUIRED_FOR_LAUNCH_MESSAGE = 'Assign a member before launching a task challenge.' const DISABLED_AI_WORKFLOW_FOR_CHALLENGE_ACTION_MESSAGE - = `One or more saved AI workflows were disabled. - Update the AI workflow configuration before saving or launching this challenge.` + = 'One or more saved AI workflows were disabled. ' + + 'Update the AI workflow configuration before saving or launching this challenge.' const CHALLENGE_TYPE_CHALLENGE_ABBREVIATION = 'CH' const CHALLENGE_TYPE_CHALLENGE_NAME = 'CHALLENGE' const CHALLENGE_TYPE_FIRST_2_FINISH_ABBREVIATION = 'F2F' @@ -1121,15 +1122,28 @@ function getReviewerValidationError( } async function getDisabledAiWorkflowForActionError( + challengeId: string | undefined, formData: ChallengeEditorFormData, ): Promise { - const selectedAiWorkflowIds = Array.from(new Set((Array.isArray(formData.reviewers) + const selectedAiWorkflowIds = (Array.isArray(formData.reviewers) ? formData.reviewers : []) .map(reviewer => normalizeTextValue(reviewer?.aiWorkflowId)) - .filter(Boolean))) - - if (!selectedAiWorkflowIds.length) { + .filter(Boolean) + const normalizedChallengeId = normalizeTextValue(challengeId) + const persistedAiConfig = normalizedChallengeId + ? await fetchAiReviewConfigByChallenge(normalizedChallengeId) + .catch(() => undefined) + : undefined + const persistedWorkflowIds = (persistedAiConfig?.workflows || []) + .map(workflow => normalizeTextValue(workflow.workflowId)) + .filter(Boolean) + const configuredAiWorkflowIds = Array.from(new Set([ + ...selectedAiWorkflowIds, + ...persistedWorkflowIds, + ])) + + if (!configuredAiWorkflowIds.length) { return undefined } @@ -1140,7 +1154,7 @@ async function getDisabledAiWorkflowForActionError( workflow, ] as const), ) - const hasDisabledWorkflow = selectedAiWorkflowIds.some(workflowId => { + const hasDisabledWorkflow = configuredAiWorkflowIds.some(workflowId => { const matchedWorkflow = workflowMapById.get(workflowId) return matchedWorkflow?.disabled === true @@ -2634,7 +2648,10 @@ export const ChallengeEditorForm: FC = ( throw createHandledLaunchBlockError(taskLaunchValidationError) } - const disabledAiWorkflowError = await getDisabledAiWorkflowForActionError(formData) + const disabledAiWorkflowError = await getDisabledAiWorkflowForActionError( + currentChallengeId, + formData, + ) if (disabledAiWorkflowError) { setSaveStatus('idle') From 64042f71d2f9cdc64885c9fc9acbd988018abcb8 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Tue, 21 Apr 2026 12:39:52 +0530 Subject: [PATCH 66/81] PM-4886 Remove completeness filter --- .../TalentSearchPage/TalentSearchPage.tsx | 24 +------------------ 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index fac94dae1..5502989b8 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -41,7 +41,6 @@ export const TalentSearchPage: FC = () => { const [selectedSkills, setSelectedSkills] = useState([]) const [sortBy, setSortBy] = useState('alphabetical') const [selectedCountry, setSelectedCountry] = useState('all') - const [onlyProfileComplete, setOnlyProfileComplete] = useState(true) const [onlyOpenToWork, setOnlyOpenToWork] = useState(false) const [onlyActive, setOnlyActive] = useState(false) const [isSearchingMembers, setIsSearchingMembers] = useState(false) @@ -124,7 +123,6 @@ export const TalentSearchPage: FC = () => { openToWork?: boolean page?: number recentlyActive?: boolean - verifiedProfile?: boolean }, ): Promise => { const append = overrides?.append === true @@ -132,7 +130,6 @@ export const TalentSearchPage: FC = () => { const openToWork = overrides?.openToWork ?? onlyOpenToWork const page = overrides?.page ?? 1 const recentlyActive = overrides?.recentlyActive ?? onlyActive - const verifiedProfile = overrides?.verifiedProfile ?? onlyProfileComplete const payload: MemberSearchPayload = { limit: MEMBER_SEARCH_LIMIT, @@ -160,10 +157,6 @@ export const TalentSearchPage: FC = () => { payload.recentlyActive = true } - if (verifiedProfile) { - payload.verifiedProfile = true - } - if (append) { setIsLoadingMore(true) } else { @@ -209,11 +202,10 @@ export const TalentSearchPage: FC = () => { setIsSearchingMembers(false) } } - }, [onlyActive, onlyOpenToWork, onlyProfileComplete, selectedCountry]) + }, [onlyActive, onlyOpenToWork, selectedCountry]) const clearAllFilters = useCallback((): void => { setSelectedCountry('all') - setOnlyProfileComplete(true) setOnlyOpenToWork(false) setOnlyActive(false) setSortBy('alphabetical') @@ -225,7 +217,6 @@ export const TalentSearchPage: FC = () => { openToWork: false, page: 1, recentlyActive: false, - verifiedProfile: true, }) }, [runMemberSearch]) @@ -300,7 +291,6 @@ export const TalentSearchPage: FC = () => { hasSearched, isExtractingSkills, onlyActive, - onlyProfileComplete, onlyOpenToWork, runMemberSearch, selectedCountry, @@ -401,18 +391,6 @@ export const TalentSearchPage: FC = () => { placeholder='Select country' />
    -
    - ) => { - setSelectedCountry(event.target.value || 'all') + const value = (event.target.value || []) as InputMultiselectOption[] + setSelectedCountries(value) }} placeholder='Select country' /> From d227028ac34cc773ee7bae3628259c36deb1d326 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Tue, 21 Apr 2026 18:04:56 +0530 Subject: [PATCH 71/81] Fix initial empty state --- .../pages/talent-search/TalentSearchPage/TalentSearchPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index 122c282b2..fa216feb7 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -46,7 +46,7 @@ export const TalentSearchPage: FC = () => { const [selectedCountries, setSelectedCountries] = useState([]) const [onlyOpenToWork, setOnlyOpenToWork] = useState(false) const [onlyActive, setOnlyActive] = useState(false) - const [isSearchingMembers, setIsSearchingMembers] = useState(false) + const [isSearchingMembers, setIsSearchingMembers] = useState(true) const [isLoadingMore, setIsLoadingMore] = useState(false) const [results, setResults] = useState([]) const [totalResults, setTotalResults] = useState(0) From 8fde4bfc13878dff51c901f85c495700f71608b3 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Tue, 21 Apr 2026 18:05:39 +0530 Subject: [PATCH 72/81] Cleanup profile completion page --- .../src/config/routes.config.ts | 1 - .../src/customer-portal.routes.tsx | 6 +- .../components/NavTabs/config/tabs-config.ts | 4 - .../customer-portal/src/lib/services/index.ts | 1 - .../lib/services/profileCompletion.service.ts | 142 ------ .../ProfileCompletionPage.module.scss | 218 ---------- .../ProfileCompletionPage.tsx | 409 ------------------ .../ProfileCompletionPage/index.ts | 1 - .../src/pages/profile-completion/index.ts | 1 - .../profile-completion.routes.tsx | 26 -- 10 files changed, 2 insertions(+), 807 deletions(-) delete mode 100644 src/apps/customer-portal/src/lib/services/profileCompletion.service.ts delete mode 100644 src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss delete mode 100644 src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.tsx delete mode 100644 src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/index.ts delete mode 100644 src/apps/customer-portal/src/pages/profile-completion/index.ts delete mode 100644 src/apps/customer-portal/src/pages/profile-completion/profile-completion.routes.tsx diff --git a/src/apps/customer-portal/src/config/routes.config.ts b/src/apps/customer-portal/src/config/routes.config.ts index e83343378..316357915 100644 --- a/src/apps/customer-portal/src/config/routes.config.ts +++ b/src/apps/customer-portal/src/config/routes.config.ts @@ -9,4 +9,3 @@ export const rootRoute: string : `/${AppSubdomain.customer}` export const talentSearchRouteId = 'talent-search' -export const profileCompletionRouteId = 'profile-completion' diff --git a/src/apps/customer-portal/src/customer-portal.routes.tsx b/src/apps/customer-portal/src/customer-portal.routes.tsx index fd5b6e0e1..2ee282745 100644 --- a/src/apps/customer-portal/src/customer-portal.routes.tsx +++ b/src/apps/customer-portal/src/customer-portal.routes.tsx @@ -11,11 +11,10 @@ import { } from '~/libs/core' import { - profileCompletionRouteId, rootRoute, + talentSearchRouteId, } from './config/routes.config' import { customerPortalTalentSearchRoutes } from './pages/talent-search/talent-search.routes' -import { customerPortalProfileCompletionRoutes } from './pages/profile-completion/profile-completion.routes' const CustomerPortalApp: LazyLoadedComponent = lazyLoad(() => import('./CustomerPortalApp')) @@ -28,10 +27,9 @@ export const customerPortalRoutes: ReadonlyArray = [ children: [ { authRequired: true, - element: , + element: , route: '', }, - ...customerPortalProfileCompletionRoutes, ...customerPortalTalentSearchRoutes, ], domain: AppSubdomain.customer, diff --git a/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts b/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts index d1292ef52..c76f23ed4 100644 --- a/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts +++ b/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts @@ -2,7 +2,6 @@ import _ from 'lodash' import { TabsNavItem } from '~/libs/ui' import { - profileCompletionRouteId, talentSearchRouteId, } from '~/apps/customer-portal/src/config/routes.config' @@ -10,9 +9,6 @@ export function getTabsConfig(userRoles: string[], isAnonymous: boolean, isUnpri const tabs: TabsNavItem[] = [ ...(!isUnprivilegedUser ? [{ - id: profileCompletionRouteId, - title: 'Profile Completion', - }, { id: talentSearchRouteId, title: 'Talent Search', }] : []), diff --git a/src/apps/customer-portal/src/lib/services/index.ts b/src/apps/customer-portal/src/lib/services/index.ts index d67943da3..9e33c22a5 100644 --- a/src/apps/customer-portal/src/lib/services/index.ts +++ b/src/apps/customer-portal/src/lib/services/index.ts @@ -1,2 +1 @@ -export * from './profileCompletion.service' export * from './talentSearch.service' diff --git a/src/apps/customer-portal/src/lib/services/profileCompletion.service.ts b/src/apps/customer-portal/src/lib/services/profileCompletion.service.ts deleted file mode 100644 index f0674b639..000000000 --- a/src/apps/customer-portal/src/lib/services/profileCompletion.service.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { EnvironmentConfig } from '~/config' -import { UserSkill, xhrGetAsync } from '~/libs/core' - -export type CompletedProfile = { - countryCode?: string - countryName?: string - city?: string - firstName?: string - handle: string - lastName?: string - photoURL?: string - skillCount?: number - userId?: number | string - isOpenToWork?: boolean | null - openToWork?: { - availability?: string - preferredRoles?: string[] - } | null -} - -export type CompletedProfilesResponse = { - data: CompletedProfile[] - page: number - perPage: number - total: number - totalPages: number -} - -export const DEFAULT_PAGE_SIZE = 50 - -function normalizeToList(raw: any): any[] { - if (Array.isArray(raw)) { - return raw - } - - if (Array.isArray(raw?.data)) { - return raw.data - } - - if (Array.isArray(raw?.result?.content)) { - return raw.result.content - } - - if (Array.isArray(raw?.result)) { - return raw.result - } - - return [] -} - -function normalizeCompletedProfilesResponse( - raw: any, - fallbackPage: number, - fallbackPerPage: number, -): CompletedProfilesResponse { - if (raw && Array.isArray(raw.data)) { - const total: number = Number(raw.total ?? raw.data.length) - const perPage: number = Number(raw.perPage ?? fallbackPerPage) - const page: number = Number(raw.page ?? fallbackPage) - const safePerPage = Number.isFinite(perPage) ? Math.max(perPage, 1) : fallbackPerPage - const safeTotal = Number.isFinite(total) ? Math.max(total, 0) : raw.data.length - - return { - data: raw.data, - page: Number.isFinite(page) ? Math.max(page, 1) : fallbackPage, - perPage: safePerPage, - total: safeTotal, - totalPages: Number.isFinite(raw.totalPages) - ? Math.max(Number(raw.totalPages), 1) - : Math.max(Math.ceil(safeTotal / safePerPage), 1), - } - } - - const rows = normalizeToList(raw) - const total = Number(raw?.total ?? rows.length) - const safeTotal = Number.isFinite(total) ? Math.max(total, 0) : rows.length - - return { - data: rows, - page: fallbackPage, - perPage: fallbackPerPage, - total: safeTotal, - totalPages: Math.max(Math.ceil(safeTotal / fallbackPerPage), 1), - } -} - -export type OpenToWorkFilter = 'all' | 'yes' | 'no' - -export async function fetchCompletedProfiles( - countryCode: string | undefined, - page: number, - perPage: number, - openToWorkFilter?: OpenToWorkFilter, - skillIds?: string[], -): Promise { - const queryParams = new URLSearchParams({ - page: String(page), - perPage: String(perPage), - }) - - if (countryCode) { - queryParams.set('countryCode', countryCode) - } - - if (openToWorkFilter === 'yes') { - queryParams.set('openToWork', 'true') - } - - if (openToWorkFilter === 'no') { - queryParams.set('openToWork', 'false') - } - - if (Array.isArray(skillIds) && skillIds.length > 0) { - skillIds.forEach(id => { - if (id) { - queryParams.append('skillId', String(id)) - } - }) - } - - const response = await xhrGetAsync( - `${EnvironmentConfig.REPORTS_API}/topcoder/completed-profiles?${queryParams.toString()}`, - ) - - return normalizeCompletedProfilesResponse(response, page, perPage) -} - -export async function fetchMemberSkillsData(userId: string | number | undefined): Promise { - if (!userId) { - return [] - } - - const baseUrl = `${EnvironmentConfig.API.V5}/standardized-skills` - const url = `${baseUrl}/user-skills/${userId}?disablePagination=true` - - try { - return await xhrGetAsync(url) - } catch { - // If skills API fails, return empty array to not block the page - return [] - } -} diff --git a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss deleted file mode 100644 index ec7051428..000000000 --- a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss +++ /dev/null @@ -1,218 +0,0 @@ -@import '@libs/ui/styles/includes'; - -.container { - display: flex; - flex-direction: column; - gap: $sp-4; -} - -.headerRow { - display: flex; - align-items: flex-end; - gap: $sp-4; - justify-content: space-between; - - @include ltemd { - flex-direction: column; - align-items: stretch; - } -} - -.filterWrapper { - display: flex; - gap: $sp-4; - - :global([class*='__value-container']) { - min-height: 18px; - } - - @include ltemd { - flex-direction: column; - align-items: stretch; - } - - .filterWrap { - min-width: 280px; - max-width: 360px; - - @include ltemd { - max-width: unset; - min-width: unset; - width: 100%; - } - } -} - -.counterCard { - border: 1px solid $black-20; - border-radius: $sp-2; - background: $tc-white; - padding: $sp-4; - min-width: 260px; - display: flex; - flex-direction: column; - gap: $sp-1; -} - -.counterLabel { - color: $black-60; - font-size: 12px; - line-height: 16px; - font-weight: 600; - text-transform: uppercase; -} - -.counterValue { - color: $black-100; - font-size: 32px; - line-height: 36px; - font-weight: 700; - font-family: 'Nunito Sans', sans-serif; -} - -.loadingWrap { - position: relative; - height: 90px; - - .spinner { - background: none; - } -} - -.errorMessage { - color: $red-100; - font-size: 14px; - line-height: 20px; - font-weight: 700; -} - -.emptyMessage { - color: $black-60; - font-size: 14px; - line-height: 20px; -} - -.tableWrap { - overflow: auto; - border: 1px solid $black-20; - border-radius: $sp-2; - - table { - width: 100%; - border-collapse: collapse; - min-width: 1120px; - } - - th, - td { - text-align: left; - padding: $sp-3 $sp-4; - border-bottom: 1px solid $black-20; - font-size: 14px; - line-height: 20px; - } - - th { - color: $black-100; - font-weight: 700; - background: $black-5; - } - - td { - color: $black-100; - vertical-align: middle; - } - - tr:last-child td { - border-bottom: 0; - } -} - -.memberCell { - display: flex; - align-items: center; - gap: $sp-2; -} - -.avatar { - width: 28px; - height: 28px; - border-radius: 50%; - object-fit: cover; - border: 1px solid $black-20; -} - -.paginationRow { - display: flex; - align-items: center; - justify-content: space-between; - gap: $sp-3; - - @include ltemd { - flex-direction: column; - align-items: flex-start; - } -} - -.paginationInfo { - color: $black-60; - font-size: 14px; - line-height: 20px; -} - -.paginationButtons { - display: flex; - align-items: center; - gap: $sp-2; -} - -.skillsList { - display: flex; - flex-wrap: wrap; - gap: $sp-2; -} - -.skillTag { - display: inline-block; - background: $black-5; - border: 1px solid $black-20; - border-radius: $sp-1; - padding: $sp-1 $sp-2; - font-size: 12px; - line-height: 16px; - color: $black-80; - white-space: nowrap; -} - -.moreIndicator { - display: inline-block; - background: $black-5; - border: 1px solid $black-20; - border-radius: $sp-1; - padding: $sp-1 $sp-2; - font-size: 12px; - line-height: 16px; - color: $black-80; - font-weight: 700; - min-width: 24px; - text-align: center; - cursor: help; -} - -.link { - display: flex; - gap: $sp-1; - text-decoration: underline; - color: $link-blue; - cursor: pointer; -} - -.openToWorkYes { - color: $green-100; - font-weight: 600; -} - -.openToWorkNo { - color: $red-100; - font-weight: 600; -} diff --git a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.tsx b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.tsx deleted file mode 100644 index 0434e1826..000000000 --- a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.tsx +++ /dev/null @@ -1,409 +0,0 @@ -/* eslint-disable react/jsx-no-bind */ -/* eslint-disable no-await-in-loop */ -/* eslint-disable complexity */ -import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react' -import useSWR, { SWRResponse } from 'swr' - -import { EnvironmentConfig } from '~/config' -import { CountryLookup, useCountryLookup, UserSkill, UserSkillDisplayModes } from '~/libs/core' -import { - Button, - InputMultiselect, - InputMultiselectOption, - InputSelect, - InputSelectOption, - LoadingSpinner, - Tooltip, -} from '~/libs/ui' -import { fetchSkillAutocompleteOptions } from '~/libs/shared' -import { getPreferredRoleLabelByValue } from '~/libs/shared/lib/utils/roles' - -import { PageWrapper } from '../../../lib' -import { - CompletedProfilesResponse, - DEFAULT_PAGE_SIZE, - fetchCompletedProfiles, - fetchMemberSkillsData, - type OpenToWorkFilter, -} from '../../../lib/services/profileCompletion.service' - -import styles from './ProfileCompletionPage.module.scss' - -const DISPLAY_SKILLS_COUNT = 5 - -export const ProfileCompletionPage: FC = () => { - const [selectedCountry, setSelectedCountry] = useState('all') - const [currentPage, setCurrentPage] = useState(1) - const [selectedOpenToWork, setSelectedOpenToWork] = useState('all') - const [selectedSkills, setSelectedSkills] = useState([]) - const [memberSkills, setMemberSkills] = useState>(new Map()) - const [skillOptionsLoading, setSkillOptionsLoading] = useState(false) - const countryLookup: CountryLookup[] | undefined = useCountryLookup() - - const countryCodeFilter = selectedCountry === 'all' ? undefined : selectedCountry - - const loadSkillOptions = useCallback(async (query: string): Promise => { - setSkillOptionsLoading(true) - try { - return await fetchSkillAutocompleteOptions(query) - } catch { - return [] - } finally { - setSkillOptionsLoading(false) - } - }, []) - - const { data, error, isValidating }: SWRResponse = useSWR( - // eslint-disable-next-line max-len - `customer-portal-completed-profiles:${countryCodeFilter || 'all'}:${selectedOpenToWork}:${currentPage}:${DEFAULT_PAGE_SIZE}:${selectedSkills.map(skill => skill.value) - .sort() - .join(',')}`, - () => fetchCompletedProfiles( - countryCodeFilter, - currentPage, - DEFAULT_PAGE_SIZE, - selectedOpenToWork, - selectedSkills.map(skill => skill.value), - ), - { - revalidateOnFocus: false, - }, - ) - - // Fetch member skills for all profiles on the current page - useEffect(() => { - if (!data?.data || data.data.length === 0) return - - const fetchAllMemberSkills = async (): Promise => { - const skillsMap = new Map() - - for (const profile of data.data) { - if (profile.userId && !memberSkills.has(profile.userId)) { - const skills = await fetchMemberSkillsData(profile.userId) - skillsMap.set(profile.userId, skills) - } - } - - if (skillsMap.size > 0) { - setMemberSkills(prevSkills => { - const newMap = new Map(prevSkills) - skillsMap.forEach((skills, userId) => { - newMap.set(userId, skills) - }) - return newMap - }) - } - } - - fetchAllMemberSkills() - }, [data?.data]) - - const countryMap = useMemo(() => { - const map = new Map() - const countries = countryLookup || [] - - countries.forEach((country: CountryLookup) => { - if (country.countryCode) { - map.set(country.countryCode, country.country) - } - }) - - return map - }, [countryLookup]) - - const countryOptions = useMemo(() => { - const staticOptions = (countryLookup || []) - .filter(country => !!country.countryCode) - .map(country => ({ - label: country.country, - value: country.countryCode, - })) - .sort((a, b) => String(a.label) - .localeCompare(String(b.label))) - - const seen = new Set(staticOptions.map(option => option.value)) - const dynamicOptions = (data?.data || []) - .filter(profile => !!profile.countryCode && !seen.has(String(profile.countryCode))) - .map(profile => ({ - label: ( - countryMap.get(String(profile.countryCode)) - || profile.countryName - || String(profile.countryCode) - ), - value: String(profile.countryCode), - })) - .sort((a, b) => String(a.label) - .localeCompare(String(b.label))) - - return [ - { - label: 'All Countries', - value: 'all', - }, - ...staticOptions, - ...dynamicOptions, - ] - }, [countryLookup, countryMap, data?.data]) - - const profiles = data?.data || [] - const totalProfiles = data?.total || 0 - const totalPages = data?.totalPages || 1 - - const displayedRows = useMemo(() => profiles - .map(profile => { - const userSkills = profile.userId ? (memberSkills.get(profile.userId) || []) : [] - - // Prioritize principal skills, then add additional skills - const principalSkills = [ - ...userSkills.filter(skill => skill.displayMode?.name === UserSkillDisplayModes.principal), - ] - - const displayedSkills = principalSkills.slice(0, DISPLAY_SKILLS_COUNT) - const remainingSkillsText = principalSkills.slice(DISPLAY_SKILLS_COUNT) - .map(skill => skill.name) - .filter(Boolean) - .join(', ') - const additionalSkillsCount = Math.max(0, principalSkills.length - DISPLAY_SKILLS_COUNT) - - const isOpenToWork = profile.isOpenToWork === true - const openToWorkLabel = isOpenToWork ? 'Yes' : 'No' - const openToWorkRolesText = profile.openToWork?.preferredRoles && profile.openToWork.preferredRoles.length - ? profile.openToWork.preferredRoles.map(getPreferredRoleLabelByValue) - .filter(Boolean) - .join(', ') - : 'No role preferences set' - - return { - ...profile, - additionalSkillsCount, - countryLabel: profile.countryCode - ? countryMap.get(profile.countryCode) || profile.countryName || profile.countryCode - : profile.countryName || '-', - displayedSkills, - fullName: [profile.firstName, profile.lastName].filter(Boolean) - .join(' ') - .trim(), - isOpenToWork, - locationLabel: [profile.city, profile.countryCode - ? countryMap.get(profile.countryCode) || profile.countryName || profile.countryCode - : profile.countryName] - .filter(Boolean) - .join(', '), - openToWorkLabel, - openToWorkRolesText, - remainingSkillsText, - } - }) - .sort((a, b) => a.handle.localeCompare(b.handle)), [profiles, countryMap, memberSkills]) - - const isPreviousDisabled = currentPage <= 1 || isValidating - const isNextDisabled = isValidating || currentPage >= totalPages - - return ( - -
    -
    -
    - ) => { - setSelectedCountry(event.target.value || 'all') - setCurrentPage(1) - }} - placeholder='Select country' - /> -
    -
    - ) => { - setSelectedOpenToWork((event.target.value || 'all') as OpenToWorkFilter) - setCurrentPage(1) - }} - placeholder='Select' - /> -
    -
    - ) => { - const value = (event.target.value || []) as InputMultiselectOption[] - setSelectedSkills(value) - setCurrentPage(1) - }} - /> -
    -
    -
    - Fully Completed Profiles - {totalProfiles} -
    -
    - - {isValidating && !data && ( -
    - -
    - )} - - {!isValidating && !!error && ( -
    - Failed to load profile completion data. -
    - )} - - {!error && !isValidating && displayedRows.length === 0 && ( -
    - No fully completed profiles found for the selected country. -
    - )} - - {!error && displayedRows.length > 0 && ( - <> -
    - - - - - - - - - - - - - {displayedRows.map(profile => ( - - - - - - - - - ))} - -
    MemberHandleLocationOpen to WorkPrincipal Skills{' '}
    -
    - {profile.photoURL && ( - {profile.handle} - )} - {profile.fullName || '-'} -
    -
    - - {profile.handle} - - {profile.locationLabel || profile.countryLabel} - { - profile.openToWorkLabel === 'Yes' ? ( - - - {profile.openToWorkLabel} - - - ) : ( - - {profile.openToWorkLabel} - - ) - } - - {profile.displayedSkills && profile.displayedSkills.length > 0 ? ( -
    - {profile.displayedSkills.map(skill => ( - - {skill.name} - - ))} - {profile.additionalSkillsCount > 0 && ( - - - + - {profile.additionalSkillsCount} - {' '} - skills - - - )} -
    - ) : ( - '-' - )} -
    - - Go to profile - -
    -
    -
    - - Page - {' '} - {currentPage} - {' '} - of - {' '} - {totalPages} - -
    - - -
    -
    - - )} -
    - ) -} - -export default ProfileCompletionPage diff --git a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/index.ts b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/index.ts deleted file mode 100644 index 4d99c8c31..000000000 --- a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as ProfileCompletionPage } from './ProfileCompletionPage' diff --git a/src/apps/customer-portal/src/pages/profile-completion/index.ts b/src/apps/customer-portal/src/pages/profile-completion/index.ts deleted file mode 100644 index 73dcadd92..000000000 --- a/src/apps/customer-portal/src/pages/profile-completion/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ProfileCompletionPage' diff --git a/src/apps/customer-portal/src/pages/profile-completion/profile-completion.routes.tsx b/src/apps/customer-portal/src/pages/profile-completion/profile-completion.routes.tsx deleted file mode 100644 index 42042bc0f..000000000 --- a/src/apps/customer-portal/src/pages/profile-completion/profile-completion.routes.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { getRoutesContainer, lazyLoad, LazyLoadedComponent } from '~/libs/core' - -import { profileCompletionRouteId } from '../../config/routes.config' - -const ProfileCompletionPage: LazyLoadedComponent = lazyLoad( - () => import('./ProfileCompletionPage'), - 'ProfileCompletionPage', -) - -export const profileCompletionChildRoutes = [ - { - authRequired: true, - element: , - id: 'profile-completion-page', - route: '', - }, -] - -export const customerPortalProfileCompletionRoutes = [ - { - children: [...profileCompletionChildRoutes], - element: getRoutesContainer(profileCompletionChildRoutes), - id: profileCompletionRouteId, - route: profileCompletionRouteId, - }, -] From 64b699170f3e8fd99322c5756ebc5557943fb1fe Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Wed, 22 Apr 2026 10:37:04 +0530 Subject: [PATCH 73/81] Devin feedback --- .../TalentSearchPage/TalentSearchPage.tsx | 32 +------------------ 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index fa216feb7..cbc953423 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -67,19 +67,6 @@ export const TalentSearchPage: FC = () => { .localeCompare(String(b.label))), [countryLookup], ) - const selectedCountryCodes = useMemo( - (): Set => new Set(selectedCountries.map(country => String(country.value || '') - .trim() - .toUpperCase())), - [selectedCountries], - ) - const selectedCountryNames = useMemo( - (): string[] => selectedCountries.map(country => String(country.label || '') - .trim() - .toLowerCase()) - .filter(Boolean), - [selectedCountries], - ) const selectedCountryCodesList = useMemo( (): string[] => selectedCountries .map(country => String(country.value || '') @@ -107,25 +94,8 @@ export const TalentSearchPage: FC = () => { return false } - if (selectedCountryCodes.size > 0) { - const location = String(talent.location || '') - .trim() - const upperLocation = location.toUpperCase() - const lowerLocation = location.toLowerCase() - const matchesCountryCode = selectedCountryCodes.has(upperLocation) - || Array.from(selectedCountryCodes) - .some(code => upperLocation.endsWith(` ${code}`)) - const matchesCountryName = selectedCountryNames.some(name => ( - lowerLocation === name || lowerLocation.endsWith(` ${name}`) - )) - - if (!matchesCountryCode && !matchesCountryName) { - return false - } - } - return true - }), [onlyActive, onlyOpenToWork, results, selectedCountryCodes, selectedCountryNames]) + }), [onlyActive, onlyOpenToWork, results]) const displayedResults = useMemo(() => { const sorted = [...filteredResults] From faaaa9bcd7e9d08914444ac9b1053589e1b5dd5d Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 22 Apr 2026 15:29:51 +1000 Subject: [PATCH 74/81] Better handling of engagements when showing billing account details --- .../BillingAccountLineItemsModal.module.scss | 35 ++- .../BillingAccountLineItemsModal.spec.tsx | 141 ++++++++++ .../BillingAccountLineItemsModal.tsx | 148 ++++++----- .../hooks/useFetchBillingAccountDetails.ts | 4 +- .../services/billing-accounts.service.spec.ts | 242 +++++++++++++++++- .../lib/services/billing-accounts.service.ts | 132 +++++++--- .../work/src/lib/services/payments.service.ts | 2 - 7 files changed, 600 insertions(+), 104 deletions(-) create mode 100644 src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.module.scss b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.module.scss index 22f110332..61aba1586 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.module.scss +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.module.scss @@ -20,7 +20,7 @@ display: flex; flex-direction: column; max-height: 90vh; - max-width: 800px; + max-width: 920px; width: 100%; } @@ -84,7 +84,7 @@ .body { flex: 1; - overflow-y: auto; + overflow: auto; padding: 16px 20px; } @@ -97,6 +97,8 @@ .table { border-collapse: collapse; + min-width: 720px; + table-layout: fixed; width: 100%; } @@ -127,6 +129,31 @@ padding-right: 0; } +.table th:nth-child(1), +.table td:nth-child(1) { + width: 16%; +} + +.table th:nth-child(2), +.table td:nth-child(2) { + width: 17%; +} + +.table th:nth-child(3), +.table td:nth-child(3) { + width: 15%; +} + +.table th:nth-child(4), +.table td:nth-child(4) { + width: 34%; +} + +.table th:nth-child(5), +.table td:nth-child(5) { + width: 18%; +} + .sortButton { align-items: center; background: none; @@ -175,6 +202,10 @@ width: 14px; } +.nameCell { + overflow-wrap: anywhere; +} + .challengeLink { color: #137d60; font-weight: 500; diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx new file mode 100644 index 000000000..21c2442a4 --- /dev/null +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx @@ -0,0 +1,141 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { + render, + screen, +} from '@testing-library/react' + +import type { BillingAccountDetails } from '../../services' + +import BillingAccountLineItemsModal from './BillingAccountLineItemsModal' + +jest.mock('../../../config/routes.config', () => ({ + rootRoute: '/work', +})) + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + API: { + V6: 'https://example.com/v6', + }, + }, +}), { + virtual: true, +}) + +jest.mock('~/libs/core', () => ({ + xhrGetAsync: jest.fn(), +}), { + virtual: true, +}) + +jest.mock('~/libs/ui', () => ({ + Button: (props: { + label: string + onClick: () => void + }): JSX.Element => ( + + ), + IconOutline: { + LockClosedIcon: (): JSX.Element => locked, + XIcon: (): JSX.Element => close, + }, + IconSolid: { + CheckCircleIcon: (): JSX.Element => consumed, + ChevronDownIcon: (): JSX.Element => sort-desc, + ChevronUpIcon: (): JSX.Element => sort-asc, + }, +}), { + virtual: true, +}) + +const baseBillingAccountDetails: BillingAccountDetails = { + budget: 1000, + consumedAmounts: [], + consumedBudget: 0, + id: 80001063, + lockedAmounts: [], + lockedBudget: 0, + name: 'Platform Dev - One', + totalBudgetRemaining: 1000, +} + +function renderModal(billingAccountDetails: BillingAccountDetails): ReturnType { + return render( + , + ) +} + +describe('BillingAccountLineItemsModal', () => { + it('builds challenge links under the work root for path-based deployments', () => { + renderModal({ + ...baseBillingAccountDetails, + lockedAmounts: [ + { + amount: '125.25', + date: '2026-02-10T00:00:00.000Z', + externalId: 'challenge / 100', + externalName: 'Canonical Challenge', + externalType: 'CHALLENGE', + }, + ], + lockedBudget: 125.25, + totalBudgetRemaining: 874.75, + }) + + const challengeLink = screen.getByRole('link', { + name: 'Canonical Challenge', + }) + + expect(challengeLink.getAttribute('href')) + .toBe('/work/challenges/challenge%20%2F%20100') + }) + + it('renders legacy-only challenge rows as plain text', () => { + renderModal({ + ...baseBillingAccountDetails, + lockedAmounts: [ + { + amount: '125.25', + challengeId: 'legacy-challenge-100', + date: '2026-02-10T00:00:00.000Z', + externalName: 'Legacy Challenge', + externalType: 'CHALLENGE', + }, + ], + lockedBudget: 125.25, + totalBudgetRemaining: 874.75, + }) + + expect(screen.getByText('Legacy Challenge')) + .toBeTruthy() + expect(screen.queryByRole('link', { + name: 'Legacy Challenge', + })) + .toBeNull() + }) + + it('renders ISO midnight entry dates without local timezone shifts', () => { + renderModal({ + ...baseBillingAccountDetails, + lockedAmounts: [ + { + amount: '125.25', + date: '2026-02-10T00:00:00.000Z', + externalId: 'challenge-100', + externalName: 'Date Stable Challenge', + externalType: 'CHALLENGE', + }, + ], + lockedBudget: 125.25, + totalBudgetRemaining: 874.75, + }) + + expect(screen.getByText('2026-02-10')) + .toBeTruthy() + }) +}) diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx index 17659d3ac..7c5ff6b40 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx @@ -12,21 +12,26 @@ import { IconSolid, } from '~/libs/ui' +import { rootRoute } from '../../../config/routes.config' import { BillingAccountDetails, BillingAccountLineItem, combineBillingAccountLineItems, -} from '../../services' +} from '../../services/billing-accounts.service' import styles from './BillingAccountLineItemsModal.module.scss' -type SortField = 'amount' | 'type' | 'createdAt' +type SortField = 'amount' | 'status' | 'date' type SortOrder = 'asc' | 'desc' +const EXTERNAL_TYPE_LABELS: Record = { + CHALLENGE: 'Challenge', + ENGAGEMENT: 'Engagement', +} + export interface BillingAccountLineItemsModalProps { billingAccountDetails: BillingAccountDetails onClose: () => void - workBaseUrl?: string } function formatCurrency(amount: number): string { @@ -40,11 +45,17 @@ function formatCurrency(amount: number): string { } function formatDate(dateString: string): string { + const isoDateMatch = dateString.match(/^(\d{4}-\d{2}-\d{2})/) + + if (isoDateMatch) { + return isoDateMatch[1] + } + const date = new Date(dateString) - const year = date.getFullYear() - const month = String(date.getMonth() + 1) + const year = date.getUTCFullYear() + const month = String(date.getUTCMonth() + 1) .padStart(2, '0') - const day = String(date.getDate()) + const day = String(date.getUTCDate()) .padStart(2, '0') return `${year}-${month}-${day}` } @@ -53,13 +64,13 @@ function compareByAmount(a: BillingAccountLineItem, b: BillingAccountLineItem): return a.amount - b.amount } -function compareByType(a: BillingAccountLineItem, b: BillingAccountLineItem): number { - return a.type.localeCompare(b.type) +function compareByStatus(a: BillingAccountLineItem, b: BillingAccountLineItem): number { + return a.status.localeCompare(b.status) } -function compareByCreatedAt(a: BillingAccountLineItem, b: BillingAccountLineItem): number { - const dateA = new Date(a.createdAt) - const dateB = new Date(b.createdAt) +function compareByDate(a: BillingAccountLineItem, b: BillingAccountLineItem): number { + const dateA = new Date(a.date) + const dateB = new Date(b.date) return dateA.getTime() - dateB.getTime() } @@ -75,11 +86,11 @@ function sortLineItems( case 'amount': comparison = compareByAmount(a, b) break - case 'type': - comparison = compareByType(a, b) + case 'status': + comparison = compareByStatus(a, b) break - case 'createdAt': - comparison = compareByCreatedAt(a, b) + case 'date': + comparison = compareByDate(a, b) break default: comparison = 0 @@ -89,15 +100,15 @@ function sortLineItems( }) } -function buildChallengeUrl(workBaseUrl: string, challengeId: string): string { - const baseUrl = workBaseUrl.replace(/\/$/, '') - return `${baseUrl}/challenges/${encodeURIComponent(challengeId)}` +function buildChallengeUrl(externalId: string): string { + const basePath = rootRoute.replace(/\/$/, '') + return `${basePath}/challenges/${encodeURIComponent(externalId)}` } export const BillingAccountLineItemsModal: FC = ( props: BillingAccountLineItemsModalProps, ) => { - const [sortBy, setSortBy] = useState('createdAt') + const [sortBy, setSortBy] = useState('date') const [sortOrder, setSortOrder] = useState('desc') const lineItems = useMemo( @@ -110,8 +121,6 @@ export const BillingAccountLineItemsModal: FC [lineItems, sortBy, sortOrder], ) - const workBaseUrl = props.workBaseUrl || window.location.origin - const handleContainerClick = useCallback( (event: MouseEvent): void => { event.stopPropagation() @@ -128,20 +137,20 @@ export const BillingAccountLineItemsModal: FC } }, [sortBy]) - const handleSortType = useCallback((): void => { - if (sortBy === 'type') { + const handleSortStatus = useCallback((): void => { + if (sortBy === 'status') { setSortOrder(current => (current === 'asc' ? 'desc' : 'asc')) } else { - setSortBy('type') + setSortBy('status') setSortOrder('desc') } }, [sortBy]) - const handleSortCreatedAt = useCallback((): void => { - if (sortBy === 'createdAt') { + const handleSortDate = useCallback((): void => { + if (sortBy === 'date') { setSortOrder(current => (current === 'asc' ? 'desc' : 'asc')) } else { - setSortBy('createdAt') + setSortBy('date') setSortOrder('desc') } }, [sortBy]) @@ -223,59 +232,70 @@ export const BillingAccountLineItemsModal: FC - Challenge ID + Type + Name - {sortedLineItems.map(item => ( - - {formatCurrency(item.amount)} - - - {item.type === 'locked' ? ( - - ) : ( - - )} - {item.type === 'locked' ? 'Locked' : 'Consumed'} - - - -
    - {item.challengeId} - - - {formatDate(item.createdAt)} - - ))} + {sortedLineItems.map(item => { + const displayName = item.externalName || '-' + const challengeUrl = item.externalType === 'CHALLENGE' && item.externalId + ? buildChallengeUrl(item.externalId) + : undefined + + return ( + + {formatCurrency(item.amount)} + + + {item.status === 'locked' ? ( + + ) : ( + + )} + {item.status === 'locked' ? 'Locked' : 'Consumed'} + + + {EXTERNAL_TYPE_LABELS[item.externalType]} + + {challengeUrl ? ( + + {displayName} + + ) : displayName} + + {formatDate(item.date)} + + ) + })} )} diff --git a/src/apps/work/src/lib/hooks/useFetchBillingAccountDetails.ts b/src/apps/work/src/lib/hooks/useFetchBillingAccountDetails.ts index ca89026dd..2adf6c007 100644 --- a/src/apps/work/src/lib/hooks/useFetchBillingAccountDetails.ts +++ b/src/apps/work/src/lib/hooks/useFetchBillingAccountDetails.ts @@ -22,10 +22,10 @@ function normalizeId(billingAccountId: string | number | undefined): string { } /** - * Fetches detailed billing account information including locked and consumed amounts. + * Fetches detailed billing account information including locked and consumed external entries. * * @param billingAccountId The billing account identifier to fetch. - * @returns Billing account details with budget information and line items. + * @returns Billing account details with budget totals and typed external-entry line item payloads. */ export function useFetchBillingAccountDetails( billingAccountId: string | number | undefined, diff --git a/src/apps/work/src/lib/services/billing-accounts.service.spec.ts b/src/apps/work/src/lib/services/billing-accounts.service.spec.ts index 52ce18538..19a49f9f6 100644 --- a/src/apps/work/src/lib/services/billing-accounts.service.spec.ts +++ b/src/apps/work/src/lib/services/billing-accounts.service.spec.ts @@ -1,7 +1,12 @@ /* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ import { xhrGetAsync } from '~/libs/core' -import { searchBillingAccounts } from './billing-accounts.service' +import { + BillingAccountDetails, + combineBillingAccountLineItems, + fetchBillingAccountById, + searchBillingAccounts, +} from './billing-accounts.service' jest.mock('~/config', () => ({ EnvironmentConfig: { @@ -25,6 +30,8 @@ jest.mock('~/libs/core', () => ({ virtual: true, }) +const NULL_EXTERNAL_NAME = JSON.parse('null') as null + describe('searchBillingAccounts', () => { beforeEach(() => { jest.clearAllMocks() @@ -62,3 +69,236 @@ describe('searchBillingAccounts', () => { ) }) }) + +describe('fetchBillingAccountById', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('fetches billing account details with typed external-entry line items', async () => { + const mockedGetAsync = xhrGetAsync as jest.Mock + const billingAccountDetails = { + budget: 5000, + consumedAmounts: [ + { + amount: 100, + date: '2026-02-11T00:00:00.000Z', + externalId: 'engagement-200', + externalName: 'Engagement Two Hundred', + externalType: 'ENGAGEMENT', + }, + ], + consumedBudget: 100, + id: 80001063, + lockedAmounts: [ + { + amount: '250.50', + challengeId: 'legacy-challenge-100', + date: '2026-02-10T00:00:00.000Z', + externalId: 'challenge-100', + externalName: 'Challenge One Hundred', + externalType: 'CHALLENGE', + }, + ], + lockedBudget: 250.50, + name: 'Platform Dev - One', + totalBudgetRemaining: 4649.50, + } as BillingAccountDetails + + mockedGetAsync.mockResolvedValue(billingAccountDetails) + + const result = await fetchBillingAccountById(' 80001063 ') + + expect(result) + .toEqual(billingAccountDetails) + expect(result.lockedAmounts[0]) + .toMatchObject({ + amount: '250.50', + challengeId: 'legacy-challenge-100', + date: '2026-02-10T00:00:00.000Z', + externalId: 'challenge-100', + externalName: 'Challenge One Hundred', + externalType: 'CHALLENGE', + }) + expect(result.consumedAmounts[0]) + .toMatchObject({ + amount: 100, + date: '2026-02-11T00:00:00.000Z', + externalId: 'engagement-200', + externalName: 'Engagement Two Hundred', + externalType: 'ENGAGEMENT', + }) + expect(mockedGetAsync) + .toHaveBeenCalledWith('https://example.com/v6/billing-accounts/80001063') + }) +}) + +describe('combineBillingAccountLineItems', () => { + it('normalizes typed external entries into status-aware UI rows', () => { + const billingAccountDetails = { + budget: 2000, + consumedAmounts: [ + { + amount: '75', + date: '2026-02-12T00:00:00.000Z', + externalId: 'assignment-300', + externalName: 'Engagement Assignment', + externalType: 'ENGAGEMENT', + }, + { + amount: '75', + date: '2026-02-12T00:00:00.000Z', + externalId: 'assignment-300', + externalName: 'Engagement Assignment', + externalType: 'ENGAGEMENT', + }, + ], + consumedBudget: 150, + id: 80001063, + lockedAmounts: [ + { + amount: '125.25', + challengeId: 'legacy-challenge-100', + createdAt: '2025-01-01T00:00:00.000Z', + date: '2026-02-10T00:00:00.000Z', + externalId: 'challenge-100', + externalName: 'Challenge One Hundred', + externalType: 'CHALLENGE', + }, + { + amount: 50, + challengeId: 'legacy-challenge-should-not-drive-type', + date: '2026-02-11T00:00:00.000Z', + externalId: 'engagement-legacy', + externalName: 'Legacy Engagement', + externalType: 'ENGAGEMENT', + }, + ], + lockedBudget: 175.25, + name: 'Platform Dev - One', + totalBudgetRemaining: 1674.75, + } as unknown as BillingAccountDetails + + const result = combineBillingAccountLineItems(billingAccountDetails) + + expect(result) + .toHaveLength(4) + expect(result[0]) + .toMatchObject({ + amount: 125.25, + date: '2026-02-10T00:00:00.000Z', + externalId: 'challenge-100', + externalName: 'Challenge One Hundred', + externalType: 'CHALLENGE', + status: 'locked', + }) + expect(result[1]) + .toMatchObject({ + amount: 50, + externalId: 'engagement-legacy', + externalName: 'Legacy Engagement', + externalType: 'ENGAGEMENT', + status: 'locked', + }) + expect(result[1].externalId) + .not + .toBe('legacy-challenge-should-not-drive-type') + + const consumedRows = result.filter(item => item.status === 'consumed') + + expect(consumedRows) + .toHaveLength(2) + expect(consumedRows[0]) + .toMatchObject({ + date: '2026-02-12T00:00:00.000Z', + externalId: 'assignment-300', + externalType: 'ENGAGEMENT', + status: 'consumed', + }) + expect(consumedRows[1]) + .toMatchObject({ + date: '2026-02-12T00:00:00.000Z', + externalId: 'assignment-300', + externalType: 'ENGAGEMENT', + status: 'consumed', + }) + expect(consumedRows[0].id) + .not + .toBe(consumedRows[1].id) + }) + + it('preserves legacy challenge ids without normalizing them into canonical external ids', () => { + const billingAccountDetails = { + budget: 2000, + consumedAmounts: [], + consumedBudget: 0, + id: 80001063, + lockedAmounts: [ + { + amount: '125.25', + challengeId: 'legacy-challenge-100', + date: '2026-02-10T00:00:00.000Z', + externalName: 'Legacy Challenge One Hundred', + externalType: 'CHALLENGE', + }, + ], + lockedBudget: 125.25, + name: 'Platform Dev - One', + totalBudgetRemaining: 1874.75, + } as BillingAccountDetails + + const result = combineBillingAccountLineItems(billingAccountDetails) + + expect(result) + .toHaveLength(1) + expect(result[0]) + .toMatchObject({ + amount: 125.25, + challengeId: 'legacy-challenge-100', + date: '2026-02-10T00:00:00.000Z', + externalName: 'Legacy Challenge One Hundred', + externalType: 'CHALLENGE', + status: 'locked', + }) + expect(Object.prototype.hasOwnProperty.call(result[0], 'externalId')) + .toBe(false) + expect(result[0].externalId) + .toBeUndefined() + }) + + it('normalizes null external names from canonical or legacy ids', () => { + const billingAccountDetails = { + budget: 2000, + consumedAmounts: [ + { + amount: 75, + date: '2026-02-12T00:00:00.000Z', + externalId: 'assignment-300', + externalName: NULL_EXTERNAL_NAME, + externalType: 'ENGAGEMENT', + }, + ], + consumedBudget: 75, + id: 80001063, + lockedAmounts: [ + { + amount: 125, + challengeId: 'legacy-challenge-100', + date: '2026-02-10T00:00:00.000Z', + externalName: NULL_EXTERNAL_NAME, + externalType: 'CHALLENGE', + }, + ], + lockedBudget: 125, + name: 'Platform Dev - One', + totalBudgetRemaining: 1800, + } as BillingAccountDetails + + const result = combineBillingAccountLineItems(billingAccountDetails) + + expect(result[0].externalName) + .toBe('legacy-challenge-100') + expect(result[1].externalName) + .toBe('assignment-300') + }) +}) diff --git a/src/apps/work/src/lib/services/billing-accounts.service.ts b/src/apps/work/src/lib/services/billing-accounts.service.ts index 555671afe..7ea99adf0 100644 --- a/src/apps/work/src/lib/services/billing-accounts.service.ts +++ b/src/apps/work/src/lib/services/billing-accounts.service.ts @@ -13,30 +13,30 @@ export interface BillingAccount { [key: string]: unknown } -export interface BillingAccountLockedAmount { - id: string - billingAccountId: number - challengeId: string - amount: number - createdAt: string - updatedAt: string -} +export type BillingAccountLineItemStatus = 'locked' | 'consumed' +export type BillingAccountExternalType = 'CHALLENGE' | 'ENGAGEMENT' -export interface BillingAccountConsumedAmount { - id: string - billingAccountId: number - challengeId: string - amount: number - createdAt: string - updatedAt: string +export interface BillingAccountBudgetEntry { + amount: number | string + challengeId?: string + date: string + externalId?: string + externalName: string | null + externalType: BillingAccountExternalType } +export type BillingAccountLockedAmount = BillingAccountBudgetEntry +export type BillingAccountConsumedAmount = BillingAccountBudgetEntry + export interface BillingAccountLineItem { id: string - challengeId: string amount: number - createdAt: string - type: 'locked' | 'consumed' + challengeId?: string + date: string + externalId?: string + externalName?: string | null + externalType: BillingAccountExternalType + status: BillingAccountLineItemStatus } export interface BillingAccountDetails extends BillingAccount { @@ -127,6 +127,72 @@ function createSearchQuery(params: SearchBillingAccountsParams): string { : '' } +/** + * Creates a deterministic UI-only row key from the source bucket and stable row context. + * + * @param status The source budget bucket for the row. + * @param item The raw external budget entry returned by the billing account API. + * @param index The entry index within its source bucket, used to keep repeated rows unique. + * @returns A row key suitable for React rendering. + */ +function createLineItemKey( + status: BillingAccountLineItemStatus, + item: BillingAccountBudgetEntry, + index: number, +): string { + return [ + status, + item.externalType, + item.externalId || item.challengeId || 'unknown', + item.date || 'unknown-date', + item.amount, + index, + ] + .map(value => encodeURIComponent(String(value))) + .join('-') +} + +/** + * Converts an API budget entry into a UI line item without aliasing legacy challenge ids. + * + * @param status The budget bucket the API entry came from. + * @param item The raw external budget entry returned by the billing account API. + * @param index The entry index within its source bucket, used in the generated row key. + * @returns A normalized line item with numeric amount, original date, display + * fallback for nullable external names, optional canonical external id, + * optional legacy challenge id, and a deterministic UI row key. + */ +function createLineItem( + status: BillingAccountLineItemStatus, + item: BillingAccountBudgetEntry, + index: number, +): BillingAccountLineItem { + const normalizedExternalName = item.externalName + || item.externalId + || item.challengeId + const lineItem: BillingAccountLineItem = { + amount: Number(item.amount), + date: item.date, + externalType: item.externalType, + id: createLineItemKey(status, item, index), + status, + } + + if (normalizedExternalName) { + lineItem.externalName = normalizedExternalName + } + + if (item.challengeId) { + lineItem.challengeId = item.challengeId + } + + if (item.externalId) { + lineItem.externalId = item.externalId + } + + return lineItem +} + /** * Fetches billing accounts using default API pagination. * @@ -168,6 +234,11 @@ export async function searchBillingAccounts( /** * Fetches a single billing account by its identifier. + * + * The detail payload includes budget totals plus locked and consumed external + * entries with `amount`, `date`, optional canonical `externalId`, `externalType`, + * and nullable `externalName`. Top-level `id` and `name` remain available for + * lookup labels. */ export async function fetchBillingAccountById( billingAccountId: string, @@ -188,26 +259,21 @@ export async function fetchBillingAccountById( } /** - * Combines locked and consumed amounts into a unified line items array. + * Combines locked and consumed external budget entries into UI line items. + * + * @param details Billing account details containing locked and consumed entry arrays. + * @returns Normalized line items with numeric amounts, API dates, external metadata, status, and UI row keys. */ export function combineBillingAccountLineItems( details: BillingAccountDetails, ): BillingAccountLineItem[] { - const lockedItems: BillingAccountLineItem[] = (details.lockedAmounts || []).map(item => ({ - amount: Number(item.amount), - challengeId: item.challengeId, - createdAt: item.createdAt, - id: `locked-${item.id}`, - type: 'locked' as const, - })) + const lockedItems: BillingAccountLineItem[] = (details.lockedAmounts || []).map( + (item, index) => createLineItem('locked', item, index), + ) - const consumedItems: BillingAccountLineItem[] = (details.consumedAmounts || []).map(item => ({ - amount: Number(item.amount), - challengeId: item.challengeId, - createdAt: item.createdAt, - id: `consumed-${item.id}`, - type: 'consumed' as const, - })) + const consumedItems: BillingAccountLineItem[] = (details.consumedAmounts || []).map( + (item, index) => createLineItem('consumed', item, index), + ) return [...lockedItems, ...consumedItems] } diff --git a/src/apps/work/src/lib/services/payments.service.ts b/src/apps/work/src/lib/services/payments.service.ts index dc06bf103..b77695e55 100644 --- a/src/apps/work/src/lib/services/payments.service.ts +++ b/src/apps/work/src/lib/services/payments.service.ts @@ -18,7 +18,6 @@ const DEFAULT_ENGAGEMENT_PAYMENT_STATUS = 'ON_HOLD_ADMIN' interface PaymentDetailsPayload { billingAccount: string - challengeFee: number currency: string grossAmount: number installmentNumber: number @@ -173,7 +172,6 @@ export async function createMemberPayment( details: [ { billingAccount: String(billingAccountId), - challengeFee: 0, currency: 'USD', grossAmount: numericAmount, installmentNumber: 1, From 5e366de47506e52545eb1730999e59c18602cdac Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 22 Apr 2026 15:51:51 +1000 Subject: [PATCH 75/81] Update for comment on PM-4893 --- .../components/EngagementEditorForm.spec.tsx | 9 +- .../components/EngagementEditorForm.tsx | 155 ++++++++++++-- .../EngagementPrivateSection.module.scss | 28 +++ .../EngagementPrivateSection.spec.tsx | 189 ++++++++++++++++++ .../components/EngagementPrivateSection.tsx | 182 ++++++++++++----- 5 files changed, 492 insertions(+), 71 deletions(-) create mode 100644 src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.spec.tsx diff --git a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.spec.tsx b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.spec.tsx index e8da02aee..b346e998f 100644 --- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.spec.tsx +++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.spec.tsx @@ -500,7 +500,7 @@ describe('EngagementEditorForm', () => { }) }) - it('keeps terminal assignment history out of the edit payload', async () => { + it('preserves existing assignments while keeping terminal history out of the edit payload', async () => { const user = userEvent.setup() const activeAssignment = { agreementRate: '800', @@ -590,6 +590,10 @@ describe('EngagementEditorForm', () => { , ) + const requiredMembersField = screen.getByLabelText('Required Members') + + await user.clear(requiredMembersField) + await user.type(requiredMembersField, '0') await user.click(screen.getByRole('button', { name: 'Save Engagement' })) await waitFor(() => { @@ -600,8 +604,11 @@ describe('EngagementEditorForm', () => { const payload = mockedUpdateEngagement.mock.calls[0][1] as { assignedMemberHandles?: string[] assignmentDetails?: Array<{ memberHandle: string }> + requiredMemberCount?: number } + expect(payload.requiredMemberCount) + .toBe(1) expect(payload.assignedMemberHandles) .toEqual(['active_member']) expect(payload.assignmentDetails) diff --git a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.tsx b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.tsx index 6318a89e1..6c9ad5f06 100644 --- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.tsx +++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.tsx @@ -105,6 +105,10 @@ interface SaveEngagementOptions { isAutosave?: boolean } +interface AssignmentSerializationOptions { + lockedAssignmentDetails?: AssignmentDetailsFormValue[] +} + type EngagementAssignment = Engagement['assignments'][number] type SerializedAssignmentDetailsPayload = { agreementRate: string @@ -137,21 +141,43 @@ function normalizeProjectId(projectId: number | string | undefined): string { * * @param requiredMemberCount raw form value for the private member count. * @param assignedMemberHandles form values for the selected member handles. + * @param lockedAssignedMemberHandles persisted member handles that must remain visible. * @returns trimmed handles for the currently active private-assignment slots. */ function getVisibleAssignedMemberHandles( requiredMemberCount: number | string | undefined, assignedMemberHandles: string[], + lockedAssignedMemberHandles: string[] = [], ): string[] { const parsedRequiredMemberCount = Number(requiredMemberCount) - const assignmentLimit = Number.isInteger(parsedRequiredMemberCount) && parsedRequiredMemberCount > 0 - ? parsedRequiredMemberCount - : assignedMemberHandles.length + const assignmentLimit = Math.max( + Number.isInteger(parsedRequiredMemberCount) && parsedRequiredMemberCount > 0 + ? parsedRequiredMemberCount + : assignedMemberHandles.length, + lockedAssignedMemberHandles.length, + ) + + return Array.from({ length: assignmentLimit }, (_, index) => ( + lockedAssignedMemberHandles[index] || assignedMemberHandles[index] || '' + )) + .map(memberHandle => String(memberHandle) + .trim()) +} - return assignedMemberHandles - .slice(0, assignmentLimit) - .map(memberHandle => String(memberHandle || '') +/** + * Extracts locked member handles from persisted assignment details. + * + * @param lockedAssignmentDetails existing assignment detail rows that should + * remain owned by the assignments list. + * @returns member handles that cannot be edited from the engagement form. + */ +function getLockedAssignedMemberHandles( + lockedAssignmentDetails: AssignmentDetailsFormValue[] = [], +): string[] { + return lockedAssignmentDetails + .map(assignmentDetail => String(assignmentDetail.memberHandle || '') .trim()) + .filter(Boolean) } /** @@ -159,19 +185,27 @@ function getVisibleAssignedMemberHandles( * member handle selected for each visible slot. * * @param values engagement editor form values. + * @param lockedAssignmentDetails persisted assignment details that must remain + * unchanged while editing the engagement. * @returns serialized assignment details aligned to the active member handles. */ function serializeAssignmentDetails( values: EngagementEditorFormData, + lockedAssignmentDetails: AssignmentDetailsFormValue[] = [], ): SerializedAssignmentDetailsPayload[] { + const lockedAssignedMemberHandles = getLockedAssignedMemberHandles(lockedAssignmentDetails) const visibleAssignedMemberHandles = getVisibleAssignedMemberHandles( values.requiredMemberCount, values.assignedMemberHandles, + lockedAssignedMemberHandles, ) const serializedAssignmentDetails: Array = visibleAssignedMemberHandles .map((memberHandle, index) => { - const detail = values.assignmentDetails[index] + const lockedDetail = lockedAssignmentDetails[index] + const detail = lockedDetail?.memberHandle + ? lockedDetail + : values.assignmentDetails[index] const detailMemberHandle = String(detail?.memberHandle || '') .trim() @@ -257,6 +291,26 @@ function getAssignmentDefaults(engagement: Engagement | undefined): { } } +/** + * Builds read-only assignment defaults for existing engagement assignments. + * + * @param engagement engagement being edited, if one exists. + * @returns assignment details for active assignments that should no longer be + * editable from the engagement editor. + */ +function getLockedAssignmentDetails( + engagement: Engagement | undefined, +): AssignmentDetailsFormValue[] { + const assignments = engagement?.assignments + + if (!Array.isArray(assignments) || assignments.length < 1) { + return [] + } + + return getCountableEngagementAssignments(assignments) + .map(toAssignmentDetailsValue) +} + /** * Resolves the form's parent project id from the engagement payload first, * falling back to the route-scoped project id for new engagements. @@ -395,10 +449,48 @@ function getEngagementsPath(projectId: number | string | undefined): string { return `${rootRoute}/projects/${normalizeProjectId(projectId)}/engagements` } -function toPayload(values: EngagementEditorFormData): Partial & { +/** + * Resolves a required-member count while preserving already-assigned slots. + * + * @param rawRequiredMemberCount form value for the required member count. + * @param minimumMemberCount minimum count needed to keep locked assignments visible. + * @returns normalized member count, or `undefined` when the form value is blank. + */ +function getPayloadRequiredMemberCount( + rawRequiredMemberCount: number | string | undefined, + minimumMemberCount: number, +): number | undefined { + if (rawRequiredMemberCount === '' || rawRequiredMemberCount === undefined) { + return minimumMemberCount > 0 + ? minimumMemberCount + : undefined + } + + const requiredMemberCount = Number(rawRequiredMemberCount) + + if (!Number.isFinite(requiredMemberCount)) { + return undefined + } + + return Math.max(requiredMemberCount, minimumMemberCount) +} + +/** + * Converts engagement editor form state into the API payload. + * + * @param values engagement editor form values. + * @param options serialization options for preserving locked assignment slots. + * @returns partial engagement payload ready for create or update. + */ +function toPayload( + values: EngagementEditorFormData, + options: AssignmentSerializationOptions = {}, +): Partial & { assignmentDetails?: SerializedAssignmentDetailsPayload[] } { const rawRequiredMemberCount = values.requiredMemberCount + const lockedAssignmentDetails = options.lockedAssignmentDetails || [] + const lockedAssignedMemberHandles = getLockedAssignedMemberHandles(lockedAssignmentDetails) const payload: Partial & { assignmentDetails?: SerializedAssignmentDetailsPayload[] } = { @@ -417,18 +509,22 @@ function toPayload(values: EngagementEditorFormData): Partial & { workload: values.workload, } - if (rawRequiredMemberCount !== '' && rawRequiredMemberCount !== undefined) { - const requiredMemberCount = Number(rawRequiredMemberCount) + const requiredMemberCount = getPayloadRequiredMemberCount( + rawRequiredMemberCount, + values.isPrivate + ? lockedAssignedMemberHandles.length + : 0, + ) - if (Number.isFinite(requiredMemberCount)) { - payload.requiredMemberCount = requiredMemberCount - } + if (requiredMemberCount !== undefined) { + payload.requiredMemberCount = requiredMemberCount } if (values.isPrivate) { const assignedMemberHandles = getVisibleAssignedMemberHandles( values.requiredMemberCount, values.assignedMemberHandles, + lockedAssignedMemberHandles, ) .filter(Boolean) @@ -436,7 +532,7 @@ function toPayload(values: EngagementEditorFormData): Partial & { payload.assignedMemberHandles = assignedMemberHandles } - const assignmentDetails = serializeAssignmentDetails(values) + const assignmentDetails = serializeAssignmentDetails(values, lockedAssignmentDetails) if (assignmentDetails.length > 0) { payload.assignmentDetails = assignmentDetails @@ -460,6 +556,16 @@ export const EngagementEditorForm: FC = ( const [isSaving, setIsSaving] = useState(false) const [saveError, setSaveError] = useState() + const lockedAssignmentDetails = useMemo( + () => (props.isEditMode + ? getLockedAssignmentDetails(props.engagement) + : []), + [props.engagement, props.isEditMode], + ) + const lockedAssignedMemberHandles = useMemo( + () => getLockedAssignedMemberHandles(lockedAssignmentDetails), + [lockedAssignmentDetails], + ) const roleOptions = useMemo(() => createRoleOptions(), []) const workloadOptions = useMemo(() => createWorkloadOptions(), []) const currentProjectOption = useMemo( @@ -522,7 +628,9 @@ export const EngagementEditorForm: FC = ( setSaveError(undefined) try { - const payload = toPayload(nextValues) + const payload = toPayload(nextValues, { + lockedAssignmentDetails, + }) let savedEngagement: Engagement @@ -565,7 +673,14 @@ export const EngagementEditorForm: FC = ( } } }, - [currentEngagementId, navigate, props.isEditMode, props.projectId, reset], + [ + currentEngagementId, + lockedAssignmentDetails, + navigate, + props.isEditMode, + props.projectId, + reset, + ], ) const loadParentProjectOptions = useCallback( @@ -755,7 +870,13 @@ export const EngagementEditorForm: FC = (
    - +
    {saveError diff --git a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.module.scss b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.module.scss index 6ff94c4ef..556da1e86 100644 --- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.module.scss +++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.module.scss @@ -35,6 +35,27 @@ gap: 8px; } +.readOnlyAssignment { + display: flex; + flex-direction: column; + gap: 6px; +} + +.readOnlyLabel { + color: #2a2a2a; + font-size: 12px; + font-weight: 700; +} + +.readOnlyValue { + background: #f5f7fa; + border: 1px solid #d8dee8; + border-radius: 6px; + color: #2a2a2a; + min-height: 44px; + padding: 12px; +} + .actionButton { align-self: flex-start; background: none; @@ -77,6 +98,13 @@ text-decoration: underline; } +.assignmentLink { + align-self: flex-start; + color: #2a62d5; + font-size: 12px; + text-decoration: underline; +} + .errorText { color: #db524f; font-size: 12px; diff --git a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.spec.tsx b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.spec.tsx new file mode 100644 index 000000000..b2239fa33 --- /dev/null +++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.spec.tsx @@ -0,0 +1,189 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, react/jsx-no-bind */ +import type { + FC, + PropsWithChildren, +} from 'react' +import { + render, + screen, +} from '@testing-library/react' +import { + FormProvider, + useForm, +} from 'react-hook-form' +import { MemoryRouter } from 'react-router-dom' + +import { + EngagementPrivateSection, +} from './EngagementPrivateSection' + +jest.mock('../../../../lib/components/form', () => { + const reactHookForm: typeof import('react-hook-form') = jest.requireActual('react-hook-form') + + return { + FormCheckboxField: function FormCheckboxField(props: { + disabled?: boolean + label: string + name: string + }) { + const controller = reactHookForm.useController({ + control: reactHookForm.useFormContext().control, + name: props.name, + }) + + return ( + + ) + }, + FormUserAutocomplete: function FormUserAutocomplete(props: { + label: string + name: string + onValueChange?: (value: string) => void + }) { + const controller = reactHookForm.useController({ + control: reactHookForm.useFormContext().control, + name: props.name, + }) + + return ( + + ) + }, + } +}) + +jest.mock('../../../../lib/utils', () => ({ + formatAssignmentCurrency: (value?: string): string => (value ? `$${value}` : ''), + getAssignmentStandardHoursPerWeek: (detail: { standardHoursPerWeek?: string }): string => ( + detail.standardHoursPerWeek || '' + ), +})) + +jest.mock('../../../../lib/utils/payment.utils', () => ({ + formatCurrency: (value?: string): string => (value ? `$${value}` : ''), +})) + +jest.mock('./AssignmentDetailsModal', () => ({ + AssignmentDetailsModal: (): JSX.Element => <>, +})) + +interface TestFormValues { + assignedMemberHandles: string[] + assignmentDetails: Array<{ + agreementRate: string + durationMonths: string + memberHandle: string + otherRemarks?: string + ratePerHour: string + standardHoursPerWeek: string + startDate: string + }> + isPrivate: boolean + requiredMemberCount: number | string +} + +const defaultAssignmentDetails = { + agreementRate: '800', + durationMonths: '3', + memberHandle: 'assigned_member', + otherRemarks: 'active notes', + ratePerHour: '20', + standardHoursPerWeek: '40', + startDate: '2026-05-01T00:00:00.000Z', +} + +function renderPrivateSection( + defaultValues: TestFormValues, + props: { + assignmentManagementPath?: string + lockedAssignedMemberHandles?: string[] + } = {}, +): void { + const FormWrapper: FC = (wrapperProps: PropsWithChildren) => { + const methods = useForm({ + defaultValues, + }) + + return ( + + + {wrapperProps.children} + + + ) + } + + render( + + + , + ) +} + +describe('EngagementPrivateSection', () => { + it('renders existing assigned members as read-only assignment rows', () => { + renderPrivateSection({ + assignedMemberHandles: ['assigned_member'], + assignmentDetails: [defaultAssignmentDetails], + isPrivate: true, + requiredMemberCount: 1, + }, { + assignmentManagementPath: '/projects/123/engagements/engagement-1/assignments', + lockedAssignedMemberHandles: ['assigned_member'], + }) + + expect(screen.getByText('assigned_member')) + .not + .toBeNull() + expect(screen.queryByLabelText('Assign to Member')) + .toBeNull() + expect(screen.queryByRole('button', { name: 'Edit' })) + .toBeNull() + expect(screen.queryByRole('button', { name: 'Add Details' })) + .toBeNull() + expect((screen.getByLabelText('Private engagement') as HTMLInputElement).disabled) + .toBe(true) + expect(screen.getByRole('link', { name: 'Assignments' }) + .getAttribute('href')) + .toBe('/projects/123/engagements/engagement-1/assignments') + }) + + it('keeps empty member slots editable before a member is assigned', () => { + renderPrivateSection({ + assignedMemberHandles: [''], + assignmentDetails: [], + isPrivate: true, + requiredMemberCount: 1, + }) + + expect(screen.getByLabelText('Assign to Member')) + .not + .toBeNull() + expect((screen.getByRole('button', { name: 'Add Details' }) as HTMLButtonElement).disabled) + .toBe(true) + expect((screen.getByLabelText('Private engagement') as HTMLInputElement).disabled) + .toBe(false) + }) +}) diff --git a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.tsx b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.tsx index fc3ab9ff5..e11fa900f 100644 --- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.tsx +++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.tsx @@ -10,6 +10,7 @@ import { import { useFormContext, } from 'react-hook-form' +import { Link } from 'react-router-dom' import { FormCheckboxField, @@ -34,6 +35,11 @@ interface EngagementPrivateSectionForm { requiredMemberCount?: number | string } +interface EngagementPrivateSectionProps { + assignmentManagementPath?: string + lockedAssignedMemberHandles?: string[] +} + function toNumber(value: unknown): number { const parsed = Number(value) @@ -82,6 +88,22 @@ function getAssignmentLabel(index: number, count: number): string { : 'Assign to Member' } +/** + * Normalizes persisted assignment handles before comparing them against form slots. + * + * @param handles member handles from existing active assignment rows. + * @returns trimmed member handles, preserving slot order. + */ +function normalizeLockedAssignedMemberHandles( + handles: string[] | undefined, +): string[] { + return Array.isArray(handles) + ? handles.map(handle => String(handle || '') + .trim()) + .filter(Boolean) + : [] +} + /** * Creates an empty assignment-details value for form slots that no longer map * to the currently selected member handle. @@ -100,13 +122,21 @@ function createEmptyAssignmentDetails(): AssignmentDetailsFormValue { } } -export const EngagementPrivateSection: FC = () => { +export const EngagementPrivateSection: FC = ( + props: EngagementPrivateSectionProps, +) => { const formContext = useFormContext() const [activeAssignmentIndex, setActiveAssignmentIndex] = useState() const isPrivate = formContext.watch('isPrivate') === true const requiredMemberCount = toNumber(formContext.watch('requiredMemberCount')) + const lockedAssignedMemberHandles = useMemo( + () => normalizeLockedAssignedMemberHandles(props.lockedAssignedMemberHandles), + [props.lockedAssignedMemberHandles], + ) + const lockedAssignmentCount = lockedAssignedMemberHandles.length + const hasLockedAssignments = lockedAssignmentCount > 0 const assignedMemberHandles = formContext.watch('assignedMemberHandles') || [] const assignmentDetails = formContext.watch('assignmentDetails') || [] @@ -118,8 +148,11 @@ export const EngagementPrivateSection: FC = () => { )?.message const assignmentIndices = useMemo( - () => Array.from({ length: requiredMemberCount }, (_, index) => index), - [requiredMemberCount], + () => Array.from( + { length: Math.max(requiredMemberCount, lockedAssignmentCount) }, + (_, index) => index, + ), + [lockedAssignmentCount, requiredMemberCount], ) useEffect(() => { @@ -127,10 +160,19 @@ export const EngagementPrivateSection: FC = () => { return } - if (requiredMemberCount < 1 || activeAssignmentIndex >= requiredMemberCount) { + if ( + requiredMemberCount < 1 + || activeAssignmentIndex >= Math.max(requiredMemberCount, lockedAssignmentCount) + || lockedAssignedMemberHandles[activeAssignmentIndex] + ) { setActiveAssignmentIndex(undefined) } - }, [activeAssignmentIndex, requiredMemberCount]) + }, [ + activeAssignmentIndex, + lockedAssignedMemberHandles, + lockedAssignmentCount, + requiredMemberCount, + ]) const activeMemberHandle = activeAssignmentIndex !== undefined ? assignedMemberHandles[activeAssignmentIndex] @@ -153,6 +195,7 @@ export const EngagementPrivateSection: FC = () => {

    Private

    @@ -160,12 +203,14 @@ export const EngagementPrivateSection: FC = () => { {isPrivate ? ( <> - {requiredMemberCount > 0 + {assignmentIndices.length > 0 ? ( <>
    {assignmentIndices.map(index => { - const memberHandle = assignedMemberHandles[index] + const lockedMemberHandle = lockedAssignedMemberHandles[index] + const isLockedAssignment = !!lockedMemberHandle + const memberHandle = lockedMemberHandle || assignedMemberHandles[index] const nextAssignmentDetail = assignmentDetails[index] const assignmentDetail = ( nextAssignmentDetail @@ -178,32 +223,45 @@ export const EngagementPrivateSection: FC = () => { return (
    - { - if (value === memberHandle) { - return - } - - const nextAssignmentDetails = [...assignmentDetails] - nextAssignmentDetails[index] = createEmptyAssignmentDetails() - - formContext.setValue('assignmentDetails', nextAssignmentDetails, { - shouldDirty: true, - shouldValidate: true, - }) - - if (!value) { - return - } - - setActiveAssignmentIndex(index) - }} - placeholder='Search user handle' - required - valueField='handle' - /> + {isLockedAssignment + ? ( +
    + + {getAssignmentLabel(index, assignmentIndices.length)} + + + {lockedMemberHandle} + +
    + ) + : ( + { + if (value === memberHandle) { + return + } + + const nextAssignmentDetails = [...assignmentDetails] + nextAssignmentDetails[index] = createEmptyAssignmentDetails() + + formContext.setValue('assignmentDetails', nextAssignmentDetails, { + shouldDirty: true, + shouldValidate: true, + }) + + if (!value) { + return + } + + setActiveAssignmentIndex(index) + }} + placeholder='Search user handle' + required + valueField='handle' + /> + )}
    @@ -239,30 +297,48 @@ export const EngagementPrivateSection: FC = () => { {' '} {formatCurrency(assignmentDetail.agreementRate)} - + {!isLockedAssignment + ? ( + + ) + : undefined}
    ) : ( - <> - -
    - No details added -
    - + isLockedAssignment + ? undefined + : ( + <> + +
    + No details added +
    + + ) )} + {isLockedAssignment && props.assignmentManagementPath + ? ( + + Assignments + + ) + : undefined}
    ) From e172251fdcb4a9b0d450524971e67ff0f0bb9a5c Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Wed, 22 Apr 2026 19:51:39 +0530 Subject: [PATCH 76/81] PM-4886 Fix filters on talent search --- .../TalentSearchPage/TalentSearchPage.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index cbc953423..1e3436eee 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -77,6 +77,11 @@ export const TalentSearchPage: FC = () => { ) const hasSkillSearch = selectedSkills.length > 0 + const hasActiveFilters = hasSkillSearch + || selectedCountries.length > 0 + || onlyOpenToWork + || onlyActive + const shouldShowIntroState = !hasSearched && !hasActiveFilters const activeSort: TalentSearchSortOption = hasSkillSearch ? 'matching-index' : sortBy const sortOptions = useMemo( (): InputSelectOption[] => (hasSkillSearch @@ -336,7 +341,7 @@ export const TalentSearchPage: FC = () => { }, [isExtractingSkills, jobDescription, runMemberSearch]) useEffect(() => { - if (!hasSearched || isExtractingSkills) { + if ((shouldShowIntroState) || isExtractingSkills) { return } @@ -348,12 +353,14 @@ export const TalentSearchPage: FC = () => { runMemberSearch(selectedSkills, { generation: searchGenerationRef.current, page: 1 }) }, [ hasSearched, + hasActiveFilters, isExtractingSkills, onlyActive, onlyOpenToWork, runMemberSearch, selectedCountries, selectedSkills, + shouldShowIntroState, ]) const handleLoadMore = useCallback((): void => { @@ -441,7 +448,7 @@ export const TalentSearchPage: FC = () => { onChange={(event: ChangeEvent) => { const value = (event.target.value || []) as InputMultiselectOption[] setSelectedSkills(value) - setHasSearched(value.length > 0) + setHasSearched(true) if (value.length === 0) { setLastSearchedDescription('') } @@ -517,7 +524,7 @@ export const TalentSearchPage: FC = () => { !hasSearched && styles.resultsPanelEmpty, )} > - {!hasSearched && ( + {shouldShowIntroState && (
    {
    )} - {hasSearched && ( + {!shouldShowIntroState && (
    {!isSearchingMembers && (
    From 2874a7757d2b98b9ec5469b6baa8fa686250bbb1 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Wed, 22 Apr 2026 20:01:43 +0530 Subject: [PATCH 77/81] Fix inconsistency with css classes --- .../pages/talent-search/TalentSearchPage/TalentSearchPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index 1e3436eee..fa6d0097e 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -521,7 +521,7 @@ export const TalentSearchPage: FC = () => {
    {shouldShowIntroState && ( From b6774c478629f069591201b9aaa8ebe71a360576 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Wed, 22 Apr 2026 20:16:25 +0530 Subject: [PATCH 78/81] Cleanup empty state --- .../talent-search/TalentSearchPage/TalentSearchPage.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index fa6d0097e..03fbadcc6 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -532,12 +532,6 @@ export const TalentSearchPage: FC = () => { className={styles.emptyIcon} />

    Find the right talent

    -

    - Paste a job description on the left and hit  - Search -  - Our AI will match you with the - best candidates from our network. -

    )} From fec911b2180ac8ce9f815d8f2559207207b713e7 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 23 Apr 2026 08:47:04 +1000 Subject: [PATCH 79/81] Better display of exhausted BAs on project screen --- ...ectBillingAccountExpiredNotice.module.scss | 6 + ...rojectBillingAccountExpiredNotice.spec.tsx | 125 ++++++++++++++++++ .../ProjectBillingAccountExpiredNotice.tsx | 103 +++++++++------ 3 files changed, 190 insertions(+), 44 deletions(-) create mode 100644 src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.spec.tsx diff --git a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.module.scss b/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.module.scss index f5e303fe1..e775b583c 100644 --- a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.module.scss +++ b/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.module.scss @@ -1,3 +1,9 @@ +.noticeStack { + align-items: flex-start; + display: flex; + flex-direction: column; +} + .container { display: inline-flex; align-items: center; diff --git a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.spec.tsx b/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.spec.tsx new file mode 100644 index 000000000..6d9a20801 --- /dev/null +++ b/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.spec.tsx @@ -0,0 +1,125 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { + fireEvent, + render, + screen, +} from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' + +import type { BillingAccountDetails } from '../../services' +import { + useFetchBillingAccountDetails, + useFetchBillingAccounts, + useFetchProjectBillingAccount, +} from '../../hooks' + +import ProjectBillingAccountExpiredNotice from './ProjectBillingAccountExpiredNotice' + +jest.mock('../../hooks', () => ({ + useFetchBillingAccountDetails: jest.fn(), + useFetchBillingAccounts: jest.fn(), + useFetchProjectBillingAccount: jest.fn(), +})) + +jest.mock('../BillingAccountLineItemsModal', () => ({ + BillingAccountLineItemsModal: (props: { + billingAccountDetails: BillingAccountDetails + }): JSX.Element => ( +
    + Billing account details for + {' '} + {props.billingAccountDetails.id} +
    + ), +})) + +jest.mock('~/libs/ui', () => ({ + IconOutline: { + InformationCircleIcon: (): JSX.Element => info, + }, +}), { + virtual: true, +}) + +const mockedUseFetchBillingAccountDetails = useFetchBillingAccountDetails as jest.MockedFunction< + typeof useFetchBillingAccountDetails +> +const mockedUseFetchBillingAccounts = useFetchBillingAccounts as jest.MockedFunction +const mockedUseFetchProjectBillingAccount = useFetchProjectBillingAccount as jest.MockedFunction< + typeof useFetchProjectBillingAccount +> + +const billingAccountDetails: BillingAccountDetails = { + budget: 1000, + consumedAmounts: [], + consumedBudget: 0, + id: 80001063, + lockedAmounts: [], + lockedBudget: 0, + name: 'Test Project Engagement BA', + totalBudgetRemaining: -25, +} + +describe('ProjectBillingAccountExpiredNotice', () => { + beforeEach(() => { + jest.clearAllMocks() + + mockedUseFetchBillingAccounts.mockReturnValue({ + billingAccounts: [], + error: undefined, + isError: false, + isLoading: false, + }) + mockedUseFetchBillingAccountDetails.mockReturnValue({ + billingAccountDetails, + error: undefined, + isError: false, + isLoading: false, + }) + mockedUseFetchProjectBillingAccount.mockReturnValue({ + billingAccount: { + active: true, + id: '80001063', + name: 'Test Project Engagement BA', + status: 'ACTIVE', + totalBudgetRemaining: -25, + }, + isLoading: false, + }) + }) + + it('keeps billing account details and line items available when remaining funds are insufficient', () => { + render( + + + , + ) + + expect(screen.getByText(/Billing account:/)) + .toBeTruthy() + expect(screen.getByText(/Test Project Engagement BA/)) + .toBeTruthy() + expect(screen.getByText(/80001063/)) + .toBeTruthy() + expect(screen.getByText('-$25 / $1,000 remaining')) + .toBeTruthy() + expect(screen.getByText(/The billing account for this project has insufficient remaining funds,/)) + .toBeTruthy() + expect(screen.getByRole('link', { name: 'click here to update' }) + .getAttribute('href')) + .toBe('/projects/project-1/edit') + + fireEvent.click(screen.getByRole('button', { + name: 'View billing account details', + })) + + expect(screen.getByRole('dialog') + .textContent) + .toContain('Billing account details for 80001063') + }) +}) diff --git a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.tsx b/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.tsx index 23820888c..82caebc7a 100644 --- a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.tsx +++ b/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.tsx @@ -83,13 +83,14 @@ export const ProjectBillingAccountExpiredNotice: FC { - if (!billingAccountDetails) { + if (!billingAccountDetailsData) { return undefined } - const totalBudget = Number(billingAccountDetails.budget) || 0 - const remaining = Number(billingAccountDetails.totalBudgetRemaining) || 0 + const totalBudget = Number(billingAccountDetailsData.budget) || 0 + const remaining = Number(billingAccountDetailsData.totalBudgetRemaining) || 0 const status = getBudgetStatus(remaining, totalBudget) return { @@ -127,7 +130,7 @@ export const ProjectBillingAccountExpiredNotice: FC { setIsModalOpen(true) @@ -137,39 +140,12 @@ export const ProjectBillingAccountExpiredNotice: FC - {props.canManageProject - ? ( - <> - {managedNoticeMessage} - - click here to update - - - ) - : ( - {noticeMessage} - )} -
    - ) - } - - if (!normalizedBillingAccountId) { - return <> - } - const budgetStatusClass = budgetInfo ? styles[`budget${budgetInfo.status.charAt(0) .toUpperCase()}${budgetInfo.status.slice(1)}`] : '' - - return ( - <> + const billingAccountDetailsContent = normalizedBillingAccountId + ? (
    Billing account: @@ -199,12 +175,51 @@ export const ProjectBillingAccountExpiredNotice: FC )}
    - {isModalOpen && billingAccountDetails && ( - - )} + ) + : undefined + const billingAccountModal = isModalOpen && billingAccountDetailsData + ? ( + + ) + : undefined + + if (billingAccountIssue) { + const noticeMessage = getProjectBillingAccountNoticeMessage(billingAccountIssue) + const managedNoticeMessage = `${noticeMessage.slice(0, -1)}, ` + + return ( +
    + {billingAccountDetailsContent} +
    + {props.canManageProject + ? ( + <> + {managedNoticeMessage} + + click here to update + + + ) + : ( + {noticeMessage} + )} +
    + {billingAccountModal} +
    + ) + } + + if (!normalizedBillingAccountId) { + return <> + } + + return ( + <> + {billingAccountDetailsContent} + {billingAccountModal} ) } From 5e1a2d633277b71c91bb7fc83ac11af7b27eea7c Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Thu, 23 Apr 2026 12:33:14 +0530 Subject: [PATCH 80/81] PM-4886 Use backend sorting --- .../src/lib/services/talentSearch.service.ts | 2 ++ .../TalentSearchPage/TalentSearchPage.tsx | 16 +++++----------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/apps/customer-portal/src/lib/services/talentSearch.service.ts b/src/apps/customer-portal/src/lib/services/talentSearch.service.ts index 14d34dd87..718bcae6c 100644 --- a/src/apps/customer-portal/src/lib/services/talentSearch.service.ts +++ b/src/apps/customer-portal/src/lib/services/talentSearch.service.ts @@ -26,6 +26,8 @@ export type MemberSearchPayload = { openToWork?: boolean page: number recentlyActive?: boolean + sortBy?: 'handle' | 'matchIndex' + sortOrder?: 'asc' | 'desc' skillSearchType: 'OR' skills: Array<{ id: string diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index 03fbadcc6..b62c33dfa 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -102,17 +102,8 @@ export const TalentSearchPage: FC = () => { return true }), [onlyActive, onlyOpenToWork, results]) - const displayedResults = useMemo(() => { - const sorted = [...filteredResults] - if (activeSort === 'matching-index') { - sorted.sort((a, b) => b.matchIndex - a.matchIndex) - return sorted - } - - sorted.sort((a, b) => String(a.handle || '') - .localeCompare(String(b.handle || ''), undefined, { sensitivity: 'base' })) - return sorted - }, [activeSort, filteredResults]) + // Order comes from reports-api (sortBy/sortOrder on each request) so pagination stays globally consistent. + const displayedResults = filteredResults const foundMembersCount = totalResults || displayedResults.length const displayedResultsWithCountryName = useMemo( @@ -175,6 +166,7 @@ export const TalentSearchPage: FC = () => { const openToWork = overrides?.openToWork ?? onlyOpenToWork const page = overrides?.page ?? 1 const recentlyActive = overrides?.recentlyActive ?? onlyActive + const hasSkills = skillsToSearch.length > 0 const payload: MemberSearchPayload = { limit: MEMBER_SEARCH_LIMIT, page, @@ -187,6 +179,8 @@ export const TalentSearchPage: FC = () => { wins: 1, })), skillSearchType: 'OR', + sortBy: hasSkills ? 'matchIndex' : 'handle', + sortOrder: hasSkills ? 'desc' : 'asc', } if (countries.length > 0) { From acf537087f388b486f713c85bade2dc51921b197 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 24 Apr 2026 09:31:31 +1000 Subject: [PATCH 81/81] Show fees on the engagements payment history and create payment modals. --- .../PaymentFormModal.module.scss | 10 + .../PaymentFormModal.spec.tsx | 8 +- .../PaymentFormModal/PaymentFormModal.tsx | 19 +- .../PaymentHistoryModal.module.scss | 18 +- .../PaymentHistoryModal.spec.tsx | 11 + .../PaymentHistoryModal.tsx | 23 +- ...rojectBillingAccountExpiredNotice.spec.tsx | 2 +- .../ProjectBillingAccountExpiredNotice.tsx | 6 +- .../ProjectCard/ProjectCard.module.scss | 4 + .../components/ProjectCard/ProjectCard.tsx | 11 +- .../ProjectsTable/ProjectsTable.module.scss | 40 ++++ .../ProjectsTable/ProjectsTable.spec.tsx | 94 +++++++- .../ProjectsTable/ProjectsTable.tsx | 223 +++++++++++++++++- .../work/src/lib/models/Engagement.model.ts | 1 + .../services/billing-accounts.service.spec.ts | 38 +++ .../lib/services/billing-accounts.service.ts | 9 +- .../work/src/lib/utils/payment.utils.spec.ts | 46 ++++ src/apps/work/src/lib/utils/payment.utils.ts | 114 ++++++++- .../EngagementPaymentPage.spec.tsx | 12 + .../EngagementPaymentPage.tsx | 15 +- 20 files changed, 664 insertions(+), 40 deletions(-) create mode 100644 src/apps/work/src/lib/utils/payment.utils.spec.ts diff --git a/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.module.scss b/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.module.scss index 23d891931..22ba8efb7 100644 --- a/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.module.scss +++ b/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.module.scss @@ -57,6 +57,16 @@ margin: 0; } +.helperText { + color: #5b5b5b; + font-size: 12px; + margin: 0; +} + +.helperLabel { + font-weight: 600; +} + .actions { display: flex; gap: 12px; diff --git a/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.spec.tsx b/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.spec.tsx index 0b341291f..c58fb5c39 100644 --- a/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.spec.tsx +++ b/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.spec.tsx @@ -1,5 +1,7 @@ /* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ -import { render } from '@testing-library/react' +import { + render, +} from '@testing-library/react' import type { Assignment } from '../../models' @@ -49,10 +51,6 @@ jest.mock('../../utils', () => ({ getAssignmentStandardHoursPerWeek: jest.fn(() => 40), })) -jest.mock('../../utils/payment.utils', () => ({ - formatCurrency: jest.fn((value: number) => `$${value.toFixed(2)}`), -})) - describe('PaymentFormModal', () => { const member: Assignment = { agreementRate: '821.20', diff --git a/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.tsx b/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.tsx index c60519bb8..b50141c4d 100644 --- a/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.tsx +++ b/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.tsx @@ -25,7 +25,10 @@ import { getAssignmentRatePerHour, getAssignmentStandardHoursPerWeek, } from '../../utils' -import { formatCurrency } from '../../utils/payment.utils' +import { + calculatePaymentChallengeFee, + formatCurrency, +} from '../../utils/payment.utils' import styles from './PaymentFormModal.module.scss' @@ -38,6 +41,7 @@ export interface PaymentFormData { interface PaymentFormModalProps { billingAccountId?: number | string + billingAccountMarkup?: number engagementName?: string isSubmitting?: boolean member: Assignment | undefined @@ -151,6 +155,10 @@ const PaymentFormModal: FC = ( () => calculatePaymentAmount(hoursWorked, ratePerHour), [hoursWorked, ratePerHour], ) + const challengeFee = useMemo( + () => calculatePaymentChallengeFee(amount, props.billingAccountMarkup), + [amount, props.billingAccountMarkup], + ) const paymentTitle = useMemo( () => { if (!isSaturday(weekEndingDate)) { @@ -358,6 +366,15 @@ const PaymentFormModal: FC = ( ? '' : amount.toFixed(2)} /> + {challengeFee !== undefined + ? ( +

    + Fee: + {' '} + {formatCurrency(challengeFee)} +

    + ) + : undefined}
    diff --git a/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.module.scss b/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.module.scss index c14487696..3d286cbf5 100644 --- a/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.module.scss +++ b/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.module.scss @@ -36,12 +36,24 @@ } .itemHeader { - align-items: center; + align-items: flex-start; display: flex; justify-content: space-between; margin-bottom: 8px; } +.amountBlock { + display: flex; + flex-direction: column; + gap: 4px; +} + +.amount { + color: #2a2a2a; + font-size: 16px; + font-weight: 700; +} + .status { background: #f3f5fb; border-radius: 999px; @@ -68,7 +80,7 @@ font-size: 12px; } -.paymentCreator { +.metaRow { color: #5b5b5b; display: flex; flex-wrap: wrap; @@ -76,7 +88,7 @@ gap: 4px; } -.paymentCreatorLabel { +.metaLabel { font-weight: 600; } diff --git a/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.spec.tsx b/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.spec.tsx index 2b459612c..622822f14 100644 --- a/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.spec.tsx +++ b/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.spec.tsx @@ -58,6 +58,13 @@ describe('PaymentHistoryModal', () => { }, createdAt: '2026-03-31T00:00:00.000Z', createdByHandle: 'payment.manager', + details: [ + { + challengeFee: 18.6, + grossAmount: 120, + totalAmount: 120, + }, + ], id: 'payment-1', title: 'Salesforce support', }, @@ -85,5 +92,9 @@ describe('PaymentHistoryModal', () => { .toBeTruthy() expect(screen.getByText('payment.manager')) .toBeTruthy() + expect(screen.getByText('Fee:')) + .toBeTruthy() + expect(screen.getByText('$18.60')) + .toBeTruthy() }) }) diff --git a/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.tsx b/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.tsx index b74d13f91..406db4486 100644 --- a/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.tsx +++ b/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.tsx @@ -11,6 +11,7 @@ import { import { formatCurrency, getPaymentAmount, + getPaymentChallengeFee, getPaymentCreatorLabel, getPaymentHoursWorked, getPaymentRemarks, @@ -86,6 +87,8 @@ const PaymentHistoryModal: FC = ( ? (
      {paymentsResult.payments.map((payment, index) => { + const paymentAmount = getPaymentAmount(payment) + const paymentChallengeFee = getPaymentChallengeFee(payment) const paymentStatus = getPaymentStatus(payment) const paymentHoursWorked = getPaymentHoursWorked(payment) const paymentRemarks = getPaymentRemarks(payment) @@ -102,7 +105,21 @@ const PaymentHistoryModal: FC = ( className={styles.item} >
      - {formatCurrency(getPaymentAmount(payment))} +
      + + {formatCurrency(paymentAmount)} + + {paymentChallengeFee !== undefined + ? ( +
      + + Fee: + + {formatCurrency(paymentChallengeFee)} +
      + ) + : undefined} +
      {showPaymentStatus ? {paymentStatus} : undefined} @@ -125,8 +142,8 @@ const PaymentHistoryModal: FC = (
      ) : undefined} -
      - +
      + Payment Creator: {paymentCreator || '-'} diff --git a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.spec.tsx b/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.spec.tsx index 6d9a20801..bda54cc16 100644 --- a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.spec.tsx +++ b/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.spec.tsx @@ -106,7 +106,7 @@ describe('ProjectBillingAccountExpiredNotice', () => { .toBeTruthy() expect(screen.getByText(/80001063/)) .toBeTruthy() - expect(screen.getByText('-$25 / $1,000 remaining')) + expect(screen.getByText('$1,025 / $1,000 spent')) .toBeTruthy() expect(screen.getByText(/The billing account for this project has insufficient remaining funds,/)) .toBeTruthy() diff --git a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.tsx b/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.tsx index 82caebc7a..124cde600 100644 --- a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.tsx +++ b/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.tsx @@ -126,7 +126,7 @@ export const ProjectBillingAccountExpiredNotice: FC - {formatCurrency(budgetInfo.remaining)} + {formatCurrency(budgetInfo.spent)} {' / '} {formatCurrency(budgetInfo.totalBudget)} - {' remaining'} + {' spent'}
      + {props.billingAccountContent + ? ( +
      + {props.billingAccountContent} +
      + ) + : undefined} {props.canEdit ? (
      diff --git a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.module.scss b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.module.scss index 2210caa3e..995b96452 100644 --- a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.module.scss +++ b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.module.scss @@ -33,6 +33,46 @@ gap: 12px; } +.billingAccountCell { + align-items: center; + display: inline-flex; + flex-wrap: wrap; + gap: 8px; +} + +.billingAccountLabel { + color: $black-80; +} + +.budgetDisplay { + background: #f2f4f7; + border-radius: 4px; + color: $black-80; + font-size: 12px; + font-weight: 600; + padding: 4px 8px; + white-space: nowrap; +} + +.infoButton { + align-items: center; + background: none; + border: none; + cursor: pointer; + display: inline-flex; + padding: 2px; +} + +.infoIcon { + color: #4a5568; + height: 18px; + width: 18px; +} + +.infoButton:hover .infoIcon { + color: #137d60; +} + .listView { display: none; gap: 12px; diff --git a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.spec.tsx b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.spec.tsx index 929fd0135..f247f35e6 100644 --- a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.spec.tsx +++ b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.spec.tsx @@ -1,20 +1,38 @@ /* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ import type { ReactNode } from 'react' import { + fireEvent, render, screen, } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' import type { Project } from '../../models' -import { useFetchBillingAccounts } from '../../hooks' +import type { BillingAccountDetails } from '../../services' +import { + useFetchBillingAccountDetails, + useFetchBillingAccounts, +} from '../../hooks' import { ProjectsTable } from './ProjectsTable' jest.mock('../../hooks', () => ({ + useFetchBillingAccountDetails: jest.fn(), useFetchBillingAccounts: jest.fn(), })) +jest.mock('../BillingAccountLineItemsModal', () => ({ + BillingAccountLineItemsModal: (props: { + billingAccountDetails: BillingAccountDetails + }): JSX.Element => ( +
      + Billing account details for + {' '} + {props.billingAccountDetails.id} +
      + ), +})) + jest.mock('../../constants', () => ({ PROJECT_STATUS: { DRAFT: 'draft', @@ -29,6 +47,9 @@ jest.mock('../../utils', () => ({ })) jest.mock('~/libs/ui', () => ({ + IconOutline: { + InformationCircleIcon: (): JSX.Element => info, + }, LoadingSpinner: () =>
      Loading
      , Table: (props: { columns: Array<{ @@ -57,8 +78,22 @@ jest.mock('../ProjectStatus', () => ({ ProjectStatus: () => Active, })) +const mockedUseFetchBillingAccountDetails = useFetchBillingAccountDetails as jest.MockedFunction< + typeof useFetchBillingAccountDetails +> const mockedUseFetchBillingAccounts = useFetchBillingAccounts as jest.MockedFunction +const billingAccountDetails: BillingAccountDetails = { + budget: 1000, + consumedAmounts: [], + consumedBudget: 225, + id: 80001063, + lockedAmounts: [], + lockedBudget: 125, + name: 'Access BA', + totalBudgetRemaining: 650, +} + describe('ProjectsTable', () => { const invitedProject: Project = { id: 100440, @@ -82,6 +117,12 @@ describe('ProjectsTable', () => { isError: false, isLoading: false, }) + mockedUseFetchBillingAccountDetails.mockReturnValue({ + billingAccountDetails: undefined, + error: undefined, + isError: false, + isLoading: false, + }) }) it('links the project name and open action to the challenges route', () => { @@ -103,4 +144,55 @@ describe('ProjectsTable', () => { .getAttribute('href')) .toBe('/projects/100440/challenges') }) + + it('shows project billing account spent totals and opens the line-item modal', () => { + mockedUseFetchBillingAccounts.mockReturnValue({ + billingAccounts: [ + { + budget: 1000, + consumedBudget: 225, + id: 80001063, + lockedBudget: 125, + name: 'Access BA', + totalBudgetRemaining: 650, + }, + ], + error: undefined, + isError: false, + isLoading: false, + }) + mockedUseFetchBillingAccountDetails.mockReturnValue({ + billingAccountDetails, + error: undefined, + isError: false, + isLoading: false, + }) + + render( + + + , + ) + + expect(screen.getAllByText('Access BA / 80001063').length) + .toBeGreaterThan(0) + expect(screen.getAllByText('$350 / $1,000 spent').length) + .toBeGreaterThan(0) + + fireEvent.click(screen.getAllByRole('button', { + name: 'View billing account details', + })[0]) + + expect(screen.getByRole('dialog') + .textContent) + .toContain('Billing account details for 80001063') + }) }) diff --git a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx index f4eddd831..ebcd6dc9c 100644 --- a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx +++ b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx @@ -2,26 +2,36 @@ import { FC, useCallback, useMemo, + useState, } from 'react' import { Link } from 'react-router-dom' import { Sort } from '~/apps/admin/src/platform/gamification-admin/src/game-lib' import { + IconOutline, LoadingSpinner, Table, TableColumn, } from '~/libs/ui' import { PROJECT_STATUS } from '../../constants' +import { + useFetchBillingAccountDetails, + useFetchBillingAccounts, +} from '../../hooks' +import type { + UseFetchBillingAccountDetailsResult, + UseFetchBillingAccountsResult, +} from '../../hooks' import { Project, ProjectStatusValue, } from '../../models' -import { useFetchBillingAccounts } from '../../hooks' -import type { UseFetchBillingAccountsResult } from '../../hooks' +import type { BillingAccount } from '../../services' import { buildProjectChallengesPath, } from '../../utils' +import { BillingAccountLineItemsModal } from '../BillingAccountLineItemsModal' import { ProjectCard } from '../ProjectCard' import { ProjectStatus } from '../ProjectStatus' @@ -29,6 +39,11 @@ import styles from './ProjectsTable.module.scss' type SortOrder = 'asc' | 'desc' +interface BillingBudgetInfo { + spent: number + totalBudget: number +} + const NOOP_CAN_EDIT_PROJECT = (): boolean => false const PROJECT_TYPE_LABEL_OVERRIDES: Readonly> = { @@ -89,15 +104,83 @@ function getProjectPath(project: Project): string { return buildProjectChallengesPath(project.id) } +/** + * Converts optional id or label values into trimmed display strings. + * + * @param value Raw value from a project or billing-account payload. + * @returns A trimmed string, or `undefined` when the value is blank. + */ +function normalizeOptionalString(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined + } + + const normalizedValue = String(value) + .trim() + + return normalizedValue || undefined +} + +/** + * Converts optional API numeric fields into finite numbers. + * + * @param value Raw budget field from the billing-account API. + * @returns A finite number, or `undefined` when the value is missing or invalid. + */ +function normalizeOptionalNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + + if (typeof value !== 'string') { + return undefined + } + + const normalizedValue = value.trim() + + if (!normalizedValue) { + return undefined + } + + const parsedValue = Number(normalizedValue) + + return Number.isFinite(parsedValue) + ? parsedValue + : undefined +} + +/** + * Formats budget amounts for compact project-list display. + * + * @param amount Dollar amount to format. + * @returns Whole-dollar USD currency text. + */ +function formatCurrency(amount: number): string { + return new Intl.NumberFormat('en-US', { + currency: 'USD', + maximumFractionDigits: 0, + minimumFractionDigits: 0, + style: 'currency', + }) + .format(amount) +} + +/** + * Builds the billing-account label for a project row. + * + * @param project Project summary from the projects API. + * @param billingAccount Matching billing-account summary from the billing API. + * @returns Name/id text, falling back to `-` when no account is available. + */ function getBillingAccountDisplay( project: Project, - billingAccountNames: Map, + billingAccount: BillingAccount | undefined, ): string { - const billingAccountId = project.billingAccountId !== undefined && project.billingAccountId !== null - ? String(project.billingAccountId) - .trim() - : '' - const billingAccountName = (project.billingAccountName || '').trim() || billingAccountNames.get(billingAccountId) + const billingAccountId = normalizeOptionalString(project.billingAccountId) + || normalizeOptionalString(billingAccount?.id) + || '' + const billingAccountName = normalizeOptionalString(project.billingAccountName) + || normalizeOptionalString(billingAccount?.name) if (!billingAccountId && !billingAccountName) { return '-' @@ -110,6 +193,111 @@ function getBillingAccountDisplay( return `${billingAccountName || 'Unknown'} / ${billingAccountId}` } +/** + * Resolves the spent/total budget values for a billing-account summary. + * + * @param billingAccount Matching billing-account summary from the list API. + * @returns Spent and total budget amounts, or `undefined` when budget data is incomplete. + */ +function getBillingAccountBudgetInfo( + billingAccount: BillingAccount | undefined, +): BillingBudgetInfo | undefined { + const totalBudget = normalizeOptionalNumber(billingAccount?.budget) + + if (totalBudget === undefined) { + return undefined + } + + const lockedBudget = normalizeOptionalNumber(billingAccount?.lockedBudget) + const consumedBudget = normalizeOptionalNumber(billingAccount?.consumedBudget) + const totalBudgetRemaining = normalizeOptionalNumber(billingAccount?.totalBudgetRemaining) + let spent: number | undefined + + if (lockedBudget !== undefined || consumedBudget !== undefined) { + spent = (lockedBudget || 0) + (consumedBudget || 0) + } else if (totalBudgetRemaining !== undefined) { + spent = totalBudget - totalBudgetRemaining + } + + return spent === undefined + ? undefined + : { + spent: Math.max(spent, 0), + totalBudget, + } +} + +interface ProjectBillingAccountCellProps { + billingAccount: BillingAccount | undefined + project: Project +} + +/** + * Renders a project billing-account summary and lazily loads the line-item + * modal only after the details button is opened. + * + * @param props Project row and matching billing-account summary from the list API. + * @returns Billing-account label, spent/total badge, and optional line-item modal. + */ +const ProjectBillingAccountCell: FC = ( + props: ProjectBillingAccountCellProps, +) => { + const [isModalOpen, setIsModalOpen] = useState(false) + const normalizedBillingAccountId = normalizeOptionalString(props.project.billingAccountId) + || normalizeOptionalString(props.billingAccount?.id) + const billingAccountDetailsResult: UseFetchBillingAccountDetailsResult = useFetchBillingAccountDetails( + isModalOpen ? normalizedBillingAccountId : undefined, + ) + const budgetInfo = getBillingAccountBudgetInfo(props.billingAccount) + + const handleOpenModal = useCallback((): void => { + setIsModalOpen(true) + }, []) + + const handleCloseModal = useCallback((): void => { + setIsModalOpen(false) + }, []) + + return ( +
      + + {getBillingAccountDisplay(props.project, props.billingAccount)} + + {budgetInfo + ? ( + + {formatCurrency(budgetInfo.spent)} + {' / '} + {formatCurrency(budgetInfo.totalBudget)} + {' spent'} + + ) + : undefined} + {normalizedBillingAccountId + ? ( + + ) + : undefined} + {isModalOpen && billingAccountDetailsResult.billingAccountDetails + ? ( + + ) + : undefined} +
      + ) +} + export const ProjectsTable: FC = (props: ProjectsTableProps) => { const canEditProject = props.canEditProject || NOOP_CAN_EDIT_PROJECT const projects: Project[] = props.projects @@ -120,11 +308,11 @@ export const ProjectsTable: FC = (props: ProjectsTableProps) const { billingAccounts, }: UseFetchBillingAccountsResult = useFetchBillingAccounts() - const billingAccountNames = useMemo( + const billingAccountsById = useMemo( () => new Map( billingAccounts.map(account => ([ String(account.id), - account.name, + account, ])), ), [billingAccounts], @@ -165,7 +353,12 @@ export const ProjectsTable: FC = (props: ProjectsTableProps) { isSortable: false, label: 'Billing Account', - renderer: (project: Project) => <>{getBillingAccountDisplay(project, billingAccountNames)}, + renderer: (project: Project) => ( + + ), type: 'element', }, { @@ -194,7 +387,7 @@ export const ProjectsTable: FC = (props: ProjectsTableProps) type: 'action', }, ], - [billingAccountNames, canEditProject], + [billingAccountsById, canEditProject], ) const forceSort = useMemo( @@ -247,6 +440,12 @@ export const ProjectsTable: FC = (props: ProjectsTableProps)
      {projects.map(project => ( + )} canEdit={canEditProject(project)} key={String(project.id)} project={project} diff --git a/src/apps/work/src/lib/models/Engagement.model.ts b/src/apps/work/src/lib/models/Engagement.model.ts index c28831c41..bf7f0258c 100644 --- a/src/apps/work/src/lib/models/Engagement.model.ts +++ b/src/apps/work/src/lib/models/Engagement.model.ts @@ -110,6 +110,7 @@ export interface AssignmentPayment { description?: string details?: Array<{ amount?: number + challengeFee?: number | string grossAmount?: number hoursWorked?: number | string totalAmount?: number diff --git a/src/apps/work/src/lib/services/billing-accounts.service.spec.ts b/src/apps/work/src/lib/services/billing-accounts.service.spec.ts index 19a49f9f6..c0c358711 100644 --- a/src/apps/work/src/lib/services/billing-accounts.service.spec.ts +++ b/src/apps/work/src/lib/services/billing-accounts.service.spec.ts @@ -4,6 +4,7 @@ import { xhrGetAsync } from '~/libs/core' import { BillingAccountDetails, combineBillingAccountLineItems, + fetchBillingAccounts, fetchBillingAccountById, searchBillingAccounts, } from './billing-accounts.service' @@ -32,6 +33,43 @@ jest.mock('~/libs/core', () => ({ const NULL_EXTERNAL_NAME = JSON.parse('null') as null +describe('fetchBillingAccounts', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('requests a large lookup page for project billing summaries', async () => { + const mockedGetAsync = xhrGetAsync as jest.Mock + + mockedGetAsync.mockResolvedValue({ + data: [ + { + budget: 1000, + consumedBudget: 225, + id: 80001063, + lockedBudget: 125, + name: 'Platform Dev - One', + }, + ], + }) + + const result = await fetchBillingAccounts() + + expect(result) + .toEqual([ + { + budget: 1000, + consumedBudget: 225, + id: 80001063, + lockedBudget: 125, + name: 'Platform Dev - One', + }, + ]) + expect(mockedGetAsync) + .toHaveBeenCalledWith('https://example.com/v6/billing-accounts?perPage=1000') + }) +}) + describe('searchBillingAccounts', () => { beforeEach(() => { jest.clearAllMocks() diff --git a/src/apps/work/src/lib/services/billing-accounts.service.ts b/src/apps/work/src/lib/services/billing-accounts.service.ts index 7ea99adf0..fdb4048e3 100644 --- a/src/apps/work/src/lib/services/billing-accounts.service.ts +++ b/src/apps/work/src/lib/services/billing-accounts.service.ts @@ -1,8 +1,13 @@ import { EnvironmentConfig } from '~/config' import { xhrGetAsync } from '~/libs/core' +const BILLING_ACCOUNTS_LOOKUP_PAGE_SIZE = 1000 + export interface BillingAccount { active?: boolean + budget?: number | string + consumedBudget?: number | string + lockedBudget?: number | string markup?: number endDate?: string id: number | string @@ -194,14 +199,14 @@ function createLineItem( } /** - * Fetches billing accounts using default API pagination. + * Fetches billing accounts using a large lookup page for project-list joins. * * Returns only accounts with both `id` and `name`, sorted by name. */ export async function fetchBillingAccounts(): Promise { try { const response = await xhrGetAsync( - `${EnvironmentConfig.API.V6}/billing-accounts`, + `${EnvironmentConfig.API.V6}/billing-accounts?perPage=${BILLING_ACCOUNTS_LOOKUP_PAGE_SIZE}`, ) return normalizeBillingAccounts(extractBillingAccounts(response)) diff --git a/src/apps/work/src/lib/utils/payment.utils.spec.ts b/src/apps/work/src/lib/utils/payment.utils.spec.ts new file mode 100644 index 000000000..c01154ce8 --- /dev/null +++ b/src/apps/work/src/lib/utils/payment.utils.spec.ts @@ -0,0 +1,46 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import type { + AssignmentPayment, +} from '../models' +import { + calculatePaymentChallengeFee, + getPaymentChallengeFee, +} from './payment.utils' + +describe('payment.utils', () => { + it('calculates payment fees from decimal or whole-number markup values', () => { + expect(calculatePaymentChallengeFee(480, 0.15)) + .toBe(72) + expect(calculatePaymentChallengeFee(480, 15)) + .toBe(72) + }) + + it('reads the persisted payment challenge fee when finance returns it explicitly', () => { + const payment: AssignmentPayment = { + details: [ + { + challengeFee: 72, + grossAmount: 480, + totalAmount: 480, + }, + ], + } + + expect(getPaymentChallengeFee(payment)) + .toBe(72) + }) + + it('falls back to the total-versus-gross delta for older payment payloads', () => { + const payment: AssignmentPayment = { + details: [ + { + grossAmount: 480, + totalAmount: 552, + }, + ], + } + + expect(getPaymentChallengeFee(payment)) + .toBe(72) + }) +}) diff --git a/src/apps/work/src/lib/utils/payment.utils.ts b/src/apps/work/src/lib/utils/payment.utils.ts index a41eb8f89..34c0fbf7e 100644 --- a/src/apps/work/src/lib/utils/payment.utils.ts +++ b/src/apps/work/src/lib/utils/payment.utils.ts @@ -30,6 +30,37 @@ function toOptionalString(value: unknown): string | undefined { return normalized || undefined } +type AssignmentPaymentDetail = NonNullable[number] + +function getFirstPaymentDetail( + payment: AssignmentPayment, +): AssignmentPaymentDetail | undefined { + return Array.isArray(payment.details) && payment.details.length > 0 + ? payment.details[0] + : undefined +} + +/** + * Normalizes billing markup into a decimal multiplier for payment fee math. + * + * Stored markup can arrive as either a decimal fraction like `0.15` or a + * whole percentage like `15`. Missing or invalid inputs return `undefined`. + * + * @param billingMarkup raw billing markup from project billing-account data. + * @returns normalized decimal markup, or `undefined` when unavailable. + */ +function normalizeBillingMarkup(billingMarkup: unknown): number | undefined { + const parsedMarkup = toNumber(billingMarkup) + + if (parsedMarkup === undefined) { + return undefined + } + + return parsedMarkup > 1 + ? parsedMarkup / 100 + : parsedMarkup +} + export function formatCurrency(value: unknown): string { const parsed = toNumber(value) @@ -45,17 +76,63 @@ export function getPaymentAmount(payment: AssignmentPayment): number | undefined return toNumber(payment.amount) } - if (Array.isArray(payment.details) && payment.details.length > 0) { - const firstDetail = payment.details[0] + const firstDetail = getFirstPaymentDetail(payment) + + if (firstDetail) { + const totalAmount = toNumber(firstDetail.totalAmount) + + if (totalAmount !== undefined) { + return totalAmount + } + + const grossAmount = toNumber(firstDetail.grossAmount) - return toNumber(firstDetail.totalAmount) - || toNumber(firstDetail.grossAmount) - || toNumber(firstDetail.amount) + if (grossAmount !== undefined) { + return grossAmount + } + + return toNumber(firstDetail.amount) } return undefined } +/** + * Resolves the persisted challenge fee associated with a payment. + * + * Engagement payments store the manager-entered payment amount separately from + * the billing-account fee. When finance returns the fee explicitly, this + * helper uses that field. For older payloads it falls back to a positive + * `totalAmount - grossAmount` delta when present. + * + * @param payment payment record returned by the finance API. + * @returns challenge fee rounded to two decimals, or `undefined` when no fee + * is available on the payment. + */ +export function getPaymentChallengeFee( + payment: AssignmentPayment, +): number | undefined { + const firstDetail = getFirstPaymentDetail(payment) + const persistedChallengeFee = toNumber(firstDetail?.challengeFee) + + if (persistedChallengeFee !== undefined && persistedChallengeFee >= 0) { + return Number(persistedChallengeFee.toFixed(2)) + } + + const totalAmount = toNumber(firstDetail?.totalAmount) + const grossAmount = toNumber(firstDetail?.grossAmount) + + if ( + totalAmount === undefined + || grossAmount === undefined + || totalAmount <= grossAmount + ) { + return undefined + } + + return Number((totalAmount - grossAmount).toFixed(2)) +} + export function getPaymentStatus(payment: AssignmentPayment): string { if (!payment.status) { return 'Unknown' @@ -168,6 +245,33 @@ export function calculatePaymentAmount( return Number((parsedHoursWorked * parsedRatePerHour).toFixed(2)) } +/** + * Calculates the billing-account fee preview for an engagement payment. + * + * @param amount manager-entered payment amount before fee. + * @param billingMarkup raw billing-account markup from the project billing + * details. Accepts decimal or whole-percentage values. + * @returns calculated fee rounded to two decimals, or `undefined` when the + * inputs are incomplete or invalid. + */ +export function calculatePaymentChallengeFee( + amount: unknown, + billingMarkup: unknown, +): number | undefined { + const parsedAmount = toNumber(amount) + const normalizedMarkup = normalizeBillingMarkup(billingMarkup) + + if ( + parsedAmount === undefined + || parsedAmount < 0 + || normalizedMarkup === undefined + ) { + return undefined + } + + return Number((parsedAmount * normalizedMarkup).toFixed(2)) +} + export function getPaymentRemarks(payment: AssignmentPayment): string { return toOptionalString(payment.attributes?.remarks) || '' } diff --git a/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.spec.tsx b/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.spec.tsx index bea41f532..9953beb9e 100644 --- a/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.spec.tsx +++ b/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.spec.tsx @@ -21,6 +21,7 @@ import type { import { useFetchEngagement, useFetchProject, + useFetchProjectBillingAccount, } from '../../../lib/hooks' import { partiallyUpdateEngagement, @@ -95,6 +96,7 @@ jest.mock('../../../lib/components/form', () => ({ jest.mock('../../../lib/hooks', () => ({ useFetchEngagement: jest.fn(), useFetchProject: jest.fn(), + useFetchProjectBillingAccount: jest.fn(), })) jest.mock('../../../lib/services', () => ({ @@ -176,12 +178,22 @@ const assignment: Assignment = { const mockedUseFetchEngagement = useFetchEngagement as jest.MockedFunction const mockedUseFetchProject = useFetchProject as jest.MockedFunction +const mockedUseFetchProjectBillingAccount = useFetchProjectBillingAccount as jest.MockedFunction< + typeof useFetchProjectBillingAccount +> const mockedPartiallyUpdateEngagement = partiallyUpdateEngagement as jest.MockedFunction< typeof partiallyUpdateEngagement > beforeEach(() => { jest.clearAllMocks() + mockedUseFetchProjectBillingAccount.mockReturnValue({ + billingAccount: { + id: 'billing-account-1', + markup: 0.15, + }, + isLoading: false, + } as unknown as ReturnType) }) describe('EngagementPaymentPage', () => { diff --git a/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.tsx b/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.tsx index 4b0f79127..d097aa86c 100644 --- a/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.tsx +++ b/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.tsx @@ -38,6 +38,7 @@ import { import { useFetchEngagement, useFetchProject, + useFetchProjectBillingAccount, } from '../../../lib/hooks' import { Assignment, @@ -581,6 +582,7 @@ export const EngagementPaymentPage: FC = () => { const engagementResult = useFetchEngagement(engagementId) const projectResult = useFetchProject(projectId) + const projectBillingAccountResult = useFetchProjectBillingAccount(projectId) const assignments = useMemo(() => { if (!Array.isArray(engagementResult.engagement?.assignments)) { @@ -614,7 +616,8 @@ export const EngagementPaymentPage: FC = () => { return } - const billingAccountId = projectResult.project?.billingAccountId + const billingAccountId = projectBillingAccountResult.billingAccount?.id + || projectResult.project?.billingAccountId if (!billingAccountId) { showErrorToast('Billing account is required to create payment') @@ -650,7 +653,11 @@ export const EngagementPaymentPage: FC = () => { } finally { setIsSubmittingPayment(false) } - }, [paymentMember, projectResult.project?.billingAccountId]) + }, [ + paymentMember, + projectBillingAccountResult.billingAccount?.id, + projectResult.project?.billingAccountId, + ]) const handleTerminateConfirm = useCallback(async (reason: string): Promise => { if (!terminateMember) { @@ -977,7 +984,9 @@ export const EngagementPaymentPage: FC = () => { />