diff --git a/src/apps/work/src/lib/constants.ts b/src/apps/work/src/lib/constants.ts index b05392525..297f75390 100644 --- a/src/apps/work/src/lib/constants.ts +++ b/src/apps/work/src/lib/constants.ts @@ -32,6 +32,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 133dbe956..473c8a622 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, @@ -1074,6 +1082,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 ? [] @@ -1084,6 +1094,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 d7a7072df..4e18f5851 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx @@ -787,7 +787,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 7bcf689a4..5322a2e17 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 @@ -113,6 +113,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 3eeaf475b..2d6b0d9b6 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -1,4 +1,5 @@ import { + ChangeEvent, FC, useCallback, useContext, @@ -19,6 +20,7 @@ import { Button } from '~/libs/ui' import { FormCheckboxField } from '../../../../lib/components/form' import { + CHALLENGE_APPROVAL_STATUS, CHALLENGE_STATUS, CHALLENGE_TRACKS, CREATE_FORUM_TYPE_IDS, @@ -267,6 +269,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.' @@ -1377,6 +1381,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 @@ -1536,6 +1552,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 [resolvedPaymentCreator, setResolvedPaymentCreator] = useState() const formMethods = useForm({ @@ -1735,6 +1753,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 @@ -1753,6 +1788,15 @@ export const ChallengeEditorForm: FC = ( ) const isScorerBlockingChallengeActions = showMarathonMatchScorerSection && (scorerHasUnsavedChanges || scorerHasError) + + useEffect(() => { + const nextReason = typeof values.approvalRejectionReason === 'string' + ? values.approvalRejectionReason + : '' + + setRejectionReasonInput(nextReason) + }, [values.approvalRejectionReason]) + const shouldDeferInitialResourceDirtyNormalization = isInitialResourceHydrationPending || (!!props.challenge?.id && props.challenge.id !== currentChallengeId) const shouldUseCopilotBillingSummary = workAppContext.isCopilot @@ -2735,6 +2779,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, @@ -2892,6 +2950,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') @@ -3314,20 +3433,23 @@ export const ChallengeEditorForm: FC = ( resolvedChallengeTypeAbbreviation } challengeTypeName={resolvedChallengeTypeName} - disabled={isReadOnly} + disabled={arePrizeFieldsDisabled} name='prizeSets' /> {showCheckpointPrizes ? ( ) : undefined}
- +
@@ -3358,6 +3480,70 @@ 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 + ? ( + <> +