From 864c4e67b285bffcfac176fe39acbcbc0db67eba Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 1 May 2026 11:10:02 +0300 Subject: [PATCH] PM-4684 - challenge budget approval flow --- src/apps/work/src/lib/constants.ts | 6 + .../work/src/lib/models/Challenge.model.ts | 3 + .../src/lib/utils/challenge-editor.utils.ts | 15 ++ .../ChallengeEditorPage.tsx | 6 +- .../ChallengeEditorForm.module.scss | 50 +++++ .../components/ChallengeEditorForm.tsx | 191 +++++++++++++++++- 6 files changed, 267 insertions(+), 4 deletions(-) diff --git a/src/apps/work/src/lib/constants.ts b/src/apps/work/src/lib/constants.ts index 42837dddb..9697f09fe 100644 --- a/src/apps/work/src/lib/constants.ts +++ b/src/apps/work/src/lib/constants.ts @@ -26,6 +26,12 @@ export const CHALLENGE_STATUS = { NEW: 'NEW', } as const +export const CHALLENGE_APPROVAL_STATUS = { + APPROVED: 'APPROVED', + PENDING_APPROVAL: 'PENDING_APPROVAL', + REJECTED: 'REJECTED', +} as const + export const PAGE_SIZE = 10 export const PAGINATION_PER_PAGE_OPTIONS: ReadonlyArray = [5, 10, 20, 25, 50] diff --git a/src/apps/work/src/lib/models/Challenge.model.ts b/src/apps/work/src/lib/models/Challenge.model.ts index 48551c4da..5f1737d44 100644 --- a/src/apps/work/src/lib/models/Challenge.model.ts +++ b/src/apps/work/src/lib/models/Challenge.model.ts @@ -65,6 +65,9 @@ export interface ChallengeTerm { export interface Challenge { id: string + approvalApprovedBy?: string + approvalRejectionReason?: string + approvalStatus?: string assignedMemberId?: string attachments?: Attachment[] billing?: { diff --git a/src/apps/work/src/lib/utils/challenge-editor.utils.ts b/src/apps/work/src/lib/utils/challenge-editor.utils.ts index a3cc5415d..3a376f3b3 100644 --- a/src/apps/work/src/lib/utils/challenge-editor.utils.ts +++ b/src/apps/work/src/lib/utils/challenge-editor.utils.ts @@ -1,3 +1,4 @@ +/* eslint-disable complexity */ import { DESIGN_WORK_TYPES, PHASE_DURATION_MAX_HOURS, @@ -8,6 +9,7 @@ import { SKILLS_OPTIONAL_BILLING_ACCOUNT_IDS, } from '../constants/challenge-editor.constants' import { + CHALLENGE_APPROVAL_STATUS, CHALLENGE_STATUS, } from '../constants' import { @@ -1002,6 +1004,8 @@ export function transformChallengeToFormData( const isTask = normalizeOptionalBoolean(challenge?.task?.isTask) || false const status = normalizeOptionalString(challenge?.status) ?.toUpperCase() + const approvalStatus = normalizeOptionalString(challenge?.approvalStatus) + ?.toUpperCase() const billing = normalizeBillingInfo(challenge?.billing) const normalizedPrizeSets = normalizePrizeSets(challenge?.prizeSets) const prizeSetsForForm = challenge?.id && status !== CHALLENGE_STATUS.NEW @@ -1009,6 +1013,10 @@ export function transformChallengeToFormData( : ensurePlacementPrizeSet(normalizedPrizeSets) return { + approvalApprovedBy: normalizeStringValue(challenge?.approvalApprovedBy) || undefined, + approvalRejectionReason: normalizeStringValue(challenge?.approvalRejectionReason) || undefined, + approvalStatus: approvalStatus + || CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL, assignedMemberId: getChallengeAssignedMemberSelectorValue(challenge), attachments: normalizeAttachments(challenge?.attachments), billing, @@ -1073,6 +1081,8 @@ export function transformFormDataToChallenge( const reviewType = normalizeReviewType(formData.legacy?.reviewType) || REVIEW_TYPES.INTERNAL const status = normalizeOptionalString(formData.status) ?.toUpperCase() + const approvalStatus = normalizeOptionalString(formData.approvalStatus) + ?.toUpperCase() const billing = normalizeBillingInfo(formData.billing) const prizeSets = formData.funChallenge === true ? [] @@ -1083,6 +1093,11 @@ export function transformFormDataToChallenge( ) const challenge: Partial = { + approvalApprovedBy: normalizeOptionalString(formData.approvalApprovedBy) + || undefined, + approvalRejectionReason: normalizeOptionalString(formData.approvalRejectionReason) + || undefined, + approvalStatus: approvalStatus || CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL, assignedMemberId: normalizeMemberSelectorValue(formData.assignedMemberId), billing, challengeFee: normalizeOptionalNumber(formData.challengeFee), diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx index e728c4830..a16f1ebf8 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx @@ -701,7 +701,11 @@ function renderLaunchModal(params: RenderLaunchModalParams): JSX.Element | undef confirmText={params.isLaunching ? 'Launching...' : 'Launch'} - message={`Are you ready to launch challenge ${params.challengeName}?`} + message={ + `Are you ready to launch challenge ${params.challengeName}? + +Prizes and copilot fees are locked after launch. Contact the Project Manager for any updates post-launch.` + } onCancel={params.onLaunchCancel} onConfirm={params.onLaunchConfirmClick} title='Launch Challenge' diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.module.scss b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.module.scss index ebebf184f..f628392d9 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.module.scss +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.module.scss @@ -104,6 +104,56 @@ width: 100%; } +.approvalSection { + border: 1px solid $black-20; + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 10px; + max-width: 100%; + padding: 14px; +} + +.approvalStatusRow { + align-items: center; + display: flex; + gap: 8px; +} + +.approvalStatusLabel { + color: $black-80; + font-size: 14px; + font-weight: 700; +} + +.approvalStatusValue { + color: $black-100; + font-size: 14px; + font-weight: 600; +} + +.approvalReason { + color: $black-80; + font-size: 13px; + line-height: 1.4; +} + +.rejectionReasonInput { + border: 1px solid $black-20; + border-radius: 6px; + font-family: inherit; + font-size: 13px; + min-height: 72px; + padding: 8px 10px; + resize: vertical; +} + +.approvalActions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + @media (min-width: 1024px) { .submissionSettingsGrid { grid-template-columns: repeat(2, minmax(0, 1fr)); 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 6c1f41a00..0da83b094 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -1,6 +1,8 @@ import { + ChangeEvent, FC, useCallback, + useContext, useEffect, useMemo, useRef, @@ -18,10 +20,12 @@ import { Button } from '~/libs/ui' import { FormCheckboxField } from '../../../../lib/components/form' import { + CHALLENGE_APPROVAL_STATUS, CHALLENGE_STATUS, CHALLENGE_TRACKS, CREATE_FORUM_TYPE_IDS, } from '../../../../lib/constants' +import { WorkAppContext } from '../../../../lib/contexts' import { AUTOSAVE_DELAY_MS, DESIGN_WORK_TYPES, @@ -257,6 +261,8 @@ const SAVE_VALIDATION_ERROR_MESSAGE = 'Please fix validation errors before savin const DESIGN_WORK_TYPE_REQUIRED_MESSAGE = 'Select a work type' const TASK_ASSIGNED_MEMBER_REQUIRED_FOR_LAUNCH_MESSAGE = 'Assign a member before launching a task challenge.' +const APPROVAL_REQUIRED_FOR_LAUNCH_MESSAGE + = 'Challenge launch is blocked until budget approval is Approved.' 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.' @@ -1340,6 +1346,18 @@ function getSaveSuccessMessage( : 'Challenge saved successfully' } +function getApprovalStatusText(approvalStatus: string | undefined): string { + if (approvalStatus === CHALLENGE_APPROVAL_STATUS.APPROVED) { + return 'Approved' + } + + if (approvalStatus === CHALLENGE_APPROVAL_STATUS.REJECTED) { + return 'Rejected' + } + + return 'Pending Approval' +} + interface TaskLaunchValidationParams { assignedMemberId?: unknown currentStatus?: unknown @@ -1435,6 +1453,7 @@ function isHandledLaunchBlockError( export const ChallengeEditorForm: FC = ( props: ChallengeEditorFormProps, ) => { + const workAppContext = useContext(WorkAppContext) const location = useLocation() const navigate = useNavigate() const isEditMode = props.isEditMode @@ -1483,6 +1502,8 @@ export const ChallengeEditorForm: FC = ( const [saveStatus, setSaveStatus] = useState<'error' | 'idle' | 'saved' | 'saving'>('idle') const [scorerHasUnsavedChanges, setScorerHasUnsavedChanges] = useState(false) const [scorerHasError, setScorerHasError] = useState(false) + const [isUpdatingApproval, setIsUpdatingApproval] = useState(false) + const [rejectionReasonInput, setRejectionReasonInput] = useState('') const formMethods = useForm({ defaultValues: applyProjectBillingToChallengeFormData( @@ -1681,6 +1702,23 @@ export const ChallengeEditorForm: FC = ( values.status, ], ) + const normalizedApprovalStatus = useMemo( + () => normalizeStatus(values.approvalStatus) + || normalizeStatus(props.challenge?.approvalStatus) + || CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL, + [ + props.challenge?.approvalStatus, + values.approvalStatus, + ], + ) + const canApproveChallengeBudget = workAppContext.isAdmin || workAppContext.isManager + const arePrizeFieldsLockedForRole = normalizedChallengeStatus === CHALLENGE_STATUS.ACTIVE + && !canApproveChallengeBudget + const arePrizeFieldsDisabled = isReadOnly || arePrizeFieldsLockedForRole + const canRenderApprovalActions = !isReadOnly + && canApproveChallengeBudget + && !!currentChallengeId + && normalizedChallengeStatus !== CHALLENGE_STATUS.ACTIVE const isChallengeCreated = !!currentChallengeId const isFunChallengeSelected = values.funChallenge === true const showFunChallengeField = isMarathonMatchChallengeSelected @@ -1699,6 +1737,15 @@ export const ChallengeEditorForm: FC = ( ) const isScorerBlockingChallengeActions = showMarathonMatchScorerSection && (scorerHasUnsavedChanges || scorerHasError) + + useEffect(() => { + const nextReason = typeof values.approvalRejectionReason === 'string' + ? values.approvalRejectionReason + : '' + + setRejectionReasonInput(nextReason) + }, [values.approvalRejectionReason]) + const getPersistedAssignmentValueByFields = useCallback(( fallbackValue: string | undefined, roleNames: readonly string[], @@ -2677,6 +2724,20 @@ export const ChallengeEditorForm: FC = ( throw createHandledLaunchBlockError(taskLaunchValidationError) } + if ( + isChallengeBeingActivated + && normalizeStatus(formData.approvalStatus) !== CHALLENGE_APPROVAL_STATUS.APPROVED + ) { + setSaveStatus('idle') + setSaveValidationError(APPROVAL_REQUIRED_FOR_LAUNCH_MESSAGE) + + if (!options.isAutosave) { + showErrorToast(APPROVAL_REQUIRED_FOR_LAUNCH_MESSAGE) + } + + throw createHandledLaunchBlockError(APPROVAL_REQUIRED_FOR_LAUNCH_MESSAGE) + } + const disabledAiWorkflowError = await getDisabledAiWorkflowForActionError( formData, currentChallengeId, @@ -2823,6 +2884,67 @@ export const ChallengeEditorForm: FC = ( ], ) + const updateApprovalStatus = useCallback(async ( + nextApprovalStatus: string, + rejectionReason?: string, + ): Promise => { + if (!currentChallengeId || isUpdatingApproval) { + return + } + + if (nextApprovalStatus === CHALLENGE_APPROVAL_STATUS.REJECTED && !normalizeTextValue(rejectionReason)) { + showErrorToast('Rejection reason is required.') + return + } + + setIsUpdatingApproval(true) + + try { + const payload = { + approvalRejectionReason: nextApprovalStatus === CHALLENGE_APPROVAL_STATUS.REJECTED + ? normalizeTextValue(rejectionReason) + : undefined, + approvalStatus: nextApprovalStatus, + } + const savedChallenge = await patchChallenge(currentChallengeId, payload) + const mergedFormData = { + ...getValues(), + ...transformChallengeToFormData(savedChallenge), + } + + reset(mergedFormData) + setSaveValidationError(undefined) + showSuccessToast(nextApprovalStatus === CHALLENGE_APPROVAL_STATUS.APPROVED + ? 'Challenge budget approved.' + : 'Challenge budget rejected.') + } catch (error) { + const errorMessage = error instanceof Error + ? error.message + : 'Failed to update approval status' + showErrorToast(errorMessage) + } finally { + setIsUpdatingApproval(false) + } + }, [ + currentChallengeId, + getValues, + isUpdatingApproval, + reset, + ]) + + const handleApproveChallengeBudget = useCallback((): void => { + updateApprovalStatus(CHALLENGE_APPROVAL_STATUS.APPROVED) + .catch(() => undefined) + }, [updateApprovalStatus]) + + const handleRejectChallengeBudget = useCallback((): void => { + updateApprovalStatus(CHALLENGE_APPROVAL_STATUS.REJECTED, rejectionReasonInput) + .catch(() => undefined) + }, [ + rejectionReasonInput, + updateApprovalStatus, + ]) + const launchChallenge = useCallback(async (): Promise => { if (isScorerBlockingChallengeActions) { showErrorToast('Save a valid scorer configuration before launching the challenge') @@ -3145,20 +3267,23 @@ export const ChallengeEditorForm: FC = ( resolvedChallengeTypeAbbreviation } challengeTypeName={resolvedChallengeTypeName} - disabled={isReadOnly} + disabled={arePrizeFieldsDisabled} name='prizeSets' /> {showCheckpointPrizes ? ( ) : undefined}
- +
@@ -3166,6 +3291,66 @@ export const ChallengeEditorForm: FC = (
+
+
+ + Approval status: + + + {getApprovalStatusText(normalizedApprovalStatus)} + +
+ {normalizedApprovalStatus === CHALLENGE_APPROVAL_STATUS.REJECTED + && normalizeTextValue(values.approvalRejectionReason) + ? ( +
+ {`Reason: ${values.approvalRejectionReason}`} +
+ ) + : undefined} + {normalizedApprovalStatus === CHALLENGE_APPROVAL_STATUS.APPROVED + && normalizeTextValue(values.approvalApprovedBy) + ? ( +
+ {`Approved by ${values.approvalApprovedBy}`} +
+ ) + : undefined} + {canRenderApprovalActions + ? ( + <> +