+
Payment Creator:
{paymentCreator || '-'}
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..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;
@@ -49,3 +55,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.spec.tsx b/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.spec.tsx
new file mode 100644
index 000000000..bda54cc16
--- /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('$1,025 / $1,000 spent'))
+ .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 2a824973d..124cde600 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,53 @@ 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 billingAccount = projectBillingAccountResult.billingAccount
const normalizedBillingAccountId = normalizeOptionalString(props.billingAccountId)
+ || normalizeOptionalString(billingAccount?.id)
+ const billingAccountDetailsResult: UseFetchBillingAccountDetailsResult = useFetchBillingAccountDetails(
+ normalizedBillingAccountId,
+ )
+
+ const billingAccountDetailsData = billingAccountDetailsResult.billingAccountDetails
const normalizedBillingAccountName = normalizeOptionalString(props.billingAccountName)
+
const billingAccountNameFromLookup: string | undefined = useMemo(
(): string | undefined => {
if (!normalizedBillingAccountId) {
@@ -64,27 +110,104 @@ export const ProjectBillingAccountExpiredNotice: FC {
+ if (!billingAccountDetailsData) {
+ return undefined
+ }
+
+ const totalBudget = Number(billingAccountDetailsData.budget) || 0
+ const remaining = Number(billingAccountDetailsData.totalBudgetRemaining) || 0
+ const status = getBudgetStatus(remaining, totalBudget)
+
+ return {
+ spent: Math.max(totalBudget - remaining, 0),
+ status,
+ totalBudget,
+ }
+ }, [billingAccountDetailsData])
+
+ const handleOpenModal = useCallback((): void => {
+ setIsModalOpen(true)
+ }, [])
+
+ const handleCloseModal = useCallback((): void => {
+ setIsModalOpen(false)
+ }, [])
+
+ const budgetStatusClass = budgetInfo
+ ? styles[`budget${budgetInfo.status.charAt(0)
+ .toUpperCase()}${budgetInfo.status.slice(1)}`]
+ : ''
+ const billingAccountDetailsContent = normalizedBillingAccountId
+ ? (
+
+
+ Billing account:
+ {' '}
+ {billingAccountName || 'Unknown'}
+ {' '}
+ /
+ {' '}
+ {normalizedBillingAccountId}
+
+ {budgetInfo && (
+ <>
+
+ {formatCurrency(budgetInfo.spent)}
+ {' / '}
+ {formatCurrency(budgetInfo.totalBudget)}
+ {' spent'}
+
+
+ >
+ )}
+
+ )
+ : undefined
+ const billingAccountModal = isModalOpen && billingAccountDetailsData
+ ? (
+
+ )
+ : undefined
+
if (billingAccountIssue) {
const noticeMessage = getProjectBillingAccountNoticeMessage(billingAccountIssue)
const managedNoticeMessage = `${noticeMessage.slice(0, -1)}, `
return (
-
- {props.canManageProject
- ? (
- <>
-
{managedNoticeMessage}
-
- click here to update
-
- >
- )
- : (
-
{noticeMessage}
- )}
+
+ {billingAccountDetailsContent}
+
+ {props.canManageProject
+ ? (
+ <>
+ {managedNoticeMessage}
+
+ click here to update
+
+ >
+ )
+ : (
+ {noticeMessage}
+ )}
+
+ {billingAccountModal}
)
}
@@ -94,17 +217,10 @@ export const ProjectBillingAccountExpiredNotice: FC
-
- Billing account:
- {' '}
- {billingAccountName || 'Unknown'}
- {' '}
- /
- {' '}
- {normalizedBillingAccountId}
-
-
+ <>
+ {billingAccountDetailsContent}
+ {billingAccountModal}
+ >
)
}
diff --git a/src/apps/work/src/lib/components/ProjectCard/ProjectCard.module.scss b/src/apps/work/src/lib/components/ProjectCard/ProjectCard.module.scss
index 2ed8a974d..237864a54 100644
--- a/src/apps/work/src/lib/components/ProjectCard/ProjectCard.module.scss
+++ b/src/apps/work/src/lib/components/ProjectCard/ProjectCard.module.scss
@@ -61,6 +61,10 @@
margin-top: 10px;
}
+.billingAccount {
+ margin-top: 10px;
+}
+
.actionLink {
color: $link-blue-dark;
font-size: 13px;
diff --git a/src/apps/work/src/lib/components/ProjectCard/ProjectCard.tsx b/src/apps/work/src/lib/components/ProjectCard/ProjectCard.tsx
index 33174eb19..7afa091b3 100644
--- a/src/apps/work/src/lib/components/ProjectCard/ProjectCard.tsx
+++ b/src/apps/work/src/lib/components/ProjectCard/ProjectCard.tsx
@@ -1,5 +1,6 @@
-import {
+import type {
FC,
+ ReactNode,
} from 'react'
import { Link } from 'react-router-dom'
import classNames from 'classnames'
@@ -16,6 +17,7 @@ import { ProjectStatus } from '../ProjectStatus'
import styles from './ProjectCard.module.scss'
interface ProjectCardProps {
+ billingAccountContent?: ReactNode
canEdit?: boolean
project: Project
selected?: boolean
@@ -49,6 +51,13 @@ export const ProjectCard: FC = (props: ProjectCardProps) => {
{lastActivity}
+ {props.billingAccountContent
+ ? (
+
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/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/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..2adf6c007
--- /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 external entries.
+ *
+ * @param billingAccountId The billing account identifier to fetch.
+ * @returns Billing account details with budget totals and typed external-entry line item payloads.
+ */
+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/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/Engagement.model.ts b/src/apps/work/src/lib/models/Engagement.model.ts
index d1bd7a742..bf7f0258c 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
@@ -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/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/schemas/challenge-editor.schema.spec.ts b/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts
index eb78539c4..84a6430a5 100644
--- a/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts
+++ b/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts
@@ -189,6 +189,70 @@ describe('challenge-editor schema fun challenge prize validation', () => {
.resolves
.toBeTruthy()
})
+
+ it('allows equal lower placement prizes when funChallenge is false', async () => {
+ await expect(
+ challengeBasicInfoSchema.validate({
+ ...baseBasicInfo,
+ funChallenge: false,
+ prizeSets: [
+ {
+ prizes: [
+ {
+ type: 'USD',
+ value: 100,
+ },
+ {
+ type: 'USD',
+ value: 50,
+ },
+ {
+ type: 'USD',
+ value: 20,
+ },
+ {
+ type: 'USD',
+ value: 20,
+ },
+ ],
+ type: PRIZE_SET_TYPES.PLACEMENT,
+ },
+ ],
+ }),
+ )
+ .resolves
+ .toBeTruthy()
+ })
+
+ it('rejects lower placement prizes that increase when funChallenge is false', async () => {
+ await expect(
+ challengeBasicInfoSchema.validate({
+ ...baseBasicInfo,
+ funChallenge: false,
+ prizeSets: [
+ {
+ prizes: [
+ {
+ type: 'USD',
+ value: 100,
+ },
+ {
+ type: 'USD',
+ value: 50,
+ },
+ {
+ type: 'USD',
+ value: 60,
+ },
+ ],
+ type: PRIZE_SET_TYPES.PLACEMENT,
+ },
+ ],
+ }),
+ )
+ .rejects
+ .toThrow('Placement prizes must stay the same or decrease for lower placements')
+ })
})
describe('challenge-editor schema reviewer slot assignment validation', () => {
diff --git a/src/apps/work/src/lib/schemas/challenge-editor.schema.ts b/src/apps/work/src/lib/schemas/challenge-editor.schema.ts
index 4a41efdd5..72403d8c0 100644
--- a/src/apps/work/src/lib/schemas/challenge-editor.schema.ts
+++ b/src/apps/work/src/lib/schemas/challenge-editor.schema.ts
@@ -59,7 +59,7 @@ const prizeSetSchema = yup.object({
.default([])
.test(
'descending-prizes',
- 'Placement prizes must be in descending order',
+ 'Placement prizes must stay the same or decrease for lower placements',
function validateDescendingPrizes(prizes: unknown): boolean {
const prizeSetType = this.parent?.type
@@ -78,7 +78,7 @@ const prizeSetSchema = yup.object({
if (
previousValue > 0
&& currentValue > 0
- && currentValue >= previousValue
+ && currentValue > previousValue
) {
return false
}
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/billing-accounts.service.spec.ts b/src/apps/work/src/lib/services/billing-accounts.service.spec.ts
index 52ce18538..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
@@ -1,7 +1,13 @@
/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
import { xhrGetAsync } from '~/libs/core'
-import { searchBillingAccounts } from './billing-accounts.service'
+import {
+ BillingAccountDetails,
+ combineBillingAccountLineItems,
+ fetchBillingAccounts,
+ fetchBillingAccountById,
+ searchBillingAccounts,
+} from './billing-accounts.service'
jest.mock('~/config', () => ({
EnvironmentConfig: {
@@ -25,6 +31,45 @@ jest.mock('~/libs/core', () => ({
virtual: true,
})
+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()
@@ -62,3 +107,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 b9d6e237b..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
@@ -13,6 +18,41 @@ export interface BillingAccount {
[key: string]: unknown
}
+export type BillingAccountLineItemStatus = 'locked' | 'consumed'
+export type BillingAccountExternalType = 'CHALLENGE' | 'ENGAGEMENT'
+
+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
+ amount: number
+ challengeId?: string
+ date: string
+ externalId?: string
+ externalName?: string | null
+ externalType: BillingAccountExternalType
+ status: BillingAccountLineItemStatus
+}
+
+export interface BillingAccountDetails extends BillingAccount {
+ budget: number
+ lockedBudget: number
+ consumedBudget: number
+ totalBudgetRemaining: number
+ lockedAmounts: BillingAccountLockedAmount[]
+ consumedAmounts: BillingAccountConsumedAmount[]
+}
+
interface BillingAccountsResponse {
data?: BillingAccount[]
page?: number
@@ -93,14 +133,80 @@ function createSearchQuery(params: SearchBillingAccountsParams): string {
}
/**
- * Fetches billing accounts using default API pagination.
+ * 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 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))
@@ -133,10 +239,15 @@ 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,
-): Promise {
+): Promise {
const normalizedBillingAccountId = billingAccountId.trim()
if (!normalizedBillingAccountId) {
@@ -144,10 +255,30 @@ 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 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, index) => createLineItem('locked', item, index),
+ )
+
+ const consumedItems: BillingAccountLineItem[] = (details.consumedAmounts || []).map(
+ (item, index) => createLineItem('consumed', item, index),
+ )
+
+ return [...lockedItems, ...consumedItems]
+}
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/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')
}
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,
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/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/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/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md
index 88c9876a3..042fe2c02 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md
@@ -15,11 +15,13 @@
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, 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. 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
@@ -66,9 +68,9 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha
- `TermsField`: advanced-option multi-select for challenge terms. The create route seeds the standard Topcoder terms entry automatically once the terms list loads, including immediately after the first draft-creation step assigns a challenge id, so the editor matches legacy work-manager defaults while still allowing the NDA toggle to add or remove the NDA term separately.
- `ChallengeTagsField`: multi creatable tag picker excluding special challenge tags.
- `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.
+- `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, multi-prize setups allow tied lower placements while still rejecting prize increases for lower places, 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..0181e1f62 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,9 +30,12 @@ import {
createResource,
createChallenge,
deleteResource,
+ fetchAiReviewConfigByChallenge,
+ fetchAiReviewTemplates,
fetchChallenge,
fetchProfile,
fetchProjectBillingAccount,
+ fetchWorkflows,
patchChallenge,
fetchResourceRoles,
fetchResources,
@@ -48,6 +51,8 @@ import {
} from './ChallengeEditorForm'
import { TermsField } from './TermsField'
+let mockShouldAutoDirtyDuringInitialHydration = false
+
jest.mock('../../../../lib/components/form', () => ({
FormCheckboxField: () => <>>,
}))
@@ -64,11 +69,14 @@ jest.mock('../../../../lib/services', () => ({
createChallenge: jest.fn(),
createResource: jest.fn(),
deleteResource: jest.fn(),
+ fetchAiReviewConfigByChallenge: jest.fn(),
+ fetchAiReviewTemplates: jest.fn(),
fetchChallenge: jest.fn(),
fetchProfile: jest.fn(),
fetchProjectBillingAccount: jest.fn(),
fetchResourceRoles: jest.fn(),
fetchResources: jest.fn(),
+ fetchWorkflows: jest.fn(),
patchChallenge: jest.fn(),
}))
jest.mock('../../../../lib/utils', () => ({
@@ -251,15 +259,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 (
-
+ <>
+
+
+
+ >
)
},
}))
@@ -484,7 +533,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>,
@@ -503,7 +574,10 @@ 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 mockedFetchAiReviewTemplates = fetchAiReviewTemplates 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
@@ -612,6 +686,9 @@ describe('ChallengeEditorForm', () => {
mockedUseFetchTimelineTemplates.mockReturnValue({
timelineTemplates: [],
})
+ mockedFetchAiReviewConfigByChallenge.mockResolvedValue(undefined)
+ mockedFetchAiReviewTemplates.mockResolvedValue([])
+ mockedFetchWorkflows.mockResolvedValue([])
mockedFetchProjectBillingAccountService.mockResolvedValue({
billingAccount: undefined,
})
@@ -622,6 +699,7 @@ describe('ChallengeEditorForm', () => {
})
afterEach(() => {
+ mockShouldAutoDirtyDuringInitialHydration = false
jest.clearAllMocks()
})
@@ -1585,6 +1663,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',
@@ -1656,6 +1807,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',
@@ -1681,22 +1841,184 @@ 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 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(() => {
+ 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()
+
+ 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(2)
+ expect(mockedFetchResourcesService)
+ .toHaveBeenCalledTimes(2)
+ expect(mockedFetchResourcesService)
+ .toHaveBeenNthCalledWith(1, '12345')
+ expect(mockedFetchResourcesService)
+ .toHaveBeenNthCalledWith(2, '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,
@@ -2124,6 +2446,112 @@ 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?.message)
+ .toContain('One or more saved AI workflows were disabled.')
+ expect(mockedPatchChallenge)
+ .not.toHaveBeenCalled()
+ expect(mockedShowErrorToast)
+ .toHaveBeenCalledWith(expect.stringContaining('One or more saved AI workflows were disabled.'))
+ })
+
+ it('blocks launching when the saved AI template has been disabled', async () => {
+ let launchAction: (() => Promise) | undefined
+ let launchError: Error | undefined
+
+ mockedFetchAiReviewConfigByChallenge.mockResolvedValue({
+ challengeId: '12345',
+ id: 'config-1',
+ minPassingThreshold: 75,
+ mode: 'AI_GATING',
+ templateId: 'template-disabled',
+ workflows: [],
+ })
+ mockedFetchAiReviewTemplates.mockResolvedValue([{
+ autoFinalize: false,
+ challengeTrack: 'DESIGN',
+ challengeType: 'First2Finish',
+ description: 'Disabled template',
+ disabled: true,
+ id: 'template-disabled',
+ minPassingThreshold: 75,
+ mode: 'AI_GATING',
+ title: 'Disabled template',
+ workflows: [],
+ }])
+
+ 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?.message)
+ .toContain('The saved AI review template was disabled.')
+ expect(mockedPatchChallenge)
+ .not.toHaveBeenCalled()
+ expect(mockedShowErrorToast)
+ .toHaveBeenCalledWith(expect.stringContaining('The saved AI review template was disabled.'))
+ })
+
it('does not render the attachments section while editing a draft', () => {
render(
@@ -2163,6 +2591,129 @@ 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(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('blocks saving when the saved AI template has been disabled', async () => {
+ const user = userEvent.setup()
+
+ mockedFetchAiReviewConfigByChallenge.mockResolvedValue({
+ challengeId: '12345',
+ id: 'config-1',
+ minPassingThreshold: 75,
+ mode: 'AI_GATING',
+ templateId: 'template-disabled',
+ workflows: [],
+ })
+ mockedFetchAiReviewTemplates.mockResolvedValue([{
+ autoFinalize: false,
+ challengeTrack: 'DESIGN',
+ challengeType: 'First2Finish',
+ description: 'Disabled template',
+ disabled: true,
+ id: 'template-disabled',
+ minPassingThreshold: 75,
+ mode: 'AI_GATING',
+ title: 'Disabled template',
+ workflows: [],
+ }])
+
+ 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(expect.stringContaining('The saved AI review template was disabled.'))
+ })
+
it('refreshes phase data when the fetched challenge updates for the same id', async () => {
const initialChallenge = {
...validDraftChallenge,
@@ -2205,6 +2756,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 a9094ce7b..6c1f41a00 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx
@@ -53,11 +53,14 @@ import {
createChallenge,
createResource,
deleteResource,
+ fetchAiReviewConfigByChallenge,
+ fetchAiReviewTemplates,
fetchChallenge,
fetchProfile,
fetchProjectBillingAccount,
fetchResourceRoles,
fetchResources,
+ fetchWorkflows,
patchChallenge,
} from '../../../../lib/services'
import {
@@ -235,6 +238,8 @@ interface SingleAssignmentConfig {
interface SyncSingleAssignmentResourceParams extends Omit {
challengeId: string
nextValue?: string
+ resourceRolesOverride?: ResourceRole[]
+ resourcesOverride?: Resource[]
}
interface PersistCreatedChallengeCopilotParams {
@@ -252,6 +257,12 @@ 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 DISABLED_AI_TEMPLATE_FOR_CHALLENGE_ACTION_MESSAGE
+ = 'The saved AI review template was disabled. '
+ + 'Update the AI template selection 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'
@@ -1114,6 +1125,75 @@ function getReviewerValidationError(
return getMissingRequiredPhaseCoverageError(reviewers, requiredPhases)
}
+async function getDisabledAiWorkflowForActionError(
+ formData: ChallengeEditorFormData,
+ challengeId: string | undefined,
+ challengeTrack?: string,
+ challengeType?: string,
+): Promise {
+ const selectedAiWorkflowIds = (Array.isArray(formData.reviewers)
+ ? formData.reviewers
+ : [])
+ .map(reviewer => normalizeTextValue(reviewer?.aiWorkflowId))
+ .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,
+ ]))
+ const selectedTemplateId = normalizeTextValue(persistedAiConfig?.templateId)
+
+ if (selectedTemplateId) {
+ const templates = await fetchAiReviewTemplates({
+ challengeTrack,
+ challengeType,
+ })
+ let selectedTemplate = templates.find(template => (
+ normalizeTextValue(template.id) === selectedTemplateId
+ ))
+
+ if (!selectedTemplate && (challengeTrack || challengeType)) {
+ const allTemplates = await fetchAiReviewTemplates()
+
+ selectedTemplate = allTemplates.find(template => (
+ normalizeTextValue(template.id) === selectedTemplateId
+ ))
+ }
+
+ if (selectedTemplate?.disabled === true) {
+ return DISABLED_AI_TEMPLATE_FOR_CHALLENGE_ACTION_MESSAGE
+ }
+ }
+
+ if (!configuredAiWorkflowIds.length) {
+ return undefined
+ }
+
+ const workflows = await fetchWorkflows()
+ const workflowMapById = new Map(
+ workflows.map(workflow => [
+ normalizeTextValue(workflow.id),
+ workflow,
+ ] as const),
+ )
+ const hasDisabledWorkflow = configuredAiWorkflowIds.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 {
@@ -1366,6 +1446,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),
@@ -1395,6 +1476,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 +1743,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,
@@ -1767,8 +1866,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,
@@ -1836,10 +1937,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),
)
@@ -1849,6 +1969,8 @@ export const ChallengeEditorForm: FC = (
undefined,
config.roleNames,
getSingleAssignmentResourceValueFields(config),
+ persistedResources,
+ persistedResourceRoles,
)
return hasSameNormalizedValue(nextValue, persistedValue)
@@ -1856,6 +1978,8 @@ export const ChallengeEditorForm: FC = (
: syncSingleAssignmentResource({
challengeId,
nextValue,
+ resourceRolesOverride: persistedResourceRoles,
+ resourcesOverride: persistedResources,
resourceValueFields: config.resourceValueFields,
roleNames: config.roleNames,
valueField: config.valueField,
@@ -1872,9 +1996,46 @@ export const ChallengeEditorForm: FC = (
}, [
getPersistedAssignmentValueByFields,
isTaskSingleAssignmentChallenge,
+ loadSingleAssignmentResourceRoles,
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 => {
@@ -1883,60 +2044,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,
@@ -1950,6 +2062,7 @@ export const ChallengeEditorForm: FC = (
if (!challengeId) {
setIsInitialResourceHydrationPending(false)
+
return () => {
isActive = false
}
@@ -1967,7 +2080,10 @@ export const ChallengeEditorForm: FC = (
fetchedResources,
fetchedResourceRoles,
]) => {
- if (!isActive || isFormDirtyRef.current) {
+ if (
+ !isActive
+ || (isFormDirtyRef.current && !isInitialResourceHydrationPendingRef.current)
+ ) {
return
}
@@ -1981,7 +2097,10 @@ export const ChallengeEditorForm: FC = (
fetchedResourceRoles,
)
- if (!isActive || isFormDirtyRef.current) {
+ if (
+ !isActive
+ || (isFormDirtyRef.current && !isInitialResourceHydrationPendingRef.current)
+ ) {
return
}
@@ -1999,16 +2118,98 @@ 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(() => {
+ isInitialResourceHydrationPendingRef.current = isInitialResourceHydrationPending
+ }, [isInitialResourceHydrationPending])
+
+ 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
+ && !isInitialResourceHydrationPendingRef.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(() => {
if (
!currentChallengeId
- || formState.isDirty
+ || (formState.isDirty && !isInitialResourceHydrationPending)
|| challengeResourcesResult.isLoading
|| resourceRolesResult.isLoading
) {
@@ -2068,6 +2269,7 @@ export const ChallengeEditorForm: FC = (
currentChallengeId,
formState.isDirty,
getValues,
+ isInitialResourceHydrationPending,
isTaskSingleAssignmentChallenge,
resourceRoles,
resourceRolesResult.isLoading,
@@ -2475,6 +2677,28 @@ export const ChallengeEditorForm: FC = (
throw createHandledLaunchBlockError(taskLaunchValidationError)
}
+ const disabledAiWorkflowError = await getDisabledAiWorkflowForActionError(
+ formData,
+ currentChallengeId,
+ selectedChallengeTrack?.track || selectedChallengeTrack?.name,
+ selectedChallengeType?.name,
+ )
+
+ 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')
@@ -2519,7 +2743,8 @@ export const ChallengeEditorForm: FC = (
)
const nextValues = applySingleAssignmentFieldValues(
- applyPersistedSingleAssignments(
+ await hydratePersistedSavedFormData(
+ currentChallengeId,
{
...persistedFormData,
attachments: Array.isArray(persistedFormData.attachments)
@@ -2579,16 +2804,18 @@ export const ChallengeEditorForm: FC = (
}
},
[
- applyPersistedSingleAssignments,
clearErrors,
currentChallengeId,
fallbackProjectId,
+ hydratePersistedSavedFormData,
isEditMode,
isTaskSingleAssignmentChallenge,
navigate,
onChallengeStatusChange,
reset,
resolveProjectBillingAccount,
+ selectedChallengeTrack,
+ selectedChallengeType,
setError,
syncDraftSingleAssignments,
usesManualReviewers,
@@ -2701,9 +2928,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,
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrizesField/ChallengePrizesField.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrizesField/ChallengePrizesField.spec.tsx
index bd6c3e623..a2fc43bc5 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrizesField/ChallengePrizesField.spec.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrizesField/ChallengePrizesField.spec.tsx
@@ -203,4 +203,68 @@ describe('ChallengePrizesField', () => {
expect(secondPrizeRow.className)
.toContain(styles.prizeRowWithRemove)
})
+
+ it('allows equal lower placement prizes without showing an ordering error', () => {
+ render(
+ ,
+ )
+
+ expect(screen.queryByText('Each subsequent prize must be less than or equal to the one above it.'))
+ .toBeNull()
+ })
+
+ it('shows an ordering error when a lower placement prize increases', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Each subsequent prize must be less than or equal to the one above it.'))
+ .toBeTruthy()
+ })
})
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrizesField/ChallengePrizesField.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrizesField/ChallengePrizesField.tsx
index cbe0102a6..ff88e3eb6 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrizesField/ChallengePrizesField.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrizesField/ChallengePrizesField.tsx
@@ -205,7 +205,7 @@ export const ChallengePrizesField: FC = (
placementSetIndex,
])
- const descendingError = useMemo(() => {
+ const nonIncreasingOrderError = useMemo(() => {
if (!Array.isArray(placementPrizes) || placementPrizes.length < 2) {
return undefined
}
@@ -217,9 +217,9 @@ export const ChallengePrizesField: FC = (
if (
previousPrize > 0
&& currentPrize > 0
- && currentPrize >= previousPrize
+ && currentPrize > previousPrize
) {
- return 'Each subsequent prize must be less than the one above it.'
+ return 'Each subsequent prize must be less than or equal to the one above it.'
}
}
@@ -328,7 +328,7 @@ export const ChallengePrizesField: FC = (
? fieldState.error.message
: undefined
const showPrizeRowLabels = fields.length > 0
- const errorMessage = fieldError || descendingError
+ const errorMessage = fieldError || nonIncreasingOrderError
const prizeTypeFieldName = `${props.name}-type`
const fieldLabelId = `${props.name}-label`
const usdOptionId = `${prizeTypeFieldName}-usd`
@@ -422,7 +422,7 @@ export const ChallengePrizesField: 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,56 @@ 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
+ && !templatesLoading
+ && templates.length > 0
+ && !activeTemplateIdSet.has(selectedTemplateId)
+ ) {
+ errors.push('Selected AI review template is deactivated. Please select an active template.')
+ }
+
+ if (
+ configurationMode === 'manual'
+ && !isWorkflowsLoading
+ && availableWorkflows.length > 0
+ ) {
+ 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,
+ availableWorkflows,
+ isWorkflowsLoading,
+ normalizedConfiguration,
+ templates,
+ templatesLoading,
+ ],
)
const hasPersistedConfigForCurrentChallenge = useMemo(
() => (
@@ -654,7 +716,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 +734,7 @@ export const AiReviewTab: FC = (
workflows: selected.workflows.map(toDraftWorkflow),
})
},
- [templates],
+ [activeTemplates],
)
const performModeSwitch = useCallback(async (targetMode: ConfigurationMode): Promise => {
@@ -1123,7 +1185,7 @@ export const AiReviewTab: FC = (
value={configuration.templateId || ''}
>
- {templates.map(template => (
+ {activeTemplates.map(template => (
@@ -1191,7 +1253,7 @@ export const AiReviewTab: FC = (
{(configuration.workflows || []).map((workflow, index) => (
(
+ {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 => ({
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..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
@@ -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,125 @@ describe('EngagementEditorForm', () => {
})
})
+ it('preserves existing assignments while keeping terminal 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(
+
+
+ ,
+ )
+
+ 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(() => {
+ expect(mockedUpdateEngagement)
+ .toHaveBeenCalled()
+ })
+
+ 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)
+ .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..6c9ad5f06 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'
@@ -104,6 +105,10 @@ interface SaveEngagementOptions {
isAutosave?: boolean
}
+interface AssignmentSerializationOptions {
+ lockedAssignmentDetails?: AssignmentDetailsFormValue[]
+}
+
type EngagementAssignment = Engagement['assignments'][number]
type SerializedAssignmentDetailsPayload = {
agreementRate: string
@@ -136,41 +141,71 @@ 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 assignedMemberHandles
- .slice(0, assignmentLimit)
- .map(memberHandle => String(memberHandle || '')
+ return Array.from({ length: assignmentLimit }, (_, index) => (
+ lockedAssignedMemberHandles[index] || assignedMemberHandles[index] || ''
+ ))
+ .map(memberHandle => String(memberHandle)
.trim())
}
+/**
+ * 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)
+}
+
/**
* Serializes private-assignment details only when they still match the current
* 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()
@@ -224,6 +259,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 +276,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),
@@ -245,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.
@@ -383,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[]
} = {
@@ -405,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)
@@ -424,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
@@ -448,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(
@@ -510,7 +628,9 @@ export const EngagementEditorForm: FC = (
setSaveError(undefined)
try {
- const payload = toPayload(nextValues)
+ const payload = toPayload(nextValues, {
+ lockedAssignmentDetails,
+ })
let savedEngagement: Engagement
@@ -553,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(
@@ -743,7 +870,13 @@ export const EngagementEditorForm: FC = (
-
+
{saveError
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}
/>
>
)
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}
)
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..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,7 +21,11 @@ import type {
import {
useFetchEngagement,
useFetchProject,
+ useFetchProjectBillingAccount,
} from '../../../lib/hooks'
+import {
+ partiallyUpdateEngagement,
+} from '../../../lib/services'
import {
EditAssignmentModal,
@@ -92,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', () => ({
@@ -101,7 +106,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 +114,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,9 +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', () => {
@@ -193,7 +219,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',
@@ -232,6 +268,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 8fcff1220..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,
@@ -50,6 +51,7 @@ import {
import {
calculateAssignmentRatePerWeek,
deserializeTentativeAssignmentDate,
+ getCountableEngagementAssignments,
normalizeAssignmentStatus,
sanitizePositiveNumericInput,
serializeTentativeAssignmentDate,
@@ -244,7 +246,7 @@ function buildAssignmentDetailsUpdatePayload(
const assignmentIdText = String(assignmentId)
return {
- assignmentDetails: assignments
+ assignmentDetails: getCountableEngagementAssignments(assignments)
.map(assignment => {
const baseEntry = buildAssignmentDetailsPayloadEntry(assignment)
@@ -580,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)) {
@@ -613,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')
@@ -649,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) {
@@ -853,7 +861,10 @@ export const EngagementPaymentPage: FC = () => {
- Billing Start
+
+ Billing Start Date
+ *
+
{formatDate(assignment.startDate)}
@@ -861,11 +872,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 || '-'}
@@ -967,7 +984,9 @@ export const EngagementPaymentPage: FC = () => {
/>
= [
route: '/projects/:projectId/challenges',
title: 'Challenges',
},
+ {
+ authRequired: true,
+ element: ,
+ route: '/projects/:projectId',
+ title: 'Project Overview',
+ },
{
authRequired: true,
element: ,
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(
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 = {
diff --git a/yarn.lock b/yarn.lock
index 3d71a1d8d..cfdce4785 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"
@@ -5813,14 +5918,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"
@@ -7682,7 +7787,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==
@@ -7750,9 +7855,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"
@@ -8665,18 +8770,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 +9100,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 +9124,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 +9387,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==
@@ -9305,11 +9398,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"
@@ -9585,9 +9678,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"
@@ -9602,10 +9695,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 +9771,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==
@@ -10082,9 +10175,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"
@@ -12272,13 +12365,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 +12482,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"
@@ -12510,9 +12595,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"
@@ -12625,10 +12710,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"
@@ -13871,9 +13956,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"
@@ -14137,18 +14222,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 +15387,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"
@@ -15468,11 +15536,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"
@@ -16755,9 +16828,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"
@@ -17489,12 +17562,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"
@@ -17524,12 +17597,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"
@@ -17771,7 +17844,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==
@@ -18466,13 +18539,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"
@@ -18654,10 +18720,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"
@@ -19584,7 +19650,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==