diff --git a/package.json b/package.json index 215e645de..8824556b9 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "@uiw/react-codemirror": "^4.25.8", "amazon-s3-uri": "^0.1.1", "apexcharts": "^3.54.1", - "axios": "^1.13.2", + "axios": "^1.15.0", "browser-cookies": "^1.2.0", "city-timezones": "^1.3.2", "classnames": "^2.5.1", @@ -66,7 +66,7 @@ "highcharts-react-official": "^3.2.3", "highlight.js": "^11.11.1", "html2canvas": "^1.4.1", - "lodash": "^4.17.21", + "lodash": "^4.18.1", "markdown-it": "^13.0.2", "marked": "4.3.0", "moment": "^2.30.1", @@ -213,7 +213,7 @@ "sass-loader": "^13.3.3", "serve": "^14.2.5", "start-server-and-test": "^2.1.3", - "storybook": "7.6.20", + "storybook": "7.6.21", "style-loader": "^3.3.4", "systemjs-webpack-interop": "^2.3.7", "tsconfig-paths-webpack-plugin": "^4.2.0", diff --git a/public/llm-icons/claude-icon.svg b/public/llm-icons/claude-icon.svg new file mode 100644 index 000000000..1dfcc85bd --- /dev/null +++ b/public/llm-icons/claude-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx b/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx index 2d9e172b9..b61fbc30e 100644 --- a/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx +++ b/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx @@ -51,6 +51,7 @@ import { checkIsMM, getSubmissionReprocessTopic, handleError, + resolveManualUploadSubmissionType, } from '../../lib/utils' import styles from './ManageSubmissionPage.module.scss' @@ -92,7 +93,9 @@ interface ManualSubmissionUploadModalProps { selectedHandle?: SelectOption setSelectedHandle: (value: SelectOption) => void isUploading: boolean + isLoadingChallenge: boolean isLoadingSubmitters: boolean + submissionTypeLabel: string submitterOptions: SelectOption[] handleFileChange: (event: ChangeEvent) => void selectedFile?: File @@ -160,6 +163,7 @@ const ManualSubmissionUploadModal: FC = ( props: ManualSubmissionUploadModalProps, ) => { const isHandleSelectDisabled = props.isUploading + || props.isLoadingChallenge || props.isLoadingSubmitters || props.submitterOptions.length === 0 const memberHandleHint = !props.isLoadingSubmitters @@ -179,6 +183,16 @@ const ManualSubmissionUploadModal: FC = ( >
+
+ + Submission type + + + {props.isLoadingChallenge + ? 'Loading challenge phases...' + : props.submissionTypeLabel} + +
= ( onClick={props.handleUploadSubmission} disabled={ props.isUploading + || props.isLoadingChallenge || props.isLoadingSubmitters || !props.selectedHandle?.value || !props.selectedFile @@ -256,6 +271,18 @@ export const ManageSubmissionPage: FC = (props: Props) => { challengeInfo, }: useFetchChallengeProps = useFetchChallenge(challengeId) const isMM = useMemo(() => checkIsMM(challengeInfo), [challengeInfo]) + const manualUploadSubmissionType = useMemo( + () => resolveManualUploadSubmissionType(challengeInfo), + [challengeInfo], + ) + const manualUploadSubmissionTypeLabel = useMemo( + () => ( + manualUploadSubmissionType === 'CHECKPOINT_SUBMISSION' + ? 'Checkpoint Submission' + : 'Submission' + ), + [manualUploadSubmissionType], + ) const submissionReprocessTopic = useMemo( () => getSubmissionReprocessTopic(challengeInfo), [challengeInfo], @@ -329,7 +356,12 @@ export const ManageSubmissionPage: FC = (props: Props) => { ) const handleUploadSubmission = useCallback(async () => { - if (!challengeId || !selectedFile || !selectedHandle?.value) { + if ( + !challengeId + || !challengeInfo + || !selectedFile + || !selectedHandle?.value + ) { return } @@ -341,6 +373,7 @@ export const ManageSubmissionPage: FC = (props: Props) => { fileName: selectedFile.name, memberHandle: String(selectedHandle.label), memberId: selectedHandle.value, + type: manualUploadSubmissionType, }) toast.success('Submission uploaded successfully', { @@ -354,7 +387,15 @@ export const ManageSubmissionPage: FC = (props: Props) => { } finally { setIsUploading(false) } - }, [challengeId, selectedFile, selectedHandle, resetUploadForm, refresh]) + }, [ + challengeId, + challengeInfo, + manualUploadSubmissionType, + refresh, + resetUploadForm, + selectedFile, + selectedHandle, + ]) useEffect(() => { let isCancelled = false @@ -415,7 +456,7 @@ export const ManageSubmissionPage: FC = (props: Props) => { - -
-
- - )} - - ) -} - -export default ProfileCompletionPage diff --git a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/index.ts b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/index.ts deleted file mode 100644 index 4d99c8c31..000000000 --- a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as ProfileCompletionPage } from './ProfileCompletionPage' diff --git a/src/apps/customer-portal/src/pages/profile-completion/index.ts b/src/apps/customer-portal/src/pages/profile-completion/index.ts deleted file mode 100644 index 73dcadd92..000000000 --- a/src/apps/customer-portal/src/pages/profile-completion/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ProfileCompletionPage' diff --git a/src/apps/customer-portal/src/pages/profile-completion/profile-completion.routes.tsx b/src/apps/customer-portal/src/pages/profile-completion/profile-completion.routes.tsx deleted file mode 100644 index 42042bc0f..000000000 --- a/src/apps/customer-portal/src/pages/profile-completion/profile-completion.routes.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { getRoutesContainer, lazyLoad, LazyLoadedComponent } from '~/libs/core' - -import { profileCompletionRouteId } from '../../config/routes.config' - -const ProfileCompletionPage: LazyLoadedComponent = lazyLoad( - () => import('./ProfileCompletionPage'), - 'ProfileCompletionPage', -) - -export const profileCompletionChildRoutes = [ - { - authRequired: true, - element: , - id: 'profile-completion-page', - route: '', - }, -] - -export const customerPortalProfileCompletionRoutes = [ - { - children: [...profileCompletionChildRoutes], - element: getRoutesContainer(profileCompletionChildRoutes), - id: profileCompletionRouteId, - route: profileCompletionRouteId, - }, -] diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index fa88968ff..b62c33dfa 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -28,65 +28,102 @@ import personSearchImage from '../../../lib/assets/person-search.png' import styles from './TalentSearchPage.module.scss' +type TalentSearchSortOption = 'alphabetical' | 'matching-index' + export const TalentSearchPage: FC = () => { const skipNextAutoSearchRef = useRef(false) + const searchGenerationRef = useRef(0) // ← add this + + const [lastSearchedDescription, setLastSearchedDescription] = useState('') const countryLookup: CountryLookup[] | undefined = useCountryLookup() const [jobDescription, setJobDescription] = useState('') const [isExtractingSkills, setIsExtractingSkills] = useState(false) const [errorMessage, setErrorMessage] = useState('') - const [hasSearched, setHasSearched] = useState(false) + const [hasSearched, setHasSearched] = useState(true) const [skillOptionsLoading, setSkillOptionsLoading] = useState(false) const [selectedSkills, setSelectedSkills] = useState([]) - const [selectedCountry, setSelectedCountry] = useState('all') - const [onlyOpenToWork, setOnlyOpenToWork] = useState(true) - const [onlyActive, setOnlyActive] = useState(true) - const [isSearchingMembers, setIsSearchingMembers] = useState(false) + const [sortBy, setSortBy] = useState('alphabetical') + const [selectedCountries, setSelectedCountries] = useState([]) + const [onlyOpenToWork, setOnlyOpenToWork] = useState(false) + const [onlyActive, setOnlyActive] = useState(false) + const [isSearchingMembers, setIsSearchingMembers] = useState(true) const [isLoadingMore, setIsLoadingMore] = useState(false) const [results, setResults] = useState([]) const [totalResults, setTotalResults] = useState(0) const [currentPage, setCurrentPage] = useState(1) - - // const breadCrumb = useMemo( - // () => [{ index: 1, label: 'Talent Search' }], - // [], - // ) - const countryOptions = useMemo( - (): InputSelectOption[] => [ - { label: 'All Countries', value: 'all' }, - ...((countryLookup || []) - .map(country => ({ - label: country.country, - value: country.countryCode, - })) - .filter(option => option.label && option.value) - .sort((a, b) => String(a.label) - .localeCompare(String(b.label)))), - ], + const countryNameByCode = useMemo((): Map => new Map( + (countryLookup || []) + .filter(country => country.countryCode && country.country) + .map(country => [country.countryCode.toUpperCase(), country.country]), + ), [countryLookup]) + const countryFilterOptions = useMemo( + (): InputMultiselectOption[] => (countryLookup || []) + .map(country => ({ + label: country.country, + value: country.countryCode, + })) + .filter(option => option.label && option.value) + .sort((a, b) => String(a.label) + .localeCompare(String(b.label))), [countryLookup], ) + const selectedCountryCodesList = useMemo( + (): string[] => selectedCountries + .map(country => String(country.value || '') + .trim() + .toUpperCase()) + .filter(Boolean), + [selectedCountries], + ) + + const hasSkillSearch = selectedSkills.length > 0 + const hasActiveFilters = hasSkillSearch + || selectedCountries.length > 0 + || onlyOpenToWork + || onlyActive + const shouldShowIntroState = !hasSearched && !hasActiveFilters + const activeSort: TalentSearchSortOption = hasSkillSearch ? 'matching-index' : sortBy + const sortOptions = useMemo( + (): InputSelectOption[] => (hasSkillSearch + ? [{ label: 'Matching Index', value: 'matching-index' }] + : [{ label: 'Alphabetical', value: 'alphabetical' }]), + [hasSkillSearch], + ) const filteredResults = useMemo(() => results.filter(talent => { - if (selectedCountry !== 'all') { - const selectedCountryOption = countryOptions.find(option => option.value === selectedCountry) - const selectedCountryName = typeof selectedCountryOption?.label === 'string' - ? selectedCountryOption.label - : '' - const normalizedLocation = talent.location.toLowerCase() - - if (!selectedCountryName || !normalizedLocation.includes(selectedCountryName.toLowerCase())) { - return false - } + if (onlyActive && !talent.isRecentlyActive) { + return false } - if (onlyActive && !talent.isRecentlyActive) { + if (onlyOpenToWork && !talent.openToWork) { return false } return true - }), [countryOptions, onlyActive, results, selectedCountry]) - const foundMembersCount = selectedCountry === 'all' - ? (totalResults || filteredResults.length) - : filteredResults.length + }), [onlyActive, onlyOpenToWork, results]) + + // Order comes from reports-api (sortBy/sortOrder on each request) so pagination stays globally consistent. + const displayedResults = filteredResults + + const foundMembersCount = totalResults || displayedResults.length + const displayedResultsWithCountryName = useMemo( + () => displayedResults.map(talent => { + const code = String(talent.location || '') + .trim() + .toUpperCase() + const countryName = countryNameByCode.get(code) + + if (!countryName) { + return talent + } + + return { + ...talent, + location: countryName, + } + }), + [countryNameByCode, displayedResults], + ) const hasMoreResults = results.length < totalResults const loadSkillOptions = useCallback(async (query: string): Promise => { @@ -99,26 +136,40 @@ export const TalentSearchPage: FC = () => { setSkillOptionsLoading(false) } }, []) + const loadCountryOptions = useCallback(async (query: string): Promise => { + const normalizedQuery = query.trim() + .toLowerCase() + if (!normalizedQuery) { + return countryFilterOptions + } + + return countryFilterOptions.filter(option => String(option.label || '') + .toLowerCase() + .includes(normalizedQuery)) + }, [countryFilterOptions]) const runMemberSearch = useCallback(async ( skillsToSearch: InputMultiselectOption[], overrides?: { append?: boolean + countries?: string[] + generation?: number openToWork?: boolean page?: number recentlyActive?: boolean }, - ): Promise => { + ): Promise => { const append = overrides?.append === true + const countries = (overrides?.countries ?? selectedCountryCodesList) + .filter(Boolean) + const generation = overrides?.generation const openToWork = overrides?.openToWork ?? onlyOpenToWork const page = overrides?.page ?? 1 const recentlyActive = overrides?.recentlyActive ?? onlyActive - + const hasSkills = skillsToSearch.length > 0 const payload: MemberSearchPayload = { limit: MEMBER_SEARCH_LIMIT, - openToWork, page, - recentlyActive, skills: skillsToSearch .map(skill => String(skill.value || '') .trim()) @@ -128,7 +179,20 @@ export const TalentSearchPage: FC = () => { wins: 1, })), skillSearchType: 'OR', - verifiedProfile: true, + sortBy: hasSkills ? 'matchIndex' : 'handle', + sortOrder: hasSkills ? 'desc' : 'asc', + } + + if (countries.length > 0) { + payload.countries = countries + } + + if (openToWork) { + payload.openToWork = true + } + + if (recentlyActive) { + payload.recentlyActive = true } if (append) { @@ -138,11 +202,14 @@ export const TalentSearchPage: FC = () => { } setErrorMessage('') - try { const response = await searchMembers(payload) - const fetchedData = Array.isArray(response?.data) ? response.data : [] + // If generation was provided and has changed, discard stale results + if (generation !== undefined && searchGenerationRef.current !== generation) { + return false + } + const fetchedData = Array.isArray(response?.data) ? response.data : [] setResults(prevResults => { if (!append) { return fetchedData @@ -156,19 +223,21 @@ export const TalentSearchPage: FC = () => { merged.push(item) } }) - return merged }) setTotalResults(Number(response?.total || 0)) setCurrentPage(Number(response?.page || page)) + return true } catch { if (!append) { setResults([]) setTotalResults(0) setCurrentPage(1) + setLastSearchedDescription('') } setErrorMessage('Failed to search matching members. Please try again.') + return false } finally { if (append) { setIsLoadingMore(false) @@ -176,19 +245,27 @@ export const TalentSearchPage: FC = () => { setIsSearchingMembers(false) } } - }, [onlyActive, onlyOpenToWork]) + }, [onlyActive, onlyOpenToWork, selectedCountryCodesList]) const clearAllFilters = useCallback((): void => { - setSelectedCountry('all') - setOnlyOpenToWork(true) - setOnlyActive(true) + searchGenerationRef.current += 1 + setSelectedCountries([]) + setOnlyOpenToWork(false) + setOnlyActive(false) + setSortBy('alphabetical') setSelectedSkills([]) - setHasSearched(false) - setResults([]) - setTotalResults(0) - setCurrentPage(1) + setHasSearched(true) setErrorMessage('') - }, []) + skipNextAutoSearchRef.current = true + setLastSearchedDescription('') + runMemberSearch([], { + countries: [], + generation: searchGenerationRef.current, + openToWork: false, + page: 1, + recentlyActive: false, + }) + }, [runMemberSearch]) const handleAiSearch = useCallback(async (): Promise => { const normalizedDescription = jobDescription.trim() @@ -196,11 +273,15 @@ export const TalentSearchPage: FC = () => { return } + const generation = searchGenerationRef.current + setErrorMessage('') setIsExtractingSkills(true) try { const extractedSkillsResult = await extractSkillsFromText(normalizedDescription) + if (searchGenerationRef.current !== generation) return + const extractedSkills = Array.isArray(extractedSkillsResult?.matches) ? extractedSkillsResult.matches : [] @@ -230,24 +311,31 @@ export const TalentSearchPage: FC = () => { setTotalResults(0) setHasSearched(true) setErrorMessage('No skills were extracted from the job description.') + skipNextAutoSearchRef.current = true return } setHasSearched(true) skipNextAutoSearchRef.current = true - await runMemberSearch(extractedOptions, { page: 1 }) + const searchSucceeded = await runMemberSearch(extractedOptions, { generation, page: 1 }) + if (searchGenerationRef.current !== generation) return + + if (searchSucceeded) { + setLastSearchedDescription(normalizedDescription) + } } catch { - // Prevent stale auto-search when extraction fails and loading flips to false. skipNextAutoSearchRef.current = true + if (searchGenerationRef.current !== generation) return setErrorMessage('Failed to extract skills. Please try again.') setHasSearched(true) } finally { setIsExtractingSkills(false) + } }, [isExtractingSkills, jobDescription, runMemberSearch]) useEffect(() => { - if (!hasSearched || isExtractingSkills || selectedSkills.length === 0) { + if ((shouldShowIntroState) || isExtractingSkills) { return } @@ -256,18 +344,21 @@ export const TalentSearchPage: FC = () => { return } - runMemberSearch(selectedSkills) + runMemberSearch(selectedSkills, { generation: searchGenerationRef.current, page: 1 }) }, [ hasSearched, + hasActiveFilters, isExtractingSkills, onlyActive, onlyOpenToWork, runMemberSearch, + selectedCountries, selectedSkills, + shouldShowIntroState, ]) const handleLoadMore = useCallback((): void => { - if (isLoadingMore || isSearchingMembers || !hasMoreResults || selectedSkills.length === 0) { + if (isLoadingMore || isSearchingMembers || !hasMoreResults) { return } @@ -276,7 +367,12 @@ export const TalentSearchPage: FC = () => { page: currentPage + 1, }) }, [currentPage, hasMoreResults, isLoadingMore, isSearchingMembers, runMemberSearch, selectedSkills]) - + const isSearchButtonDisabled = useMemo( + () => isExtractingSkills + || !jobDescription.trim() + || jobDescription.trim() === lastSearchedDescription, + [isExtractingSkills, jobDescription, lastSearchedDescription], + ) return ( { secondary disabled={isExtractingSkills} onClick={() => { + searchGenerationRef.current += 1 setJobDescription('') setErrorMessage('') + setLastSearchedDescription('') }} > Clear - - )} - + + + )} + + )} - status?: 'passed' | 'pending' | 'failed-score' | 'failed' + status?: 'passed' | 'pending' | 'failed-score' | 'failed' | 'human-override' score?: number hideLabel?: boolean showScore?: boolean @@ -85,6 +85,16 @@ export const AiWorkflowRunStatus: FC = props => { action={props.action} /> )} + {displayStatus === 'human-override' && ( + } + hideLabel={props.hideLabel} + status={displayStatus} + label='Unlocked' + score={score} + action={props.action} + /> + )} ) } diff --git a/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss index b416ee905..107b2b86b 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss +++ b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss @@ -29,6 +29,11 @@ color: #e9ecef; border-color: #e9ecef; } + + &.human-override { + color: #7B61FF; + border-color: #7B61FF; + } } .aiIcon { diff --git a/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.tsx b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.tsx index 0458c02c9..404fa19ca 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.tsx +++ b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.tsx @@ -8,7 +8,7 @@ interface StatusLabelProps { hideLabel?: boolean label?: string score?: number - status: 'pending' | 'failed' | 'passed' | 'failed-score' + status: 'pending' | 'failed' | 'passed' | 'failed-score' | 'human-override' action?: ReactNode isAiIcon?: boolean } diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts index 338c4cfe0..40498ddc9 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts @@ -119,6 +119,128 @@ describe('filterIterativeReviewRows', () => { .toBe('iterative-phase-1') }) + it('maps phase-less AI-failed submissions to iterative tabs by submission order', () => { + const multiIterativePhases: BackendPhase[] = [ + createPhase('submission-1', 'Submission'), + createPhase('iterative-1', 'Iterative Review', 'iterative-phase-1'), + createPhase('iterative-2', 'Iterative Review', 'iterative-phase-2'), + createPhase('review-1', 'Review', 'review-phase-1'), + ] + + const iterativeReviewer = createResource('iterative-resource-1', 'Iterative Reviewer') + const firstAiFailedSubmission: SubmissionInfo = { + ...createSubmission(iterativeReviewer.id), + id: 'submission-1', + status: 'AI_FAILED_REVIEW', + submittedDate: '2026-04-01T04:56:13.405Z', + } + + const secondAiFailedSubmission: SubmissionInfo = { + ...createSubmission(iterativeReviewer.id), + id: 'submission-2', + status: 'AI_FAILED_REVIEW', + submittedDate: '2026-04-01T04:57:13.405Z', + } + + const phase1Results = filterIterativeReviewRows({ + challengePhases: multiIterativePhases, + isPostMortemPhase: false, + phaseIdFilter: 'iterative-1', + reviewerResources: [iterativeReviewer], + sourceRows: [secondAiFailedSubmission, firstAiFailedSubmission], + }) + + const phase2Results = filterIterativeReviewRows({ + challengePhases: multiIterativePhases, + isPostMortemPhase: false, + phaseIdFilter: 'iterative-2', + reviewerResources: [iterativeReviewer], + sourceRows: [secondAiFailedSubmission, firstAiFailedSubmission], + }) + + expect(phase1Results) + .toHaveLength(1) + expect(phase1Results[0].id) + .toBe('submission-1') + + expect(phase2Results) + .toHaveLength(1) + expect(phase2Results[0].id) + .toBe('submission-2') + }) + + it( + 'skips iterative phases that already have assigned reviews when mapping phase-less AI-failed submissions', + () => { + const multiIterativePhases: BackendPhase[] = [ + createPhase('submission-1', 'Submission'), + createPhase('iterative-1', 'Iterative Review', 'iterative-phase-1'), + createPhase('iterative-2', 'Iterative Review', 'iterative-phase-2'), + createPhase('review-1', 'Review', 'review-phase-1'), + ] + + const iterativeReviewer = createResource('iterative-resource-1', 'Iterative Reviewer') + const assignedSubmission: SubmissionInfo = { + ...createSubmission('assigned-reviewer', 'iterative-phase-1'), + id: 'assigned-submission', + } + + const aiFailedSubmission: SubmissionInfo = { + ...createSubmission(iterativeReviewer.id), + id: 'ai-failed-submission', + status: 'AI_FAILED_REVIEW', + submittedDate: '2026-04-01T04:56:13.405Z', + } + + const phase1Results = filterIterativeReviewRows({ + challengePhases: multiIterativePhases, + isPostMortemPhase: false, + phaseIdFilter: 'iterative-1', + reviewerResources: [iterativeReviewer], + sourceRows: [assignedSubmission, aiFailedSubmission], + }) + + const phase2Results = filterIterativeReviewRows({ + challengePhases: multiIterativePhases, + isPostMortemPhase: false, + phaseIdFilter: 'iterative-2', + reviewerResources: [iterativeReviewer], + sourceRows: [assignedSubmission, aiFailedSubmission], + }) + + expect(phase1Results) + .toHaveLength(1) + expect(phase1Results[0].id) + .toBe('assigned-submission') + + expect(phase2Results) + .toHaveLength(1) + expect(phase2Results[0].id) + .toBe('ai-failed-submission') + }, + ) + + it('keeps a phase-less AI-failed submission when only one iterative phase exists', () => { + const iterativeReviewer = createResource('iterative-resource-1', 'Iterative Reviewer') + const aiFailedSubmission: SubmissionInfo = { + ...createSubmission(iterativeReviewer.id), + status: 'AI_FAILED_REVIEW', + } + + const results = filterIterativeReviewRows({ + challengePhases, + isPostMortemPhase: false, + phaseIdFilter: 'iterative-1', + reviewerResources: [iterativeReviewer], + sourceRows: [aiFailedSubmission], + }) + + expect(results) + .toHaveLength(1) + expect(results[0].id) + .toBe(`submission-${iterativeReviewer.id}`) + }) + it('limits completed F2F rows to the supplied winning submission ids', () => { const iterativeReviewer = createResource('iterative-resource-1', 'Iterative Reviewer') const losingReviewer = createResource('iterative-resource-2', 'Iterative Reviewer') diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts index 64940ae67..64c26f29e 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts @@ -142,6 +142,167 @@ function countIterativeReviewPhases(challengePhases: BackendPhase[] | undefined) .includes('iterative review')).length } +interface OrderedIterativePhase { + id: string + phaseTypeId?: string +} + +function getOrderedIterativePhases(challengePhases: BackendPhase[] | undefined): OrderedIterativePhase[] { + const iterativePhases = (challengePhases ?? []) + .map((phase, index) => ({ + id: normalizeIdentifier(phase.id), + index, + phaseTypeId: normalizeIdentifier(phase.phaseId), + startedAt: parseSortableDate(phase.actualStartDate ?? phase.scheduledStartDate), + })) + .filter(phase => Boolean(phase.id)) + .filter(phase => (challengePhases?.[phase.index].name ?? '') + .toLowerCase() + .includes('iterative review')) + .sort((left, right) => { + const leftStartedAt = Number.isFinite(left.startedAt) + ? left.startedAt + : Number.POSITIVE_INFINITY + const rightStartedAt = Number.isFinite(right.startedAt) + ? right.startedAt + : Number.POSITIVE_INFINITY + + if (leftStartedAt !== rightStartedAt) { + return leftStartedAt - rightStartedAt + } + + return left.index - right.index + }) + + return iterativePhases + .map(phase => ({ + id: phase.id, + phaseTypeId: phase.phaseTypeId, + } as OrderedIterativePhase)) + .filter((phase): phase is OrderedIterativePhase => Boolean(phase.id)) +} + +function getAiFailedSubmissionIdsForSelectedIterativePhase( + sourceRows: SubmissionInfo[], + challengePhases: BackendPhase[] | undefined, + phaseIdFilterSet: Set, + aiReviewDecisionsBySubmissionId?: Record, +): Set { + const orderedIterativePhases = getOrderedIterativePhases(challengePhases) + if (!orderedIterativePhases.length) { + return new Set() + } + + const assignedIterativePhaseIds = new Set() + sourceRows.forEach(submission => { + const reviewPhaseId = normalizeIdentifier(submission.review?.phaseId) + if (!reviewPhaseId) { + return + } + + const matchedByPhaseId = orderedIterativePhases.find(phase => phase.id === reviewPhaseId) + if (matchedByPhaseId) { + assignedIterativePhaseIds.add(matchedByPhaseId.id) + return + } + + const matchedByPhaseTypeId = orderedIterativePhases.filter( + phase => phase.phaseTypeId === reviewPhaseId, + ) + if (matchedByPhaseTypeId.length === 1) { + assignedIterativePhaseIds.add(matchedByPhaseTypeId[0].id) + } + }) + + const unassignedIterativePhaseIds = orderedIterativePhases + .map(phase => phase.id) + .filter(phaseId => !assignedIterativePhaseIds.has(phaseId)) + + if (!unassignedIterativePhaseIds.length) { + return new Set() + } + + const selectedPhase = orderedIterativePhases.find(phase => phaseIdFilterSet.has(phase.id)) + ?? (() => { + const matchedByPhaseTypeId = orderedIterativePhases.filter( + phase => Boolean(phase.phaseTypeId && phaseIdFilterSet.has(phase.phaseTypeId)), + ) + + if (matchedByPhaseTypeId.length === 1) { + return matchedByPhaseTypeId[0] + } + + return undefined + })() + + const aiFailedRows = sourceRows + .filter(submission => !normalizeIdentifier(submission.review?.phaseId)) + .filter(submission => shouldTreatAsAiFailedSubmission(submission, aiReviewDecisionsBySubmissionId)) + .map((submission, index) => ({ + index, + reviewCreatedAt: parseSortableDate(submission.review?.createdAt), + submission, + submittedAt: parseSortableDate(submission.submittedDate), + })) + .sort((left, right) => { + const leftSubmittedAt = Number.isFinite(left.submittedAt) + ? left.submittedAt + : Number.POSITIVE_INFINITY + const rightSubmittedAt = Number.isFinite(right.submittedAt) + ? right.submittedAt + : Number.POSITIVE_INFINITY + + if (leftSubmittedAt !== rightSubmittedAt) { + return leftSubmittedAt - rightSubmittedAt + } + + const leftReviewCreatedAt = Number.isFinite(left.reviewCreatedAt) + ? left.reviewCreatedAt + : Number.POSITIVE_INFINITY + const rightReviewCreatedAt = Number.isFinite(right.reviewCreatedAt) + ? right.reviewCreatedAt + : Number.POSITIVE_INFINITY + + if (leftReviewCreatedAt !== rightReviewCreatedAt) { + return leftReviewCreatedAt - rightReviewCreatedAt + } + + return left.index - right.index + }) + + if (!aiFailedRows.length) { + return new Set() + } + + if (!selectedPhase) { + return new Set() + } + + const selectedPhaseIndex = unassignedIterativePhaseIds.findIndex(phaseId => phaseId === selectedPhase.id) + if (selectedPhaseIndex < 0) { + return new Set() + } + + if (unassignedIterativePhaseIds.length === 1) { + return new Set( + aiFailedRows + .map(row => normalizeIdentifier(row.submission.id)) + .filter((id): id is string => Boolean(id)), + ) + } + + const isLastIterativePhase = selectedPhaseIndex === unassignedIterativePhaseIds.length - 1 + const assignedRows = isLastIterativePhase + ? aiFailedRows.slice(selectedPhaseIndex) + : aiFailedRows.slice(selectedPhaseIndex, selectedPhaseIndex + 1) + + return new Set( + assignedRows + .map(row => normalizeIdentifier(row.submission.id)) + .filter((id): id is string => Boolean(id)), + ) +} + /** * Collect resource ids assigned to iterative-review roles. * @@ -288,6 +449,13 @@ export function filterIterativeReviewRows(args: FilterIterativeReviewRowsArgs): const iterativeReviewerResourceIds = collectIterativeReviewerResourceIds(reviewerResources) if (phaseIdFilterSet?.size) { + const aiFailedSubmissionIdsForSelectedPhase = getAiFailedSubmissionIdsForSelectedIterativePhase( + sourceRows, + challengePhases, + phaseIdFilterSet, + aiReviewDecisionsBySubmissionId, + ) + const filteredRows = sourceRows.filter(submission => { const reviewPhaseId = normalizeIdentifier(submission.review?.phaseId) if (reviewPhaseId) { @@ -295,7 +463,8 @@ export function filterIterativeReviewRows(args: FilterIterativeReviewRowsArgs): } if (shouldTreatAsAiFailedSubmission(submission, aiReviewDecisionsBySubmissionId)) { - return true + const submissionId = normalizeIdentifier(submission.id) + return submissionId ? aiFailedSubmissionIdsForSelectedPhase.has(submissionId) : false } // New WM F2F flows can surface assigned submissions before the review row has a phase id. diff --git a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx index 326e86db1..084022e09 100644 --- a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx +++ b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx @@ -26,7 +26,7 @@ interface CollapsibleAiReviewsRowProps { export function normalizeDecisionStatus( status?: AiReviewDecisionStatus, -): 'passed' | 'failed-score' | 'pending' | 'failed' { +): 'passed' | 'failed-score' | 'pending' | 'failed' | 'human-override' { if (!status || status === 'PENDING') { return 'pending' } @@ -43,6 +43,10 @@ export function normalizeDecisionStatus( return 'failed' } + if (status === 'HUMAN_OVERRIDE') { + return 'human-override' + } + return 'pending' } diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx index a9bf48993..0aa841c61 100644 --- a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx +++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx @@ -33,7 +33,7 @@ const AiFeedback: FC = props => { const commentsArr: any[] = (feedback?.comments) || [] const onShowReply = useCallback(() => { - setShowReply(!showReply) + setShowReply(prevShowReply => !prevShowReply) }, []) const onSubmitReply = useCallback(async (content: string) => { @@ -87,9 +87,13 @@ const AiFeedback: FC = props => { + {commentsArr.length > 0 && ( + + )} + { showReply && ( = props => { /> ) } - - {commentsArr.length > 0 && ( - - )} ) } diff --git a/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx b/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx index 4f0607625..52beb361c 100644 --- a/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx +++ b/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx @@ -104,7 +104,7 @@ function formatScore(value?: number | null): string { function normalizeDecisionStatus( status?: string | null, -): 'passed' | 'failed-score' | 'pending' | 'failed' { +): 'passed' | 'failed-score' | 'pending' | 'failed' | 'human-override' { if (!status || status === 'PENDING') { return 'pending' } @@ -121,6 +121,10 @@ function normalizeDecisionStatus( return 'failed' } + if (status === 'HUMAN_OVERRIDE') { + return 'human-override' + } + return 'pending' } diff --git a/src/apps/review/src/lib/hooks/useFetchAppealQueue.ts b/src/apps/review/src/lib/hooks/useFetchAppealQueue.ts index e07cfbaff..016a68832 100644 --- a/src/apps/review/src/lib/hooks/useFetchAppealQueue.ts +++ b/src/apps/review/src/lib/hooks/useFetchAppealQueue.ts @@ -7,7 +7,7 @@ import { filter } from 'lodash' import { MappingReviewAppeal, } from '../models' -import { fetchAppealsWithReviewId } from '../services' +import { fetchAllAppealsWithReviewIds } from '../services' export interface useFetchAppealQueueProps { mappingReviewAppeal: MappingReviewAppeal // from review id to appeal info @@ -35,9 +35,11 @@ export function useFetchAppealQueue(): useFetchAppealQueueProps { return } - const nextId = idLoadQueue.current[0] - idLoadQueue.current = idLoadQueue.current.slice(1) - if (mappingReviewAppealRef.current[nextId]) { + const nextIds = Array.from(new Set(idLoadQueue.current)) + .filter(id => !mappingReviewAppealRef.current[id]) + idLoadQueue.current = [] + + if (!nextIds.length) { fetchNextDataInQueue() return } @@ -49,24 +51,29 @@ export function useFetchAppealQueue(): useFetchAppealQueueProps { } const fetchDataFail = (): void => { - mappingReviewAppealRef.current[nextId] = { - finishAppeals: 0, - totalAppeals: 0, - } + nextIds.forEach(id => { + mappingReviewAppealRef.current[id] = { + finishAppeals: 0, + totalAppeals: 0, + } + }) setMappingReviewAppeal({ ...mappingReviewAppealRef.current, }) finish() } - // Fetch appeal datas - fetchAppealsWithReviewId(1, 100, nextId) + fetchAllAppealsWithReviewIds(nextIds) .then(res => { - mappingReviewAppealRef.current[nextId] = { - finishAppeals: filter(res, item => !!item.appealResponse) - .length, - totalAppeals: res.length, - } + nextIds.forEach(id => { + const reviewAppeals = res.filter(item => item.reviewId === id) + + mappingReviewAppealRef.current[id] = { + finishAppeals: filter(reviewAppeals, item => !!item.appealResponse) + .length, + totalAppeals: reviewAppeals.length, + } + }) setMappingReviewAppeal({ ...mappingReviewAppealRef.current, }) diff --git a/src/apps/review/src/lib/models/AppealInfo.model.ts b/src/apps/review/src/lib/models/AppealInfo.model.ts index db6aaf197..b516e048a 100644 --- a/src/apps/review/src/lib/models/AppealInfo.model.ts +++ b/src/apps/review/src/lib/models/AppealInfo.model.ts @@ -5,6 +5,7 @@ import { BackendAppealResponse } from './BackendAppealResponse.model' */ export interface AppealInfo { id: string + reviewId?: string reviewItemCommentId: string content: string appealResponse?: BackendAppealResponse diff --git a/src/apps/review/src/lib/models/BackendAppeal.model.ts b/src/apps/review/src/lib/models/BackendAppeal.model.ts index 45d043294..39f63033f 100644 --- a/src/apps/review/src/lib/models/BackendAppeal.model.ts +++ b/src/apps/review/src/lib/models/BackendAppeal.model.ts @@ -12,6 +12,7 @@ export interface BackendAppealBase { export interface BackendAppeal extends BackendAppealBase { resourceId: string id: string + reviewId?: string appealResponse?: BackendAppealResponse createdAt: string createdBy: string @@ -30,6 +31,7 @@ export function convertBackendAppeal(data: BackendAppeal): AppealInfo { appealResponse: data.appealResponse, content: data.content, id: data.id, + reviewId: data.reviewId, reviewItemCommentId: data.reviewItemCommentId, } } diff --git a/src/apps/review/src/lib/services/reviews.service.ts b/src/apps/review/src/lib/services/reviews.service.ts index 00debc4cb..9ee2995b0 100644 --- a/src/apps/review/src/lib/services/reviews.service.ts +++ b/src/apps/review/src/lib/services/reviews.service.ts @@ -455,20 +455,33 @@ export const fetchAllChallengeReviews = async ( * @param resourceId resource id * @returns resolves to the array of appeals */ -export const fetchAppeals = async ( +const fetchAppealsPage = async ( page: number, perPage: number, - resourceId: string, -): Promise => { - const results = await xhrGetAsync< - BackendResponseWithMeta - >( + filters: { + resourceId?: string + reviewId?: string + reviewIds?: string[] + challengeId?: string + }, +): Promise> => ( + xhrGetAsync>( `${EnvironmentConfig.API.V6}/appeals?${qs.stringify({ page, perPage, - resourceId, + ...filters, + }, { + arrayFormat: 'comma', })}`, ) +) + +export const fetchAppeals = async ( + page: number, + perPage: number, + resourceId: string, +): Promise => { + const results = await fetchAppealsPage(page, perPage, { resourceId }) return results.data.map(convertBackendAppeal) } @@ -484,19 +497,68 @@ export const fetchAppealsWithReviewId = async ( page: number, perPage: number, reviewId: string, +): Promise => fetchAppealsPage(page, perPage, { reviewId }) + .then(results => results.data.map(convertBackendAppeal)) + +/** + * Fetch appeals with review ids + * + * @param page current page + * @param perPage number of item per page + * @param reviewIds review ids + * @returns resolves to the array of appeals + */ +export const fetchAppealsWithReviewIds = async ( + page: number, + perPage: number, + reviewIds: string[], ): Promise => { - const results = await xhrGetAsync< - BackendResponseWithMeta - >( - `${EnvironmentConfig.API.V6}/appeals?${qs.stringify({ - page, - perPage, - reviewId, - })}`, - ) + const results = await fetchAppealsPage(page, perPage, { reviewIds }) return results.data.map(convertBackendAppeal) } +/** + * Fetch all appeals with review ids using API pagination metadata. + * + * @param reviewIds review ids + * @param perPage number of items per page + * @returns resolves to the array of appeals + */ +export const fetchAllAppealsWithReviewIds = async ( + reviewIds: string[], + perPage = 500, +): Promise => { + if (!reviewIds.length) { + return [] + } + + const safePerPage = Number.isFinite(perPage) && perPage > 0 ? perPage : 500 + const firstPage = await fetchAppealsPage(1, safePerPage, { reviewIds }) + const combined = [...firstPage.data] + const totalPages = Math.max(firstPage.meta?.totalPages ?? 1, 1) + + const fetchRemainingPages = async ( + page: number, + currentTotal: number, + ): Promise => { + if (page > currentTotal) { + return + } + + const nextPage = await fetchAppealsPage(page, safePerPage, { reviewIds }) + combined.push(...nextPage.data) + const nextTotal = typeof nextPage.meta?.totalPages === 'number' + ? Math.max(nextPage.meta.totalPages, currentTotal) + : currentTotal + + await fetchRemainingPages(page + 1, nextTotal) + } + + await fetchRemainingPages(2, totalPages) + + return combined.map(convertBackendAppeal) +} + /** * Create review * diff --git a/src/apps/review/src/lib/utils/challenge.spec.ts b/src/apps/review/src/lib/utils/challenge.spec.ts index 8b4366a94..64ee4197d 100644 --- a/src/apps/review/src/lib/utils/challenge.spec.ts +++ b/src/apps/review/src/lib/utils/challenge.spec.ts @@ -230,6 +230,29 @@ describe('challenge phase tab helpers', () => { .toBe(false) }) + it('force-shows winners for past challenges with winners even when a phase remains open', () => { + expect(shouldAllowWinnersTabForPastChallenge({ + phases: [ + createBackendPhase('iterative-1', 'Iterative Review', '2026-04-20T00:00:00Z', { + isOpen: true, + }), + ], + status: 'COMPLETED', + })) + .toBe(false) + + expect(shouldForceWinnersTabForPastChallenge({ + phases: [ + createBackendPhase('iterative-1', 'Iterative Review', '2026-04-20T00:00:00Z', { + isOpen: true, + }), + ], + status: 'COMPLETED', + winners: [{ handle: 'winner-one', placement: 1, userId: 1 }], + })) + .toBe(true) + }) + it('keeps winners hidden when a follow-up approval review is still pending', () => { const challengeInfo = { phases: [ diff --git a/src/apps/review/src/lib/utils/challenge.ts b/src/apps/review/src/lib/utils/challenge.ts index 44aa240e8..a4e45352f 100644 --- a/src/apps/review/src/lib/utils/challenge.ts +++ b/src/apps/review/src/lib/utils/challenge.ts @@ -743,11 +743,15 @@ export function shouldForceWinnersTabForPastChallenge( challengeInfo?: WinnersTabFallbackChallengeInfo, approvalReviews?: ApprovalReviewStatusLike[] | null, ): boolean { + if (!isPastChallengeStatus(challengeInfo?.status)) { + return false + } + if (!(challengeInfo?.winners?.length)) { return false } - return shouldAllowWinnersTabForPastChallenge(challengeInfo, approvalReviews) + return !hasPendingApprovalReview(approvalReviews) } export function isReviewPhaseCurrentlyOpen( diff --git a/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.tsx b/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.tsx index c3ce57b3f..df0f254e9 100644 --- a/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.tsx +++ b/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.tsx @@ -252,6 +252,13 @@ const ReviewsSidebar: FC = props => { status='pending' /> +
  • + } + label='Unlocked' + status='human-override' + /> +
  • = (props: PaymentsListViewProp const restrictedDefaultStatus = isEngagementApproverView ? restrictedRoleDefaultStatus : undefined const isRestrictedApproverView = isEngagementApproverView const [filters, setFilters] = React.useState>({}) - const hasSelectedStatusFilter = (filters.status?.length ?? 0) > 0 + + // Remove the old hasSelectedStatusFilter declaration as we handle it directly below + const appliedFilters = React.useMemo>(() => { + // Strip 'all' sentinel values — never forward them to the API + const activeFilters = Object.fromEntries( + Object.entries(filters) + .filter(([, v]) => v.length > 0 && v[0] !== 'all'), + ) + if (!restrictedCategory) { - return filters + return activeFilters + } + + // Determine the correct status filter to append + let statusFilter: Record = {} + + if (filters.status && filters.status[0] !== 'all') { + // 1. User explicitly chose a specific status (e.g. 'OWED') + statusFilter = { status: activeFilters.status } + } else if (!filters.status && restrictedDefaultStatus) { + // 2. Initial load (filters.status is undefined), apply restricted default + statusFilter = { status: [restrictedDefaultStatus] } } + // 3. If user explicitly selected 'all' (filters.status[0] === 'all'), + // statusFilter remains empty, allowing the API to return all statuses. return { - ...filters, + ...activeFilters, category: [restrictedCategory], - ...(hasSelectedStatusFilter - ? { status: filters.status } - : (restrictedDefaultStatus ? { status: [restrictedDefaultStatus] } : {})), + ...statusFilter, } - }, [filters, hasSelectedStatusFilter, restrictedCategory, restrictedDefaultStatus]) + }, [filters, restrictedCategory, restrictedDefaultStatus]) + const hasActiveFilters = React.useMemo( () => Object.entries(appliedFilters) .some(([key, value]) => key !== 'category' && value.length > 0), @@ -217,26 +237,31 @@ const PaymentsListView: FC = (props: PaymentsListViewProp return {} as Record } - const statusOverride = filters.status?.[0] ?? restrictedDefaultStatus + // Reflect the user's explicit status choice in the dropdown display. + // Do not inject restrictedDefaultStatus here — it applies to the API query + // via appliedFilters but must not override the dropdown's "All" default. + const statusOverride = filters.status?.[0] !== 'all' ? filters.status?.[0] : undefined return { category: restrictedCategory, ...(statusOverride ? { status: statusOverride } : {}), } - }, [filters.status, restrictedCategory, restrictedDefaultStatus]) + }, [filters.status, restrictedCategory]) const defaultDropdownValues = React.useMemo>(() => { const defaults: Record = {} if (!restrictedCategory) { - defaults.status = filters.status?.[0] ?? 'all' defaults.category = filters.category?.[0] ?? 'all' } defaults.date = filters.date?.[0] ?? 'all' + // Fall back to the restricted default if no filter is applied + defaults.status = filters.status?.[0] ?? (restrictedDefaultStatus || 'all') + return defaults - }, [filters.category, filters.date, filters.status, restrictedCategory]) + }, [filters.category, filters.date, filters.status, restrictedCategory, restrictedDefaultStatus]) const [pagination, setPagination] = React.useState({ currentPage: 1, pageSize: defaultPageSize, @@ -703,20 +728,11 @@ const PaymentsListView: FC = (props: PaymentsListViewProp } setPagination(newPagination) - /* setFilters({ - ...filters, - [key]: value, - }) */ - setFilters(prev => { - const newFilters = { ...prev } - if (value[0] === 'all') { - delete newFilters[key] - } else { - newFilters[key] = value - } - return newFilters - }) + setFilters(prev => ({ + ...prev, + [key]: value, // store 'all' explicitly; appliedFilters strips it before the API call + })) setSelectedPayments({}) }} onResetFilters={() => { diff --git a/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.spec.tsx b/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.spec.tsx new file mode 100644 index 000000000..e6b9fdbad --- /dev/null +++ b/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.spec.tsx @@ -0,0 +1,75 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { render } from '@testing-library/react' + +import AcceptApplicationModal from './AcceptApplicationModal' + +const mockStartDateTimeInput = jest.fn((props: { label: string }): JSX.Element => ( +
    {props.label}
    +)) + +jest.mock('~/libs/ui', () => ({ + BaseModal: (props: { + buttons?: JSX.Element + children: JSX.Element + open: boolean + }): JSX.Element => ( + props.open ? ( +
    + {props.children} + {props.buttons} +
    + ) : <> + ), + Button: (props: { + disabled?: boolean + label: string + onClick?: () => void + }): JSX.Element => ( + + ), +}), { + virtual: true, +}) + +jest.mock('../form', () => ({ + StartDateTimeInput: (props: { label: string }): JSX.Element => mockStartDateTimeInput(props), +})) + +jest.mock('../../utils', () => ({ + calculateAssignmentRatePerWeek: jest.fn(() => ''), + sanitizePositiveNumericInput: jest.fn((value: string) => value), + serializeTentativeAssignmentDate: jest.fn((value: Date) => value.toISOString()), + toPositiveInteger: jest.fn(() => 1), + toPositiveNumber: jest.fn(() => 1), + toPositiveNumberWithMaxDecimalPlaces: jest.fn(() => 1), +})) + +describe('AcceptApplicationModal', () => { + beforeEach(() => { + mockStartDateTimeInput.mockClear() + }) + + it('does not restrict the billing start date to today or later', () => { + render( + , + ) + + const startDateTimeInputProps = mockStartDateTimeInput + .mock.calls[mockStartDateTimeInput.mock.calls.length - 1][0] as { + label: string + minDate?: Date | null + } + + expect(startDateTimeInputProps.label) + .toBe('Billing start date') + expect(startDateTimeInputProps.minDate) + .toBeUndefined() + }) +}) diff --git a/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.tsx b/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.tsx index 79a3b9c3e..b5f76f83c 100644 --- a/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.tsx +++ b/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.tsx @@ -65,7 +65,6 @@ const AcceptApplicationModal: FC = ( const isSubmitting = props.isSubmitting === true - const minStartDate = useMemo(() => new Date(), []) const timezone = useMemo( () => Intl.DateTimeFormat() .resolvedOptions() @@ -191,7 +190,6 @@ const AcceptApplicationModal: FC = ( ({ + rootRoute: '/work', +})) + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + API: { + V6: 'https://example.com/v6', + }, + }, +}), { + virtual: true, +}) + +jest.mock('~/libs/core', () => ({ + xhrGetAsync: jest.fn(), +}), { + virtual: true, +}) + +jest.mock('~/libs/ui', () => ({ + Button: (props: { + label: string + onClick: () => void + }): JSX.Element => ( + + ), + IconOutline: { + LockClosedIcon: (): JSX.Element => locked, + XIcon: (): JSX.Element => close, + }, + IconSolid: { + CheckCircleIcon: (): JSX.Element => consumed, + ChevronDownIcon: (): JSX.Element => sort-desc, + ChevronUpIcon: (): JSX.Element => sort-asc, + }, +}), { + virtual: true, +}) + +const baseBillingAccountDetails: BillingAccountDetails = { + budget: 1000, + consumedAmounts: [], + consumedBudget: 0, + id: 80001063, + lockedAmounts: [], + lockedBudget: 0, + name: 'Platform Dev - One', + totalBudgetRemaining: 1000, +} + +function renderModal(billingAccountDetails: BillingAccountDetails): ReturnType { + return render( + , + ) +} + +describe('BillingAccountLineItemsModal', () => { + it('builds challenge links under the work root for path-based deployments', () => { + renderModal({ + ...baseBillingAccountDetails, + lockedAmounts: [ + { + amount: '125.25', + date: '2026-02-10T00:00:00.000Z', + externalId: 'challenge / 100', + externalName: 'Canonical Challenge', + externalType: 'CHALLENGE', + }, + ], + lockedBudget: 125.25, + totalBudgetRemaining: 874.75, + }) + + const challengeLink = screen.getByRole('link', { + name: 'Canonical Challenge', + }) + + expect(challengeLink.getAttribute('href')) + .toBe('/work/challenges/challenge%20%2F%20100') + }) + + it('renders legacy-only challenge rows as plain text', () => { + renderModal({ + ...baseBillingAccountDetails, + lockedAmounts: [ + { + amount: '125.25', + challengeId: 'legacy-challenge-100', + date: '2026-02-10T00:00:00.000Z', + externalName: 'Legacy Challenge', + externalType: 'CHALLENGE', + }, + ], + lockedBudget: 125.25, + totalBudgetRemaining: 874.75, + }) + + expect(screen.getByText('Legacy Challenge')) + .toBeTruthy() + expect(screen.queryByRole('link', { + name: 'Legacy Challenge', + })) + .toBeNull() + }) + + it('renders ISO midnight entry dates without local timezone shifts', () => { + renderModal({ + ...baseBillingAccountDetails, + lockedAmounts: [ + { + amount: '125.25', + date: '2026-02-10T00:00:00.000Z', + externalId: 'challenge-100', + externalName: 'Date Stable Challenge', + externalType: 'CHALLENGE', + }, + ], + lockedBudget: 125.25, + totalBudgetRemaining: 874.75, + }) + + expect(screen.getByText('2026-02-10')) + .toBeTruthy() + }) +}) diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx new file mode 100644 index 000000000..7c5ff6b40 --- /dev/null +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx @@ -0,0 +1,317 @@ +import { + FC, + MouseEvent, + useCallback, + useMemo, + useState, +} from 'react' + +import { + Button, + IconOutline, + IconSolid, +} from '~/libs/ui' + +import { rootRoute } from '../../../config/routes.config' +import { + BillingAccountDetails, + BillingAccountLineItem, + combineBillingAccountLineItems, +} from '../../services/billing-accounts.service' + +import styles from './BillingAccountLineItemsModal.module.scss' + +type SortField = 'amount' | 'status' | 'date' +type SortOrder = 'asc' | 'desc' + +const EXTERNAL_TYPE_LABELS: Record = { + CHALLENGE: 'Challenge', + ENGAGEMENT: 'Engagement', +} + +export interface BillingAccountLineItemsModalProps { + billingAccountDetails: BillingAccountDetails + onClose: () => void +} + +function formatCurrency(amount: number): string { + return new Intl.NumberFormat('en-US', { + currency: 'USD', + maximumFractionDigits: 2, + minimumFractionDigits: 2, + style: 'currency', + }) + .format(amount) +} + +function formatDate(dateString: string): string { + const isoDateMatch = dateString.match(/^(\d{4}-\d{2}-\d{2})/) + + if (isoDateMatch) { + return isoDateMatch[1] + } + + const date = new Date(dateString) + const year = date.getUTCFullYear() + const month = String(date.getUTCMonth() + 1) + .padStart(2, '0') + const day = String(date.getUTCDate()) + .padStart(2, '0') + return `${year}-${month}-${day}` +} + +function compareByAmount(a: BillingAccountLineItem, b: BillingAccountLineItem): number { + return a.amount - b.amount +} + +function compareByStatus(a: BillingAccountLineItem, b: BillingAccountLineItem): number { + return a.status.localeCompare(b.status) +} + +function compareByDate(a: BillingAccountLineItem, b: BillingAccountLineItem): number { + const dateA = new Date(a.date) + const dateB = new Date(b.date) + return dateA.getTime() - dateB.getTime() +} + +function sortLineItems( + items: BillingAccountLineItem[], + sortBy: SortField, + sortOrder: SortOrder, +): BillingAccountLineItem[] { + return [...items].sort((a, b) => { + let comparison = 0 + + switch (sortBy) { + case 'amount': + comparison = compareByAmount(a, b) + break + case 'status': + comparison = compareByStatus(a, b) + break + case 'date': + comparison = compareByDate(a, b) + break + default: + comparison = 0 + } + + return sortOrder === 'asc' ? comparison : -comparison + }) +} + +function buildChallengeUrl(externalId: string): string { + const basePath = rootRoute.replace(/\/$/, '') + return `${basePath}/challenges/${encodeURIComponent(externalId)}` +} + +export const BillingAccountLineItemsModal: FC = ( + props: BillingAccountLineItemsModalProps, +) => { + const [sortBy, setSortBy] = useState('date') + const [sortOrder, setSortOrder] = useState('desc') + + const lineItems = useMemo( + () => combineBillingAccountLineItems(props.billingAccountDetails), + [props.billingAccountDetails], + ) + + const sortedLineItems = useMemo( + () => sortLineItems(lineItems, sortBy, sortOrder), + [lineItems, sortBy, sortOrder], + ) + + const handleContainerClick = useCallback( + (event: MouseEvent): void => { + event.stopPropagation() + }, + [], + ) + + const handleSortAmount = useCallback((): void => { + if (sortBy === 'amount') { + setSortOrder(current => (current === 'asc' ? 'desc' : 'asc')) + } else { + setSortBy('amount') + setSortOrder('desc') + } + }, [sortBy]) + + const handleSortStatus = useCallback((): void => { + if (sortBy === 'status') { + setSortOrder(current => (current === 'asc' ? 'desc' : 'asc')) + } else { + setSortBy('status') + setSortOrder('desc') + } + }, [sortBy]) + + const handleSortDate = useCallback((): void => { + if (sortBy === 'date') { + setSortOrder(current => (current === 'asc' ? 'desc' : 'asc')) + } else { + setSortBy('date') + setSortOrder('desc') + } + }, [sortBy]) + + const renderSortIcon = useCallback((field: SortField): JSX.Element | undefined => { + if (field !== sortBy) { + return undefined + } + + return sortOrder === 'asc' + ? + : + }, [sortBy, sortOrder]) + + return ( +
    +
    +
    +

    Billing Account Details

    + +
    + +
    +
    + Locked + + {formatCurrency(props.billingAccountDetails.lockedBudget)} + +
    +
    + Consumed + + {formatCurrency(props.billingAccountDetails.consumedBudget)} + +
    +
    + Remaining + + {formatCurrency(props.billingAccountDetails.totalBudgetRemaining)} + +
    +
    + +
    + {sortedLineItems.length === 0 ? ( +
    + No line items found for this billing account. +
    + ) : ( + + + + + + + + + + + + {sortedLineItems.map(item => { + const displayName = item.externalName || '-' + const challengeUrl = item.externalType === 'CHALLENGE' && item.externalId + ? buildChallengeUrl(item.externalId) + : undefined + + return ( + + + + + + + + ) + })} + +
    + + + + TypeName + +
    {formatCurrency(item.amount)} + + {item.status === 'locked' ? ( + + ) : ( + + )} + {item.status === 'locked' ? 'Locked' : 'Consumed'} + + {EXTERNAL_TYPE_LABELS[item.externalType]} + {challengeUrl ? ( + + {displayName} + + ) : displayName} + {formatDate(item.date)}
    + )} +
    + +
    +
    +
    +
    + ) +} + +export default BillingAccountLineItemsModal diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/index.ts b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/index.ts new file mode 100644 index 000000000..6a622481a --- /dev/null +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/index.ts @@ -0,0 +1 @@ +export * from './BillingAccountLineItemsModal' diff --git a/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.module.scss b/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.module.scss index 23d891931..22ba8efb7 100644 --- a/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.module.scss +++ b/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.module.scss @@ -57,6 +57,16 @@ margin: 0; } +.helperText { + color: #5b5b5b; + font-size: 12px; + margin: 0; +} + +.helperLabel { + font-weight: 600; +} + .actions { display: flex; gap: 12px; diff --git a/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.spec.tsx b/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.spec.tsx index 0b341291f..c58fb5c39 100644 --- a/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.spec.tsx +++ b/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.spec.tsx @@ -1,5 +1,7 @@ /* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ -import { render } from '@testing-library/react' +import { + render, +} from '@testing-library/react' import type { Assignment } from '../../models' @@ -49,10 +51,6 @@ jest.mock('../../utils', () => ({ getAssignmentStandardHoursPerWeek: jest.fn(() => 40), })) -jest.mock('../../utils/payment.utils', () => ({ - formatCurrency: jest.fn((value: number) => `$${value.toFixed(2)}`), -})) - describe('PaymentFormModal', () => { const member: Assignment = { agreementRate: '821.20', diff --git a/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.tsx b/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.tsx index c60519bb8..b50141c4d 100644 --- a/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.tsx +++ b/src/apps/work/src/lib/components/PaymentFormModal/PaymentFormModal.tsx @@ -25,7 +25,10 @@ import { getAssignmentRatePerHour, getAssignmentStandardHoursPerWeek, } from '../../utils' -import { formatCurrency } from '../../utils/payment.utils' +import { + calculatePaymentChallengeFee, + formatCurrency, +} from '../../utils/payment.utils' import styles from './PaymentFormModal.module.scss' @@ -38,6 +41,7 @@ export interface PaymentFormData { interface PaymentFormModalProps { billingAccountId?: number | string + billingAccountMarkup?: number engagementName?: string isSubmitting?: boolean member: Assignment | undefined @@ -151,6 +155,10 @@ const PaymentFormModal: FC = ( () => calculatePaymentAmount(hoursWorked, ratePerHour), [hoursWorked, ratePerHour], ) + const challengeFee = useMemo( + () => calculatePaymentChallengeFee(amount, props.billingAccountMarkup), + [amount, props.billingAccountMarkup], + ) const paymentTitle = useMemo( () => { if (!isSaturday(weekEndingDate)) { @@ -358,6 +366,15 @@ const PaymentFormModal: FC = ( ? '' : amount.toFixed(2)} /> + {challengeFee !== undefined + ? ( +

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

    + ) + : undefined}
    diff --git a/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.module.scss b/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.module.scss index c14487696..3d286cbf5 100644 --- a/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.module.scss +++ b/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.module.scss @@ -36,12 +36,24 @@ } .itemHeader { - align-items: center; + align-items: flex-start; display: flex; justify-content: space-between; margin-bottom: 8px; } +.amountBlock { + display: flex; + flex-direction: column; + gap: 4px; +} + +.amount { + color: #2a2a2a; + font-size: 16px; + font-weight: 700; +} + .status { background: #f3f5fb; border-radius: 999px; @@ -68,7 +80,7 @@ font-size: 12px; } -.paymentCreator { +.metaRow { color: #5b5b5b; display: flex; flex-wrap: wrap; @@ -76,7 +88,7 @@ gap: 4px; } -.paymentCreatorLabel { +.metaLabel { font-weight: 600; } diff --git a/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.spec.tsx b/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.spec.tsx index 2b459612c..622822f14 100644 --- a/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.spec.tsx +++ b/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.spec.tsx @@ -58,6 +58,13 @@ describe('PaymentHistoryModal', () => { }, createdAt: '2026-03-31T00:00:00.000Z', createdByHandle: 'payment.manager', + details: [ + { + challengeFee: 18.6, + grossAmount: 120, + totalAmount: 120, + }, + ], id: 'payment-1', title: 'Salesforce support', }, @@ -85,5 +92,9 @@ describe('PaymentHistoryModal', () => { .toBeTruthy() expect(screen.getByText('payment.manager')) .toBeTruthy() + expect(screen.getByText('Fee:')) + .toBeTruthy() + expect(screen.getByText('$18.60')) + .toBeTruthy() }) }) diff --git a/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.tsx b/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.tsx index b74d13f91..406db4486 100644 --- a/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.tsx +++ b/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.tsx @@ -11,6 +11,7 @@ import { import { formatCurrency, getPaymentAmount, + getPaymentChallengeFee, getPaymentCreatorLabel, getPaymentHoursWorked, getPaymentRemarks, @@ -86,6 +87,8 @@ const PaymentHistoryModal: FC = ( ? (
      {paymentsResult.payments.map((payment, index) => { + const paymentAmount = getPaymentAmount(payment) + const paymentChallengeFee = getPaymentChallengeFee(payment) const paymentStatus = getPaymentStatus(payment) const paymentHoursWorked = getPaymentHoursWorked(payment) const paymentRemarks = getPaymentRemarks(payment) @@ -102,7 +105,21 @@ const PaymentHistoryModal: FC = ( className={styles.item} >
      - {formatCurrency(getPaymentAmount(payment))} +
      + + {formatCurrency(paymentAmount)} + + {paymentChallengeFee !== undefined + ? ( +
      + + Fee: + + {formatCurrency(paymentChallengeFee)} +
      + ) + : undefined} +
      {showPaymentStatus ? {paymentStatus} : undefined} @@ -125,8 +142,8 @@ const PaymentHistoryModal: FC = (
      ) : undefined} -
      - +
      + Payment Creator: {paymentCreator || '-'} diff --git a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.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 + ? ( +
      + {props.billingAccountContent} +
      + ) + : undefined} {props.canEdit ? (
      diff --git a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.module.scss b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.module.scss index 2210caa3e..995b96452 100644 --- a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.module.scss +++ b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.module.scss @@ -33,6 +33,46 @@ gap: 12px; } +.billingAccountCell { + align-items: center; + display: inline-flex; + flex-wrap: wrap; + gap: 8px; +} + +.billingAccountLabel { + color: $black-80; +} + +.budgetDisplay { + background: #f2f4f7; + border-radius: 4px; + color: $black-80; + font-size: 12px; + font-weight: 600; + padding: 4px 8px; + white-space: nowrap; +} + +.infoButton { + align-items: center; + background: none; + border: none; + cursor: pointer; + display: inline-flex; + padding: 2px; +} + +.infoIcon { + color: #4a5568; + height: 18px; + width: 18px; +} + +.infoButton:hover .infoIcon { + color: #137d60; +} + .listView { display: none; gap: 12px; diff --git a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.spec.tsx b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.spec.tsx index 929fd0135..f247f35e6 100644 --- a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.spec.tsx +++ b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.spec.tsx @@ -1,20 +1,38 @@ /* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ import type { ReactNode } from 'react' import { + fireEvent, render, screen, } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' import type { Project } from '../../models' -import { useFetchBillingAccounts } from '../../hooks' +import type { BillingAccountDetails } from '../../services' +import { + useFetchBillingAccountDetails, + useFetchBillingAccounts, +} from '../../hooks' import { ProjectsTable } from './ProjectsTable' jest.mock('../../hooks', () => ({ + useFetchBillingAccountDetails: jest.fn(), useFetchBillingAccounts: jest.fn(), })) +jest.mock('../BillingAccountLineItemsModal', () => ({ + BillingAccountLineItemsModal: (props: { + billingAccountDetails: BillingAccountDetails + }): JSX.Element => ( +
      + Billing account details for + {' '} + {props.billingAccountDetails.id} +
      + ), +})) + jest.mock('../../constants', () => ({ PROJECT_STATUS: { DRAFT: 'draft', @@ -29,6 +47,9 @@ jest.mock('../../utils', () => ({ })) jest.mock('~/libs/ui', () => ({ + IconOutline: { + InformationCircleIcon: (): JSX.Element => info, + }, LoadingSpinner: () =>
      Loading
      , Table: (props: { columns: Array<{ @@ -57,8 +78,22 @@ jest.mock('../ProjectStatus', () => ({ ProjectStatus: () => Active, })) +const mockedUseFetchBillingAccountDetails = useFetchBillingAccountDetails as jest.MockedFunction< + typeof useFetchBillingAccountDetails +> const mockedUseFetchBillingAccounts = useFetchBillingAccounts as jest.MockedFunction +const billingAccountDetails: BillingAccountDetails = { + budget: 1000, + consumedAmounts: [], + consumedBudget: 225, + id: 80001063, + lockedAmounts: [], + lockedBudget: 125, + name: 'Access BA', + totalBudgetRemaining: 650, +} + describe('ProjectsTable', () => { const invitedProject: Project = { id: 100440, @@ -82,6 +117,12 @@ describe('ProjectsTable', () => { isError: false, isLoading: false, }) + mockedUseFetchBillingAccountDetails.mockReturnValue({ + billingAccountDetails: undefined, + error: undefined, + isError: false, + isLoading: false, + }) }) it('links the project name and open action to the challenges route', () => { @@ -103,4 +144,55 @@ describe('ProjectsTable', () => { .getAttribute('href')) .toBe('/projects/100440/challenges') }) + + it('shows project billing account spent totals and opens the line-item modal', () => { + mockedUseFetchBillingAccounts.mockReturnValue({ + billingAccounts: [ + { + budget: 1000, + consumedBudget: 225, + id: 80001063, + lockedBudget: 125, + name: 'Access BA', + totalBudgetRemaining: 650, + }, + ], + error: undefined, + isError: false, + isLoading: false, + }) + mockedUseFetchBillingAccountDetails.mockReturnValue({ + billingAccountDetails, + error: undefined, + isError: false, + isLoading: false, + }) + + render( + + + , + ) + + expect(screen.getAllByText('Access BA / 80001063').length) + .toBeGreaterThan(0) + expect(screen.getAllByText('$350 / $1,000 spent').length) + .toBeGreaterThan(0) + + fireEvent.click(screen.getAllByRole('button', { + name: 'View billing account details', + })[0]) + + expect(screen.getByRole('dialog') + .textContent) + .toContain('Billing account details for 80001063') + }) }) diff --git a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx index f4eddd831..ebcd6dc9c 100644 --- a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx +++ b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx @@ -2,26 +2,36 @@ import { FC, useCallback, useMemo, + useState, } from 'react' import { Link } from 'react-router-dom' import { Sort } from '~/apps/admin/src/platform/gamification-admin/src/game-lib' import { + IconOutline, LoadingSpinner, Table, TableColumn, } from '~/libs/ui' import { PROJECT_STATUS } from '../../constants' +import { + useFetchBillingAccountDetails, + useFetchBillingAccounts, +} from '../../hooks' +import type { + UseFetchBillingAccountDetailsResult, + UseFetchBillingAccountsResult, +} from '../../hooks' import { Project, ProjectStatusValue, } from '../../models' -import { useFetchBillingAccounts } from '../../hooks' -import type { UseFetchBillingAccountsResult } from '../../hooks' +import type { BillingAccount } from '../../services' import { buildProjectChallengesPath, } from '../../utils' +import { BillingAccountLineItemsModal } from '../BillingAccountLineItemsModal' import { ProjectCard } from '../ProjectCard' import { ProjectStatus } from '../ProjectStatus' @@ -29,6 +39,11 @@ import styles from './ProjectsTable.module.scss' type SortOrder = 'asc' | 'desc' +interface BillingBudgetInfo { + spent: number + totalBudget: number +} + const NOOP_CAN_EDIT_PROJECT = (): boolean => false const PROJECT_TYPE_LABEL_OVERRIDES: Readonly> = { @@ -89,15 +104,83 @@ function getProjectPath(project: Project): string { return buildProjectChallengesPath(project.id) } +/** + * Converts optional id or label values into trimmed display strings. + * + * @param value Raw value from a project or billing-account payload. + * @returns A trimmed string, or `undefined` when the value is blank. + */ +function normalizeOptionalString(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined + } + + const normalizedValue = String(value) + .trim() + + return normalizedValue || undefined +} + +/** + * Converts optional API numeric fields into finite numbers. + * + * @param value Raw budget field from the billing-account API. + * @returns A finite number, or `undefined` when the value is missing or invalid. + */ +function normalizeOptionalNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + + if (typeof value !== 'string') { + return undefined + } + + const normalizedValue = value.trim() + + if (!normalizedValue) { + return undefined + } + + const parsedValue = Number(normalizedValue) + + return Number.isFinite(parsedValue) + ? parsedValue + : undefined +} + +/** + * Formats budget amounts for compact project-list display. + * + * @param amount Dollar amount to format. + * @returns Whole-dollar USD currency text. + */ +function formatCurrency(amount: number): string { + return new Intl.NumberFormat('en-US', { + currency: 'USD', + maximumFractionDigits: 0, + minimumFractionDigits: 0, + style: 'currency', + }) + .format(amount) +} + +/** + * Builds the billing-account label for a project row. + * + * @param project Project summary from the projects API. + * @param billingAccount Matching billing-account summary from the billing API. + * @returns Name/id text, falling back to `-` when no account is available. + */ function getBillingAccountDisplay( project: Project, - billingAccountNames: Map, + billingAccount: BillingAccount | undefined, ): string { - const billingAccountId = project.billingAccountId !== undefined && project.billingAccountId !== null - ? String(project.billingAccountId) - .trim() - : '' - const billingAccountName = (project.billingAccountName || '').trim() || billingAccountNames.get(billingAccountId) + const billingAccountId = normalizeOptionalString(project.billingAccountId) + || normalizeOptionalString(billingAccount?.id) + || '' + const billingAccountName = normalizeOptionalString(project.billingAccountName) + || normalizeOptionalString(billingAccount?.name) if (!billingAccountId && !billingAccountName) { return '-' @@ -110,6 +193,111 @@ function getBillingAccountDisplay( return `${billingAccountName || 'Unknown'} / ${billingAccountId}` } +/** + * Resolves the spent/total budget values for a billing-account summary. + * + * @param billingAccount Matching billing-account summary from the list API. + * @returns Spent and total budget amounts, or `undefined` when budget data is incomplete. + */ +function getBillingAccountBudgetInfo( + billingAccount: BillingAccount | undefined, +): BillingBudgetInfo | undefined { + const totalBudget = normalizeOptionalNumber(billingAccount?.budget) + + if (totalBudget === undefined) { + return undefined + } + + const lockedBudget = normalizeOptionalNumber(billingAccount?.lockedBudget) + const consumedBudget = normalizeOptionalNumber(billingAccount?.consumedBudget) + const totalBudgetRemaining = normalizeOptionalNumber(billingAccount?.totalBudgetRemaining) + let spent: number | undefined + + if (lockedBudget !== undefined || consumedBudget !== undefined) { + spent = (lockedBudget || 0) + (consumedBudget || 0) + } else if (totalBudgetRemaining !== undefined) { + spent = totalBudget - totalBudgetRemaining + } + + return spent === undefined + ? undefined + : { + spent: Math.max(spent, 0), + totalBudget, + } +} + +interface ProjectBillingAccountCellProps { + billingAccount: BillingAccount | undefined + project: Project +} + +/** + * Renders a project billing-account summary and lazily loads the line-item + * modal only after the details button is opened. + * + * @param props Project row and matching billing-account summary from the list API. + * @returns Billing-account label, spent/total badge, and optional line-item modal. + */ +const ProjectBillingAccountCell: FC = ( + props: ProjectBillingAccountCellProps, +) => { + const [isModalOpen, setIsModalOpen] = useState(false) + const normalizedBillingAccountId = normalizeOptionalString(props.project.billingAccountId) + || normalizeOptionalString(props.billingAccount?.id) + const billingAccountDetailsResult: UseFetchBillingAccountDetailsResult = useFetchBillingAccountDetails( + isModalOpen ? normalizedBillingAccountId : undefined, + ) + const budgetInfo = getBillingAccountBudgetInfo(props.billingAccount) + + const handleOpenModal = useCallback((): void => { + setIsModalOpen(true) + }, []) + + const handleCloseModal = useCallback((): void => { + setIsModalOpen(false) + }, []) + + return ( +
      + + {getBillingAccountDisplay(props.project, props.billingAccount)} + + {budgetInfo + ? ( + + {formatCurrency(budgetInfo.spent)} + {' / '} + {formatCurrency(budgetInfo.totalBudget)} + {' spent'} + + ) + : undefined} + {normalizedBillingAccountId + ? ( + + ) + : undefined} + {isModalOpen && billingAccountDetailsResult.billingAccountDetails + ? ( + + ) + : undefined} +
      + ) +} + export const ProjectsTable: FC = (props: ProjectsTableProps) => { const canEditProject = props.canEditProject || NOOP_CAN_EDIT_PROJECT const projects: Project[] = props.projects @@ -120,11 +308,11 @@ export const ProjectsTable: FC = (props: ProjectsTableProps) const { billingAccounts, }: UseFetchBillingAccountsResult = useFetchBillingAccounts() - const billingAccountNames = useMemo( + const billingAccountsById = useMemo( () => new Map( billingAccounts.map(account => ([ String(account.id), - account.name, + account, ])), ), [billingAccounts], @@ -165,7 +353,12 @@ export const ProjectsTable: FC = (props: ProjectsTableProps) { isSortable: false, label: 'Billing Account', - renderer: (project: Project) => <>{getBillingAccountDisplay(project, billingAccountNames)}, + renderer: (project: Project) => ( + + ), type: 'element', }, { @@ -194,7 +387,7 @@ export const ProjectsTable: FC = (props: ProjectsTableProps) type: 'action', }, ], - [billingAccountNames, canEditProject], + [billingAccountsById, canEditProject], ) const forceSort = useMemo( @@ -247,6 +440,12 @@ export const ProjectsTable: FC = (props: ProjectsTableProps)
      {projects.map(project => ( + )} canEdit={canEditProject(project)} key={String(project.id)} project={project} diff --git a/src/apps/work/src/lib/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==