diff --git a/.circleci/config.yml b/.circleci/config.yml index 36aa6b62a..717a8915d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -226,7 +226,6 @@ workflows: branches: only: - dev - - hide_ba_details - deployQa: context: org-global diff --git a/.github/workflows/code_reviewer.yml b/.github/workflows/code_reviewer.yml new file mode 100644 index 000000000..02f198a18 --- /dev/null +++ b/.github/workflows/code_reviewer.yml @@ -0,0 +1,22 @@ +name: AI PR Reviewer + +on: + pull_request: + types: + - opened + - synchronize +permissions: + pull-requests: write +jobs: + tc-ai-pr-review: + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@v3 + + - name: TC AI PR Reviewer + uses: topcoder-platform/tc-ai-pr-reviewer@master + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # The GITHUB_TOKEN is there by default so you just need to keep it like it is and not necessarily need to add it as secret as it will throw an error. [More Details](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#about-the-github_token-secret) + LAB45_API_KEY: ${{ secrets.LAB45_API_KEY }} + exclude: '**/*.json, **/*.md, **/*.jpg, **/*.png, **/*.jpeg, **/*.bmp, **/*.webp' # Optional: exclude patterns separated by commas diff --git a/package.json b/package.json index 8824556b9..215e645de 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.15.0", + "axios": "^1.13.2", "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.18.1", + "lodash": "^4.17.21", "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.21", + "storybook": "7.6.20", "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 deleted file mode 100644 index 1dfcc85bd..000000000 --- a/public/llm-icons/claude-icon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/apps/admin/src/AdminHomeRedirect.tsx b/src/apps/admin/src/AdminHomeRedirect.tsx index 9bf239061..a96869288 100644 --- a/src/apps/admin/src/AdminHomeRedirect.tsx +++ b/src/apps/admin/src/AdminHomeRedirect.tsx @@ -5,7 +5,7 @@ import { reportsRootRoute } from '~/apps/reports' import { ProfileContextData, useProfileContext } from '~/libs/core' import { manageChallengeRouteId } from './config/routes.config' -import { isAdministrator } from './lib/utils/access' +import { isAdministrator } from './lib/utils' /** * Redirects authenticated admin-app users to the first route they can access. diff --git a/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx b/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx index b61fbc30e..2d9e172b9 100644 --- a/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx +++ b/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx @@ -51,7 +51,6 @@ import { checkIsMM, getSubmissionReprocessTopic, handleError, - resolveManualUploadSubmissionType, } from '../../lib/utils' import styles from './ManageSubmissionPage.module.scss' @@ -93,9 +92,7 @@ interface ManualSubmissionUploadModalProps { selectedHandle?: SelectOption setSelectedHandle: (value: SelectOption) => void isUploading: boolean - isLoadingChallenge: boolean isLoadingSubmitters: boolean - submissionTypeLabel: string submitterOptions: SelectOption[] handleFileChange: (event: ChangeEvent) => void selectedFile?: File @@ -163,7 +160,6 @@ const ManualSubmissionUploadModal: FC = ( props: ManualSubmissionUploadModalProps, ) => { const isHandleSelectDisabled = props.isUploading - || props.isLoadingChallenge || props.isLoadingSubmitters || props.submitterOptions.length === 0 const memberHandleHint = !props.isLoadingSubmitters @@ -183,16 +179,6 @@ 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 @@ -271,18 +256,6 @@ 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], @@ -356,12 +329,7 @@ export const ManageSubmissionPage: FC = (props: Props) => { ) const handleUploadSubmission = useCallback(async () => { - if ( - !challengeId - || !challengeInfo - || !selectedFile - || !selectedHandle?.value - ) { + if (!challengeId || !selectedFile || !selectedHandle?.value) { return } @@ -373,7 +341,6 @@ export const ManageSubmissionPage: FC = (props: Props) => { fileName: selectedFile.name, memberHandle: String(selectedHandle.label), memberId: selectedHandle.value, - type: manualUploadSubmissionType, }) toast.success('Submission uploaded successfully', { @@ -387,15 +354,7 @@ export const ManageSubmissionPage: FC = (props: Props) => { } finally { setIsUploading(false) } - }, [ - challengeId, - challengeInfo, - manualUploadSubmissionType, - refresh, - resetUploadForm, - selectedFile, - selectedHandle, - ]) + }, [challengeId, selectedFile, selectedHandle, resetUploadForm, refresh]) useEffect(() => { let isCancelled = false @@ -456,7 +415,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 new file mode 100644 index 000000000..4d99c8c31 --- /dev/null +++ b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/index.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 000000000..73dcadd92 --- /dev/null +++ b/src/apps/customer-portal/src/pages/profile-completion/index.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 000000000..42042bc0f --- /dev/null +++ b/src/apps/customer-portal/src/pages/profile-completion/profile-completion.routes.tsx @@ -0,0 +1,26 @@ +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.module.scss b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.module.scss index 22116c969..5a6dd85e6 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.module.scss +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.module.scss @@ -3,20 +3,17 @@ .container { display: flex; flex-direction: column; + gap: $sp-4; } :global([class*='ContentLayout-module_content-outer']) { - margin: 0 auto 0 !important; + margin: 24px auto 0 !important; } :global([class*='ContentLayout-module_content__']) { padding-bottom: 0 !important; } -:global([class*='BreadCrumb-module_breadcrumb']) { - display: none; -} - .pageArea { position: relative; @include substractPagePaddings; @@ -35,9 +32,9 @@ .pageBody { display: grid; grid-template-columns: 443px 1fr; - gap: 24px; + gap: 40px; margin-top: -232px; - padding: $sp-2 $sp-8 $sp-10 $sp-8; + padding: $sp-4 $sp-12 $sp-14 $sp-12; position: relative; z-index: 1; font-family: $font-roboto; @@ -65,17 +62,17 @@ background: $tc-white; border: 0; border-radius: 16px; - padding: 20px; + padding: 32px; display: flex; flex-direction: column; - gap: $sp-2; + gap: $sp-3; } .sidebar .panel + .panel { border-top-left-radius: 0; border-top-right-radius: 0; border-top: 0; - padding-top: 14px; + padding-top: 20px; } .panelTitle { @@ -110,9 +107,9 @@ } .jobDescriptionField { - margin-top: 0; + margin-top: 0.5rem; :global(textarea) { - min-height: 170px; + min-height: 442px; resize: vertical; color: $black-100; font-size: 14px; @@ -122,7 +119,7 @@ .aiActions { display: flex; - gap: $sp-1; + gap: $sp-2; :global(button) { font-size: 14px; @@ -137,7 +134,6 @@ } .filterBlock { - margin-bottom: 2px; :global([class*='__value-container']) { min-height: 18px; } @@ -179,8 +175,8 @@ align-items: center; gap: $sp-2; color: $black-100; - font-size: 15px; - line-height: 21px; + font-size: 16px; + line-height: 24px; font-weight: 400; position: relative; cursor: pointer; @@ -246,10 +242,8 @@ } .clearFiltersWrap { - margin-top: 6px; + margin-top: 10px; align-self: flex-start; - display: flex; - gap: $sp-2; :global(button) { font-size: 14px; @@ -286,7 +280,7 @@ align-items: center; justify-content: space-between; gap: $sp-3; - padding-top: 0; + padding-top: 8px; @include ltemd { flex-direction: column; 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 b45a923d7..2b710831c 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -1,6 +1,6 @@ /* eslint-disable complexity */ /* eslint-disable react/jsx-no-bind */ -import { ChangeEvent, FC, FocusEvent, useCallback, useMemo, useRef, useState } from 'react' +import { ChangeEvent, FC, FocusEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import classNames from 'classnames' import { CountryLookup, useCountryLookup } from '~/libs/core' @@ -9,6 +9,8 @@ import { IconOutline, InputMultiselect, InputMultiselectOption, + InputSelect, + InputSelectOption, InputTextarea, Tooltip, } from '~/libs/ui' @@ -22,12 +24,12 @@ import { searchMembers, SearchTalent, } from '../../../lib' +import personSearchImage from '../../../lib/assets/person-search.png' import styles from './TalentSearchPage.module.scss' export const TalentSearchPage: FC = () => { - const searchGenerationRef = useRef(0) - + const skipNextAutoSearchRef = useRef(false) const [lastSearchedDescription, setLastSearchedDescription] = useState('') const countryLookup: CountryLookup[] | undefined = useCountryLookup() const [jobDescription, setJobDescription] = useState('') @@ -36,8 +38,7 @@ export const TalentSearchPage: FC = () => { const [hasSearched, setHasSearched] = useState(false) const [skillOptionsLoading, setSkillOptionsLoading] = useState(false) const [selectedSkills, setSelectedSkills] = useState([]) - const [selectedCountries, setSelectedCountries] = useState([]) - const [onlyProfileComplete, setOnlyProfileComplete] = useState(false) + const [selectedCountry, setSelectedCountry] = useState('all') const [onlyOpenToWork, setOnlyOpenToWork] = useState(true) const [onlyActive, setOnlyActive] = useState(true) const [isSearchingMembers, setIsSearchingMembers] = useState(false) @@ -45,73 +46,46 @@ export const TalentSearchPage: FC = () => { const [results, setResults] = useState([]) const [totalResults, setTotalResults] = useState(0) const [currentPage, setCurrentPage] = useState(1) - const [lastAppliedSearchSignature, setLastAppliedSearchSignature] = useState('') - const [showSkillMatchOnCards, setShowSkillMatchOnCards] = useState(false) - 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))), + const [isLoading, setIsLoading] = useState(false) + // 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)))), + ], [countryLookup], ) - const selectedCountryCodesList = useMemo( - (): string[] => selectedCountries - .map(country => String(country.value || '') - .trim() - .toUpperCase()) - .filter(Boolean), - [selectedCountries], - ) - const shouldShowIntroState = !hasSearched - const currentSearchSignature = useMemo( - (): string => JSON.stringify({ - countries: selectedCountryCodesList - .slice() - .sort(), - openToWork: onlyOpenToWork, - profileComplete: onlyProfileComplete, - recentlyActive: onlyActive, - skills: selectedSkills - .map(skill => String(skill.value || '') - .trim()) - .filter(Boolean) - .sort(), - }), - [onlyActive, onlyOpenToWork, onlyProfileComplete, selectedCountryCodesList, selectedSkills], - ) + 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() - // Order comes from reports-api (sortBy/sortOrder on each request) so pagination stays globally consistent. - const displayedResults = results + if (!selectedCountryName || !normalizedLocation.includes(selectedCountryName.toLowerCase())) { + return false + } + } - const foundMembersCount = totalResults || displayedResults.length - const displayedResultsWithCountryName = useMemo( - () => displayedResults.map(talent => { - const code = String(talent.location || '') - .trim() - .toUpperCase() - const countryName = countryNameByCode.get(code) + if (onlyActive && !talent.isRecentlyActive) { + return false + } - if (!countryName) { - return talent - } + return true + }), [countryOptions, onlyActive, results, selectedCountry]) - return { - ...talent, - location: countryName, - } - }), - [countryNameByCode, displayedResults], - ) const hasMoreResults = results.length < totalResults const loadSkillOptions = useCallback(async (query: string): Promise => { @@ -124,43 +98,26 @@ 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 - profileComplete?: boolean recentlyActive?: boolean }, ): 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 profileComplete = overrides?.profileComplete ?? onlyProfileComplete 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()) @@ -170,41 +127,22 @@ export const TalentSearchPage: FC = () => { wins: 1, })), skillSearchType: 'OR', - sortBy: hasSkills ? 'matchIndex' : 'handle', - sortOrder: hasSkills ? 'desc' : 'asc', - } - - if (countries.length > 0) { - payload.countries = countries - } - - if (openToWork) { - payload.openToWork = true - } - - if (profileComplete) { - payload.profileComplete = true - } - - if (recentlyActive) { - payload.recentlyActive = true + verifiedProfile: true, } if (append) { setIsLoadingMore(true) } else { setIsSearchingMembers(true) + setIsLoading(true) } setErrorMessage('') + try { const response = await searchMembers(payload) - // 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 @@ -218,6 +156,7 @@ export const TalentSearchPage: FC = () => { merged.push(item) } }) + return merged }) setTotalResults(Number(response?.total || 0)) @@ -238,16 +177,20 @@ export const TalentSearchPage: FC = () => { setIsLoadingMore(false) } else { setIsSearchingMembers(false) + setIsLoading(false) } } - }, [onlyActive, onlyOpenToWork, onlyProfileComplete, selectedCountryCodesList]) + }, [onlyActive, onlyOpenToWork]) const clearAllFilters = useCallback((): void => { - setSelectedCountries([]) - setOnlyProfileComplete(false) + setSelectedCountry('all') setOnlyOpenToWork(true) setOnlyActive(true) setSelectedSkills([]) + setHasSearched(false) + setResults([]) + setTotalResults(0) + setCurrentPage(1) setErrorMessage('') setLastSearchedDescription('') }, []) @@ -258,15 +201,11 @@ 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 : [] @@ -292,51 +231,52 @@ export const TalentSearchPage: FC = () => { setSelectedSkills(extractedOptions) if (extractedOptions.length === 0) { + setResults([]) + setTotalResults(0) + setHasSearched(true) setErrorMessage('No skills were extracted from the job description.') return } - setLastSearchedDescription(normalizedDescription) + setHasSearched(true) + skipNextAutoSearchRef.current = true + const searchSucceeded = await runMemberSearch(extractedOptions, { page: 1 }) + if (searchSucceeded) { + setLastSearchedDescription(normalizedDescription) + } } catch { - if (searchGenerationRef.current !== generation) return + // Prevent stale auto-search when extraction fails and loading flips to false. + skipNextAutoSearchRef.current = true setErrorMessage('Failed to extract skills. Please try again.') + setHasSearched(true) } finally { setIsExtractingSkills(false) } - }, [isExtractingSkills, jobDescription]) + }, [isExtractingSkills, jobDescription, runMemberSearch]) - const handleSearch = useCallback(async (): Promise => { - if (isSearchingMembers || selectedSkills.length === 0) { + useEffect(() => { + if (!hasSearched || isExtractingSkills || selectedSkills.length === 0) { return } - setHasSearched(true) - const hadSkills = selectedSkills.length > 0 - const searchSucceeded = await runMemberSearch(selectedSkills, { - countries: selectedCountryCodesList, - openToWork: onlyOpenToWork, - page: 1, - profileComplete: onlyProfileComplete, - recentlyActive: onlyActive, - }) - if (searchSucceeded) { - setLastAppliedSearchSignature(currentSearchSignature) - setShowSkillMatchOnCards(hadSkills) + if (skipNextAutoSearchRef.current) { + skipNextAutoSearchRef.current = false + return } + + runMemberSearch(selectedSkills) }, [ - currentSearchSignature, - isSearchingMembers, + hasSearched, + isExtractingSkills, onlyActive, onlyOpenToWork, - onlyProfileComplete, runMemberSearch, - selectedCountryCodesList, selectedSkills, ]) const handleLoadMore = useCallback((): void => { - if (isLoadingMore || isSearchingMembers || !hasMoreResults) { + if (isLoadingMore || isSearchingMembers || !hasMoreResults || selectedSkills.length === 0) { return } @@ -345,7 +285,7 @@ export const TalentSearchPage: FC = () => { page: currentPage + 1, }) }, [currentPage, hasMoreResults, isLoadingMore, isSearchingMembers, runMemberSearch, selectedSkills]) - const isAiExtractButtonDisabled = useMemo( + const isSearchButtonDisabled = useMemo( () => isExtractingSkills || !jobDescription.trim() || jobDescription.trim() === lastSearchedDescription, @@ -353,7 +293,7 @@ export const TalentSearchPage: FC = () => { ) return ( @@ -362,12 +302,20 @@ export const TalentSearchPage: FC = () => {
{errorMessage && ( @@ -399,6 +346,7 @@ export const TalentSearchPage: FC = () => {
+

Filter

{ onChange={(event: ChangeEvent) => { const value = (event.target.value || []) as InputMultiselectOption[] setSelectedSkills(value) + setHasSearched(value.length > 0) if (value.length === 0) { setLastSearchedDescription('') } @@ -418,15 +367,13 @@ export const TalentSearchPage: FC = () => { />
- ) => { - const value = (event.target.value || []) as InputMultiselectOption[] - setSelectedCountries(value) + setSelectedCountry(event.target.value || 'all') }} placeholder='Select country' /> @@ -472,33 +419,10 @@ export const TalentSearchPage: FC = () => { -
-
@@ -506,63 +430,62 @@ export const TalentSearchPage: FC = () => {
- {shouldShowIntroState && ( + {!hasSearched && (
+ Person search +

Find the right talent

- Paste a job description to AI-extract skills, or enter skills manually - to find talents + Paste a job description on the left and hit  + Search +  - Our AI will match you with the + best candidates from our network.

)} - {!shouldShowIntroState && ( + {hasSearched && (
- {!isSearchingMembers && ( -
-

- We have found  - - {`${foundMembersCount} members`} - -  that match your search. -

-
- )} - {isSearchingMembers && ( + {isLoading ? (

Searching talent...

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

No matching talent found

-

Try changing filters or using a different job description.

-
- )} - {!isSearchingMembers && displayedResults.length > 0 && ( + ) : ( <> -
- {displayedResultsWithCountryName.map(talent => ( - - ))} -
- {hasMoreResults && ( -
- + + {filteredResults.length === 0 ? ( +
+

No matching talent found

+

Try changing filters or using a different job description.

+ ) : ( + <> +
+ {filteredResults.map(talent => ( + + ))} +
+ {hasMoreResults && ( +
+ +
+ )} + )} )} diff --git a/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.module.scss b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.module.scss index 2cea74382..83b5982ba 100644 --- a/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.module.scss +++ b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.module.scss @@ -194,10 +194,6 @@ font-weight: 400; } -.cardFooterWithoutMatch { - justify-content: flex-end; -} - .footerMatched { display: flex; align-items: center; diff --git a/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx index 2b37a9c8b..7d636e228 100644 --- a/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx @@ -30,7 +30,6 @@ interface TalentResultCardTalent { interface TalentResultCardProps { talent: TalentResultCardTalent - showSkillMatch: boolean } function getUniqueMatchedSkills(talent: TalentResultCardTalent): TalentResultCardTalent['matchedSkills'] { @@ -46,19 +45,6 @@ function getUniqueMatchedSkills(talent: TalentResultCardTalent): TalentResultCar }) } -function matchedSkillStatsLabel(skill: MatchedSkill): string { - const parts: string[] = [] - if (skill.wins > 0) { - parts.push(`${skill.wins} wins`) - } - - if (skill.submitted > 0) { - parts.push(`${skill.submitted} submissions`) - } - - return parts.length > 0 ? `: ${parts.join(', ')}` : '' -} - function buildMatchedSkillsTooltipContent( count: number, skills: MatchedSkill[], @@ -72,7 +58,7 @@ function buildMatchedSkillsTooltipContent( {skills.map((skill: MatchedSkill) => (
  • {skill.name} - {matchedSkillStatsLabel(skill)} + {`: ${skill.wins} wins, ${skill.submitted} submissions`}
  • ))} @@ -82,7 +68,6 @@ function buildMatchedSkillsTooltipContent( export const TalentResultCard: FC = (props: TalentResultCardProps) => { const talent: TalentResultCardTalent = props.talent - const showSkillMatch = props.showSkillMatch const uniqueSkills = useMemo(() => getUniqueMatchedSkills(talent), [talent]) const isVerifiedProfile = talent.isVerified === true const displayName = String(talent.name || '') @@ -120,11 +105,9 @@ export const TalentResultCard: FC = (props: TalentResultC
    {displayHandle} - {showSkillMatch && ( - - {`${talent.matchIndex}% Match`} - - )} + + {`${talent.matchIndex}% Match`} +

    {talent.name}

    @@ -165,29 +148,27 @@ export const TalentResultCard: FC = (props: TalentResultC
    -
    - {showSkillMatch && ( -
    - - {`${uniqueSkills.length} ${matchedSkillLabel}`} - - {uniqueSkills.length > 0 && ( - +
    + + {`${uniqueSkills.length} ${matchedSkillLabel}`} + + {uniqueSkills.length > 0 && ( + + - - )} -
    - )} + + +
    + )} +
    ({ }) => (

    {props.engagement.title}

    - {props.assignment?.status} {props.assignment?.status?.toLowerCase() === 'selected' && (
    )} - {!error && loading && ( + {!error && (
    - {skeletonCards.map(card => ( + {loading ? skeletonCards.map(card => (
    - ))} -
    - )} - {!error && !loading && ( -
    - {[ - { - engagements: assignmentSections.active, - id: 'active', - title: 'Active', - }, - { - engagements: assignmentSections.past, - id: 'past', - title: 'Past', - }, - ].filter(section => section.engagements.length > 0) - .map(section => ( -
    -

    {section.title}

    -
    - {section.engagements.map(engagement => { - const contactEmail = normalizeContactEmail(engagement.createdByEmail) - const assignment = getUserAssignment(engagement) - const handleDocumentExperienceClick = function (): void { - handleDocumentExperience(engagement) - } - - const handleAcceptOfferClick = function (): void { - setProfileGateState(undefined) - - if (profileCompleteness?.isLoading) { - return - } - - if ( - profileCompleteness - && typeof profileCompleteness.percent === 'number' - && profileCompleteness.percent < 100 - ) { - setProfileGateState({ - engagementId: engagement.id, - message: PROFILE_GATE_ERROR_MESSAGE, - }) - return - } - - startTermsAgreementFlow(() => { - handleOpenOfferModal(engagement, 'accept') - }) - } - - const handleRejectOfferClick = function (): void { - handleOpenOfferModal(engagement, 'reject') - } - - return ( - - ) - })} -
    -
    - ))} + )) : assignments.map(engagement => { + const contactEmail = normalizeContactEmail(engagement.createdByEmail) + const assignment = getUserAssignment(engagement) + const handleDocumentExperienceClick = function (): void { + handleDocumentExperience(engagement) + } + + const handleAcceptOfferClick = function (): void { + setProfileGateState(undefined) + + if (profileCompleteness?.isLoading) { + return + } + + if ( + profileCompleteness + && typeof profileCompleteness.percent === 'number' + && profileCompleteness.percent < 100 + ) { + setProfileGateState({ + engagementId: engagement.id, + message: PROFILE_GATE_ERROR_MESSAGE, + }) + return + } + + startTermsAgreementFlow(() => { + handleOpenOfferModal(engagement, 'accept') + }) + } + + const handleRejectOfferClick = function (): void { + handleOpenOfferModal(engagement, 'reject') + } + + return ( + + ) + })}
    )} {!error && assignments.length > 0 && ( diff --git a/src/apps/reports/src/config/routes.config.ts b/src/apps/reports/src/config/routes.config.ts index 914a95cd9..fb7d07091 100644 --- a/src/apps/reports/src/config/routes.config.ts +++ b/src/apps/reports/src/config/routes.config.ts @@ -10,4 +10,3 @@ export const rootRoute: string export const reportsPageRouteId = 'reports' export const bulkMemberLookupRouteId = 'bulk-member-lookup' -export const billingAccountsPageRouteId = 'billing-accounts' diff --git a/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx b/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx index 2a73aa4fe..cb0c2e34a 100644 --- a/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx +++ b/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx @@ -15,11 +15,7 @@ import classNames from 'classnames' import { useClickOutside } from '~/libs/shared/lib/hooks' import { TabsNavItem } from '~/libs/ui' -import { - billingAccountsPageRouteId, - bulkMemberLookupRouteId, - reportsPageRouteId, -} from '../../../config/routes.config' +import { bulkMemberLookupRouteId, reportsPageRouteId } from '../../../config/routes.config' import styles from './NavTabs.module.scss' @@ -38,10 +34,6 @@ const NavTabs: FC = () => { id: bulkMemberLookupRouteId, title: 'Bulk Member Lookup', }, - { - id: billingAccountsPageRouteId, - title: 'Billing Accounts', - }, ], []) const activeTabPathName: string = useMemo(() => { diff --git a/src/apps/reports/src/lib/services/index.ts b/src/apps/reports/src/lib/services/index.ts index 567bbcf6a..b2c6dc18b 100644 --- a/src/apps/reports/src/lib/services/index.ts +++ b/src/apps/reports/src/lib/services/index.ts @@ -2,7 +2,6 @@ export { downloadBlobFile, downloadReportAsCsv, downloadReportAsJson, - fetchReportJson, fetchReportsIndex, postReportAsCsv, postReportAsJson, @@ -11,12 +10,8 @@ export { } from './reports.service' export type { - BillingAccountDetail, - BillingAccountProfileResponse, - BillingAccountsViewData, ReportDefinition, ReportGroup, ReportParameter, ReportsIndexResponse, - SfdcBillingAccountPaymentRow, } from './reports.service' diff --git a/src/apps/reports/src/lib/services/reports.service.ts b/src/apps/reports/src/lib/services/reports.service.ts index a6c4ed649..d752087c8 100644 --- a/src/apps/reports/src/lib/services/reports.service.ts +++ b/src/apps/reports/src/lib/services/reports.service.ts @@ -28,46 +28,6 @@ export type ReportGroup = { export type ReportsIndexResponse = Record -export type BillingAccountDetail = { - name: string - description: string | null - subcontractingEndCustomer: string | null - status: string - startDate: string | null - endDate: string | null - budget: string | number - markup: string | number -} - -export type SfdcBillingAccountPaymentRow = { - paymentId: string - paymentDate: string - billingAccountId: string - paymentStatus: string - challengeFee: string | number - paymentAmount: string | number - challengeId: string - category: string - isTask: boolean - challengeName: string | null - challengeStatus: string | null - winnerHandle: string - winnerId: string - winnerFirstName: string - winnerLastName: string -} - -/** Response from GET /sfdc/billing-accounts */ -export type BillingAccountProfileResponse = { - billingAccount?: BillingAccountDetail -} - -/** Billing Accounts in-app view: profile + rows from GET /sfdc/payments */ -export type BillingAccountsViewData = { - billingAccount?: BillingAccountDetail - payments: SfdcBillingAccountPaymentRow[] -} - const reportsDownloadClient: AxiosInstance = xhrCreateInstance() const buildReportUrl = (path: string): string => { @@ -177,16 +137,6 @@ export const downloadReportAsJson = (path: string): Promise => ( downloadReportBlob(path, 'application/json') ) -export const fetchReportJson = async (path: string): Promise => { - if (!path) { - throw new Error('Report path is required') - } - - const normalizedPath = path.startsWith('/') ? path : `/${path}` - const url = `${EnvironmentConfig.API.V6}/reports${normalizedPath}` - return xhrGetAsync(url, reportsDownloadClient) -} - export const downloadReportAsCsv = (path: string): Promise => ( downloadReportBlob(path, 'text/csv') ) diff --git a/src/apps/reports/src/pages/reports/BillingAccountsPage.tsx b/src/apps/reports/src/pages/reports/BillingAccountsPage.tsx deleted file mode 100644 index fc399e312..000000000 --- a/src/apps/reports/src/pages/reports/BillingAccountsPage.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { BillingAccountsPage } from './ReportsPage' - -export default BillingAccountsPage diff --git a/src/apps/reports/src/pages/reports/ReportsPage.module.scss b/src/apps/reports/src/pages/reports/ReportsPage.module.scss index e282cf8d2..e804f2221 100644 --- a/src/apps/reports/src/pages/reports/ReportsPage.module.scss +++ b/src/apps/reports/src/pages/reports/ReportsPage.module.scss @@ -26,97 +26,26 @@ gap: 4px; } -.filtersPanel { - margin-top: 12px; - padding: 16px; - border: 1px solid #e4e6e9; - border-radius: 8px; - background: #fcfcfd; -} - .params { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 30px 50px; - margin-top: 0; - align-items: start; -} - -@media (max-width: 1200px) { - .params { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -@media (max-width: 760px) { - .params { - grid-template-columns: 1fr; - } -} - -.paramCard { - display: grid; - grid-template-rows: auto auto; - row-gap: 8px; - align-content: start; -} - -.paramHeader { - min-height: 28px; -} - -.paramTitleRow { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.paramHeaderActions { - display: flex; - align-items: center; - gap: 8px; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; + margin-top: 12px; } .paramLabel { font-weight: 600; - color: #2f3338; } -.paramTypePill { - font-size: 11px; - color: #5e6369; - background: #f3f4f6; - border-radius: 999px; - padding: 2px 8px; - white-space: nowrap; -} - -.actionsBar { - display: flex; - justify-content: flex-start; - margin-top: 18px; - padding-top: 14px; - border-top: 1px solid #eceef1; +.paramMeta { + color: #6b6f75; + font-size: 12px; } -.paramInfoButton { - display: inline-flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border: 0; - padding: 0; - border-radius: 50%; - background: transparent; +.paramHint { color: #6b6f75; - cursor: pointer; - - svg { - width: 16px; - height: 16px; - } + font-size: 12px; + font-style: italic; } .reportTitle { @@ -165,95 +94,3 @@ font-style: italic; color: #6b6f75; } - -.billingSummary { - margin-top: 8px; - padding: 16px; - border: 1px solid #e4e6e9; - border-radius: 6px; - background: #fafbfc; -} - -.billingSummaryTitle { - font-weight: 600; - margin-bottom: 12px; -} - -.billingDetailGrid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - gap: 12px 24px; -} - -.billingDetailItem { - display: flex; - flex-direction: column; - gap: 2px; -} - -.billingDetailLabel { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.04em; - color: #6b6f75; -} - -.billingDetailValue { - font-size: 14px; - color: #1a1d21; - word-break: break-word; -} - -.billingMissingNotice { - margin-top: 8px; - padding: 12px; - border-radius: 4px; - background: #f3f4f6; - color: #494f55; - font-size: 14px; -} - -.paymentsSection { - margin-top: 24px; -} - -.paymentsSectionTitle { - font-weight: 600; - margin-bottom: 8px; -} - -.tableWrap { - overflow-x: auto; - border: 1px solid #e4e6e9; - border-radius: 6px; -} - -.paymentsTable { - width: 100%; - border-collapse: collapse; - font-size: 13px; -} - -.paymentsTable th, -.paymentsTable td { - padding: 8px 10px; - text-align: left; - border-bottom: 1px solid #e4e6e9; - vertical-align: top; -} - -.paymentsTable th { - background: #f3f4f6; - font-weight: 600; - white-space: nowrap; -} - -.paymentsTable tbody tr:last-child td { - border-bottom: none; -} - -.paymentsEmpty { - margin-top: 8px; - color: #6b6f75; - font-style: italic; -} diff --git a/src/apps/reports/src/pages/reports/ReportsPage.tsx b/src/apps/reports/src/pages/reports/ReportsPage.tsx index 861372464..b00f5cf2f 100644 --- a/src/apps/reports/src/pages/reports/ReportsPage.tsx +++ b/src/apps/reports/src/pages/reports/ReportsPage.tsx @@ -1,32 +1,19 @@ import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react' import { NavigateFunction, useNavigate } from 'react-router-dom' -import { - Button, - IconOutline, - InputSelect, - InputSelectOption, - InputText, - LoadingSpinner, - PageTitle, - Tooltip, -} from '~/libs/ui' +import { Button, InputSelect, InputSelectOption, InputText, LoadingSpinner, PageTitle } from '~/libs/ui' import { bulkMemberLookupRouteId } from '../../config/routes.config' import { handleError } from '../../lib/utils' import { - BillingAccountProfileResponse, - BillingAccountsViewData, downloadBlobFile, downloadReportAsCsv, downloadReportAsJson, - fetchReportJson, fetchReportsIndex, ReportDefinition, ReportGroup, ReportParameter, ReportsIndexResponse, - SfdcBillingAccountPaymentRow, } from '../../lib/services' import { getReportParameterValidationError } from './reports-page.validation' @@ -34,203 +21,6 @@ import styles from './ReportsPage.module.scss' const pageTitle = 'Reports' const bulkMembersByHandlesPath = '/identity/users-by-handles' -const BILLING_ACCOUNTS_REPORT_PATH = '/sfdc/billing-accounts' -const SFDC_PAYMENTS_REPORT_PATH = '/sfdc/payments' -const BILLING_ACCOUNTS_REPORT_DEFINITION: ReportDefinition = { - description: 'View billing-account details and SFDC payments by billing account ID.', - method: 'GET', - name: 'Billing Accounts', - parameters: [ - { - description: 'Billing account ID', - location: 'query', - name: 'billingAccountId', - required: true, - type: 'string', - }, - { - description: 'Optional start date for payment filtering (ISO 8601)', - location: 'query', - name: 'startDate', - type: 'date', - }, - { - description: 'Optional end date for payment filtering (ISO 8601)', - location: 'query', - name: 'endDate', - type: 'date', - }, - ], - path: BILLING_ACCOUNTS_REPORT_PATH, -} - -type ReportsPageTab = 'reports' | 'billingAccounts' - -const buildSfdcPaymentsQueryPath = ( - billingAccountId: string, - startDate?: string, - endDate?: string, -): string => { - const query = new URLSearchParams() - query.append('billingAccountIds', billingAccountId.trim()) - const start = startDate?.trim() - const end = endDate?.trim() - - if (start) { - query.append('startDate', start) - } - - if (end) { - query.append('endDate', end) - } - - return `${SFDC_PAYMENTS_REPORT_PATH}?${query.toString()}` -} - -const formatReportCell = (value: unknown): string => { - if (value === null || value === undefined || value === '') { - return '—' - } - - if (typeof value === 'boolean') { - return value ? 'Yes' : 'No' - } - - return String(value) -} - -const formatPaymentDate = (iso: string): string => { - const parsed = Date.parse(iso) - - if (Number.isNaN(parsed)) { - return iso - } - - return new Date(parsed) - .toLocaleString() -} - -const PAYMENT_TABLE_COLUMNS: { key: keyof SfdcBillingAccountPaymentRow; label: string }[] = [ - { key: 'paymentId', label: 'Payment ID' }, - { key: 'paymentDate', label: 'Payment date' }, - { key: 'billingAccountId', label: 'Billing account ID' }, - { key: 'paymentStatus', label: 'Status' }, - { key: 'challengeFee', label: 'Challenge fee' }, - { key: 'paymentAmount', label: 'Payment amount' }, - { key: 'challengeId', label: 'Challenge ID' }, - { key: 'category', label: 'Category' }, - { key: 'isTask', label: 'Task' }, - { key: 'challengeName', label: 'Challenge name' }, - { key: 'challengeStatus', label: 'Challenge status' }, - { key: 'winnerHandle', label: 'Winner handle' }, - { key: 'winnerId', label: 'Winner ID' }, - { key: 'winnerFirstName', label: 'Winner first name' }, - { key: 'winnerLastName', label: 'Winner last name' }, -] - -const BillingAccountReportResults = ( - props: { data: BillingAccountsViewData }, -): JSX.Element => { - const billingAccount: BillingAccountsViewData['billingAccount'] = props.data.billingAccount - const payments: BillingAccountsViewData['payments'] = props.data.payments - - return ( -
    -
    -
    Billing account
    - {billingAccount ? ( -
    -
    - Name - {billingAccount.name} -
    -
    - Description - - {formatReportCell(billingAccount.description)} - -
    -
    - Subcontracting end customer - - {formatReportCell(billingAccount.subcontractingEndCustomer)} - -
    -
    - Status - {billingAccount.status} -
    -
    - Start date - - {billingAccount.startDate - ? formatPaymentDate(String(billingAccount.startDate)) - : '—'} - -
    -
    - End date - - {billingAccount.endDate - ? formatPaymentDate(String(billingAccount.endDate)) - : '—'} - -
    -
    - Budget - - {formatReportCell(billingAccount.budget)} - -
    -
    - Markup - - {formatReportCell(billingAccount.markup)} - -
    -
    - ) : ( -
    - No billing account profile was found for this ID. Payments for this account may still - appear below. -
    - )} -
    - -
    -
    Payments
    - {payments.length === 0 ? ( -
    No payments matched the selected filters.
    - ) : ( -
    - - - - {PAYMENT_TABLE_COLUMNS.map(col => ( - - ))} - - - - {payments.map(row => ( - - {PAYMENT_TABLE_COLUMNS.map(col => ( - - ))} - - ))} - -
    {col.label}
    - {col.key === 'paymentDate' - ? formatPaymentDate(String(row[col.key])) - : formatReportCell(row[col.key])} -
    -
    - )} -
    -
    - ) -} const buildDownloadName = ( name: string, @@ -258,34 +48,12 @@ const formatMethod = (method?: string): string => ( method ? method.toUpperCase() : 'GET' ) -const formatParameterLabel = (name: string): string => ( - name - .replace(/([a-z0-9])([A-Z])/g, '$1 $2') - .replace(/Ids\b/g, 'IDs') - .replace(/^./, char => char.toUpperCase()) -) - -const buildParameterTooltipContent = (parameter: ReportParameter): JSX.Element => ( - <> -
    {parameter.description?.trim() || 'No description available.'}
    -
    - {`Location: ${parameter.location || 'query'} (${parameter.name})`} -
    - -) - -const EMPTY_BILLING_ACCOUNT_PROFILE_RESPONSE: BillingAccountProfileResponse = { - billingAccount: undefined, -} - type ReportActionsProps = { handleCsvDownload: () => void handleJsonDownload: () => void - handleResetFilters: () => void handleOpenBulkMemberLookup: () => void isDownloadDisabled: boolean isHandleLookupPostReport: boolean - isResetDisabled: boolean isPostReport: boolean } @@ -326,13 +94,6 @@ const ReportActions = (props: ReportActionsProps): JSX.Element => { > Download as CSV -
    ) } @@ -364,67 +125,50 @@ const SelectedReportSection = (props: SelectedReportSectionProps): JSX.Element =
    - {(props.selectedReport.parameters?.length ?? 0) > 0 ? ( -
    -
    - {props.selectedReport.parameters?.map(parameter => ( -
    -
    -
    -
    - {formatParameterLabel(parameter.name)} - {parameter.required ? ' *' : ''} -
    -
    -
    {parameter.type}
    - - - -
    -
    -
    - {props.renderParameterInput(parameter)} + {(props.selectedReport.parameters?.length ?? 0) > 0 && ( +
    + {props.selectedReport.parameters?.map(parameter => ( +
    +
    + {parameter.name} + {parameter.required ? ' *' : ''}
    - ))} -
    -
    - {props.reportActions} -
    + {parameter.description && ( +
    {parameter.description}
    + )} +
    + Location: + {' '} + {parameter.location || 'query'} + {' '} + • Type: + {' '} + {parameter.type} +
    + {parameter.type.endsWith('[]') && ( +
    + Use comma-separated values for lists. +
    + )} + {props.renderParameterInput(parameter)} +
    + ))}
    - ) : ( - props.reportActions )} + + {props.reportActions} ) } -type ReportsPageContentProps = { - initialTab: ReportsPageTab -} - -// eslint-disable-next-line complexity -const ReportsPageContent: FC = props => { +export const ReportsPage: FC = () => { const navigate: NavigateFunction = useNavigate() - const [activeTab] = useState(props.initialTab) const [reportsIndex, setReportsIndex] = useState({}) const [selectedBasePath, setSelectedBasePath] = useState('') const [selectedReportPath, setSelectedReportPath] = useState('') const [isLoading, setIsLoading] = useState(false) const [downloadingFormat, setDownloadingFormat] = useState<'json' | 'csv' | undefined>(undefined) const [parameterValues, setParameterValues] = useState>({}) - const [billingAccountViewData, setBillingAccountViewData] = useState< - BillingAccountsViewData | undefined - >(undefined) - const [isBillingAccountViewLoading, setIsBillingAccountViewLoading] = useState(false) useEffect(() => { let isMounted = true @@ -486,21 +230,15 @@ const ReportsPageContent: FC = props => { selectedGroup?.reports?.find(report => report.path === selectedReportPath) ), [selectedGroup, selectedReportPath]) - const selectedReportForForm = activeTab === 'billingAccounts' - ? BILLING_ACCOUNTS_REPORT_DEFINITION - : selectedReport - const handleBasePathChange = useCallback((event: ChangeEvent) => { setSelectedBasePath(event.target.value) setSelectedReportPath('') setParameterValues({}) - setBillingAccountViewData(undefined) }, []) const handleReportChange = useCallback((event: ChangeEvent) => { setSelectedReportPath(event.target.value) setParameterValues({}) - setBillingAccountViewData(undefined) }, []) const handleParameterChange = useCallback((event: ChangeEvent) => { @@ -553,7 +291,7 @@ const ReportsPageContent: FC = props => { }, [parameterValues]) const parameterErrors = useMemo>(() => ( - (selectedReportForForm?.parameters ?? []).reduce>((errors, parameter) => { + (selectedReport?.parameters ?? []).reduce>((errors, parameter) => { const error = getReportParameterValidationError(parameter, parameterValues[parameter.name]) if (error) { @@ -562,55 +300,12 @@ const ReportsPageContent: FC = props => { return errors }, {}) - ), [parameterValues, selectedReportForForm]) + ), [parameterValues, selectedReport]) const hasInvalidParameterValues = useMemo(() => ( Object.keys(parameterErrors).length > 0 ), [parameterErrors]) - const handleBillingAccountView = useCallback(async () => { - if (activeTab !== 'billingAccounts' || hasInvalidParameterValues) { - return - } - - const billingAccountId = parameterValues.billingAccountId?.trim() - - if (!billingAccountId) { - return - } - - try { - setIsBillingAccountViewLoading(true) - const profileQuery = new URLSearchParams({ billingAccountId }) - const profilePath = `${BILLING_ACCOUNTS_REPORT_PATH}?${profileQuery.toString()}` - const paymentsPath = buildSfdcPaymentsQueryPath( - billingAccountId, - parameterValues.startDate, - parameterValues.endDate, - ) - - const paymentsPromise = fetchReportJson(paymentsPath) - const profilePromise = fetchReportJson(profilePath) - .catch(() => EMPTY_BILLING_ACCOUNT_PROFILE_RESPONSE) - const [profile, payments] = await Promise.all([profilePromise, paymentsPromise]) - - setBillingAccountViewData({ - billingAccount: profile.billingAccount, - payments, - }) - } catch (error) { - handleError(error) - } finally { - setIsBillingAccountViewLoading(false) - } - }, [ - activeTab, - hasInvalidParameterValues, - parameterValues.billingAccountId, - parameterValues.endDate, - parameterValues.startDate, - ]) - const handleDownload = useCallback(async (format: 'json' | 'csv') => { if (!selectedReport || hasInvalidParameterValues) { return @@ -643,24 +338,18 @@ const ReportsPageContent: FC = props => { navigate(bulkMemberLookupRouteId) }, [navigate]) - const handleResetFilters = useCallback(() => { - setParameterValues({}) - setBillingAccountViewData(undefined) - }, []) - const isDownloading = downloadingFormat !== undefined - const isBusy = isDownloading || isBillingAccountViewLoading const requiredParamsMissing = useMemo(() => { - const params = selectedReportForForm?.parameters ?? [] + const params = selectedReport?.parameters ?? [] return params.some(param => param.required && !(parameterValues[param.name]?.trim())) - }, [parameterValues, selectedReportForForm]) + }, [parameterValues, selectedReport]) const hasUnresolvedPathParams = useMemo(() => ( - (selectedReportForForm?.parameters ?? []) + (selectedReport?.parameters ?? []) .filter(param => param.location === 'path') .some(param => !parameterValues[param.name]?.trim()) - ), [parameterValues, selectedReportForForm]) + ), [parameterValues, selectedReport]) const isPostReport = selectedReport?.method?.toUpperCase() === 'POST' const isHandleLookupPostReport = isPostReport && selectedReport.path === bulkMembersByHandlesPath @@ -671,14 +360,6 @@ const ReportsPageContent: FC = props => { || hasInvalidParameterValues || hasUnresolvedPathParams - const billingAccountViewDisabled = !selectedReportForForm - || isDownloading - || isBillingAccountViewLoading - || requiredParamsMissing - || hasInvalidParameterValues - || hasUnresolvedPathParams - const isResetDisabled = Object.keys(parameterValues).length === 0 - const handleJsonDownload = useCallback(() => { handleDownload('json') }, [handleDownload]) @@ -687,41 +368,20 @@ const ReportsPageContent: FC = props => { handleDownload('csv') }, [handleDownload]) - const billingAccountReportActions = ( -
    - - -
    - ) - const reportActions = ( ) const renderParameterInput = useCallback((parameter: ReportParameter) => { const commonProps = { - label: formatParameterLabel(parameter.name), + label: parameter.name, name: parameter.name, placeholder: parameter.type === 'date' ? 'YYYY-MM-DD' @@ -763,7 +423,6 @@ const ReportsPageContent: FC = props => { return ( = props => { return ( <> - {isBusy && ( - + {isDownloading && ( + )}
    {pageTitle}

    - {activeTab === 'reports' - ? 'Select a base path to view available reports. Choose a report, ' - + 'fill required parameters, and download JSON or CSV from the reports API.' - : 'Enter a billing account ID and optional start/end dates, then click View ' - + 'to load billing account payment data.'} + Select a base path to view the available reports. After choosing a report, provide any + required parameters and download the data as JSON or CSV directly from the reports API.

    {isLoading ? ( @@ -798,56 +451,42 @@ const ReportsPageContent: FC = props => {
    ) : ( <> - {activeTab === 'reports' ? ( - <> - {basePathOptions.length ? ( -
    - - - {selectedGroup && ( - - )} -
    - ) : ( -
    - No reports are currently available. -
    - )} - - + - + + {selectedGroup && ( + + )} +
    ) : ( - +
    + No reports are currently available. +
    )} - {activeTab === 'billingAccounts' && billingAccountViewData ? ( - - ) : undefined} + )}
    @@ -855,12 +494,4 @@ const ReportsPageContent: FC = props => { ) } -export const ReportsPage: FC = () => ( - -) - -export const BillingAccountsPage: FC = () => ( - -) - export default ReportsPage diff --git a/src/apps/reports/src/reports-app.routes.tsx b/src/apps/reports/src/reports-app.routes.tsx index 01c720710..df4b56ecd 100644 --- a/src/apps/reports/src/reports-app.routes.tsx +++ b/src/apps/reports/src/reports-app.routes.tsx @@ -11,7 +11,6 @@ import { } from '~/libs/core' import { - billingAccountsPageRouteId, bulkMemberLookupRouteId, reportsPageRouteId, rootRoute, @@ -22,9 +21,6 @@ const ReportsPage: LazyLoadedComponent = lazyLoad( () => import('./pages/reports/ReportsPage'), 'ReportsPage', ) -const BillingAccountsPage: LazyLoadedComponent = lazyLoad( - () => import('./pages/reports/BillingAccountsPage'), -) const BulkMemberLookupPage: LazyLoadedComponent = lazyLoad( () => import('./pages/bulk-member-lookup/BulkMemberLookupPage'), 'BulkMemberLookupPage', @@ -47,11 +43,6 @@ export const reportsRoutes: ReadonlyArray = [ element: , route: reportsPageRouteId, }, - { - authRequired: true, - element: , - route: billingAccountsPageRouteId, - }, { authRequired: true, element: , diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx index e91f51241..234849df5 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx @@ -275,8 +275,8 @@ const AiReviewsTable: FC = props => { const failedReviewersText = failedGatingReviewers.length ? `Gating Reviewers failed: ${failedGatingReviewers.join(', ')}. - This submission is automatically failed regardless of Overall Score.` - : `This submission is failed because ${hasSubmitterRole ? 'your' : 'the'} + This submission failed regardless of Overall Score because it failed one or more of AI Gating Reviews.` + : `This submission failed because ${hasSubmitterRole ? 'your' : 'the'} Overall Score is bellow minimum threshold.` // Message text varies by role @@ -293,7 +293,7 @@ const AiReviewsTable: FC = props => { } if (hasSubmitterRole) { - return 'Submission Locked - Your submission will not be reviewed in the Review Phase.' + return 'Submission Locked - Your submission won\'t be reviewed during the Review Phase.' } return 'Submission Locked - This submission doesn\'t have to be reviewed in Review Phase.' @@ -315,7 +315,6 @@ const AiReviewsTable: FC = props => { {!reviewerRows.length && loading && (
    Loading...
    )} - {reviewerRows.map(row => (
    - status?: 'passed' | 'pending' | 'failed-score' | 'failed' | 'human-override' + status?: 'passed' | 'pending' | 'failed-score' | 'failed' score?: number hideLabel?: boolean showScore?: boolean @@ -85,16 +85,6 @@ 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 107b2b86b..b416ee905 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss +++ b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss @@ -29,11 +29,6 @@ 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 404fa19ca..0458c02c9 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' | 'human-override' + status: 'pending' | 'failed' | 'passed' | 'failed-score' 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 40498ddc9..338c4cfe0 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts @@ -119,128 +119,6 @@ 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 64c26f29e..64940ae67 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts @@ -142,167 +142,6 @@ 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. * @@ -449,13 +288,6 @@ 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) { @@ -463,8 +295,7 @@ export function filterIterativeReviewRows(args: FilterIterativeReviewRowsArgs): } if (shouldTreatAsAiFailedSubmission(submission, aiReviewDecisionsBySubmissionId)) { - const submissionId = normalizeIdentifier(submission.id) - return submissionId ? aiFailedSubmissionIdsForSelectedPhase.has(submissionId) : false + return true } // 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/ChallengeScopedErrorState/ChallengeScopedErrorState.module.scss b/src/apps/review/src/lib/components/ChallengeScopedErrorState/ChallengeScopedErrorState.module.scss deleted file mode 100644 index 8f61ea643..000000000 --- a/src/apps/review/src/lib/components/ChallengeScopedErrorState/ChallengeScopedErrorState.module.scss +++ /dev/null @@ -1,22 +0,0 @@ -@import '@libs/ui/styles/includes'; - -.container { - align-items: center; - background: #fff4f4; - border: 1px solid #f2c9c9; - border-radius: 8px; - color: #8a1f1f; - display: flex; - gap: $sp-3; - justify-content: space-between; - margin-top: $sp-4; - padding: $sp-3; -} - -.message { - font-family: "Nunito Sans", sans-serif; - font-size: 14px; - font-weight: 700; - line-height: 20px; - margin: 0; -} diff --git a/src/apps/review/src/lib/components/ChallengeScopedErrorState/ChallengeScopedErrorState.tsx b/src/apps/review/src/lib/components/ChallengeScopedErrorState/ChallengeScopedErrorState.tsx deleted file mode 100644 index 0066ca59e..000000000 --- a/src/apps/review/src/lib/components/ChallengeScopedErrorState/ChallengeScopedErrorState.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { FC } from 'react' - -import { Button } from '~/libs/ui' - -import styles from './ChallengeScopedErrorState.module.scss' - -interface ChallengeScopedErrorStateProps { - message?: string - onRetry: () => void -} - -/** - * Renders the shared retryable error state for challenge-scoped route fetches. - * - * @param props.message optional message shown in the error panel. - * @param props.onRetry callback used by route pages to revalidate failed challenge data. - * @returns a generic route-level error panel with a retry action. - */ -export const ChallengeScopedErrorState: FC = ( - props: ChallengeScopedErrorStateProps, -) => ( -
    -

    - {props.message ?? 'Something went wrong while loading the challenge. Please try again.'} -

    - -
    -) - -export default ChallengeScopedErrorState diff --git a/src/apps/review/src/lib/components/ChallengeScopedErrorState/index.ts b/src/apps/review/src/lib/components/ChallengeScopedErrorState/index.ts deleted file mode 100644 index fea79cdd9..000000000 --- a/src/apps/review/src/lib/components/ChallengeScopedErrorState/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ChallengeScopedErrorState' diff --git a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx index 084022e09..326e86db1 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' | 'human-override' { +): 'passed' | 'failed-score' | 'pending' | 'failed' { if (!status || status === 'PENDING') { return 'pending' } @@ -43,10 +43,6 @@ export function normalizeDecisionStatus( return 'failed' } - if (status === 'HUMAN_OVERRIDE') { - return 'human-override' - } - return 'pending' } diff --git a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.module.scss b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.module.scss index a1e5a0a2e..544b430b9 100644 --- a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.module.scss +++ b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.module.scss @@ -34,15 +34,6 @@ $error-line-height: 14px; pointer-events: none; } - &.readOnly { - :global { - .editor-statusbar, - .editor-toolbar { - pointer-events: none; - } - } - } - :global { .EasyMDEContainer { height: 100px; diff --git a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.spec.tsx b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.spec.tsx deleted file mode 100644 index be151abb1..000000000 --- a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.spec.tsx +++ /dev/null @@ -1,159 +0,0 @@ -/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ -import { render, waitFor } from '@testing-library/react' - -import { FieldMarkdownEditor } from './FieldMarkdownEditor' - -const mockEasyMDEInstances: any[] = [] - -jest.mock('~/apps/admin/src/lib/hooks', () => { - const React: typeof import('react') = jest.requireActual('react') - - return { - useOnComponentDidMount: (onMounted: () => void): void => { - React.useEffect(() => { - onMounted() - }, []) - }, - } -}, { virtual: true }) - -jest.mock('../../contexts', () => { - const React: typeof import('react') = jest.requireActual('react') - - return { - ChallengeDetailContext: React.createContext({ - challengeId: 'challenge-id', - }), - } -}) - -jest.mock('../../services', () => ({ - uploadReviewAttachment: jest.fn(), -})) - -jest.mock('../../utils', () => ({ - humanFileSize: jest.fn(() => '1 KB'), -})) - -jest.mock('easymde', () => { - class EasyMDEMock { - constructor(options: any) { - const wrapper = globalThis.document.createElement('div') - wrapper.appendChild(globalThis.document.createElement('div')) - - let editorValue = options.initialValue ?? '' - const imageInput = { value: '' } - const codemirror = { - focus: jest.fn(), - getCursor: jest.fn(() => ({ - ch: 0, - line: 0, - })), - getLine: jest.fn(() => ''), - getSelection: jest.fn(() => ''), - getTokenAt: jest.fn(() => ({ - type: '', - })), - getValue: jest.fn(() => editorValue), - getWrapperElement: jest.fn(() => wrapper), - indexFromPos: jest.fn(() => 0), - on: jest.fn(), - replaceRange: jest.fn(), - replaceSelection: jest.fn(), - setOption: jest.fn(), - setSelection: jest.fn(), - } - Object.assign(this, { - codemirror, - gui: { - toolbar: { - getElementsByClassName: jest.fn(() => [imageInput]), - }, - }, - options, - updateStatusBar: jest.fn(), - value: jest.fn((incomingValue?: string) => { - if (incomingValue === undefined) { - return editorValue - } - - editorValue = incomingValue - return undefined - }), - }) - - mockEasyMDEInstances.push(this) - } - } - - Object.assign(EasyMDEMock, { - drawImage: jest.fn(), - drawLink: jest.fn(), - drawTable: jest.fn(), - drawUploadedImage: jest.fn(), - toggleBlockquote: jest.fn(), - toggleCodeBlock: jest.fn(), - toggleHeading1: jest.fn(), - toggleHeading2: jest.fn(), - toggleHeading3: jest.fn(), - toggleOrderedList: jest.fn(), - toggleStrikethrough: jest.fn(), - toggleUnorderedList: jest.fn(), - }) - - return { - __esModule: true, - default: EasyMDEMock, - } -}) - -describe('FieldMarkdownEditor', () => { - beforeEach(() => { - jest.clearAllMocks() - mockEasyMDEInstances.length = 0 - }) - - it('uses the latest read-only state for the EasyMDE upload callback', async () => { - const uploadAttachment = jest.fn() - .mockResolvedValue({ - url: 'https://example.com/uploaded.png', - }) - - const rendered: ReturnType = render( - , - ) - - await waitFor(() => { - expect(mockEasyMDEInstances) - .toHaveLength(1) - }) - - const easyMDE = mockEasyMDEInstances[0] - rendered.rerender( - , - ) - - await easyMDE.options.imageUploadFunction( - new File(['image'], 'uploaded.png', { type: 'image/png' }), - ) - - expect(uploadAttachment) - .not - .toHaveBeenCalled() - }) - - it('installs EasyMDE upload handlers so editable transitions can upload', async () => { - render() - - await waitFor(() => { - expect(mockEasyMDEInstances) - .toHaveLength(1) - }) - - expect(mockEasyMDEInstances[0].options.uploadImage) - .toBe(true) - }) -}) diff --git a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.tsx b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.tsx index 3484a0c04..37dea39b8 100644 --- a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.tsx +++ b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.tsx @@ -51,7 +51,6 @@ interface Props { maxCharactersAllowed?: number textareaId?: string ariaLabel?: string - readOnly?: boolean } const errorMessages = { fileTooLarge: @@ -153,24 +152,10 @@ const toggleStrategy = { } type CodeMirrorType = keyof typeof stateStrategy | 'variable-2' -type UploadImageHandler = (file: File) => Promise - -const readOnlyUploadEvents = [ - 'dragend', - 'dragenter', - 'dragleave', - 'dragover', - 'drop', - 'paste', -] export const FieldMarkdownEditor: FC = (props: Props) => { const elementRef = useRef(null) const easyMDE = useRef(null) - const customUploadImageRef = useRef(async () => undefined) - const isReadOnlyRef = useRef(false) - const isReadOnly = !!props.disabled || !!props.readOnly - isReadOnlyRef.current = isReadOnly const [remainingCharacters, setRemainingCharacters] = useState( (props.maxCharactersAllowed || 0) - (props.initialValue?.length || 0), ) @@ -542,10 +527,6 @@ export const FieldMarkdownEditor: FC = (props: Props) => { * Upload image */ const customUploadImage = useCallback(async (file: File) => { - if (isReadOnlyRef.current) { - return - } - const editor = easyMDE.current if (!editor) { return @@ -670,7 +651,6 @@ export const FieldMarkdownEditor: FC = (props: Props) => { uploadAttachment, uploadCategory, ]) - customUploadImageRef.current = customUploadImage useOnComponentDidMount(() => { easyMDE.current = new EasyMDE({ @@ -693,7 +673,7 @@ export const FieldMarkdownEditor: FC = (props: Props) => { sbProgress: 'Uploading #file_name#: #progress#%', sizeUnits: ' B, KB, MB', }, - imageUploadFunction: file => customUploadImageRef.current(file), + imageUploadFunction: file => customUploadImage(file), initialValue: props.initialValue ?? '', insertTexts: { file: ['[](', '#url#)'], @@ -888,39 +868,6 @@ export const FieldMarkdownEditor: FC = (props: Props) => { }) }) - useEffect(() => { - if (!easyMDE.current) { - return undefined - } - - easyMDE.current.codemirror.setOption('readOnly', isReadOnly - ? 'nocursor' - : false) - - const wrapper = easyMDE.current.codemirror.getWrapperElement() - const blockReadOnlyUpload = (event: Event): void => { - if (!isReadOnlyRef.current) { - return - } - - event.preventDefault() - event.stopPropagation() - event.stopImmediatePropagation() - } - - if (isReadOnly) { - readOnlyUploadEvents.forEach(eventName => { - wrapper.addEventListener(eventName, blockReadOnlyUpload, true) - }) - } - - return () => { - readOnlyUploadEvents.forEach(eventName => { - wrapper.removeEventListener(eventName, blockReadOnlyUpload, true) - }) - } - }, [isReadOnly]) - useEffect(() => { if (!easyMDE.current) { return @@ -939,7 +886,6 @@ export const FieldMarkdownEditor: FC = (props: Props) => { className={classNames(styles.container, props.className, { [styles.isError]: !!props.error, [styles.disabled]: !!props.disabled, - [styles.readOnly]: !!props.readOnly, [styles.showBorder]: !!props.showBorder, })} > 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 0aa841c61..a9bf48993 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(prevShowReply => !prevShowReply) + setShowReply(!showReply) }, []) const onSubmitReply = useCallback(async (content: string) => { @@ -87,13 +87,9 @@ 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 52beb361c..4f0607625 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' | 'human-override' { +): 'passed' | 'failed-score' | 'pending' | 'failed' { if (!status || status === 'PENDING') { return 'pending' } @@ -121,10 +121,6 @@ function normalizeDecisionStatus( return 'failed' } - if (status === 'HUMAN_OVERRIDE') { - return 'human-override' - } - return 'pending' } diff --git a/src/apps/review/src/lib/components/index.ts b/src/apps/review/src/lib/components/index.ts index 8d4cfa287..70c559437 100644 --- a/src/apps/review/src/lib/components/index.ts +++ b/src/apps/review/src/lib/components/index.ts @@ -17,7 +17,6 @@ export * from './TableReview' export * from './TableReviewForSubmitter' export * from './TableIterativeReview' export * from './ChallengeDetailsContent' -export * from './ChallengeScopedErrorState' export * from './ChallengeTimeline' export * from './ConfirmModal' export * from './ScorecardsFilter' diff --git a/src/apps/review/src/lib/contexts/ChallengeDetailContext.ts b/src/apps/review/src/lib/contexts/ChallengeDetailContext.ts index 7560bb931..e314c37c4 100644 --- a/src/apps/review/src/lib/contexts/ChallengeDetailContext.ts +++ b/src/apps/review/src/lib/contexts/ChallengeDetailContext.ts @@ -10,12 +10,7 @@ export const ChallengeDetailContext: Context aiReviewDecisionsBySubmissionId: {}, challengeId: undefined, challengeInfo: undefined, - challengeInfoError: undefined, - challengeResourcesError: undefined, - challengeScopedFetchError: undefined, challengeSubmissions: [], - challengeSubmissionsError: undefined, - hasChallengeScopedFetchError: false, isLoadingAiReviewConfig: false, isLoadingAiReviewDecisions: false, isLoadingChallengeInfo: false, @@ -26,6 +21,5 @@ export const ChallengeDetailContext: Context registrants: [], resourceMemberIdMapping: {}, resources: [], - retryChallengeScopedFetches: () => undefined, reviewers: [], }) diff --git a/src/apps/review/src/lib/contexts/ChallengeDetailContextProvider.tsx b/src/apps/review/src/lib/contexts/ChallengeDetailContextProvider.tsx index cf662918f..8a64fb033 100644 --- a/src/apps/review/src/lib/contexts/ChallengeDetailContextProvider.tsx +++ b/src/apps/review/src/lib/contexts/ChallengeDetailContextProvider.tsx @@ -1,7 +1,7 @@ /** * Context provider for challenge detail page */ -import { FC, PropsWithChildren, useCallback, useContext, useMemo } from 'react' +import { FC, PropsWithChildren, useContext, useMemo } from 'react' import { useParams } from 'react-router-dom' import { convertBackendSubmissionToSubmissionInfo } from '../models' @@ -35,9 +35,7 @@ export const ChallengeDetailContextProvider: FC = props => { // fetch challenge info const { challengeInfo, - error: challengeInfoError, isLoading: isLoadingChallengeInfo, - retry: retryChallengeInfo, }: useFetchChallengeInfoProps = useFetchChallengeInfo(challengeId) // fetch challenge resources @@ -47,9 +45,7 @@ export const ChallengeDetailContextProvider: FC = props => { reviewers, myResources, myRoles, - error: challengeResourcesError, isLoading: isLoadingChallengeResources, - retry: retryChallengeResources, resourceMemberIdMapping, }: useFetchChallengeResourcesProps = useFetchChallengeResources(challengeId) const submissionViewer = useMemo( @@ -84,13 +80,15 @@ export const ChallengeDetailContextProvider: FC = props => { return { isCompleted, isDesign, submissionsViewable } }, - [challengeInfo], + [ + challengeInfo?.track?.name, + challengeInfo?.status, + challengeInfo?.metadata, + ], ) const { challengeSubmissions, - error: challengeSubmissionsError, isLoading: isLoadingChallengeSubmissions, - retry: retryChallengeSubmissions, }: useFetchChallengeSubmissionsProps = useFetchChallengeSubmissions( challengeId, submissionViewer, @@ -137,22 +135,6 @@ export const ChallengeDetailContextProvider: FC = props => { () => isLoadingChallengeInfo, [isLoadingChallengeInfo], ) - const challengeScopedFetchError = useMemo( - () => challengeInfoError ?? challengeResourcesError ?? challengeSubmissionsError, - [challengeInfoError, challengeResourcesError, challengeSubmissionsError], - ) - const retryChallengeScopedFetches = useCallback((): void => { - Promise.resolve(retryChallengeInfo()) - .catch(() => undefined) - Promise.resolve(retryChallengeResources()) - .catch(() => undefined) - Promise.resolve(retryChallengeSubmissions()) - .catch(() => undefined) - }, [ - retryChallengeInfo, - retryChallengeResources, - retryChallengeSubmissions, - ]) const value = useMemo( () => ({ @@ -160,12 +142,7 @@ export const ChallengeDetailContextProvider: FC = props => { aiReviewDecisionsBySubmissionId, challengeId, challengeInfo: enrichedChallengeInfo, - challengeInfoError, - challengeResourcesError, - challengeScopedFetchError, challengeSubmissions, - challengeSubmissionsError, - hasChallengeScopedFetchError: !!challengeScopedFetchError, isLoadingAiReviewConfig, isLoadingAiReviewDecisions, isLoadingChallengeInfo: isLoadingChallengeInfoCombined, @@ -176,17 +153,12 @@ export const ChallengeDetailContextProvider: FC = props => { registrants, resourceMemberIdMapping, resources, - retryChallengeScopedFetches, reviewers, }), [ challengeId, enrichedChallengeInfo, - challengeInfoError, - challengeResourcesError, - challengeScopedFetchError, challengeSubmissions, - challengeSubmissionsError, isLoadingChallengeInfoCombined, isLoadingChallengeResources, isLoadingChallengeSubmissions, @@ -197,7 +169,6 @@ export const ChallengeDetailContextProvider: FC = props => { myResources, myRoles, registrants, - retryChallengeScopedFetches, resourceMemberIdMapping, resources, reviewers, diff --git a/src/apps/review/src/lib/hooks/useFetchActiveReviews.spec.tsx b/src/apps/review/src/lib/hooks/useFetchActiveReviews.spec.tsx deleted file mode 100644 index 5ff7d42cb..000000000 --- a/src/apps/review/src/lib/hooks/useFetchActiveReviews.spec.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ -import { - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react' - -import { - fetchActiveReviews, -} from '../services' - -import { useFetchActiveReviews } from './useFetchActiveReviews' -import type { useFetchActiveReviewsProps } from './useFetchActiveReviews' - -jest.mock('~/config', () => ({ - EnvironmentConfig: { - REVIEW: { - PROFILE_PAGE_URL: 'https://profiles.example.com', - }, - }, -}), { virtual: true }) - -jest.mock('~/libs/core', () => ({ - getRatingColor: jest.fn() - .mockReturnValue('#000000'), -}), { virtual: true }) - -jest.mock('~/libs/shared', () => ({ - handleError: jest.fn(), -}), { virtual: true }) - -jest.mock('../services', () => ({ - fetchActiveReviews: jest.fn(), -})) - -const mockedFetchActiveReviews = fetchActiveReviews as jest.Mock - -const TestComponent = (): JSX.Element => { - const { - activeReviews, - loadActiveReviews, - }: useFetchActiveReviewsProps = useFetchActiveReviews() - const reviewNames: string = activeReviews.map(review => review.name) - .join(', ') - - function handleLoad(): void { - loadActiveReviews({ - page: 1, - perPage: 50, - }) - .catch(() => undefined) - } - - return ( - <> - -
    {reviewNames || 'No active reviews'}
    - - ) -} - -describe('useFetchActiveReviews', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - it('clears cached review assignments when a fresh load errors', async () => { - mockedFetchActiveReviews - .mockResolvedValueOnce({ - data: [ - { - challengeId: 'challenge-1', - challengeName: 'Restricted Challenge', - status: 'ACTIVE', - }, - ], - meta: { - page: 1, - perPage: 50, - totalCount: 1, - totalPages: 1, - }, - } as never) - .mockRejectedValueOnce(new Error('Forbidden')) - - render() - - fireEvent.click(screen.getByRole('button', { name: 'Load' })) - - await waitFor(() => { - expect(screen.getByText('Restricted Challenge')) - .toBeTruthy() - }) - - fireEvent.click(screen.getByRole('button', { name: 'Load' })) - - await waitFor(() => { - expect(screen.queryByText('Restricted Challenge')) - .toBeNull() - }) - expect(screen.getByText('No active reviews')) - .toBeTruthy() - }) -}) diff --git a/src/apps/review/src/lib/hooks/useFetchActiveReviews.ts b/src/apps/review/src/lib/hooks/useFetchActiveReviews.ts index 643e2f058..e12ee1d3a 100644 --- a/src/apps/review/src/lib/hooks/useFetchActiveReviews.ts +++ b/src/apps/review/src/lib/hooks/useFetchActiveReviews.ts @@ -267,13 +267,6 @@ export function useFetchActiveReviews(): useFetchActiveReviewsProps { }) } catch (error) { if (latestRequestKeyRef.current === requestKey) { - setActiveReviews([]) - setPagination({ - page: mergedParams.page, - perPage: mergedParams.perPage, - totalCount: 0, - totalPages: 1, - }) handleError(error) } } finally { diff --git a/src/apps/review/src/lib/hooks/useFetchAppealQueue.ts b/src/apps/review/src/lib/hooks/useFetchAppealQueue.ts index 016a68832..e07cfbaff 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 { fetchAllAppealsWithReviewIds } from '../services' +import { fetchAppealsWithReviewId } from '../services' export interface useFetchAppealQueueProps { mappingReviewAppeal: MappingReviewAppeal // from review id to appeal info @@ -35,11 +35,9 @@ export function useFetchAppealQueue(): useFetchAppealQueueProps { return } - const nextIds = Array.from(new Set(idLoadQueue.current)) - .filter(id => !mappingReviewAppealRef.current[id]) - idLoadQueue.current = [] - - if (!nextIds.length) { + const nextId = idLoadQueue.current[0] + idLoadQueue.current = idLoadQueue.current.slice(1) + if (mappingReviewAppealRef.current[nextId]) { fetchNextDataInQueue() return } @@ -51,29 +49,24 @@ export function useFetchAppealQueue(): useFetchAppealQueueProps { } const fetchDataFail = (): void => { - nextIds.forEach(id => { - mappingReviewAppealRef.current[id] = { - finishAppeals: 0, - totalAppeals: 0, - } - }) + mappingReviewAppealRef.current[nextId] = { + finishAppeals: 0, + totalAppeals: 0, + } setMappingReviewAppeal({ ...mappingReviewAppealRef.current, }) finish() } - fetchAllAppealsWithReviewIds(nextIds) + // Fetch appeal datas + fetchAppealsWithReviewId(1, 100, nextId) .then(res => { - nextIds.forEach(id => { - const reviewAppeals = res.filter(item => item.reviewId === id) - - mappingReviewAppealRef.current[id] = { - finishAppeals: filter(reviewAppeals, item => !!item.appealResponse) - .length, - totalAppeals: reviewAppeals.length, - } - }) + mappingReviewAppealRef.current[nextId] = { + finishAppeals: filter(res, item => !!item.appealResponse) + .length, + totalAppeals: res.length, + } setMappingReviewAppeal({ ...mappingReviewAppealRef.current, }) diff --git a/src/apps/review/src/lib/hooks/useFetchChallengeInfo.ts b/src/apps/review/src/lib/hooks/useFetchChallengeInfo.ts index 31956bf61..ca990b158 100644 --- a/src/apps/review/src/lib/hooks/useFetchChallengeInfo.ts +++ b/src/apps/review/src/lib/hooks/useFetchChallengeInfo.ts @@ -16,10 +16,7 @@ import { fetchChallengeInfoById } from '../services' export interface useFetchChallengeInfoProps { challengeInfo: ChallengeInfo | undefined - error: Error | undefined - isError: boolean isLoading: boolean - retry: () => Promise } /** @@ -35,7 +32,6 @@ export function useFetchChallengeInfo( data: challengeInfo, error: fetchChallengeInfoError, isValidating: isLoading, - mutate, }: SWRResponse = useSWR( `challengeBaseUrl/challenges/${challengeId}`, { @@ -52,12 +48,7 @@ export function useFetchChallengeInfo( }, [fetchChallengeInfoError]) return { - challengeInfo: fetchChallengeInfoError - ? undefined - : challengeInfo, - error: fetchChallengeInfoError, - isError: !!fetchChallengeInfoError, + challengeInfo, isLoading, - retry: () => mutate(), } } diff --git a/src/apps/review/src/lib/hooks/useFetchChallengeResources.ts b/src/apps/review/src/lib/hooks/useFetchChallengeResources.ts index 63bf0d831..7ac6f4b6b 100644 --- a/src/apps/review/src/lib/hooks/useFetchChallengeResources.ts +++ b/src/apps/review/src/lib/hooks/useFetchChallengeResources.ts @@ -17,10 +17,7 @@ export interface useFetchChallengeResourcesProps { reviewers: BackendResource[] myResources: BackendResource[] myRoles: string[] - error: Error | undefined - isError: boolean isLoading: boolean - retry: () => Promise resourceMemberIdMapping: { [memberId: string]: BackendResource } @@ -46,7 +43,6 @@ export function useFetchChallengeResources( data: resources, error: fetchResourcesError, isValidating: isLoading, - mutate, }: SWRResponse = useSWR( `resourceBaseUrl/resources?challengeId=${challengeId}`, { @@ -124,27 +120,12 @@ export function useFetchChallengeResources( }, [resourcesWithRoleName, resourceRoleReviewer]) return { - error: fetchResourcesError, - isError: !!fetchResourcesError, isLoading, - myResources: fetchResourcesError - ? [] - : myResources, - myRoles: fetchResourcesError - ? [] - : myRoles, - registrants: fetchResourcesError - ? [] - : registrants, - resourceMemberIdMapping: fetchResourcesError - ? {} - : resourceMemberIdMapping, - resources: fetchResourcesError - ? [] - : resourcesWithRoleName, - retry: () => mutate(), - reviewers: fetchResourcesError - ? [] - : reviewers, + myResources, + myRoles, + registrants, + resourceMemberIdMapping, + resources: resourcesWithRoleName, + reviewers, } } diff --git a/src/apps/review/src/lib/hooks/useFetchChallengeSubmissions.ts b/src/apps/review/src/lib/hooks/useFetchChallengeSubmissions.ts index 78d8c4552..172f199f0 100644 --- a/src/apps/review/src/lib/hooks/useFetchChallengeSubmissions.ts +++ b/src/apps/review/src/lib/hooks/useFetchChallengeSubmissions.ts @@ -14,10 +14,7 @@ export interface useFetchChallengeSubmissionsProps { challengeSubmissions: BackendSubmission[] deletedLegacySubmissionIds: Set deletedSubmissionIds: Set - error: Error | undefined - isError: boolean isLoading: boolean - retry: () => Promise } interface ChallengeSubmissionsMemoResult { @@ -55,7 +52,6 @@ export function useFetchChallengeSubmissions( data: challengeSubmissions, error, isValidating: isLoading, - mutate, }: SWRResponse = useSWR< BackendSubmission[], Error @@ -155,7 +151,8 @@ export function useFetchChallengeSubmissions( const allowViewAllSubmissionsForDesign = useMemo( () => Boolean( - challengeVisibility?.isDesign + challengeVisibility + && challengeVisibility.isDesign && challengeVisibility.isCompleted && challengeVisibility.submissionsViewable, ), @@ -249,25 +246,10 @@ export function useFetchChallengeSubmissions( allowViewAllSubmissionsForDesign, ]) - if (error) { - return { - challengeSubmissions: [], - deletedLegacySubmissionIds: new Set(), - deletedSubmissionIds: new Set(), - error, - isError: true, - isLoading, - retry: () => mutate(), - } - } - return { challengeSubmissions: filteredSubmissions, deletedLegacySubmissionIds, deletedSubmissionIds, - error, - isError: false, isLoading, - retry: () => mutate(), } } diff --git a/src/apps/review/src/lib/models/AppealInfo.model.ts b/src/apps/review/src/lib/models/AppealInfo.model.ts index b516e048a..db6aaf197 100644 --- a/src/apps/review/src/lib/models/AppealInfo.model.ts +++ b/src/apps/review/src/lib/models/AppealInfo.model.ts @@ -5,7 +5,6 @@ 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 39f63033f..45d043294 100644 --- a/src/apps/review/src/lib/models/BackendAppeal.model.ts +++ b/src/apps/review/src/lib/models/BackendAppeal.model.ts @@ -12,7 +12,6 @@ export interface BackendAppealBase { export interface BackendAppeal extends BackendAppealBase { resourceId: string id: string - reviewId?: string appealResponse?: BackendAppealResponse createdAt: string createdBy: string @@ -31,7 +30,6 @@ 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/models/ChallengeDetailContextModel.model.ts b/src/apps/review/src/lib/models/ChallengeDetailContextModel.model.ts index 0208d4549..5f5b3491d 100644 --- a/src/apps/review/src/lib/models/ChallengeDetailContextModel.model.ts +++ b/src/apps/review/src/lib/models/ChallengeDetailContextModel.model.ts @@ -8,17 +8,11 @@ import { AiReviewConfig, AiReviewDecision } from './AiReview.model' */ export interface ChallengeDetailContextModel { challengeId?: string - challengeInfoError?: Error isLoadingChallengeInfo: boolean - challengeResourcesError?: Error isLoadingChallengeResources: boolean challengeInfo?: ChallengeInfo challengeSubmissions: BackendSubmission[] - challengeSubmissionsError?: Error isLoadingChallengeSubmissions: boolean - challengeScopedFetchError?: Error - hasChallengeScopedFetchError: boolean - retryChallengeScopedFetches: () => void myResources: BackendResource[] myRoles: string[] resources: BackendResource[] diff --git a/src/apps/review/src/lib/services/reviews.service.ts b/src/apps/review/src/lib/services/reviews.service.ts index 9ee2995b0..00debc4cb 100644 --- a/src/apps/review/src/lib/services/reviews.service.ts +++ b/src/apps/review/src/lib/services/reviews.service.ts @@ -455,33 +455,20 @@ export const fetchAllChallengeReviews = async ( * @param resourceId resource id * @returns resolves to the array of appeals */ -const fetchAppealsPage = async ( +export const fetchAppeals = async ( page: number, perPage: number, - filters: { - resourceId?: string - reviewId?: string - reviewIds?: string[] - challengeId?: string - }, -): Promise> => ( - xhrGetAsync>( + resourceId: string, +): Promise => { + const results = await xhrGetAsync< + BackendResponseWithMeta + >( `${EnvironmentConfig.API.V6}/appeals?${qs.stringify({ page, perPage, - ...filters, - }, { - arrayFormat: 'comma', + resourceId, })}`, ) -) - -export const fetchAppeals = async ( - page: number, - perPage: number, - resourceId: string, -): Promise => { - const results = await fetchAppealsPage(page, perPage, { resourceId }) return results.data.map(convertBackendAppeal) } @@ -497,68 +484,19 @@ 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 fetchAppealsPage(page, perPage, { reviewIds }) + const results = await xhrGetAsync< + BackendResponseWithMeta + >( + `${EnvironmentConfig.API.V6}/appeals?${qs.stringify({ + page, + perPage, + reviewId, + })}`, + ) 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 64ee4197d..8b4366a94 100644 --- a/src/apps/review/src/lib/utils/challenge.spec.ts +++ b/src/apps/review/src/lib/utils/challenge.spec.ts @@ -230,29 +230,6 @@ 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 a4e45352f..44aa240e8 100644 --- a/src/apps/review/src/lib/utils/challenge.ts +++ b/src/apps/review/src/lib/utils/challenge.ts @@ -743,15 +743,11 @@ export function shouldForceWinnersTabForPastChallenge( challengeInfo?: WinnersTabFallbackChallengeInfo, approvalReviews?: ApprovalReviewStatusLike[] | null, ): boolean { - if (!isPastChallengeStatus(challengeInfo?.status)) { - return false - } - if (!(challengeInfo?.winners?.length)) { return false } - return !hasPendingApprovalReview(approvalReviews) + return shouldAllowWinnersTabForPastChallenge(challengeInfo, approvalReviews) } export function isReviewPhaseCurrentlyOpen( diff --git a/src/apps/review/src/pages/active-review-assignements/ChallengeDetailsPage/ChallengeDetailsPage.route.spec.tsx b/src/apps/review/src/pages/active-review-assignements/ChallengeDetailsPage/ChallengeDetailsPage.route.spec.tsx deleted file mode 100644 index 487a24495..000000000 --- a/src/apps/review/src/pages/active-review-assignements/ChallengeDetailsPage/ChallengeDetailsPage.route.spec.tsx +++ /dev/null @@ -1,412 +0,0 @@ -/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ -import type { PropsWithChildren, ReactNode } from 'react' -import { - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react' -import { MemoryRouter, Route, Routes } from 'react-router-dom' - -import { - useFetchAiReviewConfig, - useFetchAiReviewDecisions, - useFetchAiWorkflowsRuns, - useFetchChallengeInfo, - useFetchChallengeResources, - useFetchChallengeSubmissions, - useFetchScreeningReview, - useFetchSubmissionInfo, - useRole, -} from '../../../lib/hooks' -import { ReviewAppContext } from '../../../lib/contexts/ReviewAppContext' -import { ChallengeDetailContextProvider } from '../../../lib/contexts/ChallengeDetailContextProvider' -import { ReviewsContextProvider } from '../../reviews/ReviewsContext' -import { ReviewsViewer } from '../../reviews/ReviewsViewer' - -import { ChallengeDetailsPage } from './ChallengeDetailsPage' - -jest.mock('~/config', () => ({ - AppSubdomain: { - review: 'review', - }, - EnvironmentConfig: { - REVIEW: { - CHALLENGE_PAGE_URL: 'https://community.example.com/challenges', - }, - SUBDOMAIN: 'review', - }, -}), { virtual: true }) - -jest.mock('~/apps/admin/src/lib', () => ({ - TableLoading: () =>
    Loading
    , -}), { virtual: true }) - -jest.mock('~/apps/admin/src/lib/utils', () => ({ - handleError: jest.fn(), -}), { virtual: true }) - -jest.mock('~/libs/core', () => ({ - getRatingColor: jest.fn() - .mockReturnValue('#000000'), - UserRole: { - administrator: 'administrator', - projectManager: 'projectManager', - }, - xhrGetAsync: jest.fn(), -}), { virtual: true }) - -jest.mock('~/libs/ui', () => ({ - BaseModal: (props: PropsWithChildren<{ open?: boolean }>) => ( - props.open ?
    {props.children}
    : undefined - ), - Button: (props: PropsWithChildren<{ - label?: string - onClick?: () => void - }>) => ( - - ), - InputCheckbox: () => , - InputDatePicker: () => , - InputText: () => , -}), { virtual: true }) - -jest.mock('../../../lib', () => { - const challengeDetailContext = jest.requireActual('../../../lib/contexts/ChallengeDetailContext') - const reviewAppContext = jest.requireActual('../../../lib/contexts/ReviewAppContext') - - return { - ...challengeDetailContext, - ...reviewAppContext, - ChallengeDetailsContent: () =>
    Challenge details content
    , - ChallengeLinks: () =>
    Challenge links
    , - ChallengePhaseInfo: () =>
    Challenge phase info
    , - ChallengeScopedErrorState: (props: { - message?: string - onRetry: () => void - }) => ( -
    - - {props.message ?? 'Something went wrong while loading the challenge. Please try again.'} - - -
    - ), - ChallengeTimeline: () =>
    Challenge timeline
    , - PageWrapper: (props: PropsWithChildren<{ - breadCrumb?: Array<{ label?: string; path?: string }> - pageTitle?: string - rightHeader?: ReactNode - titleUrl?: string - }>) => ( -
    -

    {props.pageTitle}

    -
    {props.titleUrl}
    -
    - {(props.breadCrumb ?? []) - .map(item => `${item.label ?? ''}:${item.path ?? ''}`) - .join('|')} -
    -
    {props.rightHeader}
    - {props.children} -
    - ), - TableNoRecord: (props: { message?: string }) =>
    {props.message}
    , - TableRegistration: () =>
    Resources table
    , - Tabs: () =>
    Tabs
    , - } -}) - -jest.mock('../../../lib/hooks', () => ({ - useFetchAiReviewConfig: jest.fn(), - useFetchAiReviewDecisions: jest.fn(), - useFetchAiWorkflowsRuns: jest.fn(), - useFetchChallengeInfo: jest.fn(), - useFetchChallengeResources: jest.fn(), - useFetchChallengeSubmissions: jest.fn(), - useFetchScreeningReview: jest.fn(), - useFetchSubmissionInfo: jest.fn(), - useRole: jest.fn(), -})) - -jest.mock('../../../lib/services', () => ({ - updateChallengePhase: jest.fn(), - updatePhaseChangeNotifications: jest.fn(), -})) - -jest.mock('../../reviews/components/ReviewsSidebar', () => ({ - ReviewsSidebar: () =>
    Reviews sidebar
    , -})) - -jest.mock('../../reviews/components/AiReviewViewer', () => ({ - AiReviewViewer: () =>
    AI review viewer
    , -})) - -jest.mock('../../reviews/components/ReviewViewer', () => ({ - ReviewViewer: () =>
    Review viewer
    , -})) - -jest.mock('../../../lib/components/SubmissionBarInfo', () => ({ - SubmissionBarInfo: () =>
    Submission bar
    , -})) - -const mockedUseFetchAiReviewConfig = useFetchAiReviewConfig as jest.Mock -const mockedUseFetchAiReviewDecisions = useFetchAiReviewDecisions as jest.Mock -const mockedUseFetchAiWorkflowsRuns = useFetchAiWorkflowsRuns as jest.Mock -const mockedUseFetchChallengeInfo = useFetchChallengeInfo as jest.Mock -const mockedUseFetchChallengeResources = useFetchChallengeResources as jest.Mock -const mockedUseFetchChallengeSubmissions = useFetchChallengeSubmissions as jest.Mock -const mockedUseFetchScreeningReview = useFetchScreeningReview as jest.Mock -const mockedUseFetchSubmissionInfo = useFetchSubmissionInfo as jest.Mock -const mockedUseRole = useRole as jest.Mock - -const retryChallengeInfo = jest.fn() -const retryChallengeResources = jest.fn() -const retryChallengeSubmissions = jest.fn() - -const challengeInfo = { - currentPhase: 'Registration', - currentPhaseEndDate: '2026-01-01T00:00:00.000Z', - id: 'challenge-1', - name: 'Visible Challenge', - phases: [], - status: 'ACTIVE', - submissions: [], - track: { - id: 'track-1', - name: 'Development', - }, - type: { - id: 'type-1', - name: 'Code', - }, - typeId: 'type-1', -} - -/** - * Creates the 403 error shape returned by the mocked challenge-scoped fetches. - * - * @returns an Error object with a 403 status field. - */ -function makeForbiddenError(): Error { - return Object.assign(new Error('Forbidden'), { status: 403 }) -} - -/** - * Renders a review-app route with the review app context provider. - * - * @param route concrete route used as the initial memory history entry. - * @param element route element under test. - * @param path route pattern registered in the memory router. - */ -function renderWithReviewContext(route: string, element: JSX.Element, path: string): void { - render( - - - - - - - , - ) -} - -/** - * Renders the direct challenge details route through ChallengeDetailContextProvider. - */ -function renderChallengeDetailsRoute(): void { - renderWithReviewContext( - '/active-challenges/challenge-1/challenge-details', - - - , - '/active-challenges/:challengeId/challenge-details', - ) -} - -/** - * Renders the nested reviews route through both challenge and reviews providers. - */ -function renderReviewsRoute(): void { - renderWithReviewContext( - '/active-challenges/challenge-1/challenge-details/reviews/submission-1', - - - - - , - '/active-challenges/:challengeId/challenge-details/reviews/:submissionId', - ) -} - -describe('review challenge direct route errors', () => { - beforeEach(() => { - jest.clearAllMocks() - retryChallengeInfo.mockClear() - retryChallengeResources.mockClear() - retryChallengeSubmissions.mockClear() - retryChallengeInfo.mockImplementation(() => Promise.resolve(undefined)) - retryChallengeResources.mockImplementation(() => Promise.resolve(undefined)) - retryChallengeSubmissions.mockImplementation(() => Promise.resolve(undefined)) - - mockedUseFetchChallengeInfo.mockReturnValue({ - challengeInfo, - error: undefined, - isError: false, - isLoading: false, - retry: retryChallengeInfo, - }) - mockedUseFetchChallengeResources.mockReturnValue({ - error: undefined, - isError: false, - isLoading: false, - myResources: [{ - challengeId: 'challenge-1', - created: '2026-01-01T00:00:00.000Z', - createdBy: 'system', - id: 'resource-1', - memberHandle: 'reviewer', - memberId: '1001', - roleId: 'role-1', - roleName: 'Reviewer', - }], - myRoles: ['Reviewer'], - registrants: [], - resourceMemberIdMapping: {}, - resources: [], - retry: retryChallengeResources, - reviewers: [], - }) - mockedUseFetchChallengeSubmissions.mockReturnValue({ - challengeSubmissions: [], - deletedLegacySubmissionIds: new Set(), - deletedSubmissionIds: new Set(), - error: undefined, - isError: false, - isLoading: false, - retry: retryChallengeSubmissions, - }) - mockedUseFetchAiReviewConfig.mockReturnValue({ - aiReviewConfig: undefined, - isLoading: false, - }) - mockedUseFetchAiReviewDecisions.mockReturnValue({ - decisions: [], - isLoading: false, - }) - mockedUseFetchScreeningReview.mockReturnValue({ - approvalMinimumPassingScore: 0, - approvalReviews: [], - checkpoint: [], - checkpointReview: [], - checkpointReviewMinimumPassingScore: 0, - checkpointScreeningMinimumPassingScore: 0, - isLoading: false, - isLoadingReviews: false, - mappingReviewAppeal: {}, - postMortemMinimumPassingScore: 0, - postMortemReviews: [], - review: [], - reviewMinimumPassingScore: 0, - reviewProgress: 0, - screening: [], - screeningMinimumPassingScore: 0, - submitterReviews: [], - }) - mockedUseRole.mockReturnValue({ - actionChallengeRole: 'Reviewer', - }) - mockedUseFetchSubmissionInfo.mockReturnValue([undefined, false]) - mockedUseFetchAiWorkflowsRuns.mockReturnValue({ - isLoading: false, - runs: [], - }) - }) - - it('renders a generic retryable error for a direct challenge-info 403', async () => { - mockedUseFetchChallengeInfo.mockReturnValue({ - challengeInfo: undefined, - error: makeForbiddenError(), - isError: true, - isLoading: false, - retry: retryChallengeInfo, - }) - - renderChallengeDetailsRoute() - - await waitFor(() => { - expect(screen.getByRole('alert').textContent) - .toContain('Something went wrong while loading the challenge. Please try again.') - }) - - fireEvent.click(screen.getByRole('button', { name: 'Retry' })) - - expect(retryChallengeInfo) - .toHaveBeenCalled() - expect(screen.queryByText(/permission to see this challenge/i)) - .toBeNull() - }) - - it('renders a generic retryable error for a direct resources 403 instead of permission denied', async () => { - mockedUseFetchChallengeResources.mockReturnValue({ - error: makeForbiddenError(), - isError: true, - isLoading: false, - myResources: [], - myRoles: [], - registrants: [], - resourceMemberIdMapping: {}, - resources: [], - retry: retryChallengeResources, - reviewers: [], - }) - - renderChallengeDetailsRoute() - - await waitFor(() => { - expect(screen.getByRole('alert').textContent) - .toContain('Something went wrong while loading the challenge. Please try again.') - }) - - expect(screen.queryByText(/permission to see this challenge/i)) - .toBeNull() - }) - - it('renders the shared generic error on the nested reviews route without undefined challenge links', async () => { - mockedUseFetchChallengeInfo.mockReturnValue({ - challengeInfo: undefined, - error: makeForbiddenError(), - isError: true, - isLoading: false, - retry: retryChallengeInfo, - }) - - renderReviewsRoute() - - await waitFor(() => { - expect(screen.getByRole('alert').textContent) - .toContain('Something went wrong while loading the challenge. Please try again.') - }) - - expect(screen.queryByText('Review viewer')) - .toBeNull() - expect(screen.getByTestId('title-url').textContent) - .toBe('') - expect(screen.getByTestId('breadcrumbs').textContent) - .not.toContain('undefined') - }) -}) diff --git a/src/apps/review/src/pages/active-review-assignements/ChallengeDetailsPage/ChallengeDetailsPage.tsx b/src/apps/review/src/pages/active-review-assignements/ChallengeDetailsPage/ChallengeDetailsPage.tsx index e9c31970c..5b3909323 100644 --- a/src/apps/review/src/pages/active-review-assignements/ChallengeDetailsPage/ChallengeDetailsPage.tsx +++ b/src/apps/review/src/pages/active-review-assignements/ChallengeDetailsPage/ChallengeDetailsPage.tsx @@ -23,7 +23,6 @@ import { ChallengeDetailsContent, ChallengeLinks, ChallengePhaseInfo, - ChallengeScopedErrorState, ChallengeTimeline, ChallengeTimelineAction, ChallengeTimelineRow, @@ -281,8 +280,6 @@ export const ChallengeDetailsPage: FC = (props: Props) => { myResources, challengeSubmissions, isLoadingChallengeSubmissions, - hasChallengeScopedFetchError, - retryChallengeScopedFetches, }: ChallengeDetailContextModel = useContext(ChallengeDetailContext) const { loginUserInfo }: ReviewAppContextModel = useContext(ReviewAppContext) const { actionChallengeRole }: useRoleProps = useRole() @@ -1793,14 +1790,10 @@ export const ChallengeDetailsPage: FC = (props: Props) => { - {hasChallengeScopedFetchError ? ( - - ) : isLoadingChallengeInfo ? ( + {isLoadingChallengeInfo ? ( ) : (!isLoadingAnything && hasChallengeInfo && !canViewChallenge) ? (
    diff --git a/src/apps/review/src/pages/reviews/ReviewsContext/ReviewsContextProvider.tsx b/src/apps/review/src/pages/reviews/ReviewsContext/ReviewsContextProvider.tsx index bd250b5ab..ebbf778b6 100644 --- a/src/apps/review/src/pages/reviews/ReviewsContext/ReviewsContextProvider.tsx +++ b/src/apps/review/src/pages/reviews/ReviewsContext/ReviewsContextProvider.tsx @@ -40,7 +40,7 @@ export const ReviewsContextProvider: FC = props => { () => workflowRuns.find(w => w.workflow.id === workflowId), [workflowRuns, workflowId], ) - const workflow = useMemo(() => workflowRun?.workflow, [workflowRun?.workflow]) + const workflow = useMemo(() => workflowRun?.workflow, [workflowRuns, workflowId]) const scorecard = useMemo(() => workflow?.scorecard, [workflow]) const value = useMemo( diff --git a/src/apps/review/src/pages/reviews/ReviewsViewer/ReviewsViewer.tsx b/src/apps/review/src/pages/reviews/ReviewsViewer/ReviewsViewer.tsx index 243988172..6bb3ae350 100644 --- a/src/apps/review/src/pages/reviews/ReviewsViewer/ReviewsViewer.tsx +++ b/src/apps/review/src/pages/reviews/ReviewsViewer/ReviewsViewer.tsx @@ -3,7 +3,7 @@ import { useLocation } from 'react-router-dom' import { EnvironmentConfig } from '~/config' -import { ChallengeScopedErrorState, PageWrapper } from '../../../lib' +import { PageWrapper } from '../../../lib' import { BreadCrumbData, ReviewsContextModel } from '../../../lib/models' import { ReviewsSidebar } from '../components/ReviewsSidebar' import { useReviewsContext } from '../ReviewsContext' @@ -21,70 +21,49 @@ const ReviewsViewer: FC = () => { workflowRun, actionButtons, submissionInfo, - hasChallengeScopedFetchError, - retryChallengeScopedFetches, }: ReviewsContextModel = useReviewsContext() const location = useLocation() const containsPastChallenges = location.pathname.indexOf('/past-challenges/') - const breadCrumb = useMemo(() => { - const items: BreadCrumbData[] = [{ + const breadCrumb = useMemo(() => [ + { index: 1, label: 'Active Challenges', path: `${rootRoute}/${activeReviewAssignmentsRouteId}/`, - }] - - if (!hasChallengeScopedFetchError && challengeInfo) { - items.push({ - fallback: './../../../../challenge-details', - index: 2, - label: challengeInfo.name, - path: containsPastChallenges > -1 - ? `${rootRoute}/past-challenges/${challengeInfo.id}/challenge-details` - : `${rootRoute}/active-challenges/${challengeInfo.id}/challenge-details`, - }) - } - - items.push({ + }, + { + fallback: './../../../../challenge-details', + index: 2, + label: challengeInfo?.name, + path: containsPastChallenges > -1 + ? `${rootRoute}/past-challenges/${challengeInfo?.id}/challenge-details` + : `${rootRoute}/active-challenges/${challengeInfo?.id}/challenge-details`, + }, + { index: 3, label: `Review Scorecard - ${submissionId}`, - }) - - return items - }, [ - challengeInfo, - containsPastChallenges, - hasChallengeScopedFetchError, - submissionId, - ]) + }, + ], [challengeInfo?.name, challengeInfo?.id, submissionId, containsPastChallenges]) return ( - {hasChallengeScopedFetchError ? ( - - ) : ( - <> -
    - -
    -
    - -
    - {!!workflowRun && } - {!workflowRun && } -
    -
    - - )} +
    + +
    +
    + +
    + {!!workflowRun && } + {!workflowRun && } +
    +
    ) } 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 df0f254e9..c3ce57b3f 100644 --- a/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.tsx +++ b/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.tsx @@ -252,13 +252,6 @@ const ReviewsSidebar: FC = props => { status='pending' /> -
  • - } - label='Unlocked' - status='human-override' - /> -
  • { jest.clearAllMocks() }) - it('defaults the approver view to the On Hold (Admin) status filter and both allowed categories', async () => { + it('defaults the engagement approver view to the On Hold (Admin) status filter', async () => { render( , ) @@ -232,13 +232,14 @@ describe('PaymentsListView', () => { expect(mockedGetPayments) .toHaveBeenLastCalledWith(10, 0, { - categories: ['TASK_PAYMENT', 'ENGAGEMENT_PAYMENT'], + category: ['ENGAGEMENT_PAYMENT'], status: ['ON_HOLD_ADMIN'], }) expect(mockFilterBar) .toHaveBeenCalled() expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides) .toEqual(expect.objectContaining({ + category: 'ENGAGEMENT_PAYMENT', status: 'ON_HOLD_ADMIN', })) }) @@ -265,7 +266,7 @@ describe('PaymentsListView', () => { it('applies the default approver status after switching from admin view', async () => { render( , ) @@ -274,18 +275,19 @@ describe('PaymentsListView', () => { expect(mockedGetPayments) .toHaveBeenLastCalledWith(10, 0, {}) - fireEvent.click(screen.getByRole('button', { name: 'Approver View' })) + fireEvent.click(screen.getByRole('button', { name: 'Engagement Approver View' })) await screen.findByText('No payments match your filters.') expect(mockedGetPayments) .toHaveBeenLastCalledWith(10, 0, { - categories: ['TASK_PAYMENT', 'ENGAGEMENT_PAYMENT'], + category: ['ENGAGEMENT_PAYMENT'], status: ['ON_HOLD_ADMIN'], }) expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides) .toEqual(expect.objectContaining({ + category: 'ENGAGEMENT_PAYMENT', status: 'ON_HOLD_ADMIN', })) }) @@ -312,7 +314,7 @@ describe('PaymentsListView', () => { it('lets an explicit status filter override the default approver status', async () => { render( , ) @@ -325,13 +327,14 @@ describe('PaymentsListView', () => { await waitFor(() => { expect(mockedGetPayments) .toHaveBeenLastCalledWith(10, 0, { - categories: ['TASK_PAYMENT', 'ENGAGEMENT_PAYMENT'], + category: ['ENGAGEMENT_PAYMENT'], status: ['PAID'], }) }) expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides) .toEqual(expect.objectContaining({ + category: 'ENGAGEMENT_PAYMENT', status: 'PAID', })) }) @@ -364,7 +367,7 @@ describe('PaymentsListView', () => { })) }) - it('lets approvers reject selected on hold admin payments with an audit note', async () => { + it('lets engagement approvers reject selected on hold admin payments with an audit note', async () => { mockedGetPayments.mockResolvedValue(paymentsResponse as any) mockedGetMemberHandle.mockResolvedValue(new Map([ [111, 'sathya22in'], @@ -373,7 +376,7 @@ describe('PaymentsListView', () => { render( , ) diff --git a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx index 7a7430579..41b64aab6 100644 --- a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx +++ b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx @@ -19,13 +19,11 @@ import PaymentsTable from '../../../lib/components/payments-table/PaymentTable' import styles from './Payments.module.scss' -type PaymentRoleView = 'admin' | 'paymentApprover' | 'wiproTaasAdmin' +type PaymentRoleView = 'admin' | 'engagementApprover' | 'wiproTaasAdmin' type SelectedPaymentAction = 'approve' | 'reject' -const taskPaymentCategory = 'TASK_PAYMENT' const engagementPaymentCategory = 'ENGAGEMENT_PAYMENT' const restrictedRoleDefaultStatus = 'ON_HOLD_ADMIN' -const approverAllowedCategories = [taskPaymentCategory, engagementPaymentCategory] const taasPaymentCategory = 'TAAS_PAYMENT' const topgearPaymentCategory = 'TOPGEAR_PAYMENT' const defaultPageSize = 10 @@ -174,8 +172,8 @@ const PaymentsListView: FC = (props: PaymentsListViewProp const isWiproTaasAdmin = hasRole('Wipro TaaS Admin') const hasPaymentAdminRole = hasRole('Payment Admin') const isPaymentAdmin = hasPaymentAdminRole || isWiproTaasAdmin - const isPaymentApprover = hasRole('Payment Approver') - const canToggleRoleView = isPaymentAdmin && isPaymentApprover + const isEngagementPaymentApprover = hasRole('Engagement Payment Approver') + const canToggleRoleView = isPaymentAdmin && (isEngagementPaymentApprover) const [confirmFlow, setConfirmFlow] = React.useState(undefined) const [isConfirmFormValid, setIsConfirmFormValid] = React.useState(false) const [winnings, setWinnings] = React.useState>([]) @@ -183,110 +181,62 @@ const PaymentsListView: FC = (props: PaymentsListViewProp const selectedPaymentsCount = Object.keys(selectedPayments).length const [isLoading, setIsLoading] = React.useState(false) const [paymentRoleView, setPaymentRoleView] = React.useState( - isPaymentAdmin ? 'admin' : 'paymentApprover', + isPaymentAdmin ? 'admin' : 'engagementApprover', ) - const isApproverView = isPaymentApprover && ( - !isPaymentAdmin || paymentRoleView === 'paymentApprover' + const isEngagementApproverView = isEngagementPaymentApprover && ( + !isPaymentAdmin || paymentRoleView === 'engagementApprover' ) - const restrictedCategory = isWiproTaasAdmin && !hasPaymentAdminRole ? taasPaymentCategory : undefined - const restrictedDefaultStatus = isApproverView ? restrictedRoleDefaultStatus : undefined - const isRestrictedApproverView = isApproverView + const restrictedCategory = isEngagementApproverView + ? engagementPaymentCategory + : (isWiproTaasAdmin && !hasPaymentAdminRole ? taasPaymentCategory : undefined) + const restrictedDefaultStatus = isEngagementApproverView ? restrictedRoleDefaultStatus : undefined + const isRestrictedApproverView = isEngagementApproverView const [filters, setFilters] = React.useState>({}) - + const hasSelectedStatusFilter = (filters.status?.length ?? 0) > 0 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) { - // WiproTaasAdmin scoped to a single category - let statusFilter: Record = {} - if (filters.status && filters.status[0] !== 'all') { - statusFilter = { status: activeFilters.status } - } - - return { - ...activeFilters, - category: [restrictedCategory], - ...statusFilter, - } + if (!restrictedCategory) { + return filters } - if (isApproverView) { - // Payment Approver: restrict to allowed categories, default status ON_HOLD_ADMIN - let statusFilter: Record = {} - if (filters.status && filters.status[0] !== 'all') { - statusFilter = { status: activeFilters.status } - } else if (!filters.status && restrictedDefaultStatus) { - statusFilter = { status: [restrictedDefaultStatus] } - } - - let categoryFilter: Record = {} - if ( - activeFilters.category - && approverAllowedCategories.includes(activeFilters.category[0]) - ) { - categoryFilter = { category: activeFilters.category } - } else if (!filters.category || filters.category[0] === 'all') { - categoryFilter = { categories: ([] as string[]).concat(approverAllowedCategories) } - } - - const rest = { ...activeFilters } - delete rest.category - - return { - ...rest, - ...categoryFilter, - ...statusFilter, - } + return { + ...filters, + category: [restrictedCategory], + ...(hasSelectedStatusFilter + ? { status: filters.status } + : (restrictedDefaultStatus ? { status: [restrictedDefaultStatus] } : {})), } - - return activeFilters - }, [filters, restrictedCategory, restrictedDefaultStatus, isApproverView]) - + }, [filters, hasSelectedStatusFilter, restrictedCategory, restrictedDefaultStatus]) const hasActiveFilters = React.useMemo( () => Object.entries(appliedFilters) - .some(([key, value]) => key !== 'category' && key !== 'categories' && value.length > 0), + .some(([key, value]) => key !== 'category' && value.length > 0), [appliedFilters], ) const selectedValueOverrides = React.useMemo>(() => { - if (restrictedCategory) { - const statusOverride = filters.status?.[0] !== 'all' ? filters.status?.[0] : undefined - - return { - category: restrictedCategory, - ...(statusOverride ? { status: statusOverride } : {}), - } + if (!restrictedCategory) { + return {} as Record } - if (isApproverView) { - const statusOverride = filters.status?.[0] !== 'all' ? filters.status?.[0] : undefined + const statusOverride = filters.status?.[0] ?? restrictedDefaultStatus - return { - ...(statusOverride ? { status: statusOverride } : {}), - } + return { + category: restrictedCategory, + ...(statusOverride ? { status: statusOverride } : {}), } - - return {} as Record - }, [filters.status, restrictedCategory, isApproverView]) + }, [filters.status, restrictedCategory, restrictedDefaultStatus]) 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, restrictedDefaultStatus]) + }, [filters.category, filters.date, filters.status, restrictedCategory]) const [pagination, setPagination] = React.useState({ currentPage: 1, pageSize: defaultPageSize, @@ -586,14 +536,14 @@ const PaymentsListView: FC = (props: PaymentsListViewProp > Admin View - {isPaymentApprover && ( + {isEngagementPaymentApprover && ( )}
  • @@ -663,27 +613,7 @@ const PaymentsListView: FC = (props: PaymentsListViewProp ], type: 'dropdown', }, - ...(isWiproTaasAdmin && !hasPaymentAdminRole ? [] : isApproverView ? [ - { - key: 'category', - label: 'Payment Type', - options: [ - { - label: 'All', - value: 'all', - }, - { - label: 'Task Payments', - value: taskPaymentCategory, - }, - { - label: 'Engagement Payments', - value: engagementPaymentCategory, - }, - ], - type: 'dropdown', - }, - ] as Filter[] : [ + ...(isRestrictedApproverView || (isWiproTaasAdmin && !hasPaymentAdminRole) ? [] : [ { key: 'category', label: 'Type', @@ -773,11 +703,20 @@ 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 + } - setFilters(prev => ({ - ...prev, - [key]: value, // store 'all' explicitly; appliedFilters strips it before the API call - })) + return newFilters + }) setSelectedPayments({}) }} onResetFilters={() => { diff --git a/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.module.scss b/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.module.scss index c836fe226..0f9922925 100644 --- a/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.module.scss +++ b/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.module.scss @@ -22,12 +22,6 @@ gap: 10px; flex-wrap: wrap; min-width: 0; - - [class*="InputSelect-module_textSelected"] { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } } .filterContainer { @@ -58,15 +52,6 @@ flex-shrink: 0; margin-left: 30px; } - - .taskApproveBtns { - display: flex; - justify-content: flex-end; - gap: 10px; - width: 100%; - margin-top: 10px; - flex-wrap: wrap; - } } @media (max-width: 768px) { diff --git a/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.tsx b/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.tsx index c575a8d6a..23569ff66 100644 --- a/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.tsx +++ b/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.tsx @@ -151,20 +151,8 @@ const FilterBar: React.FC = (props: FilterBarProps) => { size='lg' /> )} -
    + )} +
    - ), -}), { - 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 b5f76f83c..79a3b9c3e 100644 --- a/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.tsx +++ b/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.tsx @@ -65,6 +65,7 @@ const AcceptApplicationModal: FC = ( const isSubmitting = props.isSubmitting === true + const minStartDate = useMemo(() => new Date(), []) const timezone = useMemo( () => Intl.DateTimeFormat() .resolvedOptions() @@ -190,6 +191,7 @@ const AcceptApplicationModal: FC = ( ({ - rootRoute: '/work', -})) - -jest.mock('../../hooks/useFetchEngagements', () => ({ - useFetchEngagements: jest.fn(), -})) - -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 mockedUseFetchEngagements = useFetchEngagements as jest.MockedFunction - -const baseBillingAccountDetails: BillingAccountDetails = { - budget: 1000, - consumedAmounts: [], - consumedBudget: 0, - id: 80001063, - lockedAmounts: [], - lockedBudget: 0, - name: 'Platform Dev - One', - totalBudgetRemaining: 1000, -} - -function renderModal( - billingAccountDetails: BillingAccountDetails, - showMemberPaymentsRemaining?: boolean, - projectId?: number | string, -): ReturnType { - return render( - , - ) -} - -describe('BillingAccountLineItemsModal', () => { - beforeEach(() => { - mockedUseFetchEngagements.mockReset() - mockedUseFetchEngagements.mockReturnValue({ - engagements: [], - error: undefined, - isLoading: false, - isValidating: false, - metadata: { - page: 1, - perPage: 0, - total: 0, - totalPages: 0, - }, - mutate: jest.fn(), - }) - }) - - 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('shows challenge member payments and calculated challenge fees for non-copilot users', () => { - renderModal({ - ...baseBillingAccountDetails, - lockedAmounts: [ - { - amount: '50', - date: '2026-02-10T00:00:00.000Z', - externalId: 'challenge-100', - externalName: 'Markup Challenge', - externalType: 'CHALLENGE', - }, - ], - lockedBudget: 66.5, - markup: 0.33, - totalBudgetRemaining: 933.5, - }) - - expect(screen.getByText('Member Payments')) - .toBeTruthy() - expect(screen.getByText('Challenge Fee')) - .toBeTruthy() - expect(screen.getAllByText('$50.00')) - .toHaveLength(1) - expect(screen.getByText('$16.50')) - .toBeTruthy() - }) - - it('builds engagement links from assignment-backed billing rows', () => { - mockedUseFetchEngagements.mockReturnValue({ - engagements: [ - { - anticipatedStart: 'IMMEDIATE', - assignedMemberHandles: [], - assignments: [ - { - agreementRate: '100', - endDate: '2026-03-01T00:00:00.000Z', - engagementId: 'engagement-300', - id: 'assignment-300', - memberHandle: 'member', - memberId: 123, - otherRemarks: '', - startDate: '2026-02-01T00:00:00.000Z', - status: 'ACTIVE', - termsAccepted: true, - }, - ], - compensationRange: '$100', - countries: [], - createdAt: '2026-01-01T00:00:00.000Z', - description: 'Engagement description', - durationWeeks: 4, - id: 'engagement-300', - isPrivate: false, - projectId: 'project 200', - requiredMemberCount: 1, - role: 'SOFTWARE_DEVELOPER', - skills: [], - status: 'Active', - timezones: [], - title: 'Resolved Engagement', - updatedAt: '2026-01-01T00:00:00.000Z', - workload: 'FULL_TIME', - }, - ], - error: undefined, - isLoading: false, - isValidating: false, - metadata: { - page: 1, - perPage: 1, - total: 1, - totalPages: 1, - }, - mutate: jest.fn(), - }) - - renderModal({ - ...baseBillingAccountDetails, - consumedAmounts: [ - { - amount: '120', - date: '2026-02-10T00:00:00.000Z', - externalId: 'assignment-300', - externalName: 'Resolved Engagement', - externalType: 'ENGAGEMENT', - }, - ], - consumedBudget: 120, - markup: 0.2, - totalBudgetRemaining: 880, - }, false, 'project 200') - - const engagementLink = screen.getByRole('link', { - name: 'Resolved Engagement', - }) - - expect(engagementLink.getAttribute('href')) - .toBe('/work/projects/project%20200/engagements/engagement-300') - expect(screen.getByText('$100.00')) - .toBeTruthy() - expect(screen.getByText('$20.00')) - .toBeTruthy() - expect(mockedUseFetchEngagements) - .toHaveBeenLastCalledWith( - 'project 200', - { includePrivate: true }, - { enabled: true }, - ) - }) - - it('builds engagement links from assignment-backed billing rows for copilot views', () => { - mockedUseFetchEngagements.mockReturnValue({ - engagements: [ - { - anticipatedStart: 'IMMEDIATE', - assignedMemberHandles: [], - assignments: [ - { - agreementRate: '100', - endDate: '2026-03-01T00:00:00.000Z', - engagementId: 'engagement-300', - id: 'assignment-300', - memberHandle: 'member', - memberId: 123, - otherRemarks: '', - startDate: '2026-02-01T00:00:00.000Z', - status: 'ACTIVE', - termsAccepted: true, - }, - ], - compensationRange: '$100', - countries: [], - createdAt: '2026-01-01T00:00:00.000Z', - description: 'Engagement description', - durationWeeks: 4, - id: 'engagement-300', - isPrivate: false, - projectId: 'project 200', - requiredMemberCount: 1, - role: 'SOFTWARE_DEVELOPER', - skills: [], - status: 'Active', - timezones: [], - title: 'Resolved Engagement', - updatedAt: '2026-01-01T00:00:00.000Z', - workload: 'FULL_TIME', - }, - ], - error: undefined, - isLoading: false, - isValidating: false, - metadata: { - page: 1, - perPage: 1, - total: 1, - totalPages: 1, - }, - mutate: jest.fn(), - }) - - renderModal({ - ...baseBillingAccountDetails, - consumedAmounts: [ - { - amount: '120', - date: '2026-02-10T00:00:00.000Z', - externalId: 'assignment-300', - externalName: 'Resolved Engagement', - externalType: 'ENGAGEMENT', - memberPaymentAmount: '100', - }, - ], - consumedBudget: 120, - memberPaymentsRemaining: 500, - totalBudgetRemaining: 880, - }, true, 'project 200') - - const engagementLink = screen.getByRole('link', { - name: 'Resolved Engagement', - }) - - expect(engagementLink.getAttribute('href')) - .toBe('/work/projects/project%20200/engagements/engagement-300') - expect(mockedUseFetchEngagements) - .toHaveBeenLastCalledWith( - 'project 200', - { includePrivate: true }, - { enabled: true }, - ) - }) - - 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() - }) - - it('shows only remaining member payments and challenge row amounts for copilots', () => { - renderModal({ - ...baseBillingAccountDetails, - consumedBudget: 500, - lockedAmounts: [ - { - amount: '50', - date: '2026-02-10T00:00:00.000Z', - externalId: 'challenge-100', - externalName: 'Markup Challenge', - externalType: 'CHALLENGE', - }, - ], - lockedBudget: 66.5, - markup: 0.33, - memberPaymentsRemaining: 200, - totalBudgetRemaining: 433.5, - }, true) - - expect(screen.getByText('Remaining member payments')) - .toBeTruthy() - expect(screen.getByText('$200.00')) - .toBeTruthy() - expect(screen.getByText('$50.00')) - .toBeTruthy() - expect(screen.queryByText('$37.59')) - .toBeNull() - expect(screen.queryByText('Consumed')) - .toBeNull() - expect(screen.queryByText('Remaining')) - .toBeNull() - expect(screen.queryByText('Challenge Fee')) - .toBeNull() - }) - - it('uses API-provided member-payment row amounts for copilot responses without markup', () => { - renderModal({ - ...baseBillingAccountDetails, - consumedAmounts: [ - { - amount: '125.25', - date: '2026-02-10T00:00:00.000Z', - externalId: 'challenge-100', - externalName: 'Consumed Markup Challenge', - externalType: 'CHALLENGE', - memberPaymentAmount: '100.20', - }, - ], - consumedBudget: 125.25, - memberPaymentsRemaining: 200, - totalBudgetRemaining: 250, - }, true) - - expect(screen.getByText('Remaining member payments')) - .toBeTruthy() - expect(screen.getByText('$200.00')) - .toBeTruthy() - expect(screen.getByText('$100.20')) - .toBeTruthy() - expect(screen.queryByText('$125.25')) - .toBeNull() - expect(screen.queryByText('Remaining')) - .toBeNull() - }) -}) diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx deleted file mode 100644 index 6f7fb7599..000000000 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx +++ /dev/null @@ -1,627 +0,0 @@ -import { - FC, - MouseEvent, - useCallback, - useMemo, - useState, -} from 'react' - -import { - Button, - IconOutline, - IconSolid, -} from '~/libs/ui' - -import { rootRoute } from '../../../config/routes.config' -import { useFetchEngagements } from '../../hooks/useFetchEngagements' -import type { Engagement } from '../../models' -import { - BillingAccountDetails, - BillingAccountLineItem, - combineBillingAccountLineItems, -} from '../../services/billing-accounts.service' -import { calculatePaymentChallengeFee } from '../../utils/payment.utils' -import { - calculateMemberPaymentAmount, - getCopilotMemberPaymentsBudgetInfo, -} from '../../utils/project-billing-account.utils' - -import styles from './BillingAccountLineItemsModal.module.scss' - -type SortField = 'amount' | 'status' | 'date' -type SortOrder = 'asc' | 'desc' - -interface BillingAccountModalLineItem extends BillingAccountLineItem { - challengeFeeAmount?: number - displayAmount?: number -} - -const ENGAGEMENT_ASSIGNMENT_FILTERS = { - includePrivate: true, -} - -const EXTERNAL_TYPE_LABELS: Record = { - CHALLENGE: 'Challenge', - ENGAGEMENT: 'Engagement', -} - -export interface BillingAccountLineItemsModalProps { - billingAccountDetails: BillingAccountDetails - onClose: () => void - projectId?: number | string - showMemberPaymentsRemaining?: boolean -} - -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}` -} - -/** - * Resolves the amount used when sorting modal line items. - * - * @param item Line item already mapped for the current caller role. - * @returns Display amount, or zero when a copilot-safe amount cannot be calculated. - * @remarks Used only by the billing-account details modal amount sort. - */ -function getSortableAmount(item: BillingAccountModalLineItem): number { - return item.displayAmount ?? 0 -} - -function compareByAmount(a: BillingAccountModalLineItem, b: BillingAccountModalLineItem): number { - return getSortableAmount(a) - getSortableAmount(b) -} - -function compareByStatus(a: BillingAccountModalLineItem, b: BillingAccountModalLineItem): number { - return a.status.localeCompare(b.status) -} - -function compareByDate(a: BillingAccountModalLineItem, b: BillingAccountModalLineItem): number { - const dateA = new Date(a.date) - const dateB = new Date(b.date) - return dateA.getTime() - dateB.getTime() -} - -function sortLineItems( - items: BillingAccountModalLineItem[], - sortBy: SortField, - sortOrder: SortOrder, -): BillingAccountModalLineItem[] { - 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 - }) -} - -/** - * Normalizes optional route identifiers before using them in work-app links. - * - * @param value Raw route identifier from project or billing-account row data. - * @returns Trimmed string id, or `undefined` when the value is blank. - * @remarks Used only by billing-account line-item links to avoid invalid route segments. - */ -function normalizeRouteId(value: unknown): string | undefined { - if (value === undefined || value === null) { - return undefined - } - - const normalizedValue = String(value) - .trim() - - return normalizedValue || undefined -} - -/** - * Builds an absolute work-app path with the configured root route prefix. - * - * @param path Path below the work-app root. It must start with `/`. - * @returns Work-app URL path safe for direct anchors. - * @remarks The work app runs at `/work` on non-work subdomains and `/` on the - * work subdomain. - */ -function buildWorkUrl(path: string): string { - const basePath = rootRoute.replace(/\/$/, '') - return `${basePath}${path}` -} - -/** - * Builds the work-app challenge detail URL for a billing-account row. - * - * @param externalId Challenge id from the billing-account line item. - * @returns Work-app challenge URL path. - * @remarks Used by the billing-account details modal name column. - */ -function buildChallengeUrl(externalId: string): string { - return buildWorkUrl(`/challenges/${encodeURIComponent(externalId)}`) -} - -/** - * Builds the work-app engagement editor URL for a billing-account row. - * - * @param projectId Project id that scopes the engagement route. - * @param engagementId Engagement id resolved from the line item assignment id. - * @returns Work-app engagement URL path. - * @remarks Engagement budget entries are keyed by assignment id, so callers - * resolve the engagement id before using this helper. - */ -function buildEngagementUrl(projectId: string, engagementId: string): string { - return buildWorkUrl( - `/projects/${encodeURIComponent(projectId)}/engagements/${encodeURIComponent(engagementId)}`, - ) -} - -/** - * Formats the role-specific line-item amount for the Member Payments column. - * - * @param item Line item already mapped for the current caller role. - * @returns Formatted currency or `-` when a copilot-safe amount is unavailable. - * @remarks Copilot rows use member payment amounts without exposing markup. - * Manager/admin challenge rows use the challenge subtotal returned by the - * billing-account API, while engagement rows still derive the payment amount - * from the billing ledger total. - */ -function formatLineItemAmount(item: BillingAccountModalLineItem): string { - return item.displayAmount === undefined - ? '-' - : formatCurrency(item.displayAmount) -} - -/** - * Formats the line-item challenge fee for the manager/admin fee column. - * - * @param item Line item already mapped for the current caller role. - * @returns Formatted currency, or `-` when the fee cannot be calculated. - * @remarks Copilot rows do not receive fee values because markup is hidden for - * that role. - */ -function formatLineItemChallengeFee(item: BillingAccountModalLineItem): string { - return item.challengeFeeAmount === undefined - ? '-' - : formatCurrency(item.challengeFeeAmount) -} - -/** - * Resolves the member-payment amount that should be visible in the row. - * - * @param item Raw locked or consumed billing-account line item. - * @param billingAccountDetails Billing account detail payload containing markup when available. - * @param showMemberPaymentsRemaining Whether the caller needs the copilot-safe view. - * @returns Member payment amount, or `undefined` for copilot rows when it cannot - * be safely calculated. - * @remarks Challenge budget rows already expose the member-payment subtotal - * for every caller. Engagement budget rows store the billing ledger total, so - * they still need markup removed before display. Copilot engagement rows prefer - * API-provided member-payment amounts and fall back to markup math only when - * that safe field is missing. - */ -function getLineItemMemberPaymentAmount( - item: BillingAccountLineItem, - billingAccountDetails: BillingAccountDetails, - showMemberPaymentsRemaining: boolean | undefined, -): number | undefined { - if (item.memberPaymentAmount !== undefined) { - return item.memberPaymentAmount - } - - if (item.externalType === 'CHALLENGE') { - return item.amount - } - - const memberPaymentAmount = calculateMemberPaymentAmount( - item.amount, - billingAccountDetails.markup, - ) - - return memberPaymentAmount !== undefined || showMemberPaymentsRemaining - ? memberPaymentAmount - : item.amount -} - -/** - * Builds the modal row model with the amount that should be visible to the caller. - * - * @param item Raw locked or consumed billing-account line item. - * @param billingAccountDetails Billing account detail payload containing hidden markup when available. - * @param showMemberPaymentsRemaining Whether the caller needs the copilot-safe view. - * @returns A line item with `displayAmount` set to the visible member-payment - * amount and, for non-copilots, `challengeFeeAmount` set to the billing markup fee. - * @remarks Challenge rows use the raw member-payment subtotal for all callers. - * Non-copilot challenge rows also calculate the hidden fee from markup. - * Engagement rows derive member payments from the raw ledger amount and - * billing-account markup. - */ -function getDisplayLineItem( - item: BillingAccountLineItem, - billingAccountDetails: BillingAccountDetails, - showMemberPaymentsRemaining: boolean | undefined, -): BillingAccountModalLineItem { - const displayAmount = getLineItemMemberPaymentAmount( - item, - billingAccountDetails, - showMemberPaymentsRemaining, - ) - const challengeFeeAmount = showMemberPaymentsRemaining - ? undefined - : calculatePaymentChallengeFee(displayAmount, billingAccountDetails.markup) - - return { - ...item, - challengeFeeAmount: challengeFeeAmount !== undefined && challengeFeeAmount >= 0 - ? challengeFeeAmount - : undefined, - displayAmount, - } -} - -/** - * Builds a lookup from engagement assignment ids to their parent engagement ids. - * - * @param engagements Engagements fetched for the current project. - * @returns Map keyed by assignment id with engagement id values. - * @remarks Billing-account engagement rows store assignment ids, while the - * work-app engagement route needs the parent engagement id. - */ -function buildEngagementIdsByAssignmentId(engagements: Engagement[]): Map { - return engagements.reduce((assignmentMap, engagement) => { - const engagementId = normalizeRouteId(engagement.id) - - if (!engagementId) { - return assignmentMap - } - - engagement.assignments.forEach(assignment => { - const assignmentId = normalizeRouteId(assignment.id) - - if (assignmentId) { - assignmentMap.set(assignmentId, engagementId) - } - }) - - return assignmentMap - }, new Map()) -} - -/** - * Resolves the link target for a modal line item name. - * - * @param item Line item rendered in the billing-account details modal. - * @param projectId Project id used for project-scoped engagement routes. - * @param engagementIdsByAssignmentId Lookup from engagement assignment ids to engagement ids. - * @returns Work-app URL path, or `undefined` when the row cannot be linked safely. - * @remarks Challenge rows link directly by external id. Engagement rows first - * use an explicit `engagementId` when provided, then fall back to the assignment - * lookup because current billing-account rows are assignment-keyed. - */ -function getLineItemUrl( - item: BillingAccountModalLineItem, - projectId: string | undefined, - engagementIdsByAssignmentId: Map, -): string | undefined { - const externalId = normalizeRouteId(item.externalId) - - if (item.externalType === 'CHALLENGE' && externalId) { - return buildChallengeUrl(externalId) - } - - if (item.externalType !== 'ENGAGEMENT' || !projectId) { - return undefined - } - - const engagementId = normalizeRouteId(item.engagementId) - || (externalId ? engagementIdsByAssignmentId.get(externalId) : undefined) - - return engagementId - ? buildEngagementUrl(projectId, engagementId) - : undefined -} - -export const BillingAccountLineItemsModal: FC = ( - props: BillingAccountLineItemsModalProps, -) => { - const [sortBy, setSortBy] = useState('date') - const [sortOrder, setSortOrder] = useState('desc') - - const lineItems = useMemo( - () => combineBillingAccountLineItems(props.billingAccountDetails) - .map(item => getDisplayLineItem( - item, - props.billingAccountDetails, - props.showMemberPaymentsRemaining, - )), - [props.billingAccountDetails, props.showMemberPaymentsRemaining], - ) - const normalizedProjectId = useMemo( - () => normalizeRouteId(props.projectId), - [props.projectId], - ) - const hasEngagementLineItems = useMemo( - () => lineItems.some(item => item.externalType === 'ENGAGEMENT' && !!item.externalId), - [lineItems], - ) - const showChallengeFeeColumn = !props.showMemberPaymentsRemaining - const engagementResult = useFetchEngagements( - normalizedProjectId, - ENGAGEMENT_ASSIGNMENT_FILTERS, - { - enabled: !!normalizedProjectId && hasEngagementLineItems, - }, - ) - const engagementIdsByAssignmentId = useMemo( - () => buildEngagementIdsByAssignmentId(engagementResult.engagements), - [engagementResult.engagements], - ) - - const sortedLineItems = useMemo( - () => sortLineItems(lineItems, sortBy, sortOrder), - [lineItems, sortBy, sortOrder], - ) - const copilotBudgetInfo = useMemo(() => ( - props.showMemberPaymentsRemaining - ? getCopilotMemberPaymentsBudgetInfo(props.billingAccountDetails) - : undefined - ), [props.billingAccountDetails, props.showMemberPaymentsRemaining]) - const copilotBudgetStatusClass = copilotBudgetInfo - ? styles[`budget${copilotBudgetInfo.status.charAt(0) - .toUpperCase()}${copilotBudgetInfo.status.slice(1)}`] - : '' - const tableClassName = showChallengeFeeColumn - ? styles.table - : `${styles.table} ${styles.tableWithoutFee}` - - 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

    - -
    - - {props.showMemberPaymentsRemaining ? ( -
    -
    - Remaining member payments - - {copilotBudgetInfo - ? formatCurrency(copilotBudgetInfo.memberPaymentsRemaining) - : '-'} - -
    -
    - ) : ( -
    -
    - 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. -
    - ) : ( - - - - - {showChallengeFeeColumn ? ( - - ) : undefined} - - - - - - - - {sortedLineItems.map(item => { - const displayName = item.externalName || '-' - const lineItemUrl = getLineItemUrl( - item, - normalizedProjectId, - engagementIdsByAssignmentId, - ) - - return ( - - - {showChallengeFeeColumn ? ( - - ) : undefined} - - - - - - ) - })} - -
    - - Challenge Fee - - TypeName - -
    {formatLineItemAmount(item)}{formatLineItemChallengeFee(item)} - - {item.status === 'locked' ? ( - - ) : ( - - )} - {item.status === 'locked' ? 'Locked' : 'Consumed'} - - {EXTERNAL_TYPE_LABELS[item.externalType]} - {lineItemUrl ? ( - - {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 deleted file mode 100644 index 6a622481a..000000000 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './BillingAccountLineItemsModal' diff --git a/src/apps/work/src/lib/components/ErrorMessage/ErrorMessage.module.scss b/src/apps/work/src/lib/components/ErrorMessage/ErrorMessage.module.scss index 0012e5a2a..465ddd847 100644 --- a/src/apps/work/src/lib/components/ErrorMessage/ErrorMessage.module.scss +++ b/src/apps/work/src/lib/components/ErrorMessage/ErrorMessage.module.scss @@ -16,9 +16,4 @@ .message { font-size: 14px; margin: 0; - - a { - color: inherit; - text-decoration: underline; - } } diff --git a/src/apps/work/src/lib/components/ErrorMessage/ErrorMessage.spec.tsx b/src/apps/work/src/lib/components/ErrorMessage/ErrorMessage.spec.tsx deleted file mode 100644 index 4ffff7ea9..000000000 --- a/src/apps/work/src/lib/components/ErrorMessage/ErrorMessage.spec.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* eslint-disable import/no-extraneous-dependencies */ -import { - render, - screen, -} from '@testing-library/react' - -import { ErrorMessage } from './ErrorMessage' - -jest.mock('~/libs/ui', () => ({ - Button: (props: { label: string }) => ( - - ), -}), { - virtual: true, -}) - -describe('ErrorMessage', () => { - it('renders the Topcoder support email as a mailto link', () => { - const message = 'You don’t have access to this project. Please contact support@topcoder.com.' - - render() - - const supportLink = screen.getByRole('link', { name: 'support@topcoder.com' }) - - expect(supportLink.getAttribute('href')) - .toBe('mailto:support@topcoder.com') - expect(supportLink.closest('p')?.textContent) - .toBe(message) - }) -}) diff --git a/src/apps/work/src/lib/components/ErrorMessage/ErrorMessage.tsx b/src/apps/work/src/lib/components/ErrorMessage/ErrorMessage.tsx index 7453ff98e..ce554e97f 100644 --- a/src/apps/work/src/lib/components/ErrorMessage/ErrorMessage.tsx +++ b/src/apps/work/src/lib/components/ErrorMessage/ErrorMessage.tsx @@ -1,48 +1,17 @@ -import { - FC, - ReactNode, -} from 'react' +import { FC } from 'react' import { Button } from '~/libs/ui' import styles from './ErrorMessage.module.scss' interface ErrorMessageProps { - message: ReactNode + message: string onRetry?: () => void } -const SUPPORT_EMAIL = 'support@topcoder.com' - -/** - * Renders the supplied error message with the Topcoder support email converted to a mailto link. - * - * @param message error message text or custom React content to display. - * @returns message content with the support email linked when the message is plain text. - * @remarks Used by ErrorMessage so project access denial messages can keep their configured copy while linking support. - * @throws Does not throw. - */ -function renderMessage(message: ReactNode): ReactNode { - if (typeof message !== 'string' || !message.includes(SUPPORT_EMAIL)) { - return message - } - - const emailIndex = message.indexOf(SUPPORT_EMAIL) - - return ( - <> - {message.slice(0, emailIndex)} - - {SUPPORT_EMAIL} - - {message.slice(emailIndex + SUPPORT_EMAIL.length)} - - ) -} - export const ErrorMessage: FC = (props: ErrorMessageProps) => (
    -

    {renderMessage(props.message)}

    +

    {props.message}

    {props.onRetry ? (
    - {BILLING_ACCOUNT_MEMBER_PAYMENT_DETAILS_ENABLED - ? ( -
    - Billing Account - {props.billingAccountId || 'Unavailable'} -
    - ) - : undefined} +
    + Billing Account + {props.billingAccountId || 'Unavailable'} +
    @@ -375,15 +358,6 @@ 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 3d286cbf5..c14487696 100644 --- a/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.module.scss +++ b/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.module.scss @@ -36,24 +36,12 @@ } .itemHeader { - align-items: flex-start; + align-items: center; 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; @@ -80,7 +68,7 @@ font-size: 12px; } -.metaRow { +.paymentCreator { color: #5b5b5b; display: flex; flex-wrap: wrap; @@ -88,7 +76,7 @@ gap: 4px; } -.metaLabel { +.paymentCreatorLabel { 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 0338e3a0b..2b459612c 100644 --- a/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.spec.tsx +++ b/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.spec.tsx @@ -12,10 +12,6 @@ jest.mock('../../hooks', () => ({ useFetchAssignmentPayments: (...args: unknown[]): unknown => mockUseFetchAssignmentPayments(...args), })) -jest.mock('../../constants', () => ({ - BILLING_ACCOUNT_MEMBER_PAYMENT_DETAILS_ENABLED: true, -})) - jest.mock('~/libs/ui', () => ({ BaseModal: (props: { buttons?: JSX.Element @@ -48,7 +44,7 @@ describe('PaymentHistoryModal', () => { mockUseFetchAssignmentPayments.mockReset() }) - it('renders clickable remarks links, the payment creator handle, and fee details', async () => { + it('renders clickable remarks links and the payment creator handle', async () => { mockUseFetchAssignmentPayments.mockReturnValue({ error: undefined, isLoading: false, @@ -62,13 +58,6 @@ 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', }, @@ -96,9 +85,5 @@ 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 bb7b33318..b74d13f91 100644 --- a/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.tsx +++ b/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.tsx @@ -5,16 +5,12 @@ import { Button, } from '~/libs/ui' -import { - BILLING_ACCOUNT_MEMBER_PAYMENT_DETAILS_ENABLED, -} from '../../constants' import { useFetchAssignmentPayments, } from '../../hooks' import { formatCurrency, getPaymentAmount, - getPaymentChallengeFee, getPaymentCreatorLabel, getPaymentHoursWorked, getPaymentRemarks, @@ -90,10 +86,6 @@ const PaymentHistoryModal: FC = ( ? (
      {paymentsResult.payments.map((payment, index) => { - const paymentAmount = getPaymentAmount(payment) - const paymentChallengeFee = BILLING_ACCOUNT_MEMBER_PAYMENT_DETAILS_ENABLED - ? getPaymentChallengeFee(payment) - : undefined const paymentStatus = getPaymentStatus(payment) const paymentHoursWorked = getPaymentHoursWorked(payment) const paymentRemarks = getPaymentRemarks(payment) @@ -110,21 +102,7 @@ const PaymentHistoryModal: FC = ( className={styles.item} >
      -
      - - {formatCurrency(paymentAmount)} - - {paymentChallengeFee !== undefined - ? ( -
      - - Fee: - - {formatCurrency(paymentChallengeFee)} -
      - ) - : undefined} -
      + {formatCurrency(getPaymentAmount(payment))} {showPaymentStatus ? {paymentStatus} : undefined} @@ -147,8 +125,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 e775b583c..4a4be4b8b 100644 --- a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.module.scss +++ b/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.module.scss @@ -1,9 +1,3 @@ -.noticeStack { - align-items: flex-start; - display: flex; - flex-direction: column; -} - .container { display: inline-flex; align-items: center; @@ -55,44 +49,3 @@ .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 deleted file mode 100644 index 7e10429d1..000000000 --- a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.spec.tsx +++ /dev/null @@ -1,267 +0,0 @@ -/* 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 { WorkAppContext } from '../../contexts/WorkAppContext' -import type { WorkAppContextModel } from '../../models' -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('../../constants', () => { - const constants = { - BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED: true, - BILLING_ACCOUNT_DETAILS_MODAL_ENABLED: true, - } - - Object.assign(globalThis, { mockWorkConstants: constants }) - - return constants -}) - -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 -> - -interface MockWorkConstants { - BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED: boolean - BILLING_ACCOUNT_DETAILS_MODAL_ENABLED: boolean -} - -/** - * Returns the mutable constants mock installed for this spec. - * - * @returns Work app billing feature flag state used by mocked constants. - */ -function getMockWorkConstants(): MockWorkConstants { - return (globalThis as unknown as { - mockWorkConstants: MockWorkConstants - }).mockWorkConstants -} - -const billingAccountDetails: BillingAccountDetails = { - budget: 1000, - consumedAmounts: [], - consumedBudget: 0, - id: 80001063, - lockedAmounts: [], - lockedBudget: 0, - name: 'Test Project Engagement BA', - totalBudgetRemaining: -25, -} - -const defaultContextValue: WorkAppContextModel = { - isAdmin: false, - isAnonymous: false, - isCopilot: false, - isManager: false, - isReadOnly: false, - loginUserInfo: undefined, - userRoles: [], -} - -function renderNotice( - contextValue: WorkAppContextModel = defaultContextValue, - displayMemberPaymentDetailsToCopilots: boolean = false, -): void { - render( - - - - - , - ) -} - -describe('ProjectBillingAccountExpiredNotice', () => { - beforeEach(() => { - jest.clearAllMocks() - - getMockWorkConstants().BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED = true - getMockWorkConstants().BILLING_ACCOUNT_DETAILS_MODAL_ENABLED = true - - 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('hides billing account budget and line-item details while billing details are disabled', () => { - getMockWorkConstants().BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED = false - getMockWorkConstants().BILLING_ACCOUNT_DETAILS_MODAL_ENABLED = false - - renderNotice() - - expect(screen.getByText(/Billing account:/)) - .toBeTruthy() - expect(screen.queryByText('$1,025 / $1,000 spent')) - .toBeNull() - expect(screen.queryByRole('button', { - name: 'View billing account details', - })) - .toBeNull() - expect(screen.queryByRole('dialog')) - .toBeNull() - }) - - it('keeps billing account details and line items available when remaining funds are insufficient', () => { - renderNotice() - - 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' })) - .toBeTruthy() - fireEvent.click(screen.getByRole('button', { - name: 'View billing account details', - })) - - expect(screen.getByRole('dialog') - .textContent) - .toContain('Billing account details for 80001063') - }) - - it('shows member payments remaining instead of spent and total budget for copilots', () => { - mockedUseFetchBillingAccountDetails.mockReturnValue({ - billingAccountDetails: { - ...billingAccountDetails, - budget: 1000, - markup: 0.25, - totalBudgetRemaining: 250, - }, - error: undefined, - isError: false, - isLoading: false, - }) - mockedUseFetchProjectBillingAccount.mockReturnValue({ - billingAccount: { - active: true, - id: '80001063', - markup: 0.25, - name: 'Test Project Engagement BA', - status: 'ACTIVE', - totalBudgetRemaining: 250, - }, - isLoading: false, - }) - - getMockWorkConstants().BILLING_ACCOUNT_DETAILS_MODAL_ENABLED = true - - renderNotice({ - ...defaultContextValue, - isCopilot: true, - userRoles: ['copilot'], - }, true) - - expect(screen.getByText('Member Payments Remaining: $200.00')) - .toBeTruthy() - expect(screen.queryByText('$750 / $1,000 spent')) - .toBeNull() - }) - - it('hides the inline member payment balance and billing account modal access for copilots ' - + 'when disabled', () => { - mockedUseFetchBillingAccountDetails.mockReturnValue({ - billingAccountDetails: { - ...billingAccountDetails, - budget: 1000, - markup: 0.25, - totalBudgetRemaining: 250, - }, - error: undefined, - isError: false, - isLoading: false, - }) - - getMockWorkConstants().BILLING_ACCOUNT_DETAILS_MODAL_ENABLED = true - - renderNotice({ - ...defaultContextValue, - isCopilot: true, - userRoles: ['copilot'], - }) - - expect(screen.queryByText('Member Payments Remaining: $200.00')) - .toBeNull() - expect(screen.queryByText('$750 / $1,000 spent')) - .toBeNull() - expect(screen.queryByRole('button', { - name: 'View billing account details', - })) - .toBeNull() - expect(screen.queryByRole('dialog')) - .toBeNull() - expect(mockedUseFetchBillingAccountDetails) - .toHaveBeenCalledWith(undefined) - }) -}) diff --git a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.tsx b/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.tsx index 12f2818a2..2a824973d 100644 --- a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.tsx +++ b/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.tsx @@ -1,46 +1,21 @@ import { FC, - useCallback, - useContext, useMemo, - useState, } from 'react' import { Link } from 'react-router-dom' -import { IconOutline } from '~/libs/ui' - -import { - BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED, - BILLING_ACCOUNT_DETAILS_MODAL_ENABLED, -} from '../../constants' import { - WorkAppContext, -} from '../../contexts/WorkAppContext' -import { - useFetchBillingAccountDetails, useFetchBillingAccounts, useFetchProjectBillingAccount, } from '../../hooks' -import type { - UseFetchBillingAccountDetailsResult, - UseFetchBillingAccountsResult, - UseFetchProjectBillingAccountResult, -} from '../../hooks' -import type { - BillingAccountDetails, -} from '../../services' -import type { WorkAppContextModel } from '../../models' -import type { - BillingAccountBudgetInfo, - CopilotMemberPaymentsBudgetInfo, -} from '../../utils/project-billing-account.utils' import { - getBillingAccountBudgetInfo, - getCopilotMemberPaymentsBudgetInfo, getProjectBillingAccountChallengeIssue, getProjectBillingAccountNoticeMessage, } from '../../utils/project-billing-account.utils' -import { BillingAccountLineItemsModal } from '../BillingAccountLineItemsModal' +import type { + UseFetchBillingAccountsResult, + UseFetchProjectBillingAccountResult, +} from '../../hooks' import styles from './ProjectBillingAccountExpiredNotice.module.scss' @@ -48,27 +23,7 @@ interface ProjectBillingAccountExpiredNoticeProps { billingAccountId?: number | string billingAccountName?: string canManageProject: boolean - displayMemberPaymentDetailsToCopilots?: boolean - projectId: string -} - -type BillingAccountIssue = ReturnType - -interface BillingAccountDetailsContentProps { - billingAccountId: string - billingAccountName: string | undefined - budgetDisplayContent: JSX.Element | undefined - budgetInfo: BillingAccountBudgetInfo | undefined - onOpenModal: () => void - showDetailsButton: boolean -} - -interface RenderBillingAccountContentParams { - billingAccountDetailsContent: JSX.Element | undefined - billingAccountModal: JSX.Element | undefined - canManageProject: boolean projectId: string - visibleBillingAccountIssue: BillingAccountIssue } function normalizeOptionalString(value: unknown): string | undefined { @@ -82,294 +37,16 @@ function normalizeOptionalString(value: unknown): string | undefined { return normalizedValue || undefined } -function formatCurrency(amount: number, includeCents: boolean = false): string { - return new Intl.NumberFormat('en-US', { - currency: 'USD', - maximumFractionDigits: includeCents ? 2 : 0, - minimumFractionDigits: includeCents ? 2 : 0, - style: 'currency', - }) - .format(amount) -} - -/** - * Identifies copilot-only users whose project payment details are controlled - * by the project-level display flag. - * - * @param workAppContext Current work app user context. - * @returns `true` when the user is a copilot without admin or manager access. - */ -function isRestrictedCopilot(workAppContext: WorkAppContextModel): boolean { - return workAppContext.isCopilot - && !workAppContext.isAdmin - && !workAppContext.isManager -} - -/** - * Resolves whether the current user may see inline project payment amounts. - * - * @param workAppContext Current work app user context. - * @param displayMemberPaymentDetailsToCopilots Project-level copilot display flag. - * @returns `true` when payment amounts may be shown in the notice summary. - */ -function canShowProjectPaymentAmounts( - workAppContext: WorkAppContextModel, - displayMemberPaymentDetailsToCopilots: boolean | undefined, -): boolean { - return !isRestrictedCopilot(workAppContext) - || displayMemberPaymentDetailsToCopilots === true -} - -/** - * Resolves whether the copilot-safe member-payment balance should be shown. - * - * @param workAppContext Current work app user context. - * @param showPaymentAmounts Whether payment amounts are enabled for this project. - * @returns `true` when the user should see member payments remaining. - */ -function canShowMemberPaymentsRemaining( - workAppContext: WorkAppContextModel, - showPaymentAmounts: boolean, -): boolean { - return showPaymentAmounts && isRestrictedCopilot(workAppContext) -} - -interface VisibleBudgetInfoParams { - copilotBudgetInfo: CopilotMemberPaymentsBudgetInfo | undefined - showMemberPaymentsRemaining: boolean - showPaymentAmounts: boolean - standardBudgetInfo: BillingAccountBudgetInfo | undefined -} - -/** - * Selects the budget payload that is safe for the current user to see. - * - * @param params Standard and copilot budget variants with display flags. - * @returns The visible budget payload, or `undefined` when hidden. - */ -function getVisibleBudgetInfo( - params: VisibleBudgetInfoParams, -): BillingAccountBudgetInfo | undefined { - if (!params.showPaymentAmounts) { - return undefined - } - - return params.showMemberPaymentsRemaining - ? params.copilotBudgetInfo - : params.standardBudgetInfo -} - -function getBudgetStatusClass( - budgetInfo: BillingAccountBudgetInfo | undefined, -): string { - return budgetInfo - ? styles[`budget${budgetInfo.status.charAt(0) - .toUpperCase()}${budgetInfo.status.slice(1)}`] - : '' -} - -function renderBudgetDisplayContent( - budgetInfo: BillingAccountBudgetInfo | undefined, - copilotBudgetInfo: CopilotMemberPaymentsBudgetInfo | undefined, - showMemberPaymentsRemaining: boolean, -): JSX.Element | undefined { - if (!budgetInfo) { - return undefined - } - - if (showMemberPaymentsRemaining && copilotBudgetInfo) { - return ( - <> - Member Payments Remaining: - {' '} - {formatCurrency(copilotBudgetInfo.memberPaymentsRemaining, true)} - - ) - } - - return ( - <> - {formatCurrency(budgetInfo.spent)} - {' / '} - {formatCurrency(budgetInfo.totalBudget)} - {' spent'} - - ) -} - -/** - * Hides budget-derived billing account notices when budget display is disabled. - * - * @param billingAccountIssue The billing account issue resolved for the project. - * @returns The issue to display, or `undefined` when budget display is disabled. - */ -function getVisibleBillingAccountIssue( - billingAccountIssue: BillingAccountIssue, -): BillingAccountIssue { - const isInsufficientFundsNoticeHidden = !BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED - && billingAccountIssue === 'insufficient-funds' - - return isInsufficientFundsNoticeHidden - ? undefined - : billingAccountIssue -} - -/** - * Renders the visible billing account label plus optional budget and details controls. - * - * @param props Billing account label, optional budget data, and modal open handler. - * @returns The billing account details row for project pages. - */ -const BillingAccountDetailsContent: FC = ( - props: BillingAccountDetailsContentProps, -) => { - const budgetStatusClass = getBudgetStatusClass(props.budgetInfo) - - return ( -
      - - Billing account: - {' '} - {props.billingAccountName || 'Unknown'} - {' '} - / - {' '} - {props.billingAccountId} - - {props.budgetInfo - ? ( - - {props.budgetDisplayContent} - - ) - : undefined} - {props.showDetailsButton - ? ( - - ) - : undefined} -
      - ) -} - -/** - * Renders the gated billing-account line-item modal. - * - * @param billingAccountDetails Billing account detail payload, if loaded. - * @param isModalOpen Whether the details modal has been requested. - * @param onClose Close handler passed to the modal. - * @param projectId Project id used to build project-scoped line-item links. - * @param showMemberPaymentsRemaining Whether the modal should hide markup and show copilot-safe payment values. - * @returns The line-item modal, or `undefined` when the feature is hidden. - */ -function renderBillingAccountModal( - billingAccountDetails: BillingAccountDetails | undefined, - isModalOpen: boolean, - onClose: () => void, - projectId: string, - showMemberPaymentsRemaining: boolean, -): JSX.Element | undefined { - if (!BILLING_ACCOUNT_DETAILS_MODAL_ENABLED || !isModalOpen || !billingAccountDetails) { - return undefined - } - - return ( - - ) -} - -/** - * Renders the project billing-account issue notice when one should remain visible. - * - * @param params Project billing display state and rendered child content. - * @returns The notice stack, normal details content, or an empty fragment. - */ -function renderBillingAccountContent(params: RenderBillingAccountContentParams): JSX.Element { - if (params.visibleBillingAccountIssue) { - const noticeMessage = getProjectBillingAccountNoticeMessage(params.visibleBillingAccountIssue) - const managedNoticeMessage = `${noticeMessage.slice(0, -1)}, ` - - return ( -
      - {params.billingAccountDetailsContent} -
      - {params.canManageProject - ? ( - <> - {managedNoticeMessage} - - click here to update - - - ) - : ( - {noticeMessage} - )} -
      - {params.billingAccountModal} -
      - ) - } - - if (!params.billingAccountDetailsContent) { - return <> - } - - return ( - <> - {params.billingAccountDetailsContent} - {params.billingAccountModal} - - ) -} - export const ProjectBillingAccountExpiredNotice: FC = ( props: ProjectBillingAccountExpiredNoticeProps, ) => { - const [isModalOpen, setIsModalOpen] = useState(false) - const workAppContext: WorkAppContextModel = useContext(WorkAppContext) - const showPaymentAmounts: boolean = canShowProjectPaymentAmounts( - workAppContext, - props.displayMemberPaymentDetailsToCopilots, - ) - const showMemberPaymentsRemaining: boolean = canShowMemberPaymentsRemaining( - workAppContext, - showPaymentAmounts, - ) - const showMemberPaymentsRemainingInModal: boolean = isRestrictedCopilot(workAppContext) - const showDetailsButton: boolean = BILLING_ACCOUNT_DETAILS_MODAL_ENABLED && showPaymentAmounts - const projectBillingAccountResult: UseFetchProjectBillingAccountResult = useFetchProjectBillingAccount( props.projectId, ) const billingAccountsResult: UseFetchBillingAccountsResult = useFetchBillingAccounts() const billingAccount = projectBillingAccountResult.billingAccount const normalizedBillingAccountId = normalizeOptionalString(props.billingAccountId) - || normalizeOptionalString(billingAccount?.id) - const shouldFetchBillingAccountDetails = (BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED && showPaymentAmounts) - || showMemberPaymentsRemaining - || (showDetailsButton && isModalOpen) - const billingAccountDetailsResult: UseFetchBillingAccountDetailsResult = useFetchBillingAccountDetails( - shouldFetchBillingAccountDetails - ? normalizedBillingAccountId - : undefined, - ) - - const billingAccountDetailsData = billingAccountDetailsResult.billingAccountDetails const normalizedBillingAccountName = normalizeOptionalString(props.billingAccountName) - const billingAccountNameFromLookup: string | undefined = useMemo( (): string | undefined => { if (!normalizedBillingAccountId) { @@ -387,73 +64,48 @@ export const ProjectBillingAccountExpiredNotice: FC { - if (!BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED || !billingAccountDetailsData) { - return undefined - } - - return getBillingAccountBudgetInfo(billingAccountDetailsData) - }, [billingAccountDetailsData]) - const copilotBudgetInfo = useMemo(() => ( - showMemberPaymentsRemaining - ? getCopilotMemberPaymentsBudgetInfo(billingAccountDetailsData) - : undefined - ), [billingAccountDetailsData, showMemberPaymentsRemaining]) - const budgetInfo = getVisibleBudgetInfo({ - copilotBudgetInfo, - showMemberPaymentsRemaining, - showPaymentAmounts, - standardBudgetInfo, - }) - const budgetDisplayContent = renderBudgetDisplayContent( - budgetInfo, - copilotBudgetInfo, - showMemberPaymentsRemaining, - ) + if (billingAccountIssue) { + const noticeMessage = getProjectBillingAccountNoticeMessage(billingAccountIssue) + const managedNoticeMessage = `${noticeMessage.slice(0, -1)}, ` - const handleOpenModal = useCallback((): void => { - setIsModalOpen(true) - }, []) + return ( +
      + {props.canManageProject + ? ( + <> + {managedNoticeMessage} + + click here to update + + + ) + : ( + {noticeMessage} + )} +
      + ) + } - const handleCloseModal = useCallback((): void => { - setIsModalOpen(false) - }, []) + if (!normalizedBillingAccountId) { + return <> + } - const billingAccountDetailsContent = normalizedBillingAccountId - ? ( - - ) - : undefined - const billingAccountModal = renderBillingAccountModal( - billingAccountDetailsData, - showDetailsButton && isModalOpen, - handleCloseModal, - props.projectId, - showMemberPaymentsRemainingInModal, + return ( +
      + + Billing account: + {' '} + {billingAccountName || 'Unknown'} + {' '} + / + {' '} + {normalizedBillingAccountId} + +
      ) - - return renderBillingAccountContent({ - billingAccountDetailsContent, - billingAccountModal, - canManageProject: props.canManageProject, - projectId: props.projectId, - visibleBillingAccountIssue, - }) } export default ProjectBillingAccountExpiredNotice 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 237864a54..2ed8a974d 100644 --- a/src/apps/work/src/lib/components/ProjectCard/ProjectCard.module.scss +++ b/src/apps/work/src/lib/components/ProjectCard/ProjectCard.module.scss @@ -61,10 +61,6 @@ 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 7afa091b3..33174eb19 100644 --- a/src/apps/work/src/lib/components/ProjectCard/ProjectCard.tsx +++ b/src/apps/work/src/lib/components/ProjectCard/ProjectCard.tsx @@ -1,6 +1,5 @@ -import type { +import { FC, - ReactNode, } from 'react' import { Link } from 'react-router-dom' import classNames from 'classnames' @@ -17,7 +16,6 @@ import { ProjectStatus } from '../ProjectStatus' import styles from './ProjectCard.module.scss' interface ProjectCardProps { - billingAccountContent?: ReactNode canEdit?: boolean project: Project selected?: boolean @@ -51,13 +49,6 @@ export const ProjectCard: FC = (props: ProjectCardProps) => { {lastActivity}
      - {props.billingAccountContent - ? ( -
      - {props.billingAccountContent} -
      - ) - : undefined} {props.canEdit ? (
      diff --git a/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.spec.tsx b/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.spec.tsx deleted file mode 100644 index fcd189e1f..000000000 --- a/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.spec.tsx +++ /dev/null @@ -1,227 +0,0 @@ -/* eslint-disable no-var, global-require, @typescript-eslint/no-var-requires */ -/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ -import type { - Context, - PropsWithChildren, -} from 'react' -import { - render, - screen, -} from '@testing-library/react' -import { - MemoryRouter, - Route, - Routes, -} from 'react-router-dom' - -import { WorkAppContextModel } from '../../models' -import { useFetchProject } from '../../hooks' -import { checkProjectAccess } from '../../utils' - -import { - PROJECT_ACCESS_DENIED_MESSAGE, - ProjectRouteAccessGuard, -} from './ProjectRouteAccessGuard' - -var mockWorkAppContext: Context - -jest.mock('~/apps/review/src/lib', () => ({ - PageWrapper: ( - props: PropsWithChildren<{ - pageTitle?: string - }>, - ) => ( -
      -

      {props.pageTitle}

      -
      {props.children}
      -
      - ), -}), { - virtual: true, -}) -jest.mock('~/libs/ui', () => ({ - Button: (props: { label: string }) => ( - - ), - LoadingSpinner: () =>
      Loading Spinner
      , -}), { - virtual: true, -}) -jest.mock('../../contexts', () => { - const React = require('react') as typeof import('react') - - mockWorkAppContext = React.createContext({ - isAdmin: false, - isAnonymous: false, - isCopilot: false, - isManager: false, - isReadOnly: false, - loginUserInfo: undefined, - userRoles: [], - }) - - return { - WorkAppContext: mockWorkAppContext, - } -}) -jest.mock('../../hooks', () => ({ - useFetchProject: jest.fn(), -})) -jest.mock('../../utils', () => ({ - checkProjectAccess: jest.fn(), -})) - -const mockedUseFetchProject = useFetchProject as jest.Mock -const mockedCheckProjectAccess = checkProjectAccess as jest.Mock - -const defaultContextValue: WorkAppContextModel = { - isAdmin: false, - isAnonymous: false, - isCopilot: false, - isManager: true, - isReadOnly: false, - loginUserInfo: { - email: 'manager@example.com', - exp: 0, - handle: 'manager-user', - iat: 0, - roles: ['Project Manager'], - userId: 12345, - } as WorkAppContextModel['loginUserInfo'], - userRoles: ['Project Manager'], -} - -function renderGuard( - route: string, - contextValue: WorkAppContextModel = defaultContextValue, -): void { - const MockWorkAppContext = mockWorkAppContext - - render( - - - - -
      Protected Project Users
      - - )} - /> -
      -
      -
      , - ) -} - -describe('ProjectRouteAccessGuard', () => { - beforeEach(() => { - jest.clearAllMocks() - mockedUseFetchProject.mockReturnValue({ - error: undefined, - isLoading: false, - mutate: jest.fn(), - project: { - id: 200, - members: [{ - userId: 12345, - }], - }, - }) - mockedCheckProjectAccess.mockReturnValue(true) - }) - - it('renders the protected route when project access is allowed', () => { - renderGuard('/projects/200/users') - - expect(mockedCheckProjectAccess) - .toHaveBeenCalledWith(defaultContextValue.userRoles, 12345, expect.objectContaining({ id: 200 })) - expect(screen.getByText('Protected Project Users')) - .toBeTruthy() - }) - - it('renders the protected route when cached project access survives a revalidation error', () => { - mockedUseFetchProject.mockReturnValue({ - error: new Error('Network unavailable'), - isLoading: false, - mutate: jest.fn(), - project: { - id: 200, - members: [{ - userId: 12345, - }], - }, - }) - mockedCheckProjectAccess.mockReturnValue(true) - - renderGuard('/projects/200/users') - - expect(mockedCheckProjectAccess) - .toHaveBeenCalledWith(defaultContextValue.userRoles, 12345, expect.objectContaining({ id: 200 })) - expect(screen.getByText('Protected Project Users')) - .toBeTruthy() - expect(screen.queryByText(PROJECT_ACCESS_DENIED_MESSAGE)) - .toBeNull() - }) - - it('shows loading while project access is resolving', () => { - mockedUseFetchProject.mockReturnValue({ - error: undefined, - isLoading: true, - mutate: jest.fn(), - project: undefined, - }) - - renderGuard('/projects/200/users') - - expect(screen.getByRole('heading', { level: 1, name: 'Users' })) - .toBeTruthy() - expect(screen.getByText('Loading Spinner')) - .toBeTruthy() - expect(screen.queryByText('Protected Project Users')) - .toBeNull() - expect(mockedCheckProjectAccess) - .not.toHaveBeenCalled() - }) - - it('shows the project access denial message when project access is rejected', () => { - mockedCheckProjectAccess.mockReturnValue(false) - - renderGuard('/projects/200/users') - - expect(screen.getByRole('heading', { level: 1, name: 'Users' })) - .toBeTruthy() - const supportLink = screen.getByRole('link', { name: 'support@topcoder.com' }) - - expect(supportLink.getAttribute('href')) - .toBe('mailto:support@topcoder.com') - expect(supportLink.closest('p')?.textContent) - .toBe(PROJECT_ACCESS_DENIED_MESSAGE) - expect(screen.queryByText('Protected Project Users')) - .toBeNull() - }) - - it('shows the project access denial message when the project fetch fails', () => { - mockedUseFetchProject.mockReturnValue({ - error: new Error('Forbidden'), - isLoading: false, - mutate: jest.fn(), - project: undefined, - }) - mockedCheckProjectAccess.mockReturnValue(false) - - renderGuard('/projects/200/users') - - expect(mockedCheckProjectAccess) - .toHaveBeenCalledWith(defaultContextValue.userRoles, 12345, undefined) - const supportLink = screen.getByRole('link', { name: 'support@topcoder.com' }) - - expect(supportLink.getAttribute('href')) - .toBe('mailto:support@topcoder.com') - expect(supportLink.closest('p')?.textContent) - .toBe(PROJECT_ACCESS_DENIED_MESSAGE) - expect(screen.queryByText('Protected Project Users')) - .toBeNull() - }) -}) diff --git a/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.tsx b/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.tsx deleted file mode 100644 index 21dc756f4..000000000 --- a/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { - FC, - PropsWithChildren, - useContext, -} from 'react' -import { useParams } from 'react-router-dom' - -import { PageWrapper } from '~/apps/review/src/lib' - -import { WorkAppContext } from '../../contexts' -import { useFetchProject } from '../../hooks' -import { WorkAppContextModel } from '../../models' -import { checkProjectAccess } from '../../utils' -import { ErrorMessage } from '../ErrorMessage' -import { LoadingSpinner } from '../LoadingSpinner' - -export const PROJECT_ACCESS_DENIED_MESSAGE - = 'You don’t have access to this project. Please contact support@topcoder.com.' - -interface ProjectRouteAccessGuardProps extends PropsWithChildren { - pageTitle: string -} - -/** - * Blocks project-scoped Work routes until the current user has project access. - * - * @param props child route content and fallback page title used while access is loading or denied. - * @returns child route content when the project exists and the caller is an admin or project member. - * @remarks Used by project workspace routes so unauthorized users do not mount pages that fetch project child data. - * Access decisions use cached project data when available, so SWR revalidation errors do not block authorized users. - * @throws Does not throw; missing project access renders the standard project access denial message. - */ -export const ProjectRouteAccessGuard: FC = ( - props: ProjectRouteAccessGuardProps, -) => { - const params: Readonly<{ projectId?: string }> = useParams<'projectId'>() - const projectId = params.projectId?.trim() - - const workAppContext = useContext(WorkAppContext) as WorkAppContextModel - const projectResult = useFetchProject(projectId || undefined) - - if (!projectId) { - return <>{props.children} - } - - if (projectResult.isLoading) { - return ( - - - - ) - } - - const hasProjectAccess = checkProjectAccess( - workAppContext.userRoles, - workAppContext.loginUserInfo?.userId, - projectResult.project, - ) - - if (!hasProjectAccess) { - return ( - - - - ) - } - - return <>{props.children} -} - -export default ProjectRouteAccessGuard diff --git a/src/apps/work/src/lib/components/ProjectRouteAccessGuard/index.ts b/src/apps/work/src/lib/components/ProjectRouteAccessGuard/index.ts deleted file mode 100644 index c9831bfb9..000000000 --- a/src/apps/work/src/lib/components/ProjectRouteAccessGuard/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ProjectRouteAccessGuard' 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 0eac072c4..2210caa3e 100644 --- a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.module.scss +++ b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.module.scss @@ -33,61 +33,6 @@ 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; -} - -.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; -} - .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 8870ba76f..929fd0135 100644 --- a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.spec.tsx +++ b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.spec.tsx @@ -1,56 +1,26 @@ /* 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 { WorkAppContext } from '../../contexts/WorkAppContext' -import type { - Project, - WorkAppContextModel, -} from '../../models' -import type { BillingAccountDetails } from '../../services' -import { - useFetchBillingAccountDetails, - useFetchBillingAccounts, -} from '../../hooks' +import type { Project } from '../../models' +import { 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', + }, })) -jest.mock('../../constants', () => { - const constants = { - BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED: true, - BILLING_ACCOUNT_DETAILS_MODAL_ENABLED: true, - PROJECT_STATUS: { - DRAFT: 'draft', - }, - } - - Object.assign(globalThis, { mockWorkConstants: constants }) - - return constants -}) - jest.mock('../../utils', () => ({ buildProjectChallengesPath: (projectId: string | number) => ( `/projects/${encodeURIComponent(String(projectId))}/challenges` @@ -59,9 +29,6 @@ jest.mock('../../utils', () => ({ })) jest.mock('~/libs/ui', () => ({ - IconOutline: { - InformationCircleIcon: (): JSX.Element => info, - }, LoadingSpinner: () =>
      Loading
      , Table: (props: { columns: Array<{ @@ -90,48 +57,8 @@ jest.mock('../ProjectStatus', () => ({ ProjectStatus: () => Active, })) -const mockedUseFetchBillingAccountDetails = useFetchBillingAccountDetails as jest.MockedFunction< - typeof useFetchBillingAccountDetails -> const mockedUseFetchBillingAccounts = useFetchBillingAccounts as jest.MockedFunction -interface MockWorkConstants { - BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED: boolean - BILLING_ACCOUNT_DETAILS_MODAL_ENABLED: boolean -} - -/** - * Returns the mutable constants mock installed for this spec. - * - * @returns Work app billing feature flag state used by mocked constants. - */ -function getMockWorkConstants(): MockWorkConstants { - return (globalThis as unknown as { - mockWorkConstants: MockWorkConstants - }).mockWorkConstants -} - -const billingAccountDetails: BillingAccountDetails = { - budget: 1000, - consumedAmounts: [], - consumedBudget: 225, - id: 80001063, - lockedAmounts: [], - lockedBudget: 125, - name: 'Access BA', - totalBudgetRemaining: 650, -} - -const defaultContextValue: WorkAppContextModel = { - isAdmin: false, - isAnonymous: false, - isCopilot: false, - isManager: false, - isReadOnly: false, - loginUserInfo: undefined, - userRoles: [], -} - describe('ProjectsTable', () => { const invitedProject: Project = { id: 100440, @@ -149,43 +76,25 @@ describe('ProjectsTable', () => { beforeEach(() => { jest.clearAllMocks() - getMockWorkConstants().BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED = true - getMockWorkConstants().BILLING_ACCOUNT_DETAILS_MODAL_ENABLED = true - mockedUseFetchBillingAccounts.mockReturnValue({ billingAccounts: [], error: undefined, isError: false, isLoading: false, }) - mockedUseFetchBillingAccountDetails.mockReturnValue({ - billingAccountDetails: undefined, - error: undefined, - isError: false, - isLoading: false, - }) }) - function renderTable( - projects: Project[], - contextValue: WorkAppContextModel = defaultContextValue, - ): void { + it('links the project name and open action to the challenges route', () => { render( - - - - - , + + + , ) - } - - it('links the project name and open action to the challenges route', () => { - renderTable([invitedProject]) expect(screen.getByRole('link', { name: 'SK project1' }) .getAttribute('href')) @@ -194,183 +103,4 @@ describe('ProjectsTable', () => { .getAttribute('href')) .toBe('/projects/100440/challenges') }) - - it('hides billing account spent totals and line-item details while billing details are disabled', () => { - getMockWorkConstants().BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED = false - getMockWorkConstants().BILLING_ACCOUNT_DETAILS_MODAL_ENABLED = false - - 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, - }) - - renderTable([{ - ...invitedProject, - billingAccountId: 80001063, - }]) - - expect(screen.getAllByText('Access BA / 80001063').length) - .toBeGreaterThan(0) - expect(screen.queryByText('$350 / $1,000 spent')) - .toBeNull() - expect(screen.queryByRole('button', { - name: 'View billing account details', - })) - .toBeNull() - expect(screen.queryByRole('dialog')) - .toBeNull() - }) - - it('shows billing account spent totals and line-item details when enabled', () => { - 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, - }) - - renderTable([{ - ...invitedProject, - billingAccountId: 80001063, - }]) - - 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') - expect(mockedUseFetchBillingAccountDetails) - .toHaveBeenCalledWith('80001063') - }) - - it('shows member payments remaining for copilot project rows', () => { - mockedUseFetchBillingAccounts.mockReturnValue({ - billingAccounts: [ - { - budget: 1000, - consumedBudget: 225, - id: 80001063, - lockedBudget: 525, - markup: 0.25, - name: 'Access BA', - totalBudgetRemaining: 250, - }, - ], - error: undefined, - isError: false, - isLoading: false, - }) - - getMockWorkConstants().BILLING_ACCOUNT_DETAILS_MODAL_ENABLED = true - - renderTable( - [{ - ...invitedProject, - billingAccountId: 80001063, - details: { - displayMemberPaymentDetailsToCopilots: true, - }, - }], - { - ...defaultContextValue, - isCopilot: true, - userRoles: ['copilot'], - }, - ) - - expect(screen.getAllByText('Member Payments Remaining: $200.00').length) - .toBeGreaterThan(0) - expect(screen.queryByText('$750 / $1,000 spent')) - .toBeNull() - }) - - it('hides inline payment amounts and billing details for copilot project rows ' - + 'when disabled', () => { - mockedUseFetchBillingAccounts.mockReturnValue({ - billingAccounts: [ - { - budget: 1000, - consumedBudget: 225, - id: 80001063, - lockedBudget: 525, - markup: 0.25, - name: 'Access BA', - totalBudgetRemaining: 250, - }, - ], - error: undefined, - isError: false, - isLoading: false, - }) - mockedUseFetchBillingAccountDetails.mockReturnValue({ - billingAccountDetails, - error: undefined, - isError: false, - isLoading: false, - }) - - getMockWorkConstants().BILLING_ACCOUNT_DETAILS_MODAL_ENABLED = true - - renderTable( - [{ - ...invitedProject, - billingAccountId: 80001063, - details: { - displayMemberPaymentDetailsToCopilots: false, - }, - }], - { - ...defaultContextValue, - isCopilot: true, - userRoles: ['copilot'], - }, - ) - - expect(screen.queryByText('Member Payments Remaining: $200.00')) - .toBeNull() - expect(screen.queryByText('$750 / $1,000 spent')) - .toBeNull() - expect(screen.queryByRole('button', { - name: 'View billing account details', - })) - .toBeNull() - expect(screen.queryByRole('dialog')) - .toBeNull() - }) }) diff --git a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx index 19dfe8896..f4eddd831 100644 --- a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx +++ b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx @@ -1,52 +1,27 @@ import { FC, useCallback, - useContext, 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 { - BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED, - BILLING_ACCOUNT_DETAILS_MODAL_ENABLED, - PROJECT_STATUS, -} from '../../constants' -import { WorkAppContext } from '../../contexts/WorkAppContext' -import { - useFetchBillingAccountDetails, - useFetchBillingAccounts, -} from '../../hooks' -import type { - UseFetchBillingAccountDetailsResult, - UseFetchBillingAccountsResult, -} from '../../hooks' +import { PROJECT_STATUS } from '../../constants' import { Project, ProjectStatusValue, - WorkAppContextModel, } from '../../models' -import type { BillingAccount } from '../../services' +import { useFetchBillingAccounts } from '../../hooks' +import type { UseFetchBillingAccountsResult } from '../../hooks' import { buildProjectChallengesPath, } from '../../utils' -import type { - BillingAccountBudgetInfo, - CopilotMemberPaymentsBudgetInfo, -} from '../../utils/project-billing-account.utils' -import { - getBillingAccountBudgetInfo, - getCopilotMemberPaymentsBudgetInfo, -} from '../../utils/project-billing-account.utils' -import { BillingAccountLineItemsModal } from '../BillingAccountLineItemsModal' import { ProjectCard } from '../ProjectCard' import { ProjectStatus } from '../ProjectStatus' @@ -114,123 +89,15 @@ 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 -} - -/** - * Formats budget amounts for compact project-list display. - * - * @param amount Dollar amount to format. - * @param includeCents Whether to include cents in the formatted value. - * @returns USD currency text. - */ -function formatCurrency(amount: number, includeCents: boolean = false): string { - return new Intl.NumberFormat('en-US', { - currency: 'USD', - maximumFractionDigits: includeCents ? 2 : 0, - minimumFractionDigits: includeCents ? 2 : 0, - style: 'currency', - }) - .format(amount) -} - -/** - * Identifies copilot-only users whose project payment details are controlled - * by the project-level display flag. - * - * @param workAppContext Current work app user context. - * @returns `true` when the user is a copilot without admin or manager access. - */ -function isRestrictedCopilot(workAppContext: WorkAppContextModel): boolean { - return workAppContext.isCopilot - && !workAppContext.isAdmin - && !workAppContext.isManager -} - -/** - * Resolves whether the current user may see inline payment amounts for one project row. - * - * @param workAppContext Current work app user context. - * @param project Project row being rendered. - * @returns `true` when payment amounts may be shown in the row summary. - */ -function canShowProjectPaymentAmounts( - workAppContext: WorkAppContextModel, - project: Project, -): boolean { - return !isRestrictedCopilot(workAppContext) - || project.details?.displayMemberPaymentDetailsToCopilots === true -} - -function getBudgetStatusClass( - budgetInfo: BillingAccountBudgetInfo | undefined, - showMemberPaymentsRemaining: boolean, -): string { - return showMemberPaymentsRemaining && budgetInfo - ? styles[`budget${budgetInfo.status.charAt(0) - .toUpperCase()}${budgetInfo.status.slice(1)}`] - : '' -} - -function renderBudgetDisplayContent( - budgetInfo: BillingAccountBudgetInfo | undefined, - copilotBudgetInfo: CopilotMemberPaymentsBudgetInfo | undefined, - showMemberPaymentsRemaining: boolean, -): JSX.Element | undefined { - if (!budgetInfo) { - return undefined - } - - if (showMemberPaymentsRemaining && copilotBudgetInfo) { - return ( - <> - Member Payments Remaining: - {' '} - {formatCurrency(copilotBudgetInfo.memberPaymentsRemaining, true)} - - ) - } - - return ( - <> - {formatCurrency(budgetInfo.spent)} - {' / '} - {formatCurrency(budgetInfo.totalBudget)} - {' spent'} - - ) -} - -/** - * 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, - billingAccount: BillingAccount | undefined, + billingAccountNames: Map, ): string { - const billingAccountId = normalizeOptionalString(project.billingAccountId) - || normalizeOptionalString(billingAccount?.id) - || '' - const billingAccountName = normalizeOptionalString(project.billingAccountName) - || normalizeOptionalString(billingAccount?.name) + const billingAccountId = project.billingAccountId !== undefined && project.billingAccountId !== null + ? String(project.billingAccountId) + .trim() + : '' + const billingAccountName = (project.billingAccountName || '').trim() || billingAccountNames.get(billingAccountId) if (!billingAccountId && !billingAccountName) { return '-' @@ -243,230 +110,6 @@ function getBillingAccountDisplay( return `${billingAccountName || 'Unknown'} / ${billingAccountId}` } -interface ProjectBillingAccountCellProps { - billingAccount: BillingAccount | undefined - project: Project - showPaymentAmounts: boolean - showMemberPaymentsRemaining: boolean - showMemberPaymentsRemainingInModal: boolean -} - -interface ProjectBillingBudgetDisplayState { - budgetInfo: BillingAccountBudgetInfo | undefined - copilotBudgetInfo: CopilotMemberPaymentsBudgetInfo | undefined -} - -interface RenderProjectBillingAccountModalParams { - billingAccountDetails: UseFetchBillingAccountDetailsResult['billingAccountDetails'] - isModalOpen: boolean - onClose: () => void - projectId: Project['id'] - showMemberPaymentsRemaining: boolean -} - -/** - * Resolves whether the billing-account details hook should fetch modal data. - * - * @param isModalOpen Whether the row details modal has been opened. - * @param showDetailsButton Whether this user may open billing-account details. - * @returns `true` when the modal feature is enabled and data should be fetched. - */ -function canFetchProjectBillingAccountDetails( - isModalOpen: boolean, - showDetailsButton: boolean, -): boolean { - return BILLING_ACCOUNT_DETAILS_MODAL_ENABLED - && showDetailsButton - && isModalOpen -} - -/** - * Selects the visible budget state for one project billing-account row. - * - * @param billingAccount Billing-account summary attached to the project row. - * @param showPaymentAmounts Whether the current user may see inline payment amounts. - * @param showMemberPaymentsRemaining Whether the current user needs the copilot-safe member payment view. - * @returns Budget data to render, with copilot budget data included when needed. - */ -function getProjectBillingBudgetDisplayState( - billingAccount: BillingAccount | undefined, - showPaymentAmounts: boolean, - showMemberPaymentsRemaining: boolean, -): ProjectBillingBudgetDisplayState { - if (showMemberPaymentsRemaining) { - const copilotBudgetInfo = getCopilotMemberPaymentsBudgetInfo(billingAccount) - - return { - budgetInfo: copilotBudgetInfo, - copilotBudgetInfo, - } - } - - if (!BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED || !showPaymentAmounts) { - return { - budgetInfo: undefined, - copilotBudgetInfo: undefined, - } - } - - return { - budgetInfo: getBillingAccountBudgetInfo(billingAccount), - copilotBudgetInfo: undefined, - } -} - -/** - * Renders the optional billing-account budget badge for one project row. - * - * @param budgetState Budget data selected for the current user. - * @param showMemberPaymentsRemaining Whether the badge should render copilot-safe copy. - * @returns A budget badge element, or `undefined` when no budget should be shown. - */ -function renderProjectBillingAccountBudget( - budgetState: ProjectBillingBudgetDisplayState, - showMemberPaymentsRemaining: boolean, -): JSX.Element | undefined { - if (!budgetState.budgetInfo) { - return undefined - } - - const budgetStatusClass = getBudgetStatusClass( - budgetState.budgetInfo, - showMemberPaymentsRemaining, - ) - const budgetDisplayClass = budgetStatusClass - ? `${styles.budgetDisplay} ${budgetStatusClass}` - : styles.budgetDisplay - - return ( - - {renderBudgetDisplayContent( - budgetState.budgetInfo, - budgetState.copilotBudgetInfo, - showMemberPaymentsRemaining, - )} - - ) -} - -/** - * Renders the billing-account line-item details button when the feature is enabled. - * - * @param billingAccountId Normalized billing-account id for the current row. - * @param onOpen Open handler for the row modal. - * @param showDetailsButton Whether this user may open billing-account details. - * @returns The details button, or `undefined` when unavailable. - */ -function renderProjectBillingAccountDetailsButton( - billingAccountId: string | undefined, - onOpen: () => void, - showDetailsButton: boolean, -): JSX.Element | undefined { - if (!BILLING_ACCOUNT_DETAILS_MODAL_ENABLED || !showDetailsButton || !billingAccountId) { - return undefined - } - - return ( - - ) -} - -/** - * Renders the row-level billing-account line items modal after data is loaded. - * - * @param params Modal visibility, project context, and loaded billing-account details. - * @returns The modal element, or `undefined` while hidden or unloaded. - */ -function renderProjectBillingAccountModal( - params: RenderProjectBillingAccountModalParams, -): JSX.Element | undefined { - if ( - !BILLING_ACCOUNT_DETAILS_MODAL_ENABLED - || !params.isModalOpen - || !params.billingAccountDetails - ) { - return undefined - } - - return ( - - ) -} - -/** - * 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, with budget and line-item details shown only when enabled. - */ -const ProjectBillingAccountCell: FC = ( - props: ProjectBillingAccountCellProps, -) => { - const [isModalOpen, setIsModalOpen] = useState(false) - const normalizedBillingAccountId = normalizeOptionalString(props.project.billingAccountId) - || normalizeOptionalString(props.billingAccount?.id) - const showDetailsButton = props.showPaymentAmounts - const billingAccountDetailsResult: UseFetchBillingAccountDetailsResult = useFetchBillingAccountDetails( - canFetchProjectBillingAccountDetails(isModalOpen, showDetailsButton) - ? normalizedBillingAccountId - : undefined, - ) - const budgetDisplayState = getProjectBillingBudgetDisplayState( - props.billingAccount, - props.showPaymentAmounts, - props.showMemberPaymentsRemaining, - ) - const billingAccountBudget = renderProjectBillingAccountBudget( - budgetDisplayState, - props.showMemberPaymentsRemaining, - ) - - const handleOpenModal = useCallback((): void => { - setIsModalOpen(true) - }, []) - - const handleCloseModal = useCallback((): void => { - setIsModalOpen(false) - }, []) - const billingAccountDetailsButton = renderProjectBillingAccountDetailsButton( - normalizedBillingAccountId, - handleOpenModal, - showDetailsButton, - ) - const billingAccountModal = renderProjectBillingAccountModal({ - billingAccountDetails: billingAccountDetailsResult.billingAccountDetails, - isModalOpen: showDetailsButton && isModalOpen, - onClose: handleCloseModal, - projectId: props.project.id, - showMemberPaymentsRemaining: props.showMemberPaymentsRemainingInModal, - }) - - return ( -
      - - {getBillingAccountDisplay(props.project, props.billingAccount)} - - {billingAccountBudget} - {billingAccountDetailsButton} - {billingAccountModal} -
      - ) -} - export const ProjectsTable: FC = (props: ProjectsTableProps) => { const canEditProject = props.canEditProject || NOOP_CAN_EDIT_PROJECT const projects: Project[] = props.projects @@ -474,15 +117,14 @@ export const ProjectsTable: FC = (props: ProjectsTableProps) const onSort: (fieldName: string) => void = props.onSort const sortBy: string = props.sortBy const sortOrder: SortOrder = props.sortOrder - const workAppContext: WorkAppContextModel = useContext(WorkAppContext) const { billingAccounts, }: UseFetchBillingAccountsResult = useFetchBillingAccounts() - const billingAccountsById = useMemo( + const billingAccountNames = useMemo( () => new Map( billingAccounts.map(account => ([ String(account.id), - account, + account.name, ])), ), [billingAccounts], @@ -523,16 +165,7 @@ export const ProjectsTable: FC = (props: ProjectsTableProps) { isSortable: false, label: 'Billing Account', - renderer: (project: Project) => ( - - ), + renderer: (project: Project) => <>{getBillingAccountDisplay(project, billingAccountNames)}, type: 'element', }, { @@ -561,7 +194,7 @@ export const ProjectsTable: FC = (props: ProjectsTableProps) type: 'action', }, ], - [billingAccountsById, canEditProject, workAppContext], + [billingAccountNames, canEditProject], ) const forceSort = useMemo( @@ -614,16 +247,6 @@ 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/SubmissionRunnerLogsModal/SubmissionRunnerLogsModal.module.scss b/src/apps/work/src/lib/components/SubmissionRunnerLogsModal/SubmissionRunnerLogsModal.module.scss deleted file mode 100644 index 77324a80a..000000000 --- a/src/apps/work/src/lib/components/SubmissionRunnerLogsModal/SubmissionRunnerLogsModal.module.scss +++ /dev/null @@ -1,91 +0,0 @@ -@import '@libs/ui/styles/includes'; - -.overlay { - align-items: center; - background: rgb(44 44 44 / 45%); - bottom: 0; - display: flex; - justify-content: center; - left: 0; - padding: 20px; - position: fixed; - right: 0; - top: 0; - z-index: 1000; -} - -.container { - background: $tc-white; - border-radius: 8px; - max-width: 980px; - width: 100%; -} - -.header { - border-bottom: 1px solid $black-10; - display: flex; - flex-direction: column; - gap: 6px; - padding: 18px 20px 12px; -} - -.title { - color: $black-80; - font-size: 18px; - margin: 0; -} - -.submissionId { - color: $black-60; - font-family: monospace; - font-size: 12px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.body { - max-height: 76vh; - overflow: hidden; - padding: 16px 20px 20px; -} - -.loadingWrap { - display: flex; - justify-content: center; - min-height: 160px; -} - -.message { - color: $black-60; - margin: 0; - text-align: center; -} - -.logsTextarea { - background: #101820; - border: 1px solid $black-20; - border-radius: 6px; - color: #f7f8fa; - display: block; - font-family: monospace; - font-size: 12px; - line-height: 1.5; - min-height: 420px; - outline: none; - padding: 12px; - resize: vertical; - white-space: pre; - width: 100%; -} - -@media (max-width: 768px) { - .body { - max-height: 72vh; - padding: 12px 14px 16px; - } - - .logsTextarea { - min-height: 320px; - } -} diff --git a/src/apps/work/src/lib/components/SubmissionRunnerLogsModal/SubmissionRunnerLogsModal.tsx b/src/apps/work/src/lib/components/SubmissionRunnerLogsModal/SubmissionRunnerLogsModal.tsx deleted file mode 100644 index 67784537d..000000000 --- a/src/apps/work/src/lib/components/SubmissionRunnerLogsModal/SubmissionRunnerLogsModal.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { - FC, - MouseEvent, - useCallback, - useMemo, -} from 'react' - -import { LoadingSpinner } from '~/libs/ui' - -import { useFetchSubmissionRunnerLogs } from '../../hooks' -import { - MarathonMatchRunnerLogEvent, - MarathonMatchRunnerLogs, -} from '../../models' - -import styles from './SubmissionRunnerLogsModal.module.scss' - -export interface SubmissionRunnerLogsModalProps { - onClose: () => void - submissionId: string -} - -/** - * Formats a CloudWatch event timestamp for display beside a log message. - * @param timestamp CloudWatch event timestamp in milliseconds. - * @returns ISO timestamp text, or an empty string when the timestamp is missing or invalid. - * Used by `formatRunnerLogsText` to preserve event ordering with readable dates. - */ -function formatEventTimestamp(timestamp: number | undefined): string { - if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) { - return '' - } - - return new Date(timestamp) - .toISOString() -} - -/** - * Formats marathon match runner log events into a single textarea value. - * @param runnerLogs Runner-log API response containing CloudWatch events and warnings. - * @returns Monospace log text for display in the modal. - * Used by `SubmissionRunnerLogsModal` after log data has loaded. - */ -function formatRunnerLogsText( - runnerLogs: MarathonMatchRunnerLogs | undefined, -): string { - if (!runnerLogs) { - return '' - } - - if (runnerLogs.warning) { - return runnerLogs.warning - } - - if (!runnerLogs.events.length) { - return 'No runner logs found.' - } - - return runnerLogs.events - .map((event: MarathonMatchRunnerLogEvent) => { - const timestamp = formatEventTimestamp(event.timestamp) - const message = event.message || '' - - return timestamp - ? `[${timestamp}] ${message}` - : message - }) - .join('\n') -} - -/** - * Renders a modal containing ECS runner logs for a marathon match submission. - * @param props Submission id to fetch plus a close handler for the overlay. - * @returns A modal dialog with loading, error, or read-only log text states. - * @throws Does not throw; API failures are shown inside the modal body. - * Used by `SubmissionsSection` when a manager or copilot opens the runner-log action. - */ -export const SubmissionRunnerLogsModal: FC = ( - props: SubmissionRunnerLogsModalProps, -) => { - const runnerLogsResult = useFetchSubmissionRunnerLogs(props.submissionId) - const logsText = useMemo( - () => formatRunnerLogsText(runnerLogsResult.runnerLogs), - [runnerLogsResult.runnerLogs], - ) - - const handleContainerClick = useCallback((event: MouseEvent): void => { - event.stopPropagation() - }, []) - - return ( -
      -
      -
      -

      Runner Logs

      - - {props.submissionId} - -
      - -
      - {runnerLogsResult.isLoading - ? ( -
      - -
      - ) - : undefined} - - {!runnerLogsResult.isLoading && runnerLogsResult.isError - ? ( -

      - Unable to load runner logs. -

      - ) - : undefined} - - {!runnerLogsResult.isLoading && !runnerLogsResult.isError - ? ( -